feat(nono): add isolated evidence harness
This commit is contained in:
Executable
+602
@@ -0,0 +1,602 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trusted-snapshot entry point for Nono evidence production.
|
||||
|
||||
The checkout copy is source only. A future maintenance installer must promote
|
||||
this file and its harness into an immutable snapshot, add the marker file, and
|
||||
invoke this command with that snapshot's digest. Running this source directly
|
||||
therefore fails closed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
_MARKER_CONTENT = "pi-sbx-trusted-snapshot-v1\n"
|
||||
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||
_RAW_OUTPUT_FIELDS = frozenset({"stdout", "stderr", "argv", "env", "environment", "exception", "output"})
|
||||
_LIVE_TESTS = frozenset({"git-live", "github-cli-live"})
|
||||
|
||||
|
||||
def _sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def _regular_file_identity(path: Path, *, max_size: int) -> tuple[int, int, int, int, str]:
|
||||
if path.is_symlink():
|
||||
raise EvidenceCliError(f"identity input cannot be a symlink: {path}")
|
||||
flags = (
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_NONBLOCK", 0)
|
||||
)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except OSError as error:
|
||||
raise EvidenceCliError(f"identity input is unavailable: {path}: {error}") from error
|
||||
try:
|
||||
file_stat = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_size > max_size:
|
||||
raise EvidenceCliError(
|
||||
f"identity input must be a bounded regular file: {path}"
|
||||
)
|
||||
digest = hashlib.sha256()
|
||||
remaining = file_stat.st_size
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(65536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
remaining -= len(chunk)
|
||||
final_stat = os.fstat(descriptor)
|
||||
if remaining or final_stat.st_size != file_stat.st_size:
|
||||
raise EvidenceCliError(f"identity input changed while reading: {path}")
|
||||
return (
|
||||
file_stat.st_dev,
|
||||
file_stat.st_ino,
|
||||
file_stat.st_size,
|
||||
file_stat.st_mtime_ns,
|
||||
digest.hexdigest(),
|
||||
)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _git_head(git: str, repository_root: Path) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[git, "-C", str(repository_root), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"},
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
raise EvidenceCliError(f"could not resolve repository commit: {error}") from error
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
class TrustedSnapshotError(RuntimeError):
|
||||
"""The source has not been authorized as an installed producer."""
|
||||
|
||||
|
||||
class EvidenceCliError(RuntimeError):
|
||||
"""Invalid evidence CLI request."""
|
||||
|
||||
|
||||
def _snapshot_digest(snapshot: Path) -> str:
|
||||
if snapshot.is_file():
|
||||
return sha256_bytes(snapshot.read_bytes())
|
||||
if not snapshot.is_dir():
|
||||
raise TrustedSnapshotError(f"trusted snapshot is not a file or directory: {snapshot}")
|
||||
records: list[bytes] = []
|
||||
for path in sorted(snapshot.rglob("*")):
|
||||
relative = path.relative_to(snapshot).as_posix()
|
||||
if relative == ".pi-sbx-trusted-snapshot":
|
||||
continue
|
||||
if path.is_symlink():
|
||||
raise TrustedSnapshotError(f"trusted snapshot contains symlink: {relative}")
|
||||
if path.is_file():
|
||||
records.append(
|
||||
relative.encode("utf-8")
|
||||
+ b"\0"
|
||||
+ _sha256_bytes(path.read_bytes()).encode("ascii")
|
||||
+ b"\n"
|
||||
)
|
||||
return _sha256_bytes(b"".join(records))
|
||||
|
||||
|
||||
def verify_trusted_snapshot(snapshot: str | os.PathLike[str], expected_digest: str) -> Path:
|
||||
"""Verify an explicit marker and immutable-content digest.
|
||||
|
||||
A path inside this source checkout is rejected even if a marker is added to
|
||||
it. This prevents a repository write from becoming a trusted producer.
|
||||
"""
|
||||
path = Path(snapshot)
|
||||
if not path.is_absolute():
|
||||
raise TrustedSnapshotError("--trusted-snapshot must be an absolute path")
|
||||
if _DIGEST.fullmatch(expected_digest) is None:
|
||||
raise TrustedSnapshotError("trusted snapshot digest must be lowercase SHA-256")
|
||||
if path.is_symlink():
|
||||
raise TrustedSnapshotError("trusted snapshot path itself must not be a symlink")
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise TrustedSnapshotError(f"cannot resolve trusted snapshot: {error}") from error
|
||||
source = REPOSITORY_ROOT.resolve()
|
||||
producer = Path(__file__).resolve()
|
||||
source_is_checkout = (source / ".git").exists()
|
||||
if source_is_checkout:
|
||||
raise TrustedSnapshotError("source checkout cannot be a trusted production snapshot")
|
||||
if not resolved.is_dir() or resolved != source or resolved not in producer.parents:
|
||||
raise TrustedSnapshotError(
|
||||
"trusted snapshot must equal the executing producer's repository root"
|
||||
)
|
||||
if resolved.is_dir():
|
||||
if resolved.stat().st_uid != os.geteuid() or resolved.stat().st_mode & 0o022:
|
||||
raise TrustedSnapshotError("trusted snapshot directory must be caller-owned and private")
|
||||
for candidate in resolved.rglob("*"):
|
||||
if candidate.is_symlink():
|
||||
raise TrustedSnapshotError(f"trusted snapshot contains symlink: {candidate}")
|
||||
if not candidate.is_dir() and not candidate.is_file():
|
||||
raise TrustedSnapshotError(f"trusted snapshot contains a non-file entry: {candidate}")
|
||||
candidate_stat = candidate.stat()
|
||||
if candidate_stat.st_uid != os.geteuid() or candidate_stat.st_mode & 0o022:
|
||||
raise TrustedSnapshotError(
|
||||
f"trusted snapshot entries must be caller-owned and not group/other writable: {candidate}"
|
||||
)
|
||||
marker = resolved / ".pi-sbx-trusted-snapshot"
|
||||
try:
|
||||
marker_contents = marker.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
raise TrustedSnapshotError(f"trusted snapshot marker is missing: {marker}") from error
|
||||
if marker_contents != _MARKER_CONTENT:
|
||||
raise TrustedSnapshotError("trusted snapshot marker has an invalid contract")
|
||||
observed = _snapshot_digest(resolved)
|
||||
if observed != expected_digest:
|
||||
raise TrustedSnapshotError(
|
||||
f"trusted snapshot digest mismatch: expected {expected_digest}, observed {observed}"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def _load_trusted_modules() -> None:
|
||||
"""Import producer code only after the executing snapshot is verified."""
|
||||
if str(REPOSITORY_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPOSITORY_ROOT))
|
||||
global EvidenceStore, EvidenceValidationError, create_summary
|
||||
global sha256_bytes, sha256_json, utc_timestamp, redact
|
||||
global isolated_environment, HostRequirementError, NonoAdapter
|
||||
global run_bounded_command
|
||||
|
||||
from tests.nono.harness.evidence import (
|
||||
EvidenceStore,
|
||||
EvidenceValidationError,
|
||||
create_summary,
|
||||
sha256_bytes,
|
||||
sha256_json,
|
||||
utc_timestamp,
|
||||
)
|
||||
from tests.nono.harness.fixtures import isolated_environment
|
||||
from tests.nono.harness.nono import (
|
||||
HostRequirementError,
|
||||
NonoAdapter,
|
||||
run_bounded_command,
|
||||
)
|
||||
from tests.nono.harness.redaction import redact
|
||||
|
||||
|
||||
def _observation(
|
||||
observation_id: str,
|
||||
capability: str,
|
||||
expected: str,
|
||||
actual: str,
|
||||
status: str,
|
||||
) -> dict[str, str]:
|
||||
body = {
|
||||
"id": observation_id,
|
||||
"capability": capability,
|
||||
"expected": expected,
|
||||
"actual": actual,
|
||||
"status": status,
|
||||
}
|
||||
body["hash"] = sha256_json(body)
|
||||
return body
|
||||
|
||||
|
||||
def _run_fixed_harness(arguments: list[str]) -> dict[str, Any]:
|
||||
if arguments:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(REPOSITORY_ROOT / "scripts" / "test_nono.py"),
|
||||
*arguments,
|
||||
]
|
||||
else:
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"unittest",
|
||||
"discover",
|
||||
"-s",
|
||||
"tests/nono/harness",
|
||||
"-p",
|
||||
"test_*.py",
|
||||
"-t",
|
||||
".",
|
||||
]
|
||||
with isolated_environment(prefix="nono-evidence-runner-") as fixture:
|
||||
result = run_bounded_command(
|
||||
command,
|
||||
cwd=REPOSITORY_ROOT,
|
||||
environment=fixture.env,
|
||||
timeout=180,
|
||||
max_output=65536,
|
||||
)
|
||||
return {
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"timedOut": result.timed_out,
|
||||
}
|
||||
|
||||
|
||||
def _hermetic_core(_: argparse.Namespace) -> dict[str, Any]:
|
||||
execution = _run_fixed_harness([])
|
||||
passed = execution["returncode"] == 0 and not execution["timedOut"]
|
||||
status = "passed" if passed else "failed"
|
||||
actual = "allowed" if passed else "unknown"
|
||||
return {
|
||||
"status": status,
|
||||
"observations": [
|
||||
_observation("hermetic-suite", "harness", "allowed", actual, status)
|
||||
],
|
||||
"hashes": {},
|
||||
"stdout": execution["stdout"],
|
||||
"stderr": execution["stderr"],
|
||||
}
|
||||
|
||||
|
||||
def _negative_matrix(_: argparse.Namespace) -> dict[str, Any]:
|
||||
capabilities = (
|
||||
"tmp", "runtime-parent", "sibling-worktree", "direct-ssh", "cross-context-key",
|
||||
"unrelated-pi-state", "trusted-instructions-write", "docker", "browser",
|
||||
"dictation", "update", "production-credentials", "nonselected-provider",
|
||||
)
|
||||
observations = [
|
||||
_observation(f"negative-{name}", name, "denied", "not-run", "blocked")
|
||||
for name in capabilities
|
||||
]
|
||||
return {"status": "blocked", "observations": observations, "hashes": {}}
|
||||
|
||||
|
||||
def _positive_contract(_: argparse.Namespace) -> dict[str, Any]:
|
||||
capabilities = ("git", "gh-cli", "package-cache", "provider-port", "unix-socket")
|
||||
observations = [
|
||||
_observation(f"positive-{name}", name, "allowed", "not-run", "blocked")
|
||||
for name in capabilities
|
||||
]
|
||||
return {"status": "blocked", "observations": observations, "hashes": {}}
|
||||
|
||||
|
||||
def _broad_fixture(args: argparse.Namespace) -> dict[str, Any]:
|
||||
if args.suite != "host":
|
||||
return {
|
||||
"status": "blocked",
|
||||
"observations": [
|
||||
_observation(
|
||||
"broad-workdir",
|
||||
"workdir",
|
||||
"denied",
|
||||
"not-run",
|
||||
"blocked",
|
||||
)
|
||||
],
|
||||
"hashes": {},
|
||||
}
|
||||
execution = _run_fixed_harness(["--host", "--nono", args.nono])
|
||||
passed = execution["returncode"] == 0 and not execution["timedOut"]
|
||||
status = "passed" if passed else "failed"
|
||||
return {
|
||||
"status": status,
|
||||
"observations": [
|
||||
_observation(
|
||||
"minimal-cross-session",
|
||||
"cross-session-filesystem",
|
||||
"denied",
|
||||
"denied" if passed else "unknown",
|
||||
status,
|
||||
),
|
||||
_observation(
|
||||
"broad-workdir",
|
||||
"workdir",
|
||||
"allowed",
|
||||
"allowed" if passed else "unknown",
|
||||
status,
|
||||
),
|
||||
],
|
||||
"hashes": {},
|
||||
"stdout": execution["stdout"],
|
||||
"stderr": execution["stderr"],
|
||||
}
|
||||
|
||||
|
||||
def _live_result(_: argparse.Namespace) -> dict[str, Any]:
|
||||
# Credentialed live work is intentionally not attempted by source code. A
|
||||
# host procedure must replace this fixed blocked observation in a future
|
||||
# installed snapshot after a human has approved it.
|
||||
return {
|
||||
"status": "blocked",
|
||||
"observations": [
|
||||
_observation("live-manual-check", "live-credentials", "allowed", "not-run", "blocked")
|
||||
],
|
||||
"hashes": {},
|
||||
}
|
||||
|
||||
|
||||
BUILTIN_TESTS: dict[str, Callable[[argparse.Namespace], dict[str, Any]]] = {
|
||||
"hermetic-core": _hermetic_core,
|
||||
"negative-capability-matrix": _negative_matrix,
|
||||
"positive-contract-matrix": _positive_contract,
|
||||
"broad-fixture": _broad_fixture,
|
||||
"git-live": _live_result,
|
||||
"github-cli-live": _live_result,
|
||||
}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--trusted-snapshot", required=True, help="installed snapshot directory")
|
||||
parser.add_argument(
|
||||
"--trusted-snapshot-digest",
|
||||
"--snapshot-digest",
|
||||
dest="trusted_snapshot_digest",
|
||||
required=True,
|
||||
help="SHA-256 digest of the installed snapshot manifest",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--state-root", required=True, help="injected XDG_STATE_HOME-compatible evidence root"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--summary-root", required=True, help="injected root for redacted summaries"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="action", required=True)
|
||||
run = subparsers.add_parser("run", help="run one fixed registry test")
|
||||
run.add_argument("test_id")
|
||||
run.add_argument("--suite", choices=("hermetic", "host", "live"), default="hermetic")
|
||||
run.add_argument("--approver")
|
||||
run.add_argument("--approve-live", action="store_true")
|
||||
run.add_argument("--repository-root", required=True)
|
||||
run.add_argument("--repository-commit", required=True)
|
||||
run.add_argument("--policy-file", required=True)
|
||||
run.add_argument("--policy-digest", required=True)
|
||||
run.add_argument("--nono", required=True, help="absolute Nono 0.74 executable")
|
||||
run.add_argument("--canary", action="append", default=[])
|
||||
summarize = subparsers.add_parser("summarize", help="summarize retained raw evidence")
|
||||
summarize.add_argument("run_id")
|
||||
summarize.add_argument("--repository-id", default="fixture-repository")
|
||||
summarize.add_argument("--canary", action="append", default=[])
|
||||
summarize.add_argument(
|
||||
"--credential-name-pattern",
|
||||
action="append",
|
||||
default=[],
|
||||
help="trusted credential-name regex that must be redacted from the summary",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _run(args: argparse.Namespace, snapshot_digest: str) -> int:
|
||||
if args.test_id not in BUILTIN_TESTS:
|
||||
raise EvidenceCliError(f"unknown test ID {args.test_id!r}; arbitrary commands are forbidden")
|
||||
selected_nono = Path(args.nono)
|
||||
if not selected_nono.is_absolute():
|
||||
raise EvidenceCliError("Nono executable must be an absolute path")
|
||||
nono_identity = _regular_file_identity(selected_nono, max_size=256 * 1024 * 1024)
|
||||
try:
|
||||
with isolated_environment(prefix="nono-evidence-host-") as fixture:
|
||||
session = fixture.session("one")
|
||||
adapter = NonoAdapter(
|
||||
selected_nono,
|
||||
cwd=session.repo,
|
||||
environment=session.environment(fixture.env),
|
||||
)
|
||||
args.nono_version = adapter.require_host_0_74()
|
||||
if args.suite == "host":
|
||||
profile = (
|
||||
REPOSITORY_ROOT
|
||||
/ "tests"
|
||||
/ "nono"
|
||||
/ "fixtures"
|
||||
/ "core"
|
||||
/ "minimal.json"
|
||||
)
|
||||
if not adapter.validate(profile).ok or not adapter.run(
|
||||
profile,
|
||||
["/usr/bin/true"],
|
||||
).ok:
|
||||
raise EvidenceCliError(
|
||||
"host suite fixture validation or execution failed"
|
||||
)
|
||||
except (HostRequirementError, OSError, ValueError) as error:
|
||||
raise EvidenceCliError(f"Nono 0.74 verification failed: {error}") from error
|
||||
if args.suite == "live":
|
||||
if args.test_id not in _LIVE_TESTS:
|
||||
raise EvidenceCliError("live mode is available only for the fixed live test registry")
|
||||
if not args.approve_live or not isinstance(args.approver, str) or not args.approver.strip():
|
||||
raise EvidenceCliError("live mode requires explicit approval via --approve-live and a named --approver")
|
||||
elif args.test_id in _LIVE_TESTS:
|
||||
raise EvidenceCliError("credentialed tests require explicit --suite live approval")
|
||||
repository_root = Path(args.repository_root)
|
||||
if not repository_root.is_absolute() or repository_root.is_symlink():
|
||||
raise EvidenceCliError("repository root must be an absolute non-symlink path")
|
||||
try:
|
||||
repository_root = repository_root.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise EvidenceCliError(f"repository root is unavailable: {error}") from error
|
||||
if not (repository_root / ".git").exists():
|
||||
raise EvidenceCliError("repository root is not a Git checkout")
|
||||
git = shutil.which("git", path="/usr/bin:/bin")
|
||||
if git is None:
|
||||
raise EvidenceCliError("Git is unavailable for repository identity verification")
|
||||
observed_commit = _git_head(git, repository_root)
|
||||
if observed_commit != args.repository_commit:
|
||||
raise EvidenceCliError("repository commit does not match the tested checkout")
|
||||
|
||||
policy_file = Path(args.policy_file)
|
||||
if not policy_file.is_absolute() or policy_file.is_symlink():
|
||||
raise EvidenceCliError("policy file must be an absolute non-symlink path")
|
||||
try:
|
||||
policy_file = policy_file.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise EvidenceCliError(f"policy file is unavailable: {error}") from error
|
||||
policy_identity = _regular_file_identity(policy_file, max_size=1024 * 1024)
|
||||
policy_digest = policy_identity[-1]
|
||||
if policy_digest != args.policy_digest:
|
||||
raise EvidenceCliError("policy digest does not match the tested policy file")
|
||||
run_id = "run-" + uuid.uuid4().hex
|
||||
started = utc_timestamp()
|
||||
result = BUILTIN_TESTS[args.test_id](args)
|
||||
if _regular_file_identity(
|
||||
selected_nono,
|
||||
max_size=256 * 1024 * 1024,
|
||||
) != nono_identity:
|
||||
raise EvidenceCliError("Nono executable changed during evidence execution")
|
||||
if _git_head(git, repository_root) != observed_commit:
|
||||
raise EvidenceCliError("repository HEAD changed during evidence execution")
|
||||
if _regular_file_identity(policy_file, max_size=1024 * 1024) != policy_identity:
|
||||
raise EvidenceCliError("policy file changed during evidence execution")
|
||||
finished = utc_timestamp()
|
||||
payload = {
|
||||
"schemaVersion": 1,
|
||||
"runId": run_id,
|
||||
"testId": args.test_id,
|
||||
"suite": args.suite,
|
||||
"metadata": {
|
||||
"repositoryCommit": args.repository_commit,
|
||||
"policySnapshotDigest": policy_digest,
|
||||
"installedSnapshotDigest": snapshot_digest,
|
||||
"nonoVersion": args.nono_version,
|
||||
"approver": args.approver if args.suite == "live" else None,
|
||||
"startedAt": started,
|
||||
"finishedAt": finished,
|
||||
},
|
||||
"result": result,
|
||||
}
|
||||
store = EvidenceStore(args.state_root, args.summary_root)
|
||||
store.purge_expired()
|
||||
reference = store.write_raw_evidence(run_id, payload)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"runId": run_id,
|
||||
"status": result["status"],
|
||||
"rawEvidenceSha256": reference.sha256,
|
||||
"rawEvidence": str(reference.path),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return {"passed": 0, "failed": 1, "blocked": 2, "not-run": 2}.get(
|
||||
str(result["status"]), 2
|
||||
)
|
||||
|
||||
|
||||
def _summarize(args: argparse.Namespace, snapshot_digest: str) -> int:
|
||||
store = EvidenceStore(args.state_root, args.summary_root)
|
||||
payload, raw_metadata = store.read_raw_evidence(args.run_id)
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("metadata"), dict):
|
||||
raise EvidenceCliError("retained evidence has no trusted metadata")
|
||||
metadata = payload["metadata"]
|
||||
if payload.get("runId") != args.run_id:
|
||||
raise EvidenceCliError("retained evidence run ID mismatch")
|
||||
if metadata.get("installedSnapshotDigest") != snapshot_digest:
|
||||
raise EvidenceCliError("evidence was produced by a different installed snapshot")
|
||||
result = payload.get("result")
|
||||
if not isinstance(result, dict):
|
||||
raise EvidenceCliError("retained evidence result is not an object")
|
||||
cleaned = redact(
|
||||
payload,
|
||||
canaries=args.canary,
|
||||
credential_name_patterns=args.credential_name_pattern,
|
||||
)
|
||||
cleaned_result = cleaned.get("result")
|
||||
if not isinstance(cleaned_result, dict):
|
||||
raise EvidenceCliError("redacted result is not an object")
|
||||
result_hashes: dict[str, str] = {}
|
||||
raw_hashes = cleaned_result.get("hashes", {})
|
||||
if not isinstance(raw_hashes, dict):
|
||||
raise EvidenceCliError("result hashes are not an object")
|
||||
for key, digest in raw_hashes.items():
|
||||
if not isinstance(key, str) or key in _RAW_OUTPUT_FIELDS or _DIGEST.fullmatch(str(digest)) is None:
|
||||
raise EvidenceCliError("result contains an invalid or raw hash field")
|
||||
result_hashes[key] = str(digest)
|
||||
# Output fields are represented only by their digest in a summary.
|
||||
for key, value in result.items():
|
||||
if key in _RAW_OUTPUT_FIELDS:
|
||||
result_hashes[key + "Hash"] = sha256_json(value)
|
||||
observations = cleaned_result.get("observations", [])
|
||||
if not isinstance(observations, list):
|
||||
raise EvidenceCliError("observations are not an array")
|
||||
summary = create_summary(
|
||||
run_id=args.run_id,
|
||||
test_id=str(payload.get("testId", "")),
|
||||
suite=str(payload.get("suite", "")),
|
||||
repository_commit=str(metadata.get("repositoryCommit", "")),
|
||||
policy_snapshot_digest=str(metadata.get("policySnapshotDigest", "")),
|
||||
installed_snapshot_digest=snapshot_digest,
|
||||
nono_version=str(metadata.get("nonoVersion", "")),
|
||||
status=str(cleaned_result.get("status", "")),
|
||||
approver=metadata.get("approver"),
|
||||
started_at=str(metadata.get("startedAt", "")),
|
||||
finished_at=str(metadata.get("finishedAt", "")),
|
||||
raw_evidence_sha256=str(raw_metadata.get("sha256", "")),
|
||||
raw_evidence_retained_until=str(raw_metadata.get("retainedUntil", "")),
|
||||
result_hashes=result_hashes,
|
||||
observations=observations,
|
||||
canaries=args.canary,
|
||||
credential_name_patterns=args.credential_name_pattern,
|
||||
)
|
||||
policy_digest = str(metadata["policySnapshotDigest"])
|
||||
path = store.write_summary(
|
||||
summary,
|
||||
repository_id=args.repository_id,
|
||||
policy_digest=policy_digest,
|
||||
)
|
||||
print(json.dumps({"runId": args.run_id, "summary": str(path), "status": summary["status"]}, sort_keys=True))
|
||||
return {"passed": 0, "failed": 1, "blocked": 2, "not-run": 2}.get(
|
||||
str(summary["status"]), 2
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
snapshot = verify_trusted_snapshot(args.trusted_snapshot, args.trusted_snapshot_digest)
|
||||
del snapshot
|
||||
sys.dont_write_bytecode = True
|
||||
_load_trusted_modules()
|
||||
if args.action == "run":
|
||||
return _run(args, args.trusted_snapshot_digest)
|
||||
if args.action == "summarize":
|
||||
return _summarize(args, args.trusted_snapshot_digest)
|
||||
raise EvidenceCliError("unknown action")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (EvidenceCliError, TrustedSnapshotError, ValueError) as error:
|
||||
print(f"nono-evidence: {error}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the Nono Slice 2 hermetic suite and explicit security gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tests.nono.harness.fixtures import isolated_environment # noqa: E402
|
||||
from tests.nono.harness.nono import HostRequirementError, NonoAdapter # noqa: E402
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", action="store_true", help="run mandatory Linux/Nono 0.74 checks")
|
||||
parser.add_argument(
|
||||
"--live", action="store_true", help="run separately approved live gate (never implicit)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--approve-live",
|
||||
metavar="APPROVER",
|
||||
help="required explicit human approval for --live; absence is an error",
|
||||
)
|
||||
parser.add_argument("--nono", default=None, help="absolute Nono executable for --host")
|
||||
return parser
|
||||
|
||||
|
||||
def run_hermetic() -> int:
|
||||
suite = unittest.defaultTestLoader.discover(
|
||||
str(ROOT / "tests" / "nono"), pattern="test_*.py", top_level_dir=str(ROOT)
|
||||
)
|
||||
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
def run_host(executable: str | None) -> int:
|
||||
if platform.system() != "Linux":
|
||||
print("host gate failed: Linux is required", file=sys.stderr)
|
||||
return 2
|
||||
selected = executable or shutil.which("nono")
|
||||
if selected is None:
|
||||
print("host gate failed: nono executable is missing", file=sys.stderr)
|
||||
return 2
|
||||
selected_path = Path(selected)
|
||||
if not selected_path.is_absolute():
|
||||
print("host gate failed: nono executable must resolve to an absolute path", file=sys.stderr)
|
||||
return 2
|
||||
fixture_root = ROOT / "tests" / "nono" / "fixtures" / "core"
|
||||
minimal_profile = fixture_root / "minimal.json"
|
||||
broad_profile = fixture_root / "broad.json"
|
||||
try:
|
||||
with isolated_environment(prefix="nono-host-") as fixture:
|
||||
session = fixture.session("one")
|
||||
adapter = NonoAdapter(
|
||||
selected_path,
|
||||
cwd=session.repo,
|
||||
timeout=20,
|
||||
environment=session.environment(fixture.env),
|
||||
)
|
||||
version = adapter.require_host_0_74()
|
||||
validation = adapter.validate_temporary_profile(minimal_profile)
|
||||
if not validation.ok:
|
||||
print(
|
||||
f"host gate failed: strict validation exited {validation.returncode}: "
|
||||
f"{validation.stderr or validation.stdout}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
shown = adapter.show_temporary_profile(minimal_profile)
|
||||
if not shown.ok:
|
||||
print("host gate failed: profile show did not succeed", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
resolved = json.loads(shown.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
print(f"host gate failed: profile show was not JSON: {error}", file=sys.stderr)
|
||||
return 1
|
||||
if resolved.get("network", {}).get("block") is not True:
|
||||
print("host gate failed: fixture is not network-blocked", file=sys.stderr)
|
||||
return 1
|
||||
run = adapter.run_temporary_profile(minimal_profile, ["/usr/bin/true"])
|
||||
if not run.ok:
|
||||
print(
|
||||
f"host gate failed: isolated execution exited {run.returncode}: "
|
||||
f"{run.stderr or run.stdout}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
denied_target = fixture.session("two").tmp / "private.txt"
|
||||
denied_target.write_text("denied fixture\n", encoding="utf-8")
|
||||
probe = [
|
||||
"/usr/bin/python3",
|
||||
"-c",
|
||||
"from pathlib import Path; Path(__import__('sys').argv[1]).read_text()",
|
||||
str(denied_target),
|
||||
]
|
||||
denied = adapter.run_temporary_profile(minimal_profile, probe)
|
||||
if denied.ok:
|
||||
print(
|
||||
"host gate failed: minimal profile read another session's temporary file",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
broad_target = session.repo / "broad-readable.txt"
|
||||
broad_target.write_text("broad fixture\n", encoding="utf-8")
|
||||
broad_probe = [*probe[:-1], str(broad_target)]
|
||||
allowed = adapter.run_temporary_profile(
|
||||
broad_profile,
|
||||
broad_probe,
|
||||
allow_cwd=True,
|
||||
)
|
||||
if not allowed.ok:
|
||||
print(
|
||||
"host gate failed: broad fixture did not demonstrate the over-grant",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"suite": "host",
|
||||
"nonoVersion": version,
|
||||
"status": "passed",
|
||||
"negativeChecks": 1,
|
||||
"overgrantDetectionChecks": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
return 0
|
||||
except HostRequirementError as error:
|
||||
print(f"host gate failed: {error}", file=sys.stderr)
|
||||
return 2
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"host gate failed: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def run_live(approver: str | None) -> int:
|
||||
if not isinstance(approver, str) or not approver.strip():
|
||||
print("live gate requires --approve-live APPROVER", file=sys.stderr)
|
||||
return 2
|
||||
# This source harness has no credential broker. A missing manual check is
|
||||
# blocked, never represented as a pass or an implicit unittest skip.
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"suite": "live",
|
||||
"approver": approver,
|
||||
"status": "blocked",
|
||||
"reason": "manual credentialed evidence is not installed in Slice 2",
|
||||
}
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
if args.host and args.live:
|
||||
print("choose exactly one of --host or --live", file=sys.stderr)
|
||||
return 2
|
||||
if args.approve_live is not None and not args.live:
|
||||
print("--approve-live is valid only with --live", file=sys.stderr)
|
||||
return 2
|
||||
if args.host:
|
||||
return run_host(args.nono)
|
||||
if args.live:
|
||||
return run_live(args.approve_live)
|
||||
return run_hermetic()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user