feat(nono): add isolated evidence harness
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://jb.codes/harness-tools/nono/evidence.schema.json",
|
||||||
|
"title": "Nono evidence summary",
|
||||||
|
"description": "Schema-v1 for secret-free, redacted Slice 2 policy evidence. Raw command material is retained separately under the injected XDG_STATE_HOME root.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schemaVersion",
|
||||||
|
"runId",
|
||||||
|
"testId",
|
||||||
|
"suite",
|
||||||
|
"repositoryCommit",
|
||||||
|
"policySnapshotDigest",
|
||||||
|
"installedSnapshotDigest",
|
||||||
|
"nonoVersion",
|
||||||
|
"status",
|
||||||
|
"approver",
|
||||||
|
"startedAt",
|
||||||
|
"finishedAt",
|
||||||
|
"rawEvidence",
|
||||||
|
"resultHashes",
|
||||||
|
"observations"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": { "const": 1 },
|
||||||
|
"runId": { "$ref": "#/$defs/id" },
|
||||||
|
"testId": { "$ref": "#/$defs/id" },
|
||||||
|
"suite": { "enum": ["hermetic", "host", "live"] },
|
||||||
|
"repositoryCommit": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
|
||||||
|
"policySnapshotDigest": { "$ref": "#/$defs/sha256" },
|
||||||
|
"installedSnapshotDigest": { "$ref": "#/$defs/sha256" },
|
||||||
|
"nonoVersion": { "type": "string", "pattern": "^0\\.74\\.[0-9]+$" },
|
||||||
|
"status": { "enum": ["passed", "failed", "blocked", "not-run"] },
|
||||||
|
"approver": { "type": ["string", "null"] },
|
||||||
|
"startedAt": { "$ref": "#/$defs/utcTimestamp" },
|
||||||
|
"finishedAt": { "$ref": "#/$defs/utcTimestamp" },
|
||||||
|
"rawEvidence": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["sha256", "retainedUntil"],
|
||||||
|
"properties": {
|
||||||
|
"sha256": { "$ref": "#/$defs/sha256" },
|
||||||
|
"retainedUntil": { "$ref": "#/$defs/utcTimestamp" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resultHashes": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": { "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
|
||||||
|
"additionalProperties": { "$ref": "#/$defs/sha256" }
|
||||||
|
},
|
||||||
|
"observations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/observation" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": { "properties": { "suite": { "const": "live" } } },
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"approver": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"$defs": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9a-f]{64}$"
|
||||||
|
},
|
||||||
|
"utcTimestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,6})?Z$"
|
||||||
|
},
|
||||||
|
"observation": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "capability", "expected", "actual", "status", "hash"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/id" },
|
||||||
|
"capability": { "$ref": "#/$defs/id" },
|
||||||
|
"expected": { "enum": ["allowed", "denied", "unknown", "not-run"] },
|
||||||
|
"actual": { "enum": ["allowed", "denied", "unknown", "not-run"] },
|
||||||
|
"status": { "enum": ["passed", "failed", "blocked", "not-run"] },
|
||||||
|
"hash": { "$ref": "#/$defs/sha256" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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())
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Nono evidence harness
|
||||||
|
|
||||||
|
Slice 2 provides isolated fixtures, policy execution helpers, redaction, and evidence contracts. It does not install profiles or produce trusted migration evidence from a source checkout.
|
||||||
|
|
||||||
|
## Test modes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/test_nono.py
|
||||||
|
python scripts/test_nono.py --host
|
||||||
|
```
|
||||||
|
|
||||||
|
The default suite is hermetic. Host mode is mandatory only when explicitly selected and fails rather than skips unless it runs on Linux with Nono 0.74.x. It validates and executes temporary profile paths, proves a minimal profile denies cross-session file access, and proves the deliberately broad fixture exposes its workdir.
|
||||||
|
|
||||||
|
Live credential checks are separate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/test_nono.py --live --approve-live <approver>
|
||||||
|
```
|
||||||
|
|
||||||
|
Slice 2 has no credential broker, so this command records a blocked outcome instead of claiming a pass or skip.
|
||||||
|
|
||||||
|
## Evidence boundary
|
||||||
|
|
||||||
|
`scripts/nono-evidence.py` accepts only fixed test IDs and runs only when the executing producer's repository root is the caller-owned, marker-bearing snapshot whose content matches the supplied SHA-256. A repository checkout cannot identify itself as trusted, and the source producer cannot borrow another snapshot's identity. Slice 3 must install the trusted snapshot and fix its invocation arguments through a maintenance-controlled command policy.
|
||||||
|
|
||||||
|
Harness subprocesses receive only explicit isolated environment values: caller-owned mode-`0700` HOME, XDG, and temporary directories plus a fixed minimal allowlist of non-credential variables. Output pipes are continuously drained into bounded buffers, and timeouts terminate the subprocess process group.
|
||||||
|
|
||||||
|
Raw command material is written under the injected state root as caller-owned mode-`0600` files in mode-`0700` directories. Summary files follow `config/nono/evidence.schema.json` and contain hashes and redacted observations, not raw argv, environment, stdout, or stderr. Retention is exactly 30 days: expired evidence is rejected, each trusted producer run purges validated expired entries and recovers interrupted quarantine deletions, and maintenance remains responsible for cleanup when the producer is dormant. Repository commits, policy digests, installed snapshot digests, and Nono versions are verified against the tested checkout, policy file, executing snapshot, and executable rather than accepted as unsupported claims.
|
||||||
|
|
||||||
|
The negative and positive matrices are fixture contracts until later slices supply production profiles. They must not be cited as proof that a deferred production capability exists.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "default",
|
||||||
|
"workdir": {
|
||||||
|
"access": "readwrite"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"block": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
safe fixture data
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "default",
|
||||||
|
"workdir": {
|
||||||
|
"access": "none"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"block": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Safe fixture probe: prints only its fixed, non-secret result."""
|
||||||
|
|
||||||
|
print("nono-fixture-probe: ok")
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
"""Schema-v1, secret-free evidence records and private atomic storage."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as _dt
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .redaction import assert_no_leaks
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
RETENTION_DAYS = 30
|
||||||
|
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
_COMMIT = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
_QUARANTINE_ID = re.compile(r"^\.purge-[0-9a-f]{32}$")
|
||||||
|
_UTC = re.compile(
|
||||||
|
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$"
|
||||||
|
)
|
||||||
|
_STATUSES = frozenset({"passed", "failed", "blocked", "not-run"})
|
||||||
|
_OUTCOME_VALUES = frozenset({"allowed", "denied", "unknown", "not-run"})
|
||||||
|
_TOP_LEVEL_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"schemaVersion",
|
||||||
|
"runId",
|
||||||
|
"testId",
|
||||||
|
"suite",
|
||||||
|
"repositoryCommit",
|
||||||
|
"policySnapshotDigest",
|
||||||
|
"installedSnapshotDigest",
|
||||||
|
"nonoVersion",
|
||||||
|
"status",
|
||||||
|
"approver",
|
||||||
|
"startedAt",
|
||||||
|
"finishedAt",
|
||||||
|
"rawEvidence",
|
||||||
|
"resultHashes",
|
||||||
|
"observations",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_OBSERVATION_FIELDS = frozenset(
|
||||||
|
{"id", "capability", "expected", "actual", "status", "hash"}
|
||||||
|
)
|
||||||
|
_RAW_FIELD_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"argv",
|
||||||
|
"args",
|
||||||
|
"command",
|
||||||
|
"env",
|
||||||
|
"environment",
|
||||||
|
"exception",
|
||||||
|
"output",
|
||||||
|
"raw",
|
||||||
|
"stderr",
|
||||||
|
"stdout",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceValidationError(ValueError):
|
||||||
|
"""A summary or evidence envelope does not satisfy the closed contract."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RawEvidenceRef:
|
||||||
|
run_id: str
|
||||||
|
path: Path
|
||||||
|
metadata_path: Path
|
||||||
|
sha256: str
|
||||||
|
retained_until: str
|
||||||
|
|
||||||
|
|
||||||
|
def utc_timestamp(value: _dt.datetime | None = None) -> str:
|
||||||
|
current = value or _dt.datetime.now(_dt.timezone.utc)
|
||||||
|
if current.tzinfo is None:
|
||||||
|
current = current.replace(tzinfo=_dt.timezone.utc)
|
||||||
|
current = current.astimezone(_dt.timezone.utc)
|
||||||
|
return current.isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_bytes(value: bytes) -> str:
|
||||||
|
return hashlib.sha256(value).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_json(value: Any) -> str:
|
||||||
|
return sha256_bytes(_canonical_json(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: Any) -> bytes:
|
||||||
|
try:
|
||||||
|
return json.dumps(
|
||||||
|
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise EvidenceValidationError(f"evidence is not JSON serializable: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_id(value: object, field: str) -> str:
|
||||||
|
if not isinstance(value, str) or _ID.fullmatch(value) is None:
|
||||||
|
raise EvidenceValidationError(f"{field} must be a safe identifier")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_digest(value: object, field: str) -> str:
|
||||||
|
if not isinstance(value, str) or _SHA256.fullmatch(value) is None:
|
||||||
|
raise EvidenceValidationError(f"{field} must be a lowercase SHA-256 digest")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp(value: object, field: str) -> _dt.datetime:
|
||||||
|
if not isinstance(value, str) or _UTC.fullmatch(value) is None:
|
||||||
|
raise EvidenceValidationError(f"{field} must be a UTC timestamp ending in Z")
|
||||||
|
try:
|
||||||
|
parsed = _dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as error:
|
||||||
|
raise EvidenceValidationError(f"{field} is not a valid timestamp") from error
|
||||||
|
if parsed.tzinfo != _dt.timezone.utc:
|
||||||
|
raise EvidenceValidationError(f"{field} must use UTC")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_raw_fields(value: Any, path: str = "$") -> None:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for key, child in value.items():
|
||||||
|
if isinstance(key, str) and key.lower() in _RAW_FIELD_NAMES:
|
||||||
|
raise EvidenceValidationError(f"raw evidence field is not allowed in summary: {path}.{key}")
|
||||||
|
_assert_no_raw_fields(child, f"{path}.{key}")
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
for index, child in enumerate(value):
|
||||||
|
_assert_no_raw_fields(child, f"{path}[{index}]")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_observation(
|
||||||
|
observation: Mapping[str, Any],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if not isinstance(observation, Mapping):
|
||||||
|
raise EvidenceValidationError("each observation must be an object")
|
||||||
|
# Hash is intentionally explicit. Callers must hash an observation before
|
||||||
|
# it enters a summary instead of accidentally putting command output here.
|
||||||
|
result = dict(observation)
|
||||||
|
if "observationHash" in result and "hash" not in result:
|
||||||
|
result["hash"] = result.pop("observationHash")
|
||||||
|
expected_fields = _OBSERVATION_FIELDS
|
||||||
|
if set(result) != expected_fields:
|
||||||
|
missing = sorted(expected_fields - set(result))
|
||||||
|
extra = sorted(set(result) - expected_fields)
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"observation fields mismatch (missing={missing}, extra={extra})"
|
||||||
|
)
|
||||||
|
if any(not isinstance(result[field], str) for field in expected_fields):
|
||||||
|
raise EvidenceValidationError("observation fields must be strings")
|
||||||
|
_safe_id(result["id"], "observation.id")
|
||||||
|
_safe_id(result["capability"], "observation.capability")
|
||||||
|
if result["expected"] not in _OUTCOME_VALUES:
|
||||||
|
raise EvidenceValidationError("observation.expected is invalid")
|
||||||
|
if result["actual"] not in _OUTCOME_VALUES:
|
||||||
|
raise EvidenceValidationError("observation.actual is invalid")
|
||||||
|
if result["status"] not in _STATUSES:
|
||||||
|
raise EvidenceValidationError("observation.status is invalid")
|
||||||
|
_safe_digest(result["hash"], "observation.hash")
|
||||||
|
return {field: result[field] for field in expected_fields}
|
||||||
|
|
||||||
|
|
||||||
|
def create_summary(
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
test_id: str,
|
||||||
|
suite: str,
|
||||||
|
repository_commit: str,
|
||||||
|
policy_snapshot_digest: str,
|
||||||
|
installed_snapshot_digest: str,
|
||||||
|
nono_version: str,
|
||||||
|
status: str,
|
||||||
|
started_at: str,
|
||||||
|
finished_at: str,
|
||||||
|
raw_evidence_sha256: str,
|
||||||
|
raw_evidence_retained_until: str,
|
||||||
|
result_hashes: Mapping[str, str],
|
||||||
|
observations: Sequence[Mapping[str, Any]],
|
||||||
|
approver: str | None = None,
|
||||||
|
canaries: Sequence[str] = (),
|
||||||
|
credential_name_patterns: Sequence[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create and validate a redacted summary; raw command fields are rejected."""
|
||||||
|
summary: dict[str, Any] = {
|
||||||
|
"schemaVersion": SCHEMA_VERSION,
|
||||||
|
"runId": _safe_id(run_id, "runId"),
|
||||||
|
"testId": _safe_id(test_id, "testId"),
|
||||||
|
"suite": suite,
|
||||||
|
"repositoryCommit": repository_commit,
|
||||||
|
"policySnapshotDigest": _safe_digest(policy_snapshot_digest, "policySnapshotDigest"),
|
||||||
|
"installedSnapshotDigest": _safe_digest(
|
||||||
|
installed_snapshot_digest, "installedSnapshotDigest"
|
||||||
|
),
|
||||||
|
"nonoVersion": nono_version,
|
||||||
|
"status": status,
|
||||||
|
"approver": approver,
|
||||||
|
"startedAt": started_at,
|
||||||
|
"finishedAt": finished_at,
|
||||||
|
"rawEvidence": {
|
||||||
|
"sha256": raw_evidence_sha256,
|
||||||
|
"retainedUntil": raw_evidence_retained_until,
|
||||||
|
},
|
||||||
|
"resultHashes": dict(result_hashes),
|
||||||
|
"observations": [
|
||||||
|
_normalize_observation(item) for item in observations
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_assert_no_raw_fields(summary)
|
||||||
|
# A caller that forgot to redact gets an explicit failure, never a best-effort
|
||||||
|
# committed summary.
|
||||||
|
assert_no_leaks(
|
||||||
|
summary,
|
||||||
|
canaries=canaries,
|
||||||
|
credential_name_patterns=credential_name_patterns,
|
||||||
|
)
|
||||||
|
validate_summary(summary)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def validate_summary(summary: Any) -> None:
|
||||||
|
if not isinstance(summary, Mapping):
|
||||||
|
raise EvidenceValidationError("summary must be a JSON object")
|
||||||
|
if set(summary) != _TOP_LEVEL_FIELDS:
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"summary fields mismatch (missing={sorted(_TOP_LEVEL_FIELDS - set(summary))}, "
|
||||||
|
f"extra={sorted(set(summary) - _TOP_LEVEL_FIELDS)})"
|
||||||
|
)
|
||||||
|
if summary["schemaVersion"] != SCHEMA_VERSION:
|
||||||
|
raise EvidenceValidationError("unsupported schemaVersion")
|
||||||
|
_safe_id(summary["runId"], "runId")
|
||||||
|
_safe_id(summary["testId"], "testId")
|
||||||
|
if summary["suite"] not in {"hermetic", "host", "live"}:
|
||||||
|
raise EvidenceValidationError("suite must be hermetic, host, or live")
|
||||||
|
if not isinstance(summary["repositoryCommit"], str) or _COMMIT.fullmatch(
|
||||||
|
summary["repositoryCommit"]
|
||||||
|
) is None:
|
||||||
|
raise EvidenceValidationError("repositoryCommit must be a 40-character commit SHA")
|
||||||
|
_safe_digest(summary["policySnapshotDigest"], "policySnapshotDigest")
|
||||||
|
_safe_digest(summary["installedSnapshotDigest"], "installedSnapshotDigest")
|
||||||
|
if not isinstance(summary["nonoVersion"], str) or re.fullmatch(
|
||||||
|
r"0\.74\.\d+", summary["nonoVersion"]
|
||||||
|
) is None:
|
||||||
|
raise EvidenceValidationError("nonoVersion must be in the 0.74.x line")
|
||||||
|
if summary["status"] not in _STATUSES:
|
||||||
|
raise EvidenceValidationError("invalid summary status")
|
||||||
|
if summary["suite"] == "live":
|
||||||
|
if not isinstance(summary["approver"], str) or not summary["approver"].strip():
|
||||||
|
raise EvidenceValidationError("live evidence requires an approver")
|
||||||
|
elif summary["approver"] is not None and not isinstance(summary["approver"], str):
|
||||||
|
raise EvidenceValidationError("approver must be null outside live mode")
|
||||||
|
started = _timestamp(summary["startedAt"], "startedAt")
|
||||||
|
finished = _timestamp(summary["finishedAt"], "finishedAt")
|
||||||
|
if finished < started:
|
||||||
|
raise EvidenceValidationError("finishedAt precedes startedAt")
|
||||||
|
|
||||||
|
raw = summary["rawEvidence"]
|
||||||
|
if not isinstance(raw, Mapping) or set(raw) != {"sha256", "retainedUntil"}:
|
||||||
|
raise EvidenceValidationError("rawEvidence fields mismatch")
|
||||||
|
_safe_digest(raw["sha256"], "rawEvidence.sha256")
|
||||||
|
_timestamp(raw["retainedUntil"], "rawEvidence.retainedUntil")
|
||||||
|
|
||||||
|
hashes = summary["resultHashes"]
|
||||||
|
if not isinstance(hashes, Mapping):
|
||||||
|
raise EvidenceValidationError("resultHashes must be an object")
|
||||||
|
for key, digest in hashes.items():
|
||||||
|
_safe_id(key, "resultHashes key")
|
||||||
|
_safe_digest(digest, f"resultHashes.{key}")
|
||||||
|
|
||||||
|
observations = summary["observations"]
|
||||||
|
if not isinstance(observations, list):
|
||||||
|
raise EvidenceValidationError("observations must be an array")
|
||||||
|
for item in observations:
|
||||||
|
_normalize_observation(item)
|
||||||
|
_assert_no_raw_fields(summary)
|
||||||
|
assert_no_leaks(summary)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceStore:
|
||||||
|
"""Store raw evidence and summaries beneath caller-injected roots only."""
|
||||||
|
|
||||||
|
def __init__(self, state_root: str | os.PathLike[str], summary_root: str | os.PathLike[str]):
|
||||||
|
self.state_root = self._root(Path(state_root), "state_root")
|
||||||
|
self.summary_root = self._root(Path(summary_root), "summary_root")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _root(path: Path, name: str) -> Path:
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ValueError(f"{name} must be absolute")
|
||||||
|
if _has_symlink_component(path):
|
||||||
|
raise ValueError(f"{name} cannot contain a symlinked path component")
|
||||||
|
if path.exists():
|
||||||
|
if not path.is_dir():
|
||||||
|
raise ValueError(f"{name} must be a regular directory, not a symlink")
|
||||||
|
if path.stat().st_uid != os.geteuid() or stat_mode(path) != 0o700:
|
||||||
|
raise ValueError(f"{name} must be caller-owned mode 0700")
|
||||||
|
else:
|
||||||
|
path.mkdir(parents=True, mode=0o700)
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _raw_dir(self, run_id: str, *, create: bool = True) -> Path:
|
||||||
|
_safe_id(run_id, "runId")
|
||||||
|
base = self.state_root / "pi-sbx" / "evidence"
|
||||||
|
directory = base / run_id
|
||||||
|
if create:
|
||||||
|
_private_dir(base)
|
||||||
|
_private_dir(directory)
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
not base.is_dir()
|
||||||
|
or base.is_symlink()
|
||||||
|
or base.stat().st_uid != os.geteuid()
|
||||||
|
or stat_mode(base) != 0o700
|
||||||
|
):
|
||||||
|
raise EvidenceValidationError("retained evidence root does not exist privately")
|
||||||
|
if (
|
||||||
|
not directory.is_dir()
|
||||||
|
or directory.is_symlink()
|
||||||
|
or directory.stat().st_uid != os.geteuid()
|
||||||
|
or stat_mode(directory) != 0o700
|
||||||
|
):
|
||||||
|
raise EvidenceValidationError("retained evidence run directory is unavailable")
|
||||||
|
return directory
|
||||||
|
|
||||||
|
def write_raw_evidence(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
evidence: Any,
|
||||||
|
*,
|
||||||
|
now: _dt.datetime | None = None,
|
||||||
|
) -> RawEvidenceRef:
|
||||||
|
raw_dir = self._raw_dir(run_id)
|
||||||
|
started = now or _dt.datetime.now(_dt.timezone.utc)
|
||||||
|
retained = utc_timestamp(started + _dt.timedelta(days=RETENTION_DAYS))
|
||||||
|
payload = _canonical_json(evidence)
|
||||||
|
digest = sha256_bytes(payload)
|
||||||
|
raw_path = raw_dir / "raw.json"
|
||||||
|
metadata_path = raw_dir / "metadata.json"
|
||||||
|
_atomic_write(raw_path, payload)
|
||||||
|
metadata = {
|
||||||
|
"schemaVersion": SCHEMA_VERSION,
|
||||||
|
"runId": run_id,
|
||||||
|
"sha256": digest,
|
||||||
|
"createdAt": utc_timestamp(started),
|
||||||
|
"retainedUntil": retained,
|
||||||
|
}
|
||||||
|
_atomic_write(metadata_path, _canonical_json(metadata))
|
||||||
|
return RawEvidenceRef(run_id, raw_path, metadata_path, digest, retained)
|
||||||
|
|
||||||
|
def read_raw_evidence(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
now: _dt.datetime | None = None,
|
||||||
|
allow_expired: bool = False,
|
||||||
|
) -> tuple[Any, dict[str, Any]]:
|
||||||
|
raw_dir = self._raw_dir(run_id, create=False)
|
||||||
|
directory_fd = _private_directory_fd(raw_dir)
|
||||||
|
try:
|
||||||
|
return self._read_raw_evidence_fd(
|
||||||
|
run_id,
|
||||||
|
directory_fd,
|
||||||
|
now=now,
|
||||||
|
allow_expired=allow_expired,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
|
||||||
|
def _read_raw_evidence_fd(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
directory_fd: int,
|
||||||
|
*,
|
||||||
|
now: _dt.datetime | None,
|
||||||
|
allow_expired: bool,
|
||||||
|
) -> tuple[Any, dict[str, Any]]:
|
||||||
|
raw_bytes = _read_nofollow_at(directory_fd, "raw.json", "raw.json")
|
||||||
|
metadata_bytes = _read_nofollow_at(
|
||||||
|
directory_fd,
|
||||||
|
"metadata.json",
|
||||||
|
"metadata.json",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_bytes.decode("utf-8"))
|
||||||
|
metadata = json.loads(metadata_bytes.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise EvidenceValidationError(f"invalid retained raw evidence: {error}") from error
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise EvidenceValidationError("raw evidence metadata is not an object")
|
||||||
|
metadata_fields = {"schemaVersion", "runId", "sha256", "createdAt", "retainedUntil"}
|
||||||
|
if set(metadata) != metadata_fields or metadata.get("schemaVersion") != SCHEMA_VERSION:
|
||||||
|
raise EvidenceValidationError("raw evidence metadata fields mismatch")
|
||||||
|
if metadata.get("sha256") != sha256_bytes(raw_bytes):
|
||||||
|
raise EvidenceValidationError("raw evidence hash does not match metadata")
|
||||||
|
if metadata.get("runId") != run_id:
|
||||||
|
raise EvidenceValidationError("raw evidence run ID mismatch")
|
||||||
|
created = _timestamp(metadata.get("createdAt"), "raw metadata createdAt")
|
||||||
|
retained = _timestamp(metadata.get("retainedUntil"), "raw metadata retainedUntil")
|
||||||
|
if retained != created + _dt.timedelta(days=RETENTION_DAYS):
|
||||||
|
raise EvidenceValidationError("raw evidence retention must be exactly 30 days")
|
||||||
|
current = now or _dt.datetime.now(_dt.timezone.utc)
|
||||||
|
if not allow_expired and retained <= current.astimezone(_dt.timezone.utc):
|
||||||
|
raise EvidenceValidationError("raw evidence retention has expired")
|
||||||
|
return payload, metadata
|
||||||
|
|
||||||
|
def purge_expired(self, *, now: _dt.datetime | None = None) -> list[str]:
|
||||||
|
base = self.state_root / "pi-sbx" / "evidence"
|
||||||
|
if not base.exists():
|
||||||
|
return []
|
||||||
|
current = now or _dt.datetime.now(_dt.timezone.utc)
|
||||||
|
removed: list[str] = []
|
||||||
|
base_fd = _private_directory_fd(base)
|
||||||
|
try:
|
||||||
|
for name in sorted(os.listdir(base_fd)):
|
||||||
|
before = os.stat(name, dir_fd=base_fd, follow_symlinks=False)
|
||||||
|
if _QUARANTINE_ID.fullmatch(name):
|
||||||
|
if (
|
||||||
|
not stat.S_ISDIR(before.st_mode)
|
||||||
|
or stat.S_IMODE(before.st_mode) != 0o700
|
||||||
|
or before.st_uid != os.geteuid()
|
||||||
|
):
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"unsafe interrupted purge entry: {base / name}"
|
||||||
|
)
|
||||||
|
_remove_tree_at(
|
||||||
|
base_fd,
|
||||||
|
name,
|
||||||
|
(before.st_dev, before.st_ino),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
run_id = _safe_id(name, "retained runId")
|
||||||
|
if (
|
||||||
|
not stat.S_ISDIR(before.st_mode)
|
||||||
|
or stat.S_IMODE(before.st_mode) != 0o700
|
||||||
|
or before.st_uid != os.geteuid()
|
||||||
|
):
|
||||||
|
raise EvidenceValidationError(f"unsafe evidence entry: {base / name}")
|
||||||
|
run_fd = os.open(
|
||||||
|
name,
|
||||||
|
os.O_RDONLY
|
||||||
|
| getattr(os, "O_DIRECTORY", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0),
|
||||||
|
dir_fd=base_fd,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
opened = os.fstat(run_fd)
|
||||||
|
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"evidence directory changed before purge read: {base / name}"
|
||||||
|
)
|
||||||
|
_payload, metadata = self._read_raw_evidence_fd(
|
||||||
|
run_id,
|
||||||
|
run_fd,
|
||||||
|
now=current,
|
||||||
|
allow_expired=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.close(run_fd)
|
||||||
|
after = os.stat(name, dir_fd=base_fd, follow_symlinks=False)
|
||||||
|
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"evidence directory changed during purge: {base / name}"
|
||||||
|
)
|
||||||
|
retained = _timestamp(
|
||||||
|
metadata["retainedUntil"],
|
||||||
|
"raw metadata retainedUntil",
|
||||||
|
)
|
||||||
|
if retained <= current.astimezone(_dt.timezone.utc):
|
||||||
|
quarantine = f".purge-{secrets.token_hex(16)}"
|
||||||
|
os.rename(
|
||||||
|
name,
|
||||||
|
quarantine,
|
||||||
|
src_dir_fd=base_fd,
|
||||||
|
dst_dir_fd=base_fd,
|
||||||
|
)
|
||||||
|
quarantined = os.stat(
|
||||||
|
quarantine,
|
||||||
|
dir_fd=base_fd,
|
||||||
|
follow_symlinks=False,
|
||||||
|
)
|
||||||
|
if (quarantined.st_dev, quarantined.st_ino) != (
|
||||||
|
before.st_dev,
|
||||||
|
before.st_ino,
|
||||||
|
):
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"evidence directory changed during quarantine: {base / name}"
|
||||||
|
)
|
||||||
|
_remove_tree_at(
|
||||||
|
base_fd,
|
||||||
|
quarantine,
|
||||||
|
(before.st_dev, before.st_ino),
|
||||||
|
)
|
||||||
|
removed.append(run_id)
|
||||||
|
finally:
|
||||||
|
os.close(base_fd)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def summary_path(self, repository_id: str, policy_digest: str, run_id: str) -> Path:
|
||||||
|
_safe_id(repository_id, "repository_id")
|
||||||
|
_safe_digest(policy_digest, "policy_digest")
|
||||||
|
_safe_id(run_id, "run_id")
|
||||||
|
directory = self.summary_root / repository_id / policy_digest
|
||||||
|
_private_dir(directory)
|
||||||
|
return directory / f"{run_id}.json"
|
||||||
|
|
||||||
|
def write_summary(
|
||||||
|
self,
|
||||||
|
summary: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
repository_id: str,
|
||||||
|
policy_digest: str,
|
||||||
|
) -> Path:
|
||||||
|
validate_summary(summary)
|
||||||
|
path = self.summary_path(repository_id, policy_digest, str(summary["runId"]))
|
||||||
|
_atomic_write(path, _canonical_json(summary))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def stat_mode(path: Path) -> int:
|
||||||
|
return path.stat().st_mode & 0o777
|
||||||
|
|
||||||
|
|
||||||
|
def _has_symlink_component(path: Path) -> bool:
|
||||||
|
current = path
|
||||||
|
while True:
|
||||||
|
if current.is_symlink():
|
||||||
|
return True
|
||||||
|
if current.parent == current:
|
||||||
|
return False
|
||||||
|
current = current.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _private_dir(path: Path) -> None:
|
||||||
|
if _has_symlink_component(path):
|
||||||
|
raise ValueError(f"evidence directory cannot contain a symlinked component: {path}")
|
||||||
|
if path.exists():
|
||||||
|
if (
|
||||||
|
path.is_symlink()
|
||||||
|
or not path.is_dir()
|
||||||
|
or path.stat().st_uid != os.geteuid()
|
||||||
|
or stat_mode(path) != 0o700
|
||||||
|
):
|
||||||
|
raise ValueError(f"evidence directory must be caller-owned mode-0700: {path}")
|
||||||
|
return
|
||||||
|
missing: list[Path] = []
|
||||||
|
current = path
|
||||||
|
while not current.exists():
|
||||||
|
missing.append(current)
|
||||||
|
current = current.parent
|
||||||
|
if (
|
||||||
|
current.is_symlink()
|
||||||
|
or not current.is_dir()
|
||||||
|
or current.stat().st_uid != os.geteuid()
|
||||||
|
or stat_mode(current) != 0o700
|
||||||
|
):
|
||||||
|
raise ValueError(f"evidence parent must be caller-owned mode-0700: {current}")
|
||||||
|
for directory in reversed(missing):
|
||||||
|
directory.mkdir(mode=0o700)
|
||||||
|
os.chmod(directory, 0o700)
|
||||||
|
|
||||||
|
|
||||||
|
def _private_directory_fd(path: Path) -> int:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = os.open(path, flags)
|
||||||
|
directory_stat = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISDIR(directory_stat.st_mode)
|
||||||
|
or stat.S_IMODE(directory_stat.st_mode) != 0o700
|
||||||
|
or directory_stat.st_uid != os.geteuid()
|
||||||
|
):
|
||||||
|
os.close(descriptor)
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"evidence directory is not caller-owned mode-0700: {path}"
|
||||||
|
)
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_tree_at(
|
||||||
|
parent_fd: int,
|
||||||
|
name: str,
|
||||||
|
expected_identity: tuple[int, int],
|
||||||
|
) -> None:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
directory_fd = os.open(name, flags, dir_fd=parent_fd)
|
||||||
|
try:
|
||||||
|
directory_stat = os.fstat(directory_fd)
|
||||||
|
if (directory_stat.st_dev, directory_stat.st_ino) != expected_identity:
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"evidence directory identity changed before removal: {name}"
|
||||||
|
)
|
||||||
|
for child in os.listdir(directory_fd):
|
||||||
|
child_stat = os.stat(child, dir_fd=directory_fd, follow_symlinks=False)
|
||||||
|
if stat.S_ISDIR(child_stat.st_mode):
|
||||||
|
_remove_tree_at(
|
||||||
|
directory_fd,
|
||||||
|
child,
|
||||||
|
(child_stat.st_dev, child_stat.st_ino),
|
||||||
|
)
|
||||||
|
elif stat.S_ISREG(child_stat.st_mode):
|
||||||
|
os.unlink(child, dir_fd=directory_fd)
|
||||||
|
else:
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"unsafe entry in expired evidence directory: {name}/{child}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
final_stat = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
||||||
|
if (final_stat.st_dev, final_stat.st_ino) != expected_identity:
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"evidence directory identity changed before final removal: {name}"
|
||||||
|
)
|
||||||
|
os.rmdir(name, dir_fd=parent_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: Path, payload: bytes) -> None:
|
||||||
|
directory_fd = _private_directory_fd(path.parent)
|
||||||
|
temporary_name = f".{path.name}.{secrets.token_hex(8)}"
|
||||||
|
descriptor = -1
|
||||||
|
try:
|
||||||
|
descriptor = os.open(
|
||||||
|
temporary_name,
|
||||||
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||||
|
0o600,
|
||||||
|
dir_fd=directory_fd,
|
||||||
|
)
|
||||||
|
written = 0
|
||||||
|
while written < len(payload):
|
||||||
|
written += os.write(descriptor, payload[written:])
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
os.fsync(descriptor)
|
||||||
|
os.close(descriptor)
|
||||||
|
descriptor = -1
|
||||||
|
os.rename(
|
||||||
|
temporary_name,
|
||||||
|
path.name,
|
||||||
|
src_dir_fd=directory_fd,
|
||||||
|
dst_dir_fd=directory_fd,
|
||||||
|
)
|
||||||
|
os.fsync(directory_fd)
|
||||||
|
finally:
|
||||||
|
if descriptor != -1:
|
||||||
|
os.close(descriptor)
|
||||||
|
try:
|
||||||
|
os.unlink(temporary_name, dir_fd=directory_fd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
os.close(directory_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_nofollow_at(directory_fd: int, name: str, display: str) -> bytes:
|
||||||
|
flags = (
|
||||||
|
os.O_RDONLY
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
| getattr(os, "O_NONBLOCK", 0)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(name, flags, dir_fd=directory_fd)
|
||||||
|
except OSError as error:
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
f"cannot read retained evidence {display}: {error}"
|
||||||
|
) from error
|
||||||
|
try:
|
||||||
|
file_stat = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
not stat.S_ISREG(file_stat.st_mode)
|
||||||
|
or stat.S_IMODE(file_stat.st_mode) != 0o600
|
||||||
|
or file_stat.st_uid != os.geteuid()
|
||||||
|
):
|
||||||
|
raise EvidenceValidationError(
|
||||||
|
"retained evidence is not a caller-owned mode-0600 regular file: "
|
||||||
|
+ display
|
||||||
|
)
|
||||||
|
remaining = file_stat.st_size
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
while remaining:
|
||||||
|
chunk = os.read(descriptor, remaining)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
validate_evidence_summary = validate_summary
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EvidenceStore",
|
||||||
|
"EvidenceValidationError",
|
||||||
|
"RawEvidenceRef",
|
||||||
|
"RETENTION_DAYS",
|
||||||
|
"SCHEMA_VERSION",
|
||||||
|
"create_summary",
|
||||||
|
"sha256_bytes",
|
||||||
|
"sha256_json",
|
||||||
|
"utc_timestamp",
|
||||||
|
"validate_evidence_summary",
|
||||||
|
"validate_summary",
|
||||||
|
]
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Hermetic filesystem and environment fixtures for the Nono evidence tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
_CREDENTIAL_NAME_HINTS = (
|
||||||
|
"API_KEY",
|
||||||
|
"TOKEN",
|
||||||
|
"SECRET",
|
||||||
|
"PASSWORD",
|
||||||
|
"PRIVATE_KEY",
|
||||||
|
"CREDENTIAL",
|
||||||
|
"SSH_AUTH_SOCK",
|
||||||
|
"AWS_ACCESS_KEY_ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mode(path: Path, permissions: int) -> None:
|
||||||
|
path.mkdir(parents=True, exist_ok=True, mode=permissions)
|
||||||
|
os.chmod(path, permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_executable(path: Path, content: str) -> None:
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FixtureSession:
|
||||||
|
"""Paths belonging to one disposable, independent session."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
root: Path
|
||||||
|
home: Path
|
||||||
|
state: Path
|
||||||
|
cache: Path
|
||||||
|
runtime: Path
|
||||||
|
tmp: Path
|
||||||
|
repo: Path
|
||||||
|
|
||||||
|
def environment(self, base: Mapping[str, str]) -> dict[str, str]:
|
||||||
|
"""Return a minimal environment whose state paths point at this session."""
|
||||||
|
return _minimal_environment(
|
||||||
|
base,
|
||||||
|
home=self.home,
|
||||||
|
config=self.home / ".config",
|
||||||
|
state=self.state,
|
||||||
|
cache=self.cache,
|
||||||
|
runtime=self.runtime,
|
||||||
|
tmp=self.tmp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IsolatedFixture:
|
||||||
|
"""Context-managed fixture with no dependency on or mutation of host state."""
|
||||||
|
|
||||||
|
def __init__(self, *, prefix: str = "nono-evidence-") -> None:
|
||||||
|
self._temporary: tempfile.TemporaryDirectory[str] | None = None
|
||||||
|
self.prefix = prefix
|
||||||
|
self.root: Path | None = None
|
||||||
|
self.home: Path | None = None
|
||||||
|
self.xdg_config_home: Path | None = None
|
||||||
|
self.xdg_state_home: Path | None = None
|
||||||
|
self.xdg_cache_home: Path | None = None
|
||||||
|
self.xdg_runtime_dir: Path | None = None
|
||||||
|
self.tmpdir: Path | None = None
|
||||||
|
self.repos: Path | None = None
|
||||||
|
self.sessions: Path | None = None
|
||||||
|
self.stub_bin: Path | None = None
|
||||||
|
self.env: dict[str, str] = {}
|
||||||
|
self._session_map: dict[str, FixtureSession] = {}
|
||||||
|
|
||||||
|
def __enter__(self) -> "IsolatedFixture":
|
||||||
|
self._temporary = tempfile.TemporaryDirectory(prefix=self.prefix)
|
||||||
|
self.root = Path(self._temporary.name)
|
||||||
|
os.chmod(self.root, 0o700)
|
||||||
|
|
||||||
|
self.home = self.root / "home"
|
||||||
|
self.xdg_config_home = self.root / "xdg-config"
|
||||||
|
self.xdg_state_home = self.root / "xdg-state"
|
||||||
|
self.xdg_cache_home = self.root / "xdg-cache"
|
||||||
|
self.xdg_runtime_dir = self.root / "xdg-runtime"
|
||||||
|
self.tmpdir = self.root / "tmp"
|
||||||
|
self.repos = self.root / "repos"
|
||||||
|
self.sessions = self.root / "sessions"
|
||||||
|
self.stub_bin = self.root / "bin"
|
||||||
|
for directory in (
|
||||||
|
self.home,
|
||||||
|
self.xdg_config_home,
|
||||||
|
self.xdg_state_home,
|
||||||
|
self.xdg_cache_home,
|
||||||
|
self.xdg_runtime_dir,
|
||||||
|
self.tmpdir,
|
||||||
|
self.repos,
|
||||||
|
self.sessions,
|
||||||
|
self.stub_bin,
|
||||||
|
):
|
||||||
|
_mode(directory, 0o700)
|
||||||
|
|
||||||
|
self.env = _minimal_environment(
|
||||||
|
os.environ,
|
||||||
|
home=self.home,
|
||||||
|
config=self.xdg_config_home,
|
||||||
|
state=self.xdg_state_home,
|
||||||
|
cache=self.xdg_cache_home,
|
||||||
|
runtime=self.xdg_runtime_dir,
|
||||||
|
tmp=self.tmpdir,
|
||||||
|
stub_bin=self.stub_bin,
|
||||||
|
)
|
||||||
|
self._create_fixture_repositories()
|
||||||
|
self._create_stub_bin()
|
||||||
|
self.new_session("one")
|
||||||
|
self.new_session("two")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_: object) -> None:
|
||||||
|
if self._temporary is not None:
|
||||||
|
self._temporary.cleanup()
|
||||||
|
self._temporary = None
|
||||||
|
self.root = None
|
||||||
|
|
||||||
|
def new_session(self, name: str) -> FixtureSession:
|
||||||
|
"""Create (or return) a distinct set of state and scratch directories."""
|
||||||
|
if self.sessions is None or self.repos is None:
|
||||||
|
raise RuntimeError("fixture is not active")
|
||||||
|
if not name or name in {".", ".."} or "/" in name:
|
||||||
|
raise ValueError("session names must be simple path components")
|
||||||
|
if name in self._session_map:
|
||||||
|
return self._session_map[name]
|
||||||
|
root = self.sessions / name
|
||||||
|
home = root / "home"
|
||||||
|
state = root / "state"
|
||||||
|
cache = root / "cache"
|
||||||
|
runtime = root / "runtime"
|
||||||
|
tmp = root / "tmp"
|
||||||
|
repo = self.repos / name
|
||||||
|
for directory in (root, home, home / ".config", state, cache, runtime, tmp, repo):
|
||||||
|
_mode(directory, 0o700)
|
||||||
|
session = FixtureSession(name, root, home, state, cache, runtime, tmp, repo)
|
||||||
|
self._session_map[name] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
def session(self, name: str) -> FixtureSession:
|
||||||
|
if name not in self._session_map:
|
||||||
|
return self.new_session(name)
|
||||||
|
return self._session_map[name]
|
||||||
|
|
||||||
|
def path(self, *parts: str) -> Path:
|
||||||
|
if self.root is None:
|
||||||
|
raise RuntimeError("fixture is not active")
|
||||||
|
return self.root.joinpath(*parts)
|
||||||
|
|
||||||
|
def _create_fixture_repositories(self) -> None:
|
||||||
|
assert self.repos is not None
|
||||||
|
for path in (
|
||||||
|
self.repos / "primary",
|
||||||
|
self.repos / "sibling-worktree",
|
||||||
|
self.repos / "unrelated",
|
||||||
|
):
|
||||||
|
_mode(path, 0o700)
|
||||||
|
(self.repos / "primary" / "README.txt").write_text(
|
||||||
|
"fixture repository\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(self.repos / "sibling-worktree" / "README.txt").write_text(
|
||||||
|
"sibling fixture\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_stub_bin(self) -> None:
|
||||||
|
assert self.stub_bin is not None
|
||||||
|
_write_executable(
|
||||||
|
self.stub_bin / "safe-probe",
|
||||||
|
"#!/bin/sh\nprintf '%s\\n' safe-probe\n",
|
||||||
|
)
|
||||||
|
_write_executable(
|
||||||
|
self.stub_bin / "credential-name-probe",
|
||||||
|
"#!/bin/sh\nprintf '%s\\n' GITHUB_TOKEN\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_environment(
|
||||||
|
inherited: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
home: Path,
|
||||||
|
config: Path,
|
||||||
|
state: Path,
|
||||||
|
cache: Path,
|
||||||
|
runtime: Path,
|
||||||
|
tmp: Path,
|
||||||
|
stub_bin: Path | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Build an allowlist environment; no inherited credential-like names survive."""
|
||||||
|
path = f"{stub_bin}:/usr/bin:/bin" if stub_bin is not None else "/usr/bin:/bin"
|
||||||
|
environment = {
|
||||||
|
"HOME": str(home),
|
||||||
|
"XDG_CONFIG_HOME": str(config),
|
||||||
|
"XDG_STATE_HOME": str(state),
|
||||||
|
"XDG_CACHE_HOME": str(cache),
|
||||||
|
"XDG_RUNTIME_DIR": str(runtime),
|
||||||
|
"TMPDIR": str(tmp),
|
||||||
|
"PATH": path,
|
||||||
|
"LANG": inherited.get("LANG", "C"),
|
||||||
|
"LC_ALL": inherited.get("LC_ALL", "C"),
|
||||||
|
"PYTHONDONTWRITEBYTECODE": "1",
|
||||||
|
}
|
||||||
|
# Deliberately construct this mapping instead of copying os.environ. Keep this
|
||||||
|
# assertion close to the allowlist so a newly added variable is reviewed.
|
||||||
|
for name in environment:
|
||||||
|
upper = name.upper()
|
||||||
|
if any(hint in upper for hint in _CREDENTIAL_NAME_HINTS):
|
||||||
|
raise AssertionError(f"credential-like variable accidentally allowlisted: {name}")
|
||||||
|
return environment
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def isolated_environment(prefix: str = "nono-evidence-") -> Iterator[IsolatedFixture]:
|
||||||
|
"""Yield an :class:`IsolatedFixture` and clean every path on exit."""
|
||||||
|
with IsolatedFixture(prefix=prefix) as fixture:
|
||||||
|
yield fixture
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FixtureSession",
|
||||||
|
"IsolatedFixture",
|
||||||
|
"isolated_environment",
|
||||||
|
]
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""Small, argv-only adapter around the installed Nono executable."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import BinaryIO, Iterable, Iterator, Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
_VERSION_RE = re.compile(r"\bnono\s+v?(\d+\.\d+\.\d+)\b", re.IGNORECASE)
|
||||||
|
_REQUIRED_PATH_ENV = (
|
||||||
|
"HOME",
|
||||||
|
"XDG_CONFIG_HOME",
|
||||||
|
"XDG_STATE_HOME",
|
||||||
|
"XDG_CACHE_HOME",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
|
"TMPDIR",
|
||||||
|
)
|
||||||
|
_ALLOWED_ENVIRONMENT = frozenset(
|
||||||
|
{
|
||||||
|
*_REQUIRED_PATH_ENV,
|
||||||
|
"PATH",
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"PYTHONUNBUFFERED",
|
||||||
|
"PYTHONDONTWRITEBYTECODE",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NonoError(RuntimeError):
|
||||||
|
"""Base error for adapter and host-gate failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class HostRequirementError(NonoError):
|
||||||
|
"""The mandatory host gate cannot be satisfied."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandResult:
|
||||||
|
argv: tuple[str, ...]
|
||||||
|
returncode: int | None
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
timed_out: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.returncode == 0 and not self.timed_out
|
||||||
|
|
||||||
|
|
||||||
|
class NonoAdapter:
|
||||||
|
"""Execute only known Nono subcommands with an injected absolute executable.
|
||||||
|
|
||||||
|
The adapter never writes to a profile directory. Profile arguments are always
|
||||||
|
absolute JSON paths, which also makes it impossible for a test to accidentally
|
||||||
|
select an active profile by name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
executable: str | os.PathLike[str],
|
||||||
|
cwd: str | os.PathLike[str],
|
||||||
|
timeout: float = 20.0,
|
||||||
|
max_output: int = 64 * 1024,
|
||||||
|
environment: Mapping[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
executable_path = Path(executable)
|
||||||
|
cwd_path = Path(cwd)
|
||||||
|
if not executable_path.is_absolute():
|
||||||
|
raise ValueError("nono executable must be an absolute path")
|
||||||
|
if not cwd_path.is_absolute():
|
||||||
|
raise ValueError("adapter cwd must be an absolute path")
|
||||||
|
if timeout <= 0:
|
||||||
|
raise ValueError("timeout must be positive")
|
||||||
|
if max_output < 1:
|
||||||
|
raise ValueError("max_output must be positive")
|
||||||
|
self.executable = str(executable_path)
|
||||||
|
self.cwd = str(cwd_path)
|
||||||
|
self.timeout = timeout
|
||||||
|
if environment is None:
|
||||||
|
raise ValueError("an explicit isolated environment is required")
|
||||||
|
self.max_output = max_output
|
||||||
|
self.environment = _validated_environment(environment)
|
||||||
|
|
||||||
|
def version(self) -> CommandResult:
|
||||||
|
return self._execute((self.executable, "--version"))
|
||||||
|
|
||||||
|
def require_host_0_74(self) -> str:
|
||||||
|
"""Require Linux and an installed Nono in the exact 0.74 release line."""
|
||||||
|
if platform.system() != "Linux":
|
||||||
|
raise HostRequirementError("mandatory Nono host checks require Linux")
|
||||||
|
if not os.path.isfile(self.executable) or not os.access(self.executable, os.X_OK):
|
||||||
|
raise HostRequirementError(f"Nono executable is unavailable: {self.executable}")
|
||||||
|
try:
|
||||||
|
result = self.version()
|
||||||
|
except (OSError, subprocess.SubprocessError) as error:
|
||||||
|
raise HostRequirementError(f"could not execute Nono: {error}") from error
|
||||||
|
if not result.ok:
|
||||||
|
raise HostRequirementError(
|
||||||
|
f"Nono version check failed (exit {result.returncode}): {result.stderr}"
|
||||||
|
)
|
||||||
|
match = _VERSION_RE.search(result.stdout + "\n" + result.stderr)
|
||||||
|
if match is None or not match.group(1).startswith("0.74."):
|
||||||
|
observed = (result.stdout + result.stderr).strip() or "unknown"
|
||||||
|
raise HostRequirementError(
|
||||||
|
f"mandatory host checks require Nono 0.74.x; observed {observed!r}"
|
||||||
|
)
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
def validate(self, profile: str | os.PathLike[str], *, strict: bool = True) -> CommandResult:
|
||||||
|
with self._profile_snapshot(profile) as profile_path:
|
||||||
|
command = [self.executable, "profile", "validate"]
|
||||||
|
if strict:
|
||||||
|
command.append("--strict")
|
||||||
|
command.append(str(profile_path))
|
||||||
|
return self._execute(command)
|
||||||
|
|
||||||
|
def show(self, profile: str | os.PathLike[str]) -> CommandResult:
|
||||||
|
with self._profile_snapshot(profile) as profile_path:
|
||||||
|
return self._execute(
|
||||||
|
[self.executable, "profile", "show", str(profile_path), "--json"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
profile: str | os.PathLike[str],
|
||||||
|
command: Sequence[str | os.PathLike[str]],
|
||||||
|
*,
|
||||||
|
allow_cwd: bool = False,
|
||||||
|
) -> CommandResult:
|
||||||
|
if isinstance(command, (str, bytes)):
|
||||||
|
raise TypeError("sandbox command must be an argv sequence, not a shell string")
|
||||||
|
command_args = [os.fspath(argument) for argument in command]
|
||||||
|
if not command_args or any(not isinstance(argument, str) for argument in command_args):
|
||||||
|
raise ValueError("sandbox command must contain at least one argv string")
|
||||||
|
with self._profile_snapshot(profile) as profile_path:
|
||||||
|
nono_args = [self.executable, "run", "--profile", str(profile_path)]
|
||||||
|
if allow_cwd:
|
||||||
|
nono_args.append("--allow-cwd")
|
||||||
|
return self._execute([*nono_args, "--", *command_args])
|
||||||
|
|
||||||
|
def validate_temporary_profile(
|
||||||
|
self, profile: str | os.PathLike[str], *, strict: bool = True
|
||||||
|
) -> CommandResult:
|
||||||
|
return self.validate(profile, strict=strict)
|
||||||
|
|
||||||
|
def show_temporary_profile(self, profile: str | os.PathLike[str]) -> CommandResult:
|
||||||
|
return self.show(profile)
|
||||||
|
|
||||||
|
def run_temporary_profile(
|
||||||
|
self,
|
||||||
|
profile: str | os.PathLike[str],
|
||||||
|
command: Sequence[str | os.PathLike[str]],
|
||||||
|
*,
|
||||||
|
allow_cwd: bool = False,
|
||||||
|
) -> CommandResult:
|
||||||
|
return self.run(
|
||||||
|
profile,
|
||||||
|
command,
|
||||||
|
allow_cwd=allow_cwd,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _open_temporary_profile(
|
||||||
|
self,
|
||||||
|
profile: str | os.PathLike[str],
|
||||||
|
) -> tuple[Path, int]:
|
||||||
|
path = Path(os.path.normpath(os.fspath(profile)))
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ValueError("temporary profile must be an absolute path")
|
||||||
|
if path.suffix != ".json":
|
||||||
|
raise ValueError("temporary profile must be a JSON file")
|
||||||
|
if _has_symlink_component(path):
|
||||||
|
raise ValueError("temporary profile cannot contain a symlinked path component")
|
||||||
|
active_roots = self._active_profile_roots()
|
||||||
|
if any(path.parent == root or root in path.parents for root in active_roots):
|
||||||
|
raise ValueError("active Nono profile namespace is not permitted")
|
||||||
|
try:
|
||||||
|
descriptor = _open_path_nofollow(path)
|
||||||
|
except OSError as error:
|
||||||
|
raise ValueError(f"temporary profile is unavailable: {error}") from error
|
||||||
|
file_stat = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(file_stat.st_mode):
|
||||||
|
os.close(descriptor)
|
||||||
|
raise ValueError("temporary profile must be a regular file")
|
||||||
|
return path, descriptor
|
||||||
|
|
||||||
|
def _active_profile_roots(self) -> set[Path]:
|
||||||
|
configured_home = self.environment["HOME"]
|
||||||
|
return {
|
||||||
|
(Path.home() / ".config" / "nono" / "profiles").resolve(),
|
||||||
|
(
|
||||||
|
Path(os.environ.get("HOME", str(Path.home())))
|
||||||
|
/ ".config"
|
||||||
|
/ "nono"
|
||||||
|
/ "profiles"
|
||||||
|
).resolve(),
|
||||||
|
(Path(configured_home) / ".config" / "nono" / "profiles").resolve(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _profile_snapshot(
|
||||||
|
self,
|
||||||
|
profile: str | os.PathLike[str],
|
||||||
|
) -> Iterator[Path]:
|
||||||
|
_source, descriptor = self._open_temporary_profile(profile)
|
||||||
|
try:
|
||||||
|
source_stat = os.fstat(descriptor)
|
||||||
|
if source_stat.st_size > 1024 * 1024:
|
||||||
|
raise ValueError("temporary profile must be a regular file no larger than 1 MiB")
|
||||||
|
for active_root in self._active_profile_roots():
|
||||||
|
if not active_root.is_dir():
|
||||||
|
continue
|
||||||
|
for active_profile in active_root.glob("*.json"):
|
||||||
|
try:
|
||||||
|
active_stat = active_profile.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if (active_stat.st_dev, active_stat.st_ino) == (
|
||||||
|
source_stat.st_dev,
|
||||||
|
source_stat.st_ino,
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"temporary profile cannot alias an active Nono profile"
|
||||||
|
)
|
||||||
|
payload = b""
|
||||||
|
while len(payload) <= source_stat.st_size:
|
||||||
|
chunk = os.read(descriptor, min(65536, source_stat.st_size + 1 - len(payload)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
payload += chunk
|
||||||
|
final_stat = os.fstat(descriptor)
|
||||||
|
if (
|
||||||
|
len(payload) != source_stat.st_size
|
||||||
|
or final_stat.st_size != source_stat.st_size
|
||||||
|
or final_stat.st_mtime_ns != source_stat.st_mtime_ns
|
||||||
|
):
|
||||||
|
raise ValueError("temporary profile changed while it was being read")
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
scratch_root = Path(self.environment["TMPDIR"])
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nono-profile-", dir=scratch_root) as directory:
|
||||||
|
os.chmod(directory, 0o700)
|
||||||
|
snapshot = Path(directory) / "profile.json"
|
||||||
|
snapshot.write_bytes(payload)
|
||||||
|
os.chmod(snapshot, 0o600)
|
||||||
|
yield snapshot
|
||||||
|
|
||||||
|
def _execute(self, argv: Iterable[str]) -> CommandResult:
|
||||||
|
return run_bounded_command(
|
||||||
|
argv,
|
||||||
|
cwd=self.cwd,
|
||||||
|
environment=self.environment,
|
||||||
|
timeout=self.timeout,
|
||||||
|
max_output=self.max_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_bounded_command(
|
||||||
|
argv: Iterable[str],
|
||||||
|
*,
|
||||||
|
cwd: str | os.PathLike[str],
|
||||||
|
environment: Mapping[str, str],
|
||||||
|
timeout: float,
|
||||||
|
max_output: int,
|
||||||
|
) -> CommandResult:
|
||||||
|
argument_list = tuple(str(value) for value in argv)
|
||||||
|
if timeout <= 0 or max_output <= 0:
|
||||||
|
raise ValueError("bounded commands require positive timeout and output limits")
|
||||||
|
cwd_path = Path(cwd)
|
||||||
|
if not cwd_path.is_absolute():
|
||||||
|
raise ValueError("bounded command cwd must be absolute")
|
||||||
|
selected_environment = _validated_environment(environment)
|
||||||
|
buffers = {"stdout": bytearray(), "stderr": bytearray()}
|
||||||
|
|
||||||
|
def drain(stream: BinaryIO, name: str) -> None:
|
||||||
|
with stream:
|
||||||
|
while chunk := stream.read(65536):
|
||||||
|
remaining = max_output - len(buffers[name])
|
||||||
|
if remaining > 0:
|
||||||
|
buffers[name].extend(chunk[:remaining])
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
argument_list,
|
||||||
|
cwd=os.fspath(cwd),
|
||||||
|
env=selected_environment,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
return CommandResult(
|
||||||
|
argument_list,
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
_bounded_text(str(error), max_output),
|
||||||
|
)
|
||||||
|
assert process.stdout is not None and process.stderr is not None
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=drain, args=(process.stdout, "stdout"), daemon=True),
|
||||||
|
threading.Thread(target=drain, args=(process.stderr, "stderr"), daemon=True),
|
||||||
|
]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
timed_out = False
|
||||||
|
try:
|
||||||
|
process.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
timed_out = True
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
raise NonoError("timed-out process group did not terminate") from error
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=5)
|
||||||
|
if thread.is_alive():
|
||||||
|
raise NonoError("subprocess output pipe did not close")
|
||||||
|
return CommandResult(
|
||||||
|
argument_list,
|
||||||
|
None if timed_out else process.returncode,
|
||||||
|
bytes(buffers["stdout"]).decode("utf-8", errors="replace"),
|
||||||
|
bytes(buffers["stderr"]).decode("utf-8", errors="replace"),
|
||||||
|
timed_out,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_text(value: str, limit: int) -> str:
|
||||||
|
if len(value) <= limit:
|
||||||
|
return value
|
||||||
|
return value[: max(0, limit - 3)] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_environment(environment: Mapping[str, str]) -> dict[str, str]:
|
||||||
|
selected = dict(environment)
|
||||||
|
for name in _REQUIRED_PATH_ENV:
|
||||||
|
value = selected.get(name)
|
||||||
|
if not value or not Path(value).is_absolute():
|
||||||
|
raise ValueError(f"the isolated environment requires an absolute {name}")
|
||||||
|
path = Path(value)
|
||||||
|
if _has_symlink_component(path):
|
||||||
|
raise ValueError(f"the isolated environment forbids symlinked {name}")
|
||||||
|
try:
|
||||||
|
path_stat = path.stat()
|
||||||
|
except OSError as error:
|
||||||
|
raise ValueError(f"the isolated environment path is unavailable: {name}") from error
|
||||||
|
if (
|
||||||
|
not stat.S_ISDIR(path_stat.st_mode)
|
||||||
|
or stat.S_IMODE(path_stat.st_mode) != 0o700
|
||||||
|
or path_stat.st_uid != os.geteuid()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"the isolated environment requires caller-owned mode-0700 {name}"
|
||||||
|
)
|
||||||
|
unknown = sorted(set(selected) - _ALLOWED_ENVIRONMENT)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
"environment variable is not in the isolated allowlist: " + unknown[0]
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _open_path_nofollow(path: Path) -> int:
|
||||||
|
if not path.is_absolute() or not path.name:
|
||||||
|
raise ValueError("profile path must identify an absolute file")
|
||||||
|
directory_flags = (
|
||||||
|
os.O_RDONLY
|
||||||
|
| getattr(os, "O_DIRECTORY", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
)
|
||||||
|
directory_fd = os.open(path.anchor, directory_flags)
|
||||||
|
try:
|
||||||
|
for component in path.parts[1:-1]:
|
||||||
|
next_fd = os.open(component, directory_flags, dir_fd=directory_fd)
|
||||||
|
os.close(directory_fd)
|
||||||
|
directory_fd = next_fd
|
||||||
|
return os.open(
|
||||||
|
path.name,
|
||||||
|
os.O_RDONLY
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
| getattr(os, "O_NONBLOCK", 0),
|
||||||
|
dir_fd=directory_fd,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_symlink_component(path: Path) -> bool:
|
||||||
|
current = path
|
||||||
|
while current.parent != current:
|
||||||
|
if current.is_symlink():
|
||||||
|
return True
|
||||||
|
current = current.parent
|
||||||
|
return current.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CommandResult",
|
||||||
|
"HostRequirementError",
|
||||||
|
"NonoAdapter",
|
||||||
|
"NonoError",
|
||||||
|
"run_bounded_command",
|
||||||
|
]
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Recursive redaction and leak checks for evidence values."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any, Iterable, Pattern
|
||||||
|
|
||||||
|
|
||||||
|
_REDACTED = "[REDACTED]"
|
||||||
|
_REDACTED_NAME = "[REDACTED_CREDENTIAL_NAME]"
|
||||||
|
DEFAULT_CREDENTIAL_NAME_PATTERNS = (
|
||||||
|
r"(?<![A-Za-z0-9])OPENAI_API_KEY(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])ANTHROPIC_API_KEY(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])MORPH_API_KEY(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])GITHUB_TOKEN(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])GH_TOKEN(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])SSH_AUTH_SOCK(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])AWS_(?:ACCESS_KEY_ID|SECRET_ACCESS_KEY)(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])(?:API_KEY|ACCESS_TOKEN|REFRESH_TOKEN|PRIVATE_KEY|PASSWORD|SECRET)(?![A-Za-z0-9])",
|
||||||
|
)
|
||||||
|
# This expression is deliberately applied to mapping keys only. Thus ordinary
|
||||||
|
# fields such as "status" and "provider" remain untouched.
|
||||||
|
_DEFAULT_KEY_PATTERN = re.compile(
|
||||||
|
r"^(?:[A-Z][A-Z0-9]*(?:_|-))*"
|
||||||
|
r"(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|PRIVATE[_-]?KEY|CREDENTIAL|SSH[_-]?AUTH[_-]?SOCK)$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RedactionError(ValueError):
|
||||||
|
"""A value cannot be safely included in a committed summary."""
|
||||||
|
|
||||||
|
|
||||||
|
def _patterns(patterns: Iterable[str | Pattern[str]] | None) -> tuple[Pattern[str], ...]:
|
||||||
|
selected = (*DEFAULT_CREDENTIAL_NAME_PATTERNS, *(patterns or ()))
|
||||||
|
result: list[Pattern[str]] = []
|
||||||
|
for pattern in selected:
|
||||||
|
result.append(re.compile(pattern) if isinstance(pattern, str) else pattern)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_credential_name(key: object, patterns: tuple[Pattern[str], ...]) -> bool:
|
||||||
|
if not isinstance(key, str):
|
||||||
|
return False
|
||||||
|
if _DEFAULT_KEY_PATTERN.fullmatch(key):
|
||||||
|
return True
|
||||||
|
return any(pattern.fullmatch(key) is not None for pattern in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_text(
|
||||||
|
value: str,
|
||||||
|
canaries: tuple[str, ...],
|
||||||
|
credential_patterns: tuple[Pattern[str], ...],
|
||||||
|
) -> str:
|
||||||
|
# Protect our own marker while applying user-supplied patterns. A pattern
|
||||||
|
# such as ``CREDENTIAL`` must not recursively redact the marker itself.
|
||||||
|
marker_sentinel = "\x00NONO_REDACTED_CREDENTIAL_NAME\x00"
|
||||||
|
result = value.replace(_REDACTED_NAME, marker_sentinel)
|
||||||
|
# Longer values first prevents a short canary from obscuring a longer one in
|
||||||
|
# the common case where one test fixture intentionally nests both.
|
||||||
|
for canary in sorted((item for item in canaries if item), key=len, reverse=True):
|
||||||
|
result = result.replace(canary, _REDACTED)
|
||||||
|
for pattern in credential_patterns:
|
||||||
|
result = pattern.sub(_REDACTED_NAME, result)
|
||||||
|
return result.replace(marker_sentinel, _REDACTED_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def redact(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
canaries: Iterable[str] = (),
|
||||||
|
credential_name_patterns: Iterable[str | Pattern[str]] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Return a recursively redacted copy of JSON-like evidence.
|
||||||
|
|
||||||
|
Credential patterns match mapping keys in full, and match credential-name
|
||||||
|
tokens in free text. This distinction protects safe field names from an
|
||||||
|
over-broad substring replacement.
|
||||||
|
"""
|
||||||
|
canary_values = tuple(str(item) for item in canaries if str(item))
|
||||||
|
patterns = _patterns(credential_name_patterns)
|
||||||
|
|
||||||
|
def visit(item: Any) -> Any:
|
||||||
|
if isinstance(item, Mapping):
|
||||||
|
result: dict[Any, Any] = {}
|
||||||
|
for key, child in item.items():
|
||||||
|
if _is_credential_name(key, patterns):
|
||||||
|
result[_REDACTED_NAME] = _REDACTED
|
||||||
|
else:
|
||||||
|
safe_key = visit(key) if isinstance(key, (str, int, float, bool)) else str(key)
|
||||||
|
result[safe_key] = visit(child)
|
||||||
|
return result
|
||||||
|
if isinstance(item, list):
|
||||||
|
return [visit(child) for child in item]
|
||||||
|
if isinstance(item, tuple):
|
||||||
|
return [visit(child) for child in item]
|
||||||
|
if isinstance(item, set):
|
||||||
|
return [visit(child) for child in sorted(item, key=str)]
|
||||||
|
if isinstance(item, BaseException):
|
||||||
|
return _redact_text(str(item), canary_values, patterns)
|
||||||
|
if isinstance(item, str):
|
||||||
|
return _redact_text(item, canary_values, patterns)
|
||||||
|
return item
|
||||||
|
|
||||||
|
return visit(value)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_for_leaks(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
canaries: Iterable[str] = (),
|
||||||
|
credential_name_patterns: Iterable[str | Pattern[str]] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return JSON-style paths where an unredacted canary/name is present."""
|
||||||
|
canary_values = tuple(str(item) for item in canaries if str(item))
|
||||||
|
patterns = _patterns(credential_name_patterns)
|
||||||
|
leaks: list[str] = []
|
||||||
|
|
||||||
|
def inspect(item: Any, path: str) -> None:
|
||||||
|
if isinstance(item, Mapping):
|
||||||
|
for key, child in item.items():
|
||||||
|
key_path = f"{path}.{key}" if path else str(key)
|
||||||
|
if _is_credential_name(key, patterns):
|
||||||
|
leaks.append(key_path)
|
||||||
|
if isinstance(key, str) and any(
|
||||||
|
canary and canary in key for canary in canary_values
|
||||||
|
):
|
||||||
|
leaks.append(key_path)
|
||||||
|
inspect(child, key_path)
|
||||||
|
return
|
||||||
|
if isinstance(item, (list, tuple, set)):
|
||||||
|
for index, child in enumerate(item):
|
||||||
|
inspect(child, f"{path}[{index}]")
|
||||||
|
return
|
||||||
|
if isinstance(item, BaseException):
|
||||||
|
inspect(str(item), path)
|
||||||
|
return
|
||||||
|
if isinstance(item, str):
|
||||||
|
if any(canary and canary in item for canary in canary_values):
|
||||||
|
leaks.append(path or "$")
|
||||||
|
# Do not rediscover the marker emitted by this module when a
|
||||||
|
# generic configured pattern includes the word CREDENTIAL.
|
||||||
|
scan_text = item.replace(_REDACTED_NAME, "")
|
||||||
|
if any(pattern.search(scan_text) is not None for pattern in patterns):
|
||||||
|
leaks.append(path or "$")
|
||||||
|
|
||||||
|
inspect(value, "")
|
||||||
|
return leaks
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_leaks(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
canaries: Iterable[str] = (),
|
||||||
|
credential_name_patterns: Iterable[str | Pattern[str]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
leaks = scan_for_leaks(
|
||||||
|
value,
|
||||||
|
canaries=canaries,
|
||||||
|
credential_name_patterns=credential_name_patterns,
|
||||||
|
)
|
||||||
|
if leaks:
|
||||||
|
raise RedactionError("unredacted evidence at " + ", ".join(leaks))
|
||||||
|
|
||||||
|
|
||||||
|
# Explicit aliases make call sites read naturally and preserve a stable harness API.
|
||||||
|
redact_recursive = redact
|
||||||
|
redact_evidence = redact
|
||||||
|
leak_scan = scan_for_leaks
|
||||||
|
assert_clean = assert_no_leaks
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_CREDENTIAL_NAME_PATTERNS",
|
||||||
|
"RedactionError",
|
||||||
|
"assert_clean",
|
||||||
|
"assert_no_leaks",
|
||||||
|
"leak_scan",
|
||||||
|
"redact",
|
||||||
|
"redact_evidence",
|
||||||
|
"redact_recursive",
|
||||||
|
"scan_for_leaks",
|
||||||
|
]
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .evidence import (
|
||||||
|
EvidenceStore,
|
||||||
|
EvidenceValidationError,
|
||||||
|
create_summary,
|
||||||
|
sha256_bytes,
|
||||||
|
sha256_json,
|
||||||
|
utc_timestamp,
|
||||||
|
validate_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceTests(unittest.TestCase):
|
||||||
|
def test_documented_schema_matches_the_runtime_contract(self) -> None:
|
||||||
|
root = Path(__file__).parents[3]
|
||||||
|
schema = json.loads(
|
||||||
|
(root / "config" / "nono" / "evidence.schema.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
summary = self._summary()
|
||||||
|
self.assertEqual(set(schema["required"]), set(summary))
|
||||||
|
self.assertFalse(schema["additionalProperties"])
|
||||||
|
self.assertEqual(
|
||||||
|
set(schema["properties"]["suite"]["enum"]),
|
||||||
|
{"hermetic", "host", "live"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
set(schema["properties"]["status"]["enum"]),
|
||||||
|
{"passed", "failed", "blocked", "not-run"},
|
||||||
|
)
|
||||||
|
observation = summary["observations"][0]
|
||||||
|
self.assertEqual(
|
||||||
|
set(schema["$defs"]["observation"]["required"]),
|
||||||
|
set(observation),
|
||||||
|
)
|
||||||
|
self.assertFalse(schema["$defs"]["observation"]["additionalProperties"])
|
||||||
|
|
||||||
|
def _summary(self, **changes: object) -> dict[str, object]:
|
||||||
|
observation = {
|
||||||
|
"id": "negative-tmp",
|
||||||
|
"capability": "filesystem",
|
||||||
|
"expected": "denied",
|
||||||
|
"actual": "denied",
|
||||||
|
"status": "passed",
|
||||||
|
"hash": sha256_json({"actual": "denied", "path": "tmp"}),
|
||||||
|
}
|
||||||
|
values: dict[str, object] = {
|
||||||
|
"run_id": "run-001",
|
||||||
|
"test_id": "negative-matrix",
|
||||||
|
"suite": "hermetic",
|
||||||
|
"repository_commit": "0" * 40,
|
||||||
|
"policy_snapshot_digest": "1" * 64,
|
||||||
|
"installed_snapshot_digest": "2" * 64,
|
||||||
|
"nono_version": "0.74.0",
|
||||||
|
"status": "passed",
|
||||||
|
"started_at": "2026-08-27T04:00:00Z",
|
||||||
|
"finished_at": "2026-08-27T04:00:01Z",
|
||||||
|
"raw_evidence_sha256": "3" * 64,
|
||||||
|
"raw_evidence_retained_until": "2026-09-26T04:00:00Z",
|
||||||
|
"result_hashes": {"result": "4" * 64},
|
||||||
|
"observations": [observation],
|
||||||
|
}
|
||||||
|
values.update(changes)
|
||||||
|
return create_summary(**values) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_summary_contract_is_closed_and_validates_hashes_times_status(self) -> None:
|
||||||
|
summary = self._summary()
|
||||||
|
validate_summary(summary)
|
||||||
|
for mutation in (
|
||||||
|
{"missing": "schemaVersion"},
|
||||||
|
{"extra": "not allowed"},
|
||||||
|
{"policySnapshotDigest": "not-a-digest"},
|
||||||
|
{"startedAt": "2026-08-27T04:00:00+00:00"},
|
||||||
|
{"status": "skipped"},
|
||||||
|
{"rawEvidence": {"sha256": "3" * 64, "retainedUntil": "bad"}},
|
||||||
|
):
|
||||||
|
candidate = dict(summary)
|
||||||
|
if "missing" in mutation:
|
||||||
|
candidate.pop(str(mutation["missing"]))
|
||||||
|
elif "extra" in mutation:
|
||||||
|
candidate["extra"] = mutation["extra"]
|
||||||
|
else:
|
||||||
|
candidate.update(mutation)
|
||||||
|
with self.assertRaises(EvidenceValidationError, msg=str(mutation)):
|
||||||
|
validate_summary(candidate)
|
||||||
|
|
||||||
|
def test_raw_command_fields_and_live_approval_contract(self) -> None:
|
||||||
|
with self.assertRaises(EvidenceValidationError):
|
||||||
|
self._summary(result_hashes={"stdout": "4" * 64})
|
||||||
|
with self.assertRaises(EvidenceValidationError):
|
||||||
|
self._summary(suite="live")
|
||||||
|
live = self._summary(suite="live", approver="JB", status="blocked")
|
||||||
|
validate_summary(live)
|
||||||
|
with self.assertRaises(EvidenceValidationError):
|
||||||
|
self._summary(observations=[{"stdout": "raw"}])
|
||||||
|
|
||||||
|
def test_raw_files_are_private_atomic_and_traceable(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
state = root / "state"
|
||||||
|
summaries = root / "summaries"
|
||||||
|
state.mkdir(mode=0o700)
|
||||||
|
summaries.mkdir(mode=0o700)
|
||||||
|
os.chmod(state, 0o700)
|
||||||
|
os.chmod(summaries, 0o700)
|
||||||
|
store = EvidenceStore(state, summaries)
|
||||||
|
payload = {
|
||||||
|
"runId": "run-001",
|
||||||
|
"result": {"stdout": "raw fixture output", "stderr": "raw error"},
|
||||||
|
}
|
||||||
|
reference = store.write_raw_evidence(
|
||||||
|
"run-001", payload, now=dt.datetime(2026, 8, 27, tzinfo=dt.timezone.utc)
|
||||||
|
)
|
||||||
|
self.assertEqual(stat.S_IMODE(reference.path.stat().st_mode), 0o600)
|
||||||
|
self.assertEqual(stat.S_IMODE(reference.metadata_path.stat().st_mode), 0o600)
|
||||||
|
self.assertEqual(stat.S_IMODE(reference.path.parent.stat().st_mode), 0o700)
|
||||||
|
self.assertEqual(stat.S_IMODE((state / "pi-sbx").stat().st_mode), 0o700)
|
||||||
|
self.assertEqual(stat.S_IMODE((state / "pi-sbx" / "evidence").stat().st_mode), 0o700)
|
||||||
|
self.assertEqual(
|
||||||
|
reference.sha256,
|
||||||
|
sha256_bytes(reference.path.read_bytes()),
|
||||||
|
)
|
||||||
|
restored, metadata = store.read_raw_evidence(
|
||||||
|
"run-001",
|
||||||
|
now=dt.datetime(2026, 8, 28, tzinfo=dt.timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(restored, payload)
|
||||||
|
self.assertEqual(metadata["sha256"], reference.sha256)
|
||||||
|
self.assertEqual(metadata["retainedUntil"], "2026-09-26T00:00:00Z")
|
||||||
|
summary = self._summary(raw_evidence_sha256=reference.sha256)
|
||||||
|
summary_path = store.write_summary(
|
||||||
|
summary,
|
||||||
|
repository_id="fixture-repo",
|
||||||
|
policy_digest="1" * 64,
|
||||||
|
)
|
||||||
|
self.assertEqual(stat.S_IMODE(summary_path.stat().st_mode), 0o600)
|
||||||
|
self.assertEqual(stat.S_IMODE(summary_path.parent.stat().st_mode), 0o700)
|
||||||
|
committed = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertNotIn("stdout", json.dumps(committed))
|
||||||
|
self.assertNotIn("stderr", json.dumps(committed))
|
||||||
|
self.assertEqual(committed["rawEvidence"]["sha256"], reference.sha256)
|
||||||
|
self.assertEqual(list(summary_path.parent.glob(".*")), [])
|
||||||
|
expired_at = dt.datetime(2026, 9, 26, tzinfo=dt.timezone.utc)
|
||||||
|
with self.assertRaisesRegex(EvidenceValidationError, "expired"):
|
||||||
|
store.read_raw_evidence("run-001", now=expired_at)
|
||||||
|
self.assertEqual(store.purge_expired(now=expired_at), ["run-001"])
|
||||||
|
self.assertFalse(reference.path.parent.exists())
|
||||||
|
|
||||||
|
def test_raw_fifo_is_rejected_without_blocking(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
state = root / "state"
|
||||||
|
summaries = root / "summaries"
|
||||||
|
state.mkdir(mode=0o700)
|
||||||
|
summaries.mkdir(mode=0o700)
|
||||||
|
store = EvidenceStore(state, summaries)
|
||||||
|
reference = store.write_raw_evidence("run-fifo", {"status": "blocked"})
|
||||||
|
reference.path.unlink()
|
||||||
|
os.mkfifo(reference.path, mode=0o600)
|
||||||
|
with self.assertRaisesRegex(EvidenceValidationError, "regular file"):
|
||||||
|
store.read_raw_evidence("run-fifo")
|
||||||
|
|
||||||
|
def test_interrupted_quarantine_is_recovered(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
state = root / "state"
|
||||||
|
summaries = root / "summaries"
|
||||||
|
state.mkdir(mode=0o700)
|
||||||
|
summaries.mkdir(mode=0o700)
|
||||||
|
store = EvidenceStore(state, summaries)
|
||||||
|
reference = store.write_raw_evidence("run-interrupted", {"status": "blocked"})
|
||||||
|
quarantine = reference.path.parent.parent / (".purge-" + "a" * 32)
|
||||||
|
reference.path.parent.rename(quarantine)
|
||||||
|
self.assertEqual(store.purge_expired(), [])
|
||||||
|
self.assertFalse(quarantine.exists())
|
||||||
|
|
||||||
|
def test_paths_cannot_escape_injected_roots(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
state = root / "state"
|
||||||
|
summaries = root / "summaries"
|
||||||
|
state.mkdir(mode=0o700)
|
||||||
|
summaries.mkdir(mode=0o700)
|
||||||
|
os.chmod(state, 0o700)
|
||||||
|
os.chmod(summaries, 0o700)
|
||||||
|
store = EvidenceStore(state, summaries)
|
||||||
|
with self.assertRaises(EvidenceValidationError):
|
||||||
|
store.read_raw_evidence("../outside")
|
||||||
|
with self.assertRaises(EvidenceValidationError):
|
||||||
|
store.summary_path("../outside", "1" * 64, "run-1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from .fixtures import isolated_environment
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureTests(unittest.TestCase):
|
||||||
|
def test_fixture_uses_private_directories_and_separate_sessions(self) -> None:
|
||||||
|
inherited = os.environ.copy()
|
||||||
|
inherited.update(
|
||||||
|
{
|
||||||
|
"OPENAI_API_KEY": "must-not-cross-boundary",
|
||||||
|
"GITHUB_TOKEN": "must-not-cross-boundary",
|
||||||
|
"SSH_AUTH_SOCK": "/tmp/host-agent.sock",
|
||||||
|
"CUSTOM_SECRET_VALUE": "must-not-cross-boundary",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with isolated_environment() as fixture:
|
||||||
|
# The fixture intentionally does not accept an inherited environment;
|
||||||
|
# this test still checks that the resulting allowlist is safe.
|
||||||
|
self.assertNotIn("OPENAI_API_KEY", fixture.env)
|
||||||
|
self.assertNotIn("GITHUB_TOKEN", fixture.env)
|
||||||
|
self.assertNotIn("SSH_AUTH_SOCK", fixture.env)
|
||||||
|
self.assertNotIn("CUSTOM_SECRET_VALUE", fixture.env)
|
||||||
|
session_environment = fixture.session("one").environment(inherited)
|
||||||
|
self.assertNotIn("OPENAI_API_KEY", session_environment)
|
||||||
|
self.assertNotIn("GITHUB_TOKEN", session_environment)
|
||||||
|
self.assertNotIn("SSH_AUTH_SOCK", session_environment)
|
||||||
|
self.assertNotIn("CUSTOM_SECRET_VALUE", session_environment)
|
||||||
|
self.assertEqual(
|
||||||
|
set(fixture.env),
|
||||||
|
{
|
||||||
|
"HOME",
|
||||||
|
"XDG_CONFIG_HOME",
|
||||||
|
"XDG_STATE_HOME",
|
||||||
|
"XDG_CACHE_HOME",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
|
"TMPDIR",
|
||||||
|
"PATH",
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"PYTHONDONTWRITEBYTECODE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for path in (
|
||||||
|
fixture.root,
|
||||||
|
fixture.home,
|
||||||
|
fixture.xdg_config_home,
|
||||||
|
fixture.xdg_state_home,
|
||||||
|
fixture.xdg_cache_home,
|
||||||
|
fixture.xdg_runtime_dir,
|
||||||
|
fixture.tmpdir,
|
||||||
|
fixture.repos,
|
||||||
|
fixture.sessions,
|
||||||
|
fixture.stub_bin,
|
||||||
|
):
|
||||||
|
assert path is not None
|
||||||
|
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700, path)
|
||||||
|
first = fixture.session("one")
|
||||||
|
second = fixture.session("two")
|
||||||
|
self.assertNotEqual(first.tmp, second.tmp)
|
||||||
|
self.assertNotEqual(first.state, second.state)
|
||||||
|
self.assertEqual(stat.S_IMODE(first.tmp.stat().st_mode), 0o700)
|
||||||
|
self.assertEqual(stat.S_IMODE(second.state.stat().st_mode), 0o700)
|
||||||
|
|
||||||
|
def test_session_environment_points_only_at_its_paths(self) -> None:
|
||||||
|
with isolated_environment() as fixture:
|
||||||
|
session = fixture.session("one")
|
||||||
|
environment = session.environment(fixture.env)
|
||||||
|
self.assertEqual(environment["HOME"], str(session.home))
|
||||||
|
self.assertEqual(environment["XDG_STATE_HOME"], str(session.state))
|
||||||
|
self.assertEqual(environment["XDG_RUNTIME_DIR"], str(session.runtime))
|
||||||
|
self.assertEqual(environment["TMPDIR"], str(session.tmp))
|
||||||
|
self.assertNotIn("GH_TOKEN", environment)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .fixtures import isolated_environment
|
||||||
|
from .nono import NonoAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class NonoAdapterTests(unittest.TestCase):
|
||||||
|
def _environment(self, root: Path, *, home: Path | None = None) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"HOME": str(home or root),
|
||||||
|
"XDG_CONFIG_HOME": str(root),
|
||||||
|
"XDG_STATE_HOME": str(root),
|
||||||
|
"XDG_CACHE_HOME": str(root),
|
||||||
|
"XDG_RUNTIME_DIR": str(root),
|
||||||
|
"TMPDIR": str(root),
|
||||||
|
"PATH": "/usr/bin:/bin",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fake_nono(self, directory: Path, log: Path) -> Path:
|
||||||
|
executable = directory / "fake-nono"
|
||||||
|
executable.write_text(
|
||||||
|
textwrap.dedent(
|
||||||
|
f"""\
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import pathlib, sys, time
|
||||||
|
pathlib.Path({str(log)!r}).write_text('\\n'.join(sys.argv[1:]), encoding='utf-8')
|
||||||
|
if '--version' in sys.argv:
|
||||||
|
print('nono 0.74.0')
|
||||||
|
if 'slow' in sys.argv:
|
||||||
|
print('x' * (2 * 1024 * 1024), flush=True)
|
||||||
|
print('y' * (2 * 1024 * 1024), file=sys.stderr, flush=True)
|
||||||
|
time.sleep(2)
|
||||||
|
print('stdout:' + ('x' * 500))
|
||||||
|
print('stderr:' + ('y' * 500), file=sys.stderr)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
executable.chmod(0o700)
|
||||||
|
return executable
|
||||||
|
|
||||||
|
def test_validate_show_and_run_are_exact_argv_and_no_active_namespace(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
log = root / "argv.log"
|
||||||
|
executable = self._fake_nono(root, log)
|
||||||
|
profile = root / "temporary.json"
|
||||||
|
profile.write_text("{}", encoding="utf-8")
|
||||||
|
adapter = NonoAdapter(
|
||||||
|
executable,
|
||||||
|
cwd=root,
|
||||||
|
environment=self._environment(root),
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.validate_temporary_profile(profile)
|
||||||
|
validation_argv = log.read_text(encoding="utf-8").splitlines()
|
||||||
|
self.assertEqual(validation_argv[:3], ["profile", "validate", "--strict"])
|
||||||
|
self.assertEqual(Path(validation_argv[3]).name, "profile.json")
|
||||||
|
self.assertNotEqual(validation_argv[3], str(profile))
|
||||||
|
adapter.show_temporary_profile(profile)
|
||||||
|
show_argv = log.read_text(encoding="utf-8").splitlines()
|
||||||
|
self.assertEqual(show_argv[:2], ["profile", "show"])
|
||||||
|
self.assertEqual(Path(show_argv[2]).name, "profile.json")
|
||||||
|
self.assertEqual(show_argv[3:], ["--json"])
|
||||||
|
adapter.run_temporary_profile(profile, ["/usr/bin/true", "--fixed"])
|
||||||
|
run_argv = log.read_text(encoding="utf-8").splitlines()
|
||||||
|
self.assertEqual(run_argv[:2], ["run", "--profile"])
|
||||||
|
self.assertEqual(Path(run_argv[2]).name, "profile.json")
|
||||||
|
self.assertEqual(run_argv[3:], ["--", "/usr/bin/true", "--fixed"])
|
||||||
|
adapter.run_temporary_profile(
|
||||||
|
profile,
|
||||||
|
["/usr/bin/true"],
|
||||||
|
allow_cwd=True,
|
||||||
|
)
|
||||||
|
cwd_argv = log.read_text(encoding="utf-8").splitlines()
|
||||||
|
self.assertEqual(cwd_argv[:2], ["run", "--profile"])
|
||||||
|
self.assertEqual(Path(cwd_argv[2]).name, "profile.json")
|
||||||
|
self.assertEqual(cwd_argv[3:], ["--allow-cwd", "--", "/usr/bin/true"])
|
||||||
|
|
||||||
|
active = root / "active" / ".config" / "nono" / "profiles" / "name.json"
|
||||||
|
# An explicitly injected active namespace is rejected by the same
|
||||||
|
# path guard used for HOME's conventional namespace.
|
||||||
|
active.parent.mkdir(parents=True)
|
||||||
|
os.chmod(root / "active", 0o700)
|
||||||
|
adapter_with_home = NonoAdapter(
|
||||||
|
executable,
|
||||||
|
cwd=root,
|
||||||
|
environment=self._environment(root, home=root / "active"),
|
||||||
|
)
|
||||||
|
active.write_text("{}", encoding="utf-8")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
adapter_with_home.validate(active)
|
||||||
|
hardlink = root / "active-hardlink.json"
|
||||||
|
os.link(active, hardlink)
|
||||||
|
with self.assertRaisesRegex(ValueError, "cannot alias"):
|
||||||
|
adapter_with_home.validate(hardlink)
|
||||||
|
|
||||||
|
def test_result_output_is_bounded_and_timeout_is_recorded(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
executable = self._fake_nono(root, root / "argv.log")
|
||||||
|
profile = root / "temporary.json"
|
||||||
|
profile.write_text("{}", encoding="utf-8")
|
||||||
|
adapter = NonoAdapter(
|
||||||
|
executable,
|
||||||
|
cwd=root,
|
||||||
|
timeout=0.1,
|
||||||
|
max_output=32,
|
||||||
|
environment=self._environment(root),
|
||||||
|
)
|
||||||
|
result = adapter.run(profile, ["slow"])
|
||||||
|
self.assertTrue(result.timed_out)
|
||||||
|
self.assertLessEqual(len(result.stdout), 32)
|
||||||
|
self.assertLessEqual(len(result.stderr), 32)
|
||||||
|
|
||||||
|
def test_commands_are_argv_only(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
executable = self._fake_nono(root, root / "argv.log")
|
||||||
|
profile = root / "temporary.json"
|
||||||
|
profile.write_text("{}", encoding="utf-8")
|
||||||
|
adapter = NonoAdapter(
|
||||||
|
executable,
|
||||||
|
cwd=root,
|
||||||
|
environment=self._environment(root),
|
||||||
|
)
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
adapter.run(profile, "echo unsafe") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_environment_is_required_and_symlinked_profiles_are_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
executable = self._fake_nono(root, root / "argv.log")
|
||||||
|
with self.assertRaisesRegex(ValueError, "explicit isolated environment"):
|
||||||
|
NonoAdapter(executable, cwd=root)
|
||||||
|
for unsafe_name in ("GITHUB_TOKEN", "AWS_ACCESS_KEY_ID", "LD_PRELOAD"):
|
||||||
|
with self.subTest(environment=unsafe_name):
|
||||||
|
unsafe_environment = self._environment(root)
|
||||||
|
unsafe_environment[unsafe_name] = "must-not-pass"
|
||||||
|
with self.assertRaisesRegex(ValueError, "allowlist"):
|
||||||
|
NonoAdapter(
|
||||||
|
executable,
|
||||||
|
cwd=root,
|
||||||
|
environment=unsafe_environment,
|
||||||
|
)
|
||||||
|
profile = root / "profile.json"
|
||||||
|
profile.write_text("{}", encoding="utf-8")
|
||||||
|
linked = root / "linked.json"
|
||||||
|
linked.symlink_to(profile)
|
||||||
|
adapter = NonoAdapter(
|
||||||
|
executable,
|
||||||
|
cwd=root,
|
||||||
|
environment=self._environment(root),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "symlinked"):
|
||||||
|
adapter.validate(linked)
|
||||||
|
fifo = root / "fifo-profile.json"
|
||||||
|
os.mkfifo(fifo, mode=0o600)
|
||||||
|
with self.assertRaisesRegex(ValueError, "regular file"):
|
||||||
|
adapter.validate(fifo)
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureProfileValidationTests(unittest.TestCase):
|
||||||
|
def test_fixture_directories_are_not_install_targets(self) -> None:
|
||||||
|
with isolated_environment() as fixture:
|
||||||
|
profile = Path(__file__).parents[1] / "fixtures" / "core" / "minimal.json"
|
||||||
|
self.assertNotIn(str(profile), fixture.env.values())
|
||||||
|
self.assertTrue(profile.is_file())
|
||||||
|
self.assertNotEqual(stat.S_IMODE(profile.stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from .redaction import RedactionError, assert_no_leaks, redact, scan_for_leaks
|
||||||
|
|
||||||
|
|
||||||
|
class RedactionTests(unittest.TestCase):
|
||||||
|
def test_recursive_canary_redaction_across_all_evidence_surfaces(self) -> None:
|
||||||
|
canary = "canary-value-93f1"
|
||||||
|
evidence = {
|
||||||
|
canary + "-key": "key canary",
|
||||||
|
"environment": {"OPENAI_API_KEY": canary, "SAFE_MODE": "on"},
|
||||||
|
"argv": ["--token", canary],
|
||||||
|
"stdout": "output " + canary,
|
||||||
|
"stderr": "error " + canary,
|
||||||
|
"exception": RuntimeError("failed with " + canary),
|
||||||
|
"nested": [{"GITHUB_TOKEN": "another-value"}],
|
||||||
|
}
|
||||||
|
self.assertTrue(scan_for_leaks(evidence, canaries=[canary]))
|
||||||
|
redacted = redact(evidence, canaries=[canary])
|
||||||
|
assert_no_leaks(redacted, canaries=[canary])
|
||||||
|
self.assertEqual(redacted["environment"]["SAFE_MODE"], "on")
|
||||||
|
self.assertNotIn(canary, str(redacted))
|
||||||
|
self.assertIn("[REDACTED]", str(redacted))
|
||||||
|
|
||||||
|
def test_configured_name_pattern_is_precise_for_safe_fields(self) -> None:
|
||||||
|
evidence = {
|
||||||
|
"status": "passed",
|
||||||
|
"provider": "fixture",
|
||||||
|
"tokenCount": 2,
|
||||||
|
"MY_SECRET_ALPHA": "fixture-secret",
|
||||||
|
"message": "MY_SECRET_ALPHA was used",
|
||||||
|
}
|
||||||
|
patterns = [r"(?<![A-Za-z0-9])MY_SECRET_[A-Z]+(?![A-Za-z0-9])"]
|
||||||
|
leaks = scan_for_leaks(evidence, credential_name_patterns=patterns)
|
||||||
|
self.assertIn("MY_SECRET_ALPHA", leaks)
|
||||||
|
self.assertIn("message", leaks)
|
||||||
|
self.assertNotIn("status", leaks)
|
||||||
|
self.assertNotIn("provider", leaks)
|
||||||
|
self.assertNotIn("tokenCount", leaks)
|
||||||
|
cleaned = redact(evidence, credential_name_patterns=patterns)
|
||||||
|
assert_no_leaks(cleaned, credential_name_patterns=patterns)
|
||||||
|
self.assertEqual(cleaned["status"], "passed")
|
||||||
|
self.assertEqual(cleaned["provider"], "fixture")
|
||||||
|
self.assertEqual(cleaned["tokenCount"], 2)
|
||||||
|
|
||||||
|
def test_leak_assertion_rejects_unredacted_summary_material(self) -> None:
|
||||||
|
with self.assertRaises(RedactionError):
|
||||||
|
assert_no_leaks(
|
||||||
|
{"observation": "GITHUB_TOKEN still visible"},
|
||||||
|
canaries=["a-secret-canary"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CORE = Path(__file__).parent / "fixtures" / "core"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CapabilityObservation:
|
||||||
|
capability: str
|
||||||
|
expected: str
|
||||||
|
actual: str
|
||||||
|
|
||||||
|
|
||||||
|
def meets_expectation(observation: CapabilityObservation) -> bool:
|
||||||
|
return observation.expected == observation.actual
|
||||||
|
|
||||||
|
|
||||||
|
def workdir_observation(profile: dict[str, object], expected: str) -> CapabilityObservation:
|
||||||
|
workdir = profile.get("workdir", {})
|
||||||
|
access = workdir.get("access") if isinstance(workdir, dict) else None
|
||||||
|
actual = "allowed" if access == "readwrite" else "denied"
|
||||||
|
return CapabilityObservation("broad-workdir", expected, actual)
|
||||||
|
|
||||||
|
|
||||||
|
NEGATIVE_CAPABILITIES = (
|
||||||
|
("broad-tmp", "broad /tmp", "denied"),
|
||||||
|
("runtime-parent", "runtime session parent", "denied"),
|
||||||
|
("other-session-tmp", "another session temporary directory", "denied"),
|
||||||
|
("sibling-worktree", "sibling linked worktree", "denied"),
|
||||||
|
("unrelated-worktree-root", "unrelated durable worktree root", "denied"),
|
||||||
|
("direct-ssh", "direct SSH", "denied"),
|
||||||
|
("personal-key-from-work", "personal key from work", "denied"),
|
||||||
|
("work-key-from-personal", "work key from personal", "denied"),
|
||||||
|
("sibling-repository", "sibling repository", "denied"),
|
||||||
|
("unrelated-pi-state", "unrelated Pi state", "denied"),
|
||||||
|
("trusted-instructions-write", "global instructions write", "denied"),
|
||||||
|
("extension-source-write", "extension source write", "denied"),
|
||||||
|
("docker", "Docker", "denied"),
|
||||||
|
("browser", "browser control", "denied"),
|
||||||
|
("dictation", "dictation", "denied"),
|
||||||
|
("update", "Pi update", "denied"),
|
||||||
|
("production-credentials", "production credentials", "denied"),
|
||||||
|
("nonselected-provider", "non-selected provider route", "denied"),
|
||||||
|
("raw-provider-key", "raw provider key environment", "denied"),
|
||||||
|
)
|
||||||
|
|
||||||
|
POSITIVE_FIXTURE_CONTRACTS = (
|
||||||
|
("git", "Git capability fixture", "allowed"),
|
||||||
|
("github-cli", "GitHub CLI capability fixture", "allowed"),
|
||||||
|
("package-cache", "package manager cache fixture", "allowed"),
|
||||||
|
("provider-port", "exact selected provider port fixture", "allowed"),
|
||||||
|
("unix-socket", "exact selected socket fixture", "allowed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityMatrixTests(unittest.TestCase):
|
||||||
|
def test_negative_matrix_remains_blocked_until_behavior_runs(self) -> None:
|
||||||
|
observations = [
|
||||||
|
CapabilityObservation(identifier, expected, "not-run")
|
||||||
|
for identifier, _description, expected in NEGATIVE_CAPABILITIES
|
||||||
|
]
|
||||||
|
for observation in observations:
|
||||||
|
with self.subTest(capability=observation.capability):
|
||||||
|
self.assertFalse(meets_expectation(observation))
|
||||||
|
|
||||||
|
def test_positive_matrix_remains_a_deferred_fixture_contract(self) -> None:
|
||||||
|
for identifier, description, expected in POSITIVE_FIXTURE_CONTRACTS:
|
||||||
|
with self.subTest(capability=identifier):
|
||||||
|
observation = CapabilityObservation(identifier, expected, "not-run")
|
||||||
|
self.assertFalse(meets_expectation(observation), description)
|
||||||
|
|
||||||
|
def test_broad_fixture_declares_an_overgrant_for_the_host_gate(self) -> None:
|
||||||
|
broad = json.loads((CORE / "broad.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(broad["workdir"]["access"], "readwrite")
|
||||||
|
observed = workdir_observation(broad, "denied")
|
||||||
|
# The assertion itself must fail for this intentionally over-broad fixture;
|
||||||
|
# assertFalse proves the harness detects the mismatch without failing the
|
||||||
|
# hermetic suite as a whole.
|
||||||
|
self.assertFalse(meets_expectation(observed))
|
||||||
|
self.assertEqual(observed.expected, "denied")
|
||||||
|
self.assertEqual(observed.actual, "allowed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[2]
|
||||||
|
SCRIPT = ROOT / "scripts" / "nono-evidence.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("nono_evidence_cli", SCRIPT)
|
||||||
|
if SPEC is None or SPEC.loader is None:
|
||||||
|
raise RuntimeError("cannot load evidence CLI")
|
||||||
|
CLI = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(CLI)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceCliTests(unittest.TestCase):
|
||||||
|
def _snapshot(self, root: Path) -> tuple[Path, Path, Path, str]:
|
||||||
|
snapshot = root / "installed-snapshot"
|
||||||
|
(snapshot / "scripts").mkdir(parents=True, mode=0o700)
|
||||||
|
shutil.copy2(SCRIPT, snapshot / "scripts" / "nono-evidence.py")
|
||||||
|
shutil.copy2(ROOT / "scripts" / "test_nono.py", snapshot / "scripts" / "test_nono.py")
|
||||||
|
shutil.copytree(
|
||||||
|
ROOT / "tests" / "nono",
|
||||||
|
snapshot / "tests" / "nono",
|
||||||
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
||||||
|
)
|
||||||
|
(snapshot / "config" / "nono").mkdir(parents=True, mode=0o700)
|
||||||
|
shutil.copy2(
|
||||||
|
ROOT / "config" / "nono" / "evidence.schema.json",
|
||||||
|
snapshot / "config" / "nono" / "evidence.schema.json",
|
||||||
|
)
|
||||||
|
(snapshot / ".pi-sbx-trusted-snapshot").write_text(
|
||||||
|
"pi-sbx-trusted-snapshot-v1\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
fake_nono = snapshot / "bin" / "nono"
|
||||||
|
fake_nono.parent.mkdir(mode=0o700)
|
||||||
|
fake_nono.write_text(
|
||||||
|
"#!/bin/sh\nprintf '%s\\n' 'nono 0.74.0'\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
fake_nono.chmod(0o700)
|
||||||
|
os.chmod(snapshot, 0o700)
|
||||||
|
installed_script = snapshot / "scripts" / "nono-evidence.py"
|
||||||
|
return snapshot, installed_script, fake_nono, CLI._snapshot_digest(snapshot)
|
||||||
|
|
||||||
|
def _metadata(self, nono: Path) -> list[str]:
|
||||||
|
commit = subprocess.run(
|
||||||
|
["git", "-C", str(ROOT), "rev-parse", "HEAD"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
policy = ROOT / "tests" / "nono" / "fixtures" / "core" / "minimal.json"
|
||||||
|
digest = hashlib.sha256(policy.read_bytes()).hexdigest()
|
||||||
|
return [
|
||||||
|
"--repository-root",
|
||||||
|
str(ROOT),
|
||||||
|
"--repository-commit",
|
||||||
|
commit,
|
||||||
|
"--policy-file",
|
||||||
|
str(policy),
|
||||||
|
"--policy-digest",
|
||||||
|
digest,
|
||||||
|
"--nono",
|
||||||
|
str(nono),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _base(
|
||||||
|
self,
|
||||||
|
snapshot: Path,
|
||||||
|
installed_script: Path,
|
||||||
|
digest: str,
|
||||||
|
state: Path,
|
||||||
|
summary: Path,
|
||||||
|
) -> list[str]:
|
||||||
|
return [
|
||||||
|
sys.executable,
|
||||||
|
str(installed_script),
|
||||||
|
"--trusted-snapshot",
|
||||||
|
str(snapshot),
|
||||||
|
"--trusted-snapshot-digest",
|
||||||
|
digest,
|
||||||
|
"--state-root",
|
||||||
|
str(state),
|
||||||
|
"--summary-root",
|
||||||
|
str(summary),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_identity_reader_rejects_fifo_without_blocking(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
fifo = Path(directory) / "identity.fifo"
|
||||||
|
os.mkfifo(fifo)
|
||||||
|
with self.assertRaisesRegex(CLI.EvidenceCliError, "regular file"):
|
||||||
|
CLI._regular_file_identity(fifo, max_size=1024)
|
||||||
|
|
||||||
|
def test_source_fails_closed_without_explicit_trusted_snapshot(self) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "run", "hermetic-core"],
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("required", result.stderr)
|
||||||
|
|
||||||
|
def test_hermetic_core_runs_real_harness_tests_before_passing(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
snapshot, installed_script, fake_nono, digest = self._snapshot(root)
|
||||||
|
state, summary = root / "state", root / "summary"
|
||||||
|
state.mkdir(mode=0o700)
|
||||||
|
summary.mkdir(mode=0o700)
|
||||||
|
base = self._base(snapshot, installed_script, digest, state, summary)
|
||||||
|
command = [
|
||||||
|
*base,
|
||||||
|
"run",
|
||||||
|
*self._metadata(fake_nono),
|
||||||
|
"hermetic-core",
|
||||||
|
]
|
||||||
|
run = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(run.returncode, 0, run.stderr)
|
||||||
|
record = json.loads(run.stdout)
|
||||||
|
self.assertEqual(record["status"], "passed")
|
||||||
|
raw = json.loads(Path(record["rawEvidence"]).read_text(encoding="utf-8"))
|
||||||
|
self.assertIn("Ran", raw["result"]["stderr"])
|
||||||
|
repeated = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(repeated.returncode, 0, repeated.stderr)
|
||||||
|
self.assertEqual(list(snapshot.rglob("__pycache__")), [])
|
||||||
|
|
||||||
|
def test_live_mode_requires_approval_and_missing_check_is_blocked(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
snapshot, installed_script, fake_nono, digest = self._snapshot(root)
|
||||||
|
state, summary = root / "state", root / "summary"
|
||||||
|
state.mkdir(mode=0o700)
|
||||||
|
summary.mkdir(mode=0o700)
|
||||||
|
base = self._base(snapshot, installed_script, digest, state, summary)
|
||||||
|
metadata = self._metadata(fake_nono)
|
||||||
|
denied = subprocess.run(
|
||||||
|
[*base, "run", *metadata, "--suite", "live", "git-live"],
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(denied.returncode, 0)
|
||||||
|
self.assertIn("approval", denied.stderr)
|
||||||
|
run = subprocess.run(
|
||||||
|
[
|
||||||
|
*base,
|
||||||
|
"run",
|
||||||
|
*metadata,
|
||||||
|
"--suite",
|
||||||
|
"live",
|
||||||
|
"--approve-live",
|
||||||
|
"--approver",
|
||||||
|
"reviewer",
|
||||||
|
"git-live",
|
||||||
|
],
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(run.returncode, 0)
|
||||||
|
run_record = json.loads(run.stdout)
|
||||||
|
self.assertEqual(run_record["status"], "blocked")
|
||||||
|
summarized = subprocess.run(
|
||||||
|
[*base, "summarize", run_record["runId"]],
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(summarized.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(summarized.stdout)["status"], "blocked")
|
||||||
|
summary_file = next(summary.rglob("*.json"))
|
||||||
|
committed = json.loads(summary_file.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(committed["status"], "blocked")
|
||||||
|
self.assertEqual(committed["approver"], "reviewer")
|
||||||
|
self.assertNotIn("stdout", json.dumps(committed))
|
||||||
|
|
||||||
|
def test_writable_nested_snapshot_directory_is_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
snapshot, installed_script, fake_nono, digest = self._snapshot(root)
|
||||||
|
os.chmod(snapshot / "tests", 0o777)
|
||||||
|
state, summary = root / "state", root / "summary"
|
||||||
|
state.mkdir(mode=0o700)
|
||||||
|
summary.mkdir(mode=0o700)
|
||||||
|
base = self._base(snapshot, installed_script, digest, state, summary)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
*base,
|
||||||
|
"run",
|
||||||
|
*self._metadata(fake_nono),
|
||||||
|
"hermetic-core",
|
||||||
|
],
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 2)
|
||||||
|
self.assertIn("not group/other writable", result.stderr)
|
||||||
|
|
||||||
|
def test_source_cannot_borrow_an_unrelated_snapshot_identity(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
snapshot, _installed_script, _fake_nono, digest = self._snapshot(
|
||||||
|
Path(directory)
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
CLI.TrustedSnapshotError,
|
||||||
|
"source checkout cannot be a trusted production snapshot",
|
||||||
|
):
|
||||||
|
CLI.verify_trusted_snapshot(snapshot, digest)
|
||||||
|
|
||||||
|
def test_checkout_cannot_be_declared_trusted(self) -> None:
|
||||||
|
with self.assertRaises(CLI.TrustedSnapshotError):
|
||||||
|
CLI.verify_trusted_snapshot(ROOT, "0" * 64)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user