Compare commits

...
2 Commits
Author SHA1 Message Date
jb 90f8090f61 feat(nono): add restrictive core policy contract 2026-08-30 12:49:17 -04:00
jb 54c20e2012 feat(nono): add isolated evidence harness 2026-08-27 02:23:29 -04:00
39 changed files with 5194 additions and 9 deletions
+94
View File
@@ -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" }
}
}
}
}
+7 -5
View File
@@ -182,14 +182,16 @@
},
{
"name": "lm-studio",
"source": "$HOME/.pi/agent/extensions/lm-studio.ts",
"installed": {
"sha256": "474d1288cb7c528e44b14fcc498ae9f4be04fcbb248dd22d76adb13c2f8c134e"
"source": "extensions/lm-studio/index.ts",
"reviewed": {
"sha256": "0c9926f8f5b549d4e0ce3a97c01a56d4955c5bcc337169ab9dcb05c7347364d9",
"legacySourceSha256": "474d1288cb7c528e44b14fcc498ae9f4be04fcbb248dd22d76adb13c2f8c134e",
"baseUrl": "http://127.0.0.1:1234/v1"
},
"status": "blocked",
"blockers": [
"The active extension is an unmanaged regular file with no immutable source revision.",
"Slice 1 must import the reviewed implementation and quarantine the unmanaged source before activation."
"The active unmanaged $HOME/.pi/agent/extensions/lm-studio.ts source has not completed the approved maintenance quarantine.",
"The reviewed repository source must be promoted into an immutable installed snapshot before normal-profile activation."
]
},
{
@@ -0,0 +1,13 @@
# Materialize trusted Nono policy composition before launch
Nono 0.74 accepts multiple `extends` parents, merges additive grants and environment allowlists across them, and preserves restrictive network decisions such as `block: true`. The trusted Slice 3 generator will therefore treat the Slice 1 core and provider profiles as reviewed policy inputs, select exactly one core and one provider, materialize one canonical launch profile, and validate that result rather than launching a multiple-inheritance profile directly. This keeps the reusable core fail-closed when validated alone while allowing the trusted generator to make the selected provider route explicit.
## Consequences
The managed LM Studio extension uses exact URL `http://127.0.0.1:1234/v1`, while Nono's Linux policy grants outbound TCP port `1234`. Nono 0.74 Landlock mediation is port-only and cannot constrain that grant to loopback, so compromised sandbox code could connect to another address on port 1234. The adjacent-port behavioral proof and exact extension endpoint make this narrower than broad networking without overstating host enforcement.
Cloud provider fragments enable Nono networking with an explicit empty direct-domain allowlist and exactly one phantom credential route. The route's reviewed upstream is the only proxy destination; raw provider keys remain denied to the sandbox. Live secret-backed verification stays behind a separate explicit approval gate.
The active unmanaged LM Studio source remains in place during Slice 1 by explicit approval because quarantining it before an immutable managed installation would disable the current provider. Its reviewed legacy digest and both blockers are recorded in `config/pi-package-lock.json`; Slice 3 must perform hash-guarded quarantine atomically with immutable promotion before activation.
Provider fragments are not standalone launch profiles despite passing strict schema validation. They exclude the inherited Linux and macOS system runtime-read groups, so direct execution cannot read its executable; the trusted generator extracts only the reviewed provider route into a core that owns runtime reads. Untrusted repositories cannot compose or override fragments, and every generated profile must prove that it contains one state namespace, one worktree grant, one temporary directory, and exactly one provider route before `nono run`. Slice 1 isolated settings expose only Pi's `read`, `edit`, and `write` built-ins; model-visible `bash`, package tools, notification commands, background-job extensions, and project resources are absent. Core profiles intentionally define no `command_policies`; later broker slices must bind each controlled command to an exact executable and declare every permitted caller under `commands.<name>.from`, because inherited denials and caller edges are not safely loosened by child profiles. Slice 3 must invoke the exact Pi entrypoint and must not expose arbitrary caller-selected Nono commands as a launch interface.
+4
View File
@@ -4,6 +4,10 @@
A Slice 0 `node scripts/pi-links.mjs prune --dry-run` from `.worktrees/nono-dynamic-permissions-slice-zero-direct` exited 2 and classified every active link into the primary checkout as an outside-repository conflict. The tool anchors ownership to the current worktree root, while active links correctly target the primary repository checkout. Teach check and dry-run planning to recognize canonical checkouts sharing the same Git common directory without broadening stale-link deletion to unrelated repositories. Add linked-worktree fixtures before changing prune behavior. Until fixed, run host reconciliation only from the integrated primary checkout.
## Harden Nono 0.74 command-policy and resolved-policy diagnostics
Slice 1 found two Nono 0.74 limitations while building behavioral policy proofs. First, command-policy startup fails with `path must be shorter than SUN_LEN` when Nono creates its supervisor socket beneath a moderately long isolated `TMPDIR` such as `/tmp/nono-slice-one-*/sessions/personal/tmp`. Before broker profiles activate command policies, reproduce the maximum safe path, then have the launcher select a private short socket root or confirm an upstream fix. Second, `nono profile show --json` omits resolved `environment`, `command_policies`, `network.connect_port`, and `network.custom_credentials` fields even though strict validation and live enforcement accept them. Do not treat resolved JSON as complete evidence for these capabilities; retain behavioral `nono run` and `nono why --command` proofs until the output is complete and regression-tested.
## Replace `pi-workflow` with a skill-based orchestrator
The strict `pi-workflow` extension repeatedly failed before implementation because planner output and persisted state-machine constraints were too brittle for adaptive subagent orchestration. In a separate session, design a repository-managed skill where the main Pi agent owns the plan, task tree, isolated worktree, explicit user approval gates, verification, and independent review while launching standard `scout` and `worker` subagents directly. Preserve file-ownership boundaries, no inferred approvals, review independence, and resumable repository evidence, but prefer native Pi primitives over a custom orchestration engine. Evaluate a small committed state manifest for crash recovery before retiring or archiving the extension. Use the abandoned `.worktrees/pi-workflow-plan-seed` implementation only as reference; do not integrate it by default.
+120
View File
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import registerLmStudio from "./index.ts";
type Registration = {
name: string;
config: {
baseUrl?: string;
refreshModels?: (context: {
allowNetwork: boolean;
signal: AbortSignal;
stored?: { models: readonly unknown[] };
}) => Promise<unknown[]>;
};
};
function captureRegistration(): Registration {
let registration: Registration | undefined;
registerLmStudio({
registerProvider(name: string, config: Registration["config"]) {
registration = { name, config };
},
} as unknown as ExtensionAPI);
assert.ok(registration);
return registration;
}
test("registers one LM Studio provider at the exact loopback endpoint", () => {
const registration = captureRegistration();
assert.equal(registration.name, "lm-studio");
assert.equal(registration.config.baseUrl, "http://127.0.0.1:1234/v1");
});
test("refresh validates and maps the LM Studio model catalog", async () => {
const registration = captureRegistration();
assert.ok(registration.config.refreshModels);
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input) => {
assert.equal(String(input), "http://127.0.0.1:1234/v1/models");
return new Response(
JSON.stringify({
data: [
{
id: "fixture-model",
name: "Fixture Model",
context_window: 32768,
max_tokens: 4096,
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
try {
const models = await registration.config.refreshModels({
allowNetwork: true,
signal: AbortSignal.timeout(1000),
});
assert.deepEqual(models, [
{
id: "fixture-model",
name: "Fixture Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32768,
maxTokens: 4096,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
supportsUsageInStreaming: false,
maxTokensField: "max_tokens",
},
},
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test("refresh reuses stored models when network access is disabled", async () => {
const registration = captureRegistration();
assert.ok(registration.config.refreshModels);
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error("offline refresh attempted network access");
};
const stored = [{ id: "stored-model" }];
try {
const models = await registration.config.refreshModels({
allowNetwork: false,
signal: AbortSignal.timeout(1000),
stored: { models: stored },
});
assert.deepEqual(models, stored);
assert.notEqual(models, stored);
} finally {
globalThis.fetch = originalFetch;
}
});
test("refresh rejects malformed model catalogs", async () => {
const registration = captureRegistration();
assert.ok(registration.config.refreshModels);
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ data: "not-an-array" }), { status: 200 });
try {
await assert.rejects(
registration.config.refreshModels({
allowNetwork: true,
signal: AbortSignal.timeout(1000),
}),
/invalid \/v1\/models response/,
);
} finally {
globalThis.fetch = originalFetch;
}
});
+72
View File
@@ -0,0 +1,72 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const BASE_URL = "http://127.0.0.1:1234/v1";
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 16_384;
type LmStudioModel = {
id?: unknown;
name?: unknown;
context_window?: unknown;
max_tokens?: unknown;
};
type LmStudioModelsResponse = {
data?: LmStudioModel[];
};
function positiveInteger(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isInteger(value) && value > 0
? value
: fallback;
}
export default function (pi: ExtensionAPI) {
pi.registerProvider("lm-studio", {
name: "LM Studio",
baseUrl: BASE_URL,
apiKey: "lm-studio",
api: "openai-completions",
models: [],
async refreshModels({ allowNetwork, signal, stored }) {
if (!allowNetwork) return [...(stored?.models ?? [])];
const response = await fetch(`${BASE_URL}/models`, { signal });
if (!response.ok) {
throw new Error(
`LM Studio model discovery failed: ${response.status} ${response.statusText}`,
);
}
const payload = (await response.json()) as LmStudioModelsResponse;
if (!Array.isArray(payload.data)) {
throw new Error("LM Studio returned an invalid /v1/models response");
}
return payload.data.flatMap((model) => {
if (typeof model.id !== "string" || model.id.length === 0) return [];
return [
{
id: model.id,
name: typeof model.name === "string" ? model.name : model.id,
reasoning: false,
input: ["text"] as ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: positiveInteger(
model.context_window,
DEFAULT_CONTEXT_WINDOW,
),
maxTokens: positiveInteger(model.max_tokens, DEFAULT_MAX_TOKENS),
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
supportsUsageInStreaming: false,
maxTokensField: "max_tokens" as const,
},
},
];
});
},
});
}
+87
View File
@@ -0,0 +1,87 @@
{
"extends": "default",
"meta": {
"name": "pi-core-personal",
"description": "Restrictive personal Pi baseline without repository or provider access"
},
"groups": {
"exclude": [
"homebrew_linux",
"homebrew_macos",
"system_write_linux",
"system_write_macos",
"user_tools"
]
},
"security": {
"signal_mode": "isolated",
"capability_elevation": false
},
"workdir": {
"access": "none"
},
"filesystem": {
"allow": [
"$XDG_CACHE_HOME/pi-sbx/personal",
"$XDG_STATE_HOME/pi-sbx/personal/agent",
"$XDG_STATE_HOME/pi-sbx/personal/sessions"
],
"read": [
"/home/jb/.bun/install/global/node_modules/@earendil-works/pi-coding-agent",
"/home/jb/.bun/install/global/node_modules/jiti"
],
"allow_file": [
"/dev/null",
"/dev/stderr",
"/dev/stdout",
"/dev/tty"
],
"read_file": [
"/home/jb/.pi/agent/AGENTS.md",
"/usr/bin/node"
]
},
"linux": {
"af_unix_mediation": "pathname"
},
"network": {
"block": true
},
"environment": {
"allow_vars": [
"COLORTERM",
"HOME",
"LANG",
"LC_ALL",
"NO_COLOR",
"PATH",
"PI_SBX_PROVIDER",
"SHELL",
"TERM",
"TMPDIR",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME"
],
"deny_vars": [
"ANTHROPIC_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"MORPH_API_KEY",
"OPENAI_API_KEY",
"SSH_AUTH_SOCK"
],
"set_vars": {
"PI_CODING_AGENT_DIR": "$XDG_STATE_HOME/pi-sbx/personal/agent",
"PI_CODING_AGENT_SESSION_DIR": "$XDG_STATE_HOME/pi-sbx/personal/sessions",
"PI_SKIP_VERSION_CHECK": "1",
"PI_TELEMETRY": "0"
}
},
"open_urls": {
"allow_origins": [],
"allow_localhost": false
},
"allow_launch_services": false
}
+87
View File
@@ -0,0 +1,87 @@
{
"extends": "default",
"meta": {
"name": "pi-core-work",
"description": "Restrictive work Pi baseline without repository or provider access"
},
"groups": {
"exclude": [
"homebrew_linux",
"homebrew_macos",
"system_write_linux",
"system_write_macos",
"user_tools"
]
},
"security": {
"signal_mode": "isolated",
"capability_elevation": false
},
"workdir": {
"access": "none"
},
"filesystem": {
"allow": [
"$XDG_CACHE_HOME/pi-sbx/work",
"$XDG_STATE_HOME/pi-sbx/work/agent",
"$XDG_STATE_HOME/pi-sbx/work/sessions"
],
"read": [
"/home/jb/.bun/install/global/node_modules/@earendil-works/pi-coding-agent",
"/home/jb/.bun/install/global/node_modules/jiti"
],
"allow_file": [
"/dev/null",
"/dev/stderr",
"/dev/stdout",
"/dev/tty"
],
"read_file": [
"/home/jb/.pi/agent/AGENTS.md",
"/usr/bin/node"
]
},
"linux": {
"af_unix_mediation": "pathname"
},
"network": {
"block": true
},
"environment": {
"allow_vars": [
"COLORTERM",
"HOME",
"LANG",
"LC_ALL",
"NO_COLOR",
"PATH",
"PI_SBX_PROVIDER",
"SHELL",
"TERM",
"TMPDIR",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME"
],
"deny_vars": [
"ANTHROPIC_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"MORPH_API_KEY",
"OPENAI_API_KEY",
"SSH_AUTH_SOCK"
],
"set_vars": {
"PI_CODING_AGENT_DIR": "$XDG_STATE_HOME/pi-sbx/work/agent",
"PI_CODING_AGENT_SESSION_DIR": "$XDG_STATE_HOME/pi-sbx/work/sessions",
"PI_SKIP_VERSION_CHECK": "1",
"PI_TELEMETRY": "0"
}
},
"open_urls": {
"allow_origins": [],
"allow_localhost": false
},
"allow_launch_services": false
}
+64
View File
@@ -0,0 +1,64 @@
{
"extends": "default",
"meta": {
"name": "provider-anthropic",
"description": "Anthropic route with a Nono-mediated phantom credential"
},
"groups": {
"exclude": [
"homebrew_linux",
"homebrew_macos",
"system_read_linux_core",
"system_read_macos",
"system_write_linux",
"system_write_macos",
"user_tools"
]
},
"workdir": {
"access": "none"
},
"network": {
"block": false,
"allow_domain": [],
"credentials": [
"anthropic"
],
"custom_credentials": {
"anthropic": {
"upstream": "https://api.anthropic.com",
"credential_key": "ANTHROPIC_API_KEY",
"inject_header": "x-api-key",
"credential_format": "{}",
"env_var": "ANTHROPIC_API_KEY"
}
}
},
"environment": {
"allow_vars": [
"HOME",
"LANG",
"LC_ALL",
"PATH",
"TERM",
"TMPDIR",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME"
],
"deny_vars": [
"ANTHROPIC_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"MORPH_API_KEY",
"OPENAI_API_KEY",
"SSH_AUTH_SOCK"
]
},
"open_urls": {
"allow_origins": [],
"allow_localhost": false
},
"allow_launch_services": false
}
+54
View File
@@ -0,0 +1,54 @@
{
"extends": "default",
"meta": {
"name": "provider-lm-studio",
"description": "Outbound TCP port 1234 for the managed loopback-only LM Studio extension"
},
"groups": {
"exclude": [
"homebrew_linux",
"homebrew_macos",
"system_read_linux_core",
"system_read_macos",
"system_write_linux",
"system_write_macos",
"user_tools"
]
},
"workdir": {
"access": "none"
},
"network": {
"block": false,
"connect_port": [
1234
]
},
"environment": {
"allow_vars": [
"HOME",
"LANG",
"LC_ALL",
"PATH",
"TERM",
"TMPDIR",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME"
],
"deny_vars": [
"ANTHROPIC_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"MORPH_API_KEY",
"OPENAI_API_KEY",
"SSH_AUTH_SOCK"
]
},
"open_urls": {
"allow_origins": [],
"allow_localhost": false
},
"allow_launch_services": false
}
+64
View File
@@ -0,0 +1,64 @@
{
"extends": "default",
"meta": {
"name": "provider-morph",
"description": "Morph route with a Nono-mediated phantom credential"
},
"groups": {
"exclude": [
"homebrew_linux",
"homebrew_macos",
"system_read_linux_core",
"system_read_macos",
"system_write_linux",
"system_write_macos",
"user_tools"
]
},
"workdir": {
"access": "none"
},
"network": {
"block": false,
"allow_domain": [],
"credentials": [
"morph"
],
"custom_credentials": {
"morph": {
"upstream": "https://api.morphllm.com",
"credential_key": "MORPH_API_KEY",
"inject_header": "Authorization",
"credential_format": "Bearer {}",
"env_var": "MORPH_API_KEY"
}
}
},
"environment": {
"allow_vars": [
"HOME",
"LANG",
"LC_ALL",
"PATH",
"TERM",
"TMPDIR",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME"
],
"deny_vars": [
"ANTHROPIC_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"MORPH_API_KEY",
"OPENAI_API_KEY",
"SSH_AUTH_SOCK"
]
},
"open_urls": {
"allow_origins": [],
"allow_localhost": false
},
"allow_launch_services": false
}
+64
View File
@@ -0,0 +1,64 @@
{
"extends": "default",
"meta": {
"name": "provider-openai",
"description": "OpenAI route with a Nono-mediated phantom credential"
},
"groups": {
"exclude": [
"homebrew_linux",
"homebrew_macos",
"system_read_linux_core",
"system_read_macos",
"system_write_linux",
"system_write_macos",
"user_tools"
]
},
"workdir": {
"access": "none"
},
"network": {
"block": false,
"allow_domain": [],
"credentials": [
"openai"
],
"custom_credentials": {
"openai": {
"upstream": "https://api.openai.com/v1",
"credential_key": "OPENAI_API_KEY",
"inject_header": "Authorization",
"credential_format": "Bearer {}",
"env_var": "OPENAI_API_KEY"
}
}
},
"environment": {
"allow_vars": [
"HOME",
"LANG",
"LC_ALL",
"PATH",
"TERM",
"TMPDIR",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME"
],
"deny_vars": [
"ANTHROPIC_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"MORPH_API_KEY",
"OPENAI_API_KEY",
"SSH_AUTH_SOCK"
]
},
"open_urls": {
"allow_origins": [],
"allow_localhost": false
},
"allow_launch_services": false
}
+602
View File
@@ -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)
+183
View File
@@ -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())
+3 -2
View File
@@ -43,9 +43,9 @@ Ask about exact files, credential routes, and Unix sockets only when the request
Completion criterion: map every requested capability to an exact path, hostname, credential route, or socket and an explicit access level.
### 3. Choose a safe parent
### 3. Choose safe composition
Use one `extends` parent; build a transitive chain rather than multiple inheritance.
Nono 0.74 accepts either one `extends` parent or an ordered array. Multiple parents merge left to right, deduplicate shared ancestors, append list-valued grants and environment filters, and preserve restrictive decisions such as blocked networking. Use a single parent for ordinary profiles. Use multiple parents only when every additive capability and merge result has been reviewed with `nono profile show` and behavioral tests.
Apply these inheritance rules:
@@ -53,6 +53,7 @@ Apply these inheritance rules:
- Extend `nolabs-ai/pi` for offline mode. Set `network.block` to `true`, clear `open_urls.allow_origins`, disable `open_urls.allow_localhost`, and set `allow_launch_services` to `false`. Do not extend allowlisted `pi-local`: inherited proxy domains conflict with blocked networking.
- Extend `nolabs-ai/pi` rather than `pi-local` when an allowlisted child needs fewer domains than `pi-local`, or when creating an open-network profile. Reproduce only the required local filesystem/socket policy.
- Edit `pi-local` only after showing how the change affects every future project launched with it.
- For trusted generated profiles, materialize the reviewed fragments into one canonical profile and validate that file. Do not rely on multiple inheritance to widen a blocked core with a provider route.
An inherited broad capability cannot reliably be narrowed by an empty child array. Choose a restrictive parent instead.
@@ -4,9 +4,11 @@ Consult this reference when mapping interview answers into a profile. Treat `non
## Inheritance
Use a single string-valued `extends`. Local profiles may extend `pi-local`, registry profiles such as `nolabs-ai/pi`, or another available profile. Inheritance is transitive.
`extends` accepts one profile name or an ordered, non-empty array of profile names. Nono 0.74 merges multiple parents left to right and resolves each shared transitive ancestor once.
Treat included groups and list-valued capabilities as additive. An empty child list does not establish revocation. To create a narrower policy, derive from a narrower parent and add only the required capabilities.
Treat included groups, environment allow/deny lists, and list-valued capabilities as additive. An empty child list does not establish revocation. Restrictive decisions such as `network.block: true` remain restrictive when parent profiles are combined. To create a narrower policy, derive from a narrower parent and add only the required capabilities. When a trusted generator must combine a fail-closed core with one selected provider, materialize and validate one canonical profile instead of expecting inheritance to widen the blocked core.
Nono 0.74 `profile show --json` omits resolved environment, command-policy, connect-port, and custom-credential fields. Use strict validation plus behavioral `nono run` or `nono why --command` proofs for those capabilities rather than treating the resolved JSON as complete evidence.
## Runtime working directory
@@ -100,6 +102,10 @@ Set networking enabled without a domain allowlist inherited from the chosen pare
State that open mode allows destinations beyond those currently known to the project.
## Exact loopback ports
On Linux with Landlock network support, use `network.connect_port` for outbound-only access to exact TCP ports. Use `network.open_port` only when the sandbox must both connect and bind. A port rule does not establish hostname or credential access; validate those separately. Prove exact enforcement with live listeners on the selected port and an adjacent denied port because strict schema validation alone does not demonstrate kernel enforcement.
## URL opening and service launching
`open_urls.allow_origins` controls origins that sandboxed tools may ask the host to open. `open_urls.allow_localhost` controls localhost URL opening. These capabilities remain separate from direct Internet access.
@@ -116,6 +122,10 @@ The installed `nolabs-ai/pi` parent enables selected OpenAI, Anthropic, and GitH
Treat a credential route as a separate capability from reaching its domain. Confirm both the route and endpoint restrictions. Prefer nono-mediated credential routes over exposing real API keys inside the sandbox.
## Command policies
Place each caller edge under `command_policies.commands.<name>.from.<caller>`. Inherited invocation denials remain restrictive when a child declares a more permissive default, so verify the effective decision with `nono why --profile <profile> --command <name> --caller <caller> --json -- <args>`. Nono 0.74 creates a command-policy supervisor Unix socket under `TMPDIR`; keep the private runtime path short enough for Linux `SUN_LEN` until the tracked limitation is fixed.
## Local sources of truth
Read these when behavior or schema details are uncertain:
+30
View File
@@ -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.
View File
+1
View File
@@ -0,0 +1 @@
"""Capability-specific Nono policy tests."""
+20
View File
@@ -0,0 +1,20 @@
# Slice 1 core policy proofs
The hermetic contract tests inspect reviewed core/provider fragments, trusted composition, generated settings with no shell/background tool exposure, package-lock evidence, and the repository-managed LM Studio registration:
```bash
python -m unittest tests.nono.capabilities.core.test_policy
node --test --experimental-strip-types extensions/lm-studio/index.test.ts
```
The mandatory Linux host gate requires Nono 0.74.x. It creates only isolated HOME/XDG/session roots and temporary profile paths. It proves Nono composition and inherited command-denial/caller-edge behavior, personal/work Pi startup with the managed LM Studio extension loaded from isolated settings, real managed-extension model requests to the fixture endpoint, raw credential filtering, cross-session state denial, read-only versus read/write worktrees, and real selected-versus-adjacent port enforcement using loopback listeners on ports 1234 and 1235. Nono 0.74's grant is destination-port-only, as recorded in ADR 0003:
```bash
PYTHONPATH=. python tests/nono/capabilities/core/host_gate.py
```
The gate fails instead of skipping when Nono, Pi, or either fixture port is unavailable. It hashes active Pi settings, the unmanaged LM Studio source, and active Nono profile links before execution and fails if verification changes any of them. While quarantine is deferred, it also requires the active legacy source to match its reviewed lock digest; Slice 3 must replace that precondition with active-path absence and quarantined-file identity during the atomic cutover.
## Activation remains blocked
Slice 1 does not mutate active Pi settings, credentials, packages, or Nono profile links. The reviewed LM Studio source remains blocked in `config/pi-package-lock.json` until maintenance verifies the recorded legacy hash, quarantines the unmanaged active file outside Pi auto-discovery, and Slice 3 promotes this source into an immutable installed snapshot. Provider credential routes are schema-validated only; live credential evidence remains a separate explicitly approved operation.
+1
View File
@@ -0,0 +1 @@
"""Restrictive core and provider composition tests."""
+724
View File
@@ -0,0 +1,724 @@
#!/usr/bin/env python3
"""Mandatory Linux/Nono 0.74 behavioral gate for Slice 1 core policies."""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import socket
import stat
import subprocess
import sys
import tempfile
import threading
from contextlib import ExitStack, closing
from pathlib import Path
from typing import Any
from tests.nono.capabilities.core.policy import compose_profile, isolated_settings, load_profile
from tests.nono.harness.fixtures import isolated_environment
ROOT = Path(__file__).resolve().parents[4]
PROFILES = ROOT / "profiles" / "nono"
LM_STUDIO = ROOT / "extensions" / "lm-studio" / "index.ts"
PI_CLI = Path(
"/home/jb/.bun/install/global/node_modules/"
"@earendil-works/pi-coding-agent/dist/bundle/cli.js"
)
PACKAGE_LOCK = ROOT / "config" / "pi-package-lock.json"
LEGACY_LM_STUDIO = Path("/home/jb/.pi/agent/extensions/lm-studio.ts")
ACTIVE_STATE_PATHS = (
Path("/home/jb/.pi/agent/settings.json"),
LEGACY_LM_STUDIO,
Path("/home/jb/.config/nono/profiles/pi-local.json"),
Path("/home/jb/.config/nono/profiles/pi-work.json"),
Path("/home/jb/.config/nono/profiles/pi-orca-local.json"),
Path("/home/jb/.config/nono/profiles/pi-harness-tools.json"),
)
RAW_CANARIES = {
"ANTHROPIC_API_KEY": "slice1-anthropic-canary",
"GH_TOKEN": "slice1-gh-canary",
"GITHUB_TOKEN": "slice1-github-canary",
"MORPH_API_KEY": "slice1-morph-canary",
"OPENAI_API_KEY": "slice1-openai-canary",
"SSH_AUTH_SOCK": "/tmp/slice1-forbidden-agent.sock",
}
class HostGateError(RuntimeError):
"""The mandatory Slice 1 host proof failed."""
def _active_state_snapshot() -> dict[str, tuple[str, str, int]]:
snapshot: dict[str, tuple[str, str, int]] = {}
for path in ACTIVE_STATE_PATHS:
if path.is_symlink():
target = str(path.readlink())
payload = path.resolve(strict=True).read_bytes()
elif path.is_file():
target = "regular"
payload = path.read_bytes()
else:
target = "absent"
payload = b""
snapshot[str(path)] = (
target,
hashlib.sha256(payload).hexdigest(),
path.lstat().st_mode if path.exists() or path.is_symlink() else 0,
)
return snapshot
def _read_regular_file(path: Path, *, limit: int = 1024 * 1024) -> bytes:
descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or before.st_size > limit:
raise HostGateError(f"expected one bounded regular file: {path}")
chunks: list[bytes] = []
total = 0
while True:
chunk = os.read(descriptor, min(65536, limit + 1 - total))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > limit:
raise HostGateError(f"file exceeds verification limit: {path}")
after = os.fstat(descriptor)
if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (
after.st_dev,
after.st_ino,
after.st_size,
after.st_mtime_ns,
):
raise HostGateError(f"file changed during verification: {path}")
return b"".join(chunks)
finally:
os.close(descriptor)
def _validate_lm_studio_sources() -> bytes:
lock = json.loads(PACKAGE_LOCK.read_text(encoding="utf-8"))
entries = [
entry for entry in lock.get("extensions", []) if entry.get("name") == "lm-studio"
]
if len(entries) != 1:
raise HostGateError("package lock must contain exactly one LM Studio entry")
reviewed = entries[0].get("reviewed", {})
managed_source = _read_regular_file(LM_STUDIO)
managed_digest = hashlib.sha256(managed_source).hexdigest()
if reviewed.get("sha256") != managed_digest:
raise HostGateError("managed LM Studio source does not match package-lock evidence")
legacy_source = _read_regular_file(LEGACY_LM_STUDIO)
legacy_digest = hashlib.sha256(legacy_source).hexdigest()
if reviewed.get("legacySourceSha256") != legacy_digest:
raise HostGateError("legacy LM Studio source drifted before approved quarantine")
return managed_source
def _validate_static_profiles(nono: str, environment: dict[str, str]) -> None:
profiles = sorted(PROFILES.glob("pi-core-*.json")) + sorted(
PROFILES.glob("provider-*.json")
)
for profile in profiles:
result = subprocess.run(
[nono, "profile", "validate", "--strict", str(profile), "--json"],
env=environment,
capture_output=True,
text=True,
check=False,
timeout=10,
)
if result.returncode != 0:
raise HostGateError(f"strict validation failed for {profile}: {result.stdout}")
def _prove_provider_fragment_is_not_launchable(
nono: str, environment: dict[str, str]
) -> None:
result = subprocess.run(
[
nono,
"run",
"--profile",
str(PROFILES / "provider-lm-studio.json"),
"--",
"/usr/bin/env",
],
env=environment,
capture_output=True,
text=True,
input="",
check=False,
timeout=20,
)
if result.returncode == 0 or "directory is not readable" not in (
result.stdout + result.stderr
):
raise HostGateError("provider fragment was directly launchable")
def _prove_nono_composition(nono: str, session: Any, base_environment: dict[str, str]) -> None:
profiles = session.home / ".config" / "nono" / "profiles"
profiles.mkdir(parents=True, mode=0o700, exist_ok=True)
os.chmod(profiles, 0o700)
(profiles / "slice-one-parent-a.json").write_text(
json.dumps(
{
"extends": "default",
"meta": {"name": "slice-one-parent-a"},
"groups": {"exclude": ["system_write_linux", "user_tools"]},
"network": {"block": True},
"environment": {
"allow_vars": ["PATH"],
"deny_vars": ["SLICE_ONE_SECRET_A"],
},
"open_urls": {"allow_origins": [], "allow_localhost": False},
"allow_launch_services": False,
}
),
encoding="utf-8",
)
(profiles / "slice-one-parent-b.json").write_text(
json.dumps(
{
"extends": "default",
"meta": {"name": "slice-one-parent-b"},
"network": {"block": False, "connect_port": [1234]},
"environment": {
"allow_vars": ["HOME"],
"deny_vars": ["SLICE_ONE_SECRET_B"],
},
}
),
encoding="utf-8",
)
(profiles / "slice-one-child.json").write_text(
json.dumps(
{
"extends": ["slice-one-parent-a", "slice-one-parent-b"],
"meta": {"name": "slice-one-child"},
}
),
encoding="utf-8",
)
for profile_name, parent_name, decision in (
("slice-one-command-parent", "default", "deny"),
("slice-one-command-child", "slice-one-command-parent", "allow"),
):
(profiles / f"{profile_name}.json").write_text(
json.dumps(
{
"extends": parent_name,
"meta": {"name": profile_name},
"groups": {"exclude": ["system_write_linux"]},
"command_policies": {
"commands": {
"echo": {
"executable": "/usr/bin/echo",
"from": {
"session": {
"sandbox": {
"fs_read_file": ["/usr/bin/echo"]
},
"invocation_policy": {"default": decision},
}
},
}
}
},
}
),
encoding="utf-8",
)
environment = dict(base_environment)
environment.update(
{
"SLICE_ONE_SECRET_A": "canary-a",
"SLICE_ONE_SECRET_B": "canary-b",
"SLICE_ONE_UNLISTED": "canary-c",
}
)
shown = subprocess.run(
[nono, "profile", "show", "slice-one-child", "--json"],
env=environment,
capture_output=True,
text=True,
check=False,
timeout=10,
)
if shown.returncode != 0:
raise HostGateError(shown.stderr + shown.stdout)
resolved = json.loads(shown.stdout)
if resolved.get("network", {}).get("block") is not True:
raise HostGateError("Nono multiple inheritance widened a blocked network")
observed = subprocess.run(
[nono, "run", "--profile", "slice-one-child", "--", "/usr/bin/env"],
env=environment,
capture_output=True,
text=True,
input="",
check=False,
timeout=20,
)
if observed.returncode != 0:
raise HostGateError(observed.stderr + observed.stdout)
child_environment = {
line.partition("=")[0]
for line in observed.stdout.splitlines()
if "=" in line
}
if not {"HOME", "PATH"}.issubset(child_environment):
raise HostGateError("Nono did not merge parent environment allowlists")
if child_environment.intersection(
{"SLICE_ONE_SECRET_A", "SLICE_ONE_SECRET_B", "SLICE_ONE_UNLISTED"}
):
raise HostGateError("Nono environment composition exposed denied variables")
command_policy = subprocess.run(
[
nono,
"why",
"--profile",
"slice-one-command-child",
"--command",
"echo",
"--json",
"--",
"hello",
],
env=environment,
capture_output=True,
text=True,
check=False,
timeout=10,
)
if command_policy.returncode != 0:
raise HostGateError(command_policy.stderr + command_policy.stdout)
try:
command_decision = json.loads(command_policy.stdout)
except json.JSONDecodeError as error:
raise HostGateError(
f"Nono command policy query returned invalid JSON: "
f"{command_policy.stdout!r} {command_policy.stderr!r}"
) from error
if command_decision.get("status") != "denied":
raise HostGateError("Nono child command policy widened an inherited denial")
if command_decision.get("policy_source") != (
"command_policies.commands.echo.from.session.invocation_policy"
):
raise HostGateError("Nono command policy used an unexpected caller edge")
def _listener(port: int, stack: ExitStack, http_requests: dict[int, int]) -> None:
server = stack.enter_context(closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)))
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
server.bind(("127.0.0.1", port))
except OSError as error:
raise HostGateError(f"loopback fixture port {port} is unavailable: {error}") from error
server.listen()
http_requests[port] = 0
def serve() -> None:
while True:
try:
connection, _address = server.accept()
except OSError:
return
with connection:
request = connection.recv(4096)
if request.startswith(b"GET /v1/models "):
http_requests[port] += 1
body = b'{"data":[{"id":"slice-one-model","name":"Slice One Model"}]}'
connection.sendall(
b"HTTP/1.1 200 OK\r\n"
+ f"Content-Length: {len(body)}\r\n".encode("ascii")
+ b"Content-Type: application/json\r\nConnection: close\r\n\r\n"
+ body
)
else:
connection.sendall(b"slice-1-ok")
threading.Thread(target=serve, daemon=True).start()
def _prepare_state(session: Any, context: str, lm_studio_extension: Path) -> Path:
agent = session.state / "pi-sbx" / context / "agent"
sessions = session.state / "pi-sbx" / context / "sessions"
cache = session.cache / "pi-sbx" / context
for path in (agent, sessions, cache):
path.mkdir(parents=True, mode=0o700)
os.chmod(path, 0o700)
settings = isolated_settings("lm-studio", lm_studio_extension=lm_studio_extension)
(agent / "settings.json").write_text(
json.dumps(settings, sort_keys=True) + "\n",
encoding="utf-8",
)
os.chmod(agent / "settings.json", 0o600)
instructions = session.home / ".pi" / "agent" / "AGENTS.md"
instructions.parent.mkdir(parents=True, mode=0o700)
instructions.write_text("fixture instructions\n", encoding="utf-8")
return agent
def _write_probe(
repository: Path,
other_tmp: Path,
other_state: Path,
other_repository: Path,
installed_extension: Path,
unselected_config: Path,
) -> Path:
probe = repository / "slice_one_probe.py"
probe.write_text(
"""\
import json, os, socket
from pathlib import Path
ports = {}
for port in (1234, 1235):
try:
with socket.create_connection((\"127.0.0.1\", port), timeout=1) as connection:
connection.sendall(b\"SLICE_ONE_PROBE\")
ports[str(port)] = connection.recv(32).decode(\"utf-8\")
except Exception as error:
ports[str(port)] = type(error).__name__
def can_read(path):
try:
Path(path).read_bytes()
return True
except OSError:
return False
def can_write(path):
try:
Path(path).write_text(\"changed\", encoding=\"utf-8\")
return True
except OSError:
return False
print(json.dumps({
\"ports\": ports,
\"rawCredentials\": sorted(name for name in %r if name in os.environ),
\"otherTmpReadable\": can_read(%r),
\"otherStateReadable\": can_read(%r),
\"otherRepositoryReadable\": can_read(%r),
\"installedExtensionWritable\": can_write(%r),
\"unselectedConfigReadable\": can_read(%r),
\"workdirWritable\": can_write(Path.cwd() / \"probe-write.txt\"),
}, sort_keys=True))
"""
% (
tuple(sorted(RAW_CANARIES)),
str(other_tmp / "private.txt"),
str(other_state),
str(other_repository),
str(installed_extension),
str(unselected_config),
),
encoding="utf-8",
)
return probe
def _write_refresh_runner(repository: Path, installed_extension: Path) -> Path:
runner = repository / "lm_studio_refresh.mjs"
runner.write_text(
f"""\
import registerLmStudio from {json.dumps(installed_extension.as_uri())};
let refreshModels;
registerLmStudio({{
registerProvider(name, config) {{
if (name !== "lm-studio" || typeof config.refreshModels !== "function") {{
throw new Error("managed LM Studio provider registration is invalid");
}}
refreshModels = config.refreshModels;
}},
}});
const models = await refreshModels({{
allowNetwork: true,
signal: AbortSignal.timeout(3000),
}});
console.log(JSON.stringify({{ modelIds: models.map((model) => model.id) }}));
""",
encoding="utf-8",
)
return runner
def _run(
nono: str,
profile: Path,
repository: Path,
environment: dict[str, str],
command: list[str],
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
nono,
"run",
"--profile",
str(profile),
"--allow-cwd",
"--workdir",
str(repository),
"--",
*command,
],
cwd=repository,
env=environment,
capture_output=True,
text=True,
input="",
check=False,
timeout=30,
)
def _last_json(stdout: str) -> dict[str, Any]:
for line in reversed(stdout.splitlines()):
try:
value = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(value, dict):
return value
raise HostGateError(f"command produced no JSON object: {stdout[-1000:]}")
def run_host_gate() -> dict[str, Any]:
if sys.platform != "linux":
raise HostGateError("Slice 1 host gate requires Linux")
nono = shutil.which("nono", path="/usr/bin:/bin")
if nono is None:
raise HostGateError("Nono is unavailable")
version = subprocess.run(
[nono, "--version"],
capture_output=True,
text=True,
check=False,
timeout=10,
env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"},
)
if version.returncode != 0 or not version.stdout.strip().startswith("nono 0.74."):
raise HostGateError(f"Nono 0.74.x is required; observed {version.stdout.strip()!r}")
if not PI_CLI.is_file():
raise HostGateError(f"Pi CLI is unavailable: {PI_CLI}")
managed_lm_studio_source = _validate_lm_studio_sources()
outcomes: dict[str, Any] = {}
active_state_before = _active_state_snapshot()
listener_requests: dict[int, int] = {}
with ExitStack() as listeners:
_listener(1234, listeners, listener_requests)
_listener(1235, listeners, listener_requests)
# Nono's command-policy supervisor uses a Unix socket under TMPDIR;
# keep the isolated fixture path below Linux SUN_LEN.
with isolated_environment(prefix="s1-") as fixture:
installed_extension = fixture.root / "installed" / "lm-studio" / "index.ts"
installed_extension.parent.mkdir(parents=True, mode=0o700)
installed_extension.write_bytes(managed_lm_studio_source)
os.chmod(installed_extension, 0o400)
unselected_config = fixture.root / "unselected" / "auth.json"
unselected_config.parent.mkdir(parents=True, mode=0o700)
unselected_config.write_text("fixture credential config\n", encoding="utf-8")
os.chmod(unselected_config, 0o600)
sessions = {name: fixture.session(name) for name in ("personal", "work", "other")}
for session in sessions.values():
(session.tmp / "private.txt").write_text("private\n", encoding="utf-8")
(session.repo / "private.txt").write_text("private\n", encoding="utf-8")
personal_environment = sessions["personal"].environment(fixture.env)
_validate_static_profiles(nono, personal_environment)
_prove_provider_fragment_is_not_launchable(nono, personal_environment)
_prove_nono_composition(
nono,
sessions["personal"],
personal_environment,
)
other_state = sessions["other"].state / "pi-sbx" / "other"
other_state.mkdir(parents=True, mode=0o700)
(other_state / "private.txt").write_text("private\n", encoding="utf-8")
for context in ("personal", "work"):
session = sessions[context]
other = sessions["other"]
launch_temporary_directory = Path(
tempfile.mkdtemp(prefix=f"s1{context[0]}-", dir="/tmp")
)
os.chmod(launch_temporary_directory, 0o700)
listeners.callback(
shutil.rmtree,
launch_temporary_directory,
ignore_errors=True,
)
launch_environment = session.environment(fixture.env)
launch_environment["TMPDIR"] = str(launch_temporary_directory)
_prepare_state(session, context, installed_extension)
probe = _write_probe(
session.repo,
other.tmp,
other_state / "private.txt",
other.repo / "private.txt",
installed_extension,
unselected_config,
)
refresh_runner = _write_refresh_runner(session.repo, installed_extension)
profile = compose_profile(
load_profile(PROFILES / f"pi-core-{context}.json"),
load_profile(PROFILES / "provider-lm-studio.json"),
workdir_access="read" if context == "personal" else "readwrite",
temporary_directory=launch_temporary_directory,
lm_studio_extension=installed_extension,
)
profile_path = fixture.root / f"generated-{context}.json"
profile_path.write_text(
json.dumps(profile, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.chmod(profile_path, 0o600)
validation = subprocess.run(
[nono, "profile", "validate", "--strict", str(profile_path)],
env=fixture.env,
capture_output=True,
text=True,
check=False,
timeout=10,
)
if validation.returncode != 0:
raise HostGateError(validation.stderr + validation.stdout)
probe_environment = dict(launch_environment)
probe_environment.update(RAW_CANARIES)
probe_result = _run(
nono,
profile_path,
session.repo,
probe_environment,
["/usr/bin/python3", str(probe)],
)
if probe_result.returncode != 0:
raise HostGateError(probe_result.stderr + probe_result.stdout)
observed = _last_json(probe_result.stdout)
expected_write = context == "work"
if observed.get("ports", {}).get("1234") != "slice-1-ok":
raise HostGateError(f"{context}: selected LM Studio port was denied")
if observed.get("ports", {}).get("1235") == "slice-1-ok":
raise HostGateError(f"{context}: adjacent loopback port was allowed")
if observed.get("rawCredentials"):
raise HostGateError(f"{context}: raw credential names reached the sandbox")
if (
observed.get("otherTmpReadable")
or observed.get("otherStateReadable")
or observed.get("otherRepositoryReadable")
):
raise HostGateError(f"{context}: cross-context data was readable")
if observed.get("installedExtensionWritable"):
raise HostGateError(f"{context}: installed extension was writable")
if observed.get("unselectedConfigReadable"):
raise HostGateError(f"{context}: unselected configuration was readable")
if observed.get("workdirWritable") is not expected_write:
raise HostGateError(f"{context}: workdir access did not match the declaration")
rpc = _run(
nono,
profile_path,
session.repo,
launch_environment,
[
"/usr/bin/node",
str(PI_CLI),
"--mode",
"rpc",
"--no-session",
"--no-skills",
],
)
# EOF exits RPC after the process has initialized. A successful
# startup may produce no response because no command was sent.
if rpc.returncode != 0:
raise HostGateError(f"{context}: Pi startup failed: {rpc.stderr}")
requests_before_refresh = listener_requests[1234]
model_refresh = _run(
nono,
profile_path,
session.repo,
launch_environment,
[
"/usr/bin/node",
"--experimental-strip-types",
str(refresh_runner),
],
)
if model_refresh.returncode != 0:
raise HostGateError(
f"{context}: LM Studio model refresh failed: "
f"{model_refresh.stderr}{model_refresh.stdout}"
)
refreshed_models = _last_json(model_refresh.stdout)
if refreshed_models.get("modelIds") != ["slice-one-model"]:
raise HostGateError(f"{context}: LM Studio model response was not mapped")
if listener_requests[1234] != requests_before_refresh + 1:
raise HostGateError(
f"{context}: LM Studio refresh did not request the exact model endpoint"
)
outcomes[context] = observed
if _active_state_snapshot() != active_state_before:
raise HostGateError("Slice 1 verification mutated active Pi or Nono state")
tested_profiles = sorted(PROFILES.glob("pi-core-*.json")) + sorted(
PROFILES.glob("provider-*.json")
)
return {
"suite": "slice-1-host",
"status": "passed",
"nonoVersion": version.stdout.strip().split()[-1],
"contexts": sorted(outcomes),
"loopbackChecks": 4,
"isolationChecks": 12,
"piStartupChecks": 2,
"providerRequestChecks": 2,
"directLaunchDenialChecks": 1,
"staticProfileChecks": len(tested_profiles),
"compositionChecks": 3,
"commandPolicyChecks": 2,
"profileDigests": {
profile.name: hashlib.sha256(profile.read_bytes()).hexdigest()
for profile in tested_profiles
},
"lmStudioDigest": hashlib.sha256(managed_lm_studio_source).hexdigest(),
}
def main() -> int:
active_before: dict[str, tuple[str, str, int]] | None = None
try:
active_before = _active_state_snapshot()
result = run_host_gate()
if _active_state_snapshot() != active_before:
raise HostGateError("Slice 1 verification mutated active Pi or Nono state")
print(json.dumps(result, sort_keys=True))
except (HostGateError, OSError, subprocess.SubprocessError, ValueError) as error:
if active_before is not None:
try:
if _active_state_snapshot() != active_before:
error = HostGateError(
"Slice 1 verification mutated active Pi or Nono state after "
f"failing: {error}"
)
except OSError as snapshot_error:
error = HostGateError(
f"Could not verify active state after failure: {snapshot_error}; "
f"original failure: {error}"
)
print(json.dumps({"suite": "slice-1-host", "status": "failed", "error": str(error)}))
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+251
View File
@@ -0,0 +1,251 @@
"""Trusted Slice 1 policy composition contract used by capability tests."""
from __future__ import annotations
import copy
import hashlib
import json
import os
import re
import stat
from pathlib import Path
from typing import Any, Literal
PROVIDER_PROFILE_NAMES = {
"anthropic": "provider-anthropic",
"lm-studio": "provider-lm-studio",
"morph": "provider-morph",
"openai": "provider-openai",
}
PROVIDER_CREDENTIAL_DEFINITIONS = {
"anthropic": {
"upstream": "https://api.anthropic.com",
"credential_key": "ANTHROPIC_API_KEY",
"inject_header": "x-api-key",
"credential_format": "{}",
"env_var": "ANTHROPIC_API_KEY",
},
"morph": {
"upstream": "https://api.morphllm.com",
"credential_key": "MORPH_API_KEY",
"inject_header": "Authorization",
"credential_format": "Bearer {}",
"env_var": "MORPH_API_KEY",
},
"openai": {
"upstream": "https://api.openai.com/v1",
"credential_key": "OPENAI_API_KEY",
"inject_header": "Authorization",
"credential_format": "Bearer {}",
"env_var": "OPENAI_API_KEY",
},
}
RAW_CREDENTIAL_VARIABLES = frozenset(
{
"ANTHROPIC_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"MORPH_API_KEY",
"OPENAI_API_KEY",
"SSH_AUTH_SOCK",
}
)
_LM_REGISTRATION = re.compile(r"registerProvider\(\s*[\"']lm-studio[\"']")
_ROOT = Path(__file__).resolve().parents[4]
_PROFILES = _ROOT / "profiles" / "nono"
_PACKAGE_LOCK = _ROOT / "config" / "pi-package-lock.json"
class CompositionError(ValueError):
"""A reviewed core/provider pair cannot produce one narrow launch policy."""
def load_profile(path: Path) -> dict[str, Any]:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise CompositionError(f"profile is not an object: {path}")
return data
def compose_profile(
core: dict[str, Any],
provider: dict[str, Any],
*,
workdir_access: Literal["read", "readwrite"],
temporary_directory: Path,
lm_studio_extension: Path | None = None,
) -> dict[str, Any]:
"""Materialize one reviewed core and one provider into a launch profile.
This intentionally does not model Nono inheritance. Nono preserves a blocked
network across inherited parents, so the trusted generator must replace the
core's fail-closed network block with exactly one reviewed provider route.
"""
profile = copy.deepcopy(core)
core_name = _profile_name(core)
provider_name = _profile_name(provider)
if core_name not in {"pi-core-personal", "pi-core-work"}:
raise CompositionError(f"unapproved core profile: {core_name}")
if core != load_profile(_PROFILES / f"{core_name}.json"):
raise CompositionError(f"core profile differs from reviewed source: {core_name}")
expected_provider = PROVIDER_PROFILE_NAMES.get(
provider_name.removeprefix("provider-")
)
if expected_provider != provider_name:
raise CompositionError(f"unapproved provider profile: {provider_name}")
if provider != load_profile(_PROFILES / f"{provider_name}.json"):
raise CompositionError(
f"provider profile differs from reviewed source: {provider_name}"
)
_validate_provider_network(provider_name, provider.get("network"))
profile["extends"] = "default"
profile["meta"] = {
"name": f"generated-{core_name.removeprefix('pi-core-')}-{provider_name.removeprefix('provider-')}",
"description": "Generated Slice 1 composition fixture",
}
profile["workdir"] = {"access": workdir_access}
profile["network"] = copy.deepcopy(provider["network"])
environment = profile.get("environment")
if not isinstance(environment, dict):
raise CompositionError("core profile has no environment policy")
denied = set(environment.get("deny_vars", []))
if denied != RAW_CREDENTIAL_VARIABLES:
raise CompositionError("core raw-credential deny list is incomplete")
filesystem = profile.get("filesystem")
if not isinstance(filesystem, dict):
raise CompositionError("core profile has no filesystem policy")
if not temporary_directory.is_absolute():
raise CompositionError("launch temporary directory must be absolute")
try:
metadata = temporary_directory.lstat()
resolved_temporary_directory = temporary_directory.resolve(strict=True)
except OSError as error:
raise CompositionError("launch temporary directory must already exist") from error
if (
temporary_directory.is_symlink()
or not stat.S_ISDIR(metadata.st_mode)
or resolved_temporary_directory != temporary_directory
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) & 0o077
or resolved_temporary_directory in {Path("/"), Path("/tmp"), Path.home()}
):
raise CompositionError(
"launch temporary directory must be one private owned directory"
)
mutable_directories = list(filesystem.get("allow", []))
if "$TMPDIR" in mutable_directories or "/tmp" in mutable_directories:
raise CompositionError("core profile contains a broad temporary grant")
mutable_directories.append(str(resolved_temporary_directory))
filesystem["allow"] = sorted(set(mutable_directories))
set_variables = dict(environment.get("set_vars", {}))
set_variables["TMPDIR"] = str(resolved_temporary_directory)
environment["set_vars"] = set_variables
if provider_name == "provider-lm-studio":
if lm_studio_extension is None:
raise CompositionError("LM Studio requires one installed extension path")
_validate_lm_studio_extension(lm_studio_extension)
reads = list(filesystem.get("read", []))
reads.append(str(lm_studio_extension))
filesystem["read"] = sorted(set(reads))
elif lm_studio_extension is not None:
raise CompositionError("non-LM providers cannot load the LM Studio extension")
return profile
def isolated_settings(
provider: str,
*,
lm_studio_extension: Path | None = None,
) -> dict[str, Any]:
if provider not in PROVIDER_PROFILE_NAMES:
raise CompositionError(f"unknown provider: {provider}")
extensions: list[str] = []
if provider == "lm-studio":
if lm_studio_extension is None:
raise CompositionError("LM Studio settings require one extension path")
_validate_lm_studio_extension(lm_studio_extension)
extensions.append(str(lm_studio_extension))
elif lm_studio_extension is not None:
raise CompositionError("LM Studio extension is forbidden for another provider")
return {
"defaultProjectTrust": "never",
"defaultTools": ["read", "edit", "write"],
"enableInstallTelemetry": False,
"extensions": extensions,
"packages": [],
"quietStartup": True,
}
def assert_single_lm_studio_registration(sources: list[Path]) -> None:
registrations: list[tuple[Path, int]] = []
for source in sources:
contents = source.read_text(encoding="utf-8")
if "http://localhost:1234" in contents:
raise CompositionError(f"legacy localhost LM Studio URL remains: {source}")
registrations.extend(
(source, match.start()) for match in _LM_REGISTRATION.finditer(contents)
)
if len(registrations) != 1:
raise CompositionError(
"expected exactly one LM Studio provider registration; found "
+ str(len(registrations))
)
def _validate_lm_studio_extension(path: Path) -> None:
if not path.is_absolute() or path.is_symlink() or not path.is_file():
raise CompositionError("LM Studio extension must be one absolute regular file")
lock = json.loads(_PACKAGE_LOCK.read_text(encoding="utf-8"))
entries = [
entry for entry in lock.get("extensions", []) if entry.get("name") == "lm-studio"
]
if len(entries) != 1:
raise CompositionError("package lock must contain one LM Studio entry")
expected_digest = entries[0].get("reviewed", {}).get("sha256")
observed_digest = hashlib.sha256(path.read_bytes()).hexdigest()
if expected_digest != observed_digest:
raise CompositionError("LM Studio extension differs from reviewed lock source")
def _profile_name(profile: dict[str, Any]) -> str:
meta = profile.get("meta")
if not isinstance(meta, dict) or not isinstance(meta.get("name"), str):
raise CompositionError("profile has no name")
return meta["name"]
def _validate_provider_network(name: str, network: Any) -> None:
if not isinstance(network, dict) or network.get("block") is not False:
raise CompositionError(f"provider route is not enabled: {name}")
credentials = network.get("credentials", [])
connect_ports = network.get("connect_port", [])
if name == "provider-lm-studio":
if (
set(network) != {"block", "connect_port"}
or credentials
or connect_ports != [1234]
):
raise CompositionError("LM Studio must grant only connect port 1234")
return
expected_credential = name.removeprefix("provider-")
if set(network) != {
"block",
"allow_domain",
"credentials",
"custom_credentials",
}:
raise CompositionError(f"provider network contains an extra route: {name}")
if network["allow_domain"] or credentials != [expected_credential] or connect_ports:
raise CompositionError(f"provider must activate only its own route: {name}")
definitions = network.get("custom_credentials")
if definitions != {
expected_credential: PROVIDER_CREDENTIAL_DEFINITIONS[expected_credential]
}:
raise CompositionError(f"provider credential definition mismatch: {name}")
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
import copy
import hashlib
import json
import tempfile
import unittest
from pathlib import Path
from .policy import (
CompositionError,
PROVIDER_CREDENTIAL_DEFINITIONS,
PROVIDER_PROFILE_NAMES,
RAW_CREDENTIAL_VARIABLES,
assert_single_lm_studio_registration,
compose_profile,
isolated_settings,
load_profile,
)
ROOT = Path(__file__).resolve().parents[4]
PROFILES = ROOT / "profiles" / "nono"
LM_STUDIO = ROOT / "extensions" / "lm-studio" / "index.ts"
PI_INSTALL = "/home/jb/.bun/install/global/node_modules/@earendil-works/pi-coding-agent"
JITI_INSTALL = "/home/jb/.bun/install/global/node_modules/jiti"
GLOBAL_INSTRUCTIONS = "/home/jb/.pi/agent/AGENTS.md"
class PolicyContractTests(unittest.TestCase):
def test_cores_are_fail_closed_and_separate_state(self) -> None:
for context in ("personal", "work"):
with self.subTest(context=context):
profile = load_profile(PROFILES / f"pi-core-{context}.json")
self.assertEqual(profile["extends"], "default")
self.assertEqual(profile["workdir"], {"access": "none"})
self.assertEqual(profile["network"], {"block": True})
self.assertEqual(
set(profile["environment"]["deny_vars"]),
RAW_CREDENTIAL_VARIABLES,
)
self.assertNotIn("SSH_AUTH_SOCK", profile["environment"]["allow_vars"])
self.assertIn("system_write_linux", profile["groups"]["exclude"])
self.assertIn("user_tools", profile["groups"]["exclude"])
grants = json.dumps(profile["filesystem"], sort_keys=True)
self.assertIn(f"pi-sbx/{context}", grants)
other = "work" if context == "personal" else "personal"
self.assertNotIn(f"pi-sbx/{other}", grants)
self.assertNotIn("$WORKDIR", grants)
self.assertNotIn('"/tmp"', grants)
self.assertNotIn("$TMPDIR", grants)
self.assertEqual(
set(profile["filesystem"]["allow"]),
{
f"$XDG_CACHE_HOME/pi-sbx/{context}",
f"$XDG_STATE_HOME/pi-sbx/{context}/agent",
f"$XDG_STATE_HOME/pi-sbx/{context}/sessions",
},
)
self.assertEqual(
profile["filesystem"]["read"],
[PI_INSTALL, JITI_INSTALL],
)
self.assertEqual(
set(profile["filesystem"]["read_file"]),
{GLOBAL_INSTRUCTIONS, "/usr/bin/node"},
)
self.assertEqual(
set(profile["filesystem"]["allow_file"]),
{"/dev/null", "/dev/stderr", "/dev/stdout", "/dev/tty"},
)
for capability in ("write", "write_file", "unix_socket"):
self.assertEqual(profile["filesystem"].get(capability, []), [])
def test_provider_profiles_select_one_route(self) -> None:
for provider, profile_name in PROVIDER_PROFILE_NAMES.items():
with self.subTest(provider=provider):
profile = load_profile(PROFILES / f"{profile_name}.json")
network = profile["network"]
self.assertFalse(network["block"])
if provider == "lm-studio":
self.assertEqual(set(network), {"block", "connect_port"})
self.assertEqual(network["connect_port"], [1234])
self.assertNotIn("credentials", network)
else:
self.assertEqual(
set(network),
{"block", "allow_domain", "credentials", "custom_credentials"},
)
self.assertEqual(network["allow_domain"], [])
self.assertEqual(network["credentials"], [provider])
self.assertEqual(
network["custom_credentials"],
{provider: PROVIDER_CREDENTIAL_DEFINITIONS[provider]},
)
self.assertNotIn("connect_port", network)
self.assertEqual(profile["workdir"], {"access": "none"})
self.assertIn("system_read_linux_core", profile["groups"]["exclude"])
self.assertIn("system_read_macos", profile["groups"]["exclude"])
self.assertTrue(profile["environment"]["allow_vars"])
self.assertEqual(
set(profile["environment"]["deny_vars"]),
RAW_CREDENTIAL_VARIABLES,
)
self.assertEqual(profile["open_urls"]["allow_origins"], [])
self.assertFalse(profile["allow_launch_services"])
def test_trusted_composition_replaces_block_with_one_provider(self) -> None:
core = load_profile(PROFILES / "pi-core-personal.json")
provider = load_profile(PROFILES / "provider-lm-studio.json")
with tempfile.TemporaryDirectory() as directory:
temporary_directory = Path(directory)
generated = compose_profile(
core,
provider,
workdir_access="read",
temporary_directory=temporary_directory,
lm_studio_extension=LM_STUDIO,
)
self.assertEqual(generated["workdir"], {"access": "read"})
self.assertIn(str(temporary_directory), generated["filesystem"]["allow"])
self.assertEqual(
generated["environment"]["set_vars"]["TMPDIR"],
str(temporary_directory),
)
self.assertEqual(generated["network"]["connect_port"], [1234])
self.assertFalse(generated["network"]["block"])
self.assertNotIn("command_policies", generated)
self.assertIn(str(LM_STUDIO), generated["filesystem"]["read"])
with self.assertRaisesRegex(CompositionError, "cannot load"):
compose_profile(
core,
load_profile(PROFILES / "provider-openai.json"),
workdir_access="read",
temporary_directory=temporary_directory,
lm_studio_extension=LM_STUDIO,
)
with self.assertRaisesRegex(CompositionError, "private owned"):
compose_profile(
core,
provider,
workdir_access="read",
temporary_directory=Path("/tmp"),
lm_studio_extension=LM_STUDIO,
)
mutated_core = copy.deepcopy(core)
mutated_core["filesystem"]["allow"].append("/tmp")
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(CompositionError, "differs from reviewed"):
compose_profile(
mutated_core,
provider,
workdir_access="read",
temporary_directory=Path(directory),
lm_studio_extension=LM_STUDIO,
)
def test_generated_settings_have_no_privileged_resources(self) -> None:
normal = isolated_settings("openai")
self.assertEqual(normal["packages"], [])
self.assertEqual(normal["extensions"], [])
self.assertEqual(normal["defaultTools"], ["read", "edit", "write"])
self.assertNotIn("bash", normal["defaultTools"])
self.assertEqual(normal["defaultProjectTrust"], "never")
local = isolated_settings("lm-studio", lm_studio_extension=LM_STUDIO)
self.assertEqual(local["extensions"], [str(LM_STUDIO)])
with tempfile.TemporaryDirectory() as directory:
unreviewed = Path(directory) / "lm-studio.ts"
unreviewed.write_text("unreviewed\n", encoding="utf-8")
with self.assertRaisesRegex(CompositionError, "reviewed lock source"):
isolated_settings("lm-studio", lm_studio_extension=unreviewed)
serialized = json.dumps(local).lower()
for forbidden in ("pi-workflow", "update", "background", "notify"):
self.assertNotIn(forbidden, serialized)
def test_lm_studio_registration_is_unique_and_uses_exact_ip(self) -> None:
lock = json.loads((ROOT / "config" / "pi-package-lock.json").read_text(encoding="utf-8"))
entry = next(item for item in lock["extensions"] if item["name"] == "lm-studio")
self.assertEqual(entry["source"], "extensions/lm-studio/index.ts")
self.assertEqual(entry["status"], "blocked")
self.assertEqual(
entry["reviewed"]["sha256"],
hashlib.sha256(LM_STUDIO.read_bytes()).hexdigest(),
)
assert_single_lm_studio_registration([LM_STUDIO])
contents = LM_STUDIO.read_text(encoding="utf-8")
self.assertIn('"http://127.0.0.1:1234/v1"', contents)
self.assertNotIn("http://localhost:1234", contents)
with tempfile.TemporaryDirectory() as directory:
duplicate = Path(directory) / "duplicate.ts"
duplicate.write_text(
'pi.registerProvider("lm-studio", {});\n',
encoding="utf-8",
)
with self.assertRaisesRegex(CompositionError, "exactly one"):
assert_single_lm_studio_registration([LM_STUDIO, duplicate])
duplicate.write_text(
'pi.registerProvider("lm-studio", {});\n'
'pi.registerProvider("lm-studio", {});\n',
encoding="utf-8",
)
with self.assertRaisesRegex(CompositionError, "found 2"):
assert_single_lm_studio_registration([duplicate])
duplicate.write_text(
'const baseUrl = "http://localhost:1234/v1";\n'
'pi.registerProvider("lm-studio", {});\n',
encoding="utf-8",
)
with self.assertRaisesRegex(CompositionError, "legacy localhost"):
assert_single_lm_studio_registration([duplicate])
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "default",
"workdir": {
"access": "readwrite"
},
"network": {
"block": false
}
}
+1
View File
@@ -0,0 +1 @@
safe fixture data
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "default",
"workdir": {
"access": "none"
},
"network": {
"block": true
}
}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env python3
"""Safe fixture probe: prints only its fixed, non-secret result."""
print("nono-fixture-probe: ok")
View File
+710
View File
@@ -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",
]
+232
View File
@@ -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",
]
+417
View File
@@ -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",
]
+184
View File
@@ -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",
]
+204
View File
@@ -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()
+81
View File
@@ -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()
+180
View File
@@ -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()
+57
View File
@@ -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()
+90
View File
@@ -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()
+245
View File
@@ -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()