search-with-proof: declarative demos, brain/executor split, kube-ax constraints

Demos are data the verifier executes (fixed build recipes, runtime+path start,
HTTP/CLI steps with captures; the canary is never referable). The agent loop with
the key never runs repository code: executor tasks without a key exchange commands
and results through presigned objects. AX capacity errors are back-pressure with at
most 2 tasks in flight; caches and builds under /workspace with a 2 GiB guard;
verifiers take one presigned archive per side. Launcher fix: private /dev in bwrap.
190 tests on the host; probe 5/5 demonstrated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-05 17:12:46 +02:00
co-authored by Claude Opus 5.5
parent 721c4b0997
commit 85f4f0b3d4
25 changed files with 3409 additions and 531 deletions
+372 -120
View File
@@ -1,47 +1,44 @@
"""Offline differential verification through an isolated, batch CLI interface.
"""Verifier-owned execution of bounded JSON demonstrations and owned oracles.
Side.command and readiness_args are trusted integration configuration, not demo
contents. TARGET is an executable CLI bridge: each invocation queues arguments
for the app. It deliberately has no synchronous response channel. Alternatively
request.json contains one argument vector. HTTP apps need a trusted CLI adapter;
this lane never treats an artefact's stdout, exit status, or claimed result as
proof. Build scripts validate the immutable checkout and may write scratch only;
only Side.build_command (trusted integration config) can produce /build runtime outputs.
Run: python -m openultrasast.search.verify vulnerable fixed demo path --entry app.py
When namespaces are unavailable, a trusted dispatcher must provide separate fresh
AX tasks for the two sides. There is deliberately no local unsandboxed fallback.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import resource
import secrets
import shutil
import signal
import stat
import subprocess
import tempfile
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from contextlib import suppress
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, cast
from ..sandbox import SandboxJob
from . import _sandbox
from .oracles import BrowserExecutor, Canary, oracle_for
_BRIDGE = """#!/usr/bin/python3 -I
import json, sys
with open('/scratch/requests.jsonl', 'a') as stream:
stream.write(json.dumps(sys.argv[1:]) + '\\n')
"""
from ..sandbox import SandboxJob, SandboxResult
from . import _sandbox, task_storage
from .demo import BUILD_RECIPES, RUNTIMES, capture_output, load_demo, substitute
from .oracles import BrowserExecutor, Canary, Oracle, oracle_for
@dataclass(frozen=True)
class Side:
checkout: Path
command: tuple[str, ...]
command: tuple[str, ...] = ()
readiness_args: tuple[str, ...] = ("--ready",)
dependencies: tuple[Path, ...] = ()
build_command: tuple[str, ...] = ()
products: Path | None = None
@dataclass(frozen=True)
@@ -75,42 +72,165 @@ class VerificationRecord:
chromium_no_sandbox: bool = False
def _tree(root: Path) -> str:
"""Read every file; hash names, modes and bytes, refusing links/devices."""
def _tree(root: Path, *, generated_links: bool = False) -> str:
"""Hash every input byte; only post-build trees may link within themselves."""
digest = hashlib.sha256()
if not root.is_dir() or root.is_symlink():
raise ValueError("input must be a real directory")
for path in [root, *sorted(root.rglob("*"))]:
if root.is_symlink():
raise ValueError("input must not be a link")
if root.is_file():
digest.update(root.read_bytes())
return digest.hexdigest()
if not root.is_dir():
raise ValueError("input must be a real directory or JSON file")
paths = [root, *sorted(root.rglob("*"))]
directory_edges: dict[Path, list[Path]] = {}
for path in paths:
mode = path.lstat().st_mode
if stat.S_ISLNK(mode) and generated_links:
try:
target = path.resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise ValueError("dangling or cyclic build link") from exc
if not target.is_relative_to(root.resolve()):
raise ValueError("build link escapes its tree")
if not (target.is_file() or target.is_dir()):
raise ValueError("build link targets a special file")
digest.update(str(path.relative_to(root)).encode())
digest.update(str(mode).encode())
digest.update(os.readlink(path).encode())
if target.is_dir():
directory_edges.setdefault(path.parent.resolve(), []).append(target)
continue
if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
raise ValueError(f"links and special files refused: {path.name}")
if path != root and path.is_dir():
directory_edges.setdefault(path.parent.resolve(), []).append(path.resolve())
digest.update(str(path.relative_to(root)).encode())
digest.update(str(mode).encode())
if path.is_file():
digest.update(path.read_bytes())
if generated_links:
visiting: set[Path] = set()
visited: set[Path] = set()
def visit(directory: Path) -> None:
if directory in visiting:
raise ValueError("cyclic build directory link")
if directory in visited:
return
visiting.add(directory)
for target in directory_edges.get(directory, []):
visit(target)
visiting.remove(directory)
visited.add(directory)
visit(root.resolve())
return digest.hexdigest()
def _requests(scratch: Path, demo: Path) -> list[list[str]]:
path = demo / "request.json"
if path.is_file():
values = [json.loads(path.read_text())]
else:
queue = scratch / "requests.jsonl"
if not queue.exists():
return []
if queue.is_symlink() or not queue.is_file() or queue.stat().st_size > 65536:
raise ValueError("invalid request queue")
values = [json.loads(line) for line in queue.read_text().splitlines()]
if len(values) > 32 or any(
not isinstance(v, list) or not all(isinstance(a, str) and "\0" not in a for a in v) or sum(map(len, v)) > 16384 for v in values
):
raise ValueError("invalid or excessive CLI requests")
return values
# Executed as verifier-owned code inside the same network sandbox as the app.
# Redirects are refused: only the configured loopback application is reachable.
_HTTP_DRIVER = r"""
import http.client, importlib.util, json, os, socket, subprocess, sys, time
spec = importlib.util.spec_from_file_location('demo', '/verifier-demo.py')
demo = importlib.util.module_from_spec(spec)
spec.loader.exec_module(demo)
config = json.load(sys.stdin)
schema = config['schema']
app = subprocess.Popen(config['command'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
until = time.monotonic() + config['timeout'] / 2
while True:
if app.poll() is not None:
raise RuntimeError('HTTP application exited during readiness')
try:
connection = socket.create_connection(('127.0.0.1', schema['start']['port']), timeout=0.1)
connection.close()
break
except OSError:
if time.monotonic() >= until:
raise RuntimeError('HTTP readiness timed out')
time.sleep(0.03)
with open('/scratch/' + config['ready'], 'w') as ready:
ready.write('ready')
while not os.path.exists('/scratch/' + config['go']):
if time.monotonic() >= until + config['timeout'] / 2:
raise RuntimeError('verifier readiness acknowledgment timed out')
time.sleep(0.01)
captures, outputs = {}, []
for step in schema['steps']:
path = demo.substitute(step['path'], captures)
if not path.startswith('/') or path.startswith('//') or '\r' in path or '\n' in path:
raise ValueError('invalid expanded HTTP path')
headers = {key: demo.substitute(value, captures) for key, value in step.get('headers', {}).items()}
conn = http.client.HTTPConnection('127.0.0.1', schema['start']['port'], timeout=config['timeout'] / 2)
conn.request(step['method'], path, body=demo.substitute(step.get('body', ''), captures).encode(), headers=headers)
response = conn.getresponse()
body = response.read(1048577)
if len(body) > 1048576:
raise ValueError('HTTP response exceeds limit')
output = body.decode(errors='replace')
outputs.append(output)
demo.capture_output(step, output, captures, config['nonce'])
conn.close()
print(json.dumps(outputs))
finally:
app.terminate()
try:
app.wait(timeout=0.5)
except subprocess.TimeoutExpired:
app.kill()
app.wait()
"""
def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExecutor | None) -> SideRecord:
def _http_steps(
schema: dict[str, Any],
command: tuple[str, ...],
job: Callable[[tuple[str, ...]], SandboxJob],
scratch: Path,
mounts: dict[str, Path],
env: dict[str, str],
namespace_pid: int | None,
nonce: str,
runner: Callable[..., SandboxResult],
ready_check: Callable[[], None],
) -> list[str]:
from . import demo as demo_module
ready, go = ".ready-" + secrets.token_hex(12), ".go-" + secrets.token_hex(12)
request = {"schema": schema, "command": command, "timeout": job(command).timeout_seconds, "nonce": nonce, "ready": ready, "go": go}
acknowledged = False
with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(
runner,
job(("/usr/bin/python3", "-I", "-c", _HTTP_DRIVER)),
scratch=scratch,
mounts={**mounts, "/verifier-demo.py": Path(demo_module.__file__)},
env=env,
namespace_pid=namespace_pid,
network=True,
input_text=json.dumps(request),
)
deadline = time.monotonic() + job(command).timeout_seconds
while not (scratch / ready).exists() and not future.done() and time.monotonic() < deadline:
time.sleep(0.01)
if (scratch / ready).exists():
ready_check()
(scratch / go).touch()
acknowledged = True
result = future.result()
if not acknowledged or result.exit_code or result.timed_out:
raise ValueError("HTTP application/steps failed or readiness not acknowledged: " + result.stderr[-2000:])
outputs = json.loads(result.stdout)
if not isinstance(outputs, list) or len(outputs) != len(schema["steps"]) or not all(isinstance(value, str) for value in outputs):
raise ValueError("invalid HTTP driver output")
return cast(list[str], outputs)
def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExecutor | None, *, task_boundary: bool = False) -> SideRecord:
started = time.monotonic()
runner = _run_task if task_boundary else _sandbox.run
observations: list[RunObservation] = []
timings = dict(build=0.0, ready=0.0, run=0.0)
@@ -137,20 +257,21 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
timings["ready"],
timings["run"],
disk_peak,
isolation_mode="task-boundary" if task_boundary else _sandbox.isolation_mode(),
chromium_no_sandbox=bool(getattr(browser, "no_sandbox", False)),
)
try:
originals = (side.checkout, *side.dependencies, demo)
schema = load_demo(demo)
originals = (side.checkout, *side.dependencies, demo, *((side.products,) if side.products else ()))
initial = tuple(_tree(p) for p in originals)
for _ in range(3):
for _ in range(1 if task_boundary else 3):
tick = time.monotonic()
with tempfile.TemporaryDirectory(prefix="ousast-proof-") as temporary:
root = Path(temporary)
checkout = shutil.copytree(side.checkout, root / "checkout")
artefact = shutil.copytree(demo, root / "demo")
dependencies = [shutil.copytree(p, root / f"dep-{i}") for i, p in enumerate(side.dependencies)]
immutable = (checkout, *dependencies, artefact)
immutable = (*dependencies,)
before = tuple(_tree(p) for p in immutable)
mounts = {f"/deps/{i}": p for i, p in enumerate(dependencies)}
@@ -158,104 +279,216 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
return SandboxJob("", command, checkout, {}, timeout, 256, 128)
products = root / "products"
products.mkdir()
if side.build_command:
compiled = timed("build", _sandbox.run, job(side.build_command), scratch=products, mounts=mounts)
if side.products is not None:
shutil.copytree(side.products, products)
else:
products.mkdir()
if task_boundary:
task_storage.check(task_storage.WORKSPACE if task_storage.WORKSPACE.is_dir() else root)
recipe = schema["build"]["recipe"]
build_args = schema["build"]["arguments"]
if task_boundary and recipe != "none":
return record("could_not_build", "verifier requires executor-built archive; builds are forbidden")
if recipe != "none":
build_input = checkout / build_args[0]
manifest = recipe in ("pip", "maven")
if not build_input.resolve().is_relative_to(checkout.resolve()) or not (
build_input.is_file() if manifest else build_input.is_dir()
):
return record("could_not_build", "recipe input missing or outside checkout")
command = (*BUILD_RECIPES[recipe], "/workspace/" + build_args[0])
if recipe == "maven":
command += ("package", "-DskipTests")
elif recipe == "gradle":
command += ("assemble",)
compiled = timed("build", runner, job(command), scratch=products, mounts=mounts, writable_checkout=True)
if compiled.exit_code or compiled.timed_out:
return record("could_not_build", "trusted build failed: " + compiled.stderr[:300])
product_hash = _tree(products)
mounts["/build"] = products
build_scratch = root / "build"
build_scratch.mkdir()
built = timed(
"build", _sandbox.run, job(("/bin/sh", "/demo/build.sh")), scratch=build_scratch, mounts={**mounts, "/demo": artefact}
)
if built.exit_code or built.timed_out:
return record("could_not_build", "build failed: " + built.stderr[:300])
if tuple(_tree(p) for p in immutable) != before or _tree(products) != product_hash:
return record("could_not_build", "build recipe failed: " + compiled.stderr[-2000:])
if tuple(_tree(p) for p in immutable) != before:
return record("inconclusive", "build changed immutable inputs")
product_hash = _tree(products, generated_links=True)
checkout_hash = _tree(checkout, generated_links=True)
mounts["/build"] = products
start_spec = schema["start"]
entry = checkout / start_spec["path"]
if not entry.is_file() or not entry.resolve().is_relative_to(checkout.resolve()):
return record("could_not_build", "runtime entry missing from checkout")
command = (*RUNTIMES[start_spec["runtime"]], "/workspace/" + start_spec["path"], *start_spec["arguments"])
if start_spec["runtime"] == "python":
# Fixed bootstrap enables ordinary local imports and the pip
# recipe's offline packages without trusting PYTHONPATH.
bootstrap = (
"import os,runpy,sys; sys.dont_write_bytecode=True; p=sys.argv.pop(1); "
"sys.path[:0]=['/build/packages',os.path.dirname(p)]; sys.argv[0]=p; "
"runpy.run_path(p,run_name='__main__')"
)
command = ("/usr/bin/python3", "-I", "-c", bootstrap, "/workspace/" + start_spec["path"], *start_spec["arguments"])
oracle = oracle_for(family, browser)
assert oracle is not None
try:
canary = Canary.fresh(root / "fixture")
if task_boundary and hasattr(oracle, "task_boundary"):
oracle.task_boundary = True
app_env = oracle.prepare(canary)
app_scratch = canary.root if family == "command" else root / "app"
app_scratch.mkdir(exist_ok=True)
fixture = canary.root
if _sandbox.isolation_mode() == "root-no-userns" and family != "command":
if not task_boundary and _sandbox.isolation_mode() == "root-no-userns" and family != "command":
# Observer originals remain private. Only the app gets a
# read-only copy of the fixtures its interface requires.
fixture = shutil.copytree(canary.root, root / "app-fixture")
for path in [fixture, *fixture.rglob("*")]:
path.chmod(0o555 if path.is_dir() else 0o444)
app_mounts = {**mounts, "/fixture": fixture}
ready = timed(
"ready",
_sandbox.run,
job((*side.command, *side.readiness_args)),
scratch=app_scratch,
mounts=app_mounts,
env=app_env,
namespace_pid=oracle.namespace_pid,
)
if ready.exit_code or ready.timed_out:
return record("could_not_run", "app readiness failed: " + ready.stderr[:300])
if family == "ssrf" and oracle.observe()[0]:
return record("inconclusive", "readiness triggered the SSRF oracle")
# Readiness cannot supply the attack's evidence or leave a command marker.
if family == "command" and any(canary.root.iterdir()):
return record("inconclusive", "readiness modified command fixture")
attack_scratch = root / "attack"
attack_scratch.mkdir()
if (artefact / "attack.sh").is_file():
bridge = root / "target"
bridge.write_text(_BRIDGE)
bridge.chmod(0o555)
attacker_input = root / "attacker-input"
attacker_input.mkdir()
attack = timed(
"run",
_sandbox.run,
job(("/bin/sh", "/demo/attack.sh"), checkout=attacker_input),
scratch=attack_scratch,
mounts={"/demo": artefact, "/target": bridge},
env={"TARGET": "/target", "SCRATCH": "/scratch"},
)
# Output/status are never evidence. Timeout and explicit filesystem
# denials veto verification; read-only mounts enforce the boundary.
if attack.timed_out:
return record("inconclusive", "attack timed out")
if "Read-only file system" in attack.stderr or "Permission denied" in attack.stderr:
return record("inconclusive", "attacker boundary refused")
for arguments in _requests(attack_scratch, artefact):
result = timed(
"run",
_sandbox.run,
job((*side.command, *arguments)),
if schema["start"]["mode"] == "cli":
ready = timed(
"ready",
runner,
job((*command, *side.readiness_args)),
scratch=app_scratch,
mounts=app_mounts,
env=app_env,
namespace_pid=oracle.namespace_pid,
)
if result.timed_out:
return record("inconclusive", "app attack timed out")
timed("run", oracle.capture, result.stdout)
if ready.exit_code or ready.timed_out:
return record("could_not_run", "app readiness failed: " + ready.stderr[-2000:])
if family == "ssrf" and oracle.observe()[0]:
return record("inconclusive", "readiness triggered the SSRF oracle")
# Readiness cannot supply the attack's evidence or leave a command marker.
if family == "command" and any(canary.root.iterdir()):
return record("inconclusive", "readiness modified command fixture")
def http_ready_check(oracle: Oracle = oracle, canary: Canary = canary) -> None:
if family == "ssrf" and oracle.observe()[0]:
raise ValueError("HTTP readiness triggered the SSRF oracle")
if family == "command" and (canary.root / "marker").exists():
raise ValueError("HTTP readiness modified command fixture")
captures: dict[str, str] = {}
if schema["start"]["mode"] == "http":
outputs = timed(
"run",
_http_steps,
schema,
command,
job,
app_scratch,
app_mounts,
app_env,
oracle.namespace_pid,
canary.nonce,
runner,
http_ready_check,
)
for output in outputs:
timed("run", oracle.capture, output)
else:
for step in schema["steps"]:
arguments = [substitute(arg, captures) for arg in step["arguments"]]
result = timed(
"run",
runner,
job((*command, *arguments)),
scratch=app_scratch,
mounts=app_mounts,
env=app_env,
namespace_pid=oracle.namespace_pid,
input_text=substitute(step.get("stdin", ""), captures),
)
if result.timed_out:
return record("inconclusive", "app attack timed out")
if result.exit_code:
return record("could_not_run", "app invocation failed")
timed("run", oracle.capture, result.stdout)
capture_output(step, result.stdout, captures, canary.nonce)
observed, evidence = timed("run", oracle.observe)
if (
tuple(_tree(p) for p in immutable) != before
or tuple(_tree(p) for p in originals) != initial
or _tree(products) != product_hash
or _tree(products, generated_links=True) != product_hash
or _tree(checkout, generated_links=True) != checkout_hash
):
return record("inconclusive", "immutable input or dependency changed")
observations.append(RunObservation(observed, evidence, time.monotonic() - tick))
finally:
oracle.close()
return record("observed", "three fresh runs completed")
except (OSError, ValueError, AssertionError) as exc:
return record("observed", "fresh task run completed" if task_boundary else "three fresh runs completed")
except task_storage.ScratchLimit:
return record("could_not_build", "scratch limit")
except (OSError, ValueError, AssertionError, KeyError, IndexError, TypeError) as exc:
return record("could_not_run", f"verification unavailable or refused: {exc}")
def _run_task(
job: SandboxJob,
*,
scratch: Path,
mounts: dict[str, Path] | None = None,
env: dict[str, str] | None = None,
input_text: str = "",
**kwargs: Any,
) -> SandboxResult:
"""Only called in a fresh externally isolated task by verify_side_task.
The schema selects tracked programs, never agent scripts. The task itself is
the filesystem/network boundary. Its launch manifest must contain no keys.
"""
translations = {"/workspace": str(job.repo_root), "/scratch": str(scratch), **{k: str(v) for k, v in (mounts or {}).items()}}
pattern = re.compile("|".join(re.escape(key) for key in sorted(translations, key=len, reverse=True)))
def translate(value: str) -> str:
# One substitution pass: host temp paths may themselves contain /scratch.
return pattern.sub(lambda match: translations[match.group(0)], value)
command = tuple(translate(arg) for arg in job.command)
environment = {"PATH": "/usr/bin:/bin", "HOME": str(scratch), "TMPDIR": str(scratch), **task_storage.environment()}
environment.update({key: translate(value) for key, value in (env or {}).items()})
def limits() -> None:
resource.setrlimit(resource.RLIMIT_FSIZE, (1024 * 1024,) * 2)
resource.setrlimit(resource.RLIMIT_AS, (job.memory_mb * 1024**2,) * 2)
resource.setrlimit(resource.RLIMIT_CPU, (job.timeout_seconds + 1,) * 2)
# Driver JSON includes command paths, but arbitrary user input stays data.
if "-c" in command and _HTTP_DRIVER in job.command:
config = json.loads(input_text)
config["command"] = [translate(arg) for arg in config["command"]]
input_text = json.dumps(config)
with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
proc = subprocess.Popen(
command, cwd=scratch, env=environment, stdin=subprocess.PIPE, stdout=out, stderr=err, start_new_session=True, preexec_fn=limits
)
expired = False
try:
task_storage.communicate(proc, input_text.encode(), job.timeout_seconds, job.repo_root.parent, files=(out, err))
except subprocess.TimeoutExpired:
expired = True
finally:
with suppress(ProcessLookupError):
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
out.seek(0)
err.seek(0)
return SandboxResult(
proc.returncode, out.read(1048576).decode(errors="replace"), err.read(1048576).decode(errors="replace"), expired
)
def verify_side_task(side: Side, demo: Path, family: str, timeout_seconds: int = 5) -> SideRecord:
"""Fresh AX task entry: one repetition, grouped by the trusted dispatcher.
This explicit API is never selected as a fallback in the host verifier.
"""
load_demo(demo)
if timeout_seconds < 1:
raise ValueError("timeout must be positive")
browser = BrowserExecutor(task_boundary=True) if family == "xss" else None
if oracle_for(family, browser) is None:
return SideRecord("no_oracle", (), 0, "no owned oracle", isolation_mode="task-boundary")
return _side(side, demo, family, timeout_seconds, browser, task_boundary=True)
def verify(
affected: Side,
safe: Side,
@@ -264,6 +497,7 @@ def verify(
*,
timeout_seconds: int = 5,
browser: BrowserExecutor | None = None,
task_dispatcher: Callable[..., SideRecord] | None = None,
) -> VerificationRecord:
"""Affected is vulnerable/head; safe is fixed/base. No negative verdict exists."""
started = time.monotonic()
@@ -271,9 +505,31 @@ def verify(
return VerificationRecord("no_oracle", (), time.monotonic() - started, "no configured owned oracle for " + family)
if timeout_seconds < 1:
raise ValueError("timeout must be positive")
_sandbox.isolation_check(timeout_seconds=timeout_seconds)
sides = tuple(_side(s, artefact, family, timeout_seconds, browser) for s in (affected, safe))
if sides[1].outcome != "observed":
try:
load_demo(artefact)
except (OSError, ValueError) as exc:
return VerificationRecord("inconclusive", (), time.monotonic() - started, "invalid declarative demo: " + str(exc))
mode = "task-boundary" if task_dispatcher is not None else _sandbox.isolation_mode()
if task_dispatcher is None:
try:
_sandbox.isolation_check(timeout_seconds=timeout_seconds)
except _sandbox.IsolationUnavailable:
raise _sandbox.IsolationUnavailable("task-boundary requires a trusted fresh-task dispatcher; no local fallback") from None
if mode == "task-boundary":
assert task_dispatcher is not None
# Only trusted infrastructure supplies this callback. One fresh task per
# side; task IDs and cleanup are the dispatcher's responsibility.
sides = tuple(
task_dispatcher(side=s, demo=load_demo(artefact), family=family, timeout_seconds=timeout_seconds, fresh_task=True)
for s in (affected, safe)
)
if any(s.isolation_mode != "task-boundary" for s in sides):
raise _sandbox.IsolationUnavailable("dispatcher did not attest task-boundary isolation")
else:
sides = tuple(_side(s, artefact, family, timeout_seconds, browser) for s in (affected, safe))
if any(s.outcome == "observed" and len(s.runs) != 3 for s in sides):
outcome, reason = "inconclusive", "each side requires exactly three fresh observations"
elif sides[1].outcome != "observed":
outcome, reason = "inconclusive", "safe side did not complete build/readiness/verification"
elif sides[0].outcome != "observed":
outcome, reason = sides[0].outcome, sides[0].reason
@@ -286,6 +542,7 @@ def verify(
sides,
time.monotonic() - started,
reason,
isolation_mode=mode,
chromium_no_sandbox=bool(getattr(browser, "no_sandbox", False)),
)
@@ -298,13 +555,8 @@ def main() -> None:
parser.add_argument("safe", type=Path)
parser.add_argument("artefact", type=Path)
parser.add_argument("family")
parser.add_argument("--entry", required=True, help="trusted Python CLI entry relative to checkout")
args = parser.parse_args()
entry = Path(args.entry)
if entry.is_absolute() or ".." in entry.parts:
parser.error("entry must stay inside checkout")
command = ("/usr/bin/python3", "-I", "/workspace/" + str(entry))
result = verify(Side(args.affected, command), Side(args.safe, command), args.artefact, args.family)
result = verify(Side(args.affected), Side(args.safe), args.artefact, args.family)
print(json.dumps(asdict(result), indent=2))