symbola 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. symbola-0.1.0/PKG-INFO +115 -0
  2. symbola-0.1.0/README.md +86 -0
  3. symbola-0.1.0/pyproject.toml +52 -0
  4. symbola-0.1.0/setup.cfg +4 -0
  5. symbola-0.1.0/src/symbola/__init__.py +3 -0
  6. symbola-0.1.0/src/symbola/agent.py +132 -0
  7. symbola-0.1.0/src/symbola/attest.py +192 -0
  8. symbola-0.1.0/src/symbola/canary.py +122 -0
  9. symbola-0.1.0/src/symbola/cli.py +868 -0
  10. symbola-0.1.0/src/symbola/diff.py +424 -0
  11. symbola-0.1.0/src/symbola/digest.py +55 -0
  12. symbola-0.1.0/src/symbola/extract.py +403 -0
  13. symbola-0.1.0/src/symbola/implementation.py +95 -0
  14. symbola-0.1.0/src/symbola/keys.py +104 -0
  15. symbola-0.1.0/src/symbola/manifest.py +100 -0
  16. symbola-0.1.0/src/symbola/mutation.py +227 -0
  17. symbola-0.1.0/src/symbola/pack.py +304 -0
  18. symbola-0.1.0/src/symbola/pool.py +314 -0
  19. symbola-0.1.0/src/symbola/publish.py +157 -0
  20. symbola-0.1.0/src/symbola/registry.py +821 -0
  21. symbola-0.1.0/src/symbola/replicate.py +459 -0
  22. symbola-0.1.0/src/symbola/sandbox.py +345 -0
  23. symbola-0.1.0/src/symbola/scaffold.py +126 -0
  24. symbola-0.1.0/src/symbola/spec.py +411 -0
  25. symbola-0.1.0/src/symbola/validate.py +123 -0
  26. symbola-0.1.0/src/symbola/worker.py +264 -0
  27. symbola-0.1.0/src/symbola.egg-info/PKG-INFO +115 -0
  28. symbola-0.1.0/src/symbola.egg-info/SOURCES.txt +45 -0
  29. symbola-0.1.0/src/symbola.egg-info/dependency_links.txt +1 -0
  30. symbola-0.1.0/src/symbola.egg-info/entry_points.txt +2 -0
  31. symbola-0.1.0/src/symbola.egg-info/requires.txt +10 -0
  32. symbola-0.1.0/src/symbola.egg-info/top_level.txt +1 -0
  33. symbola-0.1.0/tests/test_attest.py +248 -0
  34. symbola-0.1.0/tests/test_canary.py +78 -0
  35. symbola-0.1.0/tests/test_cli.py +79 -0
  36. symbola-0.1.0/tests/test_command_impl.py +142 -0
  37. symbola-0.1.0/tests/test_diff.py +159 -0
  38. symbola-0.1.0/tests/test_digest.py +48 -0
  39. symbola-0.1.0/tests/test_extract.py +257 -0
  40. symbola-0.1.0/tests/test_fixtures.py +91 -0
  41. symbola-0.1.0/tests/test_manifest.py +57 -0
  42. symbola-0.1.0/tests/test_mutants.py +95 -0
  43. symbola-0.1.0/tests/test_pack.py +191 -0
  44. symbola-0.1.0/tests/test_publish.py +107 -0
  45. symbola-0.1.0/tests/test_registry.py +414 -0
  46. symbola-0.1.0/tests/test_replicate.py +216 -0
  47. symbola-0.1.0/tests/test_validate.py +153 -0
symbola-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: symbola
3
+ Version: 0.1.0
4
+ Summary: Spec packs for coding agents: checkable specs, verifiable evidence.
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://symbola.sh
7
+ Project-URL: Documentation, https://kausalflow.github.io/symbola/
8
+ Project-URL: Source, https://github.com/kausalflow/symbola
9
+ Project-URL: Issues, https://github.com/kausalflow/symbola/issues
10
+ Keywords: specification,contracts,coding-agents,conformance,attestation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: pydantic>=2.6
22
+ Requires-Dist: hypothesis>=6.100
23
+ Requires-Dist: typer>=0.12
24
+ Requires-Dist: cryptography>=42
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8; extra == "dev"
27
+ Provides-Extra: docs
28
+ Requires-Dist: zensical>=0.0.71; extra == "docs"
29
+
30
+ # Symbolon
31
+
32
+ Trustable contracts between agents. A contract is a precise, content-addressed promise; evidence that someone kept it can be checked by anyone. Software capabilities are the first vertical: a contract specifies a capability, and any implementation, hand-written or AI-generated, is checked against it.
33
+
34
+ This repository covers **MVP steps 1 and 2**: the contract format with `init`, `validate`, and `test`, and signed attestations with `attest` and `verify`, run in a container or process sandbox.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ uv tool install git+https://github.com/kausalflow/symbola # or: pipx install git+https://github.com/kausalflow/symbola
40
+ symbola --help
41
+ ```
42
+
43
+ The package and command are `symbola` (after [symbola.sh](https://symbola.sh)). `pip install symbolon` is an unrelated project.
44
+
45
+ ## Quick start
46
+
47
+ From a clone of this repository:
48
+
49
+ ```bash
50
+ pip install -e ".[dev]"
51
+
52
+ symbola init acme/shout # scaffold a contract that already validates
53
+ symbola validate contracts/acme/shout/versions/1 # check it and print its digest
54
+
55
+ symbola validate contracts/core/date-parse/versions/1
56
+ symbola test contracts/core/date-parse/versions/1 examples/impls/date-parse-strptime # fails: too lenient
57
+ symbola test contracts/core/date-parse/versions/1 examples/impls/date-parse-strict # passes
58
+
59
+ symbola keygen # this runner's signing key
60
+ symbola attest contracts/core/date-parse/versions/1 examples/impls/date-parse-strict -o att.json
61
+ symbola verify att.json --trust ~/.config/symbolon/runner.pub \
62
+ --contract contracts/core/date-parse/versions/1 --impl examples/impls/date-parse-strict --rerun
63
+ ```
64
+
65
+ ## Commands
66
+
67
+ | Command | What it does |
68
+ | --- | --- |
69
+ | `symbola init <namespace/name>` | Scaffold a contract that passes `validate` out of the box |
70
+ | `symbola validate <dir>` | Check structure, schema drift, examples, reference, spec vacuity and mutants; print the digest |
71
+ | `symbola schema <dir>` | Regenerate `schema/*.json` from `schema.py` |
72
+ | `symbola test <dir> <impl.py>` | Run the contract's examples and properties against an implementation |
73
+ | `symbola digest <dir>` | Print the contract's content digest |
74
+ | `symbola impl-init <dir> --contract <dir>` | Write an implementation manifest pinned to the contract's digest |
75
+ | `symbola keygen` | Create this runner's Ed25519 signing key |
76
+ | `symbola attest <contract> <impl>` | Run the spec in a sandbox; write a signed in-toto attestation |
77
+ | `symbola verify <attestation>` | Check signature, trust, digests; `--rerun` reproduces it locally |
78
+ | `symbola diff <contract> <impl> <impl>...` | Run several builds on one input pool; report where they disagree, the ambiguity estimate, and the obligations each split traces to |
79
+ | `symbola extract <source> --pack <ns/product>` | Have a coding agent (Claude Code by default) draft a spec pack from an existing product; check it against the original |
80
+ | `symbola replicate <pack>` | Hand a pack to outside coding agents (Claude Code by default), then attest, holdout-test and diff the builds they make |
81
+ | `symbola pack validate <dir>` | Check a spec pack's brief, assumptions and obligations; report obligations by status |
82
+ | `symbola mutants <dir>` | Show which checks kill which mutants, and the contract's kill rate |
83
+ | `symbola canary plant <dir> <out>` / `canary check` | Give a builder a copy with one contradictory example; catch builds fitted to the examples |
84
+ | `symbola flag <ref> <reason>` | Report a contradiction in the spec instead of coding around it |
85
+
86
+ ## Seed contracts
87
+
88
+ | Contract | Capability |
89
+ | --- | --- |
90
+ | `core/date-parse@1` | Parse a date written in ISO, en-US, en-GB, or de-DE conventions |
91
+ | `core/csv-to-records@1`, `@2` | Parse RFC 4180 CSV with a header into records (v2 also rejects malformed quoting) |
92
+ | `core/unit-convert@1` | Convert length, mass, and temperature with exact factors |
93
+
94
+ The contract format is specified in [docs/CONTRACT_FORMAT.md](docs/CONTRACT_FORMAT.md), implementations and attestations in [docs/ATTESTATION.md](docs/ATTESTATION.md), and spec packs in [docs/PACK_FORMAT.md](docs/PACK_FORMAT.md).
95
+
96
+ ## Documentation
97
+
98
+ The docs site is built with [Zensical](https://zensical.org) from `docs/` and published to GitHub Pages on every push to `main`. Each pull request gets a preview at `pr-preview/pr-<number>/`, linked in a comment on the PR.
99
+
100
+ ```bash
101
+ pip install -e ".[docs]"
102
+ zensical serve # http://localhost:8000
103
+ ```
104
+
105
+ ## Tests
106
+
107
+ ```bash
108
+ pytest
109
+ ```
110
+
111
+ Sandbox tests run for each sandbox available on the machine: container tests need a Docker daemon, process tests need unprivileged user namespaces. Others are skipped.
112
+
113
+ ## Releasing
114
+
115
+ Bump `__version__` in `src/symbola/__init__.py`, merge, then publish a GitHub release tagged `v<version>` (for example `v0.1.0`). The `Publish to PyPI` workflow builds the package and uploads it to PyPI as `symbola` through trusted publishing.
@@ -0,0 +1,86 @@
1
+ # Symbolon
2
+
3
+ Trustable contracts between agents. A contract is a precise, content-addressed promise; evidence that someone kept it can be checked by anyone. Software capabilities are the first vertical: a contract specifies a capability, and any implementation, hand-written or AI-generated, is checked against it.
4
+
5
+ This repository covers **MVP steps 1 and 2**: the contract format with `init`, `validate`, and `test`, and signed attestations with `attest` and `verify`, run in a container or process sandbox.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ uv tool install git+https://github.com/kausalflow/symbola # or: pipx install git+https://github.com/kausalflow/symbola
11
+ symbola --help
12
+ ```
13
+
14
+ The package and command are `symbola` (after [symbola.sh](https://symbola.sh)). `pip install symbolon` is an unrelated project.
15
+
16
+ ## Quick start
17
+
18
+ From a clone of this repository:
19
+
20
+ ```bash
21
+ pip install -e ".[dev]"
22
+
23
+ symbola init acme/shout # scaffold a contract that already validates
24
+ symbola validate contracts/acme/shout/versions/1 # check it and print its digest
25
+
26
+ symbola validate contracts/core/date-parse/versions/1
27
+ symbola test contracts/core/date-parse/versions/1 examples/impls/date-parse-strptime # fails: too lenient
28
+ symbola test contracts/core/date-parse/versions/1 examples/impls/date-parse-strict # passes
29
+
30
+ symbola keygen # this runner's signing key
31
+ symbola attest contracts/core/date-parse/versions/1 examples/impls/date-parse-strict -o att.json
32
+ symbola verify att.json --trust ~/.config/symbolon/runner.pub \
33
+ --contract contracts/core/date-parse/versions/1 --impl examples/impls/date-parse-strict --rerun
34
+ ```
35
+
36
+ ## Commands
37
+
38
+ | Command | What it does |
39
+ | --- | --- |
40
+ | `symbola init <namespace/name>` | Scaffold a contract that passes `validate` out of the box |
41
+ | `symbola validate <dir>` | Check structure, schema drift, examples, reference, spec vacuity and mutants; print the digest |
42
+ | `symbola schema <dir>` | Regenerate `schema/*.json` from `schema.py` |
43
+ | `symbola test <dir> <impl.py>` | Run the contract's examples and properties against an implementation |
44
+ | `symbola digest <dir>` | Print the contract's content digest |
45
+ | `symbola impl-init <dir> --contract <dir>` | Write an implementation manifest pinned to the contract's digest |
46
+ | `symbola keygen` | Create this runner's Ed25519 signing key |
47
+ | `symbola attest <contract> <impl>` | Run the spec in a sandbox; write a signed in-toto attestation |
48
+ | `symbola verify <attestation>` | Check signature, trust, digests; `--rerun` reproduces it locally |
49
+ | `symbola diff <contract> <impl> <impl>...` | Run several builds on one input pool; report where they disagree, the ambiguity estimate, and the obligations each split traces to |
50
+ | `symbola extract <source> --pack <ns/product>` | Have a coding agent (Claude Code by default) draft a spec pack from an existing product; check it against the original |
51
+ | `symbola replicate <pack>` | Hand a pack to outside coding agents (Claude Code by default), then attest, holdout-test and diff the builds they make |
52
+ | `symbola pack validate <dir>` | Check a spec pack's brief, assumptions and obligations; report obligations by status |
53
+ | `symbola mutants <dir>` | Show which checks kill which mutants, and the contract's kill rate |
54
+ | `symbola canary plant <dir> <out>` / `canary check` | Give a builder a copy with one contradictory example; catch builds fitted to the examples |
55
+ | `symbola flag <ref> <reason>` | Report a contradiction in the spec instead of coding around it |
56
+
57
+ ## Seed contracts
58
+
59
+ | Contract | Capability |
60
+ | --- | --- |
61
+ | `core/date-parse@1` | Parse a date written in ISO, en-US, en-GB, or de-DE conventions |
62
+ | `core/csv-to-records@1`, `@2` | Parse RFC 4180 CSV with a header into records (v2 also rejects malformed quoting) |
63
+ | `core/unit-convert@1` | Convert length, mass, and temperature with exact factors |
64
+
65
+ The contract format is specified in [docs/CONTRACT_FORMAT.md](docs/CONTRACT_FORMAT.md), implementations and attestations in [docs/ATTESTATION.md](docs/ATTESTATION.md), and spec packs in [docs/PACK_FORMAT.md](docs/PACK_FORMAT.md).
66
+
67
+ ## Documentation
68
+
69
+ The docs site is built with [Zensical](https://zensical.org) from `docs/` and published to GitHub Pages on every push to `main`. Each pull request gets a preview at `pr-preview/pr-<number>/`, linked in a comment on the PR.
70
+
71
+ ```bash
72
+ pip install -e ".[docs]"
73
+ zensical serve # http://localhost:8000
74
+ ```
75
+
76
+ ## Tests
77
+
78
+ ```bash
79
+ pytest
80
+ ```
81
+
82
+ Sandbox tests run for each sandbox available on the machine: container tests need a Docker daemon, process tests need unprivileged user namespaces. Others are skipped.
83
+
84
+ ## Releasing
85
+
86
+ Bump `__version__` in `src/symbola/__init__.py`, merge, then publish a GitHub release tagged `v<version>` (for example `v0.1.0`). The `Publish to PyPI` workflow builds the package and uploads it to PyPI as `symbola` through trusted publishing.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ # The PyPI name "symbolon" belongs to an unrelated project, so the package,
7
+ # module and command are "symbola" (after symbola.sh).
8
+ name = "symbola"
9
+ dynamic = ["version"]
10
+ description = "Spec packs for coding agents: checkable specs, verifiable evidence."
11
+ readme = "README.md"
12
+ requires-python = ">=3.11"
13
+ license = "Apache-2.0"
14
+ keywords = ["specification", "contracts", "coding-agents", "conformance", "attestation"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Testing",
24
+ ]
25
+ dependencies = [
26
+ "pydantic>=2.6",
27
+ "hypothesis>=6.100",
28
+ "typer>=0.12",
29
+ "cryptography>=42",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8"]
34
+ docs = ["zensical>=0.0.71"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://symbola.sh"
38
+ Documentation = "https://kausalflow.github.io/symbola/"
39
+ Source = "https://github.com/kausalflow/symbola"
40
+ Issues = "https://github.com/kausalflow/symbola/issues"
41
+
42
+ [project.scripts]
43
+ symbola = "symbola.cli:app"
44
+
45
+ [tool.setuptools.dynamic]
46
+ version = { attr = "symbola.__version__" }
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["src"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Symbolon: trustable contracts between agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,132 @@
1
+ """Hand work to an external coding agent.
2
+
3
+ Symbolon does not build software or read code itself. Commands that need that,
4
+ `extract` and `replicate`, run an outside coding agent (Claude Code by default)
5
+ as a subprocess and check what it leaves behind.
6
+
7
+ An agent is any command that takes a prompt on stdin, works in its current
8
+ directory, and exits. The prompt is also written to a file, for agents that
9
+ would rather read one. A command may name these placeholders, which are
10
+ substituted after splitting:
11
+
12
+ {prompt_file} the prompt file
13
+ {workdir} the directory the agent works in
14
+
15
+ and it gets the same values as SYMBOLON_PROMPT_FILE and SYMBOLON_WORKDIR in its
16
+ environment. Directories the agent must read outside its workdir are passed to
17
+ Claude Code as --add-dir, and to any agent as SYMBOLON_EXTRA_DIRS.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shlex
24
+ import subprocess
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ AGENT_ENV = "SYMBOLON_AGENT"
30
+ DEFAULT_AGENT = "claude -p --permission-mode acceptEdits --allowedTools Read,Write,Edit,Glob,Grep,Bash"
31
+ PROMPT_DIR = ".symbolon"
32
+ PROMPT_FILE = "prompt.md"
33
+
34
+
35
+ class AgentError(Exception):
36
+ pass
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AgentCommand:
41
+ command: str
42
+ name: str = ""
43
+
44
+ def __post_init__(self) -> None:
45
+ if not self.argv:
46
+ raise AgentError("the agent command is empty")
47
+ if not self.name:
48
+ object.__setattr__(self, "name", Path(self.argv[0]).name)
49
+
50
+ @property
51
+ def argv(self) -> list[str]:
52
+ return shlex.split(self.command)
53
+
54
+ @property
55
+ def is_claude_code(self) -> bool:
56
+ return Path(self.argv[0]).name == "claude"
57
+
58
+
59
+ def resolve_agent(option: str | None = None) -> AgentCommand:
60
+ """The agent to run: the --agent option, else $SYMBOLON_AGENT, else Claude Code."""
61
+ return AgentCommand(option or os.environ.get(AGENT_ENV) or DEFAULT_AGENT)
62
+
63
+
64
+ def parse_agents(options: list[str] | None) -> list[AgentCommand]:
65
+ """Agents given as repeated `--agent NAME=COMMAND` (or a bare COMMAND); default as resolve_agent."""
66
+ if not options:
67
+ return [resolve_agent()]
68
+ agents = []
69
+ for opt in options:
70
+ name, sep, command = opt.partition("=")
71
+ # A bare command can contain '=' in its flags; only a plain word before it is a name.
72
+ if sep and name and " " not in name and "-" != name[0]:
73
+ agents.append(AgentCommand(command, name))
74
+ else:
75
+ agents.append(AgentCommand(opt))
76
+ names = [a.name for a in agents]
77
+ dupes = sorted({n for n in names if names.count(n) > 1})
78
+ if dupes:
79
+ raise AgentError(f"agent names must be unique; give them as NAME=COMMAND ({', '.join(dupes)})")
80
+ return agents
81
+
82
+
83
+ @dataclass
84
+ class AgentRun:
85
+ returncode: int
86
+ stdout: str
87
+ stderr: str
88
+ seconds: float
89
+ command: list[str] = field(default_factory=list)
90
+
91
+ @property
92
+ def ok(self) -> bool:
93
+ return self.returncode == 0
94
+
95
+ def log(self) -> str:
96
+ return (f"$ {shlex.join(self.command)}\n# exit {self.returncode} after {self.seconds:.1f}s\n"
97
+ f"\n## stdout\n{self.stdout}\n## stderr\n{self.stderr}")
98
+
99
+
100
+ def run_agent(agent: AgentCommand, prompt: str, workdir: Path, *, timeout: int = 3600,
101
+ prompt_file: Path | None = None, extra_dirs: list[Path] = ()) -> AgentRun:
102
+ """Run the agent in workdir with the prompt on stdin and wait for it to exit."""
103
+ workdir = Path(workdir).resolve()
104
+ workdir.mkdir(parents=True, exist_ok=True)
105
+ prompt_file = Path(prompt_file).resolve() if prompt_file else workdir / PROMPT_DIR / PROMPT_FILE
106
+ prompt_file.parent.mkdir(parents=True, exist_ok=True)
107
+ prompt_file.write_text(prompt)
108
+ values = {"prompt_file": str(prompt_file), "workdir": str(workdir)}
109
+ argv = [_substitute(arg, values) for arg in agent.argv]
110
+ if "/" in argv[0] and not Path(argv[0]).is_absolute():
111
+ argv[0] = str(Path(argv[0]).resolve()) # ./my-agent.sh means here, not in workdir
112
+ dirs = [str(Path(d).resolve()) for d in extra_dirs]
113
+ if agent.is_claude_code:
114
+ for d in dirs:
115
+ argv += ["--add-dir", d]
116
+ env = {**os.environ, "SYMBOLON_PROMPT_FILE": values["prompt_file"],
117
+ "SYMBOLON_WORKDIR": values["workdir"], "SYMBOLON_EXTRA_DIRS": os.pathsep.join(dirs)}
118
+ start = time.monotonic()
119
+ try:
120
+ proc = subprocess.run(argv, input=prompt, capture_output=True, text=True,
121
+ cwd=workdir, env=env, timeout=timeout)
122
+ except FileNotFoundError:
123
+ raise AgentError(f"agent command not found: {argv[0]} (set --agent or ${AGENT_ENV})") from None
124
+ except subprocess.TimeoutExpired as e:
125
+ raise AgentError(f"agent {agent.name} timed out after {timeout}s") from e
126
+ return AgentRun(proc.returncode, proc.stdout, proc.stderr, time.monotonic() - start, argv)
127
+
128
+
129
+ def _substitute(arg: str, values: dict[str, str]) -> str:
130
+ for key, value in values.items():
131
+ arg = arg.replace("{" + key + "}", value)
132
+ return arg
@@ -0,0 +1,192 @@
1
+ """Signed attestations: an in-toto Test Result statement in a DSSE envelope.
2
+
3
+ The statement's subject is the implementation digest. The contract digest
4
+ and the environment digest are configuration entries. Symbolon-specific
5
+ details (seed, budget, failures, resources, sandbox, runner key) live under
6
+ `predicate.symbolon`, which in-toto consumers that only know the Test Result
7
+ predicate can ignore. See docs/ATTESTATION.md.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ import symbola
19
+
20
+ from . import keys
21
+ from .digest import contract_digest, hex_of, implementation_digest
22
+ from .implementation import check_lockfile, load_impl_manifest
23
+ from .manifest import load_manifest
24
+ from .sandbox import Limits, Sandbox, Workspace, environment_digest
25
+
26
+ STATEMENT_TYPE = "https://in-toto.io/Statement/v1"
27
+ PREDICATE_TYPE = "https://in-toto.io/attestation/test-result/v0.1"
28
+
29
+
30
+ class AttestError(Exception):
31
+ pass
32
+
33
+
34
+ def _now() -> str:
35
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
36
+
37
+
38
+ def _test_names(result: dict) -> tuple[list[str], list[str], dict[str, str]]:
39
+ passed, failed, details = [], [], {}
40
+ for kind, items in (("example", result["examples"]), ("property", result["properties"])):
41
+ for c in items:
42
+ name = f"{kind}:{c['name'].removeprefix('example ')}"
43
+ (passed if c["ok"] else failed).append(name)
44
+ if not c["ok"]:
45
+ details[name] = c["detail"]
46
+ return passed, failed, details
47
+
48
+
49
+ def run_attestation(contract_dir: Path, impl_dir: Path, sandbox: Sandbox, key) -> dict:
50
+ """Run the contract's spec against an implementation in `sandbox`; return a signed envelope."""
51
+ contract_dir, impl_dir = Path(contract_dir).resolve(), Path(impl_dir).resolve()
52
+ manifest = load_manifest(contract_dir)
53
+ try:
54
+ impl_manifest = load_impl_manifest(impl_dir)
55
+ check_lockfile(impl_dir)
56
+ except Exception as e:
57
+ raise AttestError(f"implementation manifest: {e}") from None
58
+
59
+ ws = Workspace.create(contract_dir, impl_dir)
60
+ try:
61
+ c_digest = contract_digest(ws.contract)
62
+ i_digest = implementation_digest(ws.impl)
63
+ if impl_manifest.implementation.contract != c_digest:
64
+ raise AttestError(
65
+ f"implementation targets {impl_manifest.implementation.contract}, "
66
+ f"but this contract is {c_digest}"
67
+ )
68
+ section = impl_manifest.implementation
69
+ if section.language == "python" and not (ws.impl / section.entrypoint).is_file():
70
+ raise AttestError(f"entrypoint {section.entrypoint} not found in the implementation")
71
+
72
+ network = bool(manifest.requires.network)
73
+ env = sandbox.environment()
74
+ started = _now()
75
+ result = sandbox.run(
76
+ ws, network=network, lockfile=(ws.impl / "requirements.lock").exists(),
77
+ limits=Limits(timeout_seconds=manifest.spec.timeout_seconds),
78
+ )
79
+ finished = _now()
80
+ finally:
81
+ ws.cleanup()
82
+
83
+ if "error" in result:
84
+ raise AttestError(f"implementation could not be loaded: {result['error']}")
85
+
86
+ passed, failed, details = _test_names(result)
87
+ public = key.public_key()
88
+ statement = {
89
+ "_type": STATEMENT_TYPE,
90
+ "subject": [{
91
+ "name": f"implementation-of:{manifest.alias}",
92
+ "digest": {"sha256": hex_of(i_digest)},
93
+ }],
94
+ "predicateType": PREDICATE_TYPE,
95
+ "predicate": {
96
+ "result": "PASSED" if result["passed"] else "FAILED",
97
+ "configuration": [
98
+ {"name": f"contract:{manifest.alias}", "digest": {"sha256": hex_of(c_digest)}},
99
+ {"name": "environment", "digest": {"sha256": hex_of(environment_digest(env))}},
100
+ ],
101
+ "passedTests": passed,
102
+ "warnedTests": [],
103
+ "failedTests": failed,
104
+ "symbolon": {
105
+ "version": symbola.__version__,
106
+ "evidence": "observed" if network else "reproducible",
107
+ "spec": {"seed": manifest.spec.seed, "max_examples": manifest.spec.max_examples},
108
+ "failures": details,
109
+ "resources": result.get("resources", {}),
110
+ "sandbox": {**sandbox.isolation(network), "environment": env},
111
+ "implementation": impl_manifest.model_dump(exclude_none=True),
112
+ "runner": {
113
+ "keyid": keys.keyid(public),
114
+ "publicKey": base64.b64encode(keys.raw_public(public)).decode(),
115
+ },
116
+ "startedOn": started,
117
+ "finishedOn": finished,
118
+ },
119
+ },
120
+ }
121
+ payload = json.dumps(statement, sort_keys=True, separators=(",", ":")).encode()
122
+ return keys.sign_envelope(payload, key)
123
+
124
+
125
+ @dataclass
126
+ class Verification:
127
+ statement: dict
128
+ keyid: str
129
+ trusted: bool | None
130
+ problems: list[str] = field(default_factory=list)
131
+
132
+ @property
133
+ def ok(self) -> bool:
134
+ return not self.problems and self.trusted is not False
135
+
136
+ @property
137
+ def predicate(self) -> dict:
138
+ return self.statement["predicate"]
139
+
140
+
141
+ def verify_attestation(envelope: dict, *, trusted_keys: list[Path] = (),
142
+ contract_dir: Path | None = None, impl_dir: Path | None = None) -> Verification:
143
+ """Check the signature, and optionally the trust set and digests against local copies."""
144
+ try:
145
+ claimed = json.loads(base64.b64decode(envelope["payload"]))
146
+ runner = claimed["predicate"]["symbolon"]["runner"]
147
+ public = keys.public_from_b64(runner["publicKey"])
148
+ payload = keys.verify_envelope(envelope, public)
149
+ except Exception as e:
150
+ raise AttestError(f"not a valid Symbolon attestation: {e}") from None
151
+ statement = json.loads(payload)
152
+ kid = keys.keyid(public)
153
+ problems = []
154
+ if runner["keyid"] != kid:
155
+ problems.append("runner keyid does not match the runner public key")
156
+ if statement.get("_type") != STATEMENT_TYPE or statement.get("predicateType") != PREDICATE_TYPE:
157
+ problems.append("unexpected statement or predicate type")
158
+
159
+ trusted = None
160
+ if trusted_keys:
161
+ trusted = kid in {keys.keyid(keys.load_public(p)) for p in trusted_keys}
162
+
163
+ config = {c["name"].split(":", 1)[0]: c for c in statement["predicate"]["configuration"]}
164
+ if contract_dir is not None:
165
+ actual = hex_of(contract_digest(contract_dir))
166
+ if config["contract"]["digest"]["sha256"] != actual:
167
+ problems.append(f"contract digest differs: attested {config['contract']['digest']['sha256'][:12]}…, local {actual[:12]}…")
168
+ if impl_dir is not None:
169
+ actual = hex_of(implementation_digest(impl_dir))
170
+ if statement["subject"][0]["digest"]["sha256"] != actual:
171
+ problems.append(f"implementation digest differs: attested {statement['subject'][0]['digest']['sha256'][:12]}…, local {actual[:12]}…")
172
+ return Verification(statement, kid, trusted, problems)
173
+
174
+
175
+ def compare_runs(original: dict, rerun: dict) -> list[str]:
176
+ """Differences in per-test outcomes between two statements' predicates."""
177
+ a, b = original["predicate"], rerun["predicate"]
178
+ diffs = []
179
+ if a["result"] != b["result"]:
180
+ diffs.append(f"result {a['result']} vs {b['result']}")
181
+ for label, key in (("passed", "passedTests"), ("failed", "failedTests")):
182
+ only_a = sorted(set(a[key]) - set(b[key]))
183
+ only_b = sorted(set(b[key]) - set(a[key]))
184
+ if only_a:
185
+ diffs.append(f"{label} only originally: {', '.join(only_a)}")
186
+ if only_b:
187
+ diffs.append(f"{label} only on rerun: {', '.join(only_b)}")
188
+ return diffs
189
+
190
+
191
+ def statement_of(envelope: dict) -> dict:
192
+ return json.loads(base64.b64decode(envelope["payload"]))