Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .deps.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"dependencies": [
"cryptography>=50.0.0,<51.0.0",
"logion-client",
"logion-runner",
"logion-skillmap",
"pydantic>=2.7,<3.0.0",
"pyyaml>=6.0,<7.0",
Expand Down
14 changes: 2 additions & 12 deletions .github/workflows/pr-safety.yml
Original file line number Diff line number Diff line change
Expand Up @@ -274,18 +274,8 @@ jobs:
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Install sf
run: >
cargo install --git https://github.com/nicolasmelo1/software-factory
--rev b06be44f6c982dac58b898778d7dba224d9ed7b1 --locked

# See the comment on `factory-check` in the Makefile for why the
# flag is required rather than a convenience.
- name: Prove the factory rules still fire
run: sf verify --allow-commands

- name: Software-factory rules
run: sf check --allow-commands
- name: Prove and check the pinned factory rules
run: make factory-check

- name: Installer security guardrails
run: make check-installer-security
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ make test
- Python 3.12+
- [uv](https://docs.astral.sh/uv/) — package and workspace manager
- Node.js 18+ (only needed for the Prism mock server)
- Rust/Cargo (for `make factory-check`; the first run builds the reviewed `sf` revision)

`make factory-check` and CI use `python3 scripts/sf.py`, which installs the
commit pinned in that launcher under `.local/software-factory/` without
replacing a global `sf`. It checks Cargo source provenance and the installed
binary's recorded SHA-256 before each invocation; an unrelated `sf` on `PATH`
is never used. A missing install needs network access; a damaged or unverifiable
install fails closed with its rebuild path. Do not remove rule documentation
to accommodate a different local tool version.

## Running the OpenAPI mock locally

Expand Down
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,10 @@ check-docs:
# runs a command, and without the flag `sf verify` scores it as fired on
# the "commands are not enabled" finding instead of on its mutation --
# a rule proven by its own refusal to run.
.PHONY: factory-check
factory-check:
sf verify --allow-commands
sf check --allow-commands
python3 scripts/sf.py verify --allow-commands
python3 scripts/sf.py check --allow-commands

update-generated-lock:
uv run python scripts/check_generated_lock.py --update
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/cli/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from cli.commands import (
credits as credits_mod,
)
from cli.commands import eval as eval_mod
from cli.commands import (
referrals as referrals_mod,
)
Expand All @@ -58,6 +59,7 @@ def build_parser() -> argparse.ArgumentParser:

health.register(subparsers)
doctor.register(subparsers)
eval_mod.register(subparsers)
identity.register(subparsers)
indexed.register(subparsers)
listings.register(subparsers)
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/cli/commands/eval/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# SPDX-License-Identifier: MIT
"""Portable eval contract commands."""

from cli.commands.eval.parser import register

__all__ = ["register"]
153 changes: 153 additions & 0 deletions packages/cli/cli/commands/eval/_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# SPDX-License-Identifier: MIT
"""Handlers for portable eval creation and reproduction."""

from __future__ import annotations

import argparse
import zipfile
from pathlib import Path

from logion_eval_contract import (
EvalContract,
EvalContractError,
contract_digest,
contract_to_json,
load_document,
pair_key,
parse_result_document,
result_digest,
)

from cli._json import JsonObject
from cli._output import emit_json
from cli._version import __version__ as cli_version
from cli.commands.eval._scaffold import (
_BUNDLE_MEDIA_TYPE,
_digest,
_fail,
_json_bytes,
_load_contract,
_safe_member,
)

_HARNESS_ID = "logion-cli"
_MODEL_ID = "reference-subject"
_MODEL_VERSION = "1.0.0"
_EXIT_INVALID = 2
_EXIT_ERROR = 1
_EXIT_REFUSED = 3


def _bundle_manifest(
contract: EvalContract,
subject: bytes,
fixture_entries: dict[str, JsonObject],
result_entries: list[JsonObject],
) -> JsonObject:
contract_bytes = _json_bytes(contract_to_json(contract))
return {
"contract": {
"contract_digest": contract_digest(contract),
"digest": _digest(contract_bytes),
"path": "contract.json",
},
"fixtures": fixture_entries,
"harness": {"id": _HARNESS_ID, "version": cli_version},
"media_type": _BUNDLE_MEDIA_TYPE,
"results": result_entries,
"schema_version": 1,
"subject": {"digest": _digest(subject), "path": "subject.bin"},
}


def _checked_results(
paths: list[str], contract: EvalContract, subject: bytes
) -> tuple[list[tuple[str, bytes]], list[JsonObject]]:
if len(paths) < 2:
raise ValueError("export requires at least two --result files")
files: list[tuple[str, bytes]] = []
entries: list[JsonObject] = []
parsed = []
for index, path in enumerate(paths, start=1):
document, _ = load_document(path)
result = parse_result_document(document)
if result.contract_digest != contract_digest(contract):
raise ValueError(f"result {path!r} belongs to another contract")
if result.subject_digest != _digest(subject):
raise ValueError(f"result {path!r} belongs to another subject")
member = f"results/result-{index}.json"
raw = _json_bytes(result.to_json())
files.append((member, raw))
entries.append({
"digest": _digest(raw),
"path": member,
"result_digest": result_digest(result),
})
parsed.append(result)
if any(pair_key(item) != pair_key(parsed[0]) for item in parsed[1:]):
raise ValueError("results belong to different execution environments")
if (
contract.determinism_class == "deterministic"
and len({result_digest(item) for item in parsed}) != 1
):
raise ValueError("deterministic run results do not match")
return files, entries


def _require_fixture_digest(raw: bytes, name: str, expected: str) -> None:
if _digest(raw) != expected:
raise ValueError(f"fixture {name!r} digest mismatch")


def handle_eval_export(args: argparse.Namespace) -> int:
"""Package the contract, fixtures, subject, and run evidence."""
output = Path(args.output)
try:
if output.exists() and not args.force:
raise FileExistsError(
f"{output} already exists; pass --force to replace it"
)
contract = _load_contract(args.contract)
subject = Path(args.subject).read_bytes()
result_files, result_entries = _checked_results(
args.result, contract, subject
)
base = Path(args.contract).resolve().parent
fixture_files: list[tuple[str, bytes]] = []
fixture_entries: dict[str, JsonObject] = {}
for fixture in contract.fixtures:
name = _safe_member(fixture.name)
raw = (base / fixture.name).read_bytes()
_require_fixture_digest(raw, fixture.name, fixture.digest)
Comment on lines +118 to +121
member = f"fixtures/{name}"
fixture_files.append((member, raw))
fixture_entries[fixture.name] = {
"digest": fixture.digest,
"path": member,
}
manifest = _bundle_manifest(
contract, subject, fixture_entries, result_entries
)
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("manifest.json", _json_bytes(manifest))
archive.writestr(
"contract.json", _json_bytes(contract_to_json(contract))
)
archive.writestr("subject.bin", subject)
for member, raw in [*fixture_files, *result_files]:
archive.writestr(member, raw)
except EvalContractError as exc:
return _fail(exc.code, str(exc))
except (OSError, ValueError, zipfile.BadZipFile) as exc:
return _fail("eval_bundle_invalid", str(exc))
emit_json(
"logion.eval.export",
{
"bundle": str(output),
"bundle_digest": _digest(output.read_bytes()),
"contract_digest": contract_digest(contract),
"result_count": len(result_entries),
},
)
return 0
Loading
Loading