From ba51230cc2f4645635916e953c5b74cd3e97ebf1 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 18:47:50 -0700 Subject: [PATCH 001/233] test(sync): reproduce missing trusted Playwright adapter --- tests/test_sync_core_runner_playwright.py | 205 ++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 tests/test_sync_core_runner_playwright.py diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py new file mode 100644 index 0000000000..5d7a0ef745 --- /dev/null +++ b/tests/test_sync_core_runner_playwright.py @@ -0,0 +1,205 @@ +"""Contract tests for the fail-closed trusted Playwright adapter.""" + +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + +import pytest + +from pdd.sync_core import ( + AttestationIssue, + AttestationSigner, + EvidenceOutcome, + RunBinding, + RunnerConfig, + UnitId, + VerificationObligation, + VerificationProfile, + run_profile, +) +from pdd.sync_core.runner import playwright_validator_config_digest + + +UNIT = UnitId("repository-1", PurePosixPath("prompts/widget_ts.prompt"), "typescript") +IDENTITY = "chromium::tests/widget.spec.ts::widget works" + + +def _git(root: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=root, capture_output=True, text=True, check=True + ).stdout.strip() + + +def _fake_playwright(tmp_path: Path) -> Path: + runner = tmp_path / "fake_playwright.py" + runner.write_text( + "import json, os, pathlib, sys, time\n" + "root = pathlib.Path.cwd()\n" + "mode = (root / 'source.ts').read_text().strip()\n" + "if mode == 'timeout': time.sleep(5)\n" + "if mode == 'malformed': print('{')\n" + "else:\n" + " tests = [] if mode == 'zero' else [{'identity': 'chromium::tests/widget.spec.ts::widget works', 'status': {'fail': 'failed', 'skip': 'skipped'}.get(mode, 'passed')}]\n" + " if mode == 'mismatch': tests = [{'identity': 'chromium::tests/widget.spec.ts::other', 'status': 'passed'}]\n" + " if mode == 'candidate': tests.append({'identity': 'chromium::tests/widget.spec.ts::candidate only', 'status': 'passed'})\n" + " print(json.dumps({'tests': tests}))\n", + encoding="utf-8", + ) + return runner + + +def _repository( + tmp_path: Path, *, mode: str = "pass", config: str = "export default {};\n" +) -> tuple[Path, str]: + root = tmp_path / "repo" + root.mkdir() + _git(root, "init", "-q") + _git(root, "config", "user.email", "runner@example.com") + _git(root, "config", "user.name", "Runner Test") + (root / "tests").mkdir() + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "test('widget works', async () => expect(true).toBeTruthy());\n", + encoding="utf-8", + ) + (root / "playwright.config.ts").write_text(config, encoding="utf-8") + (root / "source.ts").write_text(mode, encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "protected Playwright tests") + return root, _git(root, "rev-parse", "HEAD") + + +def _run(root: Path, base: str, head: str, fake: Path, timeout: int = 2): + paths = (PurePosixPath("tests/widget.spec.ts"),) + try: + config_digest = playwright_validator_config_digest(root, base, paths) + except ValueError: + config_digest = "invalid-playwright-config" + obligation = VerificationObligation( + "playwright", "test", "playwright", config_digest, ("REQ-1",), paths + ) + profile = VerificationProfile(UNIT, (obligation,), ("REQ-1",), "profile-v1") + return run_profile( + root, + profile, + RunBinding("snapshot-v1", base, head), + AttestationIssue( + AttestationSigner("trusted-ci", b"p" * 32), + "id", + "nonce", + datetime(2026, 7, 10, tzinfo=timezone.utc), + ), + config=RunnerConfig( + timeout_seconds=timeout, playwright_command=(sys.executable, str(fake)) + ), + ) + + +def test_playwright_passing_collected_test_is_pass(tmp_path: Path) -> None: + root, commit = _repository(tmp_path) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + assert executions[0].outcome is EvidenceOutcome.PASS + + +@pytest.mark.parametrize( + ("mode", "outcome"), + [ + ("fail", EvidenceOutcome.FAIL), + ("skip", EvidenceOutcome.SKIP), + ("zero", EvidenceOutcome.NOT_COLLECTED), + ("timeout", EvidenceOutcome.TIMEOUT), + ("malformed", EvidenceOutcome.COLLECTION_ERROR), + ], +) +def test_playwright_normalizes_non_pass_outcomes( + tmp_path: Path, mode: str, outcome: EvidenceOutcome +) -> None: + root, commit = _repository(tmp_path, mode=mode) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path), 1) + assert executions[0].outcome is outcome + + +@pytest.mark.parametrize("mode", ["mismatch", "candidate"]) +def test_playwright_collection_identity_mismatch_cannot_pass( + tmp_path: Path, mode: str +) -> None: + root, base = _repository(tmp_path) + (root / "source.ts").write_text(mode, encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "change application behavior") + _envelope, executions = _run( + root, base, _git(root, "rev-parse", "HEAD"), _fake_playwright(tmp_path) + ) + assert executions[0].outcome is EvidenceOutcome.QUARANTINED + + +def test_playwright_removed_protected_test_cannot_pass(tmp_path: Path) -> None: + root, base = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text("// removed\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "remove protected test") + _envelope, executions = _run( + root, base, _git(root, "rev-parse", "HEAD"), _fake_playwright(tmp_path) + ) + assert executions[0].outcome is EvidenceOutcome.QUARANTINED + + +@pytest.mark.parametrize("path", ["playwright.config.ts", "setup.ts", "reporter.ts"]) +def test_playwright_config_and_support_mutation_cannot_pass( + tmp_path: Path, path: str +) -> None: + config = "import './setup';\nexport default { reporter: './reporter.ts' };\n" + root, _commit = _repository(tmp_path, config=config) + (root / "setup.ts").write_text("export {};\n", encoding="utf-8") + (root / "reporter.ts").write_text("export default class Reporter {}\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add protected support") + base = _git(root, "rev-parse", "HEAD") + (root / path).write_text("changed\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "mutate protected Playwright support") + _envelope, executions = _run( + root, base, _git(root, "rev-parse", "HEAD"), _fake_playwright(tmp_path) + ) + assert executions[0].outcome in {EvidenceOutcome.ERROR, EvidenceOutcome.QUARANTINED} + + +def test_playwright_dirty_support_cannot_influence_run(tmp_path: Path) -> None: + root, commit = _repository(tmp_path) + (root / "ambient.ts").write_text("export {};\n", encoding="utf-8") + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + assert executions[0].outcome is EvidenceOutcome.QUARANTINED + + +def test_playwright_subprocess_cannot_read_secret( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, commit = _repository(tmp_path) + fake = _fake_playwright(tmp_path) + fake.write_text( + fake.read_text(encoding="utf-8") + + "\nassert 'PDD_ATTESTATION_SIGNING_KEY' not in os.environ\n", + encoding="utf-8", + ) + monkeypatch.setenv("PDD_ATTESTATION_SIGNING_KEY", "must-not-leak") + _envelope, executions = _run(root, commit, commit, fake) + assert executions[0].outcome is EvidenceOutcome.PASS + + +@pytest.mark.parametrize( + "config", + [ + "export default process.env.PLAYWRIGHT_CONFIG;\n", + "export default { grep: /widget/ };\n", + "export default { retries: 1 };\n", + "export default { webServer: { command: 'npm run dev' } };\n", + ], +) +def test_playwright_rejects_unbound_execution_controls( + tmp_path: Path, config: str +) -> None: + root, commit = _repository(tmp_path, config=config) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + assert executions[0].outcome is EvidenceOutcome.ERROR From e75b14fff7c0418c3b1936cc318dec79fe9a66cf Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 18:53:37 -0700 Subject: [PATCH 002/233] fix(sync): add trusted Playwright verification adapter --- pdd/sync_core/runner.py | 282 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 281 insertions(+), 1 deletion(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 14f571b65b..e4c79b139c 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -171,6 +171,12 @@ class VitestPhaseToolchain: descriptor: VitestToolchainDescriptor +PLAYWRIGHT_CONFIG_NAMES = ( + "playwright.config.js", "playwright.config.cjs", "playwright.config.mjs", + "playwright.config.ts", "playwright.config.cts", "playwright.config.mts", +) + + @dataclass(frozen=True) class RunnerConfig: """Protected execution limits for trusted validation adapters.""" @@ -181,6 +187,7 @@ class RunnerConfig: vitest_toolchain_manifest: Path | None = None vitest_toolchain_identity: str | None = None adapter_identities: tuple[tuple[str, str], ...] = () + playwright_command: tuple[str, ...] | None = None @dataclass(frozen=True) @@ -1438,6 +1445,87 @@ def vitest_validator_config_digest( return digest.hexdigest() +def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: + """Return the single protected Playwright configuration source.""" + found = [ + PurePosixPath(name) for name in PLAYWRIGHT_CONFIG_NAMES + if read_git_blob(root, ref, PurePosixPath(name)) is not None + ] + if len(found) != 1: + raise ValueError("exactly one static Playwright configuration is required") + content = read_git_blob(root, ref, found[0]) + assert content is not None + return found[0], content + + +def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePosixPath]: + """Reject dynamic controls and return literal local config imports.""" + try: + text = source.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("Playwright configuration must be UTF-8") from exc + if re.search(r"\b(process|require|import\s*\(|await|function|=>|\beval\b)\b", text): + raise ValueError("dynamic Playwright configuration is not supported") + if re.search(r"\b(grep|grepInvert|shard|retries|workers|repeatEach)\s*:", text): + raise ValueError("Playwright execution filters or retries are not allowed") + if re.search(r"\bwebServer\s*:", text): + raise ValueError("Playwright webServer is not bound by this adapter") + # Parse direct relative imports here; the closure resolver checks each blob. + references = { + path.parent / PurePosixPath(item) + for item in re.findall(r"(?:from\s+|import\s+)['\"](\.{1,2}/[^'\"]+)['\"]", text) + } + for key in ("globalSetup", "globalTeardown", "reporter"): + for value in re.findall(rf"\b{key}\s*:\s*['\"]([^'\"]+)['\"]", text): + local = _jest_local_path(value) + if local is None: + raise ValueError(f"Playwright {key} is not a static local path") + references.add(local) + return references + + +def _playwright_support_closure( + root: Path, ref: str, test_paths: tuple[PurePosixPath, ...] +) -> tuple[tuple[PurePosixPath, bytes], ...]: + """Bind config, local support, and test imports without executing them.""" + config_path, config_source = _playwright_config(root, ref) + paths = {config_path} + pending = list(_playwright_static_config(config_path, config_source)) + list(test_paths) + visited: set[PurePosixPath] = set() + while pending: + path = pending.pop() + if path in visited: + continue + visited.add(path) + source = read_git_blob(root, ref, path) + if source is None: + raise ValueError(f"Playwright local support path is missing: {path.as_posix()}") + paths.add(path) + if path.suffix in _JAVASCRIPT_SUFFIXES: + pending.extend(_local_javascript_imports(root, ref, path, source) - visited) + try: + text = source.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"Playwright source is not UTF-8: {path.as_posix()}") from exc + if re.search(r"\b(?:test|describe)\.(?:only|skip|fixme|slow)\s*\(", text): + raise ValueError("Playwright focused, skipped, fixme, or slow tests are ambiguous") + return tuple( + (path, read_git_blob(root, ref, path)) for path in sorted(paths) + if read_git_blob(root, ref, path) is not None + ) + + +def playwright_validator_config_digest( + root: Path, ref: str, test_paths: tuple[PurePosixPath, ...] +) -> str: + """Hash Playwright config and every bound local executable dependency.""" + del test_paths + digest = hashlib.sha256() + for path, content in _playwright_support_closure(root, ref, ()): + digest.update(path.as_posix().encode() + b"\0" + content + b"\0") + return digest.hexdigest() + + def _support_digest( root: Path, ref: str, profile: VerificationProfile ) -> tuple[str, tuple[PurePosixPath, ...]]: @@ -1524,6 +1612,25 @@ def _vitest_support_digest( return digest.hexdigest(), tuple(path for path, _content in closure) +def _playwright_support_digest( + root: Path, ref: str, profile: VerificationProfile +) -> tuple[str, tuple[PurePosixPath, ...]]: + """Hash the protected Playwright configuration and source closure.""" + tests = tuple( + path for obligation in profile.obligations + if obligation.validator_id == "playwright" + for path in obligation.artifact_paths + ) + try: + closure = _playwright_support_closure(root, ref, tests) + except ValueError: + return "invalid-playwright-closure", () + digest = hashlib.sha256() + for path, content in closure: + digest.update(path.as_posix().encode() + b"\0" + content + b"\0") + return digest.hexdigest(), tuple(path for path, _content in closure) + + def _config_loads_plugin(root: Path, ref: str) -> bool: """Reject repository-configured plugins until profiles bind plugin identities.""" for path in PYTEST_CONFIG_PATHS: @@ -1618,6 +1725,8 @@ def runner_identity_digest( "--outputFile=", ], "vitest_environment": {"NODE_ENV": "test"}, + "playwright_command": ["", "", "test", "", "--config=", "--reporter=json"], + "playwright_environment": {"NODE_ENV": "test"}, "obligations": [ { "id": item.obligation_id, @@ -1640,6 +1749,7 @@ def runner_identity_digest( support_closure_digest + jest_support_digest + _vitest_support_digest(root, ref, profile)[0] + + _playwright_support_digest(root, ref, profile)[0] ).encode() ).hexdigest() adapter_identities = config.adapter_identities or _capture_adapter_identities( @@ -3071,6 +3181,119 @@ def _run_vitest( return RunnerExecution("vitest", outcome, digest, detail), identities +def _playwright_command(root: Path, config: RunnerConfig) -> tuple[str, ...] | None: + """Return the checked-in local Playwright CLI, never an npm script.""" + if config.playwright_command is not None: + return config.playwright_command + node = shutil.which("node") + binary = root / "node_modules" / "@playwright" / "test" / "cli.js" + if node is None or not binary.is_file(): + return None + return (node, str(binary)) + + +def _playwright_environment(home: Path) -> dict[str, str]: + """Return an isolated credential-free environment for Playwright.""" + return { + key: value for key, value in os.environ.items() + if not any(marker in key.upper() for marker in SECRET_ENV_MARKERS) + and not key.startswith(("PLAYWRIGHT_", "NODE_", "npm_config_", "NPM_CONFIG_")) + and key not in {"HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"} + } | { + "HOME": str(home), "XDG_CONFIG_HOME": str(home / "config"), + "XDG_CACHE_HOME": str(home / "cache"), "NODE_ENV": "test", + } + + +def _playwright_result( + root: Path, output: str, returncode: int, expected: tuple[str, ...] | None, + collection: bool = False, +) -> tuple[EvidenceOutcome, str, tuple[str, ...]]: + """Normalize JSON reporter output to project, file, title-path identities.""" + try: + payload = json.loads(output) + tests = payload.get("tests") + if tests is None: + tests = [] + def visit(suite: object, parents: tuple[str, ...] = ()) -> None: + if not isinstance(suite, dict): + raise ValueError("malformed Playwright suite") + title = suite.get("title", "") + next_parents = parents + ((title,) if isinstance(title, str) and title else ()) + for spec in suite.get("specs", []): + if not isinstance(spec, dict): + raise ValueError("malformed Playwright spec") + filename = Path(spec["file"]).resolve().relative_to(root.resolve()).as_posix() + title_path = " > ".join(next_parents + (spec["title"],)) + for item in spec.get("tests", []): + result = (item.get("results") or [{}])[-1] + tests.append({ + "identity": f"{item['projectName']}::{filename}::{title_path}", + "status": result.get("status", "passed" if collection else "skipped"), + }) + for child in suite.get("suites", []): + visit(child, next_parents) + for suite in payload.get("suites", []): + visit(suite) + if not isinstance(tests, list) or not all( + isinstance(item, dict) and isinstance(item.get("identity"), str) + and isinstance(item.get("status"), str) for item in tests + ): + raise ValueError("malformed Playwright reporter payload") + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return EvidenceOutcome.COLLECTION_ERROR, "Playwright JSON reporter produced malformed JSON", () + identities = tuple(sorted(item["identity"] for item in tests)) + if not identities: + return EvidenceOutcome.NOT_COLLECTED, "zero protected Playwright test identities collected", () + if len(set(identities)) != len(identities): + return EvidenceOutcome.COLLECTION_ERROR, "Playwright reporter emitted duplicate test identities", () + if expected is not None and identities != expected: + return EvidenceOutcome.QUARANTINED, "checked-head Playwright identities differ from protected base", identities + if collection: + if returncode: + return EvidenceOutcome.COLLECTION_ERROR, "Playwright collection failed", identities + return EvidenceOutcome.PASS, f"{len(identities)} protected Playwright tests collected", identities + statuses = {item["status"] for item in tests} + if statuses - {"passed", "failed", "skipped", "timedOut", "interrupted"}: + return EvidenceOutcome.COLLECTION_ERROR, "Playwright reporter emitted an unsupported status", identities + if "timedOut" in statuses: + return EvidenceOutcome.TIMEOUT, "Playwright reported a timed out protected test", identities + if returncode or "failed" in statuses or "interrupted" in statuses: + return EvidenceOutcome.FAIL, "Playwright reported failed protected tests", identities + if statuses - {"passed"}: + return EvidenceOutcome.SKIP, "Playwright reported skipped protected tests", identities + return EvidenceOutcome.PASS, f"{len(identities)} protected Playwright tests passed", identities + + +def _run_playwright( + root: Path, paths: tuple[PurePosixPath, ...], timeout_seconds: int, + config: RunnerConfig, expected: tuple[str, ...] | None = None, + command_root: Path | None = None, collection: bool = False, +) -> tuple[RunnerExecution, tuple[str, ...]]: + """Execute exact paths through Playwright's JSON reporter without filters.""" + prefix = _playwright_command(command_root or root, config) + if prefix is None: + return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-unavailable", "no local Playwright CLI is available"), () + try: + config_path, _source = _playwright_config(root, "HEAD") + except ValueError as exc: + return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-config", str(exc)), () + command = [*prefix, "test", *(path.as_posix() for path in paths), f"--config={root / config_path}", "--reporter=json"] + if collection: + command.append("--list") + digest = hashlib.sha256(json.dumps(command, separators=(",", ":")).encode()).hexdigest() + with tempfile.TemporaryDirectory(prefix="pdd-trusted-playwright-") as directory: + try: + result = subprocess.run(command, cwd=root, capture_output=True, text=True, + check=False, timeout=timeout_seconds, env=_playwright_environment(Path(directory))) + except subprocess.TimeoutExpired: + return RunnerExecution("playwright", EvidenceOutcome.TIMEOUT, digest, "Playwright execution timed out"), () + outcome, detail, identities = _playwright_result( + root, result.stdout, result.returncode, expected, collection + ) + return RunnerExecution("playwright", outcome, digest, detail), identities + + def _run_test_node( root: Path, node_id: str, @@ -3347,6 +3570,20 @@ def _dirty_vitest_support(root: Path) -> set[str]: return dirty +def _dirty_playwright_support(root: Path) -> set[str]: + """Reject ambient local JS, config, and support that Playwright could load.""" + result = subprocess.run(["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], cwd=root, capture_output=True, check=False) + if result.returncode != 0: + return {""} + dirty: set[str] = set() + for field in result.stdout.decode(errors="surrogateescape").split("\0"): + path = field[3:] if len(field) >= 4 else "" + relpath = PurePosixPath(path) + if relpath.name.startswith("playwright.config.") or relpath.suffix in _JAVASCRIPT_SUFFIXES: + dirty.add(path) + return dirty + + def _combine( obligation: VerificationObligation, executions: tuple[RunnerExecution, ...], @@ -3378,7 +3615,7 @@ def _obligation_preflight( ) -> RunnerExecution | None: # pylint: disable=too-many-return-statements """Return a normalized fail-closed result before executing pytest.""" - if obligation.kind.casefold() != "test" or obligation.validator_id not in {"pytest", "jest", "vitest"}: + if obligation.kind.casefold() != "test" or obligation.validator_id not in {"pytest", "jest", "vitest", "playwright"}: return RunnerExecution( obligation.obligation_id, EvidenceOutcome.ERROR, @@ -3396,6 +3633,7 @@ def _obligation_preflight( "pytest": pytest_validator_config_digest, "jest": jest_validator_config_digest, "vitest": vitest_validator_config_digest, + "playwright": playwright_validator_config_digest, }[obligation.validator_id] try: if obligation.validator_id == "pytest": @@ -3626,6 +3864,22 @@ def _run_vitest_at_commit( ), () +def _collect_playwright_at_base( + root: Path, base_sha: str, paths: tuple[PurePosixPath, ...], config: RunnerConfig +) -> tuple[RunnerExecution, tuple[str, ...]]: + """Execute the protected base to derive Playwright project/file/title IDs.""" + with tempfile.TemporaryDirectory(prefix="pdd-playwright-protected-base-") as directory: + clone = Path(directory) / "repository" + cloned = subprocess.run(["git", "clone", "-q", "--no-local", "--no-checkout", str(root), str(clone)], capture_output=True, text=True, check=False) + checked_out = cloned.returncode == 0 and subprocess.run(["git", "checkout", "-q", "--detach", base_sha], cwd=clone, capture_output=True, check=False).returncode == 0 + if not checked_out: + return RunnerExecution("protected-base-collection", EvidenceOutcome.COLLECTION_ERROR, hashlib.sha256(base_sha.encode()).hexdigest(), "cannot create protected-base Playwright clone"), () + return _run_playwright( + clone, paths, config.timeout_seconds, config, command_root=root, + collection=True, + ) + + def _protected_node_ids( root: Path, obligation: VerificationObligation, @@ -3746,6 +4000,32 @@ def _run_obligation_in_tree( head_execution.command_digest, head_execution.detail, ) + if obligation.validator_id == "playwright": + profile = VerificationProfile( + UnitId("runner-closure", PurePosixPath("closure.prompt"), "typescript"), + (obligation,), obligation.requirement_ids, "closure", + ) + _digest, support_paths = _playwright_support_digest(root, head_sha, profile) + protected_paths = tuple(sorted(set(obligation.artifact_paths) | set(support_paths))) + changed = _changed_paths(root, base_sha, head_sha, protected_paths) + changed.update(_dirty_playwright_support(root)) + if changed: + return RunnerExecution(obligation.obligation_id, EvidenceOutcome.QUARANTINED, + obligation.validator_config_digest, + "candidate-modified Playwright support cannot solely certify itself: " + ", ".join(sorted(changed))) + base_execution, base_ids = _collect_playwright_at_base(root, base_sha, obligation.artifact_paths, config) + if base_execution.outcome is not EvidenceOutcome.PASS: + return RunnerExecution(obligation.obligation_id, base_execution.outcome, base_execution.command_digest, base_execution.detail) + head_collection, _head_ids = _run_playwright( + root, obligation.artifact_paths, config.timeout_seconds, config, + base_ids, collection=True, + ) + if head_collection.outcome is not EvidenceOutcome.PASS: + return RunnerExecution(obligation.obligation_id, head_collection.outcome, head_collection.command_digest, head_collection.detail) + head_execution, _head_ids = _run_playwright( + root, obligation.artifact_paths, config.timeout_seconds, config, base_ids + ) + return RunnerExecution(obligation.obligation_id, head_execution.outcome, head_execution.command_digest, head_execution.detail) profile = VerificationProfile( UnitId("runner-closure", PurePosixPath("closure.prompt"), "python"), (obligation,), From 398bdda7e863c24a266513ece69a773b2acd2f72 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 19:15:42 -0700 Subject: [PATCH 003/233] test(sync): reproduce Playwright protected clone dependency gap --- tests/test_sync_core_runner_playwright.py | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 5d7a0ef745..921d5284f1 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -50,6 +50,24 @@ def _fake_playwright(tmp_path: Path) -> Path: return runner +def _fake_node_playwright(tmp_path: Path) -> Path: + runner = tmp_path / "fake_node_playwright.js" + runner.write_text( + "const path = require('path');\n" + "try { require.resolve('@playwright/test'); }\n" + "catch (error) {\n" + " console.log(JSON.stringify({suites: [], errors: [{message: error.message}]}));\n" + " process.exit(1);\n" + "}\n" + "const file = path.resolve(process.cwd(), 'tests/widget.spec.ts');\n" + "const collection = process.argv.includes('--list');\n" + "const result = collection ? [] : [{status: 'passed'}];\n" + "console.log(JSON.stringify({suites: [{title: 'tests/widget.spec.ts', file, specs: [{title: 'widget works', tests: [{projectName: 'chromium', results: result}]}]}]}));\n", + encoding="utf-8", + ) + return runner + + def _repository( tmp_path: Path, *, mode: str = "pass", config: str = "export default {};\n" ) -> tuple[Path, str]: @@ -103,6 +121,28 @@ def test_playwright_passing_collected_test_is_pass(tmp_path: Path) -> None: assert executions[0].outcome is EvidenceOutcome.PASS +def test_playwright_protected_base_clone_uses_pinned_local_node_modules( + tmp_path: Path, +) -> None: + root, commit = _repository(tmp_path) + (root / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + module = root / "node_modules" / "@playwright" / "test" + module.mkdir(parents=True) + (module / "index.js").write_text("module.exports = {};\n", encoding="utf-8") + _git(root, "add", ".gitignore") + _git(root, "commit", "-q", "-m", "ignore local node modules") + commit = _git(root, "rev-parse", "HEAD") + + _envelope, executions = _run( + root, + commit, + commit, + _fake_node_playwright(tmp_path), + ) + + assert executions[0].outcome is EvidenceOutcome.PASS + + @pytest.mark.parametrize( ("mode", "outcome"), [ From 72cb8ae01b246f042cb9909ff18e8c708ed96548 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 19:16:17 -0700 Subject: [PATCH 004/233] test(sync): exercise Playwright clone dependency resolution --- tests/test_sync_core_runner_playwright.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 921d5284f1..e575422cd5 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1,6 +1,7 @@ """Contract tests for the fail-closed trusted Playwright adapter.""" import json +import shutil import subprocess import sys from datetime import datetime, timezone @@ -89,7 +90,13 @@ def _repository( return root, _git(root, "rev-parse", "HEAD") -def _run(root: Path, base: str, head: str, fake: Path, timeout: int = 2): +def _run( + root: Path, + base: str, + head: str, + fake: Path | tuple[str, ...], + timeout: int = 2, +): paths = (PurePosixPath("tests/widget.spec.ts"),) try: config_digest = playwright_validator_config_digest(root, base, paths) @@ -110,7 +117,10 @@ def _run(root: Path, base: str, head: str, fake: Path, timeout: int = 2): datetime(2026, 7, 10, tzinfo=timezone.utc), ), config=RunnerConfig( - timeout_seconds=timeout, playwright_command=(sys.executable, str(fake)) + timeout_seconds=timeout, + playwright_command=fake + if isinstance(fake, tuple) + else (sys.executable, str(fake)), ), ) @@ -133,11 +143,14 @@ def test_playwright_protected_base_clone_uses_pinned_local_node_modules( _git(root, "commit", "-q", "-m", "ignore local node modules") commit = _git(root, "rev-parse", "HEAD") + node = shutil.which("node") + assert node is not None + _envelope, executions = _run( root, commit, commit, - _fake_node_playwright(tmp_path), + (node, str(_fake_node_playwright(tmp_path))), ) assert executions[0].outcome is EvidenceOutcome.PASS From 2df2361f55a0e4874bf397628efdaae5606b686b Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 19:17:55 -0700 Subject: [PATCH 005/233] fix(sync): resolve Playwright protected clone dependencies --- pdd/sync_core/runner.py | 22 ++++++++++++++++++---- tests/test_sync_core_runner_playwright.py | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index e4c79b139c..3ff7df1d33 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -3192,9 +3192,9 @@ def _playwright_command(root: Path, config: RunnerConfig) -> tuple[str, ...] | N return (node, str(binary)) -def _playwright_environment(home: Path) -> dict[str, str]: +def _playwright_environment(home: Path, module_root: Path) -> dict[str, str]: """Return an isolated credential-free environment for Playwright.""" - return { + environment = { key: value for key, value in os.environ.items() if not any(marker in key.upper() for marker in SECRET_ENV_MARKERS) and not key.startswith(("PLAYWRIGHT_", "NODE_", "npm_config_", "NPM_CONFIG_")) @@ -3203,6 +3203,10 @@ def _playwright_environment(home: Path) -> dict[str, str]: "HOME": str(home), "XDG_CONFIG_HOME": str(home / "config"), "XDG_CACHE_HOME": str(home / "cache"), "NODE_ENV": "test", } + node_modules = module_root / "node_modules" + if node_modules.is_dir(): + environment["NODE_PATH"] = str(node_modules) + return environment def _playwright_result( @@ -3284,8 +3288,18 @@ def _run_playwright( digest = hashlib.sha256(json.dumps(command, separators=(",", ":")).encode()).hexdigest() with tempfile.TemporaryDirectory(prefix="pdd-trusted-playwright-") as directory: try: - result = subprocess.run(command, cwd=root, capture_output=True, text=True, - check=False, timeout=timeout_seconds, env=_playwright_environment(Path(directory))) + result = subprocess.run( + command, + cwd=root, + capture_output=True, + text=True, + check=False, + timeout=timeout_seconds, + env=_playwright_environment( + Path(directory), + command_root or root, + ), + ) except subprocess.TimeoutExpired: return RunnerExecution("playwright", EvidenceOutcome.TIMEOUT, digest, "Playwright execution timed out"), () outcome, detail, identities = _playwright_result( diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index e575422cd5..04571338ce 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -63,7 +63,7 @@ def _fake_node_playwright(tmp_path: Path) -> Path: "const file = path.resolve(process.cwd(), 'tests/widget.spec.ts');\n" "const collection = process.argv.includes('--list');\n" "const result = collection ? [] : [{status: 'passed'}];\n" - "console.log(JSON.stringify({suites: [{title: 'tests/widget.spec.ts', file, specs: [{title: 'widget works', tests: [{projectName: 'chromium', results: result}]}]}]}));\n", + "console.log(JSON.stringify({suites: [{title: 'tests/widget.spec.ts', file, specs: [{title: 'widget works', file, tests: [{projectName: 'chromium', results: result}]}]}]}));\n", encoding="utf-8", ) return runner From a58932c90e4fb070c3fa07ccf06f088f33269b29 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 19:21:04 -0700 Subject: [PATCH 006/233] test(sync): clean Playwright adapter lint import --- tests/test_sync_core_runner_playwright.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 04571338ce..295bb437b1 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1,6 +1,5 @@ """Contract tests for the fail-closed trusted Playwright adapter.""" -import json import shutil import subprocess import sys From 28d17ca37bbd9decc00a4915f692b7873fa2786b Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 19:22:31 -0700 Subject: [PATCH 007/233] test(sync): reproduce Playwright relative result path gap --- tests/test_sync_core_runner_playwright.py | 30 ++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 295bb437b1..739e0e2faf 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -19,7 +19,10 @@ VerificationProfile, run_profile, ) -from pdd.sync_core.runner import playwright_validator_config_digest +from pdd.sync_core.runner import ( + _playwright_result, + playwright_validator_config_digest, +) UNIT = UnitId("repository-1", PurePosixPath("prompts/widget_ts.prompt"), "typescript") @@ -155,6 +158,31 @@ def test_playwright_protected_base_clone_uses_pinned_local_node_modules( assert executions[0].outcome is EvidenceOutcome.PASS +def test_playwright_result_resolves_relative_spec_file_from_runner_root( + tmp_path: Path, +) -> None: + root = tmp_path / "repo" + (root / "tests").mkdir(parents=True) + (root / "tests/widget.spec.ts").write_text("", encoding="utf-8") + output = ( + '{"suites":[{"title":"tests/widget.spec.ts","specs":[{' + '"title":"widget works","file":"tests/widget.spec.ts",' + '"tests":[{"projectName":"chromium","results":[{"status":"passed"}]}]' + '}]}]}' + ) + + outcome, detail, identities = _playwright_result( + root, + output, + 0, + None, + ) + + assert outcome is EvidenceOutcome.PASS + assert detail == "1 protected Playwright tests passed" + assert identities == ("chromium::tests/widget.spec.ts::tests/widget.spec.ts > widget works",) + + @pytest.mark.parametrize( ("mode", "outcome"), [ From 7df48d77063317ee5ad38f859b78079e86e3bf7c Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 19:23:23 -0700 Subject: [PATCH 008/233] fix(sync): parse Playwright relative result paths --- pdd/sync_core/runner.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 3ff7df1d33..b691f521e2 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -3227,7 +3227,10 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: for spec in suite.get("specs", []): if not isinstance(spec, dict): raise ValueError("malformed Playwright spec") - filename = Path(spec["file"]).resolve().relative_to(root.resolve()).as_posix() + spec_file = Path(spec["file"]) + if not spec_file.is_absolute(): + spec_file = root / spec_file + filename = spec_file.resolve().relative_to(root.resolve()).as_posix() title_path = " > ".join(next_parents + (spec["title"],)) for item in spec.get("tests", []): result = (item.get("results") or [{}])[-1] From 783cc49a596f9f3f2d5613e59218448b2d34355f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 19:25:38 -0700 Subject: [PATCH 009/233] fix(sync): parse Playwright relative result paths --- pdd/sync_core/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index b691f521e2..807c7d191f 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -3229,7 +3229,7 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: raise ValueError("malformed Playwright spec") spec_file = Path(spec["file"]) if not spec_file.is_absolute(): - spec_file = root / spec_file + spec_file = Path(os.path.abspath(root / spec_file)) filename = spec_file.resolve().relative_to(root.resolve()).as_posix() title_path = " > ".join(next_parents + (spec["title"],)) for item in spec.get("tests", []): From 8d41353f3f0a0be7c224594a5d3b34bb620875e8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 19:26:17 -0700 Subject: [PATCH 010/233] test(sync): reproduce Playwright isolated browser cache gap --- tests/test_sync_core_runner_playwright.py | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 739e0e2faf..17729c0db9 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -71,6 +71,28 @@ def _fake_node_playwright(tmp_path: Path) -> Path: return runner +def _fake_node_playwright_requiring_browser_path(tmp_path: Path) -> Path: + runner = tmp_path / "fake_node_playwright_browser_path.js" + runner.write_text( + "const path = require('path');\n" + "try { require.resolve('@playwright/test'); }\n" + "catch (error) {\n" + " console.log(JSON.stringify({suites: [], errors: [{message: error.message}]}));\n" + " process.exit(1);\n" + "}\n" + "if (process.env.PLAYWRIGHT_BROWSERS_PATH !== '0') {\n" + " console.log(JSON.stringify({suites: [], errors: [{message: 'missing package-local browsers path'}]}));\n" + " process.exit(1);\n" + "}\n" + "const file = path.resolve(process.cwd(), 'tests/widget.spec.ts');\n" + "const collection = process.argv.includes('--list');\n" + "const result = collection ? [] : [{status: 'passed'}];\n" + "console.log(JSON.stringify({suites: [{title: 'tests/widget.spec.ts', file, specs: [{title: 'widget works', file, tests: [{projectName: 'chromium', results: result}]}]}]}));\n", + encoding="utf-8", + ) + return runner + + def _repository( tmp_path: Path, *, mode: str = "pass", config: str = "export default {};\n" ) -> tuple[Path, str]: @@ -158,6 +180,37 @@ def test_playwright_protected_base_clone_uses_pinned_local_node_modules( assert executions[0].outcome is EvidenceOutcome.PASS +def test_playwright_uses_pinned_package_local_browser_cache(tmp_path: Path) -> None: + root, commit = _repository(tmp_path) + (root / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + module = root / "node_modules" / "@playwright" / "test" + module.mkdir(parents=True) + (module / "index.js").write_text("module.exports = {};\n", encoding="utf-8") + browsers = ( + root + / "node_modules" + / "playwright-core" + / ".local-browsers" + / "chromium_headless_shell-1181" + ) + browsers.mkdir(parents=True) + _git(root, "add", ".gitignore") + _git(root, "commit", "-q", "-m", "ignore package local Playwright install") + commit = _git(root, "rev-parse", "HEAD") + + node = shutil.which("node") + assert node is not None + + _envelope, executions = _run( + root, + commit, + commit, + (node, str(_fake_node_playwright_requiring_browser_path(tmp_path))), + ) + + assert executions[0].outcome is EvidenceOutcome.PASS + + def test_playwright_result_resolves_relative_spec_file_from_runner_root( tmp_path: Path, ) -> None: From 37dd1667bbc03d6706513d0f078c5664d499126f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 19:27:08 -0700 Subject: [PATCH 011/233] fix(sync): use pinned Playwright browser cache --- pdd/sync_core/runner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 807c7d191f..92da35e023 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -3206,6 +3206,9 @@ def _playwright_environment(home: Path, module_root: Path) -> dict[str, str]: node_modules = module_root / "node_modules" if node_modules.is_dir(): environment["NODE_PATH"] = str(node_modules) + local_browsers = node_modules / "playwright-core" / ".local-browsers" + if local_browsers.is_dir(): + environment["PLAYWRIGHT_BROWSERS_PATH"] = "0" return environment From afccf9ae3a14fc62f3d7c26e2d461d5ed9f94f4d Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 22:55:30 -0700 Subject: [PATCH 012/233] test: cover Playwright trust boundary gaps --- tests/test_sync_core_runner_playwright.py | 57 +++++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 17729c0db9..dfd7ff41de 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -149,13 +149,38 @@ def _run( ) +def _run_default_playwright(root: Path, base: str, head: str, timeout: int = 2): + paths = (PurePosixPath("tests/widget.spec.ts"),) + obligation = VerificationObligation( + "playwright", + "test", + "playwright", + playwright_validator_config_digest(root, base, paths), + ("REQ-1",), + paths, + ) + profile = VerificationProfile(UNIT, (obligation,), ("REQ-1",), "profile-v1") + return run_profile( + root, + profile, + RunBinding("snapshot-v1", base, head), + AttestationIssue( + AttestationSigner("trusted-ci", b"p" * 32), + "id", + "nonce", + datetime(2026, 7, 10, tzinfo=timezone.utc), + ), + config=RunnerConfig(timeout_seconds=timeout), + ) + + def test_playwright_passing_collected_test_is_pass(tmp_path: Path) -> None: root, commit = _repository(tmp_path) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) assert executions[0].outcome is EvidenceOutcome.PASS -def test_playwright_protected_base_clone_uses_pinned_local_node_modules( +def test_playwright_candidate_node_modules_dependency_is_not_trusted( tmp_path: Path, ) -> None: root, commit = _repository(tmp_path) @@ -177,10 +202,11 @@ def test_playwright_protected_base_clone_uses_pinned_local_node_modules( (node, str(_fake_node_playwright(tmp_path))), ) - assert executions[0].outcome is EvidenceOutcome.PASS + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "candidate node_modules" in executions[0].detail -def test_playwright_uses_pinned_package_local_browser_cache(tmp_path: Path) -> None: +def test_playwright_candidate_browser_cache_is_not_trusted(tmp_path: Path) -> None: root, commit = _repository(tmp_path) (root / ".gitignore").write_text("node_modules/\n", encoding="utf-8") module = root / "node_modules" / "@playwright" / "test" @@ -208,7 +234,28 @@ def test_playwright_uses_pinned_package_local_browser_cache(tmp_path: Path) -> N (node, str(_fake_node_playwright_requiring_browser_path(tmp_path))), ) - assert executions[0].outcome is EvidenceOutcome.PASS + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "candidate node_modules" in executions[0].detail + + +def test_default_candidate_node_modules_playwright_is_not_trusted(tmp_path: Path) -> None: + root, commit = _repository(tmp_path) + (root / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + _git(root, "add", ".gitignore") + _git(root, "commit", "-q", "-m", "ignore local node modules") + commit = _git(root, "rev-parse", "HEAD") + binary = root / "node_modules" / "@playwright" / "test" / "cli.js" + binary.parent.mkdir(parents=True) + binary.write_text( + "console.log(JSON.stringify({tests:[{identity:" + "'chromium::tests/widget.spec.ts::widget works',status:'passed'}]}));\n", + encoding="utf-8", + ) + + _envelope, executions = _run_default_playwright(root, commit, commit) + + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "candidate node_modules" in executions[0].detail def test_playwright_result_resolves_relative_spec_file_from_runner_root( @@ -328,6 +375,8 @@ def test_playwright_subprocess_cannot_read_secret( "export default { grep: /widget/ };\n", "export default { retries: 1 };\n", "export default { webServer: { command: 'npm run dev' } };\n", + "const globalSetup = './setup';\nexport default { globalSetup };\n", + "const webServer = { command: 'npm run dev' };\nexport default { webServer };\n", ], ) def test_playwright_rejects_unbound_execution_controls( From 546ae18793c44f74ad2eb339dbda9b90c72ddff5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 22:58:06 -0700 Subject: [PATCH 013/233] fix: reject unbound Playwright toolchains --- pdd/sync_core/runner.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 92da35e023..cb9d7d6e99 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1468,6 +1468,8 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos raise ValueError("dynamic Playwright configuration is not supported") if re.search(r"\b(grep|grepInvert|shard|retries|workers|repeatEach)\s*:", text): raise ValueError("Playwright execution filters or retries are not allowed") + if re.search(r"\b(?:const|let|var)\s+(globalSetup|globalTeardown|reporter|webServer)\b", text): + raise ValueError("indirect Playwright executable controls are not bound by this adapter") if re.search(r"\bwebServer\s*:", text): raise ValueError("Playwright webServer is not bound by this adapter") # Parse direct relative imports here; the closure resolver checks each blob. @@ -3181,15 +3183,20 @@ def _run_vitest( return RunnerExecution("vitest", outcome, digest, detail), identities -def _playwright_command(root: Path, config: RunnerConfig) -> tuple[str, ...] | None: +def _playwright_command(config: RunnerConfig) -> tuple[str, ...] | None: """Return the checked-in local Playwright CLI, never an npm script.""" if config.playwright_command is not None: return config.playwright_command - node = shutil.which("node") - binary = root / "node_modules" / "@playwright" / "test" / "cli.js" - if node is None or not binary.is_file(): - return None - return (node, str(binary)) + return None + + +def _playwright_candidate_toolchain(root: Path) -> bool: + """Return whether candidate checkout infrastructure would affect Playwright.""" + node_modules = root / "node_modules" + return ( + (node_modules / "@playwright" / "test").exists() + or (node_modules / "playwright-core" / ".local-browsers").exists() + ) def _playwright_environment(home: Path, module_root: Path) -> dict[str, str]: @@ -3281,7 +3288,10 @@ def _run_playwright( command_root: Path | None = None, collection: bool = False, ) -> tuple[RunnerExecution, tuple[str, ...]]: """Execute exact paths through Playwright's JSON reporter without filters.""" - prefix = _playwright_command(command_root or root, config) + tool_root = command_root or root + if _playwright_candidate_toolchain(tool_root): + return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-untrusted", "candidate node_modules Playwright toolchain is not trusted"), () + prefix = _playwright_command(config) if prefix is None: return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-unavailable", "no local Playwright CLI is available"), () try: From dc2ee04e7621ec5f2b74f61441c3eafa3720b306 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 23:37:09 -0700 Subject: [PATCH 014/233] test: cover remaining Playwright trust gaps --- tests/test_sync_core_runner_playwright.py | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index dfd7ff41de..4690e514b9 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -346,6 +346,29 @@ def test_playwright_config_and_support_mutation_cannot_pass( assert executions[0].outcome in {EvidenceOutcome.ERROR, EvidenceOutcome.QUARANTINED} +def test_playwright_side_effect_import_helper_mutation_cannot_pass(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/helper.ts").write_text("globalThis.expected = true;\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "import './helper';\n" + "test('widget works', async () => expect(globalThis.expected).toBeTruthy());\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add protected side effect helper") + base = _git(root, "rev-parse", "HEAD") + (root / "tests/helper.ts").write_text("globalThis.expected = false;\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "mutate side effect helper") + + _envelope, executions = _run( + root, base, _git(root, "rev-parse", "HEAD"), _fake_playwright(tmp_path) + ) + + assert executions[0].outcome in {EvidenceOutcome.ERROR, EvidenceOutcome.QUARANTINED} + + def test_playwright_dirty_support_cannot_influence_run(tmp_path: Path) -> None: root, commit = _repository(tmp_path) (root / "ambient.ts").write_text("export {};\n", encoding="utf-8") @@ -368,6 +391,21 @@ def test_playwright_subprocess_cannot_read_secret( assert executions[0].outcome is EvidenceOutcome.PASS +def test_explicit_candidate_local_playwright_command_is_not_trusted(tmp_path: Path) -> None: + root, commit = _repository(tmp_path) + runner = root / "tools" / "playwright.py" + runner.parent.mkdir() + runner.write_text(_fake_playwright(tmp_path).read_text(encoding="utf-8"), encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add candidate-local Playwright command") + commit = _git(root, "rev-parse", "HEAD") + + _envelope, executions = _run(root, commit, commit, runner) + + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "candidate checkout" in executions[0].detail + + @pytest.mark.parametrize( "config", [ @@ -385,3 +423,26 @@ def test_playwright_rejects_unbound_execution_controls( root, commit = _repository(tmp_path, config=config) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) assert executions[0].outcome is EvidenceOutcome.ERROR + + +@pytest.mark.parametrize( + "config", + [ + 'export default { "grep": /widget/ };\n', + "export default { 'retries': 1 };\n", + 'export default { "globalSetup": "./setup.ts" };\n', + 'export default { "webServer": { command: "npm run dev" } };\n', + ], +) +def test_playwright_rejects_quoted_execution_control_keys( + tmp_path: Path, config: str +) -> None: + root, commit = _repository(tmp_path, config=config) + (root / "setup.ts").write_text("export default async function setup() {}\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add quoted control support") + commit = _git(root, "rev-parse", "HEAD") + + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert executions[0].outcome is EvidenceOutcome.ERROR From 7bdf6df34c7019139f2019769e468cdc911c4d98 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Fri, 10 Jul 2026 23:41:01 -0700 Subject: [PATCH 015/233] fix: harden Playwright command and config trust --- pdd/sync_core/runner.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index cb9d7d6e99..305a58d8a0 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1466,12 +1466,28 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos raise ValueError("Playwright configuration must be UTF-8") from exc if re.search(r"\b(process|require|import\s*\(|await|function|=>|\beval\b)\b", text): raise ValueError("dynamic Playwright configuration is not supported") - if re.search(r"\b(grep|grepInvert|shard|retries|workers|repeatEach)\s*:", text): + sensitive_execution_keys = ( + "grep", + "grepInvert", + "shard", + "retries", + "workers", + "repeatEach", + ) + quoted_or_bare = ( + r"(?:\b(?:{keys})\b|['\"](?:{keys})['\"])\s*:" + ) + if re.search( + quoted_or_bare.format(keys="|".join(sensitive_execution_keys)), + text, + ): raise ValueError("Playwright execution filters or retries are not allowed") if re.search(r"\b(?:const|let|var)\s+(globalSetup|globalTeardown|reporter|webServer)\b", text): raise ValueError("indirect Playwright executable controls are not bound by this adapter") - if re.search(r"\bwebServer\s*:", text): + if re.search(quoted_or_bare.format(keys="webServer"), text): raise ValueError("Playwright webServer is not bound by this adapter") + if re.search(r"['\"](?:globalSetup|globalTeardown|reporter)['\"]\s*:", text): + raise ValueError("quoted Playwright executable controls are not bound by this adapter") # Parse direct relative imports here; the closure resolver checks each blob. references = { path.parent / PurePosixPath(item) @@ -2287,6 +2303,14 @@ def _validator_command_identity_digest(root: Path, config: RunnerConfig) -> str: } except (OSError, ValueError): payload["vitest"] = "invalid-vitest-toolchain" + if config.playwright_command is not None: + payload["playwright"] = [ + _file_identity(Path(part).resolve()) + if (Path(part).is_absolute() or "/" in part) + and Path(part).expanduser().exists() + else part + for part in config.playwright_command + ] return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() @@ -3294,6 +3318,8 @@ def _run_playwright( prefix = _playwright_command(config) if prefix is None: return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-unavailable", "no local Playwright CLI is available"), () + if _command_uses_candidate_checkout(root, prefix): + return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-untrusted", "explicit Playwright command inside the candidate checkout is not trusted"), () try: config_path, _source = _playwright_config(root, "HEAD") except ValueError as exc: From d6c848177b86fe50f0e73a9ea9edd2df861cf461 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 00:20:58 -0700 Subject: [PATCH 016/233] test(sync): cover Playwright round three gaps --- tests/test_sync_core_reporting.py | 39 +++++++++++++++++++++++ tests/test_sync_core_runner_playwright.py | 19 +++++++++++ 2 files changed, 58 insertions(+) diff --git a/tests/test_sync_core_reporting.py b/tests/test_sync_core_reporting.py index e911f6904e..4311f6ddf8 100644 --- a/tests/test_sync_core_reporting.py +++ b/tests/test_sync_core_reporting.py @@ -874,6 +874,45 @@ def test_validate_command_requires_vitest_command_and_manifest_together( assert "manifest" in result.output.lower() +def test_validate_command_wires_protected_playwright_runner_config( + tmp_path, monkeypatch +) -> None: + root, commit = _repository(tmp_path) + external = tmp_path / "trusted-tools" / "playwright.py" + external.parent.mkdir() + external.write_text("print('trusted playwright')\n") + signer = object() + monkeypatch.chdir(root) + with patch( + "pdd.commands.sync_core.attestation_signer_from_environment", + return_value=signer, + ), patch("pdd.commands.sync_core.finalize_unit") as mocked_finalize: + mocked_finalize.return_value.transaction.transaction_id = "tx-1" + mocked_finalize.return_value.attestation_id = "att-1" + mocked_finalize.return_value.fingerprint_path = PurePosixPath( + ".pdd/meta/v2/fingerprint.json" + ) + result = CliRunner().invoke( + validate_command, + [ + "--module", + "prompts/widget_python.prompt", + "--base-ref", + commit, + "--playwright-command", + f"{os.sys.executable} {external}", + ], + ) + + assert result.exit_code == 0, result.output + call = mocked_finalize.call_args + assert call.kwargs["signer"] is signer + assert call.kwargs["config"].playwright_command == ( + os.sys.executable, + str(external), + ) + + def test_trusted_finalizer_commits_artifact_closure_evidence_and_fingerprint( tmp_path, ) -> None: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 4690e514b9..5ec2ba3e78 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -446,3 +446,22 @@ def test_playwright_rejects_quoted_execution_control_keys( _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) assert executions[0].outcome is EvidenceOutcome.ERROR + + +def test_playwright_rejects_identifier_executable_config_value( + tmp_path: Path, +) -> None: + root, commit = _repository( + tmp_path, + config="const setup = './setup.ts';\nexport default { globalSetup: setup };\n", + ) + (root / "setup.ts").write_text( + "export default async function setup() {}\n", encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add aliased setup control") + commit = _git(root, "rev-parse", "HEAD") + + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert executions[0].outcome is EvidenceOutcome.ERROR From 7ae2a020edf094bb5329f1fcce7a5ece257445ab Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 00:20:58 -0700 Subject: [PATCH 017/233] fix(sync): wire protected Playwright validation CLI --- pdd/commands/sync_core.py | 13 +++++++++++++ pdd/sync_core/runner.py | 2 ++ 2 files changed, 15 insertions(+) diff --git a/pdd/commands/sync_core.py b/pdd/commands/sync_core.py index 505a04473b..890823f40b 100644 --- a/pdd/commands/sync_core.py +++ b/pdd/commands/sync_core.py @@ -194,6 +194,7 @@ def _runner_config_from_options( error = _vitest_command_error(cwd, protected_vitest) if error is not None: raise click.ClickException(f"--vitest-command: {error}") + playwright_command = options.get("playwright_command") config = RunnerConfig( jest_command=_protected_command( jest_command if isinstance(jest_command, str) else None, @@ -202,6 +203,11 @@ def _runner_config_from_options( ), vitest_command=protected_vitest, vitest_toolchain_manifest=manifest_path, + playwright_command=_protected_command( + playwright_command if isinstance(playwright_command, str) else None, + "--playwright-command", + cwd, + ), ) if protected_vitest is not None: try: @@ -213,6 +219,8 @@ def _runner_config_from_options( vitest_command=config.vitest_command, vitest_toolchain_manifest=config.vitest_toolchain_manifest, vitest_toolchain_identity=descriptor.identity, + playwright_command=config.playwright_command, + adapter_identities=config.adapter_identities, ) return config @@ -477,6 +485,9 @@ def baseline(ctx: click.Context, module: str, reviewed_by: str, reason: str) -> envvar="PDD_SYNC_VITEST_TOOLCHAIN_MANIFEST", type=click.Path(path_type=Path), help="Protected external Node/Vitest toolchain closure manifest.", + "--playwright-command", + envvar="PDD_SYNC_PLAYWRIGHT_COMMAND", + help="Protected absolute external Playwright command argv.", ) @click.pass_context def validate( @@ -487,6 +498,7 @@ def validate( jest_command: str | None, vitest_command: str | None, vitest_toolchain_manifest: Path | None, + playwright_command: str | None, ) -> None: # pylint: disable=too-many-arguments,too-many-positional-arguments """Run protected obligations and transactionally finalize trusted evidence.""" @@ -504,6 +516,7 @@ def validate( "vitest_command": vitest_command, "vitest_toolchain_manifest": str(vitest_toolchain_manifest) if vitest_toolchain_manifest else None, + "playwright_command": playwright_command, }, root, ), diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 305a58d8a0..78d378fd56 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1488,6 +1488,8 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos raise ValueError("Playwright webServer is not bound by this adapter") if re.search(r"['\"](?:globalSetup|globalTeardown|reporter)['\"]\s*:", text): raise ValueError("quoted Playwright executable controls are not bound by this adapter") + if re.search(r"\b(?:globalSetup|globalTeardown|reporter)\s*:\s*(?!['\"])", text): + raise ValueError("indirect Playwright executable controls are not bound by this adapter") # Parse direct relative imports here; the closure resolver checks each blob. references = { path.parent / PurePosixPath(item) From 4c7efe53db855681c6a79e005938aae247888069 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 01:17:22 -0700 Subject: [PATCH 018/233] fix(sync): bind Playwright JS support candidates --- pdd/sync_core/runner.py | 15 +-- tests/test_sync_core_runner_playwright.py | 122 +++++++++++++++++++++- 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 78d378fd56..bf503fde3a 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1491,10 +1491,12 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos if re.search(r"\b(?:globalSetup|globalTeardown|reporter)\s*:\s*(?!['\"])", text): raise ValueError("indirect Playwright executable controls are not bound by this adapter") # Parse direct relative imports here; the closure resolver checks each blob. - references = { - path.parent / PurePosixPath(item) - for item in re.findall(r"(?:from\s+|import\s+)['\"](\.{1,2}/[^'\"]+)['\"]", text) - } + references: set[PurePosixPath] = set() + for item in re.findall(r"(?:from\s+|import\s+)['\"](\.{1,2}/[^'\"]+)['\"]", text): + reference = _normalize_repo_relative_path(path.parent / PurePosixPath(item)) + if reference is None: + raise ValueError("Playwright config import escapes the repository") + references.add(reference) for key in ("globalSetup", "globalTeardown", "reporter"): for value in re.findall(rf"\b{key}\s*:\s*['\"]([^'\"]+)['\"]", text): local = _jest_local_path(value) @@ -1517,7 +1519,7 @@ def _playwright_support_closure( if path in visited: continue visited.add(path) - source = read_git_blob(root, ref, path) + path, source = _read_javascript_support_blob(root, ref, path) if source is None: raise ValueError(f"Playwright local support path is missing: {path.as_posix()}") paths.add(path) @@ -1539,9 +1541,8 @@ def playwright_validator_config_digest( root: Path, ref: str, test_paths: tuple[PurePosixPath, ...] ) -> str: """Hash Playwright config and every bound local executable dependency.""" - del test_paths digest = hashlib.sha256() - for path, content in _playwright_support_closure(root, ref, ()): + for path, content in _playwright_support_closure(root, ref, test_paths): digest.update(path.as_posix().encode() + b"\0" + content + b"\0") return digest.hexdigest() diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 5ec2ba3e78..dc97127542 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -20,6 +20,7 @@ run_profile, ) from pdd.sync_core.runner import ( + _local_javascript_imports, _playwright_result, playwright_validator_config_digest, ) @@ -312,7 +313,7 @@ def test_playwright_collection_identity_mismatch_cannot_pass( _envelope, executions = _run( root, base, _git(root, "rev-parse", "HEAD"), _fake_playwright(tmp_path) ) - assert executions[0].outcome is EvidenceOutcome.QUARANTINED + assert executions[0].outcome in {EvidenceOutcome.ERROR, EvidenceOutcome.QUARANTINED} def test_playwright_removed_protected_test_cannot_pass(tmp_path: Path) -> None: @@ -323,7 +324,7 @@ def test_playwright_removed_protected_test_cannot_pass(tmp_path: Path) -> None: _envelope, executions = _run( root, base, _git(root, "rev-parse", "HEAD"), _fake_playwright(tmp_path) ) - assert executions[0].outcome is EvidenceOutcome.QUARANTINED + assert executions[0].outcome in {EvidenceOutcome.ERROR, EvidenceOutcome.QUARANTINED} @pytest.mark.parametrize("path", ["playwright.config.ts", "setup.ts", "reporter.ts"]) @@ -369,6 +370,123 @@ def test_playwright_side_effect_import_helper_mutation_cannot_pass(tmp_path: Pat assert executions[0].outcome in {EvidenceOutcome.ERROR, EvidenceOutcome.QUARANTINED} +def test_playwright_parent_directory_import_helper_mutation_cannot_pass(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "shared").mkdir() + (root / "shared/helper.ts").write_text("export const expected = true;\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "import { expected } from '../shared/helper';\n" + "test('widget works', async () => expect(expected).toBeTruthy());\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add parent import helper") + base = _git(root, "rev-parse", "HEAD") + (root / "shared/helper.ts").write_text("export const expected = false;\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "mutate parent import helper") + + _envelope, executions = _run( + root, base, _git(root, "rev-parse", "HEAD"), _fake_playwright(tmp_path) + ) + + assert executions[0].outcome in {EvidenceOutcome.ERROR, EvidenceOutcome.QUARANTINED} + + +def test_playwright_parent_directory_side_effect_import_mutation_cannot_pass(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "shared").mkdir() + (root / "shared/setup.ts").write_text("globalThis.expected = true;\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "import '../shared/setup';\n" + "test('widget works', async () => expect(globalThis.expected).toBeTruthy());\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add parent side effect helper") + base = _git(root, "rev-parse", "HEAD") + (root / "shared/setup.ts").write_text("globalThis.expected = false;\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "mutate parent side effect helper") + + _envelope, executions = _run( + root, base, _git(root, "rev-parse", "HEAD"), _fake_playwright(tmp_path) + ) + + assert executions[0].outcome in {EvidenceOutcome.ERROR, EvidenceOutcome.QUARANTINED} + + +def test_playwright_parent_directory_imports_change_validator_digest(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + paths = (PurePosixPath("tests/widget.spec.ts"),) + (root / "shared/helpers").mkdir(parents=True) + (root / "shared/helpers/index.ts").write_text( + "export const expected = true;\n", encoding="utf-8" + ) + (root / "shared/setup.ts").write_text("globalThis.expected = true;\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "import { expected } from '../shared/helpers';\n" + "import '../shared/setup';\n" + "test('widget works', async () => expect(expected && globalThis.expected).toBeTruthy());\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add parent import helpers") + base = _git(root, "rev-parse", "HEAD") + base_digest = playwright_validator_config_digest(root, base, paths) + + (root / "shared/helpers/index.ts").write_text( + "export const expected = false;\n", encoding="utf-8" + ) + (root / "shared/setup.ts").write_text("globalThis.expected = false;\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "mutate parent import helpers") + head_digest = playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), paths + ) + + assert head_digest != base_digest + + +def test_playwright_config_reference_index_candidate_changes_validator_digest(tmp_path: Path) -> None: + config = "import './support/setup';\nexport default {};\n" + root, _commit = _repository(tmp_path, config=config) + paths = (PurePosixPath("tests/widget.spec.ts"),) + (root / "support/setup").mkdir(parents=True) + (root / "support/setup/index.ts").write_text( + "globalThis.expected = true;\n", encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add extensionless setup index") + base = _git(root, "rev-parse", "HEAD") + base_digest = playwright_validator_config_digest(root, base, paths) + + (root / "support/setup/index.ts").write_text( + "globalThis.expected = false;\n", encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "mutate extensionless setup index") + head_digest = playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), paths + ) + + assert head_digest != base_digest + + +def test_playwright_repository_escape_import_is_not_bound(tmp_path: Path) -> None: + root, commit = _repository(tmp_path) + imports = _local_javascript_imports( + root, + commit, + PurePosixPath("tests/widget.spec.ts"), + b"import '../../outside.js';\n", + ) + assert imports == set() + + def test_playwright_dirty_support_cannot_influence_run(tmp_path: Path) -> None: root, commit = _repository(tmp_path) (root / "ambient.ts").write_text("export {};\n", encoding="utf-8") From 7ab31c133474bff8c5a0ca6162a1c43fbf937df5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 02:12:56 -0700 Subject: [PATCH 019/233] fix(sync): reject pathless Playwright validator operands --- pdd/sync_core/runner.py | 13 +++---- tests/test_sync_core_runner_playwright.py | 47 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index bf503fde3a..d01ff4d8b5 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -2308,11 +2308,7 @@ def _validator_command_identity_digest(root: Path, config: RunnerConfig) -> str: payload["vitest"] = "invalid-vitest-toolchain" if config.playwright_command is not None: payload["playwright"] = [ - _file_identity(Path(part).resolve()) - if (Path(part).is_absolute() or "/" in part) - and Path(part).expanduser().exists() - else part - for part in config.playwright_command + _command_part_identity(root, part) for part in config.playwright_command ] return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() @@ -3321,8 +3317,11 @@ def _run_playwright( prefix = _playwright_command(config) if prefix is None: return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-unavailable", "no local Playwright CLI is available"), () - if _command_uses_candidate_checkout(root, prefix): - return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-untrusted", "explicit Playwright command inside the candidate checkout is not trusted"), () + command_error = _protected_command_error(root, prefix) + if command_error is not None: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, "playwright-untrusted", command_error + ), () try: config_path, _source = _playwright_config(root, "HEAD") except ValueError as exc: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index dc97127542..10d67a5953 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -524,6 +524,53 @@ def test_explicit_candidate_local_playwright_command_is_not_trusted(tmp_path: Pa assert "candidate checkout" in executions[0].detail +def test_pathless_playwright_script_operand_is_not_resolved_from_candidate( + tmp_path: Path, +) -> None: + root, base = _repository(tmp_path) + (root / "fake_playwright.py").write_text( + _fake_playwright(tmp_path).read_text(encoding="utf-8"), encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add pathless candidate Playwright command") + base = _git(root, "rev-parse", "HEAD") + (root / "fake_playwright.py").write_text( + _fake_playwright(tmp_path).read_text(encoding="utf-8") + "\n# changed\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "mutate pathless Playwright command") + paths = (PurePosixPath("tests/widget.spec.ts"),) + obligation = VerificationObligation( + "playwright", + "test", + "playwright", + playwright_validator_config_digest(root, base, paths), + ("REQ-1",), + paths, + ) + profile = VerificationProfile(UNIT, (obligation,), ("REQ-1",), "profile-v1") + + _envelope, executions = run_profile( + root, + profile, + RunBinding("snapshot-v1", base, _git(root, "rev-parse", "HEAD")), + AttestationIssue( + AttestationSigner("trusted-ci", b"v" * 32), + "id", + "nonce", + datetime(2026, 7, 10, tzinfo=timezone.utc), + ), + config=RunnerConfig( + timeout_seconds=2, + playwright_command=(sys.executable, "fake_playwright.py"), + ), + ) + + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "pathless" in executions[0].detail + + @pytest.mark.parametrize( "config", [ From 7f24cffdf5f391606dd59dd27ff9c1bd1ded6ee3 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 04:57:25 -0700 Subject: [PATCH 020/233] test(sync): cover Playwright dependency binding gaps --- tests/test_sync_core_runner_playwright.py | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 10d67a5953..7a28662c17 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -239,6 +239,53 @@ def test_playwright_candidate_browser_cache_is_not_trusted(tmp_path: Path) -> No assert "candidate node_modules" in executions[0].detail +def test_playwright_ignored_bare_package_mutation_cannot_change_evidence( + tmp_path: Path, +) -> None: + root, commit = _repository(tmp_path) + (root / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "import { expected } from 'helper';\n" + "test('widget works', async () => expect(expected).toBeTruthy());\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "ignore bare package dependencies") + commit = _git(root, "rev-parse", "HEAD") + helper = root / "node_modules" / "helper" / "index.js" + helper.parent.mkdir(parents=True) + helper.write_text("exports.expected = true;\n", encoding="utf-8") + + _envelope, first = _run(root, commit, commit, _fake_playwright(tmp_path)) + helper.write_text("exports.expected = false;\n", encoding="utf-8") + _envelope, second = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert first[0].outcome is EvidenceOutcome.ERROR + assert first[0].detail == second[0].detail + assert first[0].command_digest == second[0].command_digest + assert "bare package imports" in first[0].detail + + +def test_playwright_external_node_modules_environment_is_available( + tmp_path: Path, +) -> None: + root, commit = _repository(tmp_path) + protected = tmp_path / "protected" + package = protected / "node_modules" / "@playwright" / "test" + package.mkdir(parents=True) + (package / "index.js").write_text("module.exports = {};\n", encoding="utf-8") + runner = package / "cli.js" + runner.write_text(_fake_node_playwright(tmp_path).read_text(encoding="utf-8")) + + node = shutil.which("node") + assert node is not None + + _envelope, executions = _run(root, commit, commit, (node, str(runner))) + + assert executions[0].outcome is EvidenceOutcome.PASS + + def test_default_candidate_node_modules_playwright_is_not_trusted(tmp_path: Path) -> None: root, commit = _repository(tmp_path) (root / ".gitignore").write_text("node_modules/\n", encoding="utf-8") From 9df884ef7bdbc009513904234688e5b0e16929f8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 04:57:25 -0700 Subject: [PATCH 021/233] fix(sync): bind Playwright dependency environment --- pdd/sync_core/runner.py | 100 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 7 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index d01ff4d8b5..4800ca473b 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -834,6 +834,32 @@ def _resolve_javascript_specifier( return {mapped} if mapped is not None else set() +def _bare_javascript_imports(source: bytes) -> set[str]: + """Return bare JS package imports that are not repository-relative paths.""" + try: + text = source.decode("utf-8") + except UnicodeDecodeError: + return set() + imports = re.findall( + r"(?:from\s+|import\s*\(|import\s+|require\s*\()['\"]([^'\"]+)['\"]", + text, + ) + return { + item + for item in imports + if not item.startswith((".", "/", "node:", "data:", "file:")) + } + + +def _unbound_playwright_bare_imports(source: bytes) -> set[str]: + """Return bare imports that could resolve through candidate dependencies.""" + return { + item + for item in _bare_javascript_imports(source) + if item != "@playwright/test" + } + + def _normalize_repo_relative_path(path: PurePosixPath) -> PurePosixPath | None: """Collapse dot segments and reject paths that escape the repository.""" parts: list[str] = [] @@ -1525,6 +1551,12 @@ def _playwright_support_closure( paths.add(path) if path.suffix in _JAVASCRIPT_SUFFIXES: pending.extend(_local_javascript_imports(root, ref, path, source) - visited) + unbound_bare = _unbound_playwright_bare_imports(source) + if unbound_bare: + raise ValueError( + "Playwright bare package imports are not bound by this adapter: " + + ", ".join(sorted(unbound_bare)) + ) try: text = source.decode("utf-8") except UnicodeDecodeError as exc: @@ -2277,6 +2309,21 @@ def _prepare_vitest_toolchain( return phase +def _directory_identity(path: Path) -> str: + """Return a stable digest for files under a protected dependency directory.""" + digest = hashlib.sha256() + if not path.is_dir(): + digest.update(str(path).encode() + b"\0") + return digest.hexdigest() + for item in sorted(child for child in path.rglob("*") if child.is_file()): + try: + data = item.read_bytes() + except OSError: + data = b"" + digest.update(item.relative_to(path).as_posix().encode() + b"\0" + data + b"\0") + return digest.hexdigest() + + def _command_part_identity(root: Path, part: str) -> str: """Return literal argv text or a digest for an explicit path operand.""" path = Path(part).expanduser() @@ -2286,6 +2333,37 @@ def _command_part_identity(root: Path, part: str) -> str: return _file_identity(resolved) if resolved.exists() else part +def _is_script_like_operand(value: str) -> bool: + """Return true for argv operands that are likely executable script paths.""" + return Path(value).suffix in _JAVASCRIPT_SUFFIXES + (".py",) + + +def _external_node_modules_root( + root: Path, command: tuple[str, ...] | None +) -> Path | None: + """Derive a non-candidate node_modules root from explicit validator argv.""" + if command is None: + return None + candidate_root = root.resolve() + for part in command: + path = Path(part).expanduser() + if not path.is_absolute(): + continue + resolved = path.resolve() + if _path_is_relative_to(resolved, candidate_root): + continue + parts = resolved.parts + if "node_modules" not in parts: + continue + index = parts.index("node_modules") + node_modules = Path(*parts[: index + 1]) + if node_modules.is_dir() and not _path_is_relative_to( + node_modules.resolve(), candidate_root + ): + return node_modules + return None + + def _validator_command_identity_digest(root: Path, config: RunnerConfig) -> str: """Bind explicitly supplied validator commands to signed runner evidence.""" payload: dict[str, object] = {} @@ -2310,6 +2388,12 @@ def _validator_command_identity_digest(root: Path, config: RunnerConfig) -> str: payload["playwright"] = [ _command_part_identity(root, part) for part in config.playwright_command ] + node_modules = _external_node_modules_root(root, config.playwright_command) + if node_modules is not None: + payload["playwright_dependency_environment"] = { + "node_modules": str(node_modules), + "node_modules_sha256": _directory_identity(node_modules), + } return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() @@ -3222,7 +3306,9 @@ def _playwright_candidate_toolchain(root: Path) -> bool: ) -def _playwright_environment(home: Path, module_root: Path) -> dict[str, str]: +def _playwright_environment( + home: Path, node_modules: Path | None +) -> dict[str, str]: """Return an isolated credential-free environment for Playwright.""" environment = { key: value for key, value in os.environ.items() @@ -3233,12 +3319,11 @@ def _playwright_environment(home: Path, module_root: Path) -> dict[str, str]: "HOME": str(home), "XDG_CONFIG_HOME": str(home / "config"), "XDG_CACHE_HOME": str(home / "cache"), "NODE_ENV": "test", } - node_modules = module_root / "node_modules" - if node_modules.is_dir(): + if node_modules is not None and node_modules.is_dir(): environment["NODE_PATH"] = str(node_modules) - local_browsers = node_modules / "playwright-core" / ".local-browsers" - if local_browsers.is_dir(): - environment["PLAYWRIGHT_BROWSERS_PATH"] = "0" + local_browsers = node_modules / "playwright-core" / ".local-browsers" + if local_browsers.is_dir(): + environment["PLAYWRIGHT_BROWSERS_PATH"] = "0" return environment @@ -3322,6 +3407,7 @@ def _run_playwright( return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-untrusted", command_error ), () + node_modules = _external_node_modules_root(root, prefix) try: config_path, _source = _playwright_config(root, "HEAD") except ValueError as exc: @@ -3341,7 +3427,7 @@ def _run_playwright( timeout=timeout_seconds, env=_playwright_environment( Path(directory), - command_root or root, + node_modules, ), ) except subprocess.TimeoutExpired: From 2df310f20db278e362a86bc0f3488e1a95e2ab8b Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 06:36:36 -0700 Subject: [PATCH 022/233] test(sync): cover Playwright round seven trust gaps --- tests/test_sync_core_runner_playwright.py | 99 +++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 7a28662c17..1b3ef88922 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -20,7 +20,9 @@ run_profile, ) from pdd.sync_core.runner import ( + _directory_identity, _local_javascript_imports, + _playwright_environment, _playwright_result, playwright_validator_config_digest, ) @@ -181,6 +183,103 @@ def test_playwright_passing_collected_test_is_pass(tmp_path: Path) -> None: assert executions[0].outcome is EvidenceOutcome.PASS +@pytest.mark.parametrize("name", ["DATABASE_URL", "SSH_AUTH_SOCK", "KUBECONFIG"]) +def test_playwright_environment_drops_ambient_credentials_and_capabilities( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str +) -> None: + monkeypatch.setenv(name, "candidate-readable") + assert name not in _playwright_environment(tmp_path, None) + + +@pytest.mark.parametrize( + "payload", ["[]", "null", "1", '"value"', '{"suites":[{"specs":null}]}'] +) +def test_playwright_malformed_json_shapes_fail_closed( + tmp_path: Path, payload: str +) -> None: + outcome, _detail, identities = _playwright_result( + tmp_path, payload, 0, None + ) + assert outcome is EvidenceOutcome.COLLECTION_ERROR + assert identities == () + + +@pytest.mark.parametrize( + "config", + [ + "const key='grep'; export default { [key]: /widget/ };\n", + "const controls={ ['webServer']: {command:'./server.sh'} }; export default {...controls};\n", + "export default { globalSetup /*comment*/: './setup.ts' };\n", + ], +) +def test_playwright_rejects_non_declarative_config_shapes( + tmp_path: Path, config: str +) -> None: + root, commit = _repository(tmp_path, config=config) + with pytest.raises(ValueError, match="configuration"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize( + "source", + [ + "const dependency='helper'; await import(dependency);\n", + "const load = require; load('helper');\n", + ], +) +def test_playwright_rejects_dynamic_or_aliased_module_loading( + tmp_path: Path, source: str +) -> None: + root, commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "dynamic loader") + commit = _git(root, "rev-parse", "HEAD") + with pytest.raises(ValueError, match="module loading"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + +def test_playwright_snapshot_oracle_is_bound_to_validator_digest(tmp_path: Path) -> None: + root, base = _repository(tmp_path) + snapshot = root / "tests/widget.spec.ts-snapshots/widget-linux.png" + snapshot.parent.mkdir() + snapshot.write_bytes(b"base") + spec = root / "tests/widget.spec.ts" + spec.write_text( + spec.read_text(encoding="utf-8") + + "test('visual', async ({page}) => expect(page).toHaveScreenshot('widget.png'));\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "snapshot oracle") + base = _git(root, "rev-parse", "HEAD") + before = playwright_validator_config_digest(root, base, (PurePosixPath("tests/widget.spec.ts"),)) + snapshot.write_bytes(b"candidate") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "change oracle") + after = playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), (PurePosixPath("tests/widget.spec.ts"),) + ) + assert after != before + + +def test_directory_identity_binds_symlink_topology(tmp_path: Path) -> None: + target = tmp_path / "package-a" + target.mkdir() + (target / "index.js").write_text("module.exports = 1", encoding="utf-8") + dependencies = tmp_path / "node_modules" + dependencies.mkdir() + (dependencies / "helper").symlink_to(target, target_is_directory=True) + before = _directory_identity(dependencies) + (dependencies / "helper").unlink() + (dependencies / "helper").symlink_to(tmp_path / "missing", target_is_directory=True) + assert _directory_identity(dependencies) != before + + def test_playwright_candidate_node_modules_dependency_is_not_trusted( tmp_path: Path, ) -> None: From b5df3338f2b88aac4113e4ef1f18e2bc0a05b308 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 06:40:59 -0700 Subject: [PATCH 023/233] test(sync): reject Playwright prefix preload options --- tests/test_sync_core_runner_playwright.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 1b3ef88922..57e3f2eeb2 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -23,6 +23,7 @@ _directory_identity, _local_javascript_imports, _playwright_environment, + _playwright_command_error, _playwright_result, playwright_validator_config_digest, ) @@ -280,6 +281,21 @@ def test_directory_identity_binds_symlink_topology(tmp_path: Path) -> None: assert _directory_identity(dependencies) != before +@pytest.mark.parametrize("option", ["--require=helper", "--import=helper", "--loader=helper"]) +def test_playwright_command_rejects_candidate_resolving_prefix_options( + tmp_path: Path, option: str +) -> None: + executable = tmp_path.parent / "node" + entrypoint = tmp_path.parent / "playwright-cli.js" + executable.write_bytes(b"node") + entrypoint.write_bytes(b"cli") + error = _playwright_command_error( + tmp_path, (str(executable), option, str(entrypoint)) + ) + assert error is not None + assert "exactly" in error or "options" in error + + def test_playwright_candidate_node_modules_dependency_is_not_trusted( tmp_path: Path, ) -> None: From c49407d3a919b3865001de4b23245c1dfac3e4d5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 06:44:15 -0700 Subject: [PATCH 024/233] fix(sync): isolate Playwright trust descriptors --- pdd/commands/sync_core.py | 21 +++++++- pdd/sync_core/evidence_store.py | 6 +++ pdd/sync_core/finalize.py | 2 + pdd/sync_core/reporting.py | 1 + pdd/sync_core/runner.py | 96 +++++++++++++++++++++++++++------ pdd/sync_core/trust.py | 5 ++ 6 files changed, 115 insertions(+), 16 deletions(-) diff --git a/pdd/commands/sync_core.py b/pdd/commands/sync_core.py index 890823f40b..1c29ac0277 100644 --- a/pdd/commands/sync_core.py +++ b/pdd/commands/sync_core.py @@ -166,6 +166,25 @@ def _protected_command(value: str | None, option: str, cwd: Path) -> tuple[str, return command +def _protected_playwright_command( + value: str | None, cwd: Path +) -> tuple[str, ...] | None: + """Parse the fixed executable-plus-entrypoint Playwright descriptor.""" + command = _protected_command(value, "--playwright-command", cwd) + if command is None: + return None + if len(command) != 2 or not Path(command[1]).expanduser().is_absolute(): + raise click.ClickException( + "--playwright-command must contain exactly an absolute executable " + "and absolute external CLI entrypoint" + ) + if any(part.startswith("-") for part in command[1:]): + raise click.ClickException( + "--playwright-command must not contain interpreter options" + ) + return command + + def _runner_config_from_options( options: dict[str, object], cwd: Path ) -> RunnerConfig: @@ -204,8 +223,8 @@ def _runner_config_from_options( vitest_command=protected_vitest, vitest_toolchain_manifest=manifest_path, playwright_command=_protected_command( + playwright_command=_protected_playwright_command( playwright_command if isinstance(playwright_command, str) else None, - "--playwright-command", cwd, ), ) diff --git a/pdd/sync_core/evidence_store.py b/pdd/sync_core/evidence_store.py index 8964216e11..9dc0eda4d6 100644 --- a/pdd/sync_core/evidence_store.py +++ b/pdd/sync_core/evidence_store.py @@ -136,6 +136,12 @@ def decode_attestation(payload: Mapping[str, Any]) -> AttestationEnvelope: _string(binding_data, "base_sha"), _string(binding_data, "checked_sha"), adapter_identities=adapter_identities, + playwright_command=( + tuple(binding_data["playwright_command"]) + if isinstance(binding_data.get("playwright_command"), list) + and all(isinstance(item, str) for item in binding_data["playwright_command"]) + else None, + ), ) results = tuple( ObligationEvidence( diff --git a/pdd/sync_core/finalize.py b/pdd/sync_core/finalize.py index fe065da16e..7314de5bde 100644 --- a/pdd/sync_core/finalize.py +++ b/pdd/sync_core/finalize.py @@ -225,12 +225,14 @@ def _reusable_result( profile, root=root, ref=head_sha, config=RunnerConfig( adapter_identities=envelope.binding.adapter_identities, + playwright_command=envelope.binding.playwright_command, ), ), TRUSTED_RUNNER_VERSION, base_sha, envelope.binding.checked_sha, adapter_identities=envelope.binding.adapter_identities, + playwright_command=envelope.binding.playwright_command, ) verifier.verify_current_for_idempotency(envelope, binding, now=now) ancestry = subprocess.run( diff --git a/pdd/sync_core/reporting.py b/pdd/sync_core/reporting.py index 6242e95d91..cbdc2aa04b 100644 --- a/pdd/sync_core/reporting.py +++ b/pdd/sync_core/reporting.py @@ -168,6 +168,7 @@ def _evidence( profile, root=context.root, ref=context.manifest.head_ref, config=RunnerConfig( adapter_identities=binding.adapter_identities, + playwright_command=binding.playwright_command, ), ) or binding.tool_version != TRUSTED_RUNNER_VERSION diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 4800ca473b..d4b71998af 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1485,11 +1485,22 @@ def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePosixPath]: + # pylint: disable=too-many-branches """Reject dynamic controls and return literal local config imports.""" try: text = source.decode("utf-8") except UnicodeDecodeError as exc: raise ValueError("Playwright configuration must be UTF-8") from exc + # Playwright config is executable code. Only accept the deliberately small, + # directly inspectable object-literal subset; syntactic indirection makes a + # security decision based on token matching unsound. + if re.search(r"/\*|//|\.\.\.|\[[^\]]+\]\s*:", text): + raise ValueError("Playwright configuration must use direct declarative keys") + if re.search( + r"\b(?:storageState|projects|dependencies|snapshotPathTemplate|executablePath)\b", + text, + ): + raise ValueError("Playwright configuration references unsupported runtime resources") if re.search(r"\b(process|require|import\s*\(|await|function|=>|\beval\b)\b", text): raise ValueError("dynamic Playwright configuration is not supported") sensitive_execution_keys = ( @@ -1535,6 +1546,7 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos def _playwright_support_closure( root: Path, ref: str, test_paths: tuple[PurePosixPath, ...] ) -> tuple[tuple[PurePosixPath, bytes], ...]: + # pylint: disable=too-many-locals """Bind config, local support, and test imports without executing them.""" config_path, config_source = _playwright_config(root, ref) paths = {config_path} @@ -1561,6 +1573,26 @@ def _playwright_support_closure( text = source.decode("utf-8") except UnicodeDecodeError as exc: raise ValueError(f"Playwright source is not UTF-8: {path.as_posix()}") from exc + if re.search(r"\bimport\s*\(\s*(?!['\"])", text) or re.search( + r"\b(?:const|let|var)\s+\w+\s*=\s*require\b", text + ): + raise ValueError("dynamic or aliased Playwright module loading is not supported") + if re.search( + r"(?:from\s+|import\s*\(|require\s*\()\s*['\"](?:node:)?fs(?:/[^'\"]*)?['\"]", + text, + ): + raise ValueError("Playwright runtime file access is not bound by this adapter") + if "toHaveScreenshot" in text: + snapshot_prefix = PurePosixPath(f"{path.as_posix()}-snapshots") + listed = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", ref, "--", snapshot_prefix.as_posix()], + cwd=root, capture_output=True, text=True, check=True, + ) + for name in listed.stdout.splitlines(): + snapshot_path = PurePosixPath(name) + snapshot = read_git_blob(root, ref, snapshot_path) + if snapshot is not None: + paths.add(snapshot_path) if re.search(r"\b(?:test|describe)\.(?:only|skip|fixme|slow)\s*\(", text): raise ValueError("Playwright focused, skipped, fixme, or slow tests are ambiguous") return tuple( @@ -2315,12 +2347,22 @@ def _directory_identity(path: Path) -> str: if not path.is_dir(): digest.update(str(path).encode() + b"\0") return digest.hexdigest() - for item in sorted(child for child in path.rglob("*") if child.is_file()): + for item in sorted(path.rglob("*")): + relative = item.relative_to(path).as_posix().encode() + if item.is_symlink(): + try: + target = os.readlink(item).encode() + except OSError: + target = b"" + digest.update(relative + b"\0\0" + target + b"\0") + continue + if not item.is_file(): + continue try: data = item.read_bytes() except OSError: data = b"" - digest.update(item.relative_to(path).as_posix().encode() + b"\0" + data + b"\0") + digest.update(relative + b"\0" + data + b"\0") return digest.hexdigest() @@ -3297,6 +3339,20 @@ def _playwright_command(config: RunnerConfig) -> tuple[str, ...] | None: return None +def _playwright_command_error(root: Path, command: tuple[str, ...]) -> str | None: + """Enforce the exact protected Playwright launcher grammar.""" + error = _protected_command_error(root, command) + if error is not None: + return error + if len(command) != 2: + return "Playwright command must be exactly an executable and CLI entrypoint" + if any(part.startswith("-") for part in command[1:]): + return "Playwright command options are not trusted in the protected prefix" + if not Path(command[1]).expanduser().is_absolute(): + return "Playwright CLI entrypoint must be an absolute external path" + return None + + def _playwright_candidate_toolchain(root: Path) -> bool: """Return whether candidate checkout infrastructure would affect Playwright.""" node_modules = root / "node_modules" @@ -3310,15 +3366,11 @@ def _playwright_environment( home: Path, node_modules: Path | None ) -> dict[str, str]: """Return an isolated credential-free environment for Playwright.""" - environment = { - key: value for key, value in os.environ.items() - if not any(marker in key.upper() for marker in SECRET_ENV_MARKERS) - and not key.startswith(("PLAYWRIGHT_", "NODE_", "npm_config_", "NPM_CONFIG_")) - and key not in {"HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"} - } | { - "HOME": str(home), "XDG_CONFIG_HOME": str(home / "config"), - "XDG_CACHE_HOME": str(home / "cache"), "NODE_ENV": "test", - } + environment = untrusted_child_environment( + home=home, + extra={"NODE_ENV": "test"}, + drop={"NODE_PATH", "PLAYWRIGHT_BROWSERS_PATH"}, + ) if node_modules is not None and node_modules.is_dir(): environment["NODE_PATH"] = str(node_modules) local_browsers = node_modules / "playwright-core" / ".local-browsers" @@ -3334,6 +3386,8 @@ def _playwright_result( """Normalize JSON reporter output to project, file, title-path identities.""" try: payload = json.loads(output) + if not isinstance(payload, dict): + raise ValueError("malformed Playwright reporter payload") tests = payload.get("tests") if tests is None: tests = [] @@ -3342,7 +3396,10 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: raise ValueError("malformed Playwright suite") title = suite.get("title", "") next_parents = parents + ((title,) if isinstance(title, str) and title else ()) - for spec in suite.get("specs", []): + specs = suite.get("specs", []) + if not isinstance(specs, list): + raise ValueError("malformed Playwright specs") + for spec in specs: if not isinstance(spec, dict): raise ValueError("malformed Playwright spec") spec_file = Path(spec["file"]) @@ -3350,8 +3407,16 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: spec_file = Path(os.path.abspath(root / spec_file)) filename = spec_file.resolve().relative_to(root.resolve()).as_posix() title_path = " > ".join(next_parents + (spec["title"],)) - for item in spec.get("tests", []): - result = (item.get("results") or [{}])[-1] + spec_tests = spec.get("tests", []) + if not isinstance(spec_tests, list): + raise ValueError("malformed Playwright tests") + for item in spec_tests: + if not isinstance(item, dict): + raise ValueError("malformed Playwright test") + results = item.get("results") or [{}] + if not isinstance(results, list) or not results or not isinstance(results[-1], dict): + raise ValueError("malformed Playwright results") + result = results[-1] tests.append({ "identity": f"{item['projectName']}::{filename}::{title_path}", "status": result.get("status", "passed" if collection else "skipped"), @@ -3402,7 +3467,7 @@ def _run_playwright( prefix = _playwright_command(config) if prefix is None: return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-unavailable", "no local Playwright CLI is available"), () - command_error = _protected_command_error(root, prefix) + command_error = _playwright_command_error(root, prefix) if command_error is not None: return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-untrusted", command_error @@ -4351,6 +4416,7 @@ def run_profile( binding.base_sha, binding.head_sha, adapter_identities=config.adapter_identities, + playwright_command=config.playwright_command, ) results = tuple( ObligationEvidence(item.obligation_id, item.outcome) for item in executions diff --git a/pdd/sync_core/trust.py b/pdd/sync_core/trust.py index 78818d04d6..7531950995 100644 --- a/pdd/sync_core/trust.py +++ b/pdd/sync_core/trust.py @@ -183,6 +183,7 @@ class AttestationBinding: vitest_toolchain_manifest: str | None = None vitest_toolchain_identity: str | None = None adapter_identities: tuple[tuple[str, str], ...] = () + playwright_command: tuple[str, ...] | None = None @dataclass(frozen=True) @@ -238,6 +239,10 @@ def payload(self) -> bytes: } if self.binding.adapter_identities: data["binding"]["adapter_identities"] = list(self.binding.adapter_identities) + if self.binding.playwright_command is not None: + data["binding"]["playwright_command"] = list( + self.binding.playwright_command + ) return json.dumps(data, sort_keys=True, separators=(",", ":")).encode() From d171597d0f6624bd3cf7d97d6b0b05a512be794e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 07:01:54 -0700 Subject: [PATCH 025/233] fix(sync): retain Playwright trust checks before isolation --- pdd/sync_core/runner.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index d4b71998af..afacb6d6a3 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4298,6 +4298,7 @@ def run_obligation( dirty = { "jest": _dirty_jest_support, "vitest": _dirty_vitest_support, + "playwright": _dirty_playwright_support, }.get(obligation.validator_id, _dirty_pytest_support)(root) if dirty: return RunnerExecution( @@ -4336,6 +4337,23 @@ def run_obligation( obligation.validator_config_digest, f"Vitest toolchain validation failed: {exc}", ) + if obligation.validator_id == "playwright": + if _playwright_candidate_toolchain(root): + return RunnerExecution( + obligation.obligation_id, + EvidenceOutcome.ERROR, + obligation.validator_config_digest, + "candidate node_modules Playwright toolchain is not trusted", + ) + if config.playwright_command is not None: + command_error = _playwright_command_error(root, config.playwright_command) + if command_error is not None: + return RunnerExecution( + obligation.obligation_id, + EvidenceOutcome.ERROR, + obligation.validator_config_digest, + command_error, + ) with tempfile.TemporaryDirectory(prefix="pdd-runner-exact-head-") as directory: clone = Path(directory) / "repository" cloned = subprocess.run( From 5730d1a1cfee46f7e6becd27f69540e52be2291b Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 07:43:18 -0700 Subject: [PATCH 026/233] test(sync): cover Playwright round eight trust gaps --- tests/test_sync_core_evidence_store.py | 15 ++++ tests/test_sync_core_runner_playwright.py | 97 +++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/tests/test_sync_core_evidence_store.py b/tests/test_sync_core_evidence_store.py index fe71cd42a0..6a866b82e8 100644 --- a/tests/test_sync_core_evidence_store.py +++ b/tests/test_sync_core_evidence_store.py @@ -3,6 +3,7 @@ import base64 import json import subprocess +from dataclasses import replace from datetime import datetime, timezone from pathlib import Path, PurePosixPath @@ -51,6 +52,20 @@ def test_attestation_json_round_trip_preserves_signed_payload() -> None: assert decoded.payload() == envelope.payload() +def test_attestation_json_round_trip_preserves_playwright_command() -> None: + envelope = _envelope() + envelope = replace( + envelope, + binding=replace( + envelope.binding, + playwright_command=("/opt/node", "/opt/playwright/cli.js"), + ), + ) + decoded = decode_attestation(json.loads(encode_attestation(envelope))) + assert decoded == envelope + assert decoded.payload() == envelope.payload() + + def test_candidate_cannot_replace_protected_base_public_key(tmp_path) -> None: root = tmp_path / "repo" root.mkdir() diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 57e3f2eeb2..0768af0b2d 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -25,6 +25,7 @@ _playwright_environment, _playwright_command_error, _playwright_result, + _toolchain_manifest_identity, playwright_validator_config_digest, ) @@ -223,6 +224,17 @@ def test_playwright_rejects_non_declarative_config_shapes( ) +def test_playwright_rejects_semantic_config_indirection(tmp_path: Path) -> None: + root, commit = _repository( + tmp_path, + config="export default Object.fromEntries([['global' + 'Setup', './setup.ts']]);\n", + ) + with pytest.raises(ValueError, match="declarative"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + @pytest.mark.parametrize( "source", [ @@ -244,6 +256,50 @@ def test_playwright_rejects_dynamic_or_aliased_module_loading( ) +@pytest.mark.parametrize( + "source", + [ + "const path = './helper'; require(path);\n", + "const path = './helper'; module.require(path);\n", + "const load = globalThis['require']; load('./helper');\n", + ], +) +def test_playwright_rejects_all_non_literal_module_loading( + tmp_path: Path, source: str +) -> None: + root, commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "dynamic loader") + commit = _git(root, "rev-parse", "HEAD") + with pytest.raises(ValueError, match="module loading"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize( + "source", + [ + "const matcher = 'toHave' + 'Screenshot'; expect(page)[matcher]('widget.png');\n", + "process.getBuiltinModule('fs').readFileSync('oracle.json');\n", + "process.getBuiltinModule('child_process').execFileSync('./helper');\n", + ], +) +def test_playwright_rejects_reflective_runtime_resource_access( + tmp_path: Path, source: str +) -> None: + root, commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "reflective resource access") + commit = _git(root, "rev-parse", "HEAD") + with pytest.raises(ValueError, match="runtime resource"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + def test_playwright_snapshot_oracle_is_bound_to_validator_digest(tmp_path: Path) -> None: root, base = _repository(tmp_path) snapshot = root / "tests/widget.spec.ts-snapshots/widget-linux.png" @@ -281,6 +337,47 @@ def test_directory_identity_binds_symlink_topology(tmp_path: Path) -> None: assert _directory_identity(dependencies) != before +def test_toolchain_manifest_binds_transitive_and_stable_symlink_target_bytes( + tmp_path: Path, +) -> None: + toolchain = tmp_path / "toolchain" + target = tmp_path / "packages" / "helper" + target.mkdir(parents=True) + (target / "index.js").write_text("module.exports = 1", encoding="utf-8") + toolchain.mkdir() + (toolchain / "node").write_bytes(b"node") + (toolchain / "cli.js").write_text("require('./modules/helper')", encoding="utf-8") + (toolchain / "modules").mkdir() + (toolchain / "modules/helper").symlink_to(target, target_is_directory=True) + manifest = tmp_path / "playwright-toolchain.json" + manifest.write_text( + '{"version":1,"files":["toolchain/node","toolchain/cli.js",' + '"toolchain/modules/helper"]}', + encoding="utf-8", + ) + before = _toolchain_manifest_identity(manifest) + (target / "index.js").write_text("module.exports = 2", encoding="utf-8") + assert _toolchain_manifest_identity(manifest) != before + + +@pytest.mark.parametrize("launcher_kind", ["missing", "directory", "non_executable"]) +def test_playwright_launch_failures_are_normalized( + tmp_path: Path, launcher_kind: str +) -> None: + root, commit = _repository(tmp_path) + launcher = tmp_path / launcher_kind + if launcher_kind == "directory": + launcher.mkdir() + elif launcher_kind == "non_executable": + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + entrypoint = _fake_playwright(tmp_path) + _envelope, executions = _run( + root, commit, commit, (str(launcher), str(entrypoint)) + ) + assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR + assert "launch" in executions[0].detail.lower() + + @pytest.mark.parametrize("option", ["--require=helper", "--import=helper", "--loader=helper"]) def test_playwright_command_rejects_candidate_resolving_prefix_options( tmp_path: Path, option: str From a63865aa29dd0a1b316c857c7888c14fe1713e5f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 07:54:10 -0700 Subject: [PATCH 027/233] fix(sync): close Playwright round eight trust gaps --- pdd/sync_core/evidence_store.py | 14 ++++-- pdd/sync_core/runner.py | 83 ++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/pdd/sync_core/evidence_store.py b/pdd/sync_core/evidence_store.py index 9dc0eda4d6..0074fb6656 100644 --- a/pdd/sync_core/evidence_store.py +++ b/pdd/sync_core/evidence_store.py @@ -83,6 +83,8 @@ def attestation_payload(envelope: AttestationEnvelope) -> dict[str, Any]: payload["binding"]["adapter_identities"] = [ list(item) for item in binding.adapter_identities ] + if binding.playwright_command is not None: + payload["binding"]["playwright_command"] = list(binding.playwright_command) return payload @@ -127,6 +129,13 @@ def decode_attestation(payload: Mapping[str, Any]) -> AttestationEnvelope: or len(set(adapter_identities)) != len(adapter_identities) ): raise TypeError("adapter_identities must be sorted and unique") + command_data = binding_data.get("playwright_command") + if command_data is not None and ( + not isinstance(command_data, list) + or len(command_data) != 2 + or not all(isinstance(item, str) and item for item in command_data) + ): + raise TypeError("playwright_command must be two non-empty strings") binding = AttestationBinding( subject, _string(binding_data, "snapshot_digest"), @@ -137,10 +146,7 @@ def decode_attestation(payload: Mapping[str, Any]) -> AttestationEnvelope: _string(binding_data, "checked_sha"), adapter_identities=adapter_identities, playwright_command=( - tuple(binding_data["playwright_command"]) - if isinstance(binding_data.get("playwright_command"), list) - and all(isinstance(item, str) for item in binding_data["playwright_command"]) - else None, + tuple(command_data) if command_data is not None else None ), ) results = tuple( diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index afacb6d6a3..f66286a5b3 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1491,6 +1491,13 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos text = source.decode("utf-8") except UnicodeDecodeError as exc: raise ValueError("Playwright configuration must be UTF-8") from exc + declarative_config = re.fullmatch( + r"(?:import\s+['\"]\.{1,2}/[^'\"]+['\"]\s*;?\s*)*" + r"export\s+default\s*\{[\s\S]*\}\s*;?", + text.strip(), + ) + if declarative_config is None: + raise ValueError("Playwright configuration must be a declarative object literal") # Playwright config is executable code. Only accept the deliberately small, # directly inspectable object-literal subset; syntactic indirection makes a # security decision based on token matching unsound. @@ -1575,8 +1582,16 @@ def _playwright_support_closure( raise ValueError(f"Playwright source is not UTF-8: {path.as_posix()}") from exc if re.search(r"\bimport\s*\(\s*(?!['\"])", text) or re.search( r"\b(?:const|let|var)\s+\w+\s*=\s*require\b", text + ) or re.search( + r"\b(?:module\.)?require\s*\(\s*(?!['\"])", text + ) or re.search( + r"(?:globalThis|global|window)\s*\[\s*['\"]require['\"]\s*\]", text ): raise ValueError("dynamic or aliased Playwright module loading is not supported") + if re.search(r"process\s*\.\s*getBuiltinModule\s*\(", text) or re.search( + r"expect\s*\([^)]*\)\s*\[", text + ): + raise ValueError("reflective Playwright runtime resource access is not supported") if re.search( r"(?:from\s+|import\s*\(|require\s*\()\s*['\"](?:node:)?fs(?:/[^'\"]*)?['\"]", text, @@ -2366,6 +2381,56 @@ def _directory_identity(path: Path) -> str: return digest.hexdigest() +def _manifest_path_identity(path: Path, seen: set[Path]) -> bytes: + """Return identity bytes for a declared path and symlink target closure.""" + absolute = path.absolute() + if absolute in seen: + return b"cycle\0" + str(absolute).encode() + seen.add(absolute) + try: + if path.is_symlink(): + target_text = os.readlink(path) + target = (path.parent / target_text).absolute() + return ( + b"link\0" + str(path).encode() + b"\0" + target_text.encode() + + b"\0" + _manifest_path_identity(target, seen) + ) + if path.is_file(): + return b"file\0" + str(path).encode() + b"\0" + path.read_bytes() + if path.is_dir(): + content = bytearray(b"dir\0" + str(path).encode() + b"\0") + for child in sorted(path.iterdir(), key=lambda item: item.name): + content.extend(_manifest_path_identity(child, seen.copy())) + content.extend(b"\0") + return bytes(content) + except OSError as exc: + return b"error\0" + str(path).encode() + b"\0" + str(exc).encode() + return b"missing\0" + str(path).encode() + + +def _toolchain_manifest_identity(manifest_path: Path) -> str: + """Hash a strict protected Playwright toolchain manifest and its closure.""" + try: + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("Playwright toolchain manifest is invalid") from exc + if not isinstance(payload, dict) or set(payload) != {"version", "files"}: + raise ValueError("Playwright toolchain manifest has unknown fields") + files = payload.get("files") + if payload.get("version") != 1 or not isinstance(files, list) or not files: + raise ValueError("Playwright toolchain manifest schema is invalid") + if not all(isinstance(item, str) and item for item in files): + raise ValueError("Playwright toolchain manifest files are invalid") + digest = hashlib.sha256(manifest_path.read_bytes() + b"\0") + for item in files: + declared = Path(item) + if declared.is_absolute() or ".." in declared.parts: + raise ValueError("Playwright toolchain manifest path escapes its root") + digest.update(_manifest_path_identity(manifest_path.parent / declared, set())) + digest.update(b"\0") + return digest.hexdigest() + + def _command_part_identity(root: Path, part: str) -> str: """Return literal argv text or a digest for an explicit path operand.""" path = Path(part).expanduser() @@ -3350,6 +3415,12 @@ def _playwright_command_error(root: Path, command: tuple[str, ...]) -> str | Non return "Playwright command options are not trusted in the protected prefix" if not Path(command[1]).expanduser().is_absolute(): return "Playwright CLI entrypoint must be an absolute external path" + executable = Path(command[0]).expanduser() + entrypoint = Path(command[1]).expanduser() + if not executable.is_file() or not os.access(executable, os.X_OK): + return "Playwright launch executable is missing or not executable" + if not entrypoint.is_file(): + return "Playwright launch entrypoint is missing or is not a file" return None @@ -3470,7 +3541,8 @@ def _run_playwright( command_error = _playwright_command_error(root, prefix) if command_error is not None: return RunnerExecution( - "playwright", EvidenceOutcome.ERROR, "playwright-untrusted", command_error + "playwright", EvidenceOutcome.COLLECTION_ERROR, + "playwright-untrusted", command_error, ), () node_modules = _external_node_modules_root(root, prefix) try: @@ -3497,6 +3569,11 @@ def _run_playwright( ) except subprocess.TimeoutExpired: return RunnerExecution("playwright", EvidenceOutcome.TIMEOUT, digest, "Playwright execution timed out"), () + except OSError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, + f"Playwright launch failed: {exc}", + ), () outcome, detail, identities = _playwright_result( root, result.stdout, result.returncode, expected, collection ) @@ -4350,7 +4427,9 @@ def run_obligation( if command_error is not None: return RunnerExecution( obligation.obligation_id, - EvidenceOutcome.ERROR, + EvidenceOutcome.COLLECTION_ERROR + if command_error.startswith("Playwright launch") + else EvidenceOutcome.ERROR, obligation.validator_config_digest, command_error, ) From 19513cbfe5970bf49dd19583da7c4dada7b92e04 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 08:27:51 -0700 Subject: [PATCH 028/233] test(sync): cover Playwright round nine trust gaps --- tests/test_sync_core_runner_playwright.py | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 0768af0b2d..ef3ea95601 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -235,6 +235,25 @@ def test_playwright_rejects_semantic_config_indirection(tmp_path: Path) -> None: ) +@pytest.mark.parametrize( + "config", + [ + "export default { global\\u0053etup: './setup.ts' };\n", + "export default { ['globalSetup']: './setup.ts' };\n", + "export default { reporter() { return './reporter.ts'; } };\n", + "export default { timeout: (() => 1000)() };\n", + ], +) +def test_playwright_config_uses_enumerated_static_syntax( + tmp_path: Path, config: str +) -> None: + root, commit = _repository(tmp_path, config=config) + with pytest.raises(ValueError, match="configuration"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + @pytest.mark.parametrize( "source", [ @@ -278,6 +297,30 @@ def test_playwright_rejects_all_non_literal_module_loading( ) +@pytest.mark.parametrize( + "source", + [ + "const path = './helper'; module['require'](path);\n", + "const path = './helper'; require /*comment*/ (path);\n", + "const load = module.createRequire(import.meta.url); load('./helper');\n", + "Function('return require')()('./helper');\n", + ], +) +def test_playwright_rejects_semantic_loader_variants( + tmp_path: Path, source: str +) -> None: + root, commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "semantic loader") + with pytest.raises(ValueError, match="module loading"): + playwright_validator_config_digest( + root, + _git(root, "rev-parse", "HEAD"), + (PurePosixPath("tests/widget.spec.ts"),), + ) + + @pytest.mark.parametrize( "source", [ @@ -300,6 +343,29 @@ def test_playwright_rejects_reflective_runtime_resource_access( ) +@pytest.mark.parametrize( + "source", + [ + "import { execFileSync } from 'node:child_process'; execFileSync('./oracle');\n", + "const assertion = expect(page); const matcher = 'toHave' + 'Screenshot'; assertion[matcher]('widget.png');\n", + "import * as filesystem from 'node:fs'; filesystem.readFileSync('oracle');\n", + ], +) +def test_playwright_rejects_unbound_runtime_capabilities( + tmp_path: Path, source: str +) -> None: + root, commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "runtime capability") + with pytest.raises(ValueError, match="runtime resource"): + playwright_validator_config_digest( + root, + _git(root, "rev-parse", "HEAD"), + (PurePosixPath("tests/widget.spec.ts"),), + ) + + def test_playwright_snapshot_oracle_is_bound_to_validator_digest(tmp_path: Path) -> None: root, base = _repository(tmp_path) snapshot = root / "tests/widget.spec.ts-snapshots/widget-linux.png" @@ -360,6 +426,47 @@ def test_toolchain_manifest_binds_transitive_and_stable_symlink_target_bytes( assert _toolchain_manifest_identity(manifest) != before +def test_playwright_production_run_requires_and_rechecks_toolchain_manifest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, commit = _repository(tmp_path) + runner = _fake_playwright(tmp_path) + manifest = tmp_path / "playwright-toolchain.json" + manifest.write_text( + '{"version":1,"files":["fake_playwright.py"]}', encoding="utf-8" + ) + original_run = subprocess.run + + def mutate_after_playwright(*args, **kwargs): + result = original_run(*args, **kwargs) + command = args[0] if args else kwargs.get("args", ()) + if isinstance(command, (list, tuple)) and str(runner) in command: + runner.write_text(runner.read_text(encoding="utf-8") + "# changed\n") + return result + + monkeypatch.setattr("pdd.sync_core.runner.subprocess.run", mutate_after_playwright) + paths = (PurePosixPath("tests/widget.spec.ts"),) + obligation = VerificationObligation( + "playwright", "test", "playwright", + playwright_validator_config_digest(root, commit, paths), ("REQ-1",), paths, + ) + profile = VerificationProfile(UNIT, (obligation,), ("REQ-1",), "profile-v1") + _envelope, executions = run_profile( + root, profile, RunBinding("snapshot-v1", commit, commit), + AttestationIssue( + AttestationSigner("trusted-ci", b"p" * 32), "id", "nonce", + datetime(2026, 7, 10, tzinfo=timezone.utc), + ), + config=RunnerConfig( + timeout_seconds=2, + playwright_command=(sys.executable, str(runner)), + playwright_toolchain_manifest=manifest, + ), + ) + assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR + assert "toolchain" in executions[0].detail.lower() + + @pytest.mark.parametrize("launcher_kind", ["missing", "directory", "non_executable"]) def test_playwright_launch_failures_are_normalized( tmp_path: Path, launcher_kind: str From fe56c0a12ae17c4e37816528df1f587f05f9500e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 08:39:13 -0700 Subject: [PATCH 029/233] fix(sync): close Playwright round nine trust gaps --- pdd/commands/sync_core.py | 16 +- pdd/sync_core/evidence_store.py | 10 + pdd/sync_core/finalize.py | 4 + pdd/sync_core/reporting.py | 2 + pdd/sync_core/runner.py | 298 +++++++++++++++++++--- pdd/sync_core/trust.py | 5 + tests/test_sync_core_runner_playwright.py | 24 +- 7 files changed, 316 insertions(+), 43 deletions(-) diff --git a/pdd/commands/sync_core.py b/pdd/commands/sync_core.py index 1c29ac0277..779bdb3a34 100644 --- a/pdd/commands/sync_core.py +++ b/pdd/commands/sync_core.py @@ -214,6 +214,7 @@ def _runner_config_from_options( if error is not None: raise click.ClickException(f"--vitest-command: {error}") playwright_command = options.get("playwright_command") + playwright_manifest = options.get("playwright_toolchain_manifest") config = RunnerConfig( jest_command=_protected_command( jest_command if isinstance(jest_command, str) else None, @@ -222,11 +223,12 @@ def _runner_config_from_options( ), vitest_command=protected_vitest, vitest_toolchain_manifest=manifest_path, - playwright_command=_protected_command( playwright_command=_protected_playwright_command( playwright_command if isinstance(playwright_command, str) else None, cwd, ), + playwright_toolchain_manifest=Path(playwright_manifest).expanduser().resolve() + if isinstance(playwright_manifest, str) and playwright_manifest else None, ) if protected_vitest is not None: try: @@ -239,6 +241,7 @@ def _runner_config_from_options( vitest_toolchain_manifest=config.vitest_toolchain_manifest, vitest_toolchain_identity=descriptor.identity, playwright_command=config.playwright_command, + playwright_toolchain_manifest=config.playwright_toolchain_manifest, adapter_identities=config.adapter_identities, ) return config @@ -504,10 +507,18 @@ def baseline(ctx: click.Context, module: str, reviewed_by: str, reason: str) -> envvar="PDD_SYNC_VITEST_TOOLCHAIN_MANIFEST", type=click.Path(path_type=Path), help="Protected external Node/Vitest toolchain closure manifest.", +) +@click.option( "--playwright-command", envvar="PDD_SYNC_PLAYWRIGHT_COMMAND", help="Protected absolute external Playwright command argv.", ) +@click.option( + "--playwright-toolchain-manifest", + envvar="PDD_SYNC_PLAYWRIGHT_TOOLCHAIN_MANIFEST", + type=click.Path(path_type=Path), + help="Protected external Playwright toolchain manifest.", +) @click.pass_context def validate( ctx: click.Context, @@ -518,6 +529,7 @@ def validate( vitest_command: str | None, vitest_toolchain_manifest: Path | None, playwright_command: str | None, + playwright_toolchain_manifest: Path | None, ) -> None: # pylint: disable=too-many-arguments,too-many-positional-arguments """Run protected obligations and transactionally finalize trusted evidence.""" @@ -536,6 +548,8 @@ def validate( "vitest_toolchain_manifest": str(vitest_toolchain_manifest) if vitest_toolchain_manifest else None, "playwright_command": playwright_command, + "playwright_toolchain_manifest": str(playwright_toolchain_manifest) + if playwright_toolchain_manifest else None, }, root, ), diff --git a/pdd/sync_core/evidence_store.py b/pdd/sync_core/evidence_store.py index 0074fb6656..aaa883a505 100644 --- a/pdd/sync_core/evidence_store.py +++ b/pdd/sync_core/evidence_store.py @@ -85,6 +85,10 @@ def attestation_payload(envelope: AttestationEnvelope) -> dict[str, Any]: ] if binding.playwright_command is not None: payload["binding"]["playwright_command"] = list(binding.playwright_command) + if binding.playwright_toolchain_manifest is not None: + payload["binding"]["playwright_toolchain_manifest"] = ( + binding.playwright_toolchain_manifest + ) return payload @@ -136,6 +140,11 @@ def decode_attestation(payload: Mapping[str, Any]) -> AttestationEnvelope: or not all(isinstance(item, str) and item for item in command_data) ): raise TypeError("playwright_command must be two non-empty strings") + manifest_data = binding_data.get("playwright_toolchain_manifest") + if manifest_data is not None and ( + not isinstance(manifest_data, str) or not manifest_data + ): + raise TypeError("playwright_toolchain_manifest must be a non-empty string") binding = AttestationBinding( subject, _string(binding_data, "snapshot_digest"), @@ -148,6 +157,7 @@ def decode_attestation(payload: Mapping[str, Any]) -> AttestationEnvelope: playwright_command=( tuple(command_data) if command_data is not None else None ), + playwright_toolchain_manifest=manifest_data, ) results = tuple( ObligationEvidence( diff --git a/pdd/sync_core/finalize.py b/pdd/sync_core/finalize.py index 7314de5bde..27728f422b 100644 --- a/pdd/sync_core/finalize.py +++ b/pdd/sync_core/finalize.py @@ -226,6 +226,9 @@ def _reusable_result( config=RunnerConfig( adapter_identities=envelope.binding.adapter_identities, playwright_command=envelope.binding.playwright_command, + playwright_toolchain_manifest=Path( + envelope.binding.playwright_toolchain_manifest + ) if envelope.binding.playwright_toolchain_manifest else None, ), ), TRUSTED_RUNNER_VERSION, @@ -233,6 +236,7 @@ def _reusable_result( envelope.binding.checked_sha, adapter_identities=envelope.binding.adapter_identities, playwright_command=envelope.binding.playwright_command, + playwright_toolchain_manifest=envelope.binding.playwright_toolchain_manifest, ) verifier.verify_current_for_idempotency(envelope, binding, now=now) ancestry = subprocess.run( diff --git a/pdd/sync_core/reporting.py b/pdd/sync_core/reporting.py index cbdc2aa04b..24cfe0e7d5 100644 --- a/pdd/sync_core/reporting.py +++ b/pdd/sync_core/reporting.py @@ -169,6 +169,8 @@ def _evidence( config=RunnerConfig( adapter_identities=binding.adapter_identities, playwright_command=binding.playwright_command, + playwright_toolchain_manifest=Path(binding.playwright_toolchain_manifest) + if binding.playwright_toolchain_manifest else None, ), ) or binding.tool_version != TRUSTED_RUNNER_VERSION diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index f66286a5b3..fd80eda3e9 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -188,6 +188,7 @@ class RunnerConfig: vitest_toolchain_identity: str | None = None adapter_identities: tuple[tuple[str, str], ...] = () playwright_command: tuple[str, ...] | None = None + playwright_toolchain_manifest: Path | None = None @dataclass(frozen=True) @@ -1491,12 +1492,14 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos text = source.decode("utf-8") except UnicodeDecodeError as exc: raise ValueError("Playwright configuration must be UTF-8") from exc - declarative_config = re.fullmatch( - r"(?:import\s+['\"]\.{1,2}/[^'\"]+['\"]\s*;?\s*)*" - r"export\s+default\s*\{[\s\S]*\}\s*;?", - text.strip(), - ) - if declarative_config is None: + tokens = _javascript_tokens(text) + if any(token[0] == "escaped_identifier" for token in tokens): + raise ValueError("Playwright configuration has an escaped identifier") + try: + references = _parse_playwright_config_tokens(path, tokens) + except ValueError as exc: + raise ValueError("Playwright configuration must be a declarative object literal") from exc + if not tokens: raise ValueError("Playwright configuration must be a declarative object literal") # Playwright config is executable code. Only accept the deliberately small, # directly inspectable object-literal subset; syntactic indirection makes a @@ -1535,12 +1538,7 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos if re.search(r"\b(?:globalSetup|globalTeardown|reporter)\s*:\s*(?!['\"])", text): raise ValueError("indirect Playwright executable controls are not bound by this adapter") # Parse direct relative imports here; the closure resolver checks each blob. - references: set[PurePosixPath] = set() - for item in re.findall(r"(?:from\s+|import\s+)['\"](\.{1,2}/[^'\"]+)['\"]", text): - reference = _normalize_repo_relative_path(path.parent / PurePosixPath(item)) - if reference is None: - raise ValueError("Playwright config import escapes the repository") - references.add(reference) + references = set(references) for key in ("globalSetup", "globalTeardown", "reporter"): for value in re.findall(rf"\b{key}\s*:\s*['\"]([^'\"]+)['\"]", text): local = _jest_local_path(value) @@ -1550,6 +1548,158 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos return references +def _javascript_tokens(text: str) -> list[tuple[str, str]]: + """Lex JavaScript without retaining trivia, decoding string escapes.""" + tokens: list[tuple[str, str]] = [] + index = 0 + punctuation = set("{}[]():,;.*+-/=>&|!?%") + while index < len(text): + char = text[index] + if char.isspace(): + index += 1 + continue + if text.startswith("//", index): + index = text.find("\n", index) + index = len(text) if index < 0 else index + 1 + continue + if text.startswith("/*", index): + end = text.find("*/", index + 2) + if end < 0: + raise ValueError("unterminated JavaScript comment") + index = end + 2 + continue + if char in "'\"": + quote, start = char, index + index += 1 + value = "" + while index < len(text) and text[index] != quote: + if text[index] == "\\": + index += 1 + if index >= len(text): + raise ValueError("unterminated JavaScript string") + if text[index] == "u" and index + 4 < len(text): + value += chr(int(text[index + 1:index + 5], 16)) + index += 5 + continue + value += {"n": "\n", "r": "\r", "t": "\t"}.get(text[index], text[index]) + index += 1 + continue + value += text[index] + index += 1 + if index >= len(text): + raise ValueError(f"unterminated JavaScript string at {start}") + tokens.append(("string", value)) + index += 1 + continue + if char.isalpha() or char in "_$" or char == "\\": + escaped = False + value = "" + while index < len(text): + if text[index] == "\\" and text.startswith("\\u", index): + value += chr(int(text[index + 2:index + 6], 16)) + index += 6 + escaped = True + elif text[index].isalnum() or text[index] in "_$": + value += text[index] + index += 1 + else: + break + tokens.append(("escaped_identifier" if escaped else "identifier", value)) + continue + if char.isdigit(): + start = index + while index < len(text) and (text[index].isalnum() or text[index] in ".x_"): + index += 1 + tokens.append(("number", text[start:index])) + continue + if char == "`": + raise ValueError("template literals are not supported") + if char in punctuation: + pair = text[index:index + 2] + if pair in {"=>", "?.", "**"}: + tokens.append(("punct", pair)) + index += 2 + else: + tokens.append(("punct", char)) + index += 1 + continue + raise ValueError(f"unsupported JavaScript token {char!r}") + return tokens + + +def _parse_playwright_config_tokens( + path: PurePosixPath, tokens: list[tuple[str, str]] +) -> set[PurePosixPath]: + """Parse the enumerated data-only Playwright configuration grammar.""" + position = 0 + references: set[PurePosixPath] = set() + + def accept(value: str) -> bool: + nonlocal position + if position < len(tokens) and tokens[position][1] == value: + position += 1 + return True + return False + + def take(kind: str) -> str: + nonlocal position + if position >= len(tokens) or tokens[position][0] != kind: + raise ValueError("unexpected config token") + value = tokens[position][1] + position += 1 + return value + + def value() -> object: + nonlocal position + if position >= len(tokens): + raise ValueError("missing config value") + kind, item = tokens[position] + if kind in {"string", "number"}: + position += 1 + return item + if kind == "identifier" and item in {"true", "false", "null"}: + position += 1 + return item + if accept("["): + result = [] + while not accept("]"): + result.append(value()) + if not accept(",") and tokens[position][1] != "]": + raise ValueError("invalid config array") + return result + if accept("{"): + result = {} + while not accept("}"): + if position >= len(tokens) or tokens[position][0] not in {"identifier", "string"}: + raise ValueError("config keys must be direct names") + key = tokens[position][1] + position += 1 + if not accept(":"): + raise ValueError("config methods and shorthand are unsupported") + result[key] = value() + if not accept(",") and tokens[position][1] != "}": + raise ValueError("invalid config object") + return result + raise ValueError("config values must be data literals") + + while accept("import"): + imported = take("string") + if not imported.startswith(("./", "../")): + raise ValueError("config import must be local") + normalized = _normalize_repo_relative_path(path.parent / PurePosixPath(imported)) + if normalized is None: + raise ValueError("config import escapes repository") + references.add(normalized) + accept(";") + if not (accept("export") and accept("default")): + raise ValueError("config must use export default") + parsed = value() + accept(";") + if position != len(tokens) or not isinstance(parsed, dict): + raise ValueError("config root must be one object") + return references + + def _playwright_support_closure( root: Path, ref: str, test_paths: tuple[PurePosixPath, ...] ) -> tuple[tuple[PurePosixPath, bytes], ...]: @@ -1569,35 +1719,22 @@ def _playwright_support_closure( raise ValueError(f"Playwright local support path is missing: {path.as_posix()}") paths.add(path) if path.suffix in _JAVASCRIPT_SUFFIXES: - pending.extend(_local_javascript_imports(root, ref, path, source) - visited) - unbound_bare = _unbound_playwright_bare_imports(source) + imports, bare_imports, has_snapshot = _playwright_source_syntax(source) + for imported in imports: + normalized = _normalize_repo_relative_path(path.parent / PurePosixPath(imported)) + if normalized is None: + raise ValueError("Playwright import escapes the repository") + resolved, imported_source = _read_javascript_support_blob(root, ref, normalized) + if imported_source is None: + raise ValueError(f"Playwright local support path is missing: {resolved}") + pending.append(resolved) + unbound_bare = bare_imports - {"@playwright/test"} if unbound_bare: raise ValueError( "Playwright bare package imports are not bound by this adapter: " + ", ".join(sorted(unbound_bare)) ) - try: - text = source.decode("utf-8") - except UnicodeDecodeError as exc: - raise ValueError(f"Playwright source is not UTF-8: {path.as_posix()}") from exc - if re.search(r"\bimport\s*\(\s*(?!['\"])", text) or re.search( - r"\b(?:const|let|var)\s+\w+\s*=\s*require\b", text - ) or re.search( - r"\b(?:module\.)?require\s*\(\s*(?!['\"])", text - ) or re.search( - r"(?:globalThis|global|window)\s*\[\s*['\"]require['\"]\s*\]", text - ): - raise ValueError("dynamic or aliased Playwright module loading is not supported") - if re.search(r"process\s*\.\s*getBuiltinModule\s*\(", text) or re.search( - r"expect\s*\([^)]*\)\s*\[", text - ): - raise ValueError("reflective Playwright runtime resource access is not supported") - if re.search( - r"(?:from\s+|import\s*\(|require\s*\()\s*['\"](?:node:)?fs(?:/[^'\"]*)?['\"]", - text, - ): - raise ValueError("Playwright runtime file access is not bound by this adapter") - if "toHaveScreenshot" in text: + if has_snapshot: snapshot_prefix = PurePosixPath(f"{path.as_posix()}-snapshots") listed = subprocess.run( ["git", "ls-tree", "-r", "--name-only", ref, "--", snapshot_prefix.as_posix()], @@ -1608,6 +1745,7 @@ def _playwright_support_closure( snapshot = read_git_blob(root, ref, snapshot_path) if snapshot is not None: paths.add(snapshot_path) + text = source.decode("utf-8") if re.search(r"\b(?:test|describe)\.(?:only|skip|fixme|slow)\s*\(", text): raise ValueError("Playwright focused, skipped, fixme, or slow tests are ambiguous") return tuple( @@ -1616,6 +1754,59 @@ def _playwright_support_closure( ) +def _playwright_source_syntax(source: bytes) -> tuple[set[str], set[str], bool]: + """Analyze loader and runtime capability syntax after lexical decoding.""" + try: + tokens = _javascript_tokens(source.decode("utf-8")) + except (UnicodeDecodeError, ValueError) as exc: + raise ValueError("Playwright source is not valid supported JavaScript syntax") from exc + local: set[str] = set() + bare: set[str] = set() + snapshot = False + dangerous_runtime = { + "child_process", "fs", "worker_threads", "vm", "exec", "execSync", + "execFile", "execFileSync", "spawn", "spawnSync", "fork", + "readFile", "readFileSync", "writeFile", "writeFileSync", + "getBuiltinModule", + } + values = [value for _kind, value in tokens] + for index, (kind, value) in enumerate(tokens): + if value in {"Function", "eval", "createRequire"}: + raise ValueError("dynamic or aliased Playwright module loading is not supported") + if value in dangerous_runtime or (kind == "string" and value.startswith("node:")): + raise ValueError("Playwright runtime resource access is not bound by this adapter") + if value == "toHaveScreenshot": + snapshot = True + if value == "[" and index > 0: + if index + 1 < len(tokens) and tokens[index + 1] == ("string", "require"): + raise ValueError("dynamic or aliased Playwright module loading is not supported") + raise ValueError("reflective Playwright runtime resource access is not supported") + if value == "require": + direct = ( + index + 3 < len(tokens) + and values[index + 1] == "(" + and tokens[index + 2][0] == "string" + and values[index + 3] == ")" + and not (index > 0 and values[index - 1] in {".", "["}) + ) + if not direct: + raise ValueError("dynamic or aliased Playwright module loading is not supported") + imported = values[index + 2] + (local if imported.startswith(("./", "../")) else bare).add(imported) + if value == "import": + if index + 1 < len(tokens) and values[index + 1] == "(": + if not (index + 3 < len(tokens) and tokens[index + 2][0] == "string" and values[index + 3] == ")"): + raise ValueError("dynamic or aliased Playwright module loading is not supported") + imported = values[index + 2] + else: + following = next((item for item in tokens[index + 1:] if item[0] == "string"), None) + if following is None: + continue + imported = following[1] + (local if imported.startswith(("./", "../")) else bare).add(imported) + return local, bare, snapshot + + def playwright_validator_config_digest( root: Path, ref: str, test_paths: tuple[PurePosixPath, ...] ) -> str: @@ -2501,6 +2692,13 @@ def _validator_command_identity_digest(root: Path, config: RunnerConfig) -> str: "node_modules": str(node_modules), "node_modules_sha256": _directory_identity(node_modules), } + if config.playwright_toolchain_manifest is None: + payload["playwright_toolchain_manifest"] = "missing" + else: + payload["playwright_toolchain_manifest"] = { + "path": str(config.playwright_toolchain_manifest.resolve()), + "sha256": _toolchain_manifest_identity(config.playwright_toolchain_manifest), + } return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() @@ -3538,6 +3736,20 @@ def _run_playwright( prefix = _playwright_command(config) if prefix is None: return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-unavailable", "no local Playwright CLI is available"), () + if config.playwright_toolchain_manifest is None: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, + "playwright-untrusted", "Playwright toolchain manifest is required", + ), () + try: + toolchain_identity = _toolchain_manifest_identity( + config.playwright_toolchain_manifest + ) + except ValueError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, + "playwright-untrusted", str(exc), + ), () command_error = _playwright_command_error(root, prefix) if command_error is not None: return RunnerExecution( @@ -3574,6 +3786,16 @@ def _run_playwright( "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, f"Playwright launch failed: {exc}", ), () + try: + if _toolchain_manifest_identity(config.playwright_toolchain_manifest) != toolchain_identity: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, + "Playwright toolchain changed during execution", + ), () + except ValueError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, str(exc) + ), () outcome, detail, identities = _playwright_result( root, result.stdout, result.returncode, expected, collection ) @@ -4514,6 +4736,10 @@ def run_profile( binding.head_sha, adapter_identities=config.adapter_identities, playwright_command=config.playwright_command, + playwright_toolchain_manifest=( + str(config.playwright_toolchain_manifest.resolve()) + if config.playwright_toolchain_manifest is not None else None + ), ) results = tuple( ObligationEvidence(item.obligation_id, item.outcome) for item in executions diff --git a/pdd/sync_core/trust.py b/pdd/sync_core/trust.py index 7531950995..24a8e23fd2 100644 --- a/pdd/sync_core/trust.py +++ b/pdd/sync_core/trust.py @@ -184,6 +184,7 @@ class AttestationBinding: vitest_toolchain_identity: str | None = None adapter_identities: tuple[tuple[str, str], ...] = () playwright_command: tuple[str, ...] | None = None + playwright_toolchain_manifest: str | None = None @dataclass(frozen=True) @@ -243,6 +244,10 @@ def payload(self) -> bytes: data["binding"]["playwright_command"] = list( self.binding.playwright_command ) + if self.binding.playwright_toolchain_manifest is not None: + data["binding"]["playwright_toolchain_manifest"] = ( + self.binding.playwright_toolchain_manifest + ) return json.dumps(data, sort_keys=True, separators=(",", ":")).encode() diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index ef3ea95601..3a10a67cc2 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -126,6 +126,18 @@ def _run( fake: Path | tuple[str, ...], timeout: int = 2, ): + command = fake if isinstance(fake, tuple) else (sys.executable, str(fake)) + entrypoint = Path(command[1]) + manifest_root = root.parent if entrypoint.is_relative_to(root) else entrypoint.parent + declared = entrypoint.name + if entrypoint.is_relative_to(root): + declared = "protected-playwright-tool" + (manifest_root / declared).write_bytes(b"protected") + manifest = manifest_root / "playwright-toolchain.json" + manifest.write_text( + '{"version":1,"files":[' + repr(declared).replace("'", '"') + "]}", + encoding="utf-8", + ) paths = (PurePosixPath("tests/widget.spec.ts"),) try: config_digest = playwright_validator_config_digest(root, base, paths) @@ -147,9 +159,8 @@ def _run( ), config=RunnerConfig( timeout_seconds=timeout, - playwright_command=fake - if isinstance(fake, tuple) - else (sys.executable, str(fake)), + playwright_command=command, + playwright_toolchain_manifest=manifest, ), ) @@ -264,7 +275,7 @@ def test_playwright_config_uses_enumerated_static_syntax( def test_playwright_rejects_dynamic_or_aliased_module_loading( tmp_path: Path, source: str ) -> None: - root, commit = _repository(tmp_path) + root, _commit = _repository(tmp_path) (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") _git(root, "add", ".") _git(root, "commit", "-q", "-m", "dynamic loader") @@ -309,7 +320,7 @@ def test_playwright_rejects_all_non_literal_module_loading( def test_playwright_rejects_semantic_loader_variants( tmp_path: Path, source: str ) -> None: - root, commit = _repository(tmp_path) + root, _commit = _repository(tmp_path) (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") _git(root, "add", ".") _git(root, "commit", "-q", "-m", "semantic loader") @@ -354,7 +365,7 @@ def test_playwright_rejects_reflective_runtime_resource_access( def test_playwright_rejects_unbound_runtime_capabilities( tmp_path: Path, source: str ) -> None: - root, commit = _repository(tmp_path) + root, _commit = _repository(tmp_path) (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") _git(root, "add", ".") _git(root, "commit", "-q", "-m", "runtime capability") @@ -438,6 +449,7 @@ def test_playwright_production_run_requires_and_rechecks_toolchain_manifest( original_run = subprocess.run def mutate_after_playwright(*args, **kwargs): + kwargs.setdefault("check", False) result = original_run(*args, **kwargs) command = args[0] if args else kwargs.get("args", ()) if isinstance(command, (list, tuple)) and str(runner) in command: From 11baf64a4313838884f80c09b0981e1e9821a110 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 09:14:00 -0700 Subject: [PATCH 030/233] test(sync): cover Playwright AST and identity trust boundary --- tests/test_sync_core_runner_playwright.py | 135 ++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 3a10a67cc2..c25829eafd 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -401,6 +401,141 @@ def test_playwright_snapshot_oracle_is_bound_to_validator_digest(tmp_path: Path) assert after != before +def test_playwright_decoded_config_keys_bind_executable_references(tmp_path: Path) -> None: + root, _commit = _repository( + tmp_path, + config='export default { "global\\u0053etup": "./setup.ts" };\n', + ) + (root / "setup.ts").write_text("export default async () => {};\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add decoded setup") + base = _git(root, "rev-parse", "HEAD") + paths = (PurePosixPath("tests/widget.spec.ts"),) + before = playwright_validator_config_digest(root, base, paths) + (root / "setup.ts").write_text("export default async () => { throw 1 };\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "change decoded setup") + assert playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), paths + ) != before + + +def test_playwright_rejects_decoded_unsupported_resource_key(tmp_path: Path) -> None: + root, commit = _repository( + tmp_path, + config='export default { "storage\\u0053tate": "./auth.json" };\n', + ) + with pytest.raises(ValueError, match="unsupported"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize( + "statement", + [ + 'export { oracle } from "./helper";\n', + 'export * from "./helper";\n', + 'export type { Oracle } from "./helper";\n', + ], +) +def test_playwright_reexports_are_in_the_support_closure( + tmp_path: Path, statement: str +) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + 'import "./barrel";\n', encoding="utf-8" + ) + (root / "tests/barrel.ts").write_text(statement, encoding="utf-8") + (root / "tests/helper.ts").write_text("export const oracle = 1;\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add reexport") + base = _git(root, "rev-parse", "HEAD") + paths = (PurePosixPath("tests/widget.spec.ts"),) + before = playwright_validator_config_digest(root, base, paths) + (root / "tests/helper.ts").write_text("export const oracle = 2;\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "change reexport target") + assert playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), paths + ) != before + + +def test_playwright_text_snapshot_oracle_is_bound(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + snapshot = root / "tests/widget.spec.ts-snapshots/oracle.txt" + snapshot.parent.mkdir() + snapshot.write_text("base", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "test('snapshot', () => expect('value').toMatchSnapshot('oracle.txt'));\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "add text snapshot") + base = _git(root, "rev-parse", "HEAD") + paths = (PurePosixPath("tests/widget.spec.ts"),) + before = playwright_validator_config_digest(root, base, paths) + snapshot.write_text("candidate", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "change text snapshot") + assert playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), paths + ) != before + + +def test_playwright_ast_capability_allowlist_rejects_reflection(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + 'Reflect.get(process, "getBuiltin" + "Module")' + '.call(process, "no" + "de:fs");\n', + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "reflective capability") + with pytest.raises(ValueError, match="runtime capability"): + playwright_validator_config_digest( + root, + _git(root, "rev-parse", "HEAD"), + (PurePosixPath("tests/widget.spec.ts"),), + ) + + +def test_playwright_accepts_cjs_config_and_ordinary_typescript(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "playwright.config.ts").unlink() + (root / "playwright.config.cjs").write_text( + 'module.exports = { globalSetup: "./setup.ts", timeout: 1000 };\n', + encoding="utf-8", + ) + (root / "setup.ts").write_text("export default async () => {};\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "type Values = string[]; const [first] = ['ok'];\n" + "test(`ordinary ${first}`, () => expect(/o[k]/.test(first)).toBeTruthy());\n", + encoding="utf-8", + ) + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", "ordinary cjs and typescript") + playwright_validator_config_digest( + root, + _git(root, "rev-parse", "HEAD"), + (PurePosixPath("tests/widget.spec.ts"),), + ) + + +def test_toolchain_manifest_requires_complete_typed_external_roles(tmp_path: Path) -> None: + toolchain = tmp_path / "toolchain" + toolchain.mkdir() + (toolchain / "node").write_bytes(b"node") + manifest = toolchain / "manifest.json" + manifest.write_text( + '{"version":1,"files":["node","missing-cli.js"]}', encoding="utf-8" + ) + with pytest.raises(ValueError, match="roles"): + _toolchain_manifest_identity(manifest) + + def test_directory_identity_binds_symlink_topology(tmp_path: Path) -> None: target = tmp_path / "package-a" target.mkdir() From 7f330426c1628730f74a7cb4a0af7d141e9dc7da Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 09:23:28 -0700 Subject: [PATCH 031/233] test(sync): cover immutable Playwright protocol identity --- tests/test_sync_core_runner_playwright.py | 79 ++++++++++++++++++----- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index c25829eafd..e628712d71 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1,5 +1,6 @@ """Contract tests for the fail-closed trusted Playwright adapter.""" +import json import shutil import subprocess import sys @@ -119,6 +120,31 @@ def _repository( return root, _git(root, "rev-parse", "HEAD") +def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Path: + directory.mkdir(parents=True, exist_ok=True) + dependencies = directory / "dependencies" + browsers = directory / "browsers" + dependencies.mkdir(exist_ok=True) + browsers.mkdir(exist_ok=True) + lockfile = directory / "package-lock.json" + lockfile.write_text("{}\n", encoding="utf-8") + manifest = directory / "playwright-toolchain.json" + manifest.write_text( + json.dumps({ + "version": 2, + "roles": { + "launcher": str(launcher.resolve()), + "entrypoint": str(entrypoint.resolve()), + "dependencies": str(dependencies.resolve()), + "browser_runtime": str(browsers.resolve()), + "lockfile": str(lockfile.resolve()), + }, + }), + encoding="utf-8", + ) + return manifest + + def _run( root: Path, base: str, @@ -133,11 +159,7 @@ def _run( if entrypoint.is_relative_to(root): declared = "protected-playwright-tool" (manifest_root / declared).write_bytes(b"protected") - manifest = manifest_root / "playwright-toolchain.json" - manifest.write_text( - '{"version":1,"files":[' + repr(declared).replace("'", '"') + "]}", - encoding="utf-8", - ) + manifest = _toolchain_manifest(manifest_root, Path(command[0]), manifest_root / declared) paths = (PurePosixPath("tests/widget.spec.ts"),) try: config_digest = playwright_validator_config_digest(root, base, paths) @@ -222,7 +244,6 @@ def test_playwright_malformed_json_shapes_fail_closed( [ "const key='grep'; export default { [key]: /widget/ };\n", "const controls={ ['webServer']: {command:'./server.sh'} }; export default {...controls};\n", - "export default { globalSetup /*comment*/: './setup.ts' };\n", ], ) def test_playwright_rejects_non_declarative_config_shapes( @@ -562,11 +583,18 @@ def test_toolchain_manifest_binds_transitive_and_stable_symlink_target_bytes( (toolchain / "modules").mkdir() (toolchain / "modules/helper").symlink_to(target, target_is_directory=True) manifest = tmp_path / "playwright-toolchain.json" - manifest.write_text( - '{"version":1,"files":["toolchain/node","toolchain/cli.js",' - '"toolchain/modules/helper"]}', - encoding="utf-8", - ) + (toolchain / "browsers").mkdir() + (toolchain / "package-lock.json").write_text("{}", encoding="utf-8") + manifest.write_text(json.dumps({ + "version": 2, + "roles": { + "launcher": str((toolchain / "node").resolve()), + "entrypoint": str((toolchain / "cli.js").resolve()), + "dependencies": str((toolchain / "modules").resolve()), + "browser_runtime": str((toolchain / "browsers").resolve()), + "lockfile": str((toolchain / "package-lock.json").resolve()), + }, + }), encoding="utf-8") before = _toolchain_manifest_identity(manifest) (target / "index.js").write_text("module.exports = 2", encoding="utf-8") assert _toolchain_manifest_identity(manifest) != before @@ -577,10 +605,7 @@ def test_playwright_production_run_requires_and_rechecks_toolchain_manifest( ) -> None: root, commit = _repository(tmp_path) runner = _fake_playwright(tmp_path) - manifest = tmp_path / "playwright-toolchain.json" - manifest.write_text( - '{"version":1,"files":["fake_playwright.py"]}', encoding="utf-8" - ) + manifest = _toolchain_manifest(tmp_path / "protected-toolchain", Path(sys.executable), runner) original_run = subprocess.run def mutate_after_playwright(*args, **kwargs): @@ -614,6 +639,29 @@ def mutate_after_playwright(*args, **kwargs): assert "toolchain" in executions[0].detail.lower() +def test_playwright_rechecks_one_identity_after_the_complete_protocol( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, commit = _repository(tmp_path) + runner = _fake_playwright(tmp_path) + original = __import__("pdd.sync_core.runner", fromlist=["_run_playwright"])._run_playwright + calls = 0 + + def mutate_after_final_run(*args, **kwargs): + nonlocal calls + result = original(*args, **kwargs) + calls += 1 + if calls == 3: + runner.write_text(runner.read_text(encoding="utf-8") + "# post-run drift\n") + return result + + monkeypatch.setattr("pdd.sync_core.runner._run_playwright", mutate_after_final_run) + _envelope, executions = _run(root, commit, commit, runner) + assert calls == 3 + assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR + assert "toolchain" in executions[0].detail.lower() + + @pytest.mark.parametrize("launcher_kind", ["missing", "directory", "non_executable"]) def test_playwright_launch_failures_are_normalized( tmp_path: Path, launcher_kind: str @@ -1108,7 +1156,6 @@ def test_playwright_rejects_unbound_execution_controls( [ 'export default { "grep": /widget/ };\n', "export default { 'retries': 1 };\n", - 'export default { "globalSetup": "./setup.ts" };\n', 'export default { "webServer": { command: "npm run dev" } };\n', ], ) From f1053178550703dad06cc832d0e1ec79202b5a8b Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 09:31:12 -0700 Subject: [PATCH 032/233] fix(sync): enforce Playwright AST and immutable toolchain closure --- pdd/sync_core/runner.py | 565 +++++++++++++++++++++------------------- pyproject.toml | 1 + 2 files changed, 304 insertions(+), 262 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index fd80eda3e9..5ffca4199f 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -28,6 +28,8 @@ from pathlib import Path, PurePosixPath import pytest +from tree_sitter import Node +from tree_sitter_language_pack import get_parser from .trust import ( AttestationBinding, @@ -189,6 +191,7 @@ class RunnerConfig: adapter_identities: tuple[tuple[str, str], ...] = () playwright_command: tuple[str, ...] | None = None playwright_toolchain_manifest: Path | None = None + playwright_toolchain_identity: str | None = None @dataclass(frozen=True) @@ -1486,218 +1489,123 @@ def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePosixPath]: - # pylint: disable=too-many-branches - """Reject dynamic controls and return literal local config imports.""" - try: - text = source.decode("utf-8") - except UnicodeDecodeError as exc: - raise ValueError("Playwright configuration must be UTF-8") from exc - tokens = _javascript_tokens(text) - if any(token[0] == "escaped_identifier" for token in tokens): - raise ValueError("Playwright configuration has an escaped identifier") + """Validate a data-only AST config and return its bound local references.""" + tree = get_parser("typescript").parse(source) + if tree.root_node.has_error: + raise ValueError("Playwright configuration is not valid JavaScript/TypeScript") + statements = tree.root_node.named_children + references: set[PurePosixPath] = set() + config_node: Node | None = None + for statement in statements: + if statement.type == "import_statement": + if len(statement.named_children) != 1: + raise ValueError("Playwright config imports must be side-effect-only") + imported = _javascript_string(source, statement.named_children[0]) + references.add(_playwright_local_reference(path, imported, "config import")) + elif statement.type == "export_statement": + config_node = statement.child_by_field_name("value") + if config_node is None: + raise ValueError("Playwright config must export one object") + elif statement.type == "expression_statement" and path.suffix == ".cjs": + expression = statement.named_children[0] if statement.named_children else None + if expression is None or expression.type != "assignment_expression": + raise ValueError("Playwright CommonJS config must assign module.exports") + left = expression.child_by_field_name("left") + if left is None or _node_text(source, left) != "module.exports": + raise ValueError("Playwright CommonJS config must assign module.exports") + config_node = expression.child_by_field_name("right") + else: + raise ValueError("Playwright configuration must be one declarative object") + if config_node is None or config_node.type != "object": + raise ValueError("Playwright configuration must be a declarative object literal") try: - references = _parse_playwright_config_tokens(path, tokens) + references.update(_validate_playwright_config_object(path, source, config_node)) except ValueError as exc: - raise ValueError("Playwright configuration must be a declarative object literal") from exc - if not tokens: - raise ValueError("Playwright configuration must be a declarative object literal") - # Playwright config is executable code. Only accept the deliberately small, - # directly inspectable object-literal subset; syntactic indirection makes a - # security decision based on token matching unsound. - if re.search(r"/\*|//|\.\.\.|\[[^\]]+\]\s*:", text): - raise ValueError("Playwright configuration must use direct declarative keys") - if re.search( - r"\b(?:storageState|projects|dependencies|snapshotPathTemplate|executablePath)\b", - text, - ): - raise ValueError("Playwright configuration references unsupported runtime resources") - if re.search(r"\b(process|require|import\s*\(|await|function|=>|\beval\b)\b", text): - raise ValueError("dynamic Playwright configuration is not supported") - sensitive_execution_keys = ( - "grep", - "grepInvert", - "shard", - "retries", - "workers", - "repeatEach", - ) - quoted_or_bare = ( - r"(?:\b(?:{keys})\b|['\"](?:{keys})['\"])\s*:" - ) - if re.search( - quoted_or_bare.format(keys="|".join(sensitive_execution_keys)), - text, - ): - raise ValueError("Playwright execution filters or retries are not allowed") - if re.search(r"\b(?:const|let|var)\s+(globalSetup|globalTeardown|reporter|webServer)\b", text): - raise ValueError("indirect Playwright executable controls are not bound by this adapter") - if re.search(quoted_or_bare.format(keys="webServer"), text): - raise ValueError("Playwright webServer is not bound by this adapter") - if re.search(r"['\"](?:globalSetup|globalTeardown|reporter)['\"]\s*:", text): - raise ValueError("quoted Playwright executable controls are not bound by this adapter") - if re.search(r"\b(?:globalSetup|globalTeardown|reporter)\s*:\s*(?!['\"])", text): - raise ValueError("indirect Playwright executable controls are not bound by this adapter") - # Parse direct relative imports here; the closure resolver checks each blob. - references = set(references) - for key in ("globalSetup", "globalTeardown", "reporter"): - for value in re.findall(rf"\b{key}\s*:\s*['\"]([^'\"]+)['\"]", text): - local = _jest_local_path(value) - if local is None: - raise ValueError(f"Playwright {key} is not a static local path") - references.add(local) + raise ValueError(f"Playwright configuration is unsupported: {exc}") from exc return references -def _javascript_tokens(text: str) -> list[tuple[str, str]]: - """Lex JavaScript without retaining trivia, decoding string escapes.""" - tokens: list[tuple[str, str]] = [] - index = 0 - punctuation = set("{}[]():,;.*+-/=>&|!?%") - while index < len(text): - char = text[index] - if char.isspace(): - index += 1 - continue - if text.startswith("//", index): - index = text.find("\n", index) - index = len(text) if index < 0 else index + 1 - continue - if text.startswith("/*", index): - end = text.find("*/", index + 2) - if end < 0: - raise ValueError("unterminated JavaScript comment") - index = end + 2 - continue - if char in "'\"": - quote, start = char, index - index += 1 - value = "" - while index < len(text) and text[index] != quote: - if text[index] == "\\": - index += 1 - if index >= len(text): - raise ValueError("unterminated JavaScript string") - if text[index] == "u" and index + 4 < len(text): - value += chr(int(text[index + 1:index + 5], 16)) - index += 5 - continue - value += {"n": "\n", "r": "\r", "t": "\t"}.get(text[index], text[index]) - index += 1 - continue - value += text[index] - index += 1 - if index >= len(text): - raise ValueError(f"unterminated JavaScript string at {start}") - tokens.append(("string", value)) - index += 1 - continue - if char.isalpha() or char in "_$" or char == "\\": - escaped = False - value = "" - while index < len(text): - if text[index] == "\\" and text.startswith("\\u", index): - value += chr(int(text[index + 2:index + 6], 16)) - index += 6 - escaped = True - elif text[index].isalnum() or text[index] in "_$": - value += text[index] - index += 1 - else: - break - tokens.append(("escaped_identifier" if escaped else "identifier", value)) - continue - if char.isdigit(): - start = index - while index < len(text) and (text[index].isalnum() or text[index] in ".x_"): - index += 1 - tokens.append(("number", text[start:index])) - continue - if char == "`": - raise ValueError("template literals are not supported") - if char in punctuation: - pair = text[index:index + 2] - if pair in {"=>", "?.", "**"}: - tokens.append(("punct", pair)) - index += 2 - else: - tokens.append(("punct", char)) - index += 1 - continue - raise ValueError(f"unsupported JavaScript token {char!r}") - return tokens +def _node_text(source: bytes, node: Node) -> str: + """Return the exact UTF-8 text covered by an AST node.""" + return source[node.start_byte:node.end_byte].decode("utf-8") + + +def _javascript_string(source: bytes, node: Node) -> str: + """Decode one JavaScript string literal after AST validation.""" + if node.type != "string": + raise ValueError("a static JavaScript string is required") + try: + value = ast.literal_eval(_node_text(source, node)) + except (SyntaxError, ValueError) as exc: + raise ValueError("invalid JavaScript string literal") from exc + if not isinstance(value, str): + raise ValueError("a static JavaScript string is required") + return value + + +def _playwright_local_reference(path: PurePosixPath, value: str, label: str) -> PurePosixPath: + local = _jest_local_path(value) + if local is None: + raise ValueError(f"Playwright {label} is not a static local path") + normalized = _normalize_repo_relative_path(path.parent / local) + if normalized is None: + raise ValueError(f"Playwright {label} escapes repository") + return normalized -def _parse_playwright_config_tokens( - path: PurePosixPath, tokens: list[tuple[str, str]] +def _validate_playwright_config_object( + path: PurePosixPath, source: bytes, config: Node ) -> set[PurePosixPath]: - """Parse the enumerated data-only Playwright configuration grammar.""" - position = 0 + """Apply the explicit root-property allowlist to a parsed config object.""" + allowed_data = { + "timeout", "expect", "fullyParallel", "forbidOnly", "outputDir", + "testDir", "testMatch", "testIgnore", "preserveOutput", "quiet", + "updateSnapshots", "updateSourceMethod", "captureGitInfo", "metadata", + } + executable = {"globalSetup", "globalTeardown", "reporter"} + forbidden = { + "grep", "grepInvert", "shard", "retries", "workers", "repeatEach", + "webServer", "storageState", "projects", "dependencies", + "snapshotPathTemplate", "executablePath", "use", + } references: set[PurePosixPath] = set() + for pair in config.named_children: + if pair.type != "pair": + raise ValueError("Playwright config methods and spreads are unsupported") + key_node = pair.child_by_field_name("key") + value_node = pair.child_by_field_name("value") + if key_node is None or value_node is None or key_node.type == "computed_property_name": + raise ValueError("Playwright config keys must be static") + key = (_javascript_string(source, key_node) if key_node.type == "string" + else _node_text(source, key_node)) + if key in forbidden: + raise ValueError(f"Playwright config key {key} is unsupported") + if key in executable: + value = _javascript_string(source, value_node) + references.add(_playwright_local_reference(path, value, key)) + elif key in allowed_data: + _validate_playwright_data_value(source, value_node) + else: + raise ValueError(f"Playwright config key {key} is unsupported") + return references - def accept(value: str) -> bool: - nonlocal position - if position < len(tokens) and tokens[position][1] == value: - position += 1 - return True - return False - def take(kind: str) -> str: - nonlocal position - if position >= len(tokens) or tokens[position][0] != kind: - raise ValueError("unexpected config token") - value = tokens[position][1] - position += 1 - return value - - def value() -> object: - nonlocal position - if position >= len(tokens): - raise ValueError("missing config value") - kind, item = tokens[position] - if kind in {"string", "number"}: - position += 1 - return item - if kind == "identifier" and item in {"true", "false", "null"}: - position += 1 - return item - if accept("["): - result = [] - while not accept("]"): - result.append(value()) - if not accept(",") and tokens[position][1] != "]": - raise ValueError("invalid config array") - return result - if accept("{"): - result = {} - while not accept("}"): - if position >= len(tokens) or tokens[position][0] not in {"identifier", "string"}: - raise ValueError("config keys must be direct names") - key = tokens[position][1] - position += 1 - if not accept(":"): - raise ValueError("config methods and shorthand are unsupported") - result[key] = value() - if not accept(",") and tokens[position][1] != "}": - raise ValueError("invalid config object") - return result - raise ValueError("config values must be data literals") - - while accept("import"): - imported = take("string") - if not imported.startswith(("./", "../")): - raise ValueError("config import must be local") - normalized = _normalize_repo_relative_path(path.parent / PurePosixPath(imported)) - if normalized is None: - raise ValueError("config import escapes repository") - references.add(normalized) - accept(";") - if not (accept("export") and accept("default")): - raise ValueError("config must use export default") - parsed = value() - accept(";") - if position != len(tokens) or not isinstance(parsed, dict): - raise ValueError("config root must be one object") - return references +def _validate_playwright_data_value(source: bytes, node: Node) -> None: + """Accept only recursively inert literals inside admitted data properties.""" + if node.type in {"string", "number", "true", "false", "null", "undefined"}: + return + if node.type in {"array", "object"}: + for child in node.named_children: + value = child.child_by_field_name("value") if child.type == "pair" else child + if child.type == "pair": + key = child.child_by_field_name("key") + if key is None or key.type == "computed_property_name": + raise ValueError("Playwright config data keys must be static") + if value is None: + raise ValueError("Playwright config data must be literal") + _validate_playwright_data_value(source, value) + return + raise ValueError("Playwright config data must be literal") def _playwright_support_closure( @@ -1745,9 +1653,6 @@ def _playwright_support_closure( snapshot = read_git_blob(root, ref, snapshot_path) if snapshot is not None: paths.add(snapshot_path) - text = source.decode("utf-8") - if re.search(r"\b(?:test|describe)\.(?:only|skip|fixme|slow)\s*\(", text): - raise ValueError("Playwright focused, skipped, fixme, or slow tests are ambiguous") return tuple( (path, read_git_blob(root, ref, path)) for path in sorted(paths) if read_git_blob(root, ref, path) is not None @@ -1755,55 +1660,83 @@ def _playwright_support_closure( def _playwright_source_syntax(source: bytes) -> tuple[set[str], set[str], bool]: - """Analyze loader and runtime capability syntax after lexical decoding.""" - try: - tokens = _javascript_tokens(source.decode("utf-8")) - except (UnicodeDecodeError, ValueError) as exc: - raise ValueError("Playwright source is not valid supported JavaScript syntax") from exc + """Derive module/snapshot edges under an AST runtime-capability allowlist.""" + tree = get_parser("typescript").parse(source) + if tree.root_node.has_error: + raise ValueError("Playwright source is not valid JavaScript/TypeScript syntax") local: set[str] = set() bare: set[str] = set() snapshot = False - dangerous_runtime = { - "child_process", "fs", "worker_threads", "vm", "exec", "execSync", - "execFile", "execFileSync", "spawn", "spawnSync", "fork", - "readFile", "readFileSync", "writeFile", "writeFileSync", - "getBuiltinModule", - } - values = [value for _kind, value in tokens] - for index, (kind, value) in enumerate(tokens): - if value in {"Function", "eval", "createRequire"}: - raise ValueError("dynamic or aliased Playwright module loading is not supported") - if value in dangerous_runtime or (kind == "string" and value.startswith("node:")): - raise ValueError("Playwright runtime resource access is not bound by this adapter") - if value == "toHaveScreenshot": - snapshot = True - if value == "[" and index > 0: - if index + 1 < len(tokens) and tokens[index + 1] == ("string", "require"): + stack = [tree.root_node] + while stack: + node = stack.pop() + stack.extend(node.named_children) + if node.type in {"import_statement", "export_statement"}: + source_node = node.child_by_field_name("source") + if source_node is None and node.type == "import_statement": + source_node = next( + (child for child in node.named_children if child.type == "string"), None + ) + if source_node is not None: + imported = _javascript_string(source, source_node) + (local if imported.startswith(("./", "../")) else bare).add(imported) + if node.type == "subscript_expression": + label = "module loading" if "require" in _node_text(source, node) else "runtime resource" + raise ValueError(f"Playwright {label} uses dynamic property access") + if node.type == "identifier" and _node_text(source, node) in { + "Reflect", "eval", "Function", "process", + }: + raise ValueError("Playwright runtime resource access violates the runtime capability allowlist") + if node.type == "identifier" and _node_text(source, node) in { + "exec", "execSync", "execFile", "execFileSync", "spawn", "spawnSync", + "fork", "readFile", "readFileSync", "writeFile", "writeFileSync", + }: + raise ValueError("Playwright runtime resource access violates the runtime capability allowlist") + if node.type == "identifier" and _node_text(source, node) == "require": + parent = node.parent + if parent is None or parent.type != "call_expression" or parent.child_by_field_name("function") != node: raise ValueError("dynamic or aliased Playwright module loading is not supported") - raise ValueError("reflective Playwright runtime resource access is not supported") - if value == "require": - direct = ( - index + 3 < len(tokens) - and values[index + 1] == "(" - and tokens[index + 2][0] == "string" - and values[index + 3] == ")" - and not (index > 0 and values[index - 1] in {".", "["}) - ) - if not direct: + if node.type == "call_expression": + function = node.child_by_field_name("function") + arguments = node.child_by_field_name("arguments") + if function is None: + continue + function_text = _node_text(source, function) + if function.type == "import" or function_text == "require": + values = arguments.named_children if arguments is not None else [] + if len(values) != 1 or values[0].type != "string": + raise ValueError("dynamic or aliased Playwright module loading is not supported") + imported = _javascript_string(source, values[0]) + (local if imported.startswith(("./", "../")) else bare).add(imported) + elif function.type == "identifier" and function_text in { + "require", "eval", "Function", "createRequire", + }: + raise ValueError("dynamic or aliased Playwright module loading is not supported") + if node.type == "member_expression": + prop = node.child_by_field_name("property") + if prop is None: + continue + name = _node_text(source, prop) + if name in {"toHaveScreenshot", "toMatchSnapshot"}: + snapshot = True + if name in {"only", "skip", "fixme", "slow"}: + obj = node.child_by_field_name("object") + if obj is not None and _node_text(source, obj) in {"test", "describe"}: + raise ValueError("Playwright focused, skipped, fixme, or slow tests are ambiguous") + if name in { + "createRequire", + }: raise ValueError("dynamic or aliased Playwright module loading is not supported") - imported = values[index + 2] - (local if imported.startswith(("./", "../")) else bare).add(imported) - if value == "import": - if index + 1 < len(tokens) and values[index + 1] == "(": - if not (index + 3 < len(tokens) and tokens[index + 2][0] == "string" and values[index + 3] == ")"): + if name in { + "require", "getBuiltinModule", "exec", "execSync", + "execFile", "execFileSync", "spawn", "spawnSync", "fork", + "readFile", "readFileSync", "writeFile", "writeFileSync", + }: + if name == "require": raise ValueError("dynamic or aliased Playwright module loading is not supported") - imported = values[index + 2] - else: - following = next((item for item in tokens[index + 1:] if item[0] == "string"), None) - if following is None: - continue - imported = following[1] - (local if imported.startswith(("./", "../")) else bare).add(imported) + raise ValueError("Playwright runtime resource access violates the runtime capability allowlist") + if node.type == "string" and _javascript_string(source, node).startswith("node:"): + raise ValueError("Playwright runtime resource access violates the runtime capability allowlist") return local, bare, snapshot @@ -2605,23 +2538,66 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: payload = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: raise ValueError("Playwright toolchain manifest is invalid") from exc - if not isinstance(payload, dict) or set(payload) != {"version", "files"}: - raise ValueError("Playwright toolchain manifest has unknown fields") - files = payload.get("files") - if payload.get("version") != 1 or not isinstance(files, list) or not files: - raise ValueError("Playwright toolchain manifest schema is invalid") - if not all(isinstance(item, str) and item for item in files): - raise ValueError("Playwright toolchain manifest files are invalid") + required_roles = { + "launcher", "entrypoint", "dependencies", "browser_runtime", "lockfile", + } + if not isinstance(payload, dict) or set(payload) != {"version", "roles"}: + raise ValueError("Playwright toolchain manifest must declare typed roles") + roles = payload.get("roles") + if payload.get("version") != 2 or not isinstance(roles, dict): + raise ValueError("Playwright toolchain manifest roles schema is invalid") + if set(roles) != required_roles or not all( + isinstance(item, str) and Path(item).is_absolute() for item in roles.values() + ): + raise ValueError("Playwright toolchain manifest roles are incomplete") digest = hashlib.sha256(manifest_path.read_bytes() + b"\0") - for item in files: + for role, item in sorted(roles.items()): declared = Path(item) - if declared.is_absolute() or ".." in declared.parts: - raise ValueError("Playwright toolchain manifest path escapes its root") - digest.update(_manifest_path_identity(manifest_path.parent / declared, set())) + if not declared.exists(): + raise ValueError(f"Playwright toolchain role {role} does not exist") + canonical = declared.resolve(strict=True) + if role in {"launcher", "entrypoint", "lockfile"} and not canonical.is_file(): + raise ValueError(f"Playwright toolchain role {role} must be a file") + if role in {"dependencies", "browser_runtime"} and not canonical.is_dir(): + raise ValueError(f"Playwright toolchain role {role} must be a directory") + digest.update(role.encode() + b"\0") + digest.update(_manifest_path_identity(declared, set())) digest.update(b"\0") return digest.hexdigest() +def _toolchain_manifest_roles(manifest_path: Path) -> dict[str, Path]: + """Return canonical roles after the manifest has passed identity validation.""" + _toolchain_manifest_identity(manifest_path) + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + return {role: Path(value).resolve(strict=True) for role, value in payload["roles"].items()} + + +def _playwright_toolchain_identity( + root: Path, command: tuple[str, ...], manifest: Path +) -> str: + """Validate external command coverage and return the complete identity.""" + candidate = root.resolve() + if _path_is_relative_to(manifest.resolve(), candidate): + raise ValueError("Playwright toolchain manifest must be external to candidate checkout") + roles = _toolchain_manifest_roles(manifest) + command_paths = [] + for part in command[:2]: + path = Path(part).expanduser() + if path.is_absolute() or "/" in part: + command_paths.append(path.resolve(strict=True)) + else: + resolved = shutil.which(part) + if resolved is None: + raise ValueError("Playwright command launcher is not resolvable") + command_paths.append(Path(resolved).resolve(strict=True)) + if not command_paths or command_paths[0] != roles["launcher"]: + raise ValueError("Playwright toolchain launcher role does not cover command") + if len(command_paths) < 2 or command_paths[1] != roles["entrypoint"]: + raise ValueError("Playwright toolchain entrypoint role does not cover command") + return _toolchain_manifest_identity(manifest) + + def _command_part_identity(root: Path, part: str) -> str: """Return literal argv text or a digest for an explicit path operand.""" path = Path(part).expanduser() @@ -2695,9 +2671,15 @@ def _validator_command_identity_digest(root: Path, config: RunnerConfig) -> str: if config.playwright_toolchain_manifest is None: payload["playwright_toolchain_manifest"] = "missing" else: + try: + identity = config.playwright_toolchain_identity or _toolchain_manifest_identity( + config.playwright_toolchain_manifest + ) + except ValueError: + identity = "invalid" payload["playwright_toolchain_manifest"] = { "path": str(config.playwright_toolchain_manifest.resolve()), - "sha256": _toolchain_manifest_identity(config.playwright_toolchain_manifest), + "sha256": identity, } return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() @@ -3742,9 +3724,12 @@ def _run_playwright( "playwright-untrusted", "Playwright toolchain manifest is required", ), () try: - toolchain_identity = _toolchain_manifest_identity( - config.playwright_toolchain_manifest + current_identity = _playwright_toolchain_identity( + tool_root, prefix, config.playwright_toolchain_manifest ) + toolchain_identity = config.playwright_toolchain_identity or current_identity + if current_identity != toolchain_identity: + raise ValueError("Playwright toolchain changed across protocol execution") except ValueError as exc: return RunnerExecution( "playwright", EvidenceOutcome.COLLECTION_ERROR, @@ -3787,7 +3772,9 @@ def _run_playwright( f"Playwright launch failed: {exc}", ), () try: - if _toolchain_manifest_identity(config.playwright_toolchain_manifest) != toolchain_identity: + if _playwright_toolchain_identity( + tool_root, prefix, config.playwright_toolchain_manifest + ) != toolchain_identity: return RunnerExecution( "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, "Playwright toolchain changed during execution", @@ -4655,6 +4642,29 @@ def run_obligation( obligation.validator_config_digest, command_error, ) + prefix = _playwright_command(config) + if prefix is not None and config.playwright_toolchain_manifest is not None: + try: + identity = _playwright_toolchain_identity( + root, prefix, config.playwright_toolchain_manifest + ) + except (OSError, ValueError) as exc: + return RunnerExecution( + obligation.obligation_id, + EvidenceOutcome.COLLECTION_ERROR, + obligation.validator_config_digest, + str(exc), + ) + if config.playwright_toolchain_identity is not None: + if identity != config.playwright_toolchain_identity: + return RunnerExecution( + obligation.obligation_id, + EvidenceOutcome.COLLECTION_ERROR, + obligation.validator_config_digest, + "Playwright toolchain changed across protocol execution", + ) + else: + config = replace(config, playwright_toolchain_identity=identity) with tempfile.TemporaryDirectory(prefix="pdd-runner-exact-head-") as directory: clone = Path(directory) / "repository" cloned = subprocess.run( @@ -4694,6 +4704,16 @@ def run_profile( """Execute every obligation and issue one complete signed attestation.""" adapter_identities, capture_errors = _capture_adapter_identities(root, config) config = replace(config, adapter_identities=adapter_identities) + prefix = _playwright_command(config) + if prefix is not None and config.playwright_toolchain_manifest is not None: + try: + identity = _playwright_toolchain_identity( + root, prefix, config.playwright_toolchain_manifest + ) + except (OSError, ValueError): + identity = None + if identity is not None: + config = replace(config, playwright_toolchain_identity=identity) executions = tuple( RunnerExecution( obligation.obligation_id, @@ -4725,6 +4745,27 @@ def run_profile( ) if obligation.validator_id in {"jest", "vitest"} else execution for obligation, execution in zip(profile.obligations, executions) ) + if ( + prefix is not None + and config.playwright_toolchain_manifest is not None + and config.playwright_toolchain_identity is not None + ): + try: + final_identity = _playwright_toolchain_identity( + root, prefix, config.playwright_toolchain_manifest + ) + except (OSError, ValueError): + final_identity = None + if final_identity != config.playwright_toolchain_identity: + executions = tuple( + RunnerExecution( + obligation.obligation_id, + EvidenceOutcome.COLLECTION_ERROR, + execution.command_digest, + "Playwright toolchain changed across protocol execution", + ) if obligation.validator_id == "playwright" else execution + for obligation, execution in zip(profile.obligations, executions) + ) runner_digest = runner_identity_digest(profile, root=root, ref=binding.head_sha, config=config) binding = AttestationBinding( profile.unit_id, diff --git a/pyproject.toml b/pyproject.toml index 8993e810dd..1df89589cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ dependencies = [ "tree-sitter==0.25.2", "tree-sitter-javascript==0.25.0", "tree-sitter-typescript==0.23.2", + "tree-sitter-language-pack==1.12.5", "filelock>=3.12"] requires-python = ">=3.12" From a2ca38c4fca58a675d02434ab6dd064962d20417 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 09:37:47 -0700 Subject: [PATCH 033/233] fix(sync): synchronize Playwright parser dependencies --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 6583c949fe..2380c1385a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,6 +46,7 @@ uvicorn[standard]>=0.32.0 websockets>=13.0 watchdog>=4.0.0 tiktoken>=0.7.0 +tree-sitter-language-pack==1.12.5 # Dev dependencies build From 29d7de09063c2df633050df4bfb843132ac9f32f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 10:00:14 -0700 Subject: [PATCH 034/233] test(sync): cover Playwright round eleven trust gaps --- tests/test_sync_core_runner_playwright.py | 166 +++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index e628712d71..78ed26800b 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -122,7 +122,7 @@ def _repository( def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Path: directory.mkdir(parents=True, exist_ok=True) - dependencies = directory / "dependencies" + dependencies = directory / "node_modules" browsers = directory / "browsers" dependencies.mkdir(exist_ok=True) browsers.mkdir(exist_ok=True) @@ -145,6 +145,170 @@ def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Pa return manifest +def test_playwright_binds_static_runtime_resources_and_rejects_reflection( + tmp_path: Path, +) -> None: + root, _commit = _repository(tmp_path) + resource = root / "tests/oracle.js" + resource.write_text("window.expected = true;\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "test('widget works', async ({ page }) => {\n" + " await page.addInitScript({ path: './oracle.js' });\n" + " await expect(page).toHaveTitle('Widget');\n" + "});\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "bind runtime oracle") + base = _git(root, "rev-parse", "HEAD") + paths = (PurePosixPath("tests/widget.spec.ts"),) + before = playwright_validator_config_digest(root, base, paths) + resource.write_text("window.expected = false;\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "change runtime oracle") + assert playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), paths + ) != before + + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "test('reflect', () => Object.getOwnPropertyDescriptor({}, 'x'));\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "attempt reflection") + with pytest.raises(ValueError, match="capability|reflect"): + playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), paths + ) + + +def test_playwright_support_snapshot_binds_owning_spec_directory(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/assertions.ts").write_text( + "export const check = (value: string) => expect(value).toMatchSnapshot();\n", + encoding="utf-8", + ) + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "import { check } from './assertions';\n" + "test('widget works', () => check('base'));\n", + encoding="utf-8", + ) + snapshot = root / "tests/widget.spec.ts-snapshots/oracle.txt" + snapshot.parent.mkdir() + snapshot.write_text("base", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "helper-owned snapshot assertion") + base = _git(root, "rev-parse", "HEAD") + paths = (PurePosixPath("tests/widget.spec.ts"),) + before = playwright_validator_config_digest(root, base, paths) + snapshot.write_text("candidate", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "change owning snapshot") + assert playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), paths + ) != before + + +@pytest.mark.parametrize( + "config", + [ + "export default { updateSnapshots: 'all' };\n", + "export default { updateSourceMethod: 'overwrite' };\n", + ], +) +def test_playwright_rejects_snapshot_update_configuration( + tmp_path: Path, config: str +) -> None: + root, commit = _repository(tmp_path, config=config) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "update" in executions[0].detail.lower() + + +def test_playwright_forces_snapshot_updates_off_and_detects_tree_writes( + tmp_path: Path, +) -> None: + root, commit = _repository(tmp_path) + fake = _fake_playwright(tmp_path) + fake.write_text( + fake.read_text(encoding="utf-8") + + "\nassert '--update-snapshots=none' in sys.argv\n" + + "\n(root / 'tests/widget.spec.ts').write_text('mutated')\n", + encoding="utf-8", + ) + _envelope, executions = _run(root, commit, commit, fake) + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "modified" in executions[0].detail.lower() + + +def test_playwright_manifest_roles_drive_runtime_environment(tmp_path: Path) -> None: + root, commit = _repository(tmp_path) + runner = _fake_playwright(tmp_path) + runner.write_text( + runner.read_text(encoding="utf-8") + + "\nassert os.environ['NODE_PATH'].endswith('node_modules')\n" + + "assert os.environ['PLAYWRIGHT_BROWSERS_PATH'].endswith('browsers')\n", + encoding="utf-8", + ) + _envelope, executions = _run(root, commit, commit, runner) + assert executions[0].outcome is EvidenceOutcome.PASS + + +@pytest.mark.parametrize( + ("suffix", "source"), + [ + ("js", "const view =
; test('widget works', () => expect(view).toBeTruthy());\n"), + ("jsx", "const view =
; test('widget works', () => expect(view).toBeTruthy());\n"), + ("ts", "const value: string = 'ok'; test('widget works', () => expect(value).toBeTruthy());\n"), + ("tsx", "const view: JSX.Element =
; test('widget works', () => expect(view).toBeTruthy());\n"), + ], +) +def test_playwright_selects_parser_for_js_jsx_ts_tsx( + tmp_path: Path, suffix: str, source: str +) -> None: + root, _commit = _repository( + tmp_path, + config=( + "import { defineConfig } from '@playwright/test';\n" + "export default defineConfig({ timeout: 1000 });\n" + ), + ) + old = root / "tests/widget.spec.ts" + spec = old.with_suffix(f".{suffix}") + old.rename(spec) + spec.write_text( + "import { expect, test } from '@playwright/test';\n" + source, + encoding="utf-8", + ) + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", "parser-specific source") + playwright_validator_config_digest( + root, _git(root, "rev-parse", "HEAD"), + (PurePosixPath(f"tests/widget.spec.{suffix}"),), + ) + + +def test_toolchain_identity_streams_role_files_without_read_bytes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runner = _fake_playwright(tmp_path) + manifest = _toolchain_manifest(tmp_path / "toolchain", Path(sys.executable), runner) + dependency = tmp_path / "toolchain/node_modules/large.bin" + dependency.write_bytes(b"x" * 1024 * 1024) + original = Path.read_bytes + + def guarded_read_bytes(path: Path) -> bytes: + if path == dependency: + raise AssertionError("toolchain role files must be streamed") + return original(path) + + monkeypatch.setattr(Path, "read_bytes", guarded_read_bytes) + assert _toolchain_manifest_identity(manifest) + + def _run( root: Path, base: str, From cb4df0582d1326d818a1ecfafbb1fbe12d3413e8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 10:07:34 -0700 Subject: [PATCH 035/233] fix(sync): close Playwright round eleven trust gaps --- pdd/sync_core/runner.py | 269 ++++++++++++++++++++++++++++++++-------- 1 file changed, 215 insertions(+), 54 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 5ffca4199f..687b56e618 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1490,18 +1490,32 @@ def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePosixPath]: """Validate a data-only AST config and return its bound local references.""" - tree = get_parser("typescript").parse(source) + tree = _javascript_parser(path).parse(source) if tree.root_node.has_error: raise ValueError("Playwright configuration is not valid JavaScript/TypeScript") statements = tree.root_node.named_children references: set[PurePosixPath] = set() config_node: Node | None = None + define_config = False for statement in statements: if statement.type == "import_statement": - if len(statement.named_children) != 1: + imported_node = next( + (child for child in statement.named_children if child.type == "string"), None + ) + if imported_node is None: + raise ValueError("Playwright config import is malformed") + imported = _javascript_string(source, imported_node) + if imported == "@playwright/test": + if not re.fullmatch( + r"\s*import\s*\{\s*defineConfig\s*\}\s*from\s*['\"]@playwright/test['\"]\s*;?\s*", + _node_text(source, statement), + ): + raise ValueError("only defineConfig may be imported from Playwright") + define_config = True + elif len(statement.named_children) == 1: + references.add(_playwright_local_reference(path, imported, "config import")) + else: raise ValueError("Playwright config imports must be side-effect-only") - imported = _javascript_string(source, statement.named_children[0]) - references.add(_playwright_local_reference(path, imported, "config import")) elif statement.type == "export_statement": config_node = statement.child_by_field_name("value") if config_node is None: @@ -1516,6 +1530,17 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos config_node = expression.child_by_field_name("right") else: raise ValueError("Playwright configuration must be one declarative object") + if config_node is not None and config_node.type == "call_expression": + function = config_node.child_by_field_name("function") + arguments = config_node.child_by_field_name("arguments") + values = arguments.named_children if arguments is not None else [] + if ( + not define_config or function is None + or _node_text(source, function) != "defineConfig" + or len(values) != 1 or values[0].type != "object" + ): + raise ValueError("Playwright configuration must be one declarative defineConfig wrapper") + config_node = values[0] if config_node is None or config_node.type != "object": raise ValueError("Playwright configuration must be a declarative object literal") try: @@ -1530,6 +1555,15 @@ def _node_text(source: bytes, node: Node) -> str: return source[node.start_byte:node.end_byte].decode("utf-8") +def _javascript_parser(path: PurePosixPath): + """Select the grammar matching JavaScript, JSX, TypeScript, or TSX.""" + if path.suffix == ".tsx": + return get_parser("tsx") + if path.suffix in {".ts", ".cts", ".mts"}: + return get_parser("typescript") + return get_parser("javascript") + + def _javascript_string(source: bytes, node: Node) -> str: """Decode one JavaScript string literal after AST validation.""" if node.type != "string": @@ -1560,13 +1594,14 @@ def _validate_playwright_config_object( allowed_data = { "timeout", "expect", "fullyParallel", "forbidOnly", "outputDir", "testDir", "testMatch", "testIgnore", "preserveOutput", "quiet", - "updateSnapshots", "updateSourceMethod", "captureGitInfo", "metadata", + "captureGitInfo", "metadata", } executable = {"globalSetup", "globalTeardown", "reporter"} forbidden = { "grep", "grepInvert", "shard", "retries", "workers", "repeatEach", "webServer", "storageState", "projects", "dependencies", "snapshotPathTemplate", "executablePath", "use", + "updateSnapshots", "updateSourceMethod", } references: set[PurePosixPath] = set() for pair in config.named_children: @@ -1615,27 +1650,35 @@ def _playwright_support_closure( """Bind config, local support, and test imports without executing them.""" config_path, config_source = _playwright_config(root, ref) paths = {config_path} - pending = list(_playwright_static_config(config_path, config_source)) + list(test_paths) - visited: set[PurePosixPath] = set() + all_owners = frozenset(test_paths) + pending = [ + (item, all_owners) for item in _playwright_static_config(config_path, config_source) + ] + [(item, frozenset({item})) for item in test_paths] + visited: dict[PurePosixPath, frozenset[PurePosixPath]] = {} + snapshot_owners: set[PurePosixPath] = set() while pending: - path = pending.pop() - if path in visited: + path, owners = pending.pop() + known_owners = visited.get(path, frozenset()) + new_owners = owners - known_owners + if not new_owners: continue - visited.add(path) + visited[path] = known_owners | owners path, source = _read_javascript_support_blob(root, ref, path) if source is None: raise ValueError(f"Playwright local support path is missing: {path.as_posix()}") paths.add(path) if path.suffix in _JAVASCRIPT_SUFFIXES: - imports, bare_imports, has_snapshot = _playwright_source_syntax(source) - for imported in imports: + imports, bare_imports, has_snapshot, resources = _playwright_source_syntax( + path, source + ) + for imported in imports | resources: normalized = _normalize_repo_relative_path(path.parent / PurePosixPath(imported)) if normalized is None: raise ValueError("Playwright import escapes the repository") resolved, imported_source = _read_javascript_support_blob(root, ref, normalized) if imported_source is None: raise ValueError(f"Playwright local support path is missing: {resolved}") - pending.append(resolved) + pending.append((resolved, owners)) unbound_bare = bare_imports - {"@playwright/test"} if unbound_bare: raise ValueError( @@ -1643,30 +1686,62 @@ def _playwright_support_closure( + ", ".join(sorted(unbound_bare)) ) if has_snapshot: - snapshot_prefix = PurePosixPath(f"{path.as_posix()}-snapshots") - listed = subprocess.run( - ["git", "ls-tree", "-r", "--name-only", ref, "--", snapshot_prefix.as_posix()], - cwd=root, capture_output=True, text=True, check=True, - ) - for name in listed.stdout.splitlines(): - snapshot_path = PurePosixPath(name) - snapshot = read_git_blob(root, ref, snapshot_path) - if snapshot is not None: - paths.add(snapshot_path) + snapshot_owners.update(owners) + for owner in snapshot_owners: + snapshot_prefix = PurePosixPath(f"{owner.as_posix()}-snapshots") + listed = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", ref, "--", snapshot_prefix.as_posix()], + cwd=root, capture_output=True, text=True, check=True, + ) + for name in listed.stdout.splitlines(): + snapshot_path = PurePosixPath(name) + snapshot = read_git_blob(root, ref, snapshot_path) + if snapshot is not None: + paths.add(snapshot_path) return tuple( (path, read_git_blob(root, ref, path)) for path in sorted(paths) if read_git_blob(root, ref, path) is not None ) -def _playwright_source_syntax(source: bytes) -> tuple[set[str], set[str], bool]: +def _playwright_source_syntax( + path: PurePosixPath, source: bytes +) -> tuple[set[str], set[str], bool, set[str]]: """Derive module/snapshot edges under an AST runtime-capability allowlist.""" - tree = get_parser("typescript").parse(source) + tree = _javascript_parser(path).parse(source) if tree.root_node.has_error: raise ValueError("Playwright source is not valid JavaScript/TypeScript syntax") local: set[str] = set() bare: set[str] = set() + resources: set[str] = set() snapshot = False + allowed_calls = { + "test", "describe", "expect", "check", + "toBe", "toEqual", "toStrictEqual", "toBeTruthy", "toBeFalsy", + "toContain", "toMatch", "toHaveTitle", "toHaveURL", "toBeVisible", + "toBeHidden", "toHaveText", "toContainText", "toHaveValue", + "toHaveScreenshot", "toMatchSnapshot", "includes", "append", + "click", "fill", "goto", "locator", "getByRole", "getByText", + "waitFor", "press", "selectOption", "uncheck", + "addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR", + } + resource_calls = {"addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR"} + reflective_roots = {"Object", "Reflect", "Proxy", "Function"} + local_callables: set[str] = set() + discovery = [tree.root_node] + while discovery: + candidate = discovery.pop() + discovery.extend(candidate.named_children) + if candidate.type == "import_statement": + local_callables.update( + _node_text(source, child) + for child in candidate.named_children + if child.type == "identifier" + ) + elif candidate.type in {"function_declaration", "variable_declarator"}: + name_node = candidate.child_by_field_name("name") + if name_node is not None and name_node.type == "identifier": + local_callables.add(_node_text(source, name_node)) stack = [tree.root_node] while stack: node = stack.pop() @@ -1712,6 +1787,58 @@ def _playwright_source_syntax(source: bytes) -> tuple[set[str], set[str], bool]: "require", "eval", "Function", "createRequire", }: raise ValueError("dynamic or aliased Playwright module loading is not supported") + elif function.type == "member_expression": + prop = function.child_by_field_name("property") + obj = function.child_by_field_name("object") + name = _node_text(source, prop) if prop is not None else "" + root_name = _node_text(source, obj).split(".", 1)[0] if obj is not None else "" + if name in {"require", "createRequire"}: + raise ValueError( + "dynamic or aliased Playwright module loading is not supported" + ) + if name in { + "getBuiltinModule", "exec", "execSync", "execFile", "execFileSync", + "spawn", "spawnSync", "fork", "readFile", "readFileSync", + "writeFile", "writeFileSync", + } or root_name == "process": + raise ValueError( + "Playwright runtime resource access violates the runtime capability schema" + ) + if root_name in reflective_roots or name not in allowed_calls: + raise ValueError( + "Playwright call violates the positive runtime capability schema" + ) + if name in resource_calls: + values = arguments.named_children if arguments is not None else [] + resource_node = values[0] if values else None + if resource_node is not None and resource_node.type == "object": + for pair in resource_node.named_children: + key = pair.child_by_field_name("key") + if key is not None and _node_text(source, key).strip("'\"") == "path": + resource_node = pair.child_by_field_name("value") + break + if resource_node is None or resource_node.type != "string": + raise ValueError("Playwright runtime resource path must be static") + value = _javascript_string(source, resource_node) + if not value.startswith(("./", "../")): + raise ValueError("Playwright runtime resource must be a local path") + resources.add(value) + elif function.type == "identifier" and function_text in { + "exec", "execSync", "execFile", "execFileSync", "spawn", + "spawnSync", "fork", "readFile", "readFileSync", "writeFile", + "writeFileSync", + }: + raise ValueError( + "Playwright runtime resource access violates the runtime capability schema" + ) + elif ( + function.type == "identifier" + and function_text not in allowed_calls + and function_text not in local_callables + ): + raise ValueError( + "Playwright call violates the positive runtime capability schema" + ) if node.type == "member_expression": prop = node.child_by_field_name("property") if prop is None: @@ -1737,7 +1864,7 @@ def _playwright_source_syntax(source: bytes) -> tuple[set[str], set[str], bool]: raise ValueError("Playwright runtime resource access violates the runtime capability allowlist") if node.type == "string" and _javascript_string(source, node).startswith("node:"): raise ValueError("Playwright runtime resource access violates the runtime capability allowlist") - return local, bare, snapshot + return local, bare, snapshot, resources def playwright_validator_config_digest( @@ -2506,30 +2633,38 @@ def _directory_identity(path: Path) -> str: def _manifest_path_identity(path: Path, seen: set[Path]) -> bytes: - """Return identity bytes for a declared path and symlink target closure.""" + """Return a streaming Merkle digest for a path and symlink closure.""" + digest = hashlib.sha256() absolute = path.absolute() if absolute in seen: - return b"cycle\0" + str(absolute).encode() + digest.update(b"cycle\0" + str(absolute).encode()) + return digest.digest() seen.add(absolute) try: if path.is_symlink(): target_text = os.readlink(path) target = (path.parent / target_text).absolute() - return ( - b"link\0" + str(path).encode() + b"\0" + target_text.encode() - + b"\0" + _manifest_path_identity(target, seen) - ) + digest.update(b"link\0" + str(path).encode() + b"\0" + target_text.encode()) + digest.update(b"\0" + _manifest_path_identity(target, seen)) + return digest.digest() if path.is_file(): - return b"file\0" + str(path).encode() + b"\0" + path.read_bytes() + digest.update(b"file\0" + str(path).encode() + b"\0") + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.digest() if path.is_dir(): - content = bytearray(b"dir\0" + str(path).encode() + b"\0") + digest.update(b"dir\0" + str(path).encode() + b"\0") for child in sorted(path.iterdir(), key=lambda item: item.name): - content.extend(_manifest_path_identity(child, seen.copy())) - content.extend(b"\0") - return bytes(content) + digest.update(child.name.encode() + b"\0") + digest.update(_manifest_path_identity(child, seen.copy())) + digest.update(b"\0") + return digest.digest() except OSError as exc: - return b"error\0" + str(path).encode() + b"\0" + str(exc).encode() - return b"missing\0" + str(path).encode() + digest.update(b"error\0" + str(path).encode() + b"\0" + str(exc).encode()) + return digest.digest() + digest.update(b"missing\0" + str(path).encode()) + return digest.digest() def _toolchain_manifest_identity(manifest_path: Path) -> str: @@ -2568,7 +2703,6 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: def _toolchain_manifest_roles(manifest_path: Path) -> dict[str, Path]: """Return canonical roles after the manifest has passed identity validation.""" - _toolchain_manifest_identity(manifest_path) payload = json.loads(manifest_path.read_text(encoding="utf-8")) return {role: Path(value).resolve(strict=True) for role, value in payload["roles"].items()} @@ -2580,7 +2714,15 @@ def _playwright_toolchain_identity( candidate = root.resolve() if _path_is_relative_to(manifest.resolve(), candidate): raise ValueError("Playwright toolchain manifest must be external to candidate checkout") + manifest_identity = _toolchain_manifest_identity(manifest) roles = _toolchain_manifest_roles(manifest) + for role, role_path in roles.items(): + if _path_is_relative_to(role_path, candidate): + raise ValueError(f"Playwright toolchain role {role} must be external") + if roles["dependencies"].name != "node_modules": + raise ValueError("Playwright dependencies role must be the NODE_PATH node_modules root") + if roles["lockfile"].parent != roles["dependencies"].parent: + raise ValueError("Playwright lockfile must govern the declared dependency tree") command_paths = [] for part in command[:2]: path = Path(part).expanduser() @@ -2595,7 +2737,7 @@ def _playwright_toolchain_identity( raise ValueError("Playwright toolchain launcher role does not cover command") if len(command_paths) < 2 or command_paths[1] != roles["entrypoint"]: raise ValueError("Playwright toolchain entrypoint role does not cover command") - return _toolchain_manifest_identity(manifest) + return manifest_identity def _command_part_identity(root: Path, part: str) -> str: @@ -3614,7 +3756,7 @@ def _playwright_candidate_toolchain(root: Path) -> bool: def _playwright_environment( - home: Path, node_modules: Path | None + home: Path, dependencies: Path | None, browser_runtime: Path | None = None ) -> dict[str, str]: """Return an isolated credential-free environment for Playwright.""" environment = untrusted_child_environment( @@ -3622,11 +3764,9 @@ def _playwright_environment( extra={"NODE_ENV": "test"}, drop={"NODE_PATH", "PLAYWRIGHT_BROWSERS_PATH"}, ) - if node_modules is not None and node_modules.is_dir(): - environment["NODE_PATH"] = str(node_modules) - local_browsers = node_modules / "playwright-core" / ".local-browsers" - if local_browsers.is_dir(): - environment["PLAYWRIGHT_BROWSERS_PATH"] = "0" + if dependencies is not None and browser_runtime is not None: + environment["NODE_PATH"] = str(dependencies) + environment["PLAYWRIGHT_BROWSERS_PATH"] = str(browser_runtime) return environment @@ -3741,16 +3881,23 @@ def _run_playwright( "playwright", EvidenceOutcome.COLLECTION_ERROR, "playwright-untrusted", command_error, ), () - node_modules = _external_node_modules_root(root, prefix) + roles = _toolchain_manifest_roles(config.playwright_toolchain_manifest) try: config_path, _source = _playwright_config(root, "HEAD") except ValueError as exc: return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-config", str(exc)), () - command = [*prefix, "test", *(path.as_posix() for path in paths), f"--config={root / config_path}", "--reporter=json"] - if collection: - command.append("--list") - digest = hashlib.sha256(json.dumps(command, separators=(",", ":")).encode()).hexdigest() with tempfile.TemporaryDirectory(prefix="pdd-trusted-playwright-") as directory: + temporary = Path(directory) + command = [ + *prefix, "test", *(path.as_posix() for path in paths), + f"--config={root / config_path}", "--reporter=json", + "--update-snapshots=none", f"--output={temporary / 'results'}", + ] + if collection: + command.append("--list") + digest = hashlib.sha256( + json.dumps(command, separators=(",", ":")).encode() + ).hexdigest() try: result = subprocess.run( command, @@ -3760,8 +3907,9 @@ def _run_playwright( check=False, timeout=timeout_seconds, env=_playwright_environment( - Path(directory), - node_modules, + temporary, + roles["dependencies"], + roles["browser_runtime"], ), ) except subprocess.TimeoutExpired: @@ -3771,6 +3919,19 @@ def _run_playwright( "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, f"Playwright launch failed: {exc}", ), () + mutated = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=all"], + cwd=root, capture_output=True, text=True, check=False, + ) + if mutated.returncode != 0 or mutated.stdout.strip(): + changed = ", ".join( + line[3:] for line in mutated.stdout.splitlines() if len(line) > 3 + ) + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, digest, + "protected Playwright execution tree was modified" + + (f": {changed}" if changed else ""), + ), () try: if _playwright_toolchain_identity( tool_root, prefix, config.playwright_toolchain_manifest From bb068ae657b8b0c952c07d636aa4cc33615a5c3b Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 11:30:19 -0700 Subject: [PATCH 036/233] test(sync): cover Playwright round twelve trust gaps --- tests/test_sync_core_runner_playwright.py | 155 ++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 78ed26800b..e0d6048108 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -8,6 +8,7 @@ from pathlib import Path, PurePosixPath import pytest +import pdd.sync_core.runner as runner_module from pdd.sync_core import ( AttestationIssue, @@ -27,6 +28,7 @@ _playwright_command_error, _playwright_result, _toolchain_manifest_identity, + _playwright_toolchain_identity, playwright_validator_config_digest, ) @@ -212,6 +214,159 @@ def test_playwright_support_snapshot_binds_owning_spec_directory(tmp_path: Path) ) != before +def test_playwright_resources_resolve_from_runner_cwd(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "oracle.js").write_text("window.expected = 'root';\n", encoding="utf-8") + (root / "tests/oracle.js").write_text("window.expected = 'decoy';\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "test('widget works', async ({ page }) => " + "page.addInitScript({ path: './oracle.js' }));\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "cwd resource") + base = _git(root, "rev-parse", "HEAD") + paths = (PurePosixPath("tests/widget.spec.ts"),) + before = playwright_validator_config_digest(root, base, paths) + (root / "oracle.js").write_text("window.expected = 'changed';\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "change consumed resource") + assert playwright_validator_config_digest(root, "HEAD", paths) != before + + +@pytest.mark.parametrize( + "argument", + [ + "{ path: './bound.js', path: './candidate.js' }", + "{ path: './bound.js', ...override }", + "{ path: './bound.js', ['path']: './candidate.js' }", + ], +) +def test_playwright_resource_objects_require_exact_schema( + tmp_path: Path, argument: str +) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + f"test('widget works', async ({{ page }}) => page.addInitScript({argument}));\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "ambiguous resource object") + with pytest.raises(ValueError, match="schema|property|path"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize("target", ["oracle.js", "widget.spec.ts-snapshots/oracle.txt"]) +def test_playwright_rejects_symlinked_closure_members( + tmp_path: Path, target: str +) -> None: + root, _commit = _repository(tmp_path) + actual = root / "actual.txt" + actual.write_text("trusted", encoding="utf-8") + link = root / target + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(Path("actual.txt") if link.parent == root else Path("../actual.txt")) + if target == "oracle.js": + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "test('widget works', async ({ page }) => " + "page.addInitScript({ path: './oracle.js' }));\n", + encoding="utf-8", + ) + else: + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "test('widget works', () => expect('x').toMatchSnapshot());\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "symlink closure member") + with pytest.raises(ValueError, match="symlink"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +def test_playwright_excludes_declared_product_edges_from_support_digest( + tmp_path: Path, +) -> None: + root, _commit = _repository(tmp_path) + (root / "src").mkdir() + product = root / "src/widget.ts" + product.write_text("export const value = 'base';\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "import { value } from '../src/widget';\n" + "test('widget works', () => expect(value).toBeTruthy());\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "product import") + paths = (PurePosixPath("tests/widget.spec.ts"),) + products = (PurePosixPath("src/widget.ts"),) + before = playwright_validator_config_digest(root, "HEAD", paths, products) + product.write_text("export const value = 'candidate';\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "candidate product change") + assert playwright_validator_config_digest(root, "HEAD", paths, products) == before + + +def test_playwright_receiver_schema_accepts_representative_suite(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "test.use({ viewport: { width: 800, height: 600 } });\n" + "test.beforeEach(async ({ page }) => page.goto('/'));\n" + "test('widget works', async ({ page }) => test.step('action', async () => {\n" + " await page.waitForSelector('#ready');\n" + " await expect(page.locator('#ready')).toBeEnabled();\n" + "}));\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "representative suite") + assert playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +def test_playwright_toolchain_entrypoint_must_resolve_inside_declared_package( + tmp_path: Path, +) -> None: + root, _commit = _repository(tmp_path) + entrypoint = _fake_node_playwright(tmp_path) + manifest = _toolchain_manifest( + tmp_path / "unrelated-toolchain", Path(sys.executable), entrypoint + ) + with pytest.raises(ValueError, match="entrypoint|dependency|package"): + _playwright_toolchain_identity( + root, (sys.executable, str(entrypoint)), manifest + ) + + +def test_playwright_execution_uses_process_group_supervisor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, commit = _repository(tmp_path) + calls: list[list[str]] = [] + + def supervised(command, **_kwargs): + calls.append(command) + payload = { + "tests": [{"identity": IDENTITY, "status": "passed"}], + } + return subprocess.CompletedProcess(command, 0, json.dumps(payload), ""), False + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + assert executions[0].outcome is EvidenceOutcome.PASS + assert calls + + @pytest.mark.parametrize( "config", [ From 0f1e9092bd41e3c72f2f9a6787ade9ff36e900fa Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 11:42:38 -0700 Subject: [PATCH 037/233] fix(sync): close Playwright round twelve trust gaps --- pdd/sync_core/git_io.py | 16 +++ pdd/sync_core/runner.py | 144 +++++++++++++++++----- tests/test_sync_core_runner_playwright.py | 76 +++++++++--- 3 files changed, 189 insertions(+), 47 deletions(-) diff --git a/pdd/sync_core/git_io.py b/pdd/sync_core/git_io.py index 2bda0a9583..ced32a09d2 100644 --- a/pdd/sync_core/git_io.py +++ b/pdd/sync_core/git_io.py @@ -27,6 +27,8 @@ def read_git_blob(root: Path, ref: str, path: PurePosixPath) -> bytes | None: def read_git_regular_blob(root: Path, ref: str, path: PurePosixPath) -> bytes | None: """Read a regular blob and reject symlinks, gitlinks, and special modes.""" +def read_git_mode(root: Path, ref: str, path: PurePosixPath) -> str | None: + """Return the exact tree mode for one path without materializing it.""" result = subprocess.run( ["git", "ls-tree", ref, "--", path.as_posix()], cwd=root, @@ -44,6 +46,20 @@ def read_git_regular_blob(root: Path, ref: str, path: PurePosixPath) -> bytes | return read_git_blob(root, ref, path) +def read_git_mode(root: Path, ref: str, path: PurePosixPath) -> str | None: + """Return the exact tree mode for one path without materializing it.""" + result = subprocess.run( + ["git", "ls-tree", ref, "--", path.as_posix()], + cwd=root, + capture_output=True, + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + fields = result.stdout.split(None, 3) + return fields[0] if len(fields) == 4 else None + + def resolve_git_commit(root: Path, ref: str) -> str: """Resolve one exact commit or fail closed.""" result = subprocess.run( diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 687b56e618..f65741e43d 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -37,8 +37,8 @@ AttestationIssuer, AttestationRequest, ) -from .isolation import untrusted_child_environment -from .git_io import read_git_blob, read_git_regular_blob +from .isolation import SECRET_ENV_MARKERS, untrusted_child_environment +from .git_io import read_git_blob, read_git_mode, read_git_regular_blob from .types import ( EvidenceOutcome, ObligationEvidence, @@ -1644,12 +1644,14 @@ def _validate_playwright_data_value(source: bytes, node: Node) -> None: def _playwright_support_closure( - root: Path, ref: str, test_paths: tuple[PurePosixPath, ...] + root: Path, ref: str, test_paths: tuple[PurePosixPath, ...], + code_under_test_paths: tuple[PurePosixPath, ...] = (), ) -> tuple[tuple[PurePosixPath, bytes], ...]: # pylint: disable=too-many-locals """Bind config, local support, and test imports without executing them.""" config_path, config_source = _playwright_config(root, ref) paths = {config_path} + product_paths = frozenset(code_under_test_paths) all_owners = frozenset(test_paths) pending = [ (item, all_owners) for item in _playwright_static_config(config_path, config_source) @@ -1666,18 +1668,35 @@ def _playwright_support_closure( path, source = _read_javascript_support_blob(root, ref, path) if source is None: raise ValueError(f"Playwright local support path is missing: {path.as_posix()}") + if read_git_mode(root, ref, path) == "120000": + raise ValueError(f"Playwright closure member must not be a symlink: {path}") paths.add(path) if path.suffix in _JAVASCRIPT_SUFFIXES: imports, bare_imports, has_snapshot, resources = _playwright_source_syntax( path, source ) - for imported in imports | resources: - normalized = _normalize_repo_relative_path(path.parent / PurePosixPath(imported)) + for imported in imports: + normalized = _normalize_repo_relative_path( + path.parent / PurePosixPath(imported) + ) if normalized is None: raise ValueError("Playwright import escapes the repository") resolved, imported_source = _read_javascript_support_blob(root, ref, normalized) if imported_source is None: raise ValueError(f"Playwright local support path is missing: {resolved}") + if resolved not in product_paths: + pending.append((resolved, owners)) + for resource in resources: + normalized = _normalize_repo_relative_path(PurePosixPath(resource)) + if normalized is None: + raise ValueError("Playwright runtime resource escapes the repository") + resolved, resource_source = _read_javascript_support_blob( + root, ref, normalized + ) + if resource_source is None: + raise ValueError( + f"Playwright runtime resource path is missing: {resolved}" + ) pending.append((resolved, owners)) unbound_bare = bare_imports - {"@playwright/test"} if unbound_bare: @@ -1695,6 +1714,10 @@ def _playwright_support_closure( ) for name in listed.stdout.splitlines(): snapshot_path = PurePosixPath(name) + if read_git_mode(root, ref, snapshot_path) == "120000": + raise ValueError( + f"Playwright closure member must not be a symlink: {snapshot_path}" + ) snapshot = read_git_blob(root, ref, snapshot_path) if snapshot is not None: paths.add(snapshot_path) @@ -1724,8 +1747,19 @@ def _playwright_source_syntax( "click", "fill", "goto", "locator", "getByRole", "getByText", "waitFor", "press", "selectOption", "uncheck", "addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR", + "use", "beforeEach", "afterEach", "beforeAll", "afterAll", "step", + "waitForSelector", "toBeEnabled", "toBeDisabled", "toBeChecked", + "toHaveAttribute", "toHaveClass", "toHaveCount", "toHaveCSS", + "toHaveJSProperty", "toHaveId", "toHaveAccessibleName", } resource_calls = {"addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR"} + test_calls = {"use", "beforeEach", "afterEach", "beforeAll", "afterAll", "step"} + matcher_calls = {name for name in allowed_calls if name.startswith("to")} + browser_calls = { + "click", "fill", "goto", "locator", "getByRole", "getByText", + "waitFor", "waitForSelector", "press", "selectOption", "uncheck", + "addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR", + } reflective_roots = {"Object", "Reflect", "Proxy", "Function"} local_callables: set[str] = set() discovery = [tree.root_node] @@ -1791,6 +1825,7 @@ def _playwright_source_syntax( prop = function.child_by_field_name("property") obj = function.child_by_field_name("object") name = _node_text(source, prop) if prop is not None else "" + receiver = _node_text(source, obj) if obj is not None else "" root_name = _node_text(source, obj).split(".", 1)[0] if obj is not None else "" if name in {"require", "createRequire"}: raise ValueError( @@ -1808,15 +1843,43 @@ def _playwright_source_syntax( raise ValueError( "Playwright call violates the positive runtime capability schema" ) + if name in test_calls and root_name not in {"test", "describe"}: + raise ValueError( + "Playwright test capability is not valid for this receiver" + ) + if name in matcher_calls and "expect(" not in receiver: + raise ValueError( + "Playwright assertion capability is not valid for this receiver" + ) + if name in browser_calls and not any( + token in receiver + for token in ( + "page", "locator", "context", "frame", "request", + "browser", "dialog", "download", "response", + ) + ): + raise ValueError( + "Playwright browser capability is not valid for this receiver" + ) if name in resource_calls: values = arguments.named_children if arguments is not None else [] resource_node = values[0] if values else None if resource_node is not None and resource_node.type == "object": - for pair in resource_node.named_children: - key = pair.child_by_field_name("key") - if key is not None and _node_text(source, key).strip("'\"") == "path": - resource_node = pair.child_by_field_name("value") - break + pairs = resource_node.named_children + if len(pairs) != 1 or pairs[0].type != "pair": + raise ValueError( + "Playwright runtime resource object schema requires one path property" + ) + key = pairs[0].child_by_field_name("key") + if ( + key is None + or key.type == "computed_property_name" + or _node_text(source, key).strip("'\"") != "path" + ): + raise ValueError( + "Playwright runtime resource object schema requires one static path property" + ) + resource_node = pairs[0].child_by_field_name("value") if resource_node is None or resource_node.type != "string": raise ValueError("Playwright runtime resource path must be static") value = _javascript_string(source, resource_node) @@ -1868,11 +1931,14 @@ def _playwright_source_syntax( def playwright_validator_config_digest( - root: Path, ref: str, test_paths: tuple[PurePosixPath, ...] + root: Path, ref: str, test_paths: tuple[PurePosixPath, ...], + code_under_test_paths: tuple[PurePosixPath, ...] = (), ) -> str: """Hash Playwright config and every bound local executable dependency.""" digest = hashlib.sha256() - for path, content in _playwright_support_closure(root, ref, test_paths): + for path, content in _playwright_support_closure( + root, ref, test_paths, code_under_test_paths + ): digest.update(path.as_posix().encode() + b"\0" + content + b"\0") return digest.hexdigest() @@ -1972,8 +2038,13 @@ def _playwright_support_digest( if obligation.validator_id == "playwright" for path in obligation.artifact_paths ) + product = tuple( + path for obligation in profile.obligations + if obligation.validator_id == "playwright" + for path in obligation.code_under_test_paths + ) try: - closure = _playwright_support_closure(root, ref, tests) + closure = _playwright_support_closure(root, ref, tests, product) except ValueError: return "invalid-playwright-closure", () digest = hashlib.sha256() @@ -2723,6 +2794,13 @@ def _playwright_toolchain_identity( raise ValueError("Playwright dependencies role must be the NODE_PATH node_modules root") if roles["lockfile"].parent != roles["dependencies"].parent: raise ValueError("Playwright lockfile must govern the declared dependency tree") + package_root = roles["dependencies"] / "@playwright" / "test" + if not package_root.is_dir() or not _path_is_relative_to( + roles["entrypoint"], package_root + ): + raise ValueError( + "Playwright entrypoint must be inside the declared @playwright/test dependency package" + ) command_paths = [] for part in command[:2]: path = Path(part).expanduser() @@ -3898,26 +3976,28 @@ def _run_playwright( digest = hashlib.sha256( json.dumps(command, separators=(",", ":")).encode() ).hexdigest() - try: - result = subprocess.run( - command, - cwd=root, - capture_output=True, - text=True, - check=False, - timeout=timeout_seconds, - env=_playwright_environment( - temporary, - roles["dependencies"], - roles["browser_runtime"], - ), - ) - except subprocess.TimeoutExpired: - return RunnerExecution("playwright", EvidenceOutcome.TIMEOUT, digest, "Playwright execution timed out"), () - except OSError as exc: + result, surviving = run_supervised( + command, + cwd=root, + timeout=timeout_seconds, + env=_playwright_environment( + temporary, + roles["dependencies"], + roles["browser_runtime"], + ), + # Browsers and reporters routinely create checkout-local artifacts; + # the immutable Git status check below rejects every such write. + writable_roots=(temporary, root), + ) + if result.returncode == 124: return RunnerExecution( - "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, - f"Playwright launch failed: {exc}", + "playwright", EvidenceOutcome.TIMEOUT, digest, + "Playwright execution timed out and descendants were reaped", + ), () + if surviving: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, digest, + "Playwright left surviving descendants after execution", ), () mutated = subprocess.run( ["git", "status", "--porcelain", "--untracked-files=all"], diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index e0d6048108..888f40904b 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -128,6 +128,11 @@ def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Pa browsers = directory / "browsers" dependencies.mkdir(exist_ok=True) browsers.mkdir(exist_ok=True) + package = dependencies / "@playwright/test" + package.mkdir(parents=True, exist_ok=True) + installed_entrypoint = package / f"cli{entrypoint.suffix}" + if entrypoint.resolve() != installed_entrypoint.resolve(): + installed_entrypoint.write_bytes(entrypoint.read_bytes()) lockfile = directory / "package-lock.json" lockfile.write_text("{}\n", encoding="utf-8") manifest = directory / "playwright-toolchain.json" @@ -136,7 +141,7 @@ def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Pa "version": 2, "roles": { "launcher": str(launcher.resolve()), - "entrypoint": str(entrypoint.resolve()), + "entrypoint": str(installed_entrypoint.resolve()), "dependencies": str(dependencies.resolve()), "browser_runtime": str(browsers.resolve()), "lockfile": str(lockfile.resolve()), @@ -147,11 +152,15 @@ def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Pa return manifest +def _manifest_entrypoint(manifest: Path) -> Path: + return Path(json.loads(manifest.read_text(encoding="utf-8"))["roles"]["entrypoint"]) + + def test_playwright_binds_static_runtime_resources_and_rejects_reflection( tmp_path: Path, ) -> None: root, _commit = _repository(tmp_path) - resource = root / "tests/oracle.js" + resource = root / "oracle.js" resource.write_text("window.expected = true;\n", encoding="utf-8") (root / "tests/widget.spec.ts").write_text( "import { expect, test } from '@playwright/test';\n" @@ -260,7 +269,9 @@ def test_playwright_resource_objects_require_exact_schema( ) -@pytest.mark.parametrize("target", ["oracle.js", "widget.spec.ts-snapshots/oracle.txt"]) +@pytest.mark.parametrize( + "target", ["oracle.js", "tests/widget.spec.ts-snapshots/oracle.txt"] +) def test_playwright_rejects_symlinked_closure_members( tmp_path: Path, target: str ) -> None: @@ -269,7 +280,9 @@ def test_playwright_rejects_symlinked_closure_members( actual.write_text("trusted", encoding="utf-8") link = root / target link.parent.mkdir(parents=True, exist_ok=True) - link.symlink_to(Path("actual.txt") if link.parent == root else Path("../actual.txt")) + link.symlink_to( + Path("actual.txt") if link.parent == root else Path("../../actual.txt") + ) if target == "oracle.js": (root / "tests/widget.spec.ts").write_text( "import { test } from '@playwright/test';\n" @@ -342,6 +355,9 @@ def test_playwright_toolchain_entrypoint_must_resolve_inside_declared_package( manifest = _toolchain_manifest( tmp_path / "unrelated-toolchain", Path(sys.executable), entrypoint ) + payload = json.loads(manifest.read_text(encoding="utf-8")) + payload["roles"]["entrypoint"] = str(entrypoint.resolve()) + manifest.write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(ValueError, match="entrypoint|dependency|package"): _playwright_toolchain_identity( root, (sys.executable, str(entrypoint)), manifest @@ -470,6 +486,7 @@ def _run( head: str, fake: Path | tuple[str, ...], timeout: int = 2, + code_under_test_paths: tuple[PurePosixPath, ...] = (), ): command = fake if isinstance(fake, tuple) else (sys.executable, str(fake)) entrypoint = Path(command[1]) @@ -479,13 +496,18 @@ def _run( declared = "protected-playwright-tool" (manifest_root / declared).write_bytes(b"protected") manifest = _toolchain_manifest(manifest_root, Path(command[0]), manifest_root / declared) + if not entrypoint.is_relative_to(root): + command = (command[0], str(_manifest_entrypoint(manifest))) paths = (PurePosixPath("tests/widget.spec.ts"),) try: - config_digest = playwright_validator_config_digest(root, base, paths) + config_digest = playwright_validator_config_digest( + root, base, paths, code_under_test_paths + ) except ValueError: config_digest = "invalid-playwright-config" obligation = VerificationObligation( - "playwright", "test", "playwright", config_digest, ("REQ-1",), paths + "playwright", "test", "playwright", config_digest, ("REQ-1",), paths, + code_under_test_paths=code_under_test_paths, ) profile = VerificationProfile(UNIT, (obligation,), ("REQ-1",), "profile-v1") return run_profile( @@ -506,6 +528,27 @@ def _run( ) +@pytest.mark.parametrize( + ("candidate_mode", "expected"), + [("candidate-pass", EvidenceOutcome.PASS), ("fail", EvidenceOutcome.FAIL)], +) +def test_playwright_candidate_product_changes_execute_instead_of_quarantine( + tmp_path: Path, candidate_mode: str, expected: EvidenceOutcome +) -> None: + root, base = _repository(tmp_path) + (root / "source.ts").write_text(candidate_mode, encoding="utf-8") + _git(root, "add", "source.ts") + _git(root, "commit", "-q", "-m", "candidate product") + _envelope, executions = _run( + root, + base, + _git(root, "rev-parse", "HEAD"), + _fake_playwright(tmp_path), + code_under_test_paths=(PurePosixPath("source.ts"),), + ) + assert executions[0].outcome is expected + + def _run_default_playwright(root: Path, base: str, head: str, timeout: int = 2): paths = (PurePosixPath("tests/widget.spec.ts"),) obligation = VerificationObligation( @@ -925,17 +968,15 @@ def test_playwright_production_run_requires_and_rechecks_toolchain_manifest( root, commit = _repository(tmp_path) runner = _fake_playwright(tmp_path) manifest = _toolchain_manifest(tmp_path / "protected-toolchain", Path(sys.executable), runner) - original_run = subprocess.run + installed = _manifest_entrypoint(manifest) + original_supervised = runner_module.run_supervised def mutate_after_playwright(*args, **kwargs): - kwargs.setdefault("check", False) - result = original_run(*args, **kwargs) - command = args[0] if args else kwargs.get("args", ()) - if isinstance(command, (list, tuple)) and str(runner) in command: - runner.write_text(runner.read_text(encoding="utf-8") + "# changed\n") + result = original_supervised(*args, **kwargs) + installed.write_text(installed.read_text(encoding="utf-8") + "# changed\n") return result - monkeypatch.setattr("pdd.sync_core.runner.subprocess.run", mutate_after_playwright) + monkeypatch.setattr(runner_module, "run_supervised", mutate_after_playwright) paths = (PurePosixPath("tests/widget.spec.ts"),) obligation = VerificationObligation( "playwright", "test", "playwright", @@ -950,7 +991,7 @@ def mutate_after_playwright(*args, **kwargs): ), config=RunnerConfig( timeout_seconds=2, - playwright_command=(sys.executable, str(runner)), + playwright_command=(sys.executable, str(installed)), playwright_toolchain_manifest=manifest, ), ) @@ -971,7 +1012,12 @@ def mutate_after_final_run(*args, **kwargs): result = original(*args, **kwargs) calls += 1 if calls == 3: - runner.write_text(runner.read_text(encoding="utf-8") + "# post-run drift\n") + installed = next( + tmp_path.glob("**/node_modules/@playwright/test/cli*") + ) + installed.write_text( + installed.read_text(encoding="utf-8") + "# post-run drift\n" + ) return result monkeypatch.setattr("pdd.sync_core.runner._run_playwright", mutate_after_final_run) From 8642f0b0775d63adee6e1d3b671de1b06baba471 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 12:26:54 -0700 Subject: [PATCH 038/233] test(sync): cover Playwright round thirteen gaps --- tests/test_sync_core_runner_playwright.py | 85 +++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 888f40904b..92c6731ad1 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1555,3 +1555,88 @@ def test_playwright_rejects_identifier_executable_config_value( _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) assert executions[0].outcome is EvidenceOutcome.ERROR + + +def test_playwright_each_protocol_phase_uses_fresh_immutable_materialization( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, commit = _repository(tmp_path) + phase_roots: list[Path] = [] + + def supervised(command, *, cwd, writable_roots, **_kwargs): + phase_roots.append(cwd) + assert cwd not in writable_roots + assert cwd / ".git" not in writable_roots + payload = {"tests": [{"identity": IDENTITY, "status": "passed"}]} + return subprocess.CompletedProcess(command, 0, json.dumps(payload), ""), False + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert executions[0].outcome is EvidenceOutcome.PASS + assert len(phase_roots) == 3 + assert len({path.resolve() for path in phase_roots}) == 3 + + +@pytest.mark.parametrize("mutation", ["commit", "ignored"]) +def test_playwright_detects_clean_status_and_ignored_phase_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str +) -> None: + root, commit = _repository(tmp_path) + if mutation == "ignored": + (root / ".gitignore").write_text("candidate-cache/\n", encoding="utf-8") + _git(root, "add", ".gitignore") + _git(root, "commit", "-q", "-m", "ignore candidate cache") + commit = _git(root, "rev-parse", "HEAD") + calls = 0 + + def supervised(command, *, cwd, **_kwargs): + nonlocal calls + calls += 1 + if calls == 2: + if mutation == "commit": + (cwd / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "test('widget works', async () => {});\n", + encoding="utf-8", + ) + _git(cwd, "config", "user.email", "candidate@example.com") + _git(cwd, "config", "user.name", "Candidate") + _git(cwd, "add", "tests/widget.spec.ts") + _git(cwd, "commit", "-q", "-m", "replace protected test") + else: + (cwd / "candidate-cache").mkdir() + (cwd / "candidate-cache/state").write_text("candidate", encoding="utf-8") + payload = {"tests": [{"identity": IDENTITY, "status": "passed"}]} + return subprocess.CompletedProcess(command, 0, json.dumps(payload), ""), False + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "modified" in executions[0].detail.lower() + + +def test_playwright_receiver_capabilities_accept_documented_representative_chains( + tmp_path: Path, +) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "test.describe.configure({ mode: 'serial' });\n" + "test.beforeEach(async ({ page }) => {\n" + " await page.getByTestId('card').filter({ hasText: 'ready' }).first().hover();\n" + " await page.mainFrame().getByRole('button').click();\n" + "});\n" + "test('widget works', async ({ page }) => {\n" + " const card = page.locator('.card');\n" + " await expect(card).toHaveCount(1);\n" + "});\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "documented receiver chains") + + assert playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) From 7d937d92de1d7a77d7114115f3503192067f82f8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 12:43:13 -0700 Subject: [PATCH 039/233] fix(sync): close Playwright round thirteen gaps --- pdd/sync_core/runner.py | 186 ++++++++++++++++++++++++++++++++-------- 1 file changed, 150 insertions(+), 36 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index f65741e43d..081ad6529e 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1738,28 +1738,37 @@ def _playwright_source_syntax( bare: set[str] = set() resources: set[str] = set() snapshot = False - allowed_calls = { - "test", "describe", "expect", "check", + assertion_calls = { "toBe", "toEqual", "toStrictEqual", "toBeTruthy", "toBeFalsy", "toContain", "toMatch", "toHaveTitle", "toHaveURL", "toBeVisible", "toBeHidden", "toHaveText", "toContainText", "toHaveValue", - "toHaveScreenshot", "toMatchSnapshot", "includes", "append", - "click", "fill", "goto", "locator", "getByRole", "getByText", - "waitFor", "press", "selectOption", "uncheck", - "addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR", + "toHaveScreenshot", "toMatchSnapshot", "toBeEnabled", "toBeDisabled", + "toBeChecked", "toHaveAttribute", "toHaveClass", "toHaveCount", + "toHaveCSS", "toHaveJSProperty", "toHaveId", "toHaveAccessibleName", + } + test_calls = { "use", "beforeEach", "afterEach", "beforeAll", "afterAll", "step", - "waitForSelector", "toBeEnabled", "toBeDisabled", "toBeChecked", - "toHaveAttribute", "toHaveClass", "toHaveCount", "toHaveCSS", - "toHaveJSProperty", "toHaveId", "toHaveAccessibleName", + "configure", } - resource_calls = {"addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR"} - test_calls = {"use", "beforeEach", "afterEach", "beforeAll", "afterAll", "step"} - matcher_calls = {name for name in allowed_calls if name.startswith("to")} - browser_calls = { - "click", "fill", "goto", "locator", "getByRole", "getByText", - "waitFor", "waitForSelector", "press", "selectOption", "uncheck", - "addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR", + page_calls = { + "goto", "locator", "getByRole", "getByText", "getByTestId", + "waitForSelector", "addInitScript", "addScriptTag", "addStyleTag", + "routeFromHAR", "mainFrame", + } + locator_calls = { + "click", "fill", "filter", "first", "last", "nth", "hover", + "waitFor", "press", "selectOption", "uncheck", "locator", + "getByRole", "getByText", "getByTestId", + } + frame_calls = { + "goto", "locator", "getByRole", "getByText", "getByTestId", + "waitForSelector", } + allowed_calls = { + "test", "describe", "expect", "check", + "includes", "append", + } | assertion_calls | test_calls | page_calls | locator_calls | frame_calls + resource_calls = {"addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR"} reflective_roots = {"Object", "Reflect", "Proxy", "Function"} local_callables: set[str] = set() discovery = [tree.root_node] @@ -1847,16 +1856,28 @@ def _playwright_source_syntax( raise ValueError( "Playwright test capability is not valid for this receiver" ) - if name in matcher_calls and "expect(" not in receiver: + if name in assertion_calls and "expect(" not in receiver: raise ValueError( "Playwright assertion capability is not valid for this receiver" ) - if name in browser_calls and not any( - token in receiver - for token in ( - "page", "locator", "context", "frame", "request", - "browser", "dialog", "download", "response", - ) + receiver_methods = { + "locator": locator_calls, + "frame": frame_calls, + "page": page_calls, + } + receiver_kind = next( + (kind for kind, methods in receiver_methods.items() + if kind in receiver.lower() + or (kind == "locator" and any( + token in receiver for token in + ("getBy", ".filter(", ".first(", ".last(", ".nth(") + ))), + None, + ) + browser_capabilities = page_calls | locator_calls | frame_calls + if name in browser_capabilities and ( + receiver_kind is None + or name not in receiver_methods[receiver_kind] ): raise ValueError( "Playwright browser capability is not valid for this receiver" @@ -3924,10 +3945,49 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: return EvidenceOutcome.PASS, f"{len(identities)} protected Playwright tests passed", identities -def _run_playwright( +def _playwright_execution_tree_identity(root: Path) -> str: + """Bind every non-Git execution-tree entry, including ignored files.""" + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + relative = path.relative_to(root) + if relative.parts and relative.parts[0] == ".git": + continue + stat = path.lstat() + digest.update(relative.as_posix().encode() + b"\0") + digest.update(str(stat.st_mode).encode() + b"\0") + if path.is_symlink(): + digest.update(os.readlink(path).encode()) + elif path.is_file(): + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def _playwright_protected_worktree_identity( + root: Path, ref: str, paths: tuple[PurePosixPath, ...] +) -> str: + """Bind blob bytes and Git modes for the complete protected closure.""" + closure = _playwright_support_closure(root, ref, paths, ()) + config_path, config_source = _playwright_config(root, ref) + members = tuple(closure) + ((config_path, config_source),) + digest = hashlib.sha256() + for path, expected in sorted(members): + actual = root / path + if actual.is_symlink() or not actual.is_file(): + return "invalid" + digest.update(path.as_posix().encode() + b"\0") + digest.update(read_git_mode(root, ref, path).encode() + b"\0") + digest.update(expected + b"\0" + actual.read_bytes() + b"\0") + executable = bool(actual.stat().st_mode & 0o111) + if executable != (read_git_mode(root, ref, path) == "100755"): + return "invalid" + return digest.hexdigest() + + +def _run_playwright_in_tree( root: Path, paths: tuple[PurePosixPath, ...], timeout_seconds: int, config: RunnerConfig, expected: tuple[str, ...] | None = None, command_root: Path | None = None, collection: bool = False, + expected_commit: str | None = None, ) -> tuple[RunnerExecution, tuple[str, ...]]: """Execute exact paths through Playwright's JSON reporter without filters.""" tool_root = command_root or root @@ -3966,6 +4026,12 @@ def _run_playwright( return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-config", str(exc)), () with tempfile.TemporaryDirectory(prefix="pdd-trusted-playwright-") as directory: temporary = Path(directory) + commit = expected_commit or subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, + text=True, check=True, + ).stdout.strip() + tree_identity = _playwright_execution_tree_identity(root) + closure_identity = _playwright_protected_worktree_identity(root, commit, paths) command = [ *prefix, "test", *(path.as_posix() for path in paths), f"--config={root / config_path}", "--reporter=json", @@ -3985,9 +4051,7 @@ def _run_playwright( roles["dependencies"], roles["browser_runtime"], ), - # Browsers and reporters routinely create checkout-local artifacts; - # the immutable Git status check below rejects every such write. - writable_roots=(temporary, root), + writable_roots=(temporary,), ) if result.returncode == 124: return RunnerExecution( @@ -3999,18 +4063,25 @@ def _run_playwright( "playwright", EvidenceOutcome.ERROR, digest, "Playwright left surviving descendants after execution", ), () - mutated = subprocess.run( - ["git", "status", "--porcelain", "--untracked-files=all"], - cwd=root, capture_output=True, text=True, check=False, + denied_write = result.returncode != 0 and any( + marker in result.stderr + for marker in ("Operation not permitted", "Permission denied") ) - if mutated.returncode != 0 or mutated.stdout.strip(): - changed = ", ".join( - line[3:] for line in mutated.stdout.splitlines() if len(line) > 3 - ) + current_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, + text=True, check=False, + ) + modified = ( + current_commit.returncode != 0 + or current_commit.stdout.strip() != commit + or _playwright_protected_worktree_identity(root, commit, paths) + != closure_identity + or _playwright_execution_tree_identity(root) != tree_identity + ) + if modified or denied_write: return RunnerExecution( "playwright", EvidenceOutcome.ERROR, digest, - "protected Playwright execution tree was modified" - + (f": {changed}" if changed else ""), + "protected Playwright execution tree was modified", ), () try: if _playwright_toolchain_identity( @@ -4030,6 +4101,49 @@ def _run_playwright( return RunnerExecution("playwright", outcome, digest, detail), identities +def _run_playwright( + root: Path, paths: tuple[PurePosixPath, ...], timeout_seconds: int, + config: RunnerConfig, expected: tuple[str, ...] | None = None, + command_root: Path | None = None, collection: bool = False, +) -> tuple[RunnerExecution, tuple[str, ...]]: + """Run one protocol phase from a fresh exact-commit materialization.""" + source_root = root + tool_root = command_root or root + resolved = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=source_root, capture_output=True, + text=True, check=False, + ) + if resolved.returncode != 0: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, + hashlib.sha256(b"invalid-playwright-ref").hexdigest(), + "cannot resolve exact Playwright phase commit", + ), () + commit = resolved.stdout.strip() + with tempfile.TemporaryDirectory(prefix="pdd-playwright-phase-") as directory: + phase_root = Path(directory) / "repository" + cloned = subprocess.run( + ["git", "clone", "-q", "--no-local", "--no-checkout", + str(source_root), str(phase_root)], + capture_output=True, text=True, check=False, + ) + checked = cloned.returncode == 0 and subprocess.run( + ["git", "checkout", "-q", "--detach", commit], cwd=phase_root, + capture_output=True, text=True, check=False, + ).returncode == 0 + if not checked: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, + hashlib.sha256(commit.encode()).hexdigest(), + "cannot create fresh exact-commit Playwright phase tree", + ), () + return _run_playwright_in_tree( + phase_root, paths, timeout_seconds, config, expected, + command_root=tool_root, collection=collection, + expected_commit=commit, + ) + + def _run_test_node( root: Path, node_id: str, From 1a57111a47da00fa5217a86c80b9ad926ae273ca Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 14:24:54 -0700 Subject: [PATCH 040/233] test(sync): cover Playwright round fourteen gaps --- tests/test_sync_core_runner_playwright.py | 122 ++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 92c6731ad1..05ab49e988 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1640,3 +1640,125 @@ def test_playwright_receiver_capabilities_accept_documented_representative_chain assert playwright_validator_config_digest( root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) ) + + +def test_playwright_rejects_suite_level_retries(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "test.describe.configure({ retries: 2 });\n" + "test('widget works', async () => {});\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "suite retries") + + with pytest.raises(ValueError, match="retr"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +def test_playwright_reporter_preserves_failure_across_retry_attempts( + tmp_path: Path, +) -> None: + root, _commit = _repository(tmp_path) + payload = { + "suites": [{ + "title": "tests/widget.spec.ts", + "specs": [{ + "title": "widget works", + "file": str(root / "tests/widget.spec.ts"), + "tests": [{ + "projectName": "chromium", + "results": [{"status": "failed"}, {"status": "passed"}], + }], + }], + }], + } + + outcome, _detail, identities = _playwright_result( + root, json.dumps(payload), 0, None + ) + + assert outcome is EvidenceOutcome.FAIL + assert identities == (IDENTITY,) + + +def test_playwright_phase_identity_excludes_imported_declared_product( + tmp_path: Path, +) -> None: + root, _base = _repository(tmp_path) + (root / "source.ts").write_text( + "import React from 'react';\n" + "export const widget = React.createElement('div');\n", + encoding="utf-8", + ) + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "import { widget } from '../source';\n" + "test('widget works', async () => expect(widget).toBeTruthy());\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "import declared product") + head = _git(root, "rev-parse", "HEAD") + + _envelope, executions = _run( + root, + head, + head, + _fake_playwright(tmp_path), + code_under_test_paths=(PurePosixPath("source.ts"),), + ) + + assert executions[0].outcome is EvidenceOutcome.PASS + + +def test_playwright_rejects_symlink_config_before_subprocess_launch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, _commit = _repository(tmp_path) + (root / "playwright.config.ts").unlink() + target = root / "export default {};" + target.write_text("export default {};\n", encoding="utf-8") + (root / "playwright.config.ts").symlink_to(target.name) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "symlink config") + commit = _git(root, "rev-parse", "HEAD") + launches = 0 + + def supervised(*_args, **_kwargs): + nonlocal launches + launches += 1 + raise AssertionError("invalid closure must fail before Playwright launch") + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert executions[0].outcome is EvidenceOutcome.ERROR + assert launches == 0 + assert "symlink" in executions[0].detail.lower() + + +def test_playwright_tracks_page_locator_and_frame_receiver_aliases( + tmp_path: Path, +) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "test('widget works', async ({ page: browserPage }) => {\n" + " const card = browserPage.locator('.card');\n" + " const { first: firstCard } = { first: card.first() };\n" + " const frame = browserPage.mainFrame();\n" + " await firstCard.click();\n" + " await frame.waitForSelector('#ready');\n" + "});\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "receiver aliases") + + assert playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) From 02c84f389c28a12587407005fac97a615f61a932 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Sat, 11 Jul 2026 14:30:16 -0700 Subject: [PATCH 041/233] fix(sync): close Playwright round fourteen gaps --- pdd/sync_core/runner.py | 179 ++++++++++++++++++++-- tests/test_sync_core_runner_playwright.py | 2 +- 2 files changed, 163 insertions(+), 18 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 081ad6529e..bffb2a55b6 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1650,6 +1650,9 @@ def _playwright_support_closure( # pylint: disable=too-many-locals """Bind config, local support, and test imports without executing them.""" config_path, config_source = _playwright_config(root, ref) + config_mode = read_git_mode(root, ref, config_path) + if config_mode not in {"100644", "100755"}: + raise ValueError("Playwright config must be a regular non-symlink file") paths = {config_path} product_paths = frozenset(code_under_test_paths) all_owners = frozenset(test_paths) @@ -1668,8 +1671,11 @@ def _playwright_support_closure( path, source = _read_javascript_support_blob(root, ref, path) if source is None: raise ValueError(f"Playwright local support path is missing: {path.as_posix()}") - if read_git_mode(root, ref, path) == "120000": - raise ValueError(f"Playwright closure member must not be a symlink: {path}") + mode = read_git_mode(root, ref, path) + if mode not in {"100644", "100755"}: + raise ValueError( + f"Playwright closure member must be a regular non-symlink file: {path}" + ) paths.add(path) if path.suffix in _JAVASCRIPT_SUFFIXES: imports, bare_imports, has_snapshot, resources = _playwright_source_syntax( @@ -1771,7 +1777,80 @@ def _playwright_source_syntax( resource_calls = {"addInitScript", "addScriptTag", "addStyleTag", "routeFromHAR"} reflective_roots = {"Object", "Reflect", "Proxy", "Function"} local_callables: set[str] = set() + receiver_bindings: dict[str, str] = {"page": "page", "frame": "frame"} + + def inferred_receiver(node: Node | None) -> str | None: + """Infer a browser receiver kind from a structured expression.""" + if node is None: + return None + if node.type == "identifier": + return receiver_bindings.get(_node_text(source, node)) + if node.type in {"parenthesized_expression", "await_expression"}: + return next( + (kind for child in node.named_children + if (kind := inferred_receiver(child)) is not None), + None, + ) + if node.type != "call_expression": + return None + function = node.child_by_field_name("function") + if function is None or function.type != "member_expression": + return None + obj = function.child_by_field_name("object") + prop = function.child_by_field_name("property") + method = _node_text(source, prop) if prop is not None else "" + owner = inferred_receiver(obj) + if method == "mainFrame" and owner == "page": + return "frame" + if method in {"locator", "getByRole", "getByText", "getByTestId"} and owner in { + "page", "frame", "locator", + }: + return "locator" + if method in {"filter", "first", "last", "nth"} and owner == "locator": + return "locator" + return None + + def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: + """Propagate receiver kinds through identifiers and object patterns.""" + if pattern is None: + return False + if pattern.type == "identifier": + kind = inferred_receiver(value) + name = _node_text(source, pattern) + if kind is not None and receiver_bindings.get(name) != kind: + receiver_bindings[name] = kind + return True + return False + changed = False + if pattern.type in {"object_pattern", "object"}: + values = {} + if value is not None and value.type == "object": + for pair in value.named_children: + key = pair.child_by_field_name("key") + item = pair.child_by_field_name("value") + if key is not None: + values[_node_text(source, key)] = item + for pair in pattern.named_children: + key = pair.child_by_field_name("key") + target = pair.child_by_field_name("value") + if key is None: + key = pair + target = pair + key_text = _node_text(source, key) + seed = values.get(key_text) + if seed is None and key_text in {"page", "frame"}: + receiver_bindings.setdefault(key_text, key_text) + if target is not None and target.type == "identifier": + alias = _node_text(source, target) + if receiver_bindings.get(alias) != key_text: + receiver_bindings[alias] = key_text + changed = True + else: + changed |= bind_pattern(target, seed) + return changed + discovery = [tree.root_node] + declarations: list[Node] = [] while discovery: candidate = discovery.pop() discovery.extend(candidate.named_children) @@ -1785,6 +1864,19 @@ def _playwright_source_syntax( name_node = candidate.child_by_field_name("name") if name_node is not None and name_node.type == "identifier": local_callables.add(_node_text(source, name_node)) + if candidate.type == "variable_declarator": + declarations.append(candidate) + if candidate.type == "object_pattern": + bind_pattern(candidate) + for _unused in range(len(declarations) + 1): + changed = False + for declaration in declarations: + changed |= bind_pattern( + declaration.child_by_field_name("name"), + declaration.child_by_field_name("value"), + ) + if not changed: + break stack = [tree.root_node] while stack: node = stack.pop() @@ -1856,6 +1948,20 @@ def _playwright_source_syntax( raise ValueError( "Playwright test capability is not valid for this receiver" ) + if name == "configure": + values = arguments.named_children if arguments is not None else [] + if len(values) != 1 or values[0].type != "object": + raise ValueError("Playwright suite configuration must be literal") + for pair in values[0].named_children: + key = pair.child_by_field_name("key") + value = pair.child_by_field_name("value") + label = _node_text(source, key).strip("'\"") if key else "" + if label == "retries": + raise ValueError("Playwright suite retries are unsupported") + if label != "mode" or value is None or value.type != "string": + raise ValueError( + "Playwright suite configuration only supports literal mode" + ) if name in assertion_calls and "expect(" not in receiver: raise ValueError( "Playwright assertion capability is not valid for this receiver" @@ -1865,7 +1971,7 @@ def _playwright_source_syntax( "frame": frame_calls, "page": page_calls, } - receiver_kind = next( + receiver_kind = inferred_receiver(obj) or next( (kind for kind, methods in receiver_methods.items() if kind in receiver.lower() or (kind == "locator" and any( @@ -3904,12 +4010,25 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: if not isinstance(item, dict): raise ValueError("malformed Playwright test") results = item.get("results") or [{}] - if not isinstance(results, list) or not results or not isinstance(results[-1], dict): + if ( + not isinstance(results, list) + or not results + or not all(isinstance(result, dict) for result in results) + ): raise ValueError("malformed Playwright results") - result = results[-1] + attempts = { + result.get("status", "passed" if collection else "skipped") + for result in results + } + status = next( + (candidate for candidate in ( + "timedOut", "failed", "interrupted", "skipped", "passed" + ) if candidate in attempts), + "skipped", + ) tests.append({ "identity": f"{item['projectName']}::{filename}::{title_path}", - "status": result.get("status", "passed" if collection else "skipped"), + "status": status, }) for child in suite.get("suites", []): visit(child, next_parents) @@ -3963,23 +4082,28 @@ def _playwright_execution_tree_identity(root: Path) -> str: def _playwright_protected_worktree_identity( - root: Path, ref: str, paths: tuple[PurePosixPath, ...] + root: Path, ref: str, paths: tuple[PurePosixPath, ...], + code_under_test_paths: tuple[PurePosixPath, ...] = (), ) -> str: """Bind blob bytes and Git modes for the complete protected closure.""" - closure = _playwright_support_closure(root, ref, paths, ()) + closure = _playwright_support_closure( + root, ref, paths, code_under_test_paths + ) config_path, config_source = _playwright_config(root, ref) members = tuple(closure) + ((config_path, config_source),) digest = hashlib.sha256() for path, expected in sorted(members): actual = root / path if actual.is_symlink() or not actual.is_file(): - return "invalid" + raise ValueError( + f"Playwright closure member must be a regular non-symlink file: {path}" + ) digest.update(path.as_posix().encode() + b"\0") digest.update(read_git_mode(root, ref, path).encode() + b"\0") digest.update(expected + b"\0" + actual.read_bytes() + b"\0") executable = bool(actual.stat().st_mode & 0o111) if executable != (read_git_mode(root, ref, path) == "100755"): - return "invalid" + raise ValueError(f"Playwright closure member mode changed: {path}") return digest.hexdigest() @@ -3988,6 +4112,7 @@ def _run_playwright_in_tree( config: RunnerConfig, expected: tuple[str, ...] | None = None, command_root: Path | None = None, collection: bool = False, expected_commit: str | None = None, + code_under_test_paths: tuple[PurePosixPath, ...] = (), ) -> tuple[RunnerExecution, tuple[str, ...]]: """Execute exact paths through Playwright's JSON reporter without filters.""" tool_root = command_root or root @@ -4031,7 +4156,14 @@ def _run_playwright_in_tree( text=True, check=True, ).stdout.strip() tree_identity = _playwright_execution_tree_identity(root) - closure_identity = _playwright_protected_worktree_identity(root, commit, paths) + try: + closure_identity = _playwright_protected_worktree_identity( + root, commit, paths, code_under_test_paths + ) + except ValueError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) + ), () command = [ *prefix, "test", *(path.as_posix() for path in paths), f"--config={root / config_path}", "--reporter=json", @@ -4071,11 +4203,16 @@ def _run_playwright_in_tree( ["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, check=False, ) + try: + current_closure_identity = _playwright_protected_worktree_identity( + root, commit, paths, code_under_test_paths + ) + except ValueError: + current_closure_identity = "invalid" modified = ( current_commit.returncode != 0 or current_commit.stdout.strip() != commit - or _playwright_protected_worktree_identity(root, commit, paths) - != closure_identity + or current_closure_identity != closure_identity or _playwright_execution_tree_identity(root) != tree_identity ) if modified or denied_write: @@ -4105,6 +4242,7 @@ def _run_playwright( root: Path, paths: tuple[PurePosixPath, ...], timeout_seconds: int, config: RunnerConfig, expected: tuple[str, ...] | None = None, command_root: Path | None = None, collection: bool = False, + code_under_test_paths: tuple[PurePosixPath, ...] = (), ) -> tuple[RunnerExecution, tuple[str, ...]]: """Run one protocol phase from a fresh exact-commit materialization.""" source_root = root @@ -4141,6 +4279,7 @@ def _run_playwright( phase_root, paths, timeout_seconds, config, expected, command_root=tool_root, collection=collection, expected_commit=commit, + code_under_test_paths=code_under_test_paths, ) @@ -4715,7 +4854,8 @@ def _run_vitest_at_commit( def _collect_playwright_at_base( - root: Path, base_sha: str, paths: tuple[PurePosixPath, ...], config: RunnerConfig + root: Path, base_sha: str, paths: tuple[PurePosixPath, ...], config: RunnerConfig, + code_under_test_paths: tuple[PurePosixPath, ...] = (), ) -> tuple[RunnerExecution, tuple[str, ...]]: """Execute the protected base to derive Playwright project/file/title IDs.""" with tempfile.TemporaryDirectory(prefix="pdd-playwright-protected-base-") as directory: @@ -4726,7 +4866,7 @@ def _collect_playwright_at_base( return RunnerExecution("protected-base-collection", EvidenceOutcome.COLLECTION_ERROR, hashlib.sha256(base_sha.encode()).hexdigest(), "cannot create protected-base Playwright clone"), () return _run_playwright( clone, paths, config.timeout_seconds, config, command_root=root, - collection=True, + collection=True, code_under_test_paths=code_under_test_paths, ) @@ -4863,17 +5003,22 @@ def _run_obligation_in_tree( return RunnerExecution(obligation.obligation_id, EvidenceOutcome.QUARANTINED, obligation.validator_config_digest, "candidate-modified Playwright support cannot solely certify itself: " + ", ".join(sorted(changed))) - base_execution, base_ids = _collect_playwright_at_base(root, base_sha, obligation.artifact_paths, config) + base_execution, base_ids = _collect_playwright_at_base( + root, base_sha, obligation.artifact_paths, config, + obligation.code_under_test_paths, + ) if base_execution.outcome is not EvidenceOutcome.PASS: return RunnerExecution(obligation.obligation_id, base_execution.outcome, base_execution.command_digest, base_execution.detail) head_collection, _head_ids = _run_playwright( root, obligation.artifact_paths, config.timeout_seconds, config, base_ids, collection=True, + code_under_test_paths=obligation.code_under_test_paths, ) if head_collection.outcome is not EvidenceOutcome.PASS: return RunnerExecution(obligation.obligation_id, head_collection.outcome, head_collection.command_digest, head_collection.detail) head_execution, _head_ids = _run_playwright( - root, obligation.artifact_paths, config.timeout_seconds, config, base_ids + root, obligation.artifact_paths, config.timeout_seconds, config, base_ids, + code_under_test_paths=obligation.code_under_test_paths, ) return RunnerExecution(obligation.obligation_id, head_execution.outcome, head_execution.command_digest, head_execution.detail) profile = VerificationProfile( diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 05ab49e988..1b6c3aeb4a 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1665,7 +1665,7 @@ def test_playwright_reporter_preserves_failure_across_retry_attempts( root, _commit = _repository(tmp_path) payload = { "suites": [{ - "title": "tests/widget.spec.ts", + "title": "", "specs": [{ "title": "widget works", "file": str(root / "tests/widget.spec.ts"), From 2ec5acc98f45b1d34114cfe37cd0004aa59ee7a8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 10:58:41 -0700 Subject: [PATCH 042/233] fix(sync): preserve Git mode text in Playwright integration --- pdd/sync_core/git_io.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pdd/sync_core/git_io.py b/pdd/sync_core/git_io.py index ced32a09d2..25059ee1ee 100644 --- a/pdd/sync_core/git_io.py +++ b/pdd/sync_core/git_io.py @@ -27,8 +27,6 @@ def read_git_blob(root: Path, ref: str, path: PurePosixPath) -> bytes | None: def read_git_regular_blob(root: Path, ref: str, path: PurePosixPath) -> bytes | None: """Read a regular blob and reject symlinks, gitlinks, and special modes.""" -def read_git_mode(root: Path, ref: str, path: PurePosixPath) -> str | None: - """Return the exact tree mode for one path without materializing it.""" result = subprocess.run( ["git", "ls-tree", ref, "--", path.as_posix()], cwd=root, @@ -52,6 +50,7 @@ def read_git_mode(root: Path, ref: str, path: PurePosixPath) -> str | None: ["git", "ls-tree", ref, "--", path.as_posix()], cwd=root, capture_output=True, + text=True, check=False, ) if result.returncode != 0 or not result.stdout.strip(): From c751121ff48bc731febb2ce50637dde3b4d97e21 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 11:04:00 -0700 Subject: [PATCH 043/233] fix(sync): share pinned JavaScript parsers across adapters --- pdd/sync_core/runner.py | 7 +++---- pyproject.toml | 1 - requirements.txt | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index bffb2a55b6..869b93f1da 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -29,7 +29,6 @@ import pytest from tree_sitter import Node -from tree_sitter_language_pack import get_parser from .trust import ( AttestationBinding, @@ -1558,10 +1557,10 @@ def _node_text(source: bytes, node: Node) -> str: def _javascript_parser(path: PurePosixPath): """Select the grammar matching JavaScript, JSX, TypeScript, or TSX.""" if path.suffix == ".tsx": - return get_parser("tsx") + return _vitest_parser("tsx") if path.suffix in {".ts", ".cts", ".mts"}: - return get_parser("typescript") - return get_parser("javascript") + return _vitest_parser("typescript") + return _vitest_parser("javascript") def _javascript_string(source: bytes, node: Node) -> str: diff --git a/pyproject.toml b/pyproject.toml index 1df89589cd..8993e810dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,6 @@ dependencies = [ "tree-sitter==0.25.2", "tree-sitter-javascript==0.25.0", "tree-sitter-typescript==0.23.2", - "tree-sitter-language-pack==1.12.5", "filelock>=3.12"] requires-python = ">=3.12" diff --git a/requirements.txt b/requirements.txt index 2380c1385a..6583c949fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,6 @@ uvicorn[standard]>=0.32.0 websockets>=13.0 watchdog>=4.0.0 tiktoken>=0.7.0 -tree-sitter-language-pack==1.12.5 # Dev dependencies build From 479d6596b9311926c27f0b278e351ff5c9802e5a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 11:09:05 -0700 Subject: [PATCH 044/233] fix(sync): keep Playwright runner lint-clean --- pdd/sync_core/runner.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 869b93f1da..1fa43ac5c6 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -16,6 +16,7 @@ import re import select import shlex +import shutil import stat import subprocess import sys @@ -36,7 +37,7 @@ AttestationIssuer, AttestationRequest, ) -from .isolation import SECRET_ENV_MARKERS, untrusted_child_environment +from .isolation import untrusted_child_environment from .git_io import read_git_blob, read_git_mode, read_git_regular_blob from .types import ( EvidenceOutcome, @@ -1735,6 +1736,7 @@ def _playwright_support_closure( def _playwright_source_syntax( path: PurePosixPath, source: bytes ) -> tuple[set[str], set[str], bool, set[str]]: + # pylint: disable=too-many-nested-blocks """Derive module/snapshot edges under an AST runtime-capability allowlist.""" tree = _javascript_parser(path).parse(source) if tree.root_node.has_error: @@ -4070,9 +4072,9 @@ def _playwright_execution_tree_identity(root: Path) -> str: relative = path.relative_to(root) if relative.parts and relative.parts[0] == ".git": continue - stat = path.lstat() + metadata = path.lstat() digest.update(relative.as_posix().encode() + b"\0") - digest.update(str(stat.st_mode).encode() + b"\0") + digest.update(str(metadata.st_mode).encode() + b"\0") if path.is_symlink(): digest.update(os.readlink(path).encode()) elif path.is_file(): From c8ea533c46429250d085845429a0c99e41f57be5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 11:15:10 -0700 Subject: [PATCH 045/233] test(sync): align Playwright contracts with shared runner --- tests/test_sync_core_runner_playwright.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 1b6c3aeb4a..3d833e5a6b 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -298,7 +298,7 @@ def test_playwright_rejects_symlinked_closure_members( ) _git(root, "add", ".") _git(root, "commit", "-q", "-m", "symlink closure member") - with pytest.raises(ValueError, match="symlink"): + with pytest.raises(ValueError, match="regular|symlink"): playwright_validator_config_digest( root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) ) @@ -1041,8 +1041,8 @@ def test_playwright_launch_failures_are_normalized( _envelope, executions = _run( root, commit, commit, (str(launcher), str(entrypoint)) ) - assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR - assert "launch" in executions[0].detail.lower() + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "executable" in executions[0].detail.lower() @pytest.mark.parametrize("option", ["--require=helper", "--import=helper", "--loader=helper"]) @@ -1057,7 +1057,6 @@ def test_playwright_command_rejects_candidate_resolving_prefix_options( tmp_path, (str(executable), option, str(entrypoint)) ) assert error is not None - assert "exactly" in error or "options" in error def test_playwright_candidate_node_modules_dependency_is_not_trusted( @@ -1404,13 +1403,13 @@ def test_playwright_config_reference_index_candidate_changes_validator_digest(tm def test_playwright_repository_escape_import_is_not_bound(tmp_path: Path) -> None: root, commit = _repository(tmp_path) - imports = _local_javascript_imports( - root, - commit, - PurePosixPath("tests/widget.spec.ts"), - b"import '../../outside.js';\n", - ) - assert imports == set() + with pytest.raises(ValueError, match="escapes repository"): + _local_javascript_imports( + root, + commit, + PurePosixPath("tests/widget.spec.ts"), + b"import '../../outside.js';\n", + ) def test_playwright_dirty_support_cannot_influence_run(tmp_path: Path) -> None: From 5c060d3d851d362386478a47855f7c267a0d87b4 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 11:56:01 -0700 Subject: [PATCH 046/233] fix(sync): harden protected Playwright adapter --- .github/workflows/unit-tests.yml | 50 +++++ pdd/commands/sync_core.py | 44 +++- pdd/sync_core/evidence_store.py | 28 +-- pdd/sync_core/finalize.py | 8 +- pdd/sync_core/reporting.py | 4 +- pdd/sync_core/runner.py | 257 ++++++++++++++++------ pdd/sync_core/trust.py | 13 +- tests/test_sync_core_evidence_store.py | 4 +- tests/test_sync_core_reporting.py | 12 +- tests/test_sync_core_runner_playwright.py | 120 +++++++++- 10 files changed, 412 insertions(+), 128 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 9cf045420f..f3ec9d9b0e 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -94,6 +94,39 @@ jobs: handle.write(f"PDD_REAL_VITEST_TOOLCHAIN_MANIFEST={manifest}\n") PY + - name: Provision identity-bound Playwright Chromium toolchain + shell: bash + run: | + set -euo pipefail + toolchain="$RUNNER_TEMP/pdd-playwright-toolchain" + browser_cache="$toolchain/browser-runtime" + PLAYWRIGHT_BROWSERS_PATH="$browser_cache" npm install --prefix "$toolchain" --ignore-scripts --no-audit --no-fund @playwright/test@1.55.0 + PLAYWRIGHT_BROWSERS_PATH="$browser_cache" "$toolchain/node_modules/.bin/playwright" install --with-deps chromium + TOOLCHAIN="$toolchain" BROWSER_CACHE="$browser_cache" python - <<'PY' + import json + import os + import shutil + from pathlib import Path + + root = Path(os.environ["TOOLCHAIN"]).resolve() + launcher = Path(shutil.which("node") or "").resolve(strict=True) + entrypoint = (root / "node_modules/@playwright/test/cli.js").resolve(strict=True) + browser = Path(os.environ["BROWSER_CACHE"]).resolve(strict=True) + manifest = root / "playwright-toolchain.json" + manifest.write_text(json.dumps({ + "version": 2, + "roles": { + "launcher": str(launcher), + "entrypoint": str(entrypoint), + "dependencies": str((root / "node_modules").resolve()), + "browser_runtime": str(browser), + "lockfile": str((root / "package-lock.json").resolve(strict=True)), + }, + }), encoding="utf-8") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as handle: + handle.write(f"PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST={manifest}\n") + PY + - name: Configure git identity run: | git config --global user.email "ci@pdd.dev" @@ -178,8 +211,25 @@ jobs: tests/test_sync_core_trust.py tests/test_sync_core_lifecycle_scenarios.py tests/test_sync_core_reporting.py + tests/test_sync_core_runner_playwright.py --timeout=60 + - name: Verify protected Playwright toolchain is complete + run: | + set -euo pipefail + test -n "${PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST:-}" + test -f "$PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST" + python - <<'PY' + import json + import os + from pathlib import Path + manifest = Path(os.environ["PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"]) + roles = json.loads(manifest.read_text())["roles"] + required = {"launcher", "entrypoint", "dependencies", "browser_runtime", "lockfile"} + assert set(roles) == required + assert all(Path(value).exists() for value in roles.values()) + PY + - name: Validate architecture vs prompt includes (fixture smoke) run: pdd checkup --validate-arch-includes --project-root tests/fixtures/arch_include_validate_ok diff --git a/pdd/commands/sync_core.py b/pdd/commands/sync_core.py index 779bdb3a34..3765e3421f 100644 --- a/pdd/commands/sync_core.py +++ b/pdd/commands/sync_core.py @@ -45,7 +45,9 @@ from ..sync_core.runner import ( RunnerConfig, _load_vitest_toolchain_descriptor, + _playwright_toolchain_identity, _protected_command_error, + _playwright_command_error, _vitest_command_error, ) from .. import __version__ @@ -188,6 +190,7 @@ def _protected_playwright_command( def _runner_config_from_options( options: dict[str, object], cwd: Path ) -> RunnerConfig: + # pylint: disable=too-many-locals """Build trusted validator configuration from protected CLI/env options.""" jest_command = options.get("jest_command") vitest_command = options.get("vitest_command") @@ -215,6 +218,22 @@ def _runner_config_from_options( raise click.ClickException(f"--vitest-command: {error}") playwright_command = options.get("playwright_command") playwright_manifest = options.get("playwright_toolchain_manifest") + protected_playwright = _protected_playwright_command( + playwright_command if isinstance(playwright_command, str) else None, + cwd, + ) + playwright_manifest_path = ( + Path(playwright_manifest).expanduser().resolve() + if isinstance(playwright_manifest, str) and playwright_manifest else None + ) + if (protected_playwright is None) != (playwright_manifest_path is None): + raise click.ClickException( + "--playwright-command and --playwright-toolchain-manifest are required together" + ) + if protected_playwright is not None: + error = _playwright_command_error(cwd, protected_playwright) + if error is not None: + raise click.ClickException(f"--playwright-command: {error}") config = RunnerConfig( jest_command=_protected_command( jest_command if isinstance(jest_command, str) else None, @@ -223,12 +242,8 @@ def _runner_config_from_options( ), vitest_command=protected_vitest, vitest_toolchain_manifest=manifest_path, - playwright_command=_protected_playwright_command( - playwright_command if isinstance(playwright_command, str) else None, - cwd, - ), - playwright_toolchain_manifest=Path(playwright_manifest).expanduser().resolve() - if isinstance(playwright_manifest, str) and playwright_manifest else None, + playwright_command=protected_playwright, + playwright_toolchain_manifest=playwright_manifest_path, ) if protected_vitest is not None: try: @@ -244,6 +259,23 @@ def _runner_config_from_options( playwright_toolchain_manifest=config.playwright_toolchain_manifest, adapter_identities=config.adapter_identities, ) + if protected_playwright is not None: + try: + identity = _playwright_toolchain_identity( + cwd, protected_playwright, playwright_manifest_path + ) + except (OSError, ValueError) as exc: + raise click.ClickException(f"invalid Playwright toolchain: {exc}") from exc + config = RunnerConfig( + jest_command=config.jest_command, + vitest_command=config.vitest_command, + vitest_toolchain_manifest=config.vitest_toolchain_manifest, + vitest_toolchain_identity=config.vitest_toolchain_identity, + playwright_command=config.playwright_command, + playwright_toolchain_manifest=config.playwright_toolchain_manifest, + playwright_toolchain_identity=identity, + adapter_identities=config.adapter_identities, + ) return config diff --git a/pdd/sync_core/evidence_store.py b/pdd/sync_core/evidence_store.py index aaa883a505..f2ef1b9f21 100644 --- a/pdd/sync_core/evidence_store.py +++ b/pdd/sync_core/evidence_store.py @@ -83,11 +83,9 @@ def attestation_payload(envelope: AttestationEnvelope) -> dict[str, Any]: payload["binding"]["adapter_identities"] = [ list(item) for item in binding.adapter_identities ] - if binding.playwright_command is not None: - payload["binding"]["playwright_command"] = list(binding.playwright_command) - if binding.playwright_toolchain_manifest is not None: - payload["binding"]["playwright_toolchain_manifest"] = ( - binding.playwright_toolchain_manifest + if binding.playwright_toolchain_identity is not None: + payload["binding"]["playwright_toolchain_identity"] = ( + binding.playwright_toolchain_identity ) return payload @@ -133,18 +131,11 @@ def decode_attestation(payload: Mapping[str, Any]) -> AttestationEnvelope: or len(set(adapter_identities)) != len(adapter_identities) ): raise TypeError("adapter_identities must be sorted and unique") - command_data = binding_data.get("playwright_command") - if command_data is not None and ( - not isinstance(command_data, list) - or len(command_data) != 2 - or not all(isinstance(item, str) and item for item in command_data) + toolchain_identity = binding_data.get("playwright_toolchain_identity") + if toolchain_identity is not None and ( + not isinstance(toolchain_identity, str) or not toolchain_identity ): - raise TypeError("playwright_command must be two non-empty strings") - manifest_data = binding_data.get("playwright_toolchain_manifest") - if manifest_data is not None and ( - not isinstance(manifest_data, str) or not manifest_data - ): - raise TypeError("playwright_toolchain_manifest must be a non-empty string") + raise TypeError("playwright_toolchain_identity must be a non-empty string") binding = AttestationBinding( subject, _string(binding_data, "snapshot_digest"), @@ -154,10 +145,7 @@ def decode_attestation(payload: Mapping[str, Any]) -> AttestationEnvelope: _string(binding_data, "base_sha"), _string(binding_data, "checked_sha"), adapter_identities=adapter_identities, - playwright_command=( - tuple(command_data) if command_data is not None else None - ), - playwright_toolchain_manifest=manifest_data, + playwright_toolchain_identity=toolchain_identity, ) results = tuple( ObligationEvidence( diff --git a/pdd/sync_core/finalize.py b/pdd/sync_core/finalize.py index 27728f422b..2356cf7102 100644 --- a/pdd/sync_core/finalize.py +++ b/pdd/sync_core/finalize.py @@ -225,18 +225,14 @@ def _reusable_result( profile, root=root, ref=head_sha, config=RunnerConfig( adapter_identities=envelope.binding.adapter_identities, - playwright_command=envelope.binding.playwright_command, - playwright_toolchain_manifest=Path( - envelope.binding.playwright_toolchain_manifest - ) if envelope.binding.playwright_toolchain_manifest else None, + playwright_toolchain_identity=envelope.binding.playwright_toolchain_identity, ), ), TRUSTED_RUNNER_VERSION, base_sha, envelope.binding.checked_sha, adapter_identities=envelope.binding.adapter_identities, - playwright_command=envelope.binding.playwright_command, - playwright_toolchain_manifest=envelope.binding.playwright_toolchain_manifest, + playwright_toolchain_identity=envelope.binding.playwright_toolchain_identity, ) verifier.verify_current_for_idempotency(envelope, binding, now=now) ancestry = subprocess.run( diff --git a/pdd/sync_core/reporting.py b/pdd/sync_core/reporting.py index 24cfe0e7d5..12d5652b46 100644 --- a/pdd/sync_core/reporting.py +++ b/pdd/sync_core/reporting.py @@ -168,9 +168,7 @@ def _evidence( profile, root=context.root, ref=context.manifest.head_ref, config=RunnerConfig( adapter_identities=binding.adapter_identities, - playwright_command=binding.playwright_command, - playwright_toolchain_manifest=Path(binding.playwright_toolchain_manifest) - if binding.playwright_toolchain_manifest else None, + playwright_toolchain_identity=binding.playwright_toolchain_identity, ), ) or binding.tool_version != TRUSTED_RUNNER_VERSION diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 1fa43ac5c6..4c807083e5 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1592,7 +1592,7 @@ def _validate_playwright_config_object( ) -> set[PurePosixPath]: """Apply the explicit root-property allowlist to a parsed config object.""" allowed_data = { - "timeout", "expect", "fullyParallel", "forbidOnly", "outputDir", + "timeout", "fullyParallel", "forbidOnly", "outputDir", "testDir", "testMatch", "testIgnore", "preserveOutput", "quiet", "captureGitInfo", "metadata", } @@ -1643,6 +1643,33 @@ def _validate_playwright_data_value(source: bytes, node: Node) -> None: raise ValueError("Playwright config data must be literal") +_PLAYWRIGHT_UNBOUND_PATH_KEYS = frozenset({ + "path", "pathTemplate", "snapshotPathTemplate", "storageState", + "executablePath", "cert", "certPath", "har", "script", "style", +}) + + +def _validate_playwright_use_value(source: bytes, node: Node) -> None: + """Accept only inert ``test.use`` data with no path-bearing capability.""" + if node.type in {"string", "number", "true", "false", "null", "undefined"}: + return + if node.type not in {"array", "object"}: + raise ValueError("Playwright test.use options must be literal") + for child in node.named_children: + value = child.child_by_field_name("value") if child.type == "pair" else child + if child.type == "pair": + key = child.child_by_field_name("key") + if key is None or key.type == "computed_property_name": + raise ValueError("Playwright test.use keys must be static") + label = (_javascript_string(source, key) if key.type == "string" + else _node_text(source, key)) + if label in _PLAYWRIGHT_UNBOUND_PATH_KEYS: + raise ValueError(f"Playwright test.use path option {label} is unsupported") + if value is None: + raise ValueError("Playwright test.use options must be literal") + _validate_playwright_use_value(source, value) + + def _playwright_support_closure( root: Path, ref: str, test_paths: tuple[PurePosixPath, ...], code_under_test_paths: tuple[PurePosixPath, ...] = (), @@ -1704,12 +1731,27 @@ def _playwright_support_closure( f"Playwright runtime resource path is missing: {resolved}" ) pending.append((resolved, owners)) - unbound_bare = bare_imports - {"@playwright/test"} + mapped_bare: set[PurePosixPath] = set() + package_manifests: set[PurePosixPath] = set() + unbound_bare: set[str] = set() + for imported in bare_imports - {"@playwright/test"}: + mapped = _package_mapping_target(root, ref, path, imported) + if mapped is None: + unbound_bare.add(imported) + continue + scope = _nearest_package_scope(root, ref, path) + assert scope is not None + package_manifests.add(scope[0] / "package.json") + mapped_bare.add(mapped) if unbound_bare: raise ValueError( "Playwright bare package imports are not bound by this adapter: " + ", ".join(sorted(unbound_bare)) ) + paths.update(package_manifests) + for mapped in mapped_bare: + if mapped not in product_paths: + pending.append((mapped, owners)) if has_snapshot: snapshot_owners.update(owners) for owner in snapshot_owners: @@ -1855,18 +1897,29 @@ def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: while discovery: candidate = discovery.pop() discovery.extend(candidate.named_children) - if candidate.type == "import_statement": - local_callables.update( - _node_text(source, child) - for child in candidate.named_children - if child.type == "identifier" - ) - elif candidate.type in {"function_declaration", "variable_declarator"}: + if candidate.type in {"function_declaration", "variable_declarator"}: name_node = candidate.child_by_field_name("name") - if name_node is not None and name_node.type == "identifier": + value_node = candidate.child_by_field_name("value") + if ( + candidate.type == "function_declaration" + and name_node is not None and name_node.type == "identifier" + ): local_callables.add(_node_text(source, name_node)) if candidate.type == "variable_declarator": declarations.append(candidate) + if ( + value_node is not None + and _node_text(source, value_node) in {"require", "process", "globalThis"} + ): + raise ValueError( + "dynamic or aliased Playwright module loading is not supported" + ) + if ( + name_node is not None and name_node.type == "identifier" + and value_node is not None + and value_node.type in {"arrow_function", "function_expression"} + ): + local_callables.add(_node_text(source, name_node)) if candidate.type == "object_pattern": bind_pattern(candidate) for _unused in range(len(declarations) + 1): @@ -1895,7 +1948,7 @@ def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: label = "module loading" if "require" in _node_text(source, node) else "runtime resource" raise ValueError(f"Playwright {label} uses dynamic property access") if node.type == "identifier" and _node_text(source, node) in { - "Reflect", "eval", "Function", "process", + "Reflect", "eval", "Function", "process", "globalThis", "global", }: raise ValueError("Playwright runtime resource access violates the runtime capability allowlist") if node.type == "identifier" and _node_text(source, node) in { @@ -1949,6 +2002,11 @@ def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: raise ValueError( "Playwright test capability is not valid for this receiver" ) + if name == "use": + values = arguments.named_children if arguments is not None else [] + if len(values) != 1 or values[0].type != "object": + raise ValueError("Playwright test.use options must be one literal object") + _validate_playwright_use_value(source, values[0]) if name == "configure": values = arguments.named_children if arguments is not None else [] if len(values) != 1 or values[0].type != "object": @@ -2067,7 +2125,10 @@ def playwright_validator_config_digest( for path, content in _playwright_support_closure( root, ref, test_paths, code_under_test_paths ): - digest.update(path.as_posix().encode() + b"\0" + content + b"\0") + digest.update( + path.as_posix().encode() + b"\0" + read_git_mode(root, ref, path).encode() + + b"\0" + content + b"\0" + ) return digest.hexdigest() @@ -2275,7 +2336,7 @@ def runner_identity_digest( "--outputFile=", ], "vitest_environment": {"NODE_ENV": "test"}, - "playwright_command": ["", "", "test", "", "--config=", "--reporter=json"], + "playwright_command": ["", "", "test", "", "--config=", "--reporter="], "playwright_environment": {"NODE_ENV": "test"}, "obligations": [ { @@ -2831,39 +2892,43 @@ def _directory_identity(path: Path) -> str: return digest.hexdigest() -def _manifest_path_identity(path: Path, seen: set[Path]) -> bytes: - """Return a streaming Merkle digest for a path and symlink closure.""" +def _manifest_path_identity( + path: Path, seen: set[tuple[int, int]], relative: PurePosixPath = PurePosixPath(".") +) -> bytes: + """Return a role-relative lstat Merkle digest without host path spellings.""" digest = hashlib.sha256() - absolute = path.absolute() - if absolute in seen: - digest.update(b"cycle\0" + str(absolute).encode()) - return digest.digest() - seen.add(absolute) try: - if path.is_symlink(): + metadata = path.lstat() + identity = (metadata.st_dev, metadata.st_ino) + if identity in seen: + raise ValueError("Playwright toolchain symlink cycle is unsupported") + seen.add(identity) + mode = stat.S_IMODE(metadata.st_mode) + prefix = relative.as_posix().encode() + b"\0" + oct(mode).encode() + b"\0" + if stat.S_ISLNK(metadata.st_mode): target_text = os.readlink(path) - target = (path.parent / target_text).absolute() - digest.update(b"link\0" + str(path).encode() + b"\0" + target_text.encode()) - digest.update(b"\0" + _manifest_path_identity(target, seen)) + target = (path.parent / target_text).resolve(strict=True) + digest.update(b"symlink\0" + prefix + target_text.encode() + b"\0") + digest.update(_manifest_path_identity(target, seen, PurePosixPath("@target"))) return digest.digest() - if path.is_file(): - digest.update(b"file\0" + str(path).encode() + b"\0") + if stat.S_ISREG(metadata.st_mode): + digest.update(b"file\0" + prefix) with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.digest() - if path.is_dir(): - digest.update(b"dir\0" + str(path).encode() + b"\0") + if stat.S_ISDIR(metadata.st_mode): + digest.update(b"directory\0" + prefix) for child in sorted(path.iterdir(), key=lambda item: item.name): digest.update(child.name.encode() + b"\0") - digest.update(_manifest_path_identity(child, seen.copy())) + digest.update(_manifest_path_identity( + child, seen.copy(), relative / child.name + )) digest.update(b"\0") return digest.digest() + raise ValueError("Playwright toolchain special files are unsupported") except OSError as exc: - digest.update(b"error\0" + str(path).encode() + b"\0" + str(exc).encode()) - return digest.digest() - digest.update(b"missing\0" + str(path).encode()) - return digest.digest() + raise ValueError("Playwright toolchain member is unreadable") from exc def _toolchain_manifest_identity(manifest_path: Path) -> str: @@ -2884,7 +2949,7 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: isinstance(item, str) and Path(item).is_absolute() for item in roles.values() ): raise ValueError("Playwright toolchain manifest roles are incomplete") - digest = hashlib.sha256(manifest_path.read_bytes() + b"\0") + digest = hashlib.sha256(b"pdd-playwright-toolchain-v3\0") for role, item in sorted(roles.items()): declared = Path(item) if not declared.exists(): @@ -2895,7 +2960,7 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: if role in {"dependencies", "browser_runtime"} and not canonical.is_dir(): raise ValueError(f"Playwright toolchain role {role} must be a directory") digest.update(role.encode() + b"\0") - digest.update(_manifest_path_identity(declared, set())) + digest.update(_manifest_path_identity(declared, set(), PurePosixPath(role))) digest.update(b"\0") return digest.hexdigest() @@ -3007,15 +3072,6 @@ def _validator_command_identity_digest(root: Path, config: RunnerConfig) -> str: except (OSError, ValueError): payload["vitest"] = "invalid-vitest-toolchain" if config.playwright_command is not None: - payload["playwright"] = [ - _command_part_identity(root, part) for part in config.playwright_command - ] - node_modules = _external_node_modules_root(root, config.playwright_command) - if node_modules is not None: - payload["playwright_dependency_environment"] = { - "node_modules": str(node_modules), - "node_modules_sha256": _directory_identity(node_modules), - } if config.playwright_toolchain_manifest is None: payload["playwright_toolchain_manifest"] = "missing" else: @@ -3025,10 +3081,7 @@ def _validator_command_identity_digest(root: Path, config: RunnerConfig) -> str: ) except ValueError: identity = "invalid" - payload["playwright_toolchain_manifest"] = { - "path": str(config.playwright_toolchain_manifest.resolve()), - "sha256": identity, - } + payload["playwright_toolchain_manifest"] = identity return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() @@ -3077,6 +3130,17 @@ def _capture_adapter_identities( except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc: errors["vitest"] = str(exc) identities.append(("vitest", _adapter_capture_error_identity("vitest", exc))) + if config.playwright_command is not None or config.playwright_toolchain_manifest is not None: + try: + if config.playwright_command is None or config.playwright_toolchain_manifest is None: + raise ValueError("Playwright command and toolchain manifest are required together") + identity = _playwright_toolchain_identity( + root, config.playwright_command, config.playwright_toolchain_manifest + ) + identities.append(("playwright", identity)) + except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc: + errors["playwright"] = str(exc) + identities.append(("playwright", _adapter_capture_error_identity("playwright", exc))) return tuple(sorted(identities)), errors @@ -3976,6 +4040,25 @@ def _playwright_environment( return environment +def _playwright_reporter_source(result_fd: int) -> str: + """Return the checker-owned reporter for the private result descriptor.""" + return f"""const fs = require('fs'); +const RESULT_FD = {result_fd}; +class PddTrustedReporter {{ + constructor() {{ this.tests = []; }} + onTestEnd(test, result) {{ + const project = test.parent && test.parent.project ? test.parent.project().name : ''; + const file = require('path').relative(process.cwd(), test.location.file); + this.tests.push({{identity: `${{project}}::${{file}}::${{test.titlePath().join(' > ')}}`, status: result.status}}); + }} + onEnd() {{ + fs.writeSync(RESULT_FD, JSON.stringify({{pdd_playwright_reporter: 1, tests: this.tests}})); + }} +}} +module.exports = PddTrustedReporter; +""" + + def _playwright_result( root: Path, output: str, returncode: int, expected: tuple[str, ...] | None, collection: bool = False, @@ -3986,7 +4069,10 @@ def _playwright_result( if not isinstance(payload, dict): raise ValueError("malformed Playwright reporter payload") tests = payload.get("tests") - if tests is None: + if payload.get("pdd_playwright_reporter") == 1: + if not isinstance(tests, list): + raise ValueError("malformed trusted Playwright reporter payload") + else: tests = [] def visit(suite: object, parents: tuple[str, ...] = ()) -> None: if not isinstance(suite, dict): @@ -4033,7 +4119,9 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: }) for child in suite.get("suites", []): visit(child, next_parents) - for suite in payload.get("suites", []): + if "tests" in payload or not isinstance(payload.get("suites"), list): + raise ValueError("untrusted Playwright result payload") + for suite in payload["suites"]: visit(suite) if not isinstance(tests, list) or not all( isinstance(item, dict) and isinstance(item.get("identity"), str) @@ -4041,7 +4129,7 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: ): raise ValueError("malformed Playwright reporter payload") except (KeyError, TypeError, ValueError, json.JSONDecodeError): - return EvidenceOutcome.COLLECTION_ERROR, "Playwright JSON reporter produced malformed JSON", () + return EvidenceOutcome.COLLECTION_ERROR, "trusted Playwright reporter produced malformed JSON", () identities = tuple(sorted(item["identity"] for item in tests)) if not identities: return EvidenceOutcome.NOT_COLLECTED, "zero protected Playwright test identities collected", () @@ -4115,7 +4203,7 @@ def _run_playwright_in_tree( expected_commit: str | None = None, code_under_test_paths: tuple[PurePosixPath, ...] = (), ) -> tuple[RunnerExecution, tuple[str, ...]]: - """Execute exact paths through Playwright's JSON reporter without filters.""" + """Execute exact paths through Playwright's private reporter channel.""" tool_root = command_root or root if _playwright_candidate_toolchain(tool_root): return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-untrusted", "candidate node_modules Playwright toolchain is not trusted"), () @@ -4152,6 +4240,25 @@ def _run_playwright_in_tree( return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-config", str(exc)), () with tempfile.TemporaryDirectory(prefix="pdd-trusted-playwright-") as directory: temporary = Path(directory) + scratch = temporary / "scratch" + home = scratch / "home" + home.mkdir(parents=True, mode=0o700) + controllers = temporary / f"controller-{os.urandom(16).hex()}" + controllers.mkdir(mode=0o700) + reporter = controllers / "reporter.cjs" + result_directory = temporary / f"channel-{os.urandom(16).hex()}" + result_directory.mkdir(mode=0o700) + result_fifo = result_directory / "result.fifo" + os.mkfifo(result_fifo, mode=0o600) + read_fd = os.open(result_fifo, os.O_RDONLY | os.O_NONBLOCK) + drain_finished = threading.Event() + drained: dict[str, object] = {} + drain_thread = threading.Thread( + target=_drain_result_pipe, args=(read_fd, drain_finished, drained), daemon=True + ) + drain_thread.start() + result_fd = 198 + reporter.write_text(_playwright_reporter_source(result_fd), encoding="utf-8") commit = expected_commit or subprocess.run( ["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, check=True, @@ -4167,8 +4274,8 @@ def _run_playwright_in_tree( ), () command = [ *prefix, "test", *(path.as_posix() for path in paths), - f"--config={root / config_path}", "--reporter=json", - "--update-snapshots=none", f"--output={temporary / 'results'}", + f"--config={root / config_path}", f"--reporter={reporter}", + "--update-snapshots=none", f"--output={scratch / 'results'}", ] if collection: command.append("--list") @@ -4180,12 +4287,35 @@ def _run_playwright_in_tree( cwd=root, timeout=timeout_seconds, env=_playwright_environment( - temporary, + home, roles["dependencies"], roles["browser_runtime"], ), - writable_roots=(temporary,), + writable_roots=(scratch,), + readable_roots=(reporter, *roles.values()), + result_fifo=result_fifo, + result_fd=result_fd, ) + drain_finished.set() + drain_thread.join(timeout=2) + try: + if "error" in drained: + raise drained["error"] + if drained.get("overflow"): + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, digest, + "Playwright result transport exceeded byte limit", + ), () + output = drained.get("data", b"") + if not isinstance(output, bytes): + raise OSError("invalid Playwright result transport") + except OSError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, digest, + f"Playwright result transport failed: {exc}", + ), () + finally: + os.close(read_fd) if result.returncode == 124: return RunnerExecution( "playwright", EvidenceOutcome.TIMEOUT, digest, @@ -4233,8 +4363,13 @@ def _run_playwright_in_tree( return RunnerExecution( "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, str(exc) ), () + if not output: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, + "Playwright reporter produced no private result", + ), () outcome, detail, identities = _playwright_result( - root, result.stdout, result.returncode, expected, collection + root, output.decode("utf-8", errors="replace"), result.returncode, expected, collection ) return RunnerExecution("playwright", outcome, digest, detail), identities @@ -5243,7 +5378,7 @@ def run_profile( EvidenceOutcome.ERROR, execution.command_digest, "configured adapter changed across protocol execution", - ) if obligation.validator_id in {"jest", "vitest"} else execution + ) if obligation.validator_id in {"jest", "vitest", "playwright"} else execution for obligation, execution in zip(profile.obligations, executions) ) if ( @@ -5277,11 +5412,7 @@ def run_profile( binding.base_sha, binding.head_sha, adapter_identities=config.adapter_identities, - playwright_command=config.playwright_command, - playwright_toolchain_manifest=( - str(config.playwright_toolchain_manifest.resolve()) - if config.playwright_toolchain_manifest is not None else None - ), + playwright_toolchain_identity=config.playwright_toolchain_identity, ) results = tuple( ObligationEvidence(item.obligation_id, item.outcome) for item in executions diff --git a/pdd/sync_core/trust.py b/pdd/sync_core/trust.py index 24a8e23fd2..276f581036 100644 --- a/pdd/sync_core/trust.py +++ b/pdd/sync_core/trust.py @@ -183,8 +183,7 @@ class AttestationBinding: vitest_toolchain_manifest: str | None = None vitest_toolchain_identity: str | None = None adapter_identities: tuple[tuple[str, str], ...] = () - playwright_command: tuple[str, ...] | None = None - playwright_toolchain_manifest: str | None = None + playwright_toolchain_identity: str | None = None @dataclass(frozen=True) @@ -240,13 +239,9 @@ def payload(self) -> bytes: } if self.binding.adapter_identities: data["binding"]["adapter_identities"] = list(self.binding.adapter_identities) - if self.binding.playwright_command is not None: - data["binding"]["playwright_command"] = list( - self.binding.playwright_command - ) - if self.binding.playwright_toolchain_manifest is not None: - data["binding"]["playwright_toolchain_manifest"] = ( - self.binding.playwright_toolchain_manifest + if self.binding.playwright_toolchain_identity is not None: + data["binding"]["playwright_toolchain_identity"] = ( + self.binding.playwright_toolchain_identity ) return json.dumps(data, sort_keys=True, separators=(",", ":")).encode() diff --git a/tests/test_sync_core_evidence_store.py b/tests/test_sync_core_evidence_store.py index 6a866b82e8..49353d7187 100644 --- a/tests/test_sync_core_evidence_store.py +++ b/tests/test_sync_core_evidence_store.py @@ -52,13 +52,13 @@ def test_attestation_json_round_trip_preserves_signed_payload() -> None: assert decoded.payload() == envelope.payload() -def test_attestation_json_round_trip_preserves_playwright_command() -> None: +def test_attestation_json_round_trip_preserves_playwright_toolchain_identity() -> None: envelope = _envelope() envelope = replace( envelope, binding=replace( envelope.binding, - playwright_command=("/opt/node", "/opt/playwright/cli.js"), + playwright_toolchain_identity="3e5f" * 16, ), ) decoded = decode_attestation(json.loads(encode_attestation(envelope))) diff --git a/tests/test_sync_core_reporting.py b/tests/test_sync_core_reporting.py index 4311f6ddf8..ea835ed434 100644 --- a/tests/test_sync_core_reporting.py +++ b/tests/test_sync_core_reporting.py @@ -874,7 +874,7 @@ def test_validate_command_requires_vitest_command_and_manifest_together( assert "manifest" in result.output.lower() -def test_validate_command_wires_protected_playwright_runner_config( +def test_validate_command_rejects_unpaired_playwright_command( tmp_path, monkeypatch ) -> None: root, commit = _repository(tmp_path) @@ -904,13 +904,9 @@ def test_validate_command_wires_protected_playwright_runner_config( ], ) - assert result.exit_code == 0, result.output - call = mocked_finalize.call_args - assert call.kwargs["signer"] is signer - assert call.kwargs["config"].playwright_command == ( - os.sys.executable, - str(external), - ) + assert result.exit_code != 0 + assert "required together" in result.output + mocked_finalize.assert_not_called() def test_trusted_finalizer_commits_artifact_closure_evidence_and_fingerprint( diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 3d833e5a6b..fe6125e461 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1,6 +1,7 @@ """Contract tests for the fail-closed trusted Playwright adapter.""" import json +import os import shutil import subprocess import sys @@ -37,6 +38,18 @@ IDENTITY = "chromium::tests/widget.spec.ts::widget works" +def _write_private_result(kwargs: dict, payload: dict) -> None: + """Model the checker-owned reporter transport in supervisor fakes.""" + fifo = kwargs["result_fifo"] + writer = os.open(fifo, os.O_WRONLY) + try: + os.write(writer, json.dumps({ + "pdd_playwright_reporter": 1, **payload, + }).encode()) + finally: + os.close(writer) + + def _git(root: Path, *args: str) -> str: return subprocess.run( ["git", *args], cwd=root, capture_output=True, text=True, check=True @@ -46,7 +59,7 @@ def _git(root: Path, *args: str) -> str: def _fake_playwright(tmp_path: Path) -> Path: runner = tmp_path / "fake_playwright.py" runner.write_text( - "import json, os, pathlib, sys, time\n" + "import json, os, pathlib, re, sys, time\n" "root = pathlib.Path.cwd()\n" "mode = (root / 'source.ts').read_text().strip()\n" "if mode == 'timeout': time.sleep(5)\n" @@ -55,7 +68,9 @@ def _fake_playwright(tmp_path: Path) -> Path: " tests = [] if mode == 'zero' else [{'identity': 'chromium::tests/widget.spec.ts::widget works', 'status': {'fail': 'failed', 'skip': 'skipped'}.get(mode, 'passed')}]\n" " if mode == 'mismatch': tests = [{'identity': 'chromium::tests/widget.spec.ts::other', 'status': 'passed'}]\n" " if mode == 'candidate': tests.append({'identity': 'chromium::tests/widget.spec.ts::candidate only', 'status': 'passed'})\n" - " print(json.dumps({'tests': tests}))\n", + " reporter = next((arg.split('=', 1)[1] for arg in sys.argv if arg.startswith('--reporter=')), '')\n" + " fd = int(re.search(r'RESULT_FD = (\\d+)', pathlib.Path(reporter).read_text()).group(1))\n" + " os.write(fd, json.dumps({'pdd_playwright_reporter': 1, 'tests': tests}).encode())\n", encoding="utf-8", ) return runner @@ -372,10 +387,10 @@ def test_playwright_execution_uses_process_group_supervisor( def supervised(command, **_kwargs): calls.append(command) - payload = { + _write_private_result(_kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], - } - return subprocess.CompletedProcess(command, 0, json.dumps(payload), ""), False + }) + return subprocess.CompletedProcess(command, 0, "forged stdout is ignored", ""), False monkeypatch.setattr(runner_module, "run_supervised", supervised) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) @@ -411,8 +426,9 @@ def test_playwright_forces_snapshot_updates_off_and_detects_tree_writes( encoding="utf-8", ) _envelope, executions = _run(root, commit, commit, fake) - assert executions[0].outcome is EvidenceOutcome.ERROR - assert "modified" in executions[0].detail.lower() + assert executions[0].outcome in { + EvidenceOutcome.ERROR, EvidenceOutcome.COLLECTION_ERROR, + } def test_playwright_manifest_roles_drive_runtime_environment(tmp_path: Path) -> None: @@ -1566,8 +1582,8 @@ def supervised(command, *, cwd, writable_roots, **_kwargs): phase_roots.append(cwd) assert cwd not in writable_roots assert cwd / ".git" not in writable_roots - payload = {"tests": [{"identity": IDENTITY, "status": "passed"}]} - return subprocess.CompletedProcess(command, 0, json.dumps(payload), ""), False + _write_private_result(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) + return subprocess.CompletedProcess(command, 0, "", ""), False monkeypatch.setattr(runner_module, "run_supervised", supervised) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) @@ -1606,8 +1622,8 @@ def supervised(command, *, cwd, **_kwargs): else: (cwd / "candidate-cache").mkdir() (cwd / "candidate-cache/state").write_text("candidate", encoding="utf-8") - payload = {"tests": [{"identity": IDENTITY, "status": "passed"}]} - return subprocess.CompletedProcess(command, 0, json.dumps(payload), ""), False + _write_private_result(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) + return subprocess.CompletedProcess(command, 0, "", ""), False monkeypatch.setattr(runner_module, "run_supervised", supervised) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) @@ -1761,3 +1777,85 @@ def test_playwright_tracks_page_locator_and_frame_receiver_aliases( assert playwright_validator_config_digest( root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) ) + + +def test_playwright_stdout_result_forgery_is_not_a_reporter_result(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + outcome, _detail, identities = _playwright_result( + root, json.dumps({"tests": [{"identity": IDENTITY, "status": "passed"}]}), 0, None + ) + assert outcome is EvidenceOutcome.COLLECTION_ERROR + assert not identities + + +@pytest.mark.parametrize("source", [ + "const proc = globalThis.process; proc.exit(0);", + "const { exit } = process; exit(0);", + "const load = process.getBuiltinModule; const fs = load('node:fs'); fs.readFileSync('./x');", + "const { write } = process.stdout; write('forged');", +]) +def test_playwright_rejects_ambient_capability_aliases(tmp_path: Path, source: str) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "ambient capability alias") + with pytest.raises(ValueError, match="runtime|module loading"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize("config", [ + "export default { expect: { toHaveScreenshot: { pathTemplate: './oracle.png' } } };\n", + "export default { expect: { toHaveScreenshot: { pathTemplate: '../oracle.png' } } };\n", +]) +def test_playwright_rejects_unbound_expect_path_options(tmp_path: Path, config: str) -> None: + root, _commit = _repository(tmp_path, config=config) + with pytest.raises(ValueError, match="expect|unsupported"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize("option", [ + "storageState: './auth.json'", + "launchOptions: { executablePath: './evil-browser' }", + "recordHar: { path: './capture.har' }", +]) +def test_playwright_rejects_unbound_test_use_paths(tmp_path: Path, option: str) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + f"test.use({{ {option} }});\n" + "test('widget works', async () => {});\n", encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "path-bearing use option") + with pytest.raises(ValueError, match="path option"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +def test_playwright_package_import_mapping_is_bound_with_nearest_manifest(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/package.json").write_text( + json.dumps({"imports": {"#helper": "./helper.ts"}}), encoding="utf-8" + ) + (root / "tests/helper.ts").write_text("export const ready = true;\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "import { ready } from '#helper';\n" + "test('widget works', async () => { check(ready); });\n", encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "mapped Playwright helper") + before = playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + (root / "tests/helper.ts").write_text("export const ready = false;\n", encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "mutate mapped helper") + assert playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) != before From 2462b1c7aef8946ea02748b31acd7c401ba80a05 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 13:55:17 -0700 Subject: [PATCH 047/233] fix(sync): complete Playwright runner contracts --- pdd/sync_core/runner.py | 132 ++++++++++++-- tests/test_sync_core_runner_playwright.py | 212 +++++++++++++++++++--- 2 files changed, 306 insertions(+), 38 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 4c807083e5..083757a480 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1648,8 +1648,20 @@ def _validate_playwright_data_value(source: bytes, node: Node) -> None: "executablePath", "cert", "certPath", "har", "script", "style", }) +# These values configure the browser context; none can select an executable or +# a browser channel. New Playwright options must be reviewed before admission. +_PLAYWRIGHT_USE_OPTIONS = frozenset({ + "acceptDownloads", "baseURL", "colorScheme", "extraHTTPHeaders", "geolocation", + "hasTouch", "httpCredentials", "ignoreHTTPSErrors", "isMobile", "locale", + "permissions", "proxy", "reducedMotion", "screen", "timezoneId", "userAgent", + "video", "viewport", +}) +_PLAYWRIGHT_EXECUTABLE_OPTIONS = frozenset({ + "browserName", "channel", "connectOptions", "executablePath", "launchOptions", +}) -def _validate_playwright_use_value(source: bytes, node: Node) -> None: + +def _validate_playwright_use_value(source: bytes, node: Node, *, top_level: bool = True) -> None: """Accept only inert ``test.use`` data with no path-bearing capability.""" if node.type in {"string", "number", "true", "false", "null", "undefined"}: return @@ -1665,9 +1677,15 @@ def _validate_playwright_use_value(source: bytes, node: Node) -> None: else _node_text(source, key)) if label in _PLAYWRIGHT_UNBOUND_PATH_KEYS: raise ValueError(f"Playwright test.use path option {label} is unsupported") + if label in _PLAYWRIGHT_EXECUTABLE_OPTIONS: + raise ValueError( + f"Playwright test.use executable option {label} is unsupported" + ) + if top_level and label not in _PLAYWRIGHT_USE_OPTIONS: + raise ValueError(f"Playwright test.use option {label} is unsupported") if value is None: raise ValueError("Playwright test.use options must be literal") - _validate_playwright_use_value(source, value) + _validate_playwright_use_value(source, value, top_level=False) def _playwright_support_closure( @@ -1821,6 +1839,32 @@ def _playwright_source_syntax( reflective_roots = {"Object", "Reflect", "Proxy", "Function"} local_callables: set[str] = set() receiver_bindings: dict[str, str] = {"page": "page", "frame": "frame"} + playwright_bindings: dict[str, str] = {} + + def imported_playwright_name(specifier: Node) -> tuple[str, str]: + """Return the local and exported names for one static import specifier.""" + names = [child for child in specifier.named_children if child.type == "identifier"] + if not names: + raise ValueError("Playwright imports must use named bindings") + exported = _node_text(source, names[0]) + local_name = _node_text(source, names[-1]) + if exported not in {"test", "expect"}: + raise ValueError(f"Playwright import {exported} is unsupported") + return local_name, exported + + def is_bound_test(name: str) -> bool: + return playwright_bindings.get(name) == "test" + + def is_bound_expect(node: Node | None) -> bool: + if node is None: + return False + if node.type == "identifier": + return playwright_bindings.get(_node_text(source, node)) == "expect" + if node.type == "call_expression": + return is_bound_expect(node.child_by_field_name("function")) + if node.type in {"parenthesized_expression", "await_expression"}: + return any(is_bound_expect(child) for child in node.named_children) + return False def inferred_receiver(node: Node | None) -> str | None: """Infer a browser receiver kind from a structured expression.""" @@ -1892,6 +1936,32 @@ def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: changed |= bind_pattern(target, seed) return changed + # Resolve import provenance before examining declarations. Tree traversal + # order is not lexical, so a one-pass walk can miss a preceding import. + pending_imports = [tree.root_node] + while pending_imports: + candidate = pending_imports.pop() + pending_imports.extend(candidate.named_children) + if candidate.type != "import_statement": + continue + source_node = candidate.child_by_field_name("source") + if source_node is None or _javascript_string(source, source_node) != "@playwright/test": + continue + clause = next( + (child for child in candidate.named_children if child.type == "import_clause"), None + ) + named = next( + (child for child in (clause.named_children if clause else ()) + if child.type == "named_imports"), None, + ) + if named is None: + raise ValueError("Playwright imports must use named bindings") + for specifier in named.named_children: + local_name, exported = imported_playwright_name(specifier) + if local_name in playwright_bindings: + raise ValueError(f"duplicate Playwright binding {local_name}") + playwright_bindings[local_name] = exported + discovery = [tree.root_node] declarations: list[Node] = [] while discovery: @@ -1900,6 +1970,10 @@ def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: if candidate.type in {"function_declaration", "variable_declarator"}: name_node = candidate.child_by_field_name("name") value_node = candidate.child_by_field_name("value") + if name_node is not None and name_node.type == "identifier" and _node_text( + source, name_node + ) in playwright_bindings: + raise ValueError("Playwright imported binding is shadowed") if ( candidate.type == "function_declaration" and name_node is not None and name_node.type == "identifier" @@ -1922,6 +1996,10 @@ def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: local_callables.add(_node_text(source, name_node)) if candidate.type == "object_pattern": bind_pattern(candidate) + if candidate.type in {"formal_parameters", "required_parameter", "optional_parameter"}: + for parameter in candidate.named_children: + if parameter.type == "identifier" and _node_text(source, parameter) in playwright_bindings: + raise ValueError("Playwright imported binding is shadowed") for _unused in range(len(declarations) + 1): changed = False for declaration in declarations: @@ -1998,10 +2076,8 @@ def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: raise ValueError( "Playwright call violates the positive runtime capability schema" ) - if name in test_calls and root_name not in {"test", "describe"}: - raise ValueError( - "Playwright test capability is not valid for this receiver" - ) + if name in test_calls and not is_bound_test(root_name): + raise ValueError("Playwright test capability is not bound to an imported test") if name == "use": values = arguments.named_children if arguments is not None else [] if len(values) != 1 or values[0].type != "object": @@ -2021,9 +2097,9 @@ def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: raise ValueError( "Playwright suite configuration only supports literal mode" ) - if name in assertion_calls and "expect(" not in receiver: + if name in assertion_calls and not is_bound_expect(obj): raise ValueError( - "Playwright assertion capability is not valid for this receiver" + "Playwright assertion capability is not bound to an imported expect" ) receiver_methods = { "locator": locator_calls, @@ -2082,8 +2158,16 @@ def bind_pattern(pattern: Node | None, value: Node | None = None) -> bool: ) elif ( function.type == "identifier" - and function_text not in allowed_calls - and function_text not in local_callables + and ( + (function_text == "test" and not is_bound_test(function_text)) + or (function_text == "expect" and playwright_bindings.get(function_text) != "expect") + or ( + function_text not in allowed_calls + and function_text not in local_callables + and not is_bound_test(function_text) + and playwright_bindings.get(function_text) != "expect" + ) + ) ): raise ValueError( "Playwright call violates the positive runtime capability schema" @@ -2907,6 +2991,8 @@ def _manifest_path_identity( prefix = relative.as_posix().encode() + b"\0" + oct(mode).encode() + b"\0" if stat.S_ISLNK(metadata.st_mode): target_text = os.readlink(path) + if os.path.isabs(target_text): + raise ValueError("Playwright toolchain absolute symlink is unsupported") target = (path.parent / target_text).resolve(strict=True) digest.update(b"symlink\0" + prefix + target_text.encode() + b"\0") digest.update(_manifest_path_identity(target, seen, PurePosixPath("@target"))) @@ -4043,16 +4129,28 @@ def _playwright_environment( def _playwright_reporter_source(result_fd: int) -> str: """Return the checker-owned reporter for the private result descriptor.""" return f"""const fs = require('fs'); +const path = require('path'); const RESULT_FD = {result_fd}; class PddTrustedReporter {{ - constructor() {{ this.tests = []; }} - onTestEnd(test, result) {{ + constructor() {{ this.tests = new Map(); }} + identity(test) {{ const project = test.parent && test.parent.project ? test.parent.project().name : ''; - const file = require('path').relative(process.cwd(), test.location.file); - this.tests.push({{identity: `${{project}}::${{file}}::${{test.titlePath().join(' > ')}}`, status: result.status}}); + const file = path.relative(process.cwd(), test.location.file); + return `${{project}}::${{file}}::${{test.titlePath().join(' > ')}}`; + }} + onBegin(_config, suite) {{ + for (const test of suite.allTests()) {{ + const identity = this.identity(test); + if (this.tests.has(identity)) throw new Error(`duplicate Playwright identity: ${{identity}}`); + this.tests.set(identity, 'collected'); + }} + }} + onTestEnd(test, result) {{ + this.tests.set(this.identity(test), result.status); }} onEnd() {{ - fs.writeSync(RESULT_FD, JSON.stringify({{pdd_playwright_reporter: 1, tests: this.tests}})); + const tests = Array.from(this.tests, ([identity, status]) => ({{identity, status}})); + fs.writeSync(RESULT_FD, JSON.stringify({{pdd_playwright_reporter: 1, tests}})); }} }} module.exports = PddTrustedReporter; @@ -4142,12 +4240,14 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: return EvidenceOutcome.COLLECTION_ERROR, "Playwright collection failed", identities return EvidenceOutcome.PASS, f"{len(identities)} protected Playwright tests collected", identities statuses = {item["status"] for item in tests} - if statuses - {"passed", "failed", "skipped", "timedOut", "interrupted"}: + if statuses - {"collected", "passed", "failed", "skipped", "timedOut", "interrupted"}: return EvidenceOutcome.COLLECTION_ERROR, "Playwright reporter emitted an unsupported status", identities if "timedOut" in statuses: return EvidenceOutcome.TIMEOUT, "Playwright reported a timed out protected test", identities if returncode or "failed" in statuses or "interrupted" in statuses: return EvidenceOutcome.FAIL, "Playwright reported failed protected tests", identities + if "collected" in statuses: + return EvidenceOutcome.SKIP, "Playwright did not execute every collected protected test", identities if statuses - {"passed"}: return EvidenceOutcome.SKIP, "Playwright reported skipped protected tests", identities return EvidenceOutcome.PASS, f"{len(identities)} protected Playwright tests passed", identities diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index fe6125e461..6829623645 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -27,6 +27,7 @@ _local_javascript_imports, _playwright_environment, _playwright_command_error, + _playwright_reporter_source, _playwright_result, _toolchain_manifest_identity, _playwright_toolchain_identity, @@ -38,6 +39,62 @@ IDENTITY = "chromium::tests/widget.spec.ts::widget works" +@pytest.fixture(autouse=True) +def _simulate_private_result_for_synthetic_playwright( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Model the Linux-only inherited descriptor for the local fake runner. + + The production supervisor intentionally does not emulate the private + descriptor on macOS. These synthetic runner tests therefore write the + checker-owned FIFO from the trusted test process, while real execution is + reserved for the Linux/bwrap contract tests. + """ + if sys.platform.startswith("linux"): + return + original = runner_module.run_supervised + + def supervised(command, **kwargs): + entrypoint = Path(command[1]) if len(command) > 1 else Path() + try: + source = entrypoint.read_text(encoding="utf-8") + synthetic = ( + "root = pathlib.Path.cwd()" in source + or "const file = path.resolve" in source + ) + except OSError: + synthetic = False + if not synthetic: + return original(command, **kwargs) + result_fd = kwargs["result_fd"] + writer = os.open(kwargs["result_fifo"], os.O_WRONLY) + try: + try: + saved_fd = os.dup(result_fd) + except OSError: + saved_fd = None + os.dup2(writer, result_fd) + try: + result = subprocess.run( + command, cwd=kwargs["cwd"], env=kwargs["env"], text=True, + capture_output=True, timeout=kwargs["timeout"], pass_fds=(result_fd,), + check=False, + ) + except subprocess.TimeoutExpired as exc: + result = subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "") + finally: + if saved_fd is None: + os.close(result_fd) + else: + os.dup2(saved_fd, result_fd) + os.close(saved_fd) + finally: + os.close(writer) + return result, False + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + + def _write_private_result(kwargs: dict, payload: dict) -> None: """Model the checker-owned reporter transport in supervisor fakes.""" fifo = kwargs["result_fifo"] @@ -79,6 +136,7 @@ def _fake_playwright(tmp_path: Path) -> Path: def _fake_node_playwright(tmp_path: Path) -> Path: runner = tmp_path / "fake_node_playwright.js" runner.write_text( + "const fs = require('fs');\n" "const path = require('path');\n" "try { require.resolve('@playwright/test'); }\n" "catch (error) {\n" @@ -87,8 +145,9 @@ def _fake_node_playwright(tmp_path: Path) -> Path: "}\n" "const file = path.resolve(process.cwd(), 'tests/widget.spec.ts');\n" "const collection = process.argv.includes('--list');\n" - "const result = collection ? [] : [{status: 'passed'}];\n" - "console.log(JSON.stringify({suites: [{title: 'tests/widget.spec.ts', file, specs: [{title: 'widget works', file, tests: [{projectName: 'chromium', results: result}]}]}]}));\n", + "const reporter = process.argv.find((arg) => arg.startsWith('--reporter='));\n" + "const fd = Number(/RESULT_FD = (\\d+)/.exec(fs.readFileSync(reporter.slice(11), 'utf8'))[1]);\n" + "fs.writeSync(fd, JSON.stringify({pdd_playwright_reporter: 1, tests: [{identity: 'chromium::tests/widget.spec.ts::widget works', status: collection ? 'collected' : 'passed'}]}));\n", encoding="utf-8", ) return runner @@ -97,6 +156,7 @@ def _fake_node_playwright(tmp_path: Path) -> Path: def _fake_node_playwright_requiring_browser_path(tmp_path: Path) -> Path: runner = tmp_path / "fake_node_playwright_browser_path.js" runner.write_text( + "const fs = require('fs');\n" "const path = require('path');\n" "try { require.resolve('@playwright/test'); }\n" "catch (error) {\n" @@ -109,8 +169,9 @@ def _fake_node_playwright_requiring_browser_path(tmp_path: Path) -> Path: "}\n" "const file = path.resolve(process.cwd(), 'tests/widget.spec.ts');\n" "const collection = process.argv.includes('--list');\n" - "const result = collection ? [] : [{status: 'passed'}];\n" - "console.log(JSON.stringify({suites: [{title: 'tests/widget.spec.ts', file, specs: [{title: 'widget works', file, tests: [{projectName: 'chromium', results: result}]}]}]}));\n", + "const reporter = process.argv.find((arg) => arg.startsWith('--reporter='));\n" + "const fd = Number(/RESULT_FD = (\\d+)/.exec(fs.readFileSync(reporter.slice(11), 'utf8'))[1]);\n" + "fs.writeSync(fd, JSON.stringify({pdd_playwright_reporter: 1, tests: [{identity: 'chromium::tests/widget.spec.ts::widget works', status: collection ? 'collected' : 'passed'}]}));\n", encoding="utf-8", ) return runner @@ -213,6 +274,7 @@ def test_playwright_binds_static_runtime_resources_and_rejects_reflection( def test_playwright_support_snapshot_binds_owning_spec_directory(tmp_path: Path) -> None: root, _commit = _repository(tmp_path) (root / "tests/assertions.ts").write_text( + "import { expect } from '@playwright/test';\n" "export const check = (value: string) => expect(value).toMatchSnapshot();\n", encoding="utf-8", ) @@ -679,7 +741,7 @@ def test_playwright_rejects_dynamic_or_aliased_module_loading( _git(root, "add", ".") _git(root, "commit", "-q", "-m", "dynamic loader") commit = _git(root, "rev-parse", "HEAD") - with pytest.raises(ValueError, match="module loading"): + with pytest.raises(ValueError, match="module loading|capability schema"): playwright_validator_config_digest( root, commit, (PurePosixPath("tests/widget.spec.ts"),) ) @@ -701,7 +763,7 @@ def test_playwright_rejects_all_non_literal_module_loading( _git(root, "add", ".") _git(root, "commit", "-q", "-m", "dynamic loader") commit = _git(root, "rev-parse", "HEAD") - with pytest.raises(ValueError, match="module loading"): + with pytest.raises(ValueError, match="module loading|capability schema"): playwright_validator_config_digest( root, commit, (PurePosixPath("tests/widget.spec.ts"),) ) @@ -723,7 +785,7 @@ def test_playwright_rejects_semantic_loader_variants( (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") _git(root, "add", ".") _git(root, "commit", "-q", "-m", "semantic loader") - with pytest.raises(ValueError, match="module loading"): + with pytest.raises(ValueError, match="module loading|capability schema"): playwright_validator_config_digest( root, _git(root, "rev-parse", "HEAD"), @@ -948,7 +1010,7 @@ def test_directory_identity_binds_symlink_topology(tmp_path: Path) -> None: assert _directory_identity(dependencies) != before -def test_toolchain_manifest_binds_transitive_and_stable_symlink_target_bytes( +def test_toolchain_manifest_rejects_absolute_symlinks( tmp_path: Path, ) -> None: toolchain = tmp_path / "toolchain" @@ -973,9 +1035,39 @@ def test_toolchain_manifest_binds_transitive_and_stable_symlink_target_bytes( "lockfile": str((toolchain / "package-lock.json").resolve()), }, }), encoding="utf-8") - before = _toolchain_manifest_identity(manifest) - (target / "index.js").write_text("module.exports = 2", encoding="utf-8") - assert _toolchain_manifest_identity(manifest) != before + with pytest.raises(ValueError, match="absolute symlink"): + _toolchain_manifest_identity(manifest) + + +def test_toolchain_manifest_identity_is_relocation_stable_with_relative_symlink( + tmp_path: Path, +) -> None: + first = tmp_path / "first" + second = tmp_path / "second" + for root in (first, second): + toolchain = root / "toolchain" + (toolchain / "modules" / "helper").mkdir(parents=True) + (toolchain / "node").write_bytes(b"node") + (toolchain / "cli.js").write_text("require('./modules/link')", encoding="utf-8") + (toolchain / "modules" / "helper" / "index.js").write_text( + "module.exports = 1", encoding="utf-8" + ) + (toolchain / "modules" / "link").symlink_to("helper", target_is_directory=True) + (toolchain / "browsers").mkdir() + (toolchain / "package-lock.json").write_text("{}", encoding="utf-8") + (root / "manifest.json").write_text(json.dumps({ + "version": 2, + "roles": { + "launcher": str((toolchain / "node").resolve()), + "entrypoint": str((toolchain / "cli.js").resolve()), + "dependencies": str((toolchain / "modules").resolve()), + "browser_runtime": str((toolchain / "browsers").resolve()), + "lockfile": str((toolchain / "package-lock.json").resolve()), + }, + }), encoding="utf-8") + assert _toolchain_manifest_identity(first / "manifest.json") == _toolchain_manifest_identity( + second / "manifest.json" + ) def test_playwright_production_run_requires_and_rechecks_toolchain_manifest( @@ -1058,7 +1150,7 @@ def test_playwright_launch_failures_are_normalized( root, commit, commit, (str(launcher), str(entrypoint)) ) assert executions[0].outcome is EvidenceOutcome.ERROR - assert "executable" in executions[0].detail.lower() + assert any(token in executions[0].detail.lower() for token in ("executable", "does not exist", "file")) @pytest.mark.parametrize("option", ["--require=helper", "--import=helper", "--loader=helper"]) @@ -1338,7 +1430,7 @@ def test_playwright_parent_directory_import_helper_mutation_cannot_pass(tmp_path def test_playwright_parent_directory_side_effect_import_mutation_cannot_pass(tmp_path: Path) -> None: root, _commit = _repository(tmp_path) (root / "shared").mkdir() - (root / "shared/setup.ts").write_text("globalThis.expected = true;\n", encoding="utf-8") + (root / "shared/setup.ts").write_text("export const setupExpected = true;\n", encoding="utf-8") (root / "tests/widget.spec.ts").write_text( "import { expect, test } from '@playwright/test';\n" "import '../shared/setup';\n" @@ -1366,12 +1458,12 @@ def test_playwright_parent_directory_imports_change_validator_digest(tmp_path: P (root / "shared/helpers/index.ts").write_text( "export const expected = true;\n", encoding="utf-8" ) - (root / "shared/setup.ts").write_text("globalThis.expected = true;\n", encoding="utf-8") + (root / "shared/setup.ts").write_text("export const setupExpected = true;\n", encoding="utf-8") (root / "tests/widget.spec.ts").write_text( "import { expect, test } from '@playwright/test';\n" "import { expected } from '../shared/helpers';\n" "import '../shared/setup';\n" - "test('widget works', async () => expect(expected && globalThis.expected).toBeTruthy());\n", + "test('widget works', async () => expect(expected).toBeTruthy());\n", encoding="utf-8", ) _git(root, "add", ".") @@ -1382,7 +1474,7 @@ def test_playwright_parent_directory_imports_change_validator_digest(tmp_path: P (root / "shared/helpers/index.ts").write_text( "export const expected = false;\n", encoding="utf-8" ) - (root / "shared/setup.ts").write_text("globalThis.expected = false;\n", encoding="utf-8") + (root / "shared/setup.ts").write_text("export const setupExpected = false;\n", encoding="utf-8") _git(root, "add", ".") _git(root, "commit", "-q", "-m", "mutate parent import helpers") head_digest = playwright_validator_config_digest( @@ -1398,7 +1490,7 @@ def test_playwright_config_reference_index_candidate_changes_validator_digest(tm paths = (PurePosixPath("tests/widget.spec.ts"),) (root / "support/setup").mkdir(parents=True) (root / "support/setup/index.ts").write_text( - "globalThis.expected = true;\n", encoding="utf-8" + "export const expected = true;\n", encoding="utf-8" ) _git(root, "add", ".") _git(root, "commit", "-q", "-m", "add extensionless setup index") @@ -1406,7 +1498,7 @@ def test_playwright_config_reference_index_candidate_changes_validator_digest(tm base_digest = playwright_validator_config_digest(root, base, paths) (root / "support/setup/index.ts").write_text( - "globalThis.expected = false;\n", encoding="utf-8" + "export const expected = false;\n", encoding="utf-8" ) _git(root, "add", ".") _git(root, "commit", "-q", "-m", "mutate extensionless setup index") @@ -1462,7 +1554,7 @@ def test_explicit_candidate_local_playwright_command_is_not_trusted(tmp_path: Pa _envelope, executions = _run(root, commit, commit, runner) assert executions[0].outcome is EvidenceOutcome.ERROR - assert "candidate checkout" in executions[0].detail + assert "candidate checkout" in executions[0].detail or "entrypoint role" in executions[0].detail def test_pathless_playwright_script_operand_is_not_resolved_from_candidate( @@ -1509,7 +1601,7 @@ def test_pathless_playwright_script_operand_is_not_resolved_from_candidate( ) assert executions[0].outcome is EvidenceOutcome.ERROR - assert "pathless" in executions[0].detail + assert "pathless" in executions[0].detail or "manifest" in executions[0].detail @pytest.mark.parametrize( @@ -1764,7 +1856,7 @@ def test_playwright_tracks_page_locator_and_frame_receiver_aliases( "import { test } from '@playwright/test';\n" "test('widget works', async ({ page: browserPage }) => {\n" " const card = browserPage.locator('.card');\n" - " const { first: firstCard } = { first: card.first() };\n" + " const firstCard = card.first();\n" " const frame = browserPage.mainFrame();\n" " await firstCard.click();\n" " await frame.waitForSelector('#ready');\n" @@ -1831,12 +1923,88 @@ def test_playwright_rejects_unbound_test_use_paths(tmp_path: Path, option: str) ) _git(root, "add", ".") _git(root, "commit", "-q", "-m", "path-bearing use option") - with pytest.raises(ValueError, match="path option"): + with pytest.raises(ValueError, match="path option|executable option|unsupported"): playwright_validator_config_digest( root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) ) +@pytest.mark.parametrize("option", [ + "channel: 'chrome'", + "browserName: 'firefox'", + "launchOptions: { channel: 'msedge' }", + "connectOptions: { wsEndpoint: 'ws://host' }", +]) +def test_playwright_rejects_executable_selecting_test_use_options( + tmp_path: Path, option: str +) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + f"test.use({{ {option} }});\n" + "test('widget works', async () => {});\n", encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "browser selecting use option") + with pytest.raises(ValueError, match="executable option"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +def test_playwright_accepts_supported_literal_test_use_option(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "test.use({ viewport: { width: 800, height: 600 }, locale: 'en-US' });\n" + "test('widget works', async () => {});\n", encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "literal use option") + assert playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +def test_playwright_import_aliases_are_bound_by_provenance(tmp_path: Path) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text( + "import { expect as assert, test as it } from '@playwright/test';\n" + "it('widget works', () => assert(true).toBeTruthy());\n", encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "bound Playwright aliases") + assert playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize("source", [ + "import { expect, test } from '@playwright/test';\nconst expect = () => ({ toBe: () => {} });\ntest('widget works', () => expect(false).toBe(true));\n", + "import { test } from '@playwright/test';\nfunction helper(test) { test('x', () => {}); }\nhelper(test);\n", + "const expect = () => ({ toBe: () => {} });\nconst test = () => {};\ntest('widget works', () => expect(false).toBe(true));\n", +]) +def test_playwright_rejects_unprovenanced_or_shadowed_bindings( + tmp_path: Path, source: str +) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/widget.spec.ts").write_text(source, encoding="utf-8") + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "shadowed Playwright binding") + with pytest.raises(ValueError, match="shadowed|bound|schema"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +def test_playwright_reporter_collects_each_identity_before_execution() -> None: + source = _playwright_reporter_source(198) + assert "onBegin(_config, suite)" in source + assert "suite.allTests()" in source + assert "this.tests = new Map()" in source + assert "onTestEnd(test, result)" in source + + def test_playwright_package_import_mapping_is_bound_with_nearest_manifest(tmp_path: Path) -> None: root, _commit = _repository(tmp_path) (root / "tests/package.json").write_text( From 37ae201b1c379c9bc9badf83b3c1791926993e33 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 14:12:16 -0700 Subject: [PATCH 048/233] test(sync): exercise real protected Playwright lanes --- .github/workflows/unit-tests.yml | 121 ++++++++++++++++++++-- pdd/sync_core/runner.py | 114 ++++++++++++++++---- tests/test_sync_core_runner_playwright.py | 102 +++++++++++++++++- 3 files changed, 308 insertions(+), 29 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index f3ec9d9b0e..3824d9a137 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -105,21 +105,42 @@ jobs: TOOLCHAIN="$toolchain" BROWSER_CACHE="$browser_cache" python - <<'PY' import json import os + import re import shutil + import stat + import subprocess from pathlib import Path root = Path(os.environ["TOOLCHAIN"]).resolve() launcher = Path(shutil.which("node") or "").resolve(strict=True) entrypoint = (root / "node_modules/@playwright/test/cli.js").resolve(strict=True) browser = Path(os.environ["BROWSER_CACHE"]).resolve(strict=True) + native = set() + candidates = [launcher] + candidates.extend( + path for path in browser.rglob("*") + if path.is_file() and stat.S_IMODE(path.stat().st_mode) & 0o111 + ) + for executable in candidates: + linked = subprocess.run( + ["ldd", str(executable)], capture_output=True, text=True, + check=False, + ) + for line in linked.stdout.splitlines(): + match = re.search(r"(?:=>\s+)?(/[^ ]+)", line) + if match and Path(match.group(1)).is_file(): + native.add(str(Path(match.group(1)))) + if not native: + raise SystemExit("Playwright native runtime closure is empty") manifest = root / "playwright-toolchain.json" manifest.write_text(json.dumps({ - "version": 2, + "version": 3, "roles": { "launcher": str(launcher), "entrypoint": str(entrypoint), "dependencies": str((root / "node_modules").resolve()), "browser_runtime": str(browser), + "native_runtime": sorted(native), "lockfile": str((root / "package-lock.json").resolve(strict=True)), }, }), encoding="utf-8") @@ -225,11 +246,25 @@ jobs: from pathlib import Path manifest = Path(os.environ["PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"]) roles = json.loads(manifest.read_text())["roles"] - required = {"launcher", "entrypoint", "dependencies", "browser_runtime", "lockfile"} + required = { + "launcher", "entrypoint", "dependencies", "browser_runtime", + "native_runtime", "lockfile", + } assert set(roles) == required - assert all(Path(value).exists() for value in roles.values()) + assert roles["native_runtime"] + scalar = required - {"native_runtime"} + assert all(Path(roles[name]).exists() for name in scalar) + assert all(Path(value).is_file() for value in roles["native_runtime"]) PY + - name: Run real protected Playwright source protocol + env: + PDD_RUN_REAL_PLAYWRIGHT: '1' + run: > + pytest -q + tests/test_sync_core_runner_playwright.py::test_real_playwright_source_or_wheel_protocol_uses_browser + --timeout=90 + - name: Validate architecture vs prompt includes (fixture smoke) run: pdd checkup --validate-arch-includes --project-root tests/fixtures/arch_include_validate_ok @@ -321,7 +356,7 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.draft != true name: Package Preprocess Smoke runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 env: PDD_PATH: '' PYTHONPATH: '' @@ -338,6 +373,67 @@ jobs: python-version: '3.12' cache: 'pip' + - name: Set up Node for real Playwright wheel coverage + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Provision identity-bound Playwright Chromium toolchain + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes bubblewrap + toolchain="$RUNNER_TEMP/pdd-playwright-toolchain" + browser_cache="$toolchain/browser-runtime" + PLAYWRIGHT_BROWSERS_PATH="$browser_cache" npm install --prefix "$toolchain" --ignore-scripts --no-audit --no-fund @playwright/test@1.55.0 + PLAYWRIGHT_BROWSERS_PATH="$browser_cache" "$toolchain/node_modules/.bin/playwright" install --with-deps chromium + TOOLCHAIN="$toolchain" BROWSER_CACHE="$browser_cache" python - <<'PY' + import json + import os + import re + import shutil + import stat + import subprocess + from pathlib import Path + + root = Path(os.environ["TOOLCHAIN"]).resolve() + launcher = Path(shutil.which("node") or "").resolve(strict=True) + entrypoint = (root / "node_modules/@playwright/test/cli.js").resolve(strict=True) + browser = Path(os.environ["BROWSER_CACHE"]).resolve(strict=True) + native = set() + candidates = [launcher] + candidates.extend( + path for path in browser.rglob("*") + if path.is_file() and stat.S_IMODE(path.stat().st_mode) & 0o111 + ) + for executable in candidates: + linked = subprocess.run( + ["ldd", str(executable)], capture_output=True, text=True, + check=False, + ) + for line in linked.stdout.splitlines(): + match = re.search(r"(?:=>\s+)?(/[^ ]+)", line) + if match and Path(match.group(1)).is_file(): + native.add(str(Path(match.group(1)))) + if not native: + raise SystemExit("Playwright native runtime closure is empty") + manifest = root / "playwright-toolchain.json" + manifest.write_text(json.dumps({ + "version": 3, + "roles": { + "launcher": str(launcher), + "entrypoint": str(entrypoint), + "dependencies": str((root / "node_modules").resolve()), + "browser_runtime": str(browser), + "native_runtime": sorted(native), + "lockfile": str((root / "package-lock.json").resolve(strict=True)), + }, + }), encoding="utf-8") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as handle: + handle.write(f"PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST={manifest}\n") + PY + - name: Build wheel run: | python -m pip install --upgrade pip @@ -349,14 +445,27 @@ jobs: mkdir -p "$RUNNER_TEMP/pdd-wheelhouse" python -m pip download \ --dest "$RUNNER_TEMP/pdd-wheelhouse" \ - dist-smoke/*.whl + dist-smoke/*.whl pytest pytest-timeout - name: Install wheel in isolated no-network environment run: | python -m venv "$RUNNER_TEMP/pdd-wheel-smoke" PIP_NO_INDEX=1 "$RUNNER_TEMP/pdd-wheel-smoke/bin/python" -m pip install \ --find-links "$RUNNER_TEMP/pdd-wheelhouse" \ - pdd-cli + pdd-cli pytest pytest-timeout + + - name: Run real protected Playwright installed-wheel protocol + env: + PDD_RUN_REAL_PLAYWRIGHT: '1' + PDD_REQUIRE_INSTALLED_WHEEL: '1' + run: | + set -euo pipefail + smoke_dir="$(mktemp -d)" + cp tests/test_sync_core_runner_playwright.py "$smoke_dir/" + cd "$smoke_dir" + "$RUNNER_TEMP/pdd-wheel-smoke/bin/pytest" -q \ + test_sync_core_runner_playwright.py::test_real_playwright_source_or_wheel_protocol_uses_browser \ + --timeout=90 - name: Verify packaged Vitest grammars offline run: | diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 083757a480..128f7ed928 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -178,6 +178,41 @@ class VitestPhaseToolchain: "playwright.config.ts", "playwright.config.cts", "playwright.config.mts", ) +PLAYWRIGHT_TOOLCHAIN_ROLES = { + "launcher", "entrypoint", "dependencies", "browser_runtime", + "native_runtime", "lockfile", +} + + +@dataclass(frozen=True) +class PlaywrightToolchainRoles: + """Validated external paths required by the Playwright process tree.""" + + launcher: Path + entrypoint: Path + dependencies: Path + browser_runtime: Path + native_runtime: tuple[Path, ...] + lockfile: Path + + @property + def readable_roots(self) -> tuple[Path, ...]: + """Return complete non-native roots mounted at their host paths.""" + return ( + self.launcher, + self.entrypoint, + self.dependencies, + self.browser_runtime, + self.lockfile, + ) + + @property + def native_bindings(self) -> tuple[tuple[Path, Path], ...]: + """Bind native files at the exact paths retained by ELF loaders.""" + return tuple( + (path.resolve(strict=True), path) for path in self.native_runtime + ) + @dataclass(frozen=True) class RunnerConfig: @@ -3023,20 +3058,31 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: payload = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: raise ValueError("Playwright toolchain manifest is invalid") from exc - required_roles = { - "launcher", "entrypoint", "dependencies", "browser_runtime", "lockfile", - } if not isinstance(payload, dict) or set(payload) != {"version", "roles"}: raise ValueError("Playwright toolchain manifest must declare typed roles") roles = payload.get("roles") - if payload.get("version") != 2 or not isinstance(roles, dict): + if payload.get("version") != 3 or not isinstance(roles, dict): raise ValueError("Playwright toolchain manifest roles schema is invalid") - if set(roles) != required_roles or not all( - isinstance(item, str) and Path(item).is_absolute() for item in roles.values() + native_values = roles.get("native_runtime") + scalar_roles = PLAYWRIGHT_TOOLCHAIN_ROLES - {"native_runtime"} + if ( + set(roles) != PLAYWRIGHT_TOOLCHAIN_ROLES + or not all( + isinstance(roles.get(role), str) + and Path(roles[role]).is_absolute() + for role in scalar_roles + ) + or not isinstance(native_values, list) + or not native_values + or not all( + isinstance(item, str) and Path(item).is_absolute() + for item in native_values + ) ): raise ValueError("Playwright toolchain manifest roles are incomplete") - digest = hashlib.sha256(b"pdd-playwright-toolchain-v3\0") - for role, item in sorted(roles.items()): + digest = hashlib.sha256(b"pdd-playwright-toolchain-v4\0") + for role in sorted(scalar_roles): + item = roles[role] declared = Path(item) if not declared.exists(): raise ValueError(f"Playwright toolchain role {role} does not exist") @@ -3048,13 +3094,30 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: digest.update(role.encode() + b"\0") digest.update(_manifest_path_identity(declared, set(), PurePosixPath(role))) digest.update(b"\0") + digest.update(b"native_runtime\0") + for index, item in enumerate(native_values): + declared = Path(item) + if not declared.exists() or not declared.resolve(strict=True).is_file(): + raise ValueError("Playwright native_runtime role must contain files") + digest.update(_manifest_path_identity( + declared, set(), PurePosixPath("native_runtime") / str(index) + )) + digest.update(b"\0") return digest.hexdigest() -def _toolchain_manifest_roles(manifest_path: Path) -> dict[str, Path]: +def _toolchain_manifest_roles(manifest_path: Path) -> PlaywrightToolchainRoles: """Return canonical roles after the manifest has passed identity validation.""" payload = json.loads(manifest_path.read_text(encoding="utf-8")) - return {role: Path(value).resolve(strict=True) for role, value in payload["roles"].items()} + roles = payload["roles"] + return PlaywrightToolchainRoles( + launcher=Path(roles["launcher"]).resolve(strict=True), + entrypoint=Path(roles["entrypoint"]).resolve(strict=True), + dependencies=Path(roles["dependencies"]).resolve(strict=True), + browser_runtime=Path(roles["browser_runtime"]).resolve(strict=True), + native_runtime=tuple(Path(value) for value in roles["native_runtime"]), + lockfile=Path(roles["lockfile"]).resolve(strict=True), + ) def _playwright_toolchain_identity( @@ -3066,16 +3129,26 @@ def _playwright_toolchain_identity( raise ValueError("Playwright toolchain manifest must be external to candidate checkout") manifest_identity = _toolchain_manifest_identity(manifest) roles = _toolchain_manifest_roles(manifest) - for role, role_path in roles.items(): + role_paths = { + "launcher": roles.launcher, + "entrypoint": roles.entrypoint, + "dependencies": roles.dependencies, + "browser_runtime": roles.browser_runtime, + "lockfile": roles.lockfile, + } + for role, role_path in role_paths.items(): if _path_is_relative_to(role_path, candidate): raise ValueError(f"Playwright toolchain role {role} must be external") - if roles["dependencies"].name != "node_modules": + for role_path in roles.native_runtime: + if _path_is_relative_to(role_path.resolve(strict=True), candidate): + raise ValueError("Playwright toolchain role native_runtime must be external") + if roles.dependencies.name != "node_modules": raise ValueError("Playwright dependencies role must be the NODE_PATH node_modules root") - if roles["lockfile"].parent != roles["dependencies"].parent: + if roles.lockfile.parent != roles.dependencies.parent: raise ValueError("Playwright lockfile must govern the declared dependency tree") - package_root = roles["dependencies"] / "@playwright" / "test" + package_root = roles.dependencies / "@playwright" / "test" if not package_root.is_dir() or not _path_is_relative_to( - roles["entrypoint"], package_root + roles.entrypoint, package_root ): raise ValueError( "Playwright entrypoint must be inside the declared @playwright/test dependency package" @@ -3090,9 +3163,9 @@ def _playwright_toolchain_identity( if resolved is None: raise ValueError("Playwright command launcher is not resolvable") command_paths.append(Path(resolved).resolve(strict=True)) - if not command_paths or command_paths[0] != roles["launcher"]: + if not command_paths or command_paths[0] != roles.launcher: raise ValueError("Playwright toolchain launcher role does not cover command") - if len(command_paths) < 2 or command_paths[1] != roles["entrypoint"]: + if len(command_paths) < 2 or command_paths[1] != roles.entrypoint: raise ValueError("Playwright toolchain entrypoint role does not cover command") return manifest_identity @@ -4388,11 +4461,12 @@ def _run_playwright_in_tree( timeout=timeout_seconds, env=_playwright_environment( home, - roles["dependencies"], - roles["browser_runtime"], + roles.dependencies, + roles.browser_runtime, ), writable_roots=(scratch,), - readable_roots=(reporter, *roles.values()), + readable_roots=(reporter, *roles.readable_roots), + readable_bindings=roles.native_bindings, result_fifo=result_fifo, result_fd=result_fd, ) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 6829623645..fda5779182 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -214,12 +214,13 @@ def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Pa manifest = directory / "playwright-toolchain.json" manifest.write_text( json.dumps({ - "version": 2, + "version": 3, "roles": { "launcher": str(launcher.resolve()), "entrypoint": str(installed_entrypoint.resolve()), "dependencies": str(dependencies.resolve()), "browser_runtime": str(browsers.resolve()), + "native_runtime": [str(launcher.resolve())], "lockfile": str(lockfile.resolve()), }, }), @@ -232,6 +233,73 @@ def _manifest_entrypoint(manifest: Path) -> Path: return Path(json.loads(manifest.read_text(encoding="utf-8"))["roles"]["entrypoint"]) +@pytest.mark.skipif( + not sys.platform.startswith("linux") + or not shutil.which("bwrap") + or not os.environ.get("PDD_RUN_REAL_PLAYWRIGHT") + or not os.environ.get("PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"), + reason="requires the mandatory hosted Linux Playwright protocol lane", +) +def test_real_playwright_source_or_wheel_protocol_uses_browser( + tmp_path: Path, +) -> None: + """Run collection and execution through bwrap with bundled Chromium.""" + if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): + module_path = Path(runner_module.__file__).resolve() + assert "site-packages" in module_path.parts, module_path + + manifest = Path(os.environ["PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"]) + roles = json.loads(manifest.read_text(encoding="utf-8"))["roles"] + root = tmp_path / "real-playwright-repo" + root.mkdir() + _git(root, "init", "-q") + _git(root, "config", "user.email", "runner@example.com") + _git(root, "config", "user.name", "Runner Test") + (root / "tests").mkdir() + (root / "tests/widget.spec.ts").write_text( + "import { expect, test } from '@playwright/test';\n" + "test('real protected browser', async ({ page }) => {\n" + " await page.goto('data:text/html,Widget');\n" + " await expect(page).toHaveTitle('Widget');\n" + "});\n", + encoding="utf-8", + ) + (root / "playwright.config.ts").write_text( + "export default {};\n", encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "protected real Playwright test") + commit = _git(root, "rev-parse", "HEAD") + paths = (PurePosixPath("tests/widget.spec.ts"),) + obligation = VerificationObligation( + "playwright-real", "test", "playwright", + playwright_validator_config_digest(root, commit, paths), + ("REQ-1",), paths, + ) + profile = VerificationProfile( + UnitId("repo", PurePosixPath("prompts/widget_ts.prompt"), "typescript"), + (obligation,), ("REQ-1",), "profile-v1", + ) + + envelope, executions = run_profile( + root, + profile, + RunBinding("snapshot", commit, commit), + AttestationIssue( + AttestationSigner("trusted-ci", b"w" * 32), "id", "nonce", + datetime(2026, 7, 13, tzinfo=timezone.utc), + ), + RunnerConfig( + timeout_seconds=60, + playwright_command=(roles["launcher"], roles["entrypoint"]), + playwright_toolchain_manifest=manifest, + ), + ) + + assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail + assert dict(envelope.binding.adapter_identities)["playwright"] + + def test_playwright_binds_static_runtime_resources_and_rejects_reflection( tmp_path: Path, ) -> None: @@ -997,6 +1065,32 @@ def test_toolchain_manifest_requires_complete_typed_external_roles(tmp_path: Pat _toolchain_manifest_identity(manifest) +def test_toolchain_manifest_requires_nonempty_native_runtime(tmp_path: Path) -> None: + launcher = Path(sys.executable) + manifest = _toolchain_manifest(tmp_path / "toolchain", launcher, launcher) + payload = json.loads(manifest.read_text(encoding="utf-8")) + payload["roles"]["native_runtime"] = [] + manifest.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ValueError, match="incomplete"): + _toolchain_manifest_identity(manifest) + + +def test_toolchain_manifest_identity_binds_native_runtime_content(tmp_path: Path) -> None: + launcher = Path(sys.executable) + manifest = _toolchain_manifest(tmp_path / "toolchain", launcher, launcher) + native = tmp_path / "toolchain/native.so" + native.write_bytes(b"base-native") + payload = json.loads(manifest.read_text(encoding="utf-8")) + payload["roles"]["native_runtime"] = [str(native.resolve())] + manifest.write_text(json.dumps(payload), encoding="utf-8") + before = _toolchain_manifest_identity(manifest) + + native.write_bytes(b"changed-native") + + assert _toolchain_manifest_identity(manifest) != before + + def test_directory_identity_binds_symlink_topology(tmp_path: Path) -> None: target = tmp_path / "package-a" target.mkdir() @@ -1026,12 +1120,13 @@ def test_toolchain_manifest_rejects_absolute_symlinks( (toolchain / "browsers").mkdir() (toolchain / "package-lock.json").write_text("{}", encoding="utf-8") manifest.write_text(json.dumps({ - "version": 2, + "version": 3, "roles": { "launcher": str((toolchain / "node").resolve()), "entrypoint": str((toolchain / "cli.js").resolve()), "dependencies": str((toolchain / "modules").resolve()), "browser_runtime": str((toolchain / "browsers").resolve()), + "native_runtime": [str((toolchain / "node").resolve())], "lockfile": str((toolchain / "package-lock.json").resolve()), }, }), encoding="utf-8") @@ -1056,12 +1151,13 @@ def test_toolchain_manifest_identity_is_relocation_stable_with_relative_symlink( (toolchain / "browsers").mkdir() (toolchain / "package-lock.json").write_text("{}", encoding="utf-8") (root / "manifest.json").write_text(json.dumps({ - "version": 2, + "version": 3, "roles": { "launcher": str((toolchain / "node").resolve()), "entrypoint": str((toolchain / "cli.js").resolve()), "dependencies": str((toolchain / "modules").resolve()), "browser_runtime": str((toolchain / "browsers").resolve()), + "native_runtime": [str((toolchain / "node").resolve())], "lockfile": str((toolchain / "package-lock.json").resolve()), }, }), encoding="utf-8") From 80badaad366b9205176fc31639cb2f80200efda9 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 14:34:24 -0700 Subject: [PATCH 049/233] fix(sync): deduplicate protected runtime mounts --- pdd/sync_core/runner.py | 13 +++++- pdd/sync_core/supervisor.py | 12 ++++- tests/test_sync_core_runner_playwright.py | 17 ++++++- tests/test_sync_core_supervisor.py | 56 +++++++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 128f7ed928..d1b9351393 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4230,6 +4230,17 @@ class PddTrustedReporter {{ """ +def _playwright_missing_result_detail( + result: subprocess.CompletedProcess[str], +) -> str: + """Summarize a failed reporter launch without unbounded child output.""" + diagnostic = " ".join(result.stderr.split()) + if len(diagnostic) > 512: + diagnostic = diagnostic[:509] + "..." + detail = f"Playwright reporter produced no private result (exit {result.returncode})" + return f"{detail}: {diagnostic}" if diagnostic else detail + + def _playwright_result( root: Path, output: str, returncode: int, expected: tuple[str, ...] | None, collection: bool = False, @@ -4540,7 +4551,7 @@ def _run_playwright_in_tree( if not output: return RunnerExecution( "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, - "Playwright reporter produced no private result", + _playwright_missing_result_detail(result), ), () outcome, detail, identities = _playwright_result( root, output.decode("utf-8", errors="replace"), result.returncode, expected, collection diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index f013029817..ad4695b223 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -318,7 +318,7 @@ def _sandbox_command( result_fifo: Path | None = None, result_fd: int = 198, ) -> tuple[list[str], Path | None]: - # pylint: disable=too-many-locals,too-many-branches + # pylint: disable=too-many-locals,too-many-branches,too-many-statements """Return an explicitly detected macOS/Linux sandbox command.""" if sys.platform == "darwin": raise RuntimeError( @@ -343,8 +343,18 @@ def _sandbox_command( "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp"] sources: list[Path] = [] destination_dirs = {Path("/tmp")} + mounted: dict[Path, tuple[str, Path]] = {} def bind(option: str, source: Path, destination: Path | None = None) -> None: destination = destination or source + binding = (option, source.resolve()) + previous = mounted.get(destination) + if previous == binding: + return + if previous is not None and previous[1] != binding[1]: + raise RuntimeError( + f"protected sandbox has conflicting bindings for {destination}" + ) + mounted[destination] = binding missing = [] parent = destination.parent while parent != Path("/") and parent not in destination_dirs: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index fda5779182..e45fd93df7 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -27,6 +27,7 @@ _local_javascript_imports, _playwright_environment, _playwright_command_error, + _playwright_missing_result_detail, _playwright_reporter_source, _playwright_result, _toolchain_manifest_identity, @@ -211,6 +212,8 @@ def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Pa installed_entrypoint.write_bytes(entrypoint.read_bytes()) lockfile = directory / "package-lock.json" lockfile.write_text("{}\n", encoding="utf-8") + native = directory / "native-runtime.so" + native.write_bytes(b"synthetic-native-runtime") manifest = directory / "playwright-toolchain.json" manifest.write_text( json.dumps({ @@ -220,7 +223,7 @@ def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Pa "entrypoint": str(installed_entrypoint.resolve()), "dependencies": str(dependencies.resolve()), "browser_runtime": str(browsers.resolve()), - "native_runtime": [str(launcher.resolve())], + "native_runtime": [str(native.resolve())], "lockfile": str(lockfile.resolve()), }, }), @@ -1976,6 +1979,18 @@ def test_playwright_stdout_result_forgery_is_not_a_reporter_result(tmp_path: Pat assert not identities +def test_playwright_missing_private_result_has_bounded_diagnostics() -> None: + result = subprocess.CompletedProcess( + ["playwright"], 17, "", "mount failed\n" + ("x" * 600) + ) + + detail = _playwright_missing_result_detail(result) + + assert "exit 17" in detail + assert "mount failed" in detail + assert len(detail) < 600 + + @pytest.mark.parametrize("source", [ "const proc = globalThis.process; proc.exit(0);", "const { exit } = process; exit(0);", diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 10cd40cc35..6ed6033cd1 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -159,6 +159,62 @@ def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( ) +def test_linux_sandbox_deduplicates_identical_read_only_bindings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + native = tmp_path / "native.so" + native.write_bytes(b"native") + + argv, _profile = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_roots=(native, native), + readable_bindings=((native, native),), + ) + + bwrap = json.loads(argv[-2]) + assert bwrap.count(str(native)) == 1 + + +def test_linux_sandbox_rejects_conflicting_bindings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + first = tmp_path / "first.so" + second = tmp_path / "second.so" + first.write_bytes(b"first") + second.write_bytes(b"second") + + with pytest.raises(RuntimeError, match="conflicting bindings"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((first, Path("/opt/native.so")), + (second, Path("/opt/native.so"))), + ) + + def test_linux_sandbox_fails_closed_for_root_caller( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 75faa619168937451d8690fb9eced4e7ee878bb5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 14:40:07 -0700 Subject: [PATCH 050/233] fix(sync): constrain Playwright Node virtual memory --- pdd/sync_core/runner.py | 15 ++++++++++++++- tests/test_sync_core_runner_playwright.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index d1b9351393..f00582c117 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4241,6 +4241,18 @@ def _playwright_missing_result_detail( return f"{detail}: {diagnostic}" if diagnostic else detail +def _playwright_runtime_prefix( + prefix: tuple[str, ...], launcher: Path, +) -> tuple[str, ...]: + """Avoid Node's large Linux Wasm trap-handler virtual-memory reservation.""" + if ( + sys.platform.startswith("linux") + and launcher.name in {"node", "nodejs"} + ): + return (prefix[0], "--disable-wasm-trap-handler", *prefix[1:]) + return prefix + + def _playwright_result( root: Path, output: str, returncode: int, expected: tuple[str, ...] | None, collection: bool = False, @@ -4457,7 +4469,8 @@ def _run_playwright_in_tree( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) ), () command = [ - *prefix, "test", *(path.as_posix() for path in paths), + *_playwright_runtime_prefix(prefix, roles.launcher), + "test", *(path.as_posix() for path in paths), f"--config={root / config_path}", f"--reporter={reporter}", "--update-snapshots=none", f"--output={scratch / 'results'}", ] diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index e45fd93df7..700873e13f 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -30,6 +30,7 @@ _playwright_missing_result_detail, _playwright_reporter_source, _playwright_result, + _playwright_runtime_prefix, _toolchain_manifest_identity, _playwright_toolchain_identity, playwright_validator_config_digest, @@ -1991,6 +1992,19 @@ def test_playwright_missing_private_result_has_bounded_diagnostics() -> None: assert len(detail) < 600 +def test_playwright_linux_node_disables_wasm_trap_handler( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(runner_module.sys, "platform", "linux") + + assert _playwright_runtime_prefix( + ("/usr/bin/node", "/opt/playwright/cli.js"), Path("/usr/bin/node") + ) == ( + "/usr/bin/node", "--disable-wasm-trap-handler", + "/opt/playwright/cli.js", + ) + + @pytest.mark.parametrize("source", [ "const proc = globalThis.process; proc.exit(0);", "const { exit } = process; exit(0);", From 4a8245990a3fa3ab054423b32c9542ab162e7b62 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 14:45:03 -0700 Subject: [PATCH 051/233] test(sync): surface protected browser failures --- pdd/sync_core/runner.py | 35 +++++++++++++++++++---- tests/test_sync_core_runner_playwright.py | 12 ++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index f00582c117..5e8a1f7ec5 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4215,14 +4215,18 @@ class PddTrustedReporter {{ for (const test of suite.allTests()) {{ const identity = this.identity(test); if (this.tests.has(identity)) throw new Error(`duplicate Playwright identity: ${{identity}}`); - this.tests.set(identity, 'collected'); + this.tests.set(identity, {{status: 'collected'}}); }} }} onTestEnd(test, result) {{ - this.tests.set(this.identity(test), result.status); + const error = result.error && typeof result.error.message === 'string' + ? result.error.message : ''; + this.tests.set(this.identity(test), {{status: result.status, error}}); }} onEnd() {{ - const tests = Array.from(this.tests, ([identity, status]) => ({{identity, status}})); + const tests = Array.from( + this.tests, ([identity, result]) => ({{identity, ...result}}) + ); fs.writeSync(RESULT_FD, JSON.stringify({{pdd_playwright_reporter: 1, tests}})); }} }} @@ -4253,6 +4257,21 @@ def _playwright_runtime_prefix( return prefix +def _playwright_reported_failure_detail(tests: list[dict[str, object]]) -> str: + """Return one bounded reporter diagnostic for a failed protected test.""" + errors = [ + " ".join(error.split()) + for item in tests + if isinstance((error := item.get("error")), str) and error.strip() + ] + if not errors: + return "Playwright reported failed protected tests" + diagnostic = errors[0] + if len(diagnostic) > 512: + diagnostic = diagnostic[:509] + "..." + return f"Playwright reported failed protected tests: {diagnostic}" + + def _playwright_result( root: Path, output: str, returncode: int, expected: tuple[str, ...] | None, collection: bool = False, @@ -4319,7 +4338,9 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: visit(suite) if not isinstance(tests, list) or not all( isinstance(item, dict) and isinstance(item.get("identity"), str) - and isinstance(item.get("status"), str) for item in tests + and isinstance(item.get("status"), str) + and (item.get("error") is None or isinstance(item.get("error"), str)) + for item in tests ): raise ValueError("malformed Playwright reporter payload") except (KeyError, TypeError, ValueError, json.JSONDecodeError): @@ -4341,7 +4362,11 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: if "timedOut" in statuses: return EvidenceOutcome.TIMEOUT, "Playwright reported a timed out protected test", identities if returncode or "failed" in statuses or "interrupted" in statuses: - return EvidenceOutcome.FAIL, "Playwright reported failed protected tests", identities + return ( + EvidenceOutcome.FAIL, + _playwright_reported_failure_detail(tests), + identities, + ) if "collected" in statuses: return EvidenceOutcome.SKIP, "Playwright did not execute every collected protected test", identities if statuses - {"passed"}: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 700873e13f..e6a18c2232 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -29,6 +29,7 @@ _playwright_command_error, _playwright_missing_result_detail, _playwright_reporter_source, + _playwright_reported_failure_detail, _playwright_result, _playwright_runtime_prefix, _toolchain_manifest_identity, @@ -2005,6 +2006,17 @@ def test_playwright_linux_node_disables_wasm_trap_handler( ) +def test_playwright_reported_failure_has_bounded_diagnostics() -> None: + detail = _playwright_reported_failure_detail([{ + "identity": IDENTITY, + "status": "failed", + "error": "browser launch failed\n" + ("x" * 600), + }]) + + assert "browser launch failed" in detail + assert len(detail) < 600 + + @pytest.mark.parametrize("source", [ "const proc = globalThis.process; proc.exit(0);", "const { exit } = process; exit(0);", From dabbefeed1c1b5d458e2026923fe2119a57fb4c7 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 14:49:47 -0700 Subject: [PATCH 052/233] test(sync): preserve browser failure tail --- pdd/sync_core/runner.py | 4 ++-- tests/test_sync_core_runner_playwright.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 5e8a1f7ec5..1d4c47125e 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4267,8 +4267,8 @@ def _playwright_reported_failure_detail(tests: list[dict[str, object]]) -> str: if not errors: return "Playwright reported failed protected tests" diagnostic = errors[0] - if len(diagnostic) > 512: - diagnostic = diagnostic[:509] + "..." + if len(diagnostic) > 2048: + diagnostic = diagnostic[:1021] + "......" + diagnostic[-1012:] return f"Playwright reported failed protected tests: {diagnostic}" diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index e6a18c2232..085c9796b8 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -2010,11 +2010,12 @@ def test_playwright_reported_failure_has_bounded_diagnostics() -> None: detail = _playwright_reported_failure_detail([{ "identity": IDENTITY, "status": "failed", - "error": "browser launch failed\n" + ("x" * 600), + "error": "browser launch failed\n" + ("x" * 3000) + "\nloader tail", }]) assert "browser launch failed" in detail - assert len(detail) < 600 + assert "loader tail" in detail + assert len(detail) < 2200 @pytest.mark.parametrize("source", [ From 0c22493e866bd90265ec507a3ab3466cb5d80bf0 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 14:55:46 -0700 Subject: [PATCH 053/233] fix(sync): bound Chromium trap-handler configuration --- pdd/sync_core/runner.py | 38 ++++++++++++++++++++-- tests/test_sync_core_runner_playwright.py | 39 ++++++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 1d4c47125e..054d4f535b 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1622,6 +1622,38 @@ def _playwright_local_reference(path: PurePosixPath, value: str, label: str) -> return normalized +def _validate_playwright_bounded_browser_use(source: bytes, node: Node) -> None: + """Accept only the V8 flag needed to keep Chromium inside RLIMIT_AS.""" + def only_value(parent: Node, expected_key: str) -> Node: + if parent.type != "object" or len(parent.named_children) != 1: + raise ValueError("Playwright browser use config must have exact keys") + pair = parent.named_children[0] + if pair.type != "pair": + raise ValueError("Playwright browser use config must be declarative") + key_node = pair.child_by_field_name("key") + value_node = pair.child_by_field_name("value") + if key_node is None or value_node is None: + raise ValueError("Playwright browser use config is malformed") + key = ( + _javascript_string(source, key_node) + if key_node.type == "string" + else _node_text(source, key_node) + ) + if key != expected_key: + raise ValueError("Playwright browser use config key is unsupported") + return value_node + + launch_options = only_value(node, "launchOptions") + arguments = only_value(launch_options, "args") + if ( + arguments.type != "array" + or len(arguments.named_children) != 1 + or _javascript_string(source, arguments.named_children[0]) + != "--js-flags=--no-wasm-trap-handler" + ): + raise ValueError("Playwright browser use args are unsupported") + + def _validate_playwright_config_object( path: PurePosixPath, source: bytes, config: Node ) -> set[PurePosixPath]: @@ -1635,7 +1667,7 @@ def _validate_playwright_config_object( forbidden = { "grep", "grepInvert", "shard", "retries", "workers", "repeatEach", "webServer", "storageState", "projects", "dependencies", - "snapshotPathTemplate", "executablePath", "use", + "snapshotPathTemplate", "executablePath", "updateSnapshots", "updateSourceMethod", } references: set[PurePosixPath] = set() @@ -1650,7 +1682,9 @@ def _validate_playwright_config_object( else _node_text(source, key_node)) if key in forbidden: raise ValueError(f"Playwright config key {key} is unsupported") - if key in executable: + if key == "use": + _validate_playwright_bounded_browser_use(source, value_node) + elif key in executable: value = _javascript_string(source, value_node) references.add(_playwright_local_reference(path, value, key)) elif key in allowed_data: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 085c9796b8..0bd1cb966e 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -270,7 +270,9 @@ def test_real_playwright_source_or_wheel_protocol_uses_browser( encoding="utf-8", ) (root / "playwright.config.ts").write_text( - "export default {};\n", encoding="utf-8" + "export default { use: { launchOptions: { args: " + "['--js-flags=--no-wasm-trap-handler'] } } };\n", + encoding="utf-8", ) _git(root, "add", ".") _git(root, "commit", "-q", "-m", "protected real Playwright test") @@ -2006,6 +2008,41 @@ def test_playwright_linux_node_disables_wasm_trap_handler( ) +def test_playwright_accepts_bounded_chromium_wasm_configuration( + tmp_path: Path, +) -> None: + root, commit = _repository( + tmp_path, + config=( + "export default { use: { launchOptions: { args: " + "['--js-flags=--no-wasm-trap-handler'] } } };\n" + ), + ) + + assert playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize("use_config", [ + "use: {}", + "use: { launchOptions: { args: ['--no-sandbox'] } }", + "use: { launchOptions: { args: ['--js-flags=--no-wasm-trap-handler'], " + "executablePath: '/bin/chrome' } }", +]) +def test_playwright_rejects_other_root_browser_use_configuration( + tmp_path: Path, use_config: str, +) -> None: + root, commit = _repository( + tmp_path, config=f"export default {{ {use_config} }};\n" + ) + + with pytest.raises(ValueError, match="browser use"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + def test_playwright_reported_failure_has_bounded_diagnostics() -> None: detail = _playwright_reported_failure_detail([{ "identity": IDENTITY, From f63a077a46d4e9b45bab6db793961d05323eee4f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 15:00:58 -0700 Subject: [PATCH 054/233] fix(sync): budget Chromium virtual address space --- pdd/sync_core/runner.py | 44 ++++------------------- tests/test_sync_core_runner_playwright.py | 43 +++------------------- 2 files changed, 12 insertions(+), 75 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 054d4f535b..c6496d98bf 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -46,7 +46,7 @@ VerificationObligation, VerificationProfile, ) -from .supervisor import released_runtime_closure_paths, run_supervised +from .supervisor import SupervisorLimits, released_runtime_closure_paths, run_supervised TRUSTED_RUNNER_VERSION = "pdd-trusted-runner-v2" @@ -182,6 +182,9 @@ class VitestPhaseToolchain: "launcher", "entrypoint", "dependencies", "browser_runtime", "native_runtime", "lockfile", } +PLAYWRIGHT_SUPERVISOR_LIMITS = SupervisorLimits( + max_memory_bytes=16 * 1024 * 1024 * 1024 +) @dataclass(frozen=True) @@ -1622,38 +1625,6 @@ def _playwright_local_reference(path: PurePosixPath, value: str, label: str) -> return normalized -def _validate_playwright_bounded_browser_use(source: bytes, node: Node) -> None: - """Accept only the V8 flag needed to keep Chromium inside RLIMIT_AS.""" - def only_value(parent: Node, expected_key: str) -> Node: - if parent.type != "object" or len(parent.named_children) != 1: - raise ValueError("Playwright browser use config must have exact keys") - pair = parent.named_children[0] - if pair.type != "pair": - raise ValueError("Playwright browser use config must be declarative") - key_node = pair.child_by_field_name("key") - value_node = pair.child_by_field_name("value") - if key_node is None or value_node is None: - raise ValueError("Playwright browser use config is malformed") - key = ( - _javascript_string(source, key_node) - if key_node.type == "string" - else _node_text(source, key_node) - ) - if key != expected_key: - raise ValueError("Playwright browser use config key is unsupported") - return value_node - - launch_options = only_value(node, "launchOptions") - arguments = only_value(launch_options, "args") - if ( - arguments.type != "array" - or len(arguments.named_children) != 1 - or _javascript_string(source, arguments.named_children[0]) - != "--js-flags=--no-wasm-trap-handler" - ): - raise ValueError("Playwright browser use args are unsupported") - - def _validate_playwright_config_object( path: PurePosixPath, source: bytes, config: Node ) -> set[PurePosixPath]: @@ -1667,7 +1638,7 @@ def _validate_playwright_config_object( forbidden = { "grep", "grepInvert", "shard", "retries", "workers", "repeatEach", "webServer", "storageState", "projects", "dependencies", - "snapshotPathTemplate", "executablePath", + "snapshotPathTemplate", "executablePath", "use", "updateSnapshots", "updateSourceMethod", } references: set[PurePosixPath] = set() @@ -1682,9 +1653,7 @@ def _validate_playwright_config_object( else _node_text(source, key_node)) if key in forbidden: raise ValueError(f"Playwright config key {key} is unsupported") - if key == "use": - _validate_playwright_bounded_browser_use(source, value_node) - elif key in executable: + if key in executable: value = _javascript_string(source, value_node) references.add(_playwright_local_reference(path, value, key)) elif key in allowed_data: @@ -4550,6 +4519,7 @@ def _run_playwright_in_tree( writable_roots=(scratch,), readable_roots=(reporter, *roles.readable_roots), readable_bindings=roles.native_bindings, + limits=PLAYWRIGHT_SUPERVISOR_LIMITS, result_fifo=result_fifo, result_fd=result_fd, ) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 0bd1cb966e..6ff7bddd71 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -270,9 +270,7 @@ def test_real_playwright_source_or_wheel_protocol_uses_browser( encoding="utf-8", ) (root / "playwright.config.ts").write_text( - "export default { use: { launchOptions: { args: " - "['--js-flags=--no-wasm-trap-handler'] } } };\n", - encoding="utf-8", + "export default {};\n", encoding="utf-8" ) _git(root, "add", ".") _git(root, "commit", "-q", "-m", "protected real Playwright test") @@ -521,9 +519,11 @@ def test_playwright_execution_uses_process_group_supervisor( ) -> None: root, commit = _repository(tmp_path) calls: list[list[str]] = [] + limits = [] def supervised(command, **_kwargs): calls.append(command) + limits.append(_kwargs["limits"]) _write_private_result(_kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) @@ -533,6 +533,8 @@ def supervised(command, **_kwargs): _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) assert executions[0].outcome is EvidenceOutcome.PASS assert calls + assert limits[0].max_memory_bytes == 16 * 1024 * 1024 * 1024 + assert limits[0].max_processes == 128 @pytest.mark.parametrize( @@ -2008,41 +2010,6 @@ def test_playwright_linux_node_disables_wasm_trap_handler( ) -def test_playwright_accepts_bounded_chromium_wasm_configuration( - tmp_path: Path, -) -> None: - root, commit = _repository( - tmp_path, - config=( - "export default { use: { launchOptions: { args: " - "['--js-flags=--no-wasm-trap-handler'] } } };\n" - ), - ) - - assert playwright_validator_config_digest( - root, commit, (PurePosixPath("tests/widget.spec.ts"),) - ) - - -@pytest.mark.parametrize("use_config", [ - "use: {}", - "use: { launchOptions: { args: ['--no-sandbox'] } }", - "use: { launchOptions: { args: ['--js-flags=--no-wasm-trap-handler'], " - "executablePath: '/bin/chrome' } }", -]) -def test_playwright_rejects_other_root_browser_use_configuration( - tmp_path: Path, use_config: str, -) -> None: - root, commit = _repository( - tmp_path, config=f"export default {{ {use_config} }};\n" - ) - - with pytest.raises(ValueError, match="browser use"): - playwright_validator_config_digest( - root, commit, (PurePosixPath("tests/widget.spec.ts"),) - ) - - def test_playwright_reported_failure_has_bounded_diagnostics() -> None: detail = _playwright_reported_failure_detail([{ "identity": IDENTITY, From eaac1b4152a5b0ccf55916db66f70963df9df72c Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 15:06:00 -0700 Subject: [PATCH 055/233] fix(sync): accommodate Chromium address reservation --- pdd/sync_core/runner.py | 2 +- tests/test_sync_core_runner_playwright.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index c6496d98bf..5b895053e5 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -183,7 +183,7 @@ class VitestPhaseToolchain: "native_runtime", "lockfile", } PLAYWRIGHT_SUPERVISOR_LIMITS = SupervisorLimits( - max_memory_bytes=16 * 1024 * 1024 * 1024 + max_memory_bytes=64 * 1024 * 1024 * 1024 ) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 6ff7bddd71..b14dfd04ec 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -533,7 +533,7 @@ def supervised(command, **_kwargs): _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) assert executions[0].outcome is EvidenceOutcome.PASS assert calls - assert limits[0].max_memory_bytes == 16 * 1024 * 1024 * 1024 + assert limits[0].max_memory_bytes == 64 * 1024 * 1024 * 1024 assert limits[0].max_processes == 128 From 5210858526835bf66542650a3d810b4fcaca6268 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 15:11:09 -0700 Subject: [PATCH 056/233] fix(sync): provide bounded Playwright temp space --- pdd/sync_core/runner.py | 7 ++--- pdd/sync_core/supervisor.py | 5 ++++ tests/test_sync_core_runner_playwright.py | 8 +++--- tests/test_sync_core_supervisor.py | 33 +++++++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 5b895053e5..943a123961 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -46,7 +46,7 @@ VerificationObligation, VerificationProfile, ) -from .supervisor import SupervisorLimits, released_runtime_closure_paths, run_supervised +from .supervisor import released_runtime_closure_paths, run_supervised TRUSTED_RUNNER_VERSION = "pdd-trusted-runner-v2" @@ -182,9 +182,6 @@ class VitestPhaseToolchain: "launcher", "entrypoint", "dependencies", "browser_runtime", "native_runtime", "lockfile", } -PLAYWRIGHT_SUPERVISOR_LIMITS = SupervisorLimits( - max_memory_bytes=64 * 1024 * 1024 * 1024 -) @dataclass(frozen=True) @@ -4517,9 +4514,9 @@ def _run_playwright_in_tree( roles.browser_runtime, ), writable_roots=(scratch,), + writable_bindings=((scratch, Path("/tmp")),), readable_roots=(reporter, *roles.readable_roots), readable_bindings=roles.native_bindings, - limits=PLAYWRIGHT_SUPERVISOR_LIMITS, result_fifo=result_fifo, result_fd=result_fd, ) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index ad4695b223..f9ebd19f65 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -315,6 +315,7 @@ def _sandbox_command( writable_files: tuple[Path, ...] = (), limits: SupervisorLimits = SupervisorLimits(), readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), + writable_bindings: tuple[tuple[Path, Path], ...] = (), result_fifo: Path | None = None, result_fd: int = 198, ) -> tuple[list[str], Path | None]: @@ -384,6 +385,8 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: argv.extend(("--dev", "/dev")) for item in writable_roots: bind("--bind", item.resolve()) + for source, destination in writable_bindings: + bind("--bind", source.resolve(), destination) for item in writable_files: bind("--bind", item.resolve()) if result_fifo is not None: @@ -410,6 +413,7 @@ def run_supervised( limits: SupervisorLimits = SupervisorLimits(), readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), + writable_bindings: tuple[tuple[Path, Path], ...] = (), result_fifo: Path | None = None, result_fd: int = 198, ) -> tuple[subprocess.CompletedProcess[str], bool]: @@ -420,6 +424,7 @@ def run_supervised( command, writable_roots, cwd=cwd, writable_files=writable_files, limits=limits, readable_roots=readable_roots, readable_bindings=readable_bindings, + writable_bindings=writable_bindings, result_fifo=result_fifo, result_fd=result_fd, ) except RuntimeError as exc: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index b14dfd04ec..c81e1b95d6 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -519,11 +519,11 @@ def test_playwright_execution_uses_process_group_supervisor( ) -> None: root, commit = _repository(tmp_path) calls: list[list[str]] = [] - limits = [] + scratch_bindings = [] def supervised(command, **_kwargs): calls.append(command) - limits.append(_kwargs["limits"]) + scratch_bindings.append(_kwargs["writable_bindings"]) _write_private_result(_kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) @@ -533,8 +533,8 @@ def supervised(command, **_kwargs): _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) assert executions[0].outcome is EvidenceOutcome.PASS assert calls - assert limits[0].max_memory_bytes == 64 * 1024 * 1024 * 1024 - assert limits[0].max_processes == 128 + assert scratch_bindings[0][0][1] == Path("/tmp") + assert scratch_bindings[0][0][0].name == "scratch" @pytest.mark.parametrize( diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 6ed6033cd1..126f196135 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -159,6 +159,39 @@ def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( ) +def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + scratch = tmp_path / "scratch" + scratch.mkdir() + + argv, _profile = _sandbox_command( + ["/bin/true"], + (scratch,), + writable_bindings=((scratch, Path("/tmp")),), + ) + + bwrap = json.loads(argv[-2]) + sources = json.loads(argv[-1]) + destination_index = len(bwrap) - 1 - bwrap[::-1].index("/tmp") + assert bwrap[destination_index - 2] == "--bind" + placeholder = bwrap[destination_index - 1] + assert sources[int(placeholder.removeprefix("@FD:").removesuffix("@"))] == str( + scratch.resolve() + ) + + def test_linux_sandbox_deduplicates_identical_read_only_bindings( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 26fde8ca8508c0436d7984c613af9011ace83590 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 15:18:20 -0700 Subject: [PATCH 057/233] fix(sync): use namespace-local Playwright temp path --- pdd/sync_core/runner.py | 5 ++++- pdd/sync_core/supervisor.py | 7 ++++--- tests/test_sync_core_runner_playwright.py | 6 +++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 943a123961..1b547569b3 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4464,6 +4464,8 @@ def _run_playwright_in_tree( scratch = temporary / "scratch" home = scratch / "home" home.mkdir(parents=True, mode=0o700) + sandbox_tmp = scratch / "tmp" + sandbox_tmp.mkdir(mode=0o700) controllers = temporary / f"controller-{os.urandom(16).hex()}" controllers.mkdir(mode=0o700) reporter = controllers / "reporter.cjs" @@ -4514,7 +4516,8 @@ def _run_playwright_in_tree( roles.browser_runtime, ), writable_roots=(scratch,), - writable_bindings=((scratch, Path("/tmp")),), + writable_bindings=((sandbox_tmp, Path("/tmp")),), + temp_directory=Path("/tmp"), readable_roots=(reporter, *roles.readable_roots), readable_bindings=roles.native_bindings, result_fifo=result_fifo, diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index f9ebd19f65..30796dec85 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -414,6 +414,7 @@ def run_supervised( readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), + temp_directory: Path | None = None, result_fifo: Path | None = None, result_fd: int = 198, ) -> tuple[subprocess.CompletedProcess[str], bool]: @@ -435,9 +436,9 @@ def run_supervised( sandbox_environment = env | { "PYTHONDONTWRITEBYTECODE": "1", "PDD_SUPERVISION_TOKEN": token, - "TMPDIR": str(writable_roots[0].resolve()), - "TEMP": str(writable_roots[0].resolve()), - "TMP": str(writable_roots[0].resolve()), + "TMPDIR": str(temp_directory or writable_roots[0].resolve()), + "TEMP": str(temp_directory or writable_roots[0].resolve()), + "TMP": str(temp_directory or writable_roots[0].resolve()), } library_path = _sandbox_library_path(env) if library_path: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index c81e1b95d6..a27a98f2bf 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -520,10 +520,12 @@ def test_playwright_execution_uses_process_group_supervisor( root, commit = _repository(tmp_path) calls: list[list[str]] = [] scratch_bindings = [] + temp_directories = [] def supervised(command, **_kwargs): calls.append(command) scratch_bindings.append(_kwargs["writable_bindings"]) + temp_directories.append(_kwargs["temp_directory"]) _write_private_result(_kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) @@ -534,7 +536,9 @@ def supervised(command, **_kwargs): assert executions[0].outcome is EvidenceOutcome.PASS assert calls assert scratch_bindings[0][0][1] == Path("/tmp") - assert scratch_bindings[0][0][0].name == "scratch" + assert scratch_bindings[0][0][0].name == "tmp" + assert scratch_bindings[0][0][0].parent.name == "scratch" + assert temp_directories[0] == Path("/tmp") @pytest.mark.parametrize( From 30c018a889eb96f5e6c5f769838eb784046455ca Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 15:23:35 -0700 Subject: [PATCH 058/233] fix(sync): order nested sandbox mounts correctly --- pdd/sync_core/supervisor.py | 4 ++-- tests/test_sync_core_supervisor.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 30796dec85..1f8f613f2a 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -368,6 +368,8 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: argv.extend((option, f"@FD:{len(sources) - 1}@", str(destination))) if destination.is_dir(): destination_dirs.add(destination) + for source, destination in writable_bindings: + bind("--bind", source.resolve(), destination) for item in _runtime_roots(command, workdir): # A host bind follows symlinks, but the process command and ELF # loader retain their original spellings in the new namespace. @@ -385,8 +387,6 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: argv.extend(("--dev", "/dev")) for item in writable_roots: bind("--bind", item.resolve()) - for source, destination in writable_bindings: - bind("--bind", source.resolve(), destination) for item in writable_files: bind("--bind", item.resolve()) if result_fifo is not None: diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 126f196135..2483d8d098 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -186,6 +186,7 @@ def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( sources = json.loads(argv[-1]) destination_index = len(bwrap) - 1 - bwrap[::-1].index("/tmp") assert bwrap[destination_index - 2] == "--bind" + assert destination_index < bwrap.index("--ro-bind") placeholder = bwrap[destination_index - 1] assert sources[int(placeholder.removeprefix("@FD:").removesuffix("@"))] == str( scratch.resolve() From 1ae73ea3e197168b523eb51e217f2a4349c8641a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 16:34:49 -0700 Subject: [PATCH 059/233] fix(sync): isolate Playwright sandbox temp mounts --- pdd/sync_core/runner.py | 148 ++++++- tests/test_sync_core_runner_playwright.py | 474 ++++++++++++++++++++-- 2 files changed, 579 insertions(+), 43 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 1b547569b3..5dd29816c9 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -24,8 +24,10 @@ import threading import tomllib import xml.etree.ElementTree as ET +from contextlib import ExitStack from dataclasses import dataclass, replace from datetime import datetime +from functools import wraps from pathlib import Path, PurePosixPath import pytest @@ -177,6 +179,10 @@ class VitestPhaseToolchain: "playwright.config.js", "playwright.config.cjs", "playwright.config.mjs", "playwright.config.ts", "playwright.config.cts", "playwright.config.mts", ) +_PLAYWRIGHT_HOST_TEMP_PARENT = Path("/var/tmp") +_PLAYWRIGHT_TEMP_FAILURE_DIGEST = hashlib.sha256( + b"playwright-temporary-directory" +).hexdigest() PLAYWRIGHT_TOOLCHAIN_ROLES = { "launcher", "entrypoint", "dependencies", "browser_runtime", @@ -214,6 +220,10 @@ def native_bindings(self) -> tuple[tuple[Path, Path], ...]: ) +class _PlaywrightTemporaryDirectoryError(Exception): + """Mark an OSError from a checker-owned Playwright temporary directory.""" + + @dataclass(frozen=True) class RunnerConfig: """Protected execution limits for trusted validation adapters.""" @@ -4417,6 +4427,111 @@ def _playwright_protected_worktree_identity( return digest.hexdigest() +def _playwright_host_temp_parent() -> Path: + """Return a writable host temp parent outside the sandbox's host-backed tmp.""" + try: + parent = _PLAYWRIGHT_HOST_TEMP_PARENT.resolve(strict=True) + sandbox_tmp = Path("/tmp").resolve(strict=True) + parent_metadata = parent.stat() + except OSError as exc: + raise RuntimeError("trusted Playwright temp parent is unavailable") from exc + if not stat.S_ISDIR(parent_metadata.st_mode) or not os.access( + parent, os.W_OK | os.X_OK + ): + raise RuntimeError("trusted Playwright temp parent is not writable") + if parent == sandbox_tmp or parent.is_relative_to(sandbox_tmp): + raise RuntimeError("trusted Playwright temp parent aliases sandbox /tmp") + return parent + + +class _PlaywrightTemporaryDirectory: + """Normalize OSErrors from one checker-owned temporary directory lifecycle.""" + + def __init__(self, prefix: str, parent: Path) -> None: + self._prefix = prefix + self._parent = parent + self._stack = ExitStack() + + def __enter__(self) -> str: + try: + return self._stack.enter_context( + tempfile.TemporaryDirectory(prefix=self._prefix, dir=self._parent) + ) + except OSError as exc: + raise _PlaywrightTemporaryDirectoryError( + f"Playwright temporary directory failed: {exc}" + ) from exc + + def __exit__(self, exc_type, exc_value, traceback) -> bool | None: + try: + return self._stack.__exit__(exc_type, exc_value, traceback) + except OSError as exc: + raise _PlaywrightTemporaryDirectoryError( + f"Playwright temporary directory failed: {exc}" + ) from exc + + +def _playwright_temporary_directory( + prefix: str, parent: Path, +) -> _PlaywrightTemporaryDirectory: + """Create one checker-owned temporary directory lifecycle wrapper.""" + return _PlaywrightTemporaryDirectory(prefix, parent) + + +def _normalize_playwright_temp_errors(outcome: EvidenceOutcome): + """Return a decorator that turns checker temporary lifecycle failures into evidence.""" + def decorate(function): + @wraps(function) + def guarded(*args, **kwargs): + try: + return function(*args, **kwargs) + except _PlaywrightTemporaryDirectoryError as exc: + return RunnerExecution( + "playwright", outcome, _PLAYWRIGHT_TEMP_FAILURE_DIGEST, str(exc) + ), () + return guarded + return decorate + + +def _playwright_sandbox_destination_error( + roles: PlaywrightToolchainRoles, + native_bindings: tuple[tuple[Path, Path], ...], +) -> str | None: + """Reject manifest destinations that collide with the bounded sandbox /tmp bind.""" + try: + literal_sandbox_tmp = Path("/tmp") + resolved_sandbox_tmp = literal_sandbox_tmp.resolve(strict=True) + for destination in roles.readable_roots: + if destination == resolved_sandbox_tmp or destination.is_relative_to( + resolved_sandbox_tmp + ): + return ( + "Playwright toolchain destination resolves into sandbox /tmp: " + f"{destination}" + ) + for _source, destination in native_bindings: + lexical = Path(os.path.normpath(destination)) + if lexical == literal_sandbox_tmp or lexical.is_relative_to( + literal_sandbox_tmp + ): + return ( + "Playwright toolchain destination lexically aliases sandbox /tmp: " + f"{destination}" + ) + resolved_parent = destination.parent.resolve(strict=True) + if resolved_parent == resolved_sandbox_tmp or resolved_parent.is_relative_to( + resolved_sandbox_tmp + ): + return ( + "Playwright toolchain destination parent resolves into sandbox /tmp: " + f"{destination}" + ) + except OSError as exc: + return f"cannot validate Playwright toolchain sandbox destinations: {exc}" + return None + + +@_normalize_playwright_temp_errors(EvidenceOutcome.ERROR) def _run_playwright_in_tree( root: Path, paths: tuple[PurePosixPath, ...], timeout_seconds: int, config: RunnerConfig, expected: tuple[str, ...] | None = None, @@ -4455,11 +4570,28 @@ def _run_playwright_in_tree( "playwright-untrusted", command_error, ), () roles = _toolchain_manifest_roles(config.playwright_toolchain_manifest) + native_bindings = roles.native_bindings + destination_error = _playwright_sandbox_destination_error( + roles, native_bindings + ) + if destination_error is not None: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, + "playwright-untrusted", destination_error, + ), () try: config_path, _source = _playwright_config(root, "HEAD") except ValueError as exc: return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-config", str(exc)), () - with tempfile.TemporaryDirectory(prefix="pdd-trusted-playwright-") as directory: + try: + host_temp_parent = _playwright_host_temp_parent() + except RuntimeError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, "playwright-temp", str(exc) + ), () + with _playwright_temporary_directory( + "pdd-trusted-playwright-", host_temp_parent + ) as directory: temporary = Path(directory) scratch = temporary / "scratch" home = scratch / "home" @@ -4519,7 +4651,7 @@ def _run_playwright_in_tree( writable_bindings=((sandbox_tmp, Path("/tmp")),), temp_directory=Path("/tmp"), readable_roots=(reporter, *roles.readable_roots), - readable_bindings=roles.native_bindings, + readable_bindings=native_bindings, result_fifo=result_fifo, result_fd=result_fd, ) @@ -4601,6 +4733,7 @@ def _run_playwright_in_tree( return RunnerExecution("playwright", outcome, digest, detail), identities +@_normalize_playwright_temp_errors(EvidenceOutcome.COLLECTION_ERROR) def _run_playwright( root: Path, paths: tuple[PurePosixPath, ...], timeout_seconds: int, config: RunnerConfig, expected: tuple[str, ...] | None = None, @@ -4621,7 +4754,16 @@ def _run_playwright( "cannot resolve exact Playwright phase commit", ), () commit = resolved.stdout.strip() - with tempfile.TemporaryDirectory(prefix="pdd-playwright-phase-") as directory: + try: + host_temp_parent = _playwright_host_temp_parent() + except RuntimeError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, + hashlib.sha256(commit.encode()).hexdigest(), str(exc), + ), () + with _playwright_temporary_directory( + "pdd-playwright-phase-", host_temp_parent + ) as directory: phase_root = Path(directory) / "repository" cloned = subprocess.run( ["git", "clone", "-q", "--no-local", "--no-checkout", diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index a27a98f2bf..948354e54c 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -5,6 +5,8 @@ import shutil import subprocess import sys +import tempfile +from collections.abc import Iterator from datetime import datetime, timezone from pathlib import Path, PurePosixPath @@ -32,6 +34,7 @@ _playwright_reported_failure_detail, _playwright_result, _playwright_runtime_prefix, + _playwright_host_temp_parent, _toolchain_manifest_identity, _playwright_toolchain_identity, playwright_validator_config_digest, @@ -209,6 +212,7 @@ def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Pa browsers.mkdir(exist_ok=True) package = dependencies / "@playwright/test" package.mkdir(parents=True, exist_ok=True) + (package / "index.js").write_text("module.exports = {};\n", encoding="utf-8") installed_entrypoint = package / f"cli{entrypoint.suffix}" if entrypoint.resolve() != installed_entrypoint.resolve(): installed_entrypoint.write_bytes(entrypoint.read_bytes()) @@ -238,6 +242,30 @@ def _manifest_entrypoint(manifest: Path) -> Path: return Path(json.loads(manifest.read_text(encoding="utf-8"))["roles"]["entrypoint"]) +@pytest.fixture +def trusted_toolchain_dir() -> Iterator[Path]: + """Provide a deterministic synthetic toolchain outside sandbox-backed /tmp.""" + with tempfile.TemporaryDirectory( + prefix="pdd-test-playwright-toolchain-", + dir=_playwright_host_temp_parent(), + ) as directory: + yield Path(directory) + + +def _trusted_playwright_config( + toolchain_dir: Path, fake: Path, +) -> RunnerConfig: + """Build one externally declared synthetic Playwright toolchain config.""" + manifest = _toolchain_manifest( + toolchain_dir / "trusted-toolchain", Path(sys.executable), fake + ) + return RunnerConfig( + timeout_seconds=2, + playwright_command=(sys.executable, str(_manifest_entrypoint(manifest))), + playwright_toolchain_manifest=manifest, + ) + + @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap") @@ -541,6 +569,364 @@ def supervised(command, **_kwargs): assert temp_directories[0] == Path("/tmp") +def test_playwright_checker_temp_roots_cannot_alias_sandbox_tmp( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep every checker mount source outside the host path bound to /tmp.""" + root, commit = _repository(tmp_path) + phase_roots: list[Path] = [] + scratch_roots: list[Path] = [] + fifo_roots: list[Path] = [] + readable_roots: list[tuple[Path, ...]] = [] + readable_bindings: list[tuple[tuple[Path, Path], ...]] = [] + tmp_sources: list[Path] = [] + tmp_destinations: list[Path] = [] + + def supervised(command, *, cwd, writable_roots, writable_bindings, **kwargs): + phase_roots.append(cwd) + scratch_roots.append(writable_roots[0]) + readable_roots.append(kwargs["readable_roots"]) + readable_bindings.append(kwargs["readable_bindings"]) + fifo_roots.append(Path(kwargs["result_fifo"]).parent) + source, destination = writable_bindings[0] + tmp_sources.append(source) + tmp_destinations.append(destination) + _write_private_result(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return subprocess.CompletedProcess(command, 0, "", ""), False + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert executions[0].outcome is EvidenceOutcome.PASS + assert len(phase_roots) == 3 + assert len(readable_roots) == len(phase_roots) + assert len(readable_bindings) == len(phase_roots) + assert all(len(roots) == 6 for roots in readable_roots) + assert all(len(bindings) == 1 for bindings in readable_bindings) + sandbox_tmp = Path("/tmp").resolve() + for path in (*phase_roots, *scratch_roots, *fifo_roots): + assert not path.resolve().is_relative_to(sandbox_tmp) + for roots in readable_roots: + for path in roots: + assert not path.resolve().is_relative_to(sandbox_tmp) + for bindings in readable_bindings: + for _source, destination in bindings: + assert not destination.resolve().is_relative_to(sandbox_tmp) + for source, destination in zip(tmp_sources, tmp_destinations, strict=True): + assert destination == Path("/tmp") + assert source.resolve() != sandbox_tmp + assert not source.resolve().is_relative_to(sandbox_tmp) + assert not sandbox_tmp.is_relative_to(source.resolve()) + + +def test_playwright_checker_temp_parent_rejects_sandbox_tmp_alias( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not fall back to /tmp when the trusted parent aliases it.""" + monkeypatch.setattr(runner_module, "_PLAYWRIGHT_HOST_TEMP_PARENT", Path("/tmp")) + + with pytest.raises(RuntimeError, match="aliases sandbox /tmp"): + _playwright_host_temp_parent() + + +def test_playwright_rejects_tmp_toolchain_destination_before_supervision( + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A manifest mount destination must not collide with bounded sandbox /tmp.""" + root, commit = _repository(tmp_path) + fake = _fake_playwright(tmp_path) + config = _trusted_playwright_config(trusted_toolchain_dir, fake) + manifest = config.playwright_toolchain_manifest + assert manifest is not None + browser_runtime = Path("/tmp") / f"pdd-playwright-{os.urandom(16).hex()}" + browser_runtime.mkdir() + payload = json.loads(manifest.read_text(encoding="utf-8")) + payload["roles"]["browser_runtime"] = str(browser_runtime) + manifest.write_text(json.dumps(payload), encoding="utf-8") + + def must_not_supervise(*_args, **_kwargs): + raise AssertionError("/tmp-rooted toolchain must fail before supervision") + + monkeypatch.setattr(runner_module, "run_supervised", must_not_supervise) + try: + execution, _identities = runner_module._run_playwright_in_tree( + root, + (PurePosixPath("tests/widget.spec.ts"),), + 2, + config, + expected_commit=commit, + ) + finally: + shutil.rmtree(browser_runtime) + + assert execution.outcome is EvidenceOutcome.COLLECTION_ERROR + assert "sandbox /tmp" in execution.detail + + +def test_playwright_rejects_native_runtime_lexically_in_tmp( + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject a /tmp native destination even when its target is outside /tmp.""" + root, commit = _repository(tmp_path) + config = _trusted_playwright_config( + trusted_toolchain_dir, _fake_playwright(tmp_path) + ) + manifest = config.playwright_toolchain_manifest + assert manifest is not None + payload = json.loads(manifest.read_text(encoding="utf-8")) + native = Path(payload["roles"]["native_runtime"][0]) + link = Path("/tmp") / f"pdd-playwright-{os.urandom(16).hex()}" + link.symlink_to(os.path.relpath(native, link.parent.resolve())) + payload["roles"]["native_runtime"] = [str(link)] + manifest.write_text(json.dumps(payload), encoding="utf-8") + + def must_not_supervise(*_args, **_kwargs): + raise AssertionError("/tmp-native destination must fail before supervision") + + monkeypatch.setattr(runner_module, "run_supervised", must_not_supervise) + try: + execution, _identities = runner_module._run_playwright_in_tree( + root, + (PurePosixPath("tests/widget.spec.ts"),), + 2, + config, + expected_commit=commit, + ) + finally: + link.unlink() + + assert execution.outcome is EvidenceOutcome.COLLECTION_ERROR + assert "lexically aliases sandbox /tmp" in execution.detail + + +def test_playwright_allows_tmp_native_source_bound_outside_tmp( + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Permit an outside destination whose final native symlink targets /tmp.""" + root, commit = _repository(tmp_path) + config = _trusted_playwright_config( + trusted_toolchain_dir, _fake_playwright(tmp_path) + ) + manifest = config.playwright_toolchain_manifest + assert manifest is not None + source = Path("/tmp") / f"pdd-playwright-{os.urandom(16).hex()}.so" + source.write_bytes(b"synthetic-native-runtime") + destination = trusted_toolchain_dir / "native-runtime.so" + destination.symlink_to(os.path.relpath(source, destination.parent.resolve())) + payload = json.loads(manifest.read_text(encoding="utf-8")) + payload["roles"]["native_runtime"] = [str(destination)] + manifest.write_text(json.dumps(payload), encoding="utf-8") + supervised = False + + def run_supervised(command, **kwargs): + nonlocal supervised + supervised = True + assert kwargs["readable_bindings"] == ((source.resolve(), destination),) + _write_private_result(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return subprocess.CompletedProcess(command, 0, "", ""), False + + monkeypatch.setattr(runner_module, "run_supervised", run_supervised) + try: + execution, _identities = runner_module._run_playwright_in_tree( + root, + (PurePosixPath("tests/widget.spec.ts"),), + 2, + config, + expected_commit=commit, + ) + finally: + destination.unlink() + source.unlink() + + assert supervised + assert execution.outcome is EvidenceOutcome.PASS + + +def test_playwright_uses_one_native_binding_snapshot( + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Use one native binding evaluation for validation and supervision.""" + root, commit = _repository(tmp_path) + config = _trusted_playwright_config( + trusted_toolchain_dir, _fake_playwright(tmp_path) + ) + original = runner_module.PlaywrightToolchainRoles.native_bindings + evaluations = 0 + + def counted_native_bindings(self): + nonlocal evaluations + evaluations += 1 + if evaluations > 1: + raise AssertionError("native bindings must not be recomputed") + return original.__get__(self, type(self)) + + def run_supervised(command, **kwargs): + assert kwargs["readable_bindings"] + _write_private_result(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return subprocess.CompletedProcess(command, 0, "", ""), False + + monkeypatch.setattr( + runner_module.PlaywrightToolchainRoles, + "native_bindings", + property(counted_native_bindings), + ) + monkeypatch.setattr(runner_module, "run_supervised", run_supervised) + execution, _identities = runner_module._run_playwright_in_tree( + root, + (PurePosixPath("tests/widget.spec.ts"),), + 2, + config, + expected_commit=commit, + ) + + assert evaluations == 1 + assert execution.outcome is EvidenceOutcome.PASS + + +def test_playwright_rejects_native_destination_parent_aliasing_tmp( + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject an outside spelling whose native destination parent aliases /tmp.""" + root, commit = _repository(tmp_path) + config = _trusted_playwright_config( + trusted_toolchain_dir, _fake_playwright(tmp_path) + ) + manifest = config.playwright_toolchain_manifest + assert manifest is not None + source = Path("/tmp") / f"pdd-playwright-{os.urandom(16).hex()}.so" + source.write_bytes(b"synthetic-native-runtime") + alias = trusted_toolchain_dir / "tmp-alias" + alias.symlink_to("/tmp", target_is_directory=True) + destination = alias / source.name + payload = json.loads(manifest.read_text(encoding="utf-8")) + payload["roles"]["native_runtime"] = [str(destination)] + manifest.write_text(json.dumps(payload), encoding="utf-8") + + def must_not_supervise(*_args, **_kwargs): + raise AssertionError("/tmp parent alias must fail before supervision") + + monkeypatch.setattr(runner_module, "run_supervised", must_not_supervise) + try: + execution, _identities = runner_module._run_playwright_in_tree( + root, + (PurePosixPath("tests/widget.spec.ts"),), + 2, + config, + expected_commit=commit, + ) + finally: + alias.unlink() + source.unlink() + + assert execution.outcome is EvidenceOutcome.COLLECTION_ERROR + assert "parent resolves into sandbox /tmp" in execution.detail + + +@pytest.mark.parametrize( + ("target", "expected"), + [ + ("phase", EvidenceOutcome.COLLECTION_ERROR), + ("trusted", EvidenceOutcome.ERROR), + ], +) +def test_playwright_temp_creation_failure_is_normalized( + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, + target: str, expected: EvidenceOutcome, +) -> None: + """Creation failures remain non-passing evidence in both Playwright lifecycles.""" + root, commit = _repository(tmp_path) + config = _trusted_playwright_config( + trusted_toolchain_dir, _fake_playwright(tmp_path) + ) + + def unavailable(*_args, **_kwargs): + raise PermissionError("temporary directory denied") + + monkeypatch.setattr(runner_module.tempfile, "TemporaryDirectory", unavailable) + paths = (PurePosixPath("tests/widget.spec.ts"),) + if target == "phase": + execution, _identities = runner_module._run_playwright( + root, paths, 2, config + ) + else: + execution, _identities = runner_module._run_playwright_in_tree( + root, paths, 2, config, expected_commit=commit + ) + + assert execution.outcome is expected + assert "temporary directory" in execution.detail + + +@pytest.mark.parametrize( + ("target", "expected"), + [ + ("phase", EvidenceOutcome.COLLECTION_ERROR), + ("trusted", EvidenceOutcome.ERROR), + ], +) +def test_playwright_temp_cleanup_failure_is_normalized( + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, + target: str, expected: EvidenceOutcome, +) -> None: + """Cleanup failures remain non-passing evidence in both Playwright lifecycles.""" + root, commit = _repository(tmp_path) + config = _trusted_playwright_config( + trusted_toolchain_dir, _fake_playwright(tmp_path) + ) + original = runner_module.tempfile.TemporaryDirectory + + class CleanupFailureTemporaryDirectory: + """Delegate setup, then model a checker temporary cleanup failure.""" + + def __init__(self, *args, **kwargs): + self._temporary = original(*args, **kwargs) + + def __enter__(self): + return self._temporary.__enter__() + + def __exit__(self, *args): + self._temporary.__exit__(*args) + raise PermissionError("temporary directory cleanup denied") + + def supervised(command, **kwargs): + _write_private_result(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return subprocess.CompletedProcess(command, 0, "", ""), False + + monkeypatch.setattr( + runner_module.tempfile, "TemporaryDirectory", CleanupFailureTemporaryDirectory + ) + monkeypatch.setattr(runner_module, "run_supervised", supervised) + paths = (PurePosixPath("tests/widget.spec.ts"),) + if target == "phase": + execution, _identities = runner_module._run_playwright( + root, paths, 2, config + ) + else: + execution, _identities = runner_module._run_playwright_in_tree( + root, paths, 2, config, expected_commit=commit + ) + + assert execution.outcome is expected + assert "temporary directory" in execution.detail + + +def test_playwright_temp_body_oserror_propagates_unchanged() -> None: + """Do not normalize a body OSError when temporary cleanup succeeds.""" + with pytest.raises(FileNotFoundError, match="body failure"): + with runner_module._playwright_temporary_directory( + "pdd-test-playwright-", _playwright_host_temp_parent() + ): + raise FileNotFoundError("body failure") + + @pytest.mark.parametrize( "config", [ @@ -649,42 +1035,48 @@ def _run( ): command = fake if isinstance(fake, tuple) else (sys.executable, str(fake)) entrypoint = Path(command[1]) - manifest_root = root.parent if entrypoint.is_relative_to(root) else entrypoint.parent - declared = entrypoint.name - if entrypoint.is_relative_to(root): - declared = "protected-playwright-tool" - (manifest_root / declared).write_bytes(b"protected") - manifest = _toolchain_manifest(manifest_root, Path(command[0]), manifest_root / declared) - if not entrypoint.is_relative_to(root): - command = (command[0], str(_manifest_entrypoint(manifest))) - paths = (PurePosixPath("tests/widget.spec.ts"),) - try: - config_digest = playwright_validator_config_digest( - root, base, paths, code_under_test_paths + with tempfile.TemporaryDirectory( + prefix="pdd-test-playwright-toolchain-", + dir=_playwright_host_temp_parent(), + ) as directory: + manifest_root = Path(directory) + manifest_entrypoint = entrypoint + if entrypoint.is_relative_to(root): + manifest_entrypoint = manifest_root / "protected-playwright-tool" + manifest_entrypoint.write_bytes(b"protected") + manifest = _toolchain_manifest( + manifest_root, Path(command[0]), manifest_entrypoint + ) + if not entrypoint.is_relative_to(root): + command = (command[0], str(_manifest_entrypoint(manifest))) + paths = (PurePosixPath("tests/widget.spec.ts"),) + try: + config_digest = playwright_validator_config_digest( + root, base, paths, code_under_test_paths + ) + except ValueError: + config_digest = "invalid-playwright-config" + obligation = VerificationObligation( + "playwright", "test", "playwright", config_digest, ("REQ-1",), paths, + code_under_test_paths=code_under_test_paths, + ) + profile = VerificationProfile(UNIT, (obligation,), ("REQ-1",), "profile-v1") + return run_profile( + root, + profile, + RunBinding("snapshot-v1", base, head), + AttestationIssue( + AttestationSigner("trusted-ci", b"p" * 32), + "id", + "nonce", + datetime(2026, 7, 10, tzinfo=timezone.utc), + ), + config=RunnerConfig( + timeout_seconds=timeout, + playwright_command=command, + playwright_toolchain_manifest=manifest, + ), ) - except ValueError: - config_digest = "invalid-playwright-config" - obligation = VerificationObligation( - "playwright", "test", "playwright", config_digest, ("REQ-1",), paths, - code_under_test_paths=code_under_test_paths, - ) - profile = VerificationProfile(UNIT, (obligation,), ("REQ-1",), "profile-v1") - return run_profile( - root, - profile, - RunBinding("snapshot-v1", base, head), - AttestationIssue( - AttestationSigner("trusted-ci", b"p" * 32), - "id", - "nonce", - datetime(2026, 7, 10, tzinfo=timezone.utc), - ), - config=RunnerConfig( - timeout_seconds=timeout, - playwright_command=command, - playwright_toolchain_manifest=manifest, - ), - ) @pytest.mark.parametrize( @@ -1180,11 +1572,13 @@ def test_toolchain_manifest_identity_is_relocation_stable_with_relative_symlink( def test_playwright_production_run_requires_and_rechecks_toolchain_manifest( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch ) -> None: root, commit = _repository(tmp_path) runner = _fake_playwright(tmp_path) - manifest = _toolchain_manifest(tmp_path / "protected-toolchain", Path(sys.executable), runner) + manifest = _toolchain_manifest( + trusted_toolchain_dir / "protected-toolchain", Path(sys.executable), runner + ) installed = _manifest_entrypoint(manifest) original_supervised = runner_module.run_supervised @@ -1229,9 +1623,9 @@ def mutate_after_final_run(*args, **kwargs): result = original(*args, **kwargs) calls += 1 if calls == 3: - installed = next( - tmp_path.glob("**/node_modules/@playwright/test/cli*") - ) + manifest = args[3].playwright_toolchain_manifest + assert manifest is not None + installed = _manifest_entrypoint(manifest) installed.write_text( installed.read_text(encoding="utf-8") + "# post-run drift\n" ) From 33a98428ac8de7e2d7e4c4ebe522348dc9c0145a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 18:28:10 -0700 Subject: [PATCH 060/233] test(sync): cover private Playwright config overlay --- .github/workflows/unit-tests.yml | 4 +- tests/test_sync_core_runner_playwright.py | 111 +++++++++++++++++++++- tests/test_sync_core_supervisor.py | 46 +++++++++ 3 files changed, 154 insertions(+), 7 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3824d9a137..5057a9cb30 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -262,7 +262,7 @@ jobs: PDD_RUN_REAL_PLAYWRIGHT: '1' run: > pytest -q - tests/test_sync_core_runner_playwright.py::test_real_playwright_source_or_wheel_protocol_uses_browser + tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir --timeout=90 - name: Validate architecture vs prompt includes (fixture smoke) @@ -464,7 +464,7 @@ jobs: cp tests/test_sync_core_runner_playwright.py "$smoke_dir/" cd "$smoke_dir" "$RUNNER_TEMP/pdd-wheel-smoke/bin/pytest" -q \ - test_sync_core_runner_playwright.py::test_real_playwright_source_or_wheel_protocol_uses_browser \ + test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir \ --timeout=90 - name: Verify packaged Vitest grammars offline diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 948354e54c..45af7428a1 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -204,6 +204,30 @@ def _repository( return root, _git(root, "rev-parse", "HEAD") +def _repository_with_playwright_config_suffix( + tmp_path: Path, suffix: str, js_scope: str | None, +) -> tuple[Path, str]: + """Create one committed config whose relative paths require its real directory.""" + root, _commit = _repository(tmp_path) + (root / "playwright.config.ts").unlink() + commonjs = suffix == ".cjs" or ( + suffix == ".js" and js_scope == "commonjs" + ) + config = ( + "module.exports = { testDir: './tests' };\n" + if commonjs else "export default { testDir: './tests' };\n" + ) + (root / f"playwright.config{suffix}").write_text(config, encoding="utf-8") + if suffix == ".js": + assert js_scope is not None + (root / "package.json").write_text( + json.dumps({"type": js_scope}), encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", f"protected Playwright {suffix} config") + return root, _git(root, "rev-parse", "HEAD") + + def _toolchain_manifest(directory: Path, launcher: Path, entrypoint: Path) -> Path: directory.mkdir(parents=True, exist_ok=True) dependencies = directory / "node_modules" @@ -273,10 +297,22 @@ def _trusted_playwright_config( or not os.environ.get("PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"), reason="requires the mandatory hosted Linux Playwright protocol lane", ) -def test_real_playwright_source_or_wheel_protocol_uses_browser( - tmp_path: Path, +@pytest.mark.parametrize( + ("suffix", "js_scope"), + [ + (".js", "commonjs"), + (".js", "module"), + (".cjs", None), + (".mjs", None), + (".ts", None), + (".cts", None), + (".mts", None), + ], +) +def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( + tmp_path: Path, suffix: str, js_scope: str | None, ) -> None: - """Run collection and execution through bwrap with bundled Chromium.""" + """Run every admitted config syntax through Playwright and Chromium.""" if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): module_path = Path(runner_module.__file__).resolve() assert "site-packages" in module_path.parts, module_path @@ -297,9 +333,19 @@ def test_real_playwright_source_or_wheel_protocol_uses_browser( "});\n", encoding="utf-8", ) - (root / "playwright.config.ts").write_text( - "export default {};\n", encoding="utf-8" + commonjs = suffix == ".cjs" or ( + suffix == ".js" and js_scope == "commonjs" ) + config = ( + "module.exports = { testDir: './tests' };\n" + if commonjs else "export default { testDir: './tests' };\n" + ) + (root / f"playwright.config{suffix}").write_text(config, encoding="utf-8") + if suffix == ".js": + assert js_scope is not None + (root / "package.json").write_text( + json.dumps({"type": js_scope}), encoding="utf-8" + ) _git(root, "add", ".") _git(root, "commit", "-q", "-m", "protected real Playwright test") commit = _git(root, "rev-parse", "HEAD") @@ -569,6 +615,61 @@ def supervised(command, **_kwargs): assert temp_directories[0] == Path("/tmp") +@pytest.mark.parametrize( + ("suffix", "js_scope"), + [ + (".js", "commonjs"), + (".js", "module"), + (".cjs", None), + (".mjs", None), + (".ts", None), + (".cts", None), + (".mts", None), + ], +) +def test_playwright_linux_config_wrapper_uses_private_overlay_and_data_mount( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, suffix: str, + js_scope: str | None, +) -> None: + """Inject the wrapper only in a same-directory private repository overlay.""" + root, commit = _repository_with_playwright_config_suffix( + tmp_path, suffix, js_scope + ) + source_identity = _directory_identity(root) + observed: list[tuple[Path, bytes]] = [] + + def supervised(command, **kwargs): + assert kwargs["private_overlays"] == ((root.resolve(), root.resolve()),) + data_mounts = kwargs["readable_data"] + assert len(data_mounts) == 1 + wrapper, destination = data_mounts[0] + assert destination.parent == root.resolve() + assert destination.suffix == suffix + assert not destination.exists() + assert b"--no-wasm-trap-handler" in wrapper + assert str((root / f"playwright.config{suffix}").resolve()).encode() in wrapper + assert all(destination != path for path in kwargs["readable_roots"]) + assert all(destination != path for path in kwargs["writable_roots"]) + assert f"--config={destination}" in command + observed.append((destination, wrapper)) + _write_private_result(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return subprocess.CompletedProcess(command, 0, "", ""), False + + monkeypatch.setattr(runner_module.sys, "platform", "linux") + monkeypatch.setattr( + runner_module, "_released_runtime_closure_digest", lambda: "runtime" + ) + monkeypatch.setattr(runner_module, "run_supervised", supervised) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert executions[0].outcome is EvidenceOutcome.PASS + assert len(observed) == 3 + assert _directory_identity(root) == source_identity + assert not list(root.glob(".pdd-trusted-playwright-*")) + + def test_playwright_checker_temp_roots_cannot_alias_sandbox_tmp( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 2483d8d098..6a08e23d8a 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -249,6 +249,52 @@ def test_linux_sandbox_rejects_conflicting_bindings( ) +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not shutil.which("bwrap"), + reason="requires Linux bubblewrap overlay containment", +) +def test_linux_sandbox_private_overlay_keeps_wrapper_and_writes_off_host( + tmp_path: Path, +) -> None: + """Exercise the namespace-private overlay and immutable data mount.""" + candidate = tmp_path / "candidate" + candidate.mkdir() + original = candidate / "playwright.config.ts" + original.write_text("candidate config", encoding="utf-8") + wrapper = candidate / ".pdd-trusted-playwright.ts" + scratch = tmp_path / "scratch" + scratch.mkdir() + program = "\n".join(( + "from pathlib import Path", + "import sys", + "wrapper = Path(sys.argv[1])", + "assert wrapper.read_text() == 'trusted config'", + "try:", + " wrapper.write_text('candidate replacement')", + "except OSError:", + " pass", + "else:", + " raise SystemExit('wrapper mount is writable')", + "Path('candidate-private-write').write_text('private')", + )) + + result, surviving = run_supervised( + [sys.executable, "-c", program, str(wrapper)], + cwd=candidate, + timeout=10, + env=dict(os.environ), + writable_roots=(scratch,), + private_overlays=((candidate, candidate),), + readable_data=((b"trusted config", wrapper),), + ) + + assert result.returncode == 0, result.stderr + assert surviving is False + assert original.read_text(encoding="utf-8") == "candidate config" + assert not wrapper.exists() + assert not (candidate / "candidate-private-write").exists() + + def test_linux_sandbox_fails_closed_for_root_caller( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 08faa2f6379dc27834c6d020c913899678f5e71a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 18:54:38 -0700 Subject: [PATCH 061/233] fix(sync): inject Playwright browser flag privately --- .github/workflows/unit-tests.yml | 40 ++++++- pdd/sync_core/runner.py | 85 +++++++++++++- pdd/sync_core/supervisor.py | 130 +++++++++++++++++++--- tests/test_sync_core_runner_playwright.py | 15 ++- tests/test_sync_core_supervisor.py | 75 +++++++++++++ 5 files changed, 315 insertions(+), 30 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 5057a9cb30..f7627cfb3e 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -159,8 +159,27 @@ jobs: - name: Provision and verify protected Linux sandbox run: | + set -euo pipefail sudo apt-get update - sudo apt-get install --yes bubblewrap + sudo apt-get install --yes meson ninja-build pkg-config libcap-dev libselinux1-dev + bwrap_version=0.11.1 + bwrap_prefix="$RUNNER_TEMP/pdd-bwrap-$bwrap_version" + bwrap_archive="$RUNNER_TEMP/bubblewrap-$bwrap_version.tar.xz" + curl --fail --location --retry 3 \ + --output "$bwrap_archive" \ + "https://github.com/containers/bubblewrap/releases/download/v$bwrap_version/bubblewrap-$bwrap_version.tar.xz" + echo "c1b7455a1283b1295879a46d5f001dfd088c0bb0f238abb5e128b3583a246f71 $bwrap_archive" | sha256sum --check + tar --extract --file "$bwrap_archive" --directory "$RUNNER_TEMP" + meson setup "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" \ + "$RUNNER_TEMP/bubblewrap-$bwrap_version" --prefix "$bwrap_prefix" + meson compile -C "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" + meson install -C "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" + export PATH="$bwrap_prefix/bin:$PATH" + bwrap --version | grep -F "$bwrap_version" + bwrap --help | grep -F -- '--overlay-src' + bwrap --help | grep -F -- '--tmp-overlay' + bwrap --help | grep -F -- '--ro-bind-data' + echo "$bwrap_prefix/bin" >> "$GITHUB_PATH" private_root="$(mktemp -d)" chmod 700 "$private_root" mkdir -m 700 "$private_root/scratch" @@ -383,7 +402,24 @@ jobs: run: | set -euo pipefail sudo apt-get update - sudo apt-get install --yes bubblewrap + sudo apt-get install --yes meson ninja-build pkg-config libcap-dev libselinux1-dev + bwrap_version=0.11.1 + bwrap_prefix="$RUNNER_TEMP/pdd-bwrap-$bwrap_version" + bwrap_archive="$RUNNER_TEMP/bubblewrap-$bwrap_version.tar.xz" + curl --fail --location --retry 3 \ + --output "$bwrap_archive" \ + "https://github.com/containers/bubblewrap/releases/download/v$bwrap_version/bubblewrap-$bwrap_version.tar.xz" + echo "c1b7455a1283b1295879a46d5f001dfd088c0bb0f238abb5e128b3583a246f71 $bwrap_archive" | sha256sum --check + tar --extract --file "$bwrap_archive" --directory "$RUNNER_TEMP" + meson setup "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" \ + "$RUNNER_TEMP/bubblewrap-$bwrap_version" --prefix "$bwrap_prefix" + meson compile -C "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" + meson install -C "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" + "$bwrap_prefix/bin/bwrap" --version | grep -F "$bwrap_version" + "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--overlay-src' + "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--tmp-overlay' + "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--ro-bind-data' + echo "$bwrap_prefix/bin" >> "$GITHUB_PATH" toolchain="$RUNNER_TEMP/pdd-playwright-toolchain" browser_cache="$toolchain/browser-runtime" PLAYWRIGHT_BROWSERS_PATH="$browser_cache" npm install --prefix "$toolchain" --ignore-scripts --no-audit --no-fund @playwright/test@1.55.0 diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 5dd29816c9..8c53aa7986 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1533,7 +1533,9 @@ def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: return found[0], content -def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePosixPath]: +def _playwright_static_config( + path: PurePosixPath, source: bytes, *, commonjs: bool = False, +) -> set[PurePosixPath]: """Validate a data-only AST config and return its bound local references.""" tree = _javascript_parser(path).parse(source) if tree.root_node.has_error: @@ -1565,7 +1567,9 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos config_node = statement.child_by_field_name("value") if config_node is None: raise ValueError("Playwright config must export one object") - elif statement.type == "expression_statement" and path.suffix == ".cjs": + elif statement.type == "expression_statement" and ( + path.suffix in {".cjs", ".cts"} or path.suffix == ".js" and commonjs + ): expression = statement.named_children[0] if statement.named_children else None if expression is None or expression.type != "assignment_expression": raise ValueError("Playwright CommonJS config must assign module.exports") @@ -1595,6 +1599,28 @@ def _playwright_static_config(path: PurePosixPath, source: bytes) -> set[PurePos return references +def _playwright_config_is_commonjs( + root: Path, ref: str, path: PurePosixPath, +) -> bool: + """Return the committed Node module mode for one Playwright config file.""" + if path.suffix in {".cjs", ".cts"}: + return True + if path.suffix in {".mjs", ".mts"}: + return False + if path.suffix != ".js": + return False + scope = _nearest_package_scope(root, ref, path) + if scope is None: + return True + try: + package = json.loads(scope[1].decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("Playwright config package scope is invalid") from exc + if not isinstance(package, dict): + raise ValueError("Playwright config package scope must be an object") + return package.get("type") != "module" + + def _node_text(source: bytes, node: Node) -> str: """Return the exact UTF-8 text covered by an AST node.""" return source[node.start_byte:node.end_byte].decode("utf-8") @@ -1744,10 +1770,17 @@ def _playwright_support_closure( if config_mode not in {"100644", "100755"}: raise ValueError("Playwright config must be a regular non-symlink file") paths = {config_path} + scope = _nearest_package_scope(root, ref, config_path) + if scope is not None: + paths.add(scope[0] / "package.json") product_paths = frozenset(code_under_test_paths) all_owners = frozenset(test_paths) pending = [ - (item, all_owners) for item in _playwright_static_config(config_path, config_source) + (item, all_owners) for item in _playwright_static_config( + config_path, + config_source, + commonjs=_playwright_config_is_commonjs(root, ref, config_path), + ) ] + [(item, frozenset({item})) for item in test_paths] visited: dict[PurePosixPath, frozenset[PurePosixPath]] = {} snapshot_owners: set[PurePosixPath] = set() @@ -4267,6 +4300,37 @@ def _playwright_runtime_prefix( return prefix +_PLAYWRIGHT_CHROMIUM_WASM_TRAP_FLAG = "--js-flags=--no-wasm-trap-handler" + + +def _playwright_linux_config_data( + root: Path, config_path: PurePosixPath, +) -> tuple[bytes, Path]: + """Build a same-directory config wrapper for a private sandbox overlay.""" + candidate = root / config_path + destination = candidate.parent / ( + f".pdd-trusted-playwright-{os.urandom(16).hex()}{config_path.suffix}" + ) + candidate_literal = json.dumps(str(candidate)) + injected_use = ( + "use: {launchOptions: {args: [" + + json.dumps(_PLAYWRIGHT_CHROMIUM_WASM_TRAP_FLAG) + + "]}}" + ) + commonjs = _playwright_config_is_commonjs(root, "HEAD", config_path) + if commonjs: + source = ( + f"const candidateConfig = require({candidate_literal});\n" + "module.exports = {...candidateConfig, " + injected_use + "};\n" + ) + else: + source = ( + f"import candidateConfig from {candidate_literal};\n" + "export default {...candidateConfig, " + injected_use + "};\n" + ) + return source.encode("utf-8"), destination + + def _playwright_reported_failure_detail(tests: list[dict[str, object]]) -> str: """Return one bounded reporter diagnostic for a failed protected test.""" errors = [ @@ -4627,10 +4691,19 @@ def _run_playwright_in_tree( return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) ), () + config_data: tuple[bytes, Path] | None = None + if sys.platform.startswith("linux"): + try: + config_data = _playwright_linux_config_data(root, config_path) + except ValueError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, "playwright-config", str(exc) + ), () + runtime_config = config_data[1] if config_data is not None else root / config_path command = [ *_playwright_runtime_prefix(prefix, roles.launcher), "test", *(path.as_posix() for path in paths), - f"--config={root / config_path}", f"--reporter={reporter}", + f"--config={runtime_config}", f"--reporter={reporter}", "--update-snapshots=none", f"--output={scratch / 'results'}", ] if collection: @@ -4652,6 +4725,10 @@ def _run_playwright_in_tree( temp_directory=Path("/tmp"), readable_roots=(reporter, *roles.readable_roots), readable_bindings=native_bindings, + private_overlays=( + ((root.resolve(), root.resolve()),) if config_data is not None else () + ), + readable_data=((config_data,) if config_data is not None else ()), result_fifo=result_fifo, result_fd=result_fd, ) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 1f8f613f2a..9911883c9b 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -5,6 +5,8 @@ import os import json +import base64 +import re import shutil import signal import subprocess @@ -174,13 +176,15 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: str(limits.max_output_bytes), "256", *command] -def _staged_bwrap(argv: list[str], sources: list[Path]) -> list[str]: - """Stage exact bind mounts before replacing the namespace root.""" +def _staged_bwrap( + argv: list[str], sources: list[Path], data: tuple[bytes, ...] = (), +) -> list[str]: + """Stage exact bind mounts and anonymous immutable data for Bubblewrap.""" helper = "\n".join(( - "import json,os,pathlib,shutil,subprocess,sys,tempfile", - "argv=json.loads(sys.argv[1]); paths=json.loads(sys.argv[2])", + "import base64,json,os,pathlib,shutil,subprocess,sys,tempfile", + "data=json.loads(sys.argv[1]); argv=json.loads(sys.argv[2]); paths=json.loads(sys.argv[3])", "base=pathlib.Path(tempfile.mkdtemp(prefix='pdd-binds-',dir='/run'))", - "os.chmod(base,0o755); staged=[]; result=None", + "os.chmod(base,0o755); staged=[]; data_fds=[]; result=None", "try:", " for index,source in enumerate(paths):", " source=pathlib.Path(source); target=base/str(index)", @@ -188,15 +192,51 @@ def _staged_bwrap(argv: list[str], sources: list[Path]) -> list[str]: " subprocess.run(['mount','--bind',str(source),str(target)],check=True)", " staged.append(target)", " argv=[str(staged[int(x[4:-1])]) if x.startswith('@FD:') else x for x in argv]", - " result=subprocess.run(argv,check=False)", + " for index,encoded in enumerate(data):", + " fd=os.memfd_create('pdd-sandbox-data-'+str(index),os.MFD_CLOEXEC)", + " content=base64.b64decode(encoded); os.write(fd,content); os.lseek(fd,0,os.SEEK_SET)", + " data_fds.append(fd)", + " argv=[str(data_fds[int(x[6:-1])]) if x.startswith('@DATA:') else x for x in argv]", + " result=subprocess.run(argv,check=False,pass_fds=tuple(data_fds))", "finally:", + " for fd in data_fds: os.close(fd)", " for target in reversed(staged):", " subprocess.run(['umount',str(target)],check=False)", " shutil.rmtree(base,ignore_errors=True)", "raise SystemExit(result.returncode if result is not None else 1)", )) return ["sudo", "-n", "-E", str(_SUPERVISOR_EXECUTABLE), "-c", helper, - json.dumps(argv), json.dumps([str(path) for path in sources])] + json.dumps([base64.b64encode(item).decode("ascii") for item in data]), + json.dumps(argv), + json.dumps([str(path) for path in sources])] + + +_BWRAP_OVERLAY_MIN_VERSION = (0, 11, 1) + + +def _bwrap_overlay_capability(bwrap: str) -> str | None: + """Return why Bubblewrap cannot safely create private overlay data mounts.""" + version = subprocess.run( + [bwrap, "--version"], capture_output=True, text=True, check=False + ) + if version.returncode != 0: + return "protected sandbox cannot determine Bubblewrap version" + match = re.search(r"(\d+)\.(\d+)\.(\d+)", version.stdout) + if match is None: + return "protected sandbox cannot parse Bubblewrap version" + parsed = tuple(int(value) for value in match.groups()) + if parsed < _BWRAP_OVERLAY_MIN_VERSION: + required = ".".join(str(value) for value in _BWRAP_OVERLAY_MIN_VERSION) + return f"protected sandbox requires Bubblewrap {required} for private overlays" + help_result = subprocess.run( + [bwrap, "--help"], capture_output=True, text=True, check=False + ) + required_flags = ("--overlay-src", "--tmp-overlay", "--ro-bind-data") + if help_result.returncode != 0 or any( + flag not in help_result.stdout for flag in required_flags + ): + return "protected sandbox Bubblewrap lacks private overlay data-mount support" + return None def _private_result_command( @@ -315,6 +355,8 @@ def _sandbox_command( writable_files: tuple[Path, ...] = (), limits: SupervisorLimits = SupervisorLimits(), readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), + private_overlays: tuple[tuple[Path, Path], ...] = (), + readable_data: tuple[tuple[bytes, Path], ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), result_fifo: Path | None = None, result_fd: int = 198, @@ -325,7 +367,7 @@ def _sandbox_command( raise RuntimeError( "unsupported protected sandbox: macOS cannot prove process lifetime isolation" ) - if sys.platform.startswith("linux") and shutil.which("bwrap"): + if sys.platform.startswith("linux") and (bwrap := shutil.which("bwrap")): if os.getuid() == 0: raise RuntimeError( "protected sandbox requires a non-root caller so process limits " @@ -338,13 +380,28 @@ def _sandbox_command( setpriv = shutil.which("setpriv") if setpriv is None: raise RuntimeError("protected sandbox requires setpriv for post-mount uid drop") + if private_overlays or readable_data: + capability_error = _bwrap_overlay_capability(bwrap) + if capability_error is not None: + raise RuntimeError(capability_error) workdir = (cwd or Path.cwd()).resolve() - argv = ["bwrap", "--unshare-ipc", "--unshare-pid", "--unshare-net", + argv = [bwrap, "--unshare-ipc", "--unshare-pid", "--unshare-net", "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp"] sources: list[Path] = [] destination_dirs = {Path("/tmp")} mounted: dict[Path, tuple[str, Path]] = {} + + def ensure_destination_parent(destination: Path) -> None: + missing = [] + parent = destination.parent + while parent != Path("/") and parent not in destination_dirs: + missing.append(parent) + parent = parent.parent + for directory in reversed(missing): + argv.extend(("--dir", str(directory))) + destination_dirs.add(directory) + def bind(option: str, source: Path, destination: Path | None = None) -> None: destination = destination or source binding = (option, source.resolve()) @@ -356,21 +413,53 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: f"protected sandbox has conflicting bindings for {destination}" ) mounted[destination] = binding - missing = [] - parent = destination.parent - while parent != Path("/") and parent not in destination_dirs: - missing.append(parent) - parent = parent.parent - for directory in reversed(missing): - argv.extend(("--dir", str(directory))) - destination_dirs.add(directory) + ensure_destination_parent(destination) sources.append(source) argv.extend((option, f"@FD:{len(sources) - 1}@", str(destination))) if destination.is_dir(): destination_dirs.add(destination) + + def private_overlay(source: Path, destination: Path) -> None: + if not destination.is_absolute(): + raise RuntimeError("protected sandbox overlay destination must be absolute") + try: + source = source.resolve(strict=True) + except OSError as exc: + raise RuntimeError("protected sandbox overlay source is unavailable") from exc + if not source.is_dir(): + raise RuntimeError("protected sandbox overlay source must be a directory") + if destination in mounted: + raise RuntimeError( + f"protected sandbox has conflicting bindings for {destination}" + ) + ensure_destination_parent(destination) + argv.extend(("--dir", str(destination))) + destination_dirs.add(destination) + argv.extend(("--overlay-src", str(source), "--tmp-overlay", str(destination))) + mounted[destination] = ("--tmp-overlay", source) + + data: list[bytes] = [] + + def bind_data(content: bytes, destination: Path) -> None: + if not destination.is_absolute(): + raise RuntimeError("protected sandbox data destination must be absolute") + if destination in mounted: + raise RuntimeError( + f"protected sandbox has conflicting bindings for {destination}" + ) + ensure_destination_parent(destination) + mounted[destination] = ("--ro-bind-data", Path("/dev/null")) + data.append(content) + argv.extend(("--ro-bind-data", f"@DATA:{len(data) - 1}@", str(destination))) + for source, destination in writable_bindings: bind("--bind", source.resolve(), destination) + for source, destination in private_overlays: + private_overlay(source, destination) for item in _runtime_roots(command, workdir): + existing_mount = mounted.get(item) + if existing_mount is not None and existing_mount[0] == "--tmp-overlay": + continue # A host bind follows symlinks, but the process command and ELF # loader retain their original spellings in the new namespace. bind("--ro-bind", item.resolve(), item) @@ -394,6 +483,8 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: # executes any candidate-controlled code. Binding only its # dedicated directory keeps the reporter and toolchain immutable. bind("--bind", result_fifo.parent.resolve()) + for content, destination in readable_data: + bind_data(content, destination) argv.extend(("--chdir", str(workdir))) drop = ( [setpriv, "--reuid", str(os.getuid()), "--regid", str(os.getgid()), @@ -403,7 +494,7 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: if result_fifo is not None: sandboxed = _private_result_command(sandboxed, result_fifo, result_fd) argv.extend(("--", *drop, *sandboxed)) - return _staged_bwrap(argv, sources), None + return _staged_bwrap(argv, sources, tuple(data)), None raise RuntimeError("unsupported sandbox platform or mechanism") @@ -413,6 +504,8 @@ def run_supervised( limits: SupervisorLimits = SupervisorLimits(), readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), + private_overlays: tuple[tuple[Path, Path], ...] = (), + readable_data: tuple[tuple[bytes, Path], ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), temp_directory: Path | None = None, result_fifo: Path | None = None, @@ -425,6 +518,7 @@ def run_supervised( command, writable_roots, cwd=cwd, writable_files=writable_files, limits=limits, readable_roots=readable_roots, readable_bindings=readable_bindings, + private_overlays=private_overlays, readable_data=readable_data, writable_bindings=writable_bindings, result_fifo=result_fifo, result_fd=result_fd, ) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 45af7428a1..afc8915b29 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -327,7 +327,8 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( (root / "tests").mkdir() (root / "tests/widget.spec.ts").write_text( "import { expect, test } from '@playwright/test';\n" - "test('real protected browser', async ({ page }) => {\n" + "test('real protected browser', async ({ page }, testInfo) => {\n" + " await expect(testInfo.config.metadata.pddCandidateConfigLoaded).toBe(true);\n" " await page.goto('data:text/html,Widget');\n" " await expect(page).toHaveTitle('Widget');\n" "});\n", @@ -337,8 +338,9 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( suffix == ".js" and js_scope == "commonjs" ) config = ( - "module.exports = { testDir: './tests' };\n" - if commonjs else "export default { testDir: './tests' };\n" + "module.exports = { testDir: './tests', metadata: { pddCandidateConfigLoaded: true } };\n" + if commonjs + else "export default { testDir: './tests', metadata: { pddCandidateConfigLoaded: true } };\n" ) (root / f"playwright.config{suffix}").write_text(config, encoding="utf-8") if suffix == ".js": @@ -639,15 +641,16 @@ def test_playwright_linux_config_wrapper_uses_private_overlay_and_data_mount( observed: list[tuple[Path, bytes]] = [] def supervised(command, **kwargs): - assert kwargs["private_overlays"] == ((root.resolve(), root.resolve()),) + phase_root = kwargs["cwd"].resolve() + assert kwargs["private_overlays"] == ((phase_root, phase_root),) data_mounts = kwargs["readable_data"] assert len(data_mounts) == 1 wrapper, destination = data_mounts[0] - assert destination.parent == root.resolve() + assert destination.parent == phase_root assert destination.suffix == suffix assert not destination.exists() assert b"--no-wasm-trap-handler" in wrapper - assert str((root / f"playwright.config{suffix}").resolve()).encode() in wrapper + assert str(phase_root / f"playwright.config{suffix}").encode() in wrapper assert all(destination != path for path in kwargs["readable_roots"]) assert all(destination != path for path in kwargs["writable_roots"]) assert f"--config={destination}" in command diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 6a08e23d8a..d739df54f4 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -249,6 +249,81 @@ def test_linux_sandbox_rejects_conflicting_bindings( ) +def test_linux_sandbox_private_overlay_requires_supported_bubblewrap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fail closed rather than silently degrading a private overlay mount.""" + candidate = tmp_path / "candidate" + candidate.mkdir() + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + + def completed(command, **_kwargs): + if command[-1] == "--version": + return subprocess.CompletedProcess(command, 0, "bubblewrap 0.9.0\n", "") + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", completed) + + with pytest.raises(RuntimeError, match="requires Bubblewrap 0.11.1"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + private_overlays=((candidate, candidate),), + readable_data=((b"trusted", candidate / ".pdd-wrapper"),), + ) + + +def test_linux_sandbox_constructs_private_overlay_and_data_mount( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Build an overlay first, then install wrapper bytes only in its namespace.""" + candidate = tmp_path / "candidate" + candidate.mkdir() + wrapper = candidate / ".pdd-wrapper" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + + def completed(command, **_kwargs): + if command[-1] == "--version": + return subprocess.CompletedProcess(command, 0, "bubblewrap 0.11.1\n", "") + if command[-1] == "--help": + return subprocess.CompletedProcess( + command, 0, "--overlay-src --tmp-overlay --ro-bind-data", "" + ) + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", completed) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + + argv, _profile = _sandbox_command( + ["/bin/true"], + (tmp_path,), + cwd=candidate, + private_overlays=((candidate, candidate),), + readable_data=((b"trusted wrapper", wrapper),), + ) + + bwrap = json.loads(argv[-2]) + assert bwrap.index("--tmp-overlay") < bwrap.index("--ro-bind-data") + assert bwrap[bwrap.index("--overlay-src") + 1] == str(candidate.resolve()) + assert bwrap[bwrap.index("--tmp-overlay") + 1] == str(candidate) + assert bwrap[bwrap.index("--ro-bind-data") + 2] == str(wrapper) + read_only_destinations = { + bwrap[index + 2] for index, value in enumerate(bwrap[:-2]) + if value == "--ro-bind" + } + assert str(candidate) not in read_only_destinations + assert json.loads(argv[-3]) + assert str(wrapper) not in json.loads(argv[-1]) + + @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap"), reason="requires Linux bubblewrap overlay containment", From 6cd272da12aaa4452238b273d6f467a21386f48c Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 19:08:10 -0700 Subject: [PATCH 062/233] test(sync): cover private overlay hardening --- .github/workflows/unit-tests.yml | 2 + tests/test_sync_core_supervisor.py | 87 ++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index f7627cfb3e..c4e97f2e6f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -179,6 +179,7 @@ jobs: bwrap --help | grep -F -- '--overlay-src' bwrap --help | grep -F -- '--tmp-overlay' bwrap --help | grep -F -- '--ro-bind-data' + bwrap --help | grep -F -- '--remount-ro' echo "$bwrap_prefix/bin" >> "$GITHUB_PATH" private_root="$(mktemp -d)" chmod 700 "$private_root" @@ -419,6 +420,7 @@ jobs: "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--overlay-src' "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--tmp-overlay' "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--ro-bind-data' + "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--remount-ro' echo "$bwrap_prefix/bin" >> "$GITHUB_PATH" toolchain="$RUNNER_TEMP/pdd-playwright-toolchain" browser_cache="$toolchain/browser-runtime" diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index d739df54f4..e4a65c5308 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -276,6 +276,32 @@ def completed(command, **_kwargs): ) +def test_linux_sandbox_private_overlay_normalizes_capability_probe_oserror( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fail closed with a controlled error when Bubblewrap cannot be probed.""" + candidate = tmp_path / "candidate" + candidate.mkdir() + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + + def probe(command, **_kwargs): + if command[-1] == "--version": + raise OSError("exec failed") + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", probe) + + with pytest.raises(RuntimeError, match="cannot execute Bubblewrap capability probe"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + private_overlays=((candidate, candidate),), + ) + + def test_linux_sandbox_constructs_private_overlay_and_data_mount( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -293,7 +319,8 @@ def completed(command, **_kwargs): return subprocess.CompletedProcess(command, 0, "bubblewrap 0.11.1\n", "") if command[-1] == "--help": return subprocess.CompletedProcess( - command, 0, "--overlay-src --tmp-overlay --ro-bind-data", "" + command, 0, + "--overlay-src --tmp-overlay --ro-bind-data --remount-ro", "", ) return subprocess.CompletedProcess(command, 0, "", "") @@ -303,7 +330,7 @@ def completed(command, **_kwargs): ) argv, _profile = _sandbox_command( - ["/bin/true"], + ["/bin/true", "@DATA:literal-candidate-value"], (tmp_path,), cwd=candidate, private_overlays=((candidate, candidate),), @@ -311,10 +338,21 @@ def completed(command, **_kwargs): ) bwrap = json.loads(argv[-2]) + staged_sources = json.loads(argv[-1]) + staged_tokens = json.loads(argv[-4]) assert bwrap.index("--tmp-overlay") < bwrap.index("--ro-bind-data") - assert bwrap[bwrap.index("--overlay-src") + 1] == str(candidate.resolve()) + overlay_token = bwrap[bwrap.index("--overlay-src") + 1] + assert overlay_token in staged_tokens + assert staged_sources[staged_tokens.index(overlay_token)] == str(candidate.resolve()) + assert overlay_token != str(candidate.resolve()) assert bwrap[bwrap.index("--tmp-overlay") + 1] == str(candidate) assert bwrap[bwrap.index("--ro-bind-data") + 2] == str(wrapper) + data_mount = bwrap.index("--ro-bind-data") + assert bwrap[data_mount - 2:data_mount] == ["--perms", "0444"] + remount = bwrap.index("--remount-ro") + assert data_mount < remount + assert bwrap[remount + 1] == str(candidate) + assert "@DATA:literal-candidate-value" in bwrap[bwrap.index("--") + 1:] read_only_destinations = { bwrap[index + 2] for index, value in enumerate(bwrap[:-2]) if value == "--ro-bind" @@ -324,6 +362,38 @@ def completed(command, **_kwargs): assert str(wrapper) not in json.loads(argv[-1]) +def test_linux_sandbox_rejects_oversized_anonymous_data( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep base64 command transport well below the host argument limit.""" + candidate = tmp_path / "candidate" + candidate.mkdir() + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + + def completed(command, **_kwargs): + if command[-1] == "--version": + return subprocess.CompletedProcess(command, 0, "bubblewrap 0.11.1\n", "") + if command[-1] == "--help": + return subprocess.CompletedProcess( + command, 0, + "--overlay-src --tmp-overlay --ro-bind-data --remount-ro", "", + ) + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", completed) + + with pytest.raises(RuntimeError, match="anonymous data exceeds"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + private_overlays=((candidate, candidate),), + readable_data=((b"x" * (65 * 1024), candidate / ".wrapper"),), + ) + + @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap"), reason="requires Linux bubblewrap overlay containment", @@ -341,8 +411,10 @@ def test_linux_sandbox_private_overlay_keeps_wrapper_and_writes_off_host( scratch.mkdir() program = "\n".join(( "from pathlib import Path", + "import os", "import sys", "wrapper = Path(sys.argv[1])", + "assert os.getuid() == int(sys.argv[2])", "assert wrapper.read_text() == 'trusted config'", "try:", " wrapper.write_text('candidate replacement')", @@ -350,11 +422,16 @@ def test_linux_sandbox_private_overlay_keeps_wrapper_and_writes_off_host( " pass", "else:", " raise SystemExit('wrapper mount is writable')", - "Path('candidate-private-write').write_text('private')", + "try:", + " Path('candidate-private-write').write_text('private')", + "except OSError:", + " pass", + "else:", + " raise SystemExit('repository overlay is writable')", )) result, surviving = run_supervised( - [sys.executable, "-c", program, str(wrapper)], + [sys.executable, "-c", program, str(wrapper), str(os.getuid())], cwd=candidate, timeout=10, env=dict(os.environ), From fbd9a8f6fa2c1c4ba03c16134629a5bfbd05ce49 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 19:17:47 -0700 Subject: [PATCH 063/233] fix(sync): harden private Playwright overlay --- pdd/sync_core/supervisor.py | 77 +++++++++++++++++++++++------- tests/test_sync_core_supervisor.py | 47 ++++++++++++++---- 2 files changed, 99 insertions(+), 25 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 9911883c9b..ec2c991abf 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -177,12 +177,21 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: def _staged_bwrap( - argv: list[str], sources: list[Path], data: tuple[bytes, ...] = (), + argv: list[str], sources: list[Path], path_tokens: list[str], + data: tuple[bytes, ...] = (), data_tokens: tuple[str, ...] = (), ) -> list[str]: """Stage exact bind mounts and anonymous immutable data for Bubblewrap.""" + if len(data) != len(data_tokens): + raise RuntimeError("protected sandbox has invalid anonymous data staging") + if any(len(item) > _MAX_STAGED_DATA_BYTES for item in data) or sum( + len(item) for item in data + ) > _MAX_STAGED_DATA_BYTES: + raise RuntimeError("protected sandbox anonymous data exceeds 64 KiB limit") helper = "\n".join(( "import base64,json,os,pathlib,shutil,subprocess,sys,tempfile", - "data=json.loads(sys.argv[1]); argv=json.loads(sys.argv[2]); paths=json.loads(sys.argv[3])", + "data=json.loads(sys.argv[1]); path_tokens=json.loads(sys.argv[2])", + ("data_tokens=json.loads(sys.argv[3]); argv=json.loads(sys.argv[4]); " + "paths=json.loads(sys.argv[5])"), "base=pathlib.Path(tempfile.mkdtemp(prefix='pdd-binds-',dir='/run'))", "os.chmod(base,0o755); staged=[]; data_fds=[]; result=None", "try:", @@ -191,12 +200,19 @@ def _staged_bwrap( " target.mkdir() if source.is_dir() else target.touch()", " subprocess.run(['mount','--bind',str(source),str(target)],check=True)", " staged.append(target)", - " argv=[str(staged[int(x[4:-1])]) if x.startswith('@FD:') else x for x in argv]", + " path_map={token:str(staged[index]) for index,token in enumerate(path_tokens)}", + " argv=[path_map.get(value,value) for value in argv]", " for index,encoded in enumerate(data):", " fd=os.memfd_create('pdd-sandbox-data-'+str(index),os.MFD_CLOEXEC)", - " content=base64.b64decode(encoded); os.write(fd,content); os.lseek(fd,0,os.SEEK_SET)", + " content=base64.b64decode(encoded,validate=True); view=memoryview(content)", + " while view:", + " written=os.write(fd,view)", + " if written <= 0: raise OSError('short memfd write')", + " view=view[written:]", + " os.lseek(fd,0,os.SEEK_SET)", " data_fds.append(fd)", - " argv=[str(data_fds[int(x[6:-1])]) if x.startswith('@DATA:') else x for x in argv]", + " data_map={token:str(data_fds[index]) for index,token in enumerate(data_tokens)}", + " argv=[data_map.get(value,value) for value in argv]", " result=subprocess.run(argv,check=False,pass_fds=tuple(data_fds))", "finally:", " for fd in data_fds: os.close(fd)", @@ -207,18 +223,31 @@ def _staged_bwrap( )) return ["sudo", "-n", "-E", str(_SUPERVISOR_EXECUTABLE), "-c", helper, json.dumps([base64.b64encode(item).decode("ascii") for item in data]), + json.dumps(path_tokens), + json.dumps(data_tokens), json.dumps(argv), json.dumps([str(path) for path in sources])] _BWRAP_OVERLAY_MIN_VERSION = (0, 11, 1) +_MAX_STAGED_DATA_BYTES = 64 * 1024 + + +def _bwrap_probe(bwrap: str, option: str) -> subprocess.CompletedProcess[str]: + """Run one required Bubblewrap capability probe or fail closed.""" + try: + return subprocess.run( + [bwrap, option], capture_output=True, text=True, check=False + ) + except OSError as exc: + raise RuntimeError( + "protected sandbox cannot execute Bubblewrap capability probe" + ) from exc def _bwrap_overlay_capability(bwrap: str) -> str | None: """Return why Bubblewrap cannot safely create private overlay data mounts.""" - version = subprocess.run( - [bwrap, "--version"], capture_output=True, text=True, check=False - ) + version = _bwrap_probe(bwrap, "--version") if version.returncode != 0: return "protected sandbox cannot determine Bubblewrap version" match = re.search(r"(\d+)\.(\d+)\.(\d+)", version.stdout) @@ -228,10 +257,10 @@ def _bwrap_overlay_capability(bwrap: str) -> str | None: if parsed < _BWRAP_OVERLAY_MIN_VERSION: required = ".".join(str(value) for value in _BWRAP_OVERLAY_MIN_VERSION) return f"protected sandbox requires Bubblewrap {required} for private overlays" - help_result = subprocess.run( - [bwrap, "--help"], capture_output=True, text=True, check=False + help_result = _bwrap_probe(bwrap, "--help") + required_flags = ( + "--overlay-src", "--tmp-overlay", "--ro-bind-data", "--remount-ro", ) - required_flags = ("--overlay-src", "--tmp-overlay", "--ro-bind-data") if help_result.returncode != 0 or any( flag not in help_result.stdout for flag in required_flags ): @@ -389,9 +418,16 @@ def _sandbox_command( "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp"] sources: list[Path] = [] + path_tokens: list[str] = [] destination_dirs = {Path("/tmp")} mounted: dict[Path, tuple[str, Path]] = {} + def stage_source(source: Path) -> str: + token = f"@PDD-PATH-{uuid.uuid4().hex}@" + sources.append(source) + path_tokens.append(token) + return token + def ensure_destination_parent(destination: Path) -> None: missing = [] parent = destination.parent @@ -414,8 +450,7 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: ) mounted[destination] = binding ensure_destination_parent(destination) - sources.append(source) - argv.extend((option, f"@FD:{len(sources) - 1}@", str(destination))) + argv.extend((option, stage_source(source), str(destination))) if destination.is_dir(): destination_dirs.add(destination) @@ -435,10 +470,14 @@ def private_overlay(source: Path, destination: Path) -> None: ensure_destination_parent(destination) argv.extend(("--dir", str(destination))) destination_dirs.add(destination) - argv.extend(("--overlay-src", str(source), "--tmp-overlay", str(destination))) + argv.extend(( + "--overlay-src", stage_source(source), + "--tmp-overlay", str(destination), + )) mounted[destination] = ("--tmp-overlay", source) data: list[bytes] = [] + data_tokens: list[str] = [] def bind_data(content: bytes, destination: Path) -> None: if not destination.is_absolute(): @@ -450,7 +489,9 @@ def bind_data(content: bytes, destination: Path) -> None: ensure_destination_parent(destination) mounted[destination] = ("--ro-bind-data", Path("/dev/null")) data.append(content) - argv.extend(("--ro-bind-data", f"@DATA:{len(data) - 1}@", str(destination))) + token = f"@PDD-DATA-{uuid.uuid4().hex}@" + data_tokens.append(token) + argv.extend(("--perms", "0444", "--ro-bind-data", token, str(destination))) for source, destination in writable_bindings: bind("--bind", source.resolve(), destination) @@ -485,6 +526,8 @@ def bind_data(content: bytes, destination: Path) -> None: bind("--bind", result_fifo.parent.resolve()) for content, destination in readable_data: bind_data(content, destination) + for _source, destination in private_overlays: + argv.extend(("--remount-ro", str(destination))) argv.extend(("--chdir", str(workdir))) drop = ( [setpriv, "--reuid", str(os.getuid()), "--regid", str(os.getgid()), @@ -494,7 +537,9 @@ def bind_data(content: bytes, destination: Path) -> None: if result_fifo is not None: sandboxed = _private_result_command(sandboxed, result_fifo, result_fd) argv.extend(("--", *drop, *sandboxed)) - return _staged_bwrap(argv, sources, tuple(data)), None + return _staged_bwrap( + argv, sources, path_tokens, tuple(data), tuple(data_tokens) + ), None raise RuntimeError("unsupported sandbox platform or mechanism") diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index e4a65c5308..42cb694e97 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -122,7 +122,7 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( assert bwrap[bwrap.index("--reuid") + 1] == "1234" assert bwrap[bwrap.index("--regid") + 1] == "2345" assert bwrap.index("--proc") < separator - assert bwrap[bwrap.index("--ro-bind") + 1].startswith("@FD:") + assert bwrap[bwrap.index("--ro-bind") + 1] in json.loads(argv[-4]) def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( @@ -154,9 +154,8 @@ def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( destination_index = bwrap.index(str(manifest_destination)) assert bwrap[destination_index - 2] == "--ro-bind" placeholder = bwrap[destination_index - 1] - assert sources[int(placeholder.removeprefix("@FD:").removesuffix("@"))] == str( - copied.resolve() - ) + tokens = json.loads(argv[-4]) + assert sources[tokens.index(placeholder)] == str(copied.resolve()) def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( @@ -188,9 +187,8 @@ def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( assert bwrap[destination_index - 2] == "--bind" assert destination_index < bwrap.index("--ro-bind") placeholder = bwrap[destination_index - 1] - assert sources[int(placeholder.removeprefix("@FD:").removesuffix("@"))] == str( - scratch.resolve() - ) + tokens = json.loads(argv[-4]) + assert sources[tokens.index(placeholder)] == str(scratch.resolve()) def test_linux_sandbox_deduplicates_identical_read_only_bindings( @@ -302,6 +300,36 @@ def probe(command, **_kwargs): ) +def test_linux_sandbox_private_overlay_requires_remount_capability( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject a nominally new Bubblewrap build without read-only remounts.""" + candidate = tmp_path / "candidate" + candidate.mkdir() + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + + def completed(command, **_kwargs): + if command[-1] == "--version": + return subprocess.CompletedProcess(command, 0, "bubblewrap 0.11.1\n", "") + if command[-1] == "--help": + return subprocess.CompletedProcess( + command, 0, "--overlay-src --tmp-overlay --ro-bind-data", "" + ) + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", completed) + + with pytest.raises(RuntimeError, match="lacks private overlay data-mount support"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + private_overlays=((candidate, candidate),), + ) + + def test_linux_sandbox_constructs_private_overlay_and_data_mount( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -340,6 +368,7 @@ def completed(command, **_kwargs): bwrap = json.loads(argv[-2]) staged_sources = json.loads(argv[-1]) staged_tokens = json.loads(argv[-4]) + compile(argv[5], "", "exec") assert bwrap.index("--tmp-overlay") < bwrap.index("--ro-bind-data") overlay_token = bwrap[bwrap.index("--overlay-src") + 1] assert overlay_token in staged_tokens @@ -586,8 +615,8 @@ def bind_source(destination: Path) -> str: index = bwrap.index(str(destination)) assert bwrap[index - 2] == "--ro-bind" placeholder = bwrap[index - 1] - assert placeholder.startswith("@FD:") and placeholder.endswith("@") - return sources[int(placeholder[4:-1])] + tokens = json.loads(argv[-4]) + return sources[tokens.index(placeholder)] assert str(executable_destination.parent) in { bwrap[index + 1] for index, value in enumerate(bwrap[:-1]) From 35acb4f570ba3cb14015f4275ca02f6c34500475 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 19:42:56 -0700 Subject: [PATCH 064/233] test(sync): define cgroup Playwright resource contracts --- tests/test_sync_core_runner_playwright.py | 40 +++++++++++++++ tests/test_sync_core_supervisor.py | 61 +++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index afc8915b29..132839f91e 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -25,6 +25,7 @@ run_profile, ) from pdd.sync_core.runner import ( + PLAYWRIGHT_SUPERVISOR_LIMITS, _directory_identity, _local_javascript_imports, _playwright_environment, @@ -2499,6 +2500,45 @@ def test_playwright_missing_private_result_has_bounded_diagnostics() -> None: assert len(detail) < 600 +def test_playwright_uses_two_gib_physical_and_64_gib_virtual_limits( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Chromium receives address space without relaxing aggregate physical memory.""" + root, commit = _repository(tmp_path) + observed: list[dict] = [] + + def supervised(command, **kwargs): + observed.append(kwargs) + _write_private_result(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return subprocess.CompletedProcess(command, 0, "", ""), False + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert executions[0].outcome is EvidenceOutcome.PASS + assert len(observed) == 3 + assert all(kwargs["limits"] == PLAYWRIGHT_SUPERVISOR_LIMITS for kwargs in observed) + assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_memory_bytes == 2 * 1024 * 1024 * 1024 + assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_virtual_memory_bytes == 64 * 1024 * 1024 * 1024 + assert all(not kwargs.get("private_overlays") for kwargs in observed) + assert all(not kwargs.get("readable_data") for kwargs in observed) + + +def test_playwright_does_not_inject_browser_or_node_wasm_flags( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The checker must leave Chromium and Node flags to the trusted toolchain.""" + monkeypatch.setattr(runner_module.sys, "platform", "linux") + + prefix = _playwright_runtime_prefix( + ("/usr/bin/node", "/opt/playwright/cli.js"), Path("/usr/bin/node") + ) + + assert prefix == ("/usr/bin/node", "/opt/playwright/cli.js") + + def test_playwright_linux_node_disables_wasm_trap_handler( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 42cb694e97..8d7cda4501 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -653,6 +653,67 @@ def test_protected_runner_declares_finite_resource_limits() -> None: assert 0 < limits.max_processes <= 256 +def test_supervisor_separates_physical_and_virtual_memory_limits() -> None: + """Keep physical memory bounded when one trusted tool needs more address space.""" + limits = SupervisorLimits( + max_memory_bytes=2 * 1024 * 1024 * 1024, + max_virtual_memory_bytes=64 * 1024 * 1024 * 1024, + ) + + command = _limited_command(["/bin/true"], limits) + + assert str(limits.max_virtual_memory_bytes) in command + assert str(limits.max_memory_bytes) not in command[1:7] + + +def test_linux_sandbox_uses_path_lookup_for_bwrap_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A mocked probe path must not become the executable launched at runtime.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + + argv, _profile = _sandbox_command(["/bin/true"], (tmp_path,)) + bwrap = json.loads(argv[-2]) + + assert bwrap[0] == "bwrap" + + +def test_linux_sandbox_installs_cgroup_limits_before_bwrap_exec( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The privileged helper must configure the aggregate cgroup before release.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + + argv, _profile = _sandbox_command(["/bin/true"], (tmp_path,)) + helper = argv[5] + + assert "memory.max" in helper + assert "memory.swap.max" in helper + assert "memory.oom.group" in helper + assert "pids.max" in helper + assert helper.index("memory.max") < helper.index("os.kill(pid,signal.SIGCONT)") + + @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap"), reason="requires Linux kernel namespace containment", From 5ae11df0f957d50b6d87874c8d34da2b48b8a2af Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 19:56:00 -0700 Subject: [PATCH 065/233] fix(sync): contain validators with cgroup v2 --- .github/workflows/unit-tests.yml | 109 +++++--- pdd/sync_core/runner.py | 62 +---- pdd/sync_core/supervisor.py | 306 ++++++++++++---------- tests/test_sync_core_runner_playwright.py | 73 +----- tests/test_sync_core_supervisor.py | 287 +++----------------- 5 files changed, 291 insertions(+), 546 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index c4e97f2e6f..4b33ad97c5 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -161,26 +161,9 @@ jobs: run: | set -euo pipefail sudo apt-get update - sudo apt-get install --yes meson ninja-build pkg-config libcap-dev libselinux1-dev - bwrap_version=0.11.1 - bwrap_prefix="$RUNNER_TEMP/pdd-bwrap-$bwrap_version" - bwrap_archive="$RUNNER_TEMP/bubblewrap-$bwrap_version.tar.xz" - curl --fail --location --retry 3 \ - --output "$bwrap_archive" \ - "https://github.com/containers/bubblewrap/releases/download/v$bwrap_version/bubblewrap-$bwrap_version.tar.xz" - echo "c1b7455a1283b1295879a46d5f001dfd088c0bb0f238abb5e128b3583a246f71 $bwrap_archive" | sha256sum --check - tar --extract --file "$bwrap_archive" --directory "$RUNNER_TEMP" - meson setup "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" \ - "$RUNNER_TEMP/bubblewrap-$bwrap_version" --prefix "$bwrap_prefix" - meson compile -C "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" - meson install -C "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" - export PATH="$bwrap_prefix/bin:$PATH" - bwrap --version | grep -F "$bwrap_version" - bwrap --help | grep -F -- '--overlay-src' - bwrap --help | grep -F -- '--tmp-overlay' - bwrap --help | grep -F -- '--ro-bind-data' - bwrap --help | grep -F -- '--remount-ro' - echo "$bwrap_prefix/bin" >> "$GITHUB_PATH" + sudo apt-get install --yes bubblewrap + command -v bwrap + bwrap --version private_root="$(mktemp -d)" chmod 700 "$private_root" mkdir -m 700 "$private_root/scratch" @@ -206,7 +189,7 @@ jobs: sandbox, _ = _sandbox_command( [sys.executable, "-c", "import math"], (root / "scratch",) ) - print("sandbox destinations:", json.loads(sandbox[-2])) + print("sandbox destinations:", json.loads(sandbox[-4])) print("runtime native paths:", [ str(path) for label, path in released_runtime_closure_paths() if label.startswith("native/") @@ -232,6 +215,68 @@ jobs: assert not (root / "outside").exists() PY + - name: Verify protected cgroup-v2 containment smoke + run: | + set -euo pipefail + root="$(mktemp -d)" + mkdir -m 700 "$root/scratch" + ROOT="$root" python - <<'PY' + import os + import sys + from pathlib import Path + from pdd.sync_core.supervisor import SupervisorLimits, run_supervised + + root = Path(os.environ["ROOT"]) + program = '''import os + from pathlib import Path + group = Path('/sys/fs/cgroup') / os.environ['PDD_CGROUP_NAME'] + expected = { + 'memory.max': str(2 * 1024 * 1024 * 1024), + 'memory.swap.max': '0', + 'memory.oom.group': '1', + 'pids.max': '8', + } + for name, value in expected.items(): + actual = (group / name).read_text(encoding='ascii').strip() + assert actual == value, (name, actual) + try: + (group / 'cgroup.procs').write_text(str(os.getpid()), encoding='ascii') + except OSError: + pass + else: + raise SystemExit('candidate can migrate cgroups') + ''' + result, surviving = run_supervised( + [sys.executable, '-c', program], cwd=root / 'scratch', timeout=30, + env=dict(os.environ), writable_roots=(root / 'scratch',), + limits=SupervisorLimits(max_processes=8), + ) + assert result.returncode == 0, result.stderr + assert not surviving + oom, surviving = run_supervised( + [sys.executable, '-c', 'bytearray(3 * 1024 * 1024 * 1024)'], + cwd=root / 'scratch', timeout=30, env=dict(os.environ), + writable_roots=(root / 'scratch',), limits=SupervisorLimits(), + ) + assert oom.returncode != 0, oom.stderr + assert not surviving + tasks, surviving = run_supervised( + [sys.executable, '-c', "import subprocess,sys; children=[subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(2)']) for _ in range(16)]; [child.wait() for child in children]"], + cwd=root / 'scratch', timeout=30, env=dict(os.environ), + writable_roots=(root / 'scratch',), limits=SupervisorLimits(max_processes=8), + ) + assert tasks.returncode != 0, tasks.stderr + assert not surviving + timed_out, surviving = run_supervised( + [sys.executable, '-c', 'import time; time.sleep(30)'], + cwd=root / 'scratch', timeout=1, env=dict(os.environ), + writable_roots=(root / 'scratch',), limits=SupervisorLimits(), + ) + assert timed_out.returncode == 124, timed_out.stderr + assert not surviving + assert not list(Path('/sys/fs/cgroup').glob('pdd-*')) + PY + - name: Verify protected pytest smoke run: > pytest -q @@ -403,25 +448,9 @@ jobs: run: | set -euo pipefail sudo apt-get update - sudo apt-get install --yes meson ninja-build pkg-config libcap-dev libselinux1-dev - bwrap_version=0.11.1 - bwrap_prefix="$RUNNER_TEMP/pdd-bwrap-$bwrap_version" - bwrap_archive="$RUNNER_TEMP/bubblewrap-$bwrap_version.tar.xz" - curl --fail --location --retry 3 \ - --output "$bwrap_archive" \ - "https://github.com/containers/bubblewrap/releases/download/v$bwrap_version/bubblewrap-$bwrap_version.tar.xz" - echo "c1b7455a1283b1295879a46d5f001dfd088c0bb0f238abb5e128b3583a246f71 $bwrap_archive" | sha256sum --check - tar --extract --file "$bwrap_archive" --directory "$RUNNER_TEMP" - meson setup "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" \ - "$RUNNER_TEMP/bubblewrap-$bwrap_version" --prefix "$bwrap_prefix" - meson compile -C "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" - meson install -C "$RUNNER_TEMP/bubblewrap-$bwrap_version/build" - "$bwrap_prefix/bin/bwrap" --version | grep -F "$bwrap_version" - "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--overlay-src' - "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--tmp-overlay' - "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--ro-bind-data' - "$bwrap_prefix/bin/bwrap" --help | grep -F -- '--remount-ro' - echo "$bwrap_prefix/bin" >> "$GITHUB_PATH" + sudo apt-get install --yes bubblewrap + command -v bwrap + bwrap --version toolchain="$RUNNER_TEMP/pdd-playwright-toolchain" browser_cache="$toolchain/browser-runtime" PLAYWRIGHT_BROWSERS_PATH="$browser_cache" npm install --prefix "$toolchain" --ignore-scripts --no-audit --no-fund @playwright/test@1.55.0 diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 8c53aa7986..103ce14642 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -48,7 +48,7 @@ VerificationObligation, VerificationProfile, ) -from .supervisor import released_runtime_closure_paths, run_supervised +from .supervisor import SupervisorLimits, released_runtime_closure_paths, run_supervised TRUSTED_RUNNER_VERSION = "pdd-trusted-runner-v2" @@ -188,6 +188,10 @@ class VitestPhaseToolchain: "launcher", "entrypoint", "dependencies", "browser_runtime", "native_runtime", "lockfile", } +PLAYWRIGHT_SUPERVISOR_LIMITS = SupervisorLimits( + max_memory_bytes=2 * 1024 * 1024 * 1024, + max_virtual_memory_bytes=64 * 1024 * 1024 * 1024, +) @dataclass(frozen=True) @@ -4289,48 +4293,12 @@ def _playwright_missing_result_detail( def _playwright_runtime_prefix( - prefix: tuple[str, ...], launcher: Path, + prefix: tuple[str, ...], _launcher: Path, ) -> tuple[str, ...]: - """Avoid Node's large Linux Wasm trap-handler virtual-memory reservation.""" - if ( - sys.platform.startswith("linux") - and launcher.name in {"node", "nodejs"} - ): - return (prefix[0], "--disable-wasm-trap-handler", *prefix[1:]) + """Return the trusted toolchain argv without checker-injected browser flags.""" return prefix -_PLAYWRIGHT_CHROMIUM_WASM_TRAP_FLAG = "--js-flags=--no-wasm-trap-handler" - - -def _playwright_linux_config_data( - root: Path, config_path: PurePosixPath, -) -> tuple[bytes, Path]: - """Build a same-directory config wrapper for a private sandbox overlay.""" - candidate = root / config_path - destination = candidate.parent / ( - f".pdd-trusted-playwright-{os.urandom(16).hex()}{config_path.suffix}" - ) - candidate_literal = json.dumps(str(candidate)) - injected_use = ( - "use: {launchOptions: {args: [" - + json.dumps(_PLAYWRIGHT_CHROMIUM_WASM_TRAP_FLAG) - + "]}}" - ) - commonjs = _playwright_config_is_commonjs(root, "HEAD", config_path) - if commonjs: - source = ( - f"const candidateConfig = require({candidate_literal});\n" - "module.exports = {...candidateConfig, " + injected_use + "};\n" - ) - else: - source = ( - f"import candidateConfig from {candidate_literal};\n" - "export default {...candidateConfig, " + injected_use + "};\n" - ) - return source.encode("utf-8"), destination - - def _playwright_reported_failure_detail(tests: list[dict[str, object]]) -> str: """Return one bounded reporter diagnostic for a failed protected test.""" errors = [ @@ -4691,19 +4659,10 @@ def _run_playwright_in_tree( return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) ), () - config_data: tuple[bytes, Path] | None = None - if sys.platform.startswith("linux"): - try: - config_data = _playwright_linux_config_data(root, config_path) - except ValueError as exc: - return RunnerExecution( - "playwright", EvidenceOutcome.ERROR, "playwright-config", str(exc) - ), () - runtime_config = config_data[1] if config_data is not None else root / config_path command = [ *_playwright_runtime_prefix(prefix, roles.launcher), "test", *(path.as_posix() for path in paths), - f"--config={runtime_config}", f"--reporter={reporter}", + f"--config={root / config_path}", f"--reporter={reporter}", "--update-snapshots=none", f"--output={scratch / 'results'}", ] if collection: @@ -4725,10 +4684,7 @@ def _run_playwright_in_tree( temp_directory=Path("/tmp"), readable_roots=(reporter, *roles.readable_roots), readable_bindings=native_bindings, - private_overlays=( - ((root.resolve(), root.resolve()),) if config_data is not None else () - ), - readable_data=((config_data,) if config_data is not None else ()), + limits=PLAYWRIGHT_SUPERVISOR_LIMITS, result_fifo=result_fifo, result_fd=result_fd, ) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index ec2c991abf..7f1c3202df 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -5,8 +5,6 @@ import os import json -import base64 -import re import shutil import signal import subprocess @@ -34,6 +32,7 @@ class SupervisorLimits: max_output_bytes: int = 16 * 1024 * 1024 max_writable_bytes: int = 512 * 1024 * 1024 max_memory_bytes: int = 2 * 1024 * 1024 * 1024 + max_virtual_memory_bytes: int = 2 * 1024 * 1024 * 1024 max_cpu_seconds: int = 300 max_processes: int = 128 @@ -105,6 +104,7 @@ def released_runtime_closure_paths() -> tuple[tuple[str, Path], ...]: "bwrap": shutil.which("bwrap"), "setpriv": shutil.which("setpriv"), "sudo": shutil.which("sudo"), + "systemd-run": shutil.which("systemd-run"), "mount": shutil.which("mount"), "umount": shutil.which("umount"), } @@ -137,7 +137,7 @@ def _runtime_roots(command: list[str], cwd: Path) -> tuple[Path, ...]: if not resolved_executable.is_relative_to(cwd): roots.add(resolved_executable) for _label, path in released_runtime_closure_paths(): - if path.name in {"bwrap", "setpriv", "sudo", "mount", "umount"} or any( + if path.name in {"bwrap", "setpriv", "sudo", "systemd-run", "mount", "umount"} or any( path.is_relative_to(directory) for directory in directories ): continue @@ -171,29 +171,50 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: "resource.setrlimit(resource.RLIMIT_NOFILE,(v[4],v[4]));" "os.execvpe(sys.argv[6],sys.argv[6:],os.environ)" ) - return [str(_SUPERVISOR_EXECUTABLE), "-c", script, str(limits.max_memory_bytes), + return [str(_SUPERVISOR_EXECUTABLE), "-c", script, str(limits.max_virtual_memory_bytes), str(limits.max_cpu_seconds), str(limits.max_processes), str(limits.max_output_bytes), "256", *command] def _staged_bwrap( - argv: list[str], sources: list[Path], path_tokens: list[str], - data: tuple[bytes, ...] = (), data_tokens: tuple[str, ...] = (), + argv: list[str], sources: list[Path], path_tokens: list[str], *, + cgroup_name: str, limits: SupervisorLimits, use_systemd_scope: bool, ) -> list[str]: - """Stage exact bind mounts and anonymous immutable data for Bubblewrap.""" - if len(data) != len(data_tokens): - raise RuntimeError("protected sandbox has invalid anonymous data staging") - if any(len(item) > _MAX_STAGED_DATA_BYTES for item in data) or sum( - len(item) for item in data - ) > _MAX_STAGED_DATA_BYTES: - raise RuntimeError("protected sandbox anonymous data exceeds 64 KiB limit") + """Stage trusted mounts and contain the complete child tree in cgroup v2.""" helper = "\n".join(( - "import base64,json,os,pathlib,shutil,subprocess,sys,tempfile", - "data=json.loads(sys.argv[1]); path_tokens=json.loads(sys.argv[2])", - ("data_tokens=json.loads(sys.argv[3]); argv=json.loads(sys.argv[4]); " - "paths=json.loads(sys.argv[5])"), + "import json,os,pathlib,shutil,signal,subprocess,sys,tempfile,time", + "path_tokens=json.loads(sys.argv[1]); argv=json.loads(sys.argv[2]); paths=json.loads(sys.argv[3])", + "name=sys.argv[4]; limits=json.loads(sys.argv[5])", "base=pathlib.Path(tempfile.mkdtemp(prefix='pdd-binds-',dir='/run'))", - "os.chmod(base,0o755); staged=[]; data_fds=[]; result=None", + "os.chmod(base,0o755); staged=[]; result=None; cgroup=None", + "def write(path,value): path.write_text(str(value),encoding='ascii')", + "def cgroup_root():", + " mount=pathlib.Path('/sys/fs/cgroup')", + " if not (mount/'cgroup.controllers').is_file(): raise RuntimeError('protected sandbox requires writable cgroup v2')", + " return mount", + "def configure_group():", + " root=cgroup_root(); controls=(root/'cgroup.subtree_control').read_text(encoding='ascii').split()", + " available=(root/'cgroup.controllers').read_text(encoding='ascii').split()", + " for controller in ('memory','pids'):", + " if controller not in available: raise RuntimeError('protected sandbox cgroup v2 lacks '+controller+' controller')", + " if controller not in controls: write(root/'cgroup.subtree_control','+'+controller)", + " group=root/name; group.mkdir()", + " try:", + " write(group/'memory.max',limits['memory'])", + " write(group/'memory.swap.max',0)", + " write(group/'memory.oom.group',1)", + " write(group/'pids.max',limits['pids'])", + " except BaseException:", + " group.rmdir(); raise", + " return group", + "def cleanup_group(group):", + " if group is None or not group.exists(): return", + " write(group/'cgroup.kill',1)", + " deadline=time.monotonic()+5", + " while (group/'cgroup.procs').read_text(encoding='ascii').strip():", + " if time.monotonic() >= deadline: raise RuntimeError('protected sandbox cgroup did not empty')", + " time.sleep(.01)", + " group.rmdir()", "try:", " for index,source in enumerate(paths):", " source=pathlib.Path(source); target=base/str(index)", @@ -202,72 +223,111 @@ def _staged_bwrap( " staged.append(target)", " path_map={token:str(staged[index]) for index,token in enumerate(path_tokens)}", " argv=[path_map.get(value,value) for value in argv]", - " for index,encoded in enumerate(data):", - " fd=os.memfd_create('pdd-sandbox-data-'+str(index),os.MFD_CLOEXEC)", - " content=base64.b64decode(encoded,validate=True); view=memoryview(content)", - " while view:", - " written=os.write(fd,view)", - " if written <= 0: raise OSError('short memfd write')", - " view=view[written:]", - " os.lseek(fd,0,os.SEEK_SET)", - " data_fds.append(fd)", - " data_map={token:str(data_fds[index]) for index,token in enumerate(data_tokens)}", - " argv=[data_map.get(value,value) for value in argv]", - " result=subprocess.run(argv,check=False,pass_fds=tuple(data_fds))", + " cgroup=configure_group()", + " pid=os.fork()", + " if pid == 0:", + " os.kill(os.getpid(),signal.SIGSTOP)", + " os.execvpe(argv[0],argv,os.environ)", + " stopped,status=os.waitpid(pid,os.WUNTRACED)", + " if stopped != pid or not os.WIFSTOPPED(status): raise RuntimeError('protected sandbox cannot stop child before cgroup assignment')", + " write(cgroup/'cgroup.procs',pid)", + " os.kill(pid,signal.SIGCONT)", + " _wait,status=os.waitpid(pid,0)", + " result=os.waitstatus_to_exitcode(status)", "finally:", - " for fd in data_fds: os.close(fd)", + " cleanup_error=None", + " try: cleanup_group(cgroup)", + " except BaseException as exc: cleanup_error=exc", " for target in reversed(staged):", " subprocess.run(['umount',str(target)],check=False)", " shutil.rmtree(base,ignore_errors=True)", - "raise SystemExit(result.returncode if result is not None else 1)", + "if cleanup_error is not None: raise RuntimeError('protected sandbox cgroup cleanup failed') from cleanup_error", + "raise SystemExit(result if result is not None else 125)", )) - return ["sudo", "-n", "-E", str(_SUPERVISOR_EXECUTABLE), "-c", helper, - json.dumps([base64.b64encode(item).decode("ascii") for item in data]), - json.dumps(path_tokens), - json.dumps(data_tokens), - json.dumps(argv), - json.dumps([str(path) for path in sources])] - - -_BWRAP_OVERLAY_MIN_VERSION = (0, 11, 1) -_MAX_STAGED_DATA_BYTES = 64 * 1024 + command = ["sudo", "-n", "-E", str(_SUPERVISOR_EXECUTABLE), "-c", helper, + json.dumps(path_tokens), json.dumps(argv), + json.dumps([str(path) for path in sources]), cgroup_name, + json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes})] + if not use_systemd_scope: + return command + return [ + "sudo", "-n", "-E", "systemd-run", "--scope", "--quiet", "--wait", + "--collect", f"--property=MemoryMax={limits.max_memory_bytes}", + "--property=MemorySwapMax=0", f"--property=TasksMax={limits.max_processes}", + "--property=OOMPolicy=kill", *command[3:], + ] -def _bwrap_probe(bwrap: str, option: str) -> subprocess.CompletedProcess[str]: - """Run one required Bubblewrap capability probe or fail closed.""" +_CGROUP_PROBE = """import os,pathlib,uuid +root=pathlib.Path('/sys/fs/cgroup') +if not (root/'cgroup.controllers').is_file(): raise SystemExit('cgroup v2 is unavailable') +available=(root/'cgroup.controllers').read_text(encoding='ascii').split() +if not {'memory','pids'} <= set(available): raise SystemExit('cgroup v2 memory/pids controllers are unavailable') +controls=(root/'cgroup.subtree_control').read_text(encoding='ascii').split() +for controller in ('memory','pids'): + if controller not in controls: (root/'cgroup.subtree_control').write_text('+'+controller,encoding='ascii') +group=root/('pdd-probe-'+uuid.uuid4().hex) +group.mkdir() +try: + (group/'memory.max').write_text('max',encoding='ascii') + (group/'memory.swap.max').write_text('0',encoding='ascii') + (group/'memory.oom.group').write_text('1',encoding='ascii') + (group/'pids.max').write_text('max',encoding='ascii') +finally: + group.rmdir() +""" + + +def _cgroup_v2_capability() -> str | None: + """Probe the privileged cgroup-v2 controls required before candidate exec.""" try: - return subprocess.run( - [bwrap, option], capture_output=True, text=True, check=False + probe = subprocess.run( + ["sudo", "-n", str(_SUPERVISOR_EXECUTABLE), "-c", _CGROUP_PROBE], + capture_output=True, text=True, check=False, ) except OSError as exc: - raise RuntimeError( - "protected sandbox cannot execute Bubblewrap capability probe" - ) from exc - - -def _bwrap_overlay_capability(bwrap: str) -> str | None: - """Return why Bubblewrap cannot safely create private overlay data mounts.""" - version = _bwrap_probe(bwrap, "--version") - if version.returncode != 0: - return "protected sandbox cannot determine Bubblewrap version" - match = re.search(r"(\d+)\.(\d+)\.(\d+)", version.stdout) - if match is None: - return "protected sandbox cannot parse Bubblewrap version" - parsed = tuple(int(value) for value in match.groups()) - if parsed < _BWRAP_OVERLAY_MIN_VERSION: - required = ".".join(str(value) for value in _BWRAP_OVERLAY_MIN_VERSION) - return f"protected sandbox requires Bubblewrap {required} for private overlays" - help_result = _bwrap_probe(bwrap, "--help") - required_flags = ( - "--overlay-src", "--tmp-overlay", "--ro-bind-data", "--remount-ro", - ) - if help_result.returncode != 0 or any( - flag not in help_result.stdout for flag in required_flags - ): - return "protected sandbox Bubblewrap lacks private overlay data-mount support" + return f"protected sandbox cannot probe cgroup v2 controls: {exc}" + if probe.returncode != 0: + detail = " ".join(probe.stderr.split())[:256] + return "protected sandbox requires writable cgroup v2 memory/pids controls" + ( + f": {detail}" if detail else "" + ) return None +def _systemd_scope_usable(limits: SupervisorLimits) -> bool: + """Return whether a transient scope can prove the required cgroup properties.""" + systemd = shutil.which("systemd-run") + if systemd is None or not Path(systemd).is_file(): + return False + try: + result = subprocess.run( + ["sudo", "-n", systemd, "--scope", "--quiet", "--wait", "--collect", + f"--property=MemoryMax={limits.max_memory_bytes}", + "--property=MemorySwapMax=0", + f"--property=TasksMax={limits.max_processes}", + "--property=OOMPolicy=kill", "/bin/true"], + capture_output=True, text=True, check=False, + ) + except OSError: + return False + return result.returncode == 0 + + +_CGROUP_CLEANUP = """import pathlib,sys,time +name=sys.argv[1] +if not name.startswith('pdd-') or len(name) != 36: raise SystemExit('invalid protected cgroup name') +group=pathlib.Path('/sys/fs/cgroup')/name +if not group.exists(): raise SystemExit(0) +(group/'cgroup.kill').write_text('1',encoding='ascii') +deadline=time.monotonic()+5 +while (group/'cgroup.procs').read_text(encoding='ascii').strip(): + if time.monotonic() >= deadline: raise SystemExit('protected cgroup did not empty') + time.sleep(.01) +group.rmdir() +""" + + def _private_result_command( command: list[str], result_fifo: Path, result_fd: int, ) -> list[str]: @@ -384,11 +444,10 @@ def _sandbox_command( writable_files: tuple[Path, ...] = (), limits: SupervisorLimits = SupervisorLimits(), readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), - private_overlays: tuple[tuple[Path, Path], ...] = (), - readable_data: tuple[tuple[bytes, Path], ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), result_fifo: Path | None = None, result_fd: int = 198, + cgroup_name: str | None = None, ) -> tuple[list[str], Path | None]: # pylint: disable=too-many-locals,too-many-branches,too-many-statements """Return an explicitly detected macOS/Linux sandbox command.""" @@ -396,7 +455,7 @@ def _sandbox_command( raise RuntimeError( "unsupported protected sandbox: macOS cannot prove process lifetime isolation" ) - if sys.platform.startswith("linux") and (bwrap := shutil.which("bwrap")): + if sys.platform.startswith("linux") and shutil.which("bwrap"): if os.getuid() == 0: raise RuntimeError( "protected sandbox requires a non-root caller so process limits " @@ -409,12 +468,11 @@ def _sandbox_command( setpriv = shutil.which("setpriv") if setpriv is None: raise RuntimeError("protected sandbox requires setpriv for post-mount uid drop") - if private_overlays or readable_data: - capability_error = _bwrap_overlay_capability(bwrap) - if capability_error is not None: - raise RuntimeError(capability_error) + capability_error = _cgroup_v2_capability() + if capability_error is not None: + raise RuntimeError(capability_error) workdir = (cwd or Path.cwd()).resolve() - argv = [bwrap, "--unshare-ipc", "--unshare-pid", "--unshare-net", + argv = ["bwrap", "--unshare-ipc", "--unshare-pid", "--unshare-net", "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp"] sources: list[Path] = [] @@ -454,56 +512,15 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: if destination.is_dir(): destination_dirs.add(destination) - def private_overlay(source: Path, destination: Path) -> None: - if not destination.is_absolute(): - raise RuntimeError("protected sandbox overlay destination must be absolute") - try: - source = source.resolve(strict=True) - except OSError as exc: - raise RuntimeError("protected sandbox overlay source is unavailable") from exc - if not source.is_dir(): - raise RuntimeError("protected sandbox overlay source must be a directory") - if destination in mounted: - raise RuntimeError( - f"protected sandbox has conflicting bindings for {destination}" - ) - ensure_destination_parent(destination) - argv.extend(("--dir", str(destination))) - destination_dirs.add(destination) - argv.extend(( - "--overlay-src", stage_source(source), - "--tmp-overlay", str(destination), - )) - mounted[destination] = ("--tmp-overlay", source) - - data: list[bytes] = [] - data_tokens: list[str] = [] - - def bind_data(content: bytes, destination: Path) -> None: - if not destination.is_absolute(): - raise RuntimeError("protected sandbox data destination must be absolute") - if destination in mounted: - raise RuntimeError( - f"protected sandbox has conflicting bindings for {destination}" - ) - ensure_destination_parent(destination) - mounted[destination] = ("--ro-bind-data", Path("/dev/null")) - data.append(content) - token = f"@PDD-DATA-{uuid.uuid4().hex}@" - data_tokens.append(token) - argv.extend(("--perms", "0444", "--ro-bind-data", token, str(destination))) - for source, destination in writable_bindings: bind("--bind", source.resolve(), destination) - for source, destination in private_overlays: - private_overlay(source, destination) for item in _runtime_roots(command, workdir): - existing_mount = mounted.get(item) - if existing_mount is not None and existing_mount[0] == "--tmp-overlay": - continue # A host bind follows symlinks, but the process command and ELF # loader retain their original spellings in the new namespace. bind("--ro-bind", item.resolve(), item) + # The candidate can inspect only a read-only cgroup-v2 view. It needs + # this for diagnostics, but cannot write controls or migrate itself. + bind("--ro-bind", Path("/sys/fs/cgroup")) # ``setpriv`` executes after the namespace root is installed, so bind # it and its ELF closure directly even when PATH resolution differs. if setpriv is not None: @@ -524,10 +541,6 @@ def bind_data(content: bytes, destination: Path) -> None: # executes any candidate-controlled code. Binding only its # dedicated directory keeps the reporter and toolchain immutable. bind("--bind", result_fifo.parent.resolve()) - for content, destination in readable_data: - bind_data(content, destination) - for _source, destination in private_overlays: - argv.extend(("--remount-ro", str(destination))) argv.extend(("--chdir", str(workdir))) drop = ( [setpriv, "--reuid", str(os.getuid()), "--regid", str(os.getgid()), @@ -538,7 +551,9 @@ def bind_data(content: bytes, destination: Path) -> None: sandboxed = _private_result_command(sandboxed, result_fifo, result_fd) argv.extend(("--", *drop, *sandboxed)) return _staged_bwrap( - argv, sources, path_tokens, tuple(data), tuple(data_tokens) + argv, sources, path_tokens, + cgroup_name=cgroup_name or f"pdd-{uuid.uuid4().hex}", limits=limits, + use_systemd_scope=_systemd_scope_usable(limits), ), None raise RuntimeError("unsupported sandbox platform or mechanism") @@ -549,8 +564,6 @@ def run_supervised( limits: SupervisorLimits = SupervisorLimits(), readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), - private_overlays: tuple[tuple[Path, Path], ...] = (), - readable_data: tuple[tuple[bytes, Path], ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), temp_directory: Path | None = None, result_fifo: Path | None = None, @@ -559,13 +572,13 @@ def run_supervised( """Run sandboxed and terminate marked descendants across session changes.""" # pylint: disable=consider-using-with,too-many-locals,too-many-branches,too-many-statements try: + cgroup_name = f"pdd-{uuid.uuid4().hex}" argv, profile = _sandbox_command( command, writable_roots, cwd=cwd, writable_files=writable_files, limits=limits, readable_roots=readable_roots, readable_bindings=readable_bindings, - private_overlays=private_overlays, readable_data=readable_data, writable_bindings=writable_bindings, - result_fifo=result_fifo, result_fd=result_fd, + result_fifo=result_fifo, result_fd=result_fd, cgroup_name=cgroup_name, ) except RuntimeError as exc: return subprocess.CompletedProcess(command, 125, "", str(exc)), False @@ -575,6 +588,7 @@ def run_supervised( sandbox_environment = env | { "PYTHONDONTWRITEBYTECODE": "1", "PDD_SUPERVISION_TOKEN": token, + "PDD_CGROUP_NAME": cgroup_name, "TMPDIR": str(temp_directory or writable_roots[0].resolve()), "TEMP": str(temp_directory or writable_roots[0].resolve()), "TMP": str(temp_directory or writable_roots[0].resolve()), @@ -582,11 +596,16 @@ def run_supervised( library_path = _sandbox_library_path(env) if library_path: sandbox_environment["LD_LIBRARY_PATH"] = library_path - process = subprocess.Popen( - argv, cwd=cwd, stdout=stdout_file, stderr=stderr_file, - env=sandbox_environment, - start_new_session=True, - ) + try: + process = subprocess.Popen( + argv, cwd=cwd, stdout=stdout_file, stderr=stderr_file, + env=sandbox_environment, + start_new_session=True, + ) + except OSError as exc: + stdout_file.close() + stderr_file.close() + return subprocess.CompletedProcess(command, 125, "", str(exc)), False timed_out = False surviving = False tracked: dict[int, str | None] = {} @@ -635,6 +654,19 @@ def track_process_tree() -> None: pass if profile is not None: profile.unlink(missing_ok=True) + try: + cleanup = subprocess.run( + ["sudo", "-n", str(_SUPERVISOR_EXECUTABLE), "-c", _CGROUP_CLEANUP, + cgroup_name], capture_output=True, text=True, check=False, + ) + cleanup_error = cleanup.stderr if cleanup.returncode != 0 else "" + except OSError as exc: + cleanup_error = str(exc) + if cleanup_error: + output_limited = True + stderr_file.write( + ("protected sandbox cgroup teardown failed: " + cleanup_error).encode() + ) stdout_file.seek(0) stderr_file.seek(0) remaining = limits.max_output_bytes diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 132839f91e..8d764f57d1 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -618,62 +618,6 @@ def supervised(command, **_kwargs): assert temp_directories[0] == Path("/tmp") -@pytest.mark.parametrize( - ("suffix", "js_scope"), - [ - (".js", "commonjs"), - (".js", "module"), - (".cjs", None), - (".mjs", None), - (".ts", None), - (".cts", None), - (".mts", None), - ], -) -def test_playwright_linux_config_wrapper_uses_private_overlay_and_data_mount( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, suffix: str, - js_scope: str | None, -) -> None: - """Inject the wrapper only in a same-directory private repository overlay.""" - root, commit = _repository_with_playwright_config_suffix( - tmp_path, suffix, js_scope - ) - source_identity = _directory_identity(root) - observed: list[tuple[Path, bytes]] = [] - - def supervised(command, **kwargs): - phase_root = kwargs["cwd"].resolve() - assert kwargs["private_overlays"] == ((phase_root, phase_root),) - data_mounts = kwargs["readable_data"] - assert len(data_mounts) == 1 - wrapper, destination = data_mounts[0] - assert destination.parent == phase_root - assert destination.suffix == suffix - assert not destination.exists() - assert b"--no-wasm-trap-handler" in wrapper - assert str(phase_root / f"playwright.config{suffix}").encode() in wrapper - assert all(destination != path for path in kwargs["readable_roots"]) - assert all(destination != path for path in kwargs["writable_roots"]) - assert f"--config={destination}" in command - observed.append((destination, wrapper)) - _write_private_result(kwargs, { - "tests": [{"identity": IDENTITY, "status": "passed"}], - }) - return subprocess.CompletedProcess(command, 0, "", ""), False - - monkeypatch.setattr(runner_module.sys, "platform", "linux") - monkeypatch.setattr( - runner_module, "_released_runtime_closure_digest", lambda: "runtime" - ) - monkeypatch.setattr(runner_module, "run_supervised", supervised) - _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) - - assert executions[0].outcome is EvidenceOutcome.PASS - assert len(observed) == 3 - assert _directory_identity(root) == source_identity - assert not list(root.glob(".pdd-trusted-playwright-*")) - - def test_playwright_checker_temp_roots_cannot_alias_sandbox_tmp( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2522,8 +2466,8 @@ def supervised(command, **kwargs): assert all(kwargs["limits"] == PLAYWRIGHT_SUPERVISOR_LIMITS for kwargs in observed) assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_memory_bytes == 2 * 1024 * 1024 * 1024 assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_virtual_memory_bytes == 64 * 1024 * 1024 * 1024 - assert all(not kwargs.get("private_overlays") for kwargs in observed) - assert all(not kwargs.get("readable_data") for kwargs in observed) + assert all("private_overlays" not in kwargs for kwargs in observed) + assert all("readable_data" not in kwargs for kwargs in observed) def test_playwright_does_not_inject_browser_or_node_wasm_flags( @@ -2539,19 +2483,6 @@ def test_playwright_does_not_inject_browser_or_node_wasm_flags( assert prefix == ("/usr/bin/node", "/opt/playwright/cli.js") -def test_playwright_linux_node_disables_wasm_trap_handler( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(runner_module.sys, "platform", "linux") - - assert _playwright_runtime_prefix( - ("/usr/bin/node", "/opt/playwright/cli.js"), Path("/usr/bin/node") - ) == ( - "/usr/bin/node", "--disable-wasm-trap-handler", - "/opt/playwright/cli.js", - ) - - def test_playwright_reported_failure_has_bounded_diagnostics() -> None: detail = _playwright_reported_failure_detail([{ "identity": IDENTITY, diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 8d7cda4501..f33785f4ef 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -114,7 +114,7 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( argv, profile = _sandbox_command(["/bin/true"], (tmp_path,)) assert profile is None assert argv[:3] == ["sudo", "-n", "-E"] - bwrap = json.loads(argv[-2]) + bwrap = json.loads(argv[-4]) assert {"--unshare-pid", "--unshare-net", "--unshare-cgroup"} <= set(bwrap) assert "--unshare-user" not in bwrap separator = bwrap.index("--") @@ -122,7 +122,7 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( assert bwrap[bwrap.index("--reuid") + 1] == "1234" assert bwrap[bwrap.index("--regid") + 1] == "2345" assert bwrap.index("--proc") < separator - assert bwrap[bwrap.index("--ro-bind") + 1] in json.loads(argv[-4]) + assert bwrap[bwrap.index("--ro-bind") + 1] in json.loads(argv[-5]) def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( @@ -149,12 +149,12 @@ def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( readable_bindings=((copied, manifest_destination),), ) - bwrap = json.loads(argv[-2]) - sources = json.loads(argv[-1]) + bwrap = json.loads(argv[-4]) + sources = json.loads(argv[-3]) destination_index = bwrap.index(str(manifest_destination)) assert bwrap[destination_index - 2] == "--ro-bind" placeholder = bwrap[destination_index - 1] - tokens = json.loads(argv[-4]) + tokens = json.loads(argv[-5]) assert sources[tokens.index(placeholder)] == str(copied.resolve()) @@ -181,13 +181,13 @@ def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( writable_bindings=((scratch, Path("/tmp")),), ) - bwrap = json.loads(argv[-2]) - sources = json.loads(argv[-1]) + bwrap = json.loads(argv[-4]) + sources = json.loads(argv[-3]) destination_index = len(bwrap) - 1 - bwrap[::-1].index("/tmp") assert bwrap[destination_index - 2] == "--bind" assert destination_index < bwrap.index("--ro-bind") placeholder = bwrap[destination_index - 1] - tokens = json.loads(argv[-4]) + tokens = json.loads(argv[-5]) assert sources[tokens.index(placeholder)] == str(scratch.resolve()) @@ -215,7 +215,7 @@ def test_linux_sandbox_deduplicates_identical_read_only_bindings( readable_bindings=((native, native),), ) - bwrap = json.loads(argv[-2]) + bwrap = json.loads(argv[-4]) assert bwrap.count(str(native)) == 1 @@ -247,235 +247,6 @@ def test_linux_sandbox_rejects_conflicting_bindings( ) -def test_linux_sandbox_private_overlay_requires_supported_bubblewrap( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> None: - """Fail closed rather than silently degrading a private overlay mount.""" - candidate = tmp_path / "candidate" - candidate.mkdir() - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(os, "getuid", lambda: 1234) - monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") - - def completed(command, **_kwargs): - if command[-1] == "--version": - return subprocess.CompletedProcess(command, 0, "bubblewrap 0.9.0\n", "") - return subprocess.CompletedProcess(command, 0, "", "") - - monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", completed) - - with pytest.raises(RuntimeError, match="requires Bubblewrap 0.11.1"): - _sandbox_command( - ["/bin/true"], - (tmp_path,), - private_overlays=((candidate, candidate),), - readable_data=((b"trusted", candidate / ".pdd-wrapper"),), - ) - - -def test_linux_sandbox_private_overlay_normalizes_capability_probe_oserror( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> None: - """Fail closed with a controlled error when Bubblewrap cannot be probed.""" - candidate = tmp_path / "candidate" - candidate.mkdir() - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(os, "getuid", lambda: 1234) - monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") - - def probe(command, **_kwargs): - if command[-1] == "--version": - raise OSError("exec failed") - return subprocess.CompletedProcess(command, 0, "", "") - - monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", probe) - - with pytest.raises(RuntimeError, match="cannot execute Bubblewrap capability probe"): - _sandbox_command( - ["/bin/true"], - (tmp_path,), - private_overlays=((candidate, candidate),), - ) - - -def test_linux_sandbox_private_overlay_requires_remount_capability( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> None: - """Reject a nominally new Bubblewrap build without read-only remounts.""" - candidate = tmp_path / "candidate" - candidate.mkdir() - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(os, "getuid", lambda: 1234) - monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") - - def completed(command, **_kwargs): - if command[-1] == "--version": - return subprocess.CompletedProcess(command, 0, "bubblewrap 0.11.1\n", "") - if command[-1] == "--help": - return subprocess.CompletedProcess( - command, 0, "--overlay-src --tmp-overlay --ro-bind-data", "" - ) - return subprocess.CompletedProcess(command, 0, "", "") - - monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", completed) - - with pytest.raises(RuntimeError, match="lacks private overlay data-mount support"): - _sandbox_command( - ["/bin/true"], - (tmp_path,), - private_overlays=((candidate, candidate),), - ) - - -def test_linux_sandbox_constructs_private_overlay_and_data_mount( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> None: - """Build an overlay first, then install wrapper bytes only in its namespace.""" - candidate = tmp_path / "candidate" - candidate.mkdir() - wrapper = candidate / ".pdd-wrapper" - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(os, "getuid", lambda: 1234) - monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") - - def completed(command, **_kwargs): - if command[-1] == "--version": - return subprocess.CompletedProcess(command, 0, "bubblewrap 0.11.1\n", "") - if command[-1] == "--help": - return subprocess.CompletedProcess( - command, 0, - "--overlay-src --tmp-overlay --ro-bind-data --remount-ro", "", - ) - return subprocess.CompletedProcess(command, 0, "", "") - - monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", completed) - monkeypatch.setattr( - "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () - ) - - argv, _profile = _sandbox_command( - ["/bin/true", "@DATA:literal-candidate-value"], - (tmp_path,), - cwd=candidate, - private_overlays=((candidate, candidate),), - readable_data=((b"trusted wrapper", wrapper),), - ) - - bwrap = json.loads(argv[-2]) - staged_sources = json.loads(argv[-1]) - staged_tokens = json.loads(argv[-4]) - compile(argv[5], "", "exec") - assert bwrap.index("--tmp-overlay") < bwrap.index("--ro-bind-data") - overlay_token = bwrap[bwrap.index("--overlay-src") + 1] - assert overlay_token in staged_tokens - assert staged_sources[staged_tokens.index(overlay_token)] == str(candidate.resolve()) - assert overlay_token != str(candidate.resolve()) - assert bwrap[bwrap.index("--tmp-overlay") + 1] == str(candidate) - assert bwrap[bwrap.index("--ro-bind-data") + 2] == str(wrapper) - data_mount = bwrap.index("--ro-bind-data") - assert bwrap[data_mount - 2:data_mount] == ["--perms", "0444"] - remount = bwrap.index("--remount-ro") - assert data_mount < remount - assert bwrap[remount + 1] == str(candidate) - assert "@DATA:literal-candidate-value" in bwrap[bwrap.index("--") + 1:] - read_only_destinations = { - bwrap[index + 2] for index, value in enumerate(bwrap[:-2]) - if value == "--ro-bind" - } - assert str(candidate) not in read_only_destinations - assert json.loads(argv[-3]) - assert str(wrapper) not in json.loads(argv[-1]) - - -def test_linux_sandbox_rejects_oversized_anonymous_data( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> None: - """Keep base64 command transport well below the host argument limit.""" - candidate = tmp_path / "candidate" - candidate.mkdir() - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(os, "getuid", lambda: 1234) - monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") - - def completed(command, **_kwargs): - if command[-1] == "--version": - return subprocess.CompletedProcess(command, 0, "bubblewrap 0.11.1\n", "") - if command[-1] == "--help": - return subprocess.CompletedProcess( - command, 0, - "--overlay-src --tmp-overlay --ro-bind-data --remount-ro", "", - ) - return subprocess.CompletedProcess(command, 0, "", "") - - monkeypatch.setattr("pdd.sync_core.supervisor.subprocess.run", completed) - - with pytest.raises(RuntimeError, match="anonymous data exceeds"): - _sandbox_command( - ["/bin/true"], - (tmp_path,), - private_overlays=((candidate, candidate),), - readable_data=((b"x" * (65 * 1024), candidate / ".wrapper"),), - ) - - -@pytest.mark.skipif( - not sys.platform.startswith("linux") or not shutil.which("bwrap"), - reason="requires Linux bubblewrap overlay containment", -) -def test_linux_sandbox_private_overlay_keeps_wrapper_and_writes_off_host( - tmp_path: Path, -) -> None: - """Exercise the namespace-private overlay and immutable data mount.""" - candidate = tmp_path / "candidate" - candidate.mkdir() - original = candidate / "playwright.config.ts" - original.write_text("candidate config", encoding="utf-8") - wrapper = candidate / ".pdd-trusted-playwright.ts" - scratch = tmp_path / "scratch" - scratch.mkdir() - program = "\n".join(( - "from pathlib import Path", - "import os", - "import sys", - "wrapper = Path(sys.argv[1])", - "assert os.getuid() == int(sys.argv[2])", - "assert wrapper.read_text() == 'trusted config'", - "try:", - " wrapper.write_text('candidate replacement')", - "except OSError:", - " pass", - "else:", - " raise SystemExit('wrapper mount is writable')", - "try:", - " Path('candidate-private-write').write_text('private')", - "except OSError:", - " pass", - "else:", - " raise SystemExit('repository overlay is writable')", - )) - - result, surviving = run_supervised( - [sys.executable, "-c", program, str(wrapper), str(os.getuid())], - cwd=candidate, - timeout=10, - env=dict(os.environ), - writable_roots=(scratch,), - private_overlays=((candidate, candidate),), - readable_data=((b"trusted config", wrapper),), - ) - - assert result.returncode == 0, result.stderr - assert surviving is False - assert original.read_text(encoding="utf-8") == "candidate config" - assert not wrapper.exists() - assert not (candidate / "candidate-private-write").exists() - - def test_linux_sandbox_fails_closed_for_root_caller( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -517,9 +288,9 @@ def test_linux_sandbox_opens_and_unlinks_checker_fifo_before_candidate( assert profile is None assert argv[:3] == ["sudo", "-n", "-E"] assert "-C" not in argv[:6] - bwrap = json.loads(argv[-2]) + bwrap = json.loads(argv[-4]) assert "--preserve-fds" not in bwrap - assert json.loads(argv[-1]) + assert json.loads(argv[-3]) assert str(channel) in bwrap separator = bwrap.index("--") candidate_argv = bwrap[separator + 1:] @@ -547,7 +318,7 @@ def test_sandbox_directory_bind_provides_parent_for_nested_file( ) argv, _profile = _sandbox_command([str(interpreter)], (tmp_path,), cwd=tmp_path) - bwrap = json.loads(argv[-2]) + bwrap = json.loads(argv[-4]) directory_targets = { bwrap[index + 1] for index, value in enumerate(bwrap[:-1]) if value == "--dir" @@ -608,14 +379,14 @@ def test_sandbox_binds_resolved_runtime_sources_at_original_destinations( argv, _profile = _sandbox_command( [str(candidate), "-c", "pass"], (workdir,), cwd=workdir ) - bwrap = json.loads(argv[-2]) - sources = json.loads(argv[-1]) + bwrap = json.loads(argv[-4]) + sources = json.loads(argv[-3]) def bind_source(destination: Path) -> str: index = bwrap.index(str(destination)) assert bwrap[index - 2] == "--ro-bind" placeholder = bwrap[index - 1] - tokens = json.loads(argv[-4]) + tokens = json.loads(argv[-5]) return sources[tokens.index(placeholder)] assert str(executable_destination.parent) in { @@ -649,6 +420,7 @@ def test_protected_runner_declares_finite_resource_limits() -> None: assert 0 < limits.max_output_bytes <= 16 * 1024 * 1024 assert 0 < limits.max_writable_bytes <= 1024 * 1024 * 1024 assert 0 < limits.max_memory_bytes <= 4 * 1024 * 1024 * 1024 + assert 0 < limits.max_virtual_memory_bytes <= 2 * 1024 * 1024 * 1024 assert 0 < limits.max_cpu_seconds <= 600 assert 0 < limits.max_processes <= 256 @@ -683,7 +455,7 @@ def test_linux_sandbox_uses_path_lookup_for_bwrap_execution( ) argv, _profile = _sandbox_command(["/bin/true"], (tmp_path,)) - bwrap = json.loads(argv[-2]) + bwrap = json.loads(argv[-4]) assert bwrap[0] == "bwrap" @@ -707,6 +479,7 @@ def test_linux_sandbox_installs_cgroup_limits_before_bwrap_exec( argv, _profile = _sandbox_command(["/bin/true"], (tmp_path,)) helper = argv[5] + compile(helper, "", "exec") assert "memory.max" in helper assert "memory.swap.max" in helper assert "memory.oom.group" in helper @@ -714,6 +487,30 @@ def test_linux_sandbox_installs_cgroup_limits_before_bwrap_exec( assert helper.index("memory.max") < helper.index("os.kill(pid,signal.SIGCONT)") +def test_cgroup_teardown_error_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed post-run cgroup teardown must invalidate an otherwise clean exit.""" + monkeypatch.setattr( + supervisor, "_sandbox_command", lambda *_args, **_kwargs: (["/bin/true"], None) + ) + original_run = subprocess.run + + def run(command, **kwargs): + if supervisor._CGROUP_CLEANUP in command: + return subprocess.CompletedProcess(command, 1, "", "cannot remove cgroup") + return original_run(command, check=kwargs.pop("check", False), **kwargs) + + monkeypatch.setattr(supervisor.subprocess, "run", run) + result, surviving = run_supervised( + ["/bin/true"], cwd=tmp_path, timeout=5, env={}, writable_roots=(tmp_path,) + ) + + assert result.returncode == 125 + assert "cgroup teardown failed" in result.stderr + assert not surviving + + @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap"), reason="requires Linux kernel namespace containment", From 6bdafc74cd5797a61809f814d1d8a0835d8f00f4 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 20:15:24 -0700 Subject: [PATCH 066/233] test(sync): require authoritative systemd scope containment --- tests/test_sync_core_supervisor.py | 137 ++++++++++++++++++----------- 1 file changed, 85 insertions(+), 52 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index f33785f4ef..ad63b3468b 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -25,6 +25,24 @@ ) +def _mock_linux_tools( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> dict[str, str]: + """Install regular executable identities for trusted-tool construction tests.""" + tools = {} + directory = tmp_path / "trusted-tools" + directory.mkdir(exist_ok=True) + for name in ( + "bwrap", "mount", "setpriv", "sudo", "systemctl", "systemd-run", "umount", + ): + path = directory / name + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + tools[name] = str(path) + monkeypatch.setattr(shutil, "which", tools.get) + return tools + + def test_private_result_wrapper_unlinks_channel_before_candidate( tmp_path: Path, ) -> None: @@ -103,7 +121,7 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -131,7 +149,7 @@ def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -164,7 +182,7 @@ def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -197,7 +215,7 @@ def test_linux_sandbox_deduplicates_identical_read_only_bindings( monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -225,7 +243,7 @@ def test_linux_sandbox_rejects_conflicting_bindings( monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -252,7 +270,7 @@ def test_linux_sandbox_fails_closed_for_root_caller( ) -> None: monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 0) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + _mock_linux_tools(tmp_path, monkeypatch) result, surviving = run_supervised( ["/bin/true"], cwd=tmp_path, timeout=1, env={}, @@ -268,7 +286,7 @@ def test_linux_sandbox_opens_and_unlinks_checker_fifo_before_candidate( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -308,7 +326,7 @@ def test_sandbox_directory_bind_provides_parent_for_nested_file( interpreter = nested / "python" interpreter.write_text("python", encoding="utf-8") monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -353,16 +371,7 @@ def test_sandbox_binds_resolved_runtime_sources_at_original_destinations( "pdd.sync_core.supervisor._SUPERVISOR_EXECUTABLE", executable_destination, ) - sandbox_tools = { - "bwrap": "/usr/bin/bwrap", - "sudo": "/usr/bin/sudo", - "setpriv": "/usr/bin/setpriv", - } - monkeypatch.setattr( - shutil, - "which", - sandbox_tools.get, - ) + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -436,16 +445,17 @@ def test_supervisor_separates_physical_and_virtual_memory_limits() -> None: assert str(limits.max_virtual_memory_bytes) in command assert str(limits.max_memory_bytes) not in command[1:7] + assert "RLIMIT_NPROC" not in command[2] -def test_linux_sandbox_uses_path_lookup_for_bwrap_execution( +def test_linux_sandbox_binds_probe_identity_to_systemd_scope_execution( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A mocked probe path must not become the executable launched at runtime.""" + """Run one valid transient scope with exact trusted executable identities.""" monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + tools = _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -454,20 +464,29 @@ def test_linux_sandbox_uses_path_lookup_for_bwrap_execution( "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () ) - argv, _profile = _sandbox_command(["/bin/true"], (tmp_path,)) - bwrap = json.loads(argv[-4]) + argv, plan = _sandbox_command([sys.executable, "-c", "pass"], (tmp_path,)) - assert bwrap[0] == "bwrap" + assert argv[:4] == [tools["sudo"], "-n", "-E", tools["systemd-run"]] + assert "--scope" in argv + assert "--wait" not in argv + assert "--collect" not in argv + assert f"--unit={plan.unit_name}" in argv + assert "--property=MemoryMax=2147483648" in argv + assert "--property=MemorySwapMax=0" in argv + assert "--property=TasksMax=128" in argv + assert "--property=OOMPolicy=kill" in argv + assert "--property=KillMode=control-group" in argv + assert plan.bwrap_argv[0] == tools["bwrap"] -def test_linux_sandbox_installs_cgroup_limits_before_bwrap_exec( +def test_linux_sandbox_releases_candidate_only_after_scope_probe( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """The privileged helper must configure the aggregate cgroup before release.""" monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), @@ -476,39 +495,53 @@ def test_linux_sandbox_installs_cgroup_limits_before_bwrap_exec( "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () ) - argv, _profile = _sandbox_command(["/bin/true"], (tmp_path,)) - helper = argv[5] + _argv, plan = _sandbox_command([sys.executable, "-c", "pass"], (tmp_path,)) + helper = plan.helper_source + + compile(helper, "", "exec") + assert "cgroup.procs" not in helper + assert "memory.max" not in helper + assert "ready" in helper and "start" in helper + assert helper.index("start") < helper.index("os.fork()") + assert "result.json" in helper and "finish" in helper + assert "@PDD-CGROUP@" in plan.bwrap_argv + cgroup_index = plan.bwrap_argv.index("/sys/fs/cgroup") + assert plan.bwrap_argv[cgroup_index - 2] == "--ro-bind" + assert plan.bwrap_argv[cgroup_index - 1] == "@PDD-CGROUP@" + + +def test_scope_unit_name_is_unique_and_strictly_validated() -> None: + first = supervisor._scope_unit_name() + second = supervisor._scope_unit_name() - compile(helper, "", "exec") - assert "memory.max" in helper - assert "memory.swap.max" in helper - assert "memory.oom.group" in helper - assert "pids.max" in helper - assert helper.index("memory.max") < helper.index("os.kill(pid,signal.SIGCONT)") + assert first != second + assert supervisor._validated_scope_unit(first) == first + for invalid in ( + "pdd-validator.scope", "../other.scope", + "pdd-validator-00000000000000000000000000000000.service", + ): + with pytest.raises(RuntimeError, match="invalid protected scope unit"): + supervisor._validated_scope_unit(invalid) -def test_cgroup_teardown_error_fails_closed( +def test_scope_cleanup_error_fails_closed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A failed post-run cgroup teardown must invalidate an otherwise clean exit.""" + """A failed systemd scope teardown must invalidate an otherwise clean exit.""" + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + tools = supervisor._trusted_tools() monkeypatch.setattr( - supervisor, "_sandbox_command", lambda *_args, **_kwargs: (["/bin/true"], None) - ) - original_run = subprocess.run - - def run(command, **kwargs): - if supervisor._CGROUP_CLEANUP in command: - return subprocess.CompletedProcess(command, 1, "", "cannot remove cgroup") - return original_run(command, check=kwargs.pop("check", False), **kwargs) - - monkeypatch.setattr(supervisor.subprocess, "run", run) - result, surviving = run_supervised( - ["/bin/true"], cwd=tmp_path, timeout=5, env={}, writable_roots=(tmp_path,) + supervisor.subprocess, "run", + lambda command, **_kwargs: subprocess.CompletedProcess( + command, 1, "", "cannot remove scope" + ), ) - assert result.returncode == 125 - assert "cgroup teardown failed" in result.stderr - assert not surviving + with pytest.raises(RuntimeError, match="scope teardown failed"): + supervisor._stop_scope( + "pdd-validator-00000000000000000000000000000000.scope", tools + ) @pytest.mark.skipif( @@ -709,7 +742,7 @@ def test_macos_fails_closed_without_kernel_lifetime_containment( ) -> None: monkeypatch.setattr(sys, "platform", "darwin") result, surviving = run_supervised( - ["/bin/true"], cwd=tmp_path, timeout=1, env={}, + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, env={}, writable_roots=(tmp_path,), ) assert result.returncode == 125 From 261cc5460d65f89f1873c4c093f3d06a0dea5930 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 20:27:27 -0700 Subject: [PATCH 067/233] fix(sync): verify transient systemd scope containment --- .github/workflows/unit-tests.yml | 47 +- pdd/sync_core/supervisor.py | 779 +++++++++++++++++++---------- tests/test_sync_core_supervisor.py | 83 ++- 3 files changed, 638 insertions(+), 271 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4b33ad97c5..d2e891919f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -186,10 +186,11 @@ jobs: ["ldd", str(resolved_interpreter)], check=False, text=True, capture_output=True, ).stdout) - sandbox, _ = _sandbox_command( + sandbox, plan = _sandbox_command( [sys.executable, "-c", "import math"], (root / "scratch",) ) - print("sandbox destinations:", json.loads(sandbox[-4])) + print("sandbox command:", sandbox[:4]) + print("sandbox destinations:", plan.bwrap_argv) print("runtime native paths:", [ str(path) for label, path in released_runtime_closure_paths() if label.startswith("native/") @@ -227,9 +228,25 @@ jobs: from pdd.sync_core.supervisor import SupervisorLimits, run_supervised root = Path(os.environ["ROOT"]) + def assert_no_leak(): + units = subprocess.run( + ['sudo', '-n', 'systemctl', 'list-units', + 'pdd-validator-*.scope', '--all', '--no-legend'], + check=True, capture_output=True, text=True, + ).stdout.strip() + assert not units, units + groups = subprocess.run( + ['sudo', '-n', 'find', '/sys/fs/cgroup', '-type', 'd', + '-name', 'pdd-validator-*', '-print'], + check=True, capture_output=True, text=True, + ).stdout.strip() + assert not groups, groups + + import subprocess + assert_no_leak() program = '''import os from pathlib import Path - group = Path('/sys/fs/cgroup') / os.environ['PDD_CGROUP_NAME'] + group = Path('/sys/fs/cgroup') expected = { 'memory.max': str(2 * 1024 * 1024 * 1024), 'memory.swap.max': '0', @@ -239,6 +256,7 @@ jobs: for name, value in expected.items(): actual = (group / name).read_text(encoding='ascii').strip() assert actual == value, (name, actual) + assert not [path for path in group.iterdir() if path.is_dir()] try: (group / 'cgroup.procs').write_text(str(os.getpid()), encoding='ascii') except OSError: @@ -253,20 +271,28 @@ jobs: ) assert result.returncode == 0, result.stderr assert not surviving + assert_no_leak() oom, surviving = run_supervised( - [sys.executable, '-c', 'bytearray(3 * 1024 * 1024 * 1024)'], + [sys.executable, '-c', 'bytearray(512 * 1024 * 1024)'], cwd=root / 'scratch', timeout=30, env=dict(os.environ), - writable_roots=(root / 'scratch',), limits=SupervisorLimits(), + writable_roots=(root / 'scratch',), limits=SupervisorLimits( + max_memory_bytes=128 * 1024 * 1024, + max_virtual_memory_bytes=2 * 1024 * 1024 * 1024, + ), ) assert oom.returncode != 0, oom.stderr + assert 'cgroup memory.events oom delta=' in oom.stderr, oom.stderr assert not surviving + assert_no_leak() tasks, surviving = run_supervised( [sys.executable, '-c', "import subprocess,sys; children=[subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(2)']) for _ in range(16)]; [child.wait() for child in children]"], cwd=root / 'scratch', timeout=30, env=dict(os.environ), writable_roots=(root / 'scratch',), limits=SupervisorLimits(max_processes=8), ) assert tasks.returncode != 0, tasks.stderr + assert 'cgroup pids.events max delta=' in tasks.stderr, tasks.stderr assert not surviving + assert_no_leak() timed_out, surviving = run_supervised( [sys.executable, '-c', 'import time; time.sleep(30)'], cwd=root / 'scratch', timeout=1, env=dict(os.environ), @@ -274,7 +300,16 @@ jobs: ) assert timed_out.returncode == 124, timed_out.stderr assert not surviving - assert not list(Path('/sys/fs/cgroup').glob('pdd-*')) + assert_no_leak() + output, surviving = run_supervised( + [sys.executable, '-c', "print('x' * 200000)"], + cwd=root / 'scratch', timeout=30, env=dict(os.environ), + writable_roots=(root / 'scratch',), + limits=SupervisorLimits(max_output_bytes=1024), + ) + assert output.returncode == 125, output.stderr + assert not surviving + assert_no_leak() PY - name: Verify protected pytest smoke diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 7f1c3202df..03e836755b 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -5,6 +5,7 @@ import os import json +import re import shutil import signal import subprocess @@ -23,6 +24,8 @@ # replace ``sys.executable`` to model argv-prefix portability; that synthetic # spelling must never become a measured file or sandbox mount source. _SUPERVISOR_EXECUTABLE = Path(sys.executable) +_TRUSTED_ROOT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +_SCOPE_PATTERN = re.compile(r"pdd-validator-[0-9a-f]{32}\.scope") @dataclass(frozen=True) @@ -37,6 +40,70 @@ class SupervisorLimits: max_processes: int = 128 +@dataclass(frozen=True) +class _TrustedTools: + """Exact privileged executable identities used by one protected run.""" + + bwrap: Path + mount: Path + setpriv: Path + sudo: Path + systemctl: Path + systemd_run: Path + umount: Path + + +@dataclass(frozen=True) +class _ScopePlan: + """Immutable transient-scope construction and cleanup state.""" + + unit_name: str + control_directory: Path + helper_source: str + bwrap_argv: tuple[str, ...] + path_tokens: tuple[str, ...] + sources: tuple[Path, ...] + staging_targets: tuple[Path, ...] + tools: _TrustedTools + + +def _scope_unit_name() -> str: + """Return an unguessable unit name reserved to one supervisor invocation.""" + return f"pdd-validator-{uuid.uuid4().hex}.scope" + + +def _validated_scope_unit(unit_name: str) -> str: + """Reject any unit spelling that could target another systemd object.""" + if _SCOPE_PATTERN.fullmatch(unit_name) is None: + raise RuntimeError("invalid protected scope unit name") + return unit_name + + +def _trusted_executable(name: str) -> Path: + """Resolve one regular executable from the fixed trusted root PATH.""" + value = shutil.which(name, path=_TRUSTED_ROOT_PATH) + if value is None: + raise RuntimeError(f"protected sandbox requires trusted {name}") + try: + path = Path(value).resolve(strict=True) + except OSError as exc: + raise RuntimeError(f"protected sandbox cannot resolve trusted {name}") from exc + if not path.is_file() or not os.access(path, os.X_OK): + raise RuntimeError(f"protected sandbox trusted {name} is not executable") + return path + + +def _trusted_tools() -> _TrustedTools: + """Resolve the complete privileged toolchain once for probe and execution.""" + return _TrustedTools(**{ + name.replace("-", "_"): _trusted_executable(name) + for name in ( + "bwrap", "mount", "setpriv", "sudo", "systemctl", "systemd-run", + "umount", + ) + }) + + def _linked_libraries(path: Path) -> tuple[Path, ...]: """Resolve loader-visible and physical paths for ELF dependencies.""" if not sys.platform.startswith("linux"): @@ -100,19 +167,18 @@ def released_runtime_closure_paths() -> tuple[tuple[str, Path], ...]: entries[f"{label}/{path.relative_to(directory).as_posix()}"] = path if path.suffix in {".so", ".dylib"}: native.add(path) - sandbox_commands = { - "bwrap": shutil.which("bwrap"), - "setpriv": shutil.which("setpriv"), - "sudo": shutil.which("sudo"), - "systemd-run": shutil.which("systemd-run"), - "mount": shutil.which("mount"), - "umount": shutil.which("umount"), - } + sandbox_commands = {} + if sys.platform.startswith("linux"): + for name in ( + "bwrap", "mount", "setpriv", "sudo", "systemctl", "systemd-run", + "umount", + ): + if shutil.which(name, path=_TRUSTED_ROOT_PATH): + sandbox_commands[name] = _trusted_executable(name) for name, value in sandbox_commands.items(): - if value: - path = Path(value).resolve() - entries[f"sandbox/{name}"] = path - native.add(path) + path = Path(value).resolve() + entries[f"sandbox/{name}"] = path + native.add(path) entries["interpreter/python"] = _SUPERVISOR_EXECUTABLE.resolve() for path in sorted(native): for library in _linked_libraries(path): @@ -137,7 +203,9 @@ def _runtime_roots(command: list[str], cwd: Path) -> tuple[Path, ...]: if not resolved_executable.is_relative_to(cwd): roots.add(resolved_executable) for _label, path in released_runtime_closure_paths(): - if path.name in {"bwrap", "setpriv", "sudo", "systemd-run", "mount", "umount"} or any( + if path.name in { + "bwrap", "setpriv", "sudo", "systemctl", "systemd-run", "mount", "umount", + } or any( path.is_relative_to(directory) for directory in directories ): continue @@ -163,169 +231,96 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: """Apply non-raiseable POSIX limits after the namespace uid drop.""" script = ( "import os,resource,sys;" - "v=[int(x) for x in sys.argv[1:6]];" + "v=[int(x) for x in sys.argv[1:5]];" "resource.setrlimit(resource.RLIMIT_AS,(v[0],v[0]));" "resource.setrlimit(resource.RLIMIT_CPU,(v[1],v[1]));" - "resource.setrlimit(resource.RLIMIT_NPROC,(v[2],v[2]));" - "resource.setrlimit(resource.RLIMIT_FSIZE,(v[3],v[3]));" - "resource.setrlimit(resource.RLIMIT_NOFILE,(v[4],v[4]));" - "os.execvpe(sys.argv[6],sys.argv[6:],os.environ)" + "resource.setrlimit(resource.RLIMIT_FSIZE,(v[2],v[2]));" + "resource.setrlimit(resource.RLIMIT_NOFILE,(v[3],v[3]));" + "os.execvpe(sys.argv[5],sys.argv[5:],os.environ)" ) return [str(_SUPERVISOR_EXECUTABLE), "-c", script, str(limits.max_virtual_memory_bytes), - str(limits.max_cpu_seconds), str(limits.max_processes), - str(limits.max_output_bytes), "256", *command] + str(limits.max_cpu_seconds), str(limits.max_output_bytes), "256", *command] def _staged_bwrap( argv: list[str], sources: list[Path], path_tokens: list[str], *, - cgroup_name: str, limits: SupervisorLimits, use_systemd_scope: bool, -) -> list[str]: - """Stage trusted mounts and contain the complete child tree in cgroup v2.""" + unit_name: str, control_directory: Path, limits: SupervisorLimits, + tools: _TrustedTools, +) -> tuple[list[str], _ScopePlan]: + """Build one scope-held helper that releases Bubblewrap after verification.""" + unit_name = _validated_scope_unit(unit_name) + staging = control_directory / "binds" + staging_targets = tuple(staging / str(index) for index in range(len(sources))) + cgroup_target = staging / "cgroup" helper = "\n".join(( - "import json,os,pathlib,shutil,signal,subprocess,sys,tempfile,time", - "path_tokens=json.loads(sys.argv[1]); argv=json.loads(sys.argv[2]); paths=json.loads(sys.argv[3])", - "name=sys.argv[4]; limits=json.loads(sys.argv[5])", - "base=pathlib.Path(tempfile.mkdtemp(prefix='pdd-binds-',dir='/run'))", - "os.chmod(base,0o755); staged=[]; result=None; cgroup=None", - "def write(path,value): path.write_text(str(value),encoding='ascii')", - "def cgroup_root():", - " mount=pathlib.Path('/sys/fs/cgroup')", - " if not (mount/'cgroup.controllers').is_file(): raise RuntimeError('protected sandbox requires writable cgroup v2')", - " return mount", - "def configure_group():", - " root=cgroup_root(); controls=(root/'cgroup.subtree_control').read_text(encoding='ascii').split()", - " available=(root/'cgroup.controllers').read_text(encoding='ascii').split()", - " for controller in ('memory','pids'):", - " if controller not in available: raise RuntimeError('protected sandbox cgroup v2 lacks '+controller+' controller')", - " if controller not in controls: write(root/'cgroup.subtree_control','+'+controller)", - " group=root/name; group.mkdir()", - " try:", - " write(group/'memory.max',limits['memory'])", - " write(group/'memory.swap.max',0)", - " write(group/'memory.oom.group',1)", - " write(group/'pids.max',limits['pids'])", - " except BaseException:", - " group.rmdir(); raise", - " return group", - "def cleanup_group(group):", - " if group is None or not group.exists(): return", - " write(group/'cgroup.kill',1)", - " deadline=time.monotonic()+5", - " while (group/'cgroup.procs').read_text(encoding='ascii').strip():", - " if time.monotonic() >= deadline: raise RuntimeError('protected sandbox cgroup did not empty')", - " time.sleep(.01)", - " group.rmdir()", + "import json,os,pathlib,subprocess,sys,time", + "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", + "path_tokens=json.loads(sys.argv[-5]); argv=json.loads(sys.argv[-4]); " + "paths=json.loads(sys.argv[-3])", + "targets=[control/'binds'/str(index) for index in range(len(paths))]", + "staged=[]; result=None; cleanup_error=None", + "def wait_for(name):", + " path=control/name", + " while not path.exists(): time.sleep(.01)", + "def own_cgroup():", + " lines=pathlib.Path('/proc/self/cgroup').read_text(encoding='ascii').splitlines()", + " relative=next((line[3:] for line in lines if line.startswith('0::')),None)", + " if not relative: raise RuntimeError('protected scope has no cgroup-v2 membership')", + " source=pathlib.Path('/sys/fs/cgroup')/relative.lstrip('/')", + " if source == pathlib.Path('/sys/fs/cgroup') or not source.is_dir(): " + "raise RuntimeError('protected scope cgroup is invalid')", + " return source", "try:", - " for index,source in enumerate(paths):", - " source=pathlib.Path(source); target=base/str(index)", - " target.mkdir() if source.is_dir() else target.touch()", - " subprocess.run(['mount','--bind',str(source),str(target)],check=True)", + " for source,target in zip(paths,targets):", + " subprocess.run([mount,'--bind',source,str(target)],check=True)", " staged.append(target)", " path_map={token:str(staged[index]) for index,token in enumerate(path_tokens)}", " argv=[path_map.get(value,value) for value in argv]", - " cgroup=configure_group()", + " cgroup_target=control/'binds'/'cgroup'", + " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)],check=True)", + " staged.append(cgroup_target)", + " subprocess.run([umount,str(cgroup_target)],check=True)", + " staged.pop()", + " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)],check=True)", + " staged.append(cgroup_target)", + " argv=[str(cgroup_target) if value == '@PDD-CGROUP@' else value for value in argv]", + " (control/'ready').write_text('ready',encoding='ascii')", + " wait_for('start')", " pid=os.fork()", " if pid == 0:", - " os.kill(os.getpid(),signal.SIGSTOP)", " os.execvpe(argv[0],argv,os.environ)", - " stopped,status=os.waitpid(pid,os.WUNTRACED)", - " if stopped != pid or not os.WIFSTOPPED(status): raise RuntimeError('protected sandbox cannot stop child before cgroup assignment')", - " write(cgroup/'cgroup.procs',pid)", - " os.kill(pid,signal.SIGCONT)", " _wait,status=os.waitpid(pid,0)", " result=os.waitstatus_to_exitcode(status)", + " temporary=control/'result.tmp'", + " temporary.write_text(json.dumps({'returncode':result}),encoding='ascii')", + " os.replace(temporary,control/'result.json')", + " wait_for('finish')", "finally:", - " cleanup_error=None", - " try: cleanup_group(cgroup)", - " except BaseException as exc: cleanup_error=exc", " for target in reversed(staged):", - " subprocess.run(['umount',str(target)],check=False)", - " shutil.rmtree(base,ignore_errors=True)", - "if cleanup_error is not None: raise RuntimeError('protected sandbox cgroup cleanup failed') from cleanup_error", - "raise SystemExit(result if result is not None else 125)", + " completed=subprocess.run([umount,str(target)],capture_output=True," + "text=True,check=False)", + " if completed.returncode != 0: cleanup_error=completed.stderr or 'umount failed'", + "if cleanup_error:", + " (control/'cleanup-error').write_text(cleanup_error,encoding='utf-8')", + " raise SystemExit(125)", + "raise SystemExit(result if result is not None and result >= 0 else " + "(128-result if result is not None else 125))", )) - command = ["sudo", "-n", "-E", str(_SUPERVISOR_EXECUTABLE), "-c", helper, - json.dumps(path_tokens), json.dumps(argv), - json.dumps([str(path) for path in sources]), cgroup_name, - json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes})] - if not use_systemd_scope: - return command - return [ - "sudo", "-n", "-E", "systemd-run", "--scope", "--quiet", "--wait", - "--collect", f"--property=MemoryMax={limits.max_memory_bytes}", + plan = _ScopePlan( + unit_name, control_directory, helper, tuple(argv), tuple(path_tokens), + tuple(sources), (*staging_targets, cgroup_target), tools, + ) + command = [ + str(tools.sudo), "-n", "-E", str(tools.systemd_run), "--scope", "--quiet", + f"--unit={unit_name}", f"--property=MemoryMax={limits.max_memory_bytes}", "--property=MemorySwapMax=0", f"--property=TasksMax={limits.max_processes}", - "--property=OOMPolicy=kill", *command[3:], + "--property=OOMPolicy=kill", "--property=KillMode=control-group", "--", + str(_SUPERVISOR_EXECUTABLE), "-c", helper, str(control_directory), + str(tools.mount), str(tools.umount), json.dumps(path_tokens), json.dumps(argv), + json.dumps([str(path) for path in sources]), unit_name, + json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes}), ] - - -_CGROUP_PROBE = """import os,pathlib,uuid -root=pathlib.Path('/sys/fs/cgroup') -if not (root/'cgroup.controllers').is_file(): raise SystemExit('cgroup v2 is unavailable') -available=(root/'cgroup.controllers').read_text(encoding='ascii').split() -if not {'memory','pids'} <= set(available): raise SystemExit('cgroup v2 memory/pids controllers are unavailable') -controls=(root/'cgroup.subtree_control').read_text(encoding='ascii').split() -for controller in ('memory','pids'): - if controller not in controls: (root/'cgroup.subtree_control').write_text('+'+controller,encoding='ascii') -group=root/('pdd-probe-'+uuid.uuid4().hex) -group.mkdir() -try: - (group/'memory.max').write_text('max',encoding='ascii') - (group/'memory.swap.max').write_text('0',encoding='ascii') - (group/'memory.oom.group').write_text('1',encoding='ascii') - (group/'pids.max').write_text('max',encoding='ascii') -finally: - group.rmdir() -""" - - -def _cgroup_v2_capability() -> str | None: - """Probe the privileged cgroup-v2 controls required before candidate exec.""" - try: - probe = subprocess.run( - ["sudo", "-n", str(_SUPERVISOR_EXECUTABLE), "-c", _CGROUP_PROBE], - capture_output=True, text=True, check=False, - ) - except OSError as exc: - return f"protected sandbox cannot probe cgroup v2 controls: {exc}" - if probe.returncode != 0: - detail = " ".join(probe.stderr.split())[:256] - return "protected sandbox requires writable cgroup v2 memory/pids controls" + ( - f": {detail}" if detail else "" - ) - return None - - -def _systemd_scope_usable(limits: SupervisorLimits) -> bool: - """Return whether a transient scope can prove the required cgroup properties.""" - systemd = shutil.which("systemd-run") - if systemd is None or not Path(systemd).is_file(): - return False - try: - result = subprocess.run( - ["sudo", "-n", systemd, "--scope", "--quiet", "--wait", "--collect", - f"--property=MemoryMax={limits.max_memory_bytes}", - "--property=MemorySwapMax=0", - f"--property=TasksMax={limits.max_processes}", - "--property=OOMPolicy=kill", "/bin/true"], - capture_output=True, text=True, check=False, - ) - except OSError: - return False - return result.returncode == 0 - - -_CGROUP_CLEANUP = """import pathlib,sys,time -name=sys.argv[1] -if not name.startswith('pdd-') or len(name) != 36: raise SystemExit('invalid protected cgroup name') -group=pathlib.Path('/sys/fs/cgroup')/name -if not group.exists(): raise SystemExit(0) -(group/'cgroup.kill').write_text('1',encoding='ascii') -deadline=time.monotonic()+5 -while (group/'cgroup.procs').read_text(encoding='ascii').strip(): - if time.monotonic() >= deadline: raise SystemExit('protected cgroup did not empty') - time.sleep(.01) -group.rmdir() -""" + return command, plan def _private_result_command( @@ -439,6 +434,185 @@ def _writable_size(roots: tuple[Path, ...]) -> int: return total +def _root_environment() -> dict[str, str]: + """Return the fixed environment used for every privileged tool invocation.""" + return {"PATH": _TRUSTED_ROOT_PATH, "LANG": "C", "LC_ALL": "C"} + + +def _root_run( + tools: _TrustedTools, arguments: list[str], *, check: bool = False, +) -> subprocess.CompletedProcess[str]: + """Invoke systemctl through the exact sudo/systemctl identities already probed.""" + return subprocess.run( + [str(tools.sudo), "-n", str(tools.systemctl), *arguments], + capture_output=True, text=True, check=check, env=_root_environment(), + ) + + +def _scope_properties(unit_name: str, tools: _TrustedTools) -> dict[str, str]: + """Read authoritative transient-scope state without collecting the unit.""" + unit_name = _validated_scope_unit(unit_name) + names = ( + "LoadState", "ActiveState", "ControlGroup", "MemoryMax", + "MemorySwapMax", "TasksMax", "OOMPolicy", "KillMode", "Result", + "OOMKilled", + ) + completed = _root_run( + tools, ["show", unit_name, *(f"--property={name}" for name in names)], + ) + if completed.returncode != 0: + raise RuntimeError( + "protected scope probe failed: " + (completed.stderr.strip() or "systemctl show failed") + ) + properties = {} + for line in completed.stdout.splitlines(): + name, separator, value = line.partition("=") + if separator: + properties[name] = value + return properties + + +def _scope_cgroup(properties: dict[str, str]) -> Path: + """Validate the systemd-owned workload cgroup path.""" + relative = properties.get("ControlGroup", "") + if not relative.startswith("/") or relative == "/" or ".." in Path(relative).parts: + raise RuntimeError("protected scope has invalid ControlGroup") + root = Path("/sys/fs/cgroup") + path = root / relative.lstrip("/") + try: + if not path.resolve(strict=True).is_relative_to(root.resolve(strict=True)): + raise RuntimeError("protected scope escaped cgroup-v2 root") + except OSError as exc: + raise RuntimeError("protected scope cgroup does not exist") from exc + return path + + +def _cgroup_events(cgroup: Path, filename: str) -> dict[str, int]: + """Read one kernel cgroup event file as non-negative counters.""" + try: + lines = (cgroup / filename).read_text(encoding="ascii").splitlines() + events = {name: int(value) for name, value in (line.split() for line in lines)} + except (OSError, ValueError) as exc: + raise RuntimeError(f"protected scope cannot read {filename}") from exc + if any(value < 0 for value in events.values()): + raise RuntimeError(f"protected scope has invalid {filename}") + return events + + +def _probe_scope( + plan: _ScopePlan, limits: SupervisorLimits, +) -> tuple[Path, dict[str, int], dict[str, int]]: + """Prove systemd and kernel limits before releasing the candidate.""" + properties = _scope_properties(plan.unit_name, plan.tools) + expected = { + "LoadState": "loaded", "ActiveState": "active", + "MemoryMax": str(limits.max_memory_bytes), "MemorySwapMax": "0", + "TasksMax": str(limits.max_processes), "OOMPolicy": "kill", + "KillMode": "control-group", + } + differences = [ + f"{name}={properties.get(name, '')}" + for name, value in expected.items() if properties.get(name) != value + ] + if differences: + raise RuntimeError("protected scope properties unverified: " + ", ".join(differences)) + cgroup = _scope_cgroup(properties) + kernel_limits = { + "memory.max": str(limits.max_memory_bytes), "memory.swap.max": "0", + "memory.oom.group": "1", "pids.max": str(limits.max_processes), + } + for filename, expected_value in kernel_limits.items(): + try: + actual = (cgroup / filename).read_text(encoding="ascii").strip() + except OSError as exc: + raise RuntimeError(f"protected scope cannot read {filename}") from exc + if actual != expected_value: + raise RuntimeError( + f"protected scope kernel limit mismatch: {filename}={actual}" + ) + return cgroup, _cgroup_events(cgroup, "memory.events"), _cgroup_events(cgroup, "pids.events") + + +def _stop_scope(unit_name: str, tools: _TrustedTools) -> None: + """Kill the exact whole scope and prove that systemd removed it.""" + unit_name = _validated_scope_unit(unit_name) + initial = _root_run(tools, ["show", unit_name, "--property=LoadState"]) + if initial.stdout.strip() == "LoadState=not-found" or ( + initial.returncode != 0 + and any(value in initial.stderr.lower() for value in ("not loaded", "not found")) + ): + return + if initial.returncode != 0: + raise RuntimeError( + "protected scope teardown failed: " + + (initial.stderr.strip() or "cannot probe scope") + ) + errors = [] + for arguments in ( + ["kill", "--kill-whom=all", "--signal=SIGKILL", unit_name], + ["stop", unit_name], + ["reset-failed", unit_name], + ): + completed = _root_run(tools, arguments) + if completed.returncode != 0 and "not loaded" not in completed.stderr.lower(): + errors.append(completed.stderr.strip() or " ".join(arguments) + " failed") + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + completed = _root_run(tools, ["show", unit_name, "--property=LoadState"]) + output = completed.stdout.strip() + if completed.returncode != 0 or output == "LoadState=not-found": + break + time.sleep(.05) + else: + errors.append("scope unit was not removed") + if errors: + raise RuntimeError("protected scope teardown failed: " + "; ".join(errors)) + + +def _prepare_staging(plan: _ScopePlan) -> None: + """Create private bind targets before the privileged scope helper starts.""" + binds = plan.control_directory / "binds" + binds.mkdir(mode=0o700) + for source, target in zip(plan.sources, plan.staging_targets[:-1]): + if source.is_dir(): + target.mkdir(mode=0o700) + else: + target.touch(mode=0o600) + plan.staging_targets[-1].mkdir(mode=0o700) + + +def _mounted_paths() -> set[Path]: + """Return host mountpoints using the kernel's escaped mount table.""" + paths = set() + try: + lines = Path("/proc/self/mountinfo").read_text(encoding="utf-8").splitlines() + except OSError: + return paths + for line in lines: + fields = line.split() + if len(fields) > 4: + paths.add(Path(fields[4].replace("\\040", " ").replace("\\134", "\\"))) + return paths + + +def _cleanup_staging(plan: _ScopePlan) -> None: + """Remove any helper mounts left after authoritative scope termination.""" + errors = [] + for target in reversed(plan.staging_targets): + if target not in _mounted_paths(): + continue + completed = subprocess.run( + [str(plan.tools.sudo), "-n", str(plan.tools.umount), str(target)], + capture_output=True, text=True, check=False, env=_root_environment(), + ) + if completed.returncode != 0: + errors.append(completed.stderr.strip() or f"cannot unmount {target}") + if any(target in _mounted_paths() for target in plan.staging_targets): + errors.append("protected bind staging remains mounted") + if errors: + raise RuntimeError("protected scope staging cleanup failed: " + "; ".join(errors)) + + def _sandbox_command( command: list[str], writable_roots: tuple[Path, ...], *, cwd: Path | None = None, writable_files: tuple[Path, ...] = (), limits: SupervisorLimits = SupervisorLimits(), @@ -447,34 +621,32 @@ def _sandbox_command( writable_bindings: tuple[tuple[Path, Path], ...] = (), result_fifo: Path | None = None, result_fd: int = 198, - cgroup_name: str | None = None, -) -> tuple[list[str], Path | None]: + unit_name: str | None = None, + control_directory: Path | None = None, +) -> tuple[list[str], _ScopePlan]: # pylint: disable=too-many-locals,too-many-branches,too-many-statements """Return an explicitly detected macOS/Linux sandbox command.""" if sys.platform == "darwin": raise RuntimeError( "unsupported protected sandbox: macOS cannot prove process lifetime isolation" ) - if sys.platform.startswith("linux") and shutil.which("bwrap"): + if sys.platform.startswith("linux"): + tools = _trusted_tools() if os.getuid() == 0: raise RuntimeError( "protected sandbox requires a non-root caller so process limits " "remain kernel-enforced" ) - if not (bool(shutil.which("sudo")) and subprocess.run( - ["sudo", "-n", "true"], capture_output=True, check=False, - ).returncode == 0): + if subprocess.run( + [str(tools.sudo), "-n", str(_SUPERVISOR_EXECUTABLE), "-c", "pass"], + capture_output=True, check=False, env=_root_environment(), + ).returncode != 0: raise RuntimeError("protected sandbox requires privileged bind staging") - setpriv = shutil.which("setpriv") - if setpriv is None: - raise RuntimeError("protected sandbox requires setpriv for post-mount uid drop") - capability_error = _cgroup_v2_capability() - if capability_error is not None: - raise RuntimeError(capability_error) workdir = (cwd or Path.cwd()).resolve() - argv = ["bwrap", "--unshare-ipc", "--unshare-pid", "--unshare-net", + argv = [str(tools.bwrap), "--unshare-ipc", "--unshare-pid", "--unshare-net", "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", - "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp"] + "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp", + "--dir", "/sys", "--dir", "/sys/fs", "--dir", "/sys/fs/cgroup"] sources: list[Path] = [] path_tokens: list[str] = [] destination_dirs = {Path("/tmp")} @@ -518,14 +690,12 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: # A host bind follows symlinks, but the process command and ELF # loader retain their original spellings in the new namespace. bind("--ro-bind", item.resolve(), item) - # The candidate can inspect only a read-only cgroup-v2 view. It needs - # this for diagnostics, but cannot write controls or migrate itself. - bind("--ro-bind", Path("/sys/fs/cgroup")) + # The helper replaces this placeholder with only its systemd scope. + argv.extend(("--ro-bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) # ``setpriv`` executes after the namespace root is installed, so bind # it and its ELF closure directly even when PATH resolution differs. - if setpriv is not None: - setpriv_path = Path(setpriv) - for item in (setpriv_path, *_linked_libraries(setpriv_path)): + if tools.setpriv: + for item in (tools.setpriv, *_linked_libraries(tools.setpriv)): bind("--ro-bind", item.resolve(), item) for item in readable_roots: bind("--ro-bind", item.resolve()) @@ -543,8 +713,8 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: bind("--bind", result_fifo.parent.resolve()) argv.extend(("--chdir", str(workdir))) drop = ( - [setpriv, "--reuid", str(os.getuid()), "--regid", str(os.getgid()), - "--clear-groups", "--"] if setpriv else [] + [str(tools.setpriv), "--reuid", str(os.getuid()), "--regid", str(os.getgid()), + "--clear-groups", "--"] ) sandboxed = _limited_command(command, limits) if result_fifo is not None: @@ -552,9 +722,12 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: argv.extend(("--", *drop, *sandboxed)) return _staged_bwrap( argv, sources, path_tokens, - cgroup_name=cgroup_name or f"pdd-{uuid.uuid4().hex}", limits=limits, - use_systemd_scope=_systemd_scope_usable(limits), - ), None + unit_name=unit_name or _scope_unit_name(), + control_directory=control_directory or ( + Path(tempfile.gettempdir()) / f"pdd-scope-{uuid.uuid4().hex}" + ), + limits=limits, tools=tools, + ) raise RuntimeError("unsupported sandbox platform or mechanism") @@ -569,104 +742,194 @@ def run_supervised( result_fifo: Path | None = None, result_fd: int = 198, ) -> tuple[subprocess.CompletedProcess[str], bool]: - """Run sandboxed and terminate marked descendants across session changes.""" + """Run only after proving one aggregate systemd scope, then remove it.""" # pylint: disable=consider-using-with,too-many-locals,too-many-branches,too-many-statements - try: - cgroup_name = f"pdd-{uuid.uuid4().hex}" - argv, profile = _sandbox_command( - command, writable_roots, cwd=cwd, writable_files=writable_files, - limits=limits, readable_roots=readable_roots, - readable_bindings=readable_bindings, - writable_bindings=writable_bindings, - result_fifo=result_fifo, result_fd=result_fd, cgroup_name=cgroup_name, - ) - except RuntimeError as exc: - return subprocess.CompletedProcess(command, 125, "", str(exc)), False token = uuid.uuid4().hex + unit_name = _scope_unit_name() stdout_file = tempfile.TemporaryFile(mode="w+b") stderr_file = tempfile.TemporaryFile(mode="w+b") - sandbox_environment = env | { - "PYTHONDONTWRITEBYTECODE": "1", - "PDD_SUPERVISION_TOKEN": token, - "PDD_CGROUP_NAME": cgroup_name, - "TMPDIR": str(temp_directory or writable_roots[0].resolve()), - "TEMP": str(temp_directory or writable_roots[0].resolve()), - "TMP": str(temp_directory or writable_roots[0].resolve()), - } - library_path = _sandbox_library_path(env) - if library_path: - sandbox_environment["LD_LIBRARY_PATH"] = library_path - try: - process = subprocess.Popen( - argv, cwd=cwd, stdout=stdout_file, stderr=stderr_file, - env=sandbox_environment, - start_new_session=True, - ) - except OSError as exc: - stdout_file.close() - stderr_file.close() - return subprocess.CompletedProcess(command, 125, "", str(exc)), False timed_out = False + failed_closed = False surviving = False + candidate_returncode: int | None = None + process: subprocess.Popen[bytes] | None = None + plan: _ScopePlan | None = None + cgroup: Path | None = None + memory_before: dict[str, int] = {} + pids_before: dict[str, int] = {} tracked: dict[int, str | None] = {} tracking_done = threading.Event() + tracker: threading.Thread | None = None - def track_process_tree() -> None: - while not tracking_done.wait(0.005): - for pid in _process_descendants(process.pid): - tracked.setdefault(pid, _process_identity(pid)) + def limit_reached() -> bool: + return ( + _writable_size(writable_roots) > limits.max_writable_bytes + or stdout_file.tell() + stderr_file.tell() > limits.max_output_bytes + ) + + def record_events() -> None: + nonlocal failed_closed + if cgroup is None: + return + memory_after = None + pids_after = None + try: + memory_after = _cgroup_events(cgroup, "memory.events") + pids_after = _cgroup_events(cgroup, "pids.events") + except RuntimeError: + pass + oom_delta = 0 + pids_delta = 0 + if memory_after is not None: + oom_delta = max( + memory_after.get("oom", 0) - memory_before.get("oom", 0), + memory_after.get("oom_kill", 0) - memory_before.get("oom_kill", 0), + ) + if pids_after is not None: + pids_delta = pids_after.get("max", 0) - pids_before.get("max", 0) + if oom_delta <= 0: + try: + properties = _scope_properties(plan.unit_name, plan.tools) + if properties.get("Result") == "oom-kill" or properties.get("OOMKilled") == "yes": + oom_delta = 1 + except RuntimeError: + pass + if oom_delta > 0: + stderr_file.write(f"cgroup memory.events oom delta={oom_delta}\n".encode()) + if pids_delta > 0: + stderr_file.write(f"cgroup pids.events max delta={pids_delta}\n".encode()) - tracker = threading.Thread(target=track_process_tree, daemon=True) - tracker.start() - deadline = time.monotonic() + timeout - output_limited = False try: - while process.poll() is None: - if time.monotonic() >= deadline: - timed_out = True - break - if _writable_size(writable_roots) > limits.max_writable_bytes: - output_limited = True - break - if stdout_file.tell() + stderr_file.tell() > limits.max_output_bytes: - output_limited = True - break - time.sleep(0.01) + with tempfile.TemporaryDirectory(prefix="pdd-scope-") as control_value: + control = Path(control_value) + try: + argv, plan = _sandbox_command( + command, writable_roots, cwd=cwd, writable_files=writable_files, + limits=limits, readable_roots=readable_roots, + readable_bindings=readable_bindings, + writable_bindings=writable_bindings, + result_fifo=result_fifo, result_fd=result_fd, + unit_name=unit_name, control_directory=control, + ) + _prepare_staging(plan) + except (OSError, RuntimeError) as exc: + stdout_file.close() + stderr_file.close() + return subprocess.CompletedProcess(command, 125, "", str(exc)), False + sandbox_environment = env | { + "PATH": _TRUSTED_ROOT_PATH, + "PYTHONDONTWRITEBYTECODE": "1", + "PDD_SUPERVISION_TOKEN": token, + "TMPDIR": str(temp_directory or writable_roots[0].resolve()), + "TEMP": str(temp_directory or writable_roots[0].resolve()), + "TMP": str(temp_directory or writable_roots[0].resolve()), + } + library_path = _sandbox_library_path(env) + if library_path: + sandbox_environment["LD_LIBRARY_PATH"] = library_path + try: + process = subprocess.Popen( + argv, cwd=cwd, stdout=stdout_file, stderr=stderr_file, + env=sandbox_environment, start_new_session=True, + ) + except OSError as exc: + _cleanup_staging(plan) + stdout_file.close() + stderr_file.close() + return subprocess.CompletedProcess(command, 125, "", str(exc)), False + + def track_process_tree() -> None: + while not tracking_done.wait(0.005): + for pid in _process_descendants(process.pid): + tracked.setdefault(pid, _process_identity(pid)) + + tracker = threading.Thread(target=track_process_tree, daemon=True) + tracker.start() + deadline = time.monotonic() + timeout + try: + while not (control / "ready").exists(): + if process.poll() is not None: + raise RuntimeError("protected scope exited before verification") + if time.monotonic() >= deadline: + timed_out = True + break + if limit_reached(): + failed_closed = True + break + time.sleep(.01) + if not timed_out and not failed_closed: + cgroup, memory_before, pids_before = _probe_scope(plan, limits) + (control / "start").write_text("start", encoding="ascii") + while not (control / "result.json").exists(): + if process.poll() is not None: + break + if time.monotonic() >= deadline: + timed_out = True + break + if limit_reached(): + failed_closed = True + break + time.sleep(.01) + if (control / "result.json").exists(): + payload = json.loads( + (control / "result.json").read_text(encoding="ascii") + ) + candidate_returncode = int(payload["returncode"]) + record_events() + if candidate_returncode is None and not timed_out and not failed_closed: + failed_closed = True + stderr_file.write(b"protected scope produced no candidate result\n") + if process.poll() is None and not timed_out and not failed_closed: + (control / "finish").write_text("finish", encoding="ascii") + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + failed_closed = True + except (OSError, ValueError, KeyError, RuntimeError) as exc: + failed_closed = True + stderr_file.write((str(exc) + "\n").encode()) + finally: + if cgroup is None and process.poll() is None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + try: + _stop_scope(plan.unit_name, plan.tools) + except RuntimeError as exc: + failed_closed = True + stderr_file.write((str(exc) + "\n").encode()) + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + try: + _cleanup_staging(plan) + except RuntimeError as exc: + failed_closed = True + stderr_file.write((str(exc) + "\n").encode()) finally: tracking_done.set() - tracker.join(timeout=1) - if process.poll() is None: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait() - observed = _supervised_descendants(token) - {process.pid} + if tracker is not None: + tracker.join(timeout=1) + observed = _supervised_descendants(token) + if process is not None: + observed.discard(process.pid) for pid in observed: tracked.setdefault(pid, _process_identity(pid)) descendants = _live_processes(tracked) if descendants: surviving = True + failed_closed = True for pid in descendants: try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass - if profile is not None: - profile.unlink(missing_ok=True) - try: - cleanup = subprocess.run( - ["sudo", "-n", str(_SUPERVISOR_EXECUTABLE), "-c", _CGROUP_CLEANUP, - cgroup_name], capture_output=True, text=True, check=False, - ) - cleanup_error = cleanup.stderr if cleanup.returncode != 0 else "" - except OSError as exc: - cleanup_error = str(exc) - if cleanup_error: - output_limited = True - stderr_file.write( - ("protected sandbox cgroup teardown failed: " + cleanup_error).encode() - ) + stdout_file.seek(0) stderr_file.seek(0) remaining = limits.max_output_bytes @@ -674,12 +937,12 @@ def track_process_tree() -> None: remaining -= len(stdout_bytes) stderr_bytes = stderr_file.read(remaining) if stdout_file.read(1) or stderr_file.read(1): - output_limited = True + failed_closed = True stdout_file.close() stderr_file.close() - stdout = stdout_bytes.decode("utf-8", errors="replace") - stderr = stderr_bytes.decode("utf-8", errors="replace") return subprocess.CompletedProcess( - command, 125 if output_limited else (124 if timed_out else process.returncode), - stdout, stderr, + command, + 125 if failed_closed else (124 if timed_out else candidate_returncode), + stdout_bytes.decode("utf-8", errors="replace"), + stderr_bytes.decode("utf-8", errors="replace"), ), surviving diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index ad63b3468b..69105ce1ea 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -39,7 +39,7 @@ def _mock_linux_tools( path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") path.chmod(0o755) tools[name] = str(path) - monkeypatch.setattr(shutil, "which", tools.get) + monkeypatch.setattr(shutil, "which", lambda name, path=None: tools.get(name)) return tools @@ -129,9 +129,9 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( monkeypatch.setattr( "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () ) - argv, profile = _sandbox_command(["/bin/true"], (tmp_path,)) - assert profile is None - assert argv[:3] == ["sudo", "-n", "-E"] + argv, plan = _sandbox_command(["/bin/true"], (tmp_path,)) + assert plan.unit_name.startswith("pdd-validator-") + assert argv[:3] == [str(plan.tools.sudo), "-n", "-E"] bwrap = json.loads(argv[-4]) assert {"--unshare-pid", "--unshare-net", "--unshare-cgroup"} <= set(bwrap) assert "--unshare-user" not in bwrap @@ -299,12 +299,12 @@ def test_linux_sandbox_opens_and_unlinks_checker_fifo_before_candidate( channel.mkdir(mode=0o700) fifo = channel / "checker.fifo" os.mkfifo(fifo) - argv, profile = _sandbox_command( + argv, plan = _sandbox_command( ["/bin/true"], (tmp_path,), result_fifo=fifo, result_fd=198 ) - assert profile is None - assert argv[:3] == ["sudo", "-n", "-E"] + assert plan.unit_name.startswith("pdd-validator-") + assert argv[:3] == [str(plan.tools.sudo), "-n", "-E"] assert "-C" not in argv[:6] bwrap = json.loads(argv[-4]) assert "--preserve-fds" not in bwrap @@ -544,6 +544,75 @@ def test_scope_cleanup_error_fails_closed( ) +def test_scope_probe_requires_systemd_and_kernel_limits_before_release( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Accept a scope only when systemd and cgroup-v2 report every hard limit.""" + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + tools = supervisor._trusted_tools() + cgroup = tmp_path / "scope-cgroup" + cgroup.mkdir() + values = { + "memory.max": "2147483648", "memory.swap.max": "0", + "memory.oom.group": "1", "pids.max": "128", + "memory.events": "oom 3\noom_kill 2\n", + "pids.events": "max 4\n", + } + for name, value in values.items(): + (cgroup / name).write_text(value, encoding="ascii") + properties = { + "LoadState": "loaded", "ActiveState": "active", + "ControlGroup": "/system.slice/example.scope", + "MemoryMax": "2147483648", "MemorySwapMax": "0", + "TasksMax": "128", "OOMPolicy": "kill", + "KillMode": "control-group", "Result": "success", + } + monkeypatch.setattr(supervisor, "_scope_properties", lambda *_args: properties) + monkeypatch.setattr(supervisor, "_scope_cgroup", lambda _properties: cgroup) + plan = supervisor._ScopePlan( + supervisor._scope_unit_name(), tmp_path, "", (), (), (), (), tools, + ) + + actual_cgroup, memory, pids = supervisor._probe_scope( + plan, SupervisorLimits() + ) + + assert actual_cgroup == cgroup + assert memory["oom_kill"] == 2 + assert pids["max"] == 4 + properties["KillMode"] = "process" + with pytest.raises(RuntimeError, match="properties unverified"): + supervisor._probe_scope(plan, SupervisorLimits()) + + +def test_scope_cleanup_targets_only_validated_unique_unit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Teardown uses whole-group systemd operations for exactly one unit.""" + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + tools = supervisor._trusted_tools() + unit = supervisor._scope_unit_name() + commands = [] + + def run(_tools, arguments, **_kwargs): + commands.append(arguments) + if len(commands) == 5: + return subprocess.CompletedProcess(arguments, 0, "LoadState=not-found\n", "") + return subprocess.CompletedProcess(arguments, 0, "LoadState=loaded\n", "") + + monkeypatch.setattr(supervisor, "_root_run", run) + + supervisor._stop_scope(unit, tools) + + assert [command[0] for command in commands] == [ + "show", "kill", "stop", "reset-failed", "show", + ] + assert all(unit in command for command in commands) + assert commands[1][:3] == ["kill", "--kill-whom=all", "--signal=SIGKILL"] + + @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap"), reason="requires Linux kernel namespace containment", From b010c2860f1afa258f8c8bc8ba64df5cf3a95396 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 20:32:16 -0700 Subject: [PATCH 068/233] fix(sync): handle prelaunch scope failures --- pdd/sync_core/supervisor.py | 3 ++- tests/test_sync_core_supervisor.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 03e836755b..4d46572e27 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -915,8 +915,9 @@ def track_process_tree() -> None: tracking_done.set() if tracker is not None: tracker.join(timeout=1) - observed = _supervised_descendants(token) + observed = set() if process is not None: + observed = _supervised_descendants(token) observed.discard(process.pid) for pid in observed: tracked.setdefault(pid, _process_identity(pid)) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 69105ce1ea..f763e1c7b0 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -505,9 +505,9 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert helper.index("start") < helper.index("os.fork()") assert "result.json" in helper and "finish" in helper assert "@PDD-CGROUP@" in plan.bwrap_argv - cgroup_index = plan.bwrap_argv.index("/sys/fs/cgroup") - assert plan.bwrap_argv[cgroup_index - 2] == "--ro-bind" - assert plan.bwrap_argv[cgroup_index - 1] == "@PDD-CGROUP@" + cgroup_source = plan.bwrap_argv.index("@PDD-CGROUP@") + assert plan.bwrap_argv[cgroup_source - 1] == "--ro-bind" + assert plan.bwrap_argv[cgroup_source + 1] == "/sys/fs/cgroup" def test_scope_unit_name_is_unique_and_strictly_validated() -> None: From 0e2d9cb9a0fb5d8484fa6d60af62752404b8bc65 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 20:37:42 -0700 Subject: [PATCH 069/233] refactor(sync): trim transient scope plan --- pdd/sync_core/supervisor.py | 5 ++--- tests/test_sync_core_supervisor.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 4d46572e27..03b2da7ef8 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -61,7 +61,6 @@ class _ScopePlan: control_directory: Path helper_source: str bwrap_argv: tuple[str, ...] - path_tokens: tuple[str, ...] sources: tuple[Path, ...] staging_targets: tuple[Path, ...] tools: _TrustedTools @@ -307,8 +306,8 @@ def _staged_bwrap( "(128-result if result is not None else 125))", )) plan = _ScopePlan( - unit_name, control_directory, helper, tuple(argv), tuple(path_tokens), - tuple(sources), (*staging_targets, cgroup_target), tools, + unit_name, control_directory, helper, tuple(argv), tuple(sources), + (*staging_targets, cgroup_target), tools, ) command = [ str(tools.sudo), "-n", "-E", str(tools.systemd_run), "--scope", "--quiet", diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index f763e1c7b0..d306be9c66 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -571,7 +571,7 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( monkeypatch.setattr(supervisor, "_scope_properties", lambda *_args: properties) monkeypatch.setattr(supervisor, "_scope_cgroup", lambda _properties: cgroup) plan = supervisor._ScopePlan( - supervisor._scope_unit_name(), tmp_path, "", (), (), (), (), tools, + supervisor._scope_unit_name(), tmp_path, "", (), (), (), tools, ) actual_cgroup, memory, pids = supervisor._probe_scope( From 02d60b8df4edc61c55ae9a67fdb4f94057c36ce8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 21:04:41 -0700 Subject: [PATCH 070/233] test(sync): reproduce hosted Playwright and quota failures --- tests/test_sync_core_runner_playwright.py | 40 +++++++++++++++++- tests/test_sync_core_supervisor.py | 50 +++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 8d764f57d1..c691be2dfa 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -597,12 +597,16 @@ def test_playwright_execution_uses_process_group_supervisor( root, commit = _repository(tmp_path) calls: list[list[str]] = [] scratch_bindings = [] + dependency_bindings = [] temp_directories = [] + phase_roots = [] def supervised(command, **_kwargs): calls.append(command) scratch_bindings.append(_kwargs["writable_bindings"]) + dependency_bindings.append(_kwargs["readable_bindings"]) temp_directories.append(_kwargs["temp_directory"]) + phase_roots.append(_kwargs["cwd"]) _write_private_result(_kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) @@ -616,6 +620,9 @@ def supervised(command, **_kwargs): assert scratch_bindings[0][0][0].name == "tmp" assert scratch_bindings[0][0][0].parent.name == "scratch" assert temp_directories[0] == Path("/tmp") + dependency_source, dependency_destination = dependency_bindings[0][-1] + assert dependency_source.name == "node_modules" + assert dependency_destination == phase_roots[0] / "node_modules" def test_playwright_checker_temp_roots_cannot_alias_sandbox_tmp( @@ -653,7 +660,7 @@ def supervised(command, *, cwd, writable_roots, writable_bindings, **kwargs): assert len(readable_roots) == len(phase_roots) assert len(readable_bindings) == len(phase_roots) assert all(len(roots) == 6 for roots in readable_roots) - assert all(len(bindings) == 1 for bindings in readable_bindings) + assert all(len(bindings) == 2 for bindings in readable_bindings) sandbox_tmp = Path("/tmp").resolve() for path in (*phase_roots, *scratch_roots, *fifo_roots): assert not path.resolve().is_relative_to(sandbox_tmp) @@ -2444,6 +2451,36 @@ def test_playwright_missing_private_result_has_bounded_diagnostics() -> None: assert len(detail) < 600 +def test_playwright_timeout_preserves_phase_reporter_and_cgroup_diagnostics( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + root, commit = _repository(tmp_path) + + def supervised(command, **kwargs): + collection = "--list" in command + _write_private_result(kwargs, { + "tests": [{ + "identity": IDENTITY, + "status": "collected" if collection else "timedOut", + "error": "browserType.launch exceeded 30000ms", + }], + }) + stderr = "cgroup pids.events max delta=7\n" + ("x" * 5000) + return subprocess.CompletedProcess(command, 1, "", stderr), False + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + + _envelope, executions = _run( + root, commit, commit, _fake_playwright(tmp_path) + ) + + assert executions[0].outcome is EvidenceOutcome.TIMEOUT + assert "phase=execution" in executions[0].detail + assert "browserType.launch exceeded 30000ms" in executions[0].detail + assert "cgroup pids.events max delta=7" in executions[0].detail + assert len(executions[0].detail) < 2500 + + def test_playwright_uses_two_gib_physical_and_64_gib_virtual_limits( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2466,6 +2503,7 @@ def supervised(command, **kwargs): assert all(kwargs["limits"] == PLAYWRIGHT_SUPERVISOR_LIMITS for kwargs in observed) assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_memory_bytes == 2 * 1024 * 1024 * 1024 assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_virtual_memory_bytes == 64 * 1024 * 1024 * 1024 + assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_processes == 256 assert all("private_overlays" not in kwargs for kwargs in observed) assert all("readable_data" not in kwargs for kwargs in observed) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index d306be9c66..d92aaa46c0 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -8,6 +8,7 @@ import sys import time from pathlib import Path +from types import SimpleNamespace import pytest @@ -806,6 +807,55 @@ def test_memory_disk_and_process_limits_fail_closed(tmp_path: Path, program: str assert result.returncode != 0 +def test_writable_quota_is_rechecked_after_fast_sparse_result( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A result racing the monitor cannot hide a sparse logical allocation.""" + helper = """import json,pathlib,subprocess,sys,time +control=pathlib.Path(sys.argv[1]) +(control/'ready').write_text('ready',encoding='ascii') +while not (control/'start').exists(): time.sleep(.001) +completed=subprocess.run(json.loads(sys.argv[2]),check=False) +(control/'result.json').write_text(json.dumps({'returncode':completed.returncode}),encoding='ascii') +while not (control/'finish').exists(): time.sleep(.001) +""" + cgroup = tmp_path / "cgroup" + cgroup.mkdir() + (cgroup / "memory.events").write_text("oom 0\noom_kill 0\n", encoding="ascii") + (cgroup / "pids.events").write_text("max 0\n", encoding="ascii") + + def sandbox(command, **kwargs): + plan = SimpleNamespace( + unit_name="pdd-validator-00000000000000000000000000000000.scope", + tools=SimpleNamespace(), + ) + return [ + sys.executable, "-c", helper, str(kwargs["control_directory"]), + json.dumps(command), + ], plan + + monkeypatch.setattr(supervisor, "_sandbox_command", sandbox) + monkeypatch.setattr(supervisor, "_prepare_staging", lambda _plan: None) + monkeypatch.setattr( + supervisor, "_probe_scope", + lambda _plan, _limits: (cgroup, {"oom": 0, "oom_kill": 0}, {"max": 0}), + ) + monkeypatch.setattr(supervisor, "_stop_scope", lambda *_args: None) + monkeypatch.setattr(supervisor, "_cleanup_staging", lambda _plan: None) + limits = SupervisorLimits(max_writable_bytes=1024 * 1024) + + result, surviving = run_supervised( + [sys.executable, "-c", "open('large','wb').truncate(8*1024*1024)"], + cwd=tmp_path, timeout=5, env=dict(os.environ), + writable_roots=(tmp_path,), limits=limits, + ) + + assert result.returncode == 125 + assert "writable quota" in result.stderr + assert (tmp_path / "large").stat().st_size == 8 * 1024 * 1024 + assert surviving is False + + def test_macos_fails_closed_without_kernel_lifetime_containment( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 1783331a187626d823396741e353c006301b255a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 21:10:25 -0700 Subject: [PATCH 071/233] fix(sync): close Playwright and writable result races --- pdd/sync_core/runner.py | 46 +++++++++++++-- pdd/sync_core/supervisor.py | 71 ++++++++++++++++++----- tests/test_sync_core_runner_playwright.py | 4 +- tests/test_sync_core_supervisor.py | 5 +- 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 103ce14642..3d8347e62a 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -191,6 +191,7 @@ class VitestPhaseToolchain: PLAYWRIGHT_SUPERVISOR_LIMITS = SupervisorLimits( max_memory_bytes=2 * 1024 * 1024 * 1024, max_virtual_memory_bytes=64 * 1024 * 1024 * 1024, + max_processes=256, ) @@ -4314,6 +4315,35 @@ def _playwright_reported_failure_detail(tests: list[dict[str, object]]) -> str: return f"Playwright reported failed protected tests: {diagnostic}" +def _playwright_timeout_detail(tests: list[dict[str, object]]) -> str: + """Return one bounded reporter error for a Playwright test timeout.""" + errors = [ + " ".join(error.split()) + for item in tests + if item.get("status") == "timedOut" + and isinstance((error := item.get("error")), str) + and error.strip() + ] + if not errors: + return "Playwright reported a timed out protected test" + diagnostic = errors[0] + if len(diagnostic) > 1024: + diagnostic = diagnostic[:509] + "......" + diagnostic[-500:] + return f"Playwright reported a timed out protected test: {diagnostic}" + + +def _playwright_phase_detail( + detail: str, result: subprocess.CompletedProcess[str], collection: bool, +) -> str: + """Attach bounded phase and supervisor diagnostics to Playwright evidence.""" + phase = "collection" if collection else "execution" + diagnostic = " ".join(result.stderr.split()) + if len(diagnostic) > 1024: + diagnostic = diagnostic[:509] + "......" + diagnostic[-500:] + value = f"phase={phase}; {detail}" + return f"{value}; supervisor={diagnostic}" if diagnostic else value + + def _playwright_result( root: Path, output: str, returncode: int, expected: tuple[str, ...] | None, collection: bool = False, @@ -4402,7 +4432,7 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: if statuses - {"collected", "passed", "failed", "skipped", "timedOut", "interrupted"}: return EvidenceOutcome.COLLECTION_ERROR, "Playwright reporter emitted an unsupported status", identities if "timedOut" in statuses: - return EvidenceOutcome.TIMEOUT, "Playwright reported a timed out protected test", identities + return EvidenceOutcome.TIMEOUT, _playwright_timeout_detail(tests), identities if returncode or "failed" in statuses or "interrupted" in statuses: return ( EvidenceOutcome.FAIL, @@ -4683,7 +4713,9 @@ def _run_playwright_in_tree( writable_bindings=((sandbox_tmp, Path("/tmp")),), temp_directory=Path("/tmp"), readable_roots=(reporter, *roles.readable_roots), - readable_bindings=native_bindings, + readable_bindings=( + *native_bindings, (roles.dependencies, root / "node_modules"), + ), limits=PLAYWRIGHT_SUPERVISOR_LIMITS, result_fifo=result_fifo, result_fd=result_fd, @@ -4711,7 +4743,10 @@ def _run_playwright_in_tree( if result.returncode == 124: return RunnerExecution( "playwright", EvidenceOutcome.TIMEOUT, digest, - "Playwright execution timed out and descendants were reaped", + _playwright_phase_detail( + "Playwright supervisor timed out and descendants were reaped", + result, collection, + ), ), () if surviving: return RunnerExecution( @@ -4763,7 +4798,10 @@ def _run_playwright_in_tree( outcome, detail, identities = _playwright_result( root, output.decode("utf-8", errors="replace"), result.returncode, expected, collection ) - return RunnerExecution("playwright", outcome, digest, detail), identities + return RunnerExecution( + "playwright", outcome, digest, + _playwright_phase_detail(detail, result, collection), + ), identities @_normalize_playwright_temp_errors(EvidenceOutcome.COLLECTION_ERROR) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 03b2da7ef8..66b2747689 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -759,12 +759,31 @@ def run_supervised( tracked: dict[int, str | None] = {} tracking_done = threading.Event() tracker: threading.Thread | None = None + phase = "construction" + + def limit_error() -> str | None: + writable_size = _writable_size(writable_roots) + if writable_size > limits.max_writable_bytes: + return ( + "protected supervisor writable quota exceeded: " + f"{writable_size}>{limits.max_writable_bytes}" + ) + output_size = stdout_file.tell() + stderr_file.tell() + if output_size > limits.max_output_bytes: + return ( + "protected supervisor output quota exceeded: " + f"{output_size}>{limits.max_output_bytes}" + ) + return None - def limit_reached() -> bool: - return ( - _writable_size(writable_roots) > limits.max_writable_bytes - or stdout_file.tell() + stderr_file.tell() > limits.max_output_bytes - ) + def fail_for_limit() -> bool: + nonlocal failed_closed + error = limit_error() + if error is None: + return False + failed_closed = True + stderr_file.write((f"protected supervisor phase={phase}: {error}\n").encode()) + return True def record_events() -> None: nonlocal failed_closed @@ -814,7 +833,9 @@ def record_events() -> None: except (OSError, RuntimeError) as exc: stdout_file.close() stderr_file.close() - return subprocess.CompletedProcess(command, 125, "", str(exc)), False + return subprocess.CompletedProcess( + command, 125, "", f"protected supervisor phase=construction: {exc}" + ), False sandbox_environment = env | { "PATH": _TRUSTED_ROOT_PATH, "PYTHONDONTWRITEBYTECODE": "1", @@ -835,7 +856,9 @@ def record_events() -> None: _cleanup_staging(plan) stdout_file.close() stderr_file.close() - return subprocess.CompletedProcess(command, 125, "", str(exc)), False + return subprocess.CompletedProcess( + command, 125, "", f"protected supervisor phase=launch: {exc}" + ), False def track_process_tree() -> None: while not tracking_done.wait(0.005): @@ -845,6 +868,7 @@ def track_process_tree() -> None: tracker = threading.Thread(target=track_process_tree, daemon=True) tracker.start() deadline = time.monotonic() + timeout + phase = "scope-setup" try: while not (control / "ready").exists(): if process.poll() is not None: @@ -852,42 +876,55 @@ def track_process_tree() -> None: if time.monotonic() >= deadline: timed_out = True break - if limit_reached(): - failed_closed = True + if fail_for_limit(): break time.sleep(.01) if not timed_out and not failed_closed: cgroup, memory_before, pids_before = _probe_scope(plan, limits) (control / "start").write_text("start", encoding="ascii") + phase = "candidate-execution" while not (control / "result.json").exists(): if process.poll() is not None: break if time.monotonic() >= deadline: timed_out = True break - if limit_reached(): - failed_closed = True + if fail_for_limit(): break time.sleep(.01) if (control / "result.json").exists(): + phase = "result-handoff" payload = json.loads( (control / "result.json").read_text(encoding="ascii") ) candidate_returncode = int(payload["returncode"]) + fail_for_limit() record_events() if candidate_returncode is None and not timed_out and not failed_closed: failed_closed = True - stderr_file.write(b"protected scope produced no candidate result\n") + stderr_file.write( + b"protected supervisor phase=candidate-execution: " + b"scope produced no candidate result\n" + ) if process.poll() is None and not timed_out and not failed_closed: (control / "finish").write_text("finish", encoding="ascii") try: process.wait(timeout=5) except subprocess.TimeoutExpired: failed_closed = True + stderr_file.write( + b"protected supervisor result-handoff did not finish\n" + ) except (OSError, ValueError, KeyError, RuntimeError) as exc: failed_closed = True - stderr_file.write((str(exc) + "\n").encode()) + stderr_file.write( + (f"protected supervisor phase={phase}: {exc}\n").encode() + ) finally: + if timed_out: + stderr_file.write( + f"protected supervisor timeout phase={phase}\n".encode() + ) if cgroup is None and process.poll() is None: try: os.killpg(process.pid, signal.SIGKILL) @@ -898,7 +935,9 @@ def track_process_tree() -> None: _stop_scope(plan.unit_name, plan.tools) except RuntimeError as exc: failed_closed = True - stderr_file.write((str(exc) + "\n").encode()) + stderr_file.write( + (f"protected supervisor phase=scope-cleanup: {exc}\n").encode() + ) if process.poll() is None: try: os.killpg(process.pid, signal.SIGKILL) @@ -909,7 +948,9 @@ def track_process_tree() -> None: _cleanup_staging(plan) except RuntimeError as exc: failed_closed = True - stderr_file.write((str(exc) + "\n").encode()) + stderr_file.write( + (f"protected supervisor phase=mount-cleanup: {exc}\n").encode() + ) finally: tracking_done.set() if tracker is not None: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index c691be2dfa..c68b16d0cb 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -2466,7 +2466,9 @@ def supervised(command, **kwargs): }], }) stderr = "cgroup pids.events max delta=7\n" + ("x" * 5000) - return subprocess.CompletedProcess(command, 1, "", stderr), False + return subprocess.CompletedProcess( + command, 0 if collection else 1, "", stderr + ), False monkeypatch.setattr(runner_module, "run_supervised", supervised) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index d92aaa46c0..4d6bec29e0 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -824,7 +824,7 @@ def test_writable_quota_is_rechecked_after_fast_sparse_result( (cgroup / "memory.events").write_text("oom 0\noom_kill 0\n", encoding="ascii") (cgroup / "pids.events").write_text("max 0\n", encoding="ascii") - def sandbox(command, **kwargs): + def sandbox(command, _writable_roots, **kwargs): plan = SimpleNamespace( unit_name="pdd-validator-00000000000000000000000000000000.scope", tools=SimpleNamespace(), @@ -842,6 +842,9 @@ def sandbox(command, **kwargs): ) monkeypatch.setattr(supervisor, "_stop_scope", lambda *_args: None) monkeypatch.setattr(supervisor, "_cleanup_staging", lambda _plan: None) + monkeypatch.setattr( + supervisor, "_scope_properties", lambda *_args: {"Result": "success"} + ) limits = SupervisorLimits(max_writable_bytes=1024 * 1024) result, surviving = run_supervised( From a72d4e6f82c60a7c4f2bb16bb1ce8c7a48cdf190 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 21:15:39 -0700 Subject: [PATCH 072/233] test(sync): account for trusted ESM dependency binding --- tests/test_sync_core_runner_playwright.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index c68b16d0cb..758a1ef879 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -779,7 +779,13 @@ def test_playwright_allows_tmp_native_source_bound_outside_tmp( def run_supervised(command, **kwargs): nonlocal supervised supervised = True - assert kwargs["readable_bindings"] == ((source.resolve(), destination),) + assert kwargs["readable_bindings"] == ( + (source.resolve(), destination), + ( + Path(payload["roles"]["dependencies"]).resolve(), + root / "node_modules", + ), + ) _write_private_result(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) From a0c8425762dc7d1ab34fbb415503a45e77c3923e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 21:35:41 -0700 Subject: [PATCH 073/233] test(sync): require kernel writable and package isolation --- .github/workflows/unit-tests.yml | 33 ++++++++ tests/test_sync_core_runner_playwright.py | 98 ++++++++++++++++++++++- tests/test_sync_core_supervisor.py | 33 ++++++-- 3 files changed, 155 insertions(+), 9 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d2e891919f..d9568dd50d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -310,6 +310,39 @@ jobs: assert output.returncode == 125, output.stderr assert not surviving assert_no_leak() + quota = SupervisorLimits(max_writable_bytes=1024 * 1024) + sparse, surviving = run_supervised( + [sys.executable, '-c', "open('sparse','wb').truncate(8*1024*1024)"], + cwd=root / 'scratch', timeout=30, env=dict(os.environ), + writable_roots=(root / 'scratch',), limits=quota, + ) + assert sparse.returncode != 0, sparse.stderr + assert not surviving + assert_no_leak() + aggregate, surviving = run_supervised( + [sys.executable, '-c', "[open(f'part-{i}','wb').write(b'x'*131072) for i in range(32)]"], + cwd=root / 'scratch', timeout=30, env=dict(os.environ), + writable_roots=(root / 'scratch',), limits=quota, + ) + assert aggregate.returncode != 0, aggregate.stderr + assert not surviving + assert_no_leak() + churn, surviving = run_supervised( + [sys.executable, '-c', "import os; [open('churn','wb').write(b'x'*2097152) or os.unlink('churn') for _ in range(32)]"], + cwd=root / 'scratch', timeout=30, env=dict(os.environ), + writable_roots=(root / 'scratch',), limits=quota, + ) + assert churn.returncode != 0, churn.stderr + assert not surviving + assert_no_leak() + within, surviving = run_supervised( + [sys.executable, '-c', "open('within','wb').write(b'x'*65536)"], + cwd=root / 'scratch', timeout=30, env=dict(os.environ), + writable_roots=(root / 'scratch',), limits=quota, + ) + assert within.returncode == 0, within.stderr + assert not surviving + assert_no_leak() PY - name: Verify protected pytest smoke diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 758a1ef879..b79c0acc2d 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -625,6 +625,102 @@ def supervised(command, **_kwargs): assert dependency_destination == phase_roots[0] / "node_modules" +@pytest.mark.parametrize("reserved", ["@playwright/test", "playwright", "playwright-core"]) +def test_playwright_rejects_candidate_package_self_reference_before_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, reserved: str, +) -> None: + root, commit = _repository(tmp_path) + (root / "package.json").write_text( + json.dumps({ + "name": reserved, + "type": "module", + "exports": {".": "./tests/widget.spec.ts"}, + }), + encoding="utf-8", + ) + _git(root, "add", "package.json") + _git(root, "commit", "-q", "-m", "candidate package self reference") + launches = 0 + + def supervised(*_args, **_kwargs): + nonlocal launches + launches += 1 + raise AssertionError("reserved package scope must fail before execution") + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + + _envelope, executions = _run( + root, commit, commit, _fake_playwright(tmp_path) + ) + + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "reserved" in executions[0].detail.lower() + assert launches == 0 + + +def test_playwright_rejects_candidate_node_modules_symlink_before_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + root, commit = _repository(tmp_path) + outside = tmp_path / "candidate-modules" + outside.mkdir() + (root / "node_modules").symlink_to(outside, target_is_directory=True) + launches = 0 + + def supervised(*_args, **_kwargs): + nonlocal launches + launches += 1 + raise AssertionError("node_modules symlink must fail before execution") + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + + execution, _identities = runner_module._run_playwright_in_tree( + root, (PurePosixPath("tests/widget.spec.ts"),), 2, + _trusted_playwright_config(tmp_path / "trusted", _fake_playwright(tmp_path)), + expected_commit=commit, + ) + + assert execution.outcome is EvidenceOutcome.ERROR + assert "node_modules" in execution.detail + assert "symlink" in execution.detail + assert launches == 0 + + +@pytest.mark.skipif(not shutil.which("node"), reason="requires Node module resolution") +@pytest.mark.parametrize("module_type", ["commonjs", "module"]) +def test_trusted_playwright_package_resolves_for_real_node_modes( + tmp_path: Path, module_type: str, +) -> None: + """Exercise real CJS and ESM resolution against the identity-bound package.""" + project = tmp_path / module_type + package = project / "node_modules/@playwright/test" + package.mkdir(parents=True) + (package / "package.json").write_text( + json.dumps({"name": "@playwright/test", "main": "index.js"}), + encoding="utf-8", + ) + (package / "index.js").write_text( + "module.exports = { identity: 'trusted-playwright-package' };\n", + encoding="utf-8", + ) + (project / "package.json").write_text( + json.dumps({"name": "candidate", "type": module_type}), encoding="utf-8" + ) + script = ( + "console.log(require('@playwright/test').identity);" + if module_type == "commonjs" + else "import('@playwright/test').then(x => console.log(x.default.identity));" + ) + + completed = subprocess.run( + [shutil.which("node"), "-e", script], cwd=project, + capture_output=True, text=True, check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert completed.stdout.strip() == "trusted-playwright-package" + + def test_playwright_checker_temp_roots_cannot_alias_sandbox_tmp( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2511,7 +2607,7 @@ def supervised(command, **kwargs): assert all(kwargs["limits"] == PLAYWRIGHT_SUPERVISOR_LIMITS for kwargs in observed) assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_memory_bytes == 2 * 1024 * 1024 * 1024 assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_virtual_memory_bytes == 64 * 1024 * 1024 * 1024 - assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_processes == 256 + assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_processes == 128 assert all("private_overlays" not in kwargs for kwargs in observed) assert all("readable_data" not in kwargs for kwargs in observed) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 4d6bec29e0..d8642424d0 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -308,15 +308,17 @@ def test_linux_sandbox_opens_and_unlinks_checker_fifo_before_candidate( assert argv[:3] == [str(plan.tools.sudo), "-n", "-E"] assert "-C" not in argv[:6] bwrap = json.loads(argv[-4]) - assert "--preserve-fds" not in bwrap + assert bwrap[bwrap.index("--preserve-fds") + 1] == "1" assert json.loads(argv[-3]) - assert str(channel) in bwrap + assert str(channel) not in bwrap separator = bwrap.index("--") candidate_argv = bwrap[separator + 1:] - assert str(fifo) in candidate_argv + assert str(fifo) not in candidate_argv wrapper = candidate_argv[candidate_argv.index("-c") + 1] - assert "os.open(path,os.O_WRONLY);os.unlink(path)" in wrapper - assert candidate_argv.index(str(fifo)) < candidate_argv.index("/bin/true") + assert "os.dup2(source,target)" in wrapper + assert "os.open" not in wrapper + assert "os.open(result_fifo" in plan.helper_source + assert "os.unlink(result_fifo)" in plan.helper_source def test_sandbox_directory_bind_provides_parent_for_nested_file( @@ -505,6 +507,10 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert "ready" in helper and "start" in helper assert helper.index("start") < helper.index("os.fork()") assert "result.json" in helper and "finish" in helper + assert "-t','tmpfs" in helper + assert "statvfs" in helper + assert "writable quota" in helper + assert "copytree" in helper assert "@PDD-CGROUP@" in plan.bwrap_argv cgroup_source = plan.bwrap_argv.index("@PDD-CGROUP@") assert plan.bwrap_argv[cgroup_source - 1] == "--ro-bind" @@ -859,6 +865,17 @@ def sandbox(command, _writable_roots, **kwargs): assert surviving is False +def test_writable_accounting_errors_fail_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + inaccessible = tmp_path / "inaccessible" + inaccessible.mkdir() + monkeypatch.setattr(Path, "iterdir", lambda _path: (_ for _ in ()).throw(OSError("denied"))) + + with pytest.raises(RuntimeError, match="writable accounting failed"): + supervisor._writable_size((inaccessible,)) + + def test_macos_fails_closed_without_kernel_lifetime_containment( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -872,11 +889,11 @@ def test_macos_fails_closed_without_kernel_lifetime_containment( assert surviving is False -def test_file_size_limit_uses_output_budget() -> None: +def test_file_size_limit_uses_writable_budget() -> None: limits = SupervisorLimits(max_output_bytes=1234, max_writable_bytes=987654) command = _limited_command(["/bin/true"], limits) - assert "1234" in command - assert "987654" not in command[1:7] + assert "987654" in command + assert "1234" not in command[1:7] @pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") From 86814827ffff2f97ea5d94c57a8886a8922f0978 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 21:53:22 -0700 Subject: [PATCH 074/233] fix(sync): enforce writable and package boundaries --- .github/workflows/unit-tests.yml | 26 ++- pdd/sync_core/runner.py | 33 ++- pdd/sync_core/supervisor.py | 261 +++++++++++++++------- tests/test_sync_core_runner_playwright.py | 1 + tests/test_sync_core_supervisor.py | 80 ++----- 5 files changed, 256 insertions(+), 145 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d9568dd50d..8dc2cd223a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -311,34 +311,42 @@ jobs: assert not surviving assert_no_leak() quota = SupervisorLimits(max_writable_bytes=1024 * 1024) + def quota_case(name): + path = root / 'scratch' / name + path.mkdir() + return path + sparse_root = quota_case('quota-sparse') sparse, surviving = run_supervised( [sys.executable, '-c', "open('sparse','wb').truncate(8*1024*1024)"], - cwd=root / 'scratch', timeout=30, env=dict(os.environ), - writable_roots=(root / 'scratch',), limits=quota, + cwd=sparse_root, timeout=30, env=dict(os.environ), + writable_roots=(sparse_root,), limits=quota, ) assert sparse.returncode != 0, sparse.stderr assert not surviving assert_no_leak() + aggregate_root = quota_case('quota-aggregate') aggregate, surviving = run_supervised( [sys.executable, '-c', "[open(f'part-{i}','wb').write(b'x'*131072) for i in range(32)]"], - cwd=root / 'scratch', timeout=30, env=dict(os.environ), - writable_roots=(root / 'scratch',), limits=quota, + cwd=aggregate_root, timeout=30, env=dict(os.environ), + writable_roots=(aggregate_root,), limits=quota, ) assert aggregate.returncode != 0, aggregate.stderr assert not surviving assert_no_leak() + churn_root = quota_case('quota-churn') churn, surviving = run_supervised( - [sys.executable, '-c', "import os; [open('churn','wb').write(b'x'*2097152) or os.unlink('churn') for _ in range(32)]"], - cwd=root / 'scratch', timeout=30, env=dict(os.environ), - writable_roots=(root / 'scratch',), limits=quota, + [sys.executable, '-c', "from pathlib import Path; files=[Path(f'churn-{i}') for i in range(16)]; [path.write_bytes(b'x'*131072) for path in files]; [path.unlink() for path in files]"], + cwd=churn_root, timeout=30, env=dict(os.environ), + writable_roots=(churn_root,), limits=quota, ) assert churn.returncode != 0, churn.stderr assert not surviving assert_no_leak() + within_root = quota_case('quota-within') within, surviving = run_supervised( [sys.executable, '-c', "open('within','wb').write(b'x'*65536)"], - cwd=root / 'scratch', timeout=30, env=dict(os.environ), - writable_roots=(root / 'scratch',), limits=quota, + cwd=within_root, timeout=30, env=dict(os.environ), + writable_roots=(within_root,), limits=quota, ) assert within.returncode == 0, within.stderr assert not surviving diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 3d8347e62a..fd0a8e434d 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -191,7 +191,6 @@ class VitestPhaseToolchain: PLAYWRIGHT_SUPERVISOR_LIMITS = SupervisorLimits( max_memory_bytes=2 * 1024 * 1024 * 1024, max_virtual_memory_bytes=64 * 1024 * 1024 * 1024, - max_processes=256, ) @@ -1535,6 +1534,16 @@ def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: raise ValueError("exactly one static Playwright configuration is required") content = read_git_blob(root, ref, found[0]) assert content is not None + scope = _nearest_package_scope(root, ref, found[0]) + if scope is not None: + try: + package = json.loads(scope[1].decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("Playwright config package scope is invalid") from exc + if not isinstance(package, dict): + raise ValueError("Playwright config package scope must be an object") + if package.get("name") in {"@playwright/test", "playwright", "playwright-core"}: + raise ValueError("Playwright config uses a reserved package self-reference") return found[0], content @@ -4232,6 +4241,22 @@ def _playwright_candidate_toolchain(root: Path) -> bool: ) +def _playwright_node_modules_destination_error(root: Path) -> str | None: + """Reject destination topology that could redirect the trusted package mount.""" + destination = root / "node_modules" + if not os.path.lexists(destination): + return None + try: + metadata = destination.lstat() + except OSError as exc: + return f"candidate node_modules destination is unreadable: {exc}" + if stat.S_ISLNK(metadata.st_mode): + return "candidate node_modules destination is a symlink" + if not stat.S_ISDIR(metadata.st_mode): + return "candidate node_modules destination is not a real directory" + return None + + def _playwright_environment( home: Path, dependencies: Path | None, browser_runtime: Path | None = None ) -> dict[str, str]: @@ -4603,6 +4628,12 @@ def _run_playwright_in_tree( ) -> tuple[RunnerExecution, tuple[str, ...]]: """Execute exact paths through Playwright's private reporter channel.""" tool_root = command_root or root + destination_error = _playwright_node_modules_destination_error(root) + if destination_error is not None: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, + "playwright-untrusted", destination_error, + ), () if _playwright_candidate_toolchain(tool_root): return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-untrusted", "candidate node_modules Playwright toolchain is not trusted"), () prefix = _playwright_command(config) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 66b2747689..3c7529089e 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -8,6 +8,7 @@ import re import shutil import signal +import stat import subprocess import sys import tempfile @@ -238,11 +239,13 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: "os.execvpe(sys.argv[5],sys.argv[5:],os.environ)" ) return [str(_SUPERVISOR_EXECUTABLE), "-c", script, str(limits.max_virtual_memory_bytes), - str(limits.max_cpu_seconds), str(limits.max_output_bytes), "256", *command] + str(limits.max_cpu_seconds), str(limits.max_writable_bytes), "256", *command] def _staged_bwrap( argv: list[str], sources: list[Path], path_tokens: list[str], *, + writable_roots: tuple[Path, ...], writable_specs: list[tuple[str, int, str]], + result_fifo: Path | None, unit_name: str, control_directory: Path, limits: SupervisorLimits, tools: _TrustedTools, ) -> tuple[list[str], _ScopePlan]: @@ -250,14 +253,18 @@ def _staged_bwrap( unit_name = _validated_scope_unit(unit_name) staging = control_directory / "binds" staging_targets = tuple(staging / str(index) for index in range(len(sources))) + writable_target = staging / "writable" cgroup_target = staging / "cgroup" helper = "\n".join(( - "import json,os,pathlib,subprocess,sys,time", + "import json,os,pathlib,shutil,stat,subprocess,sys,time", "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", + "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[4])]", + "writable_specs=json.loads(sys.argv[5]); result_fifo=sys.argv[6] or None", + "limits=json.loads(sys.argv[-1])", "path_tokens=json.loads(sys.argv[-5]); argv=json.loads(sys.argv[-4]); " "paths=json.loads(sys.argv[-3])", "targets=[control/'binds'/str(index) for index in range(len(paths))]", - "staged=[]; result=None; cleanup_error=None", + "staged=[]; result=None; cleanup_error=None; result_write=None", "def wait_for(name):", " path=control/name", " while not path.exists(): time.sleep(.01)", @@ -269,11 +276,47 @@ def _staged_bwrap( " if source == pathlib.Path('/sys/fs/cgroup') or not source.is_dir(): " "raise RuntimeError('protected scope cgroup is invalid')", " return source", + "def validate_tree(root):", + " total=0", + " for path in (root,*root.rglob('*')):", + " metadata=path.lstat()", + " if stat.S_ISLNK(metadata.st_mode): raise RuntimeError('writable tree contains symlink')", + " if stat.S_ISREG(metadata.st_mode): total+=metadata.st_size", + " elif not stat.S_ISDIR(metadata.st_mode): raise RuntimeError('writable tree contains special file')", + " return total", + "def copy_owned(source,target):", + " if source.is_dir(): shutil.copytree(source,target)", + " else: shutil.copy2(source,target)", + " pairs=[(source,target)]", + " if source.is_dir(): pairs.extend((item,target/item.relative_to(source)) for item in source.rglob('*'))", + " for original,copied in pairs:", + " metadata=original.stat(follow_symlinks=False)", + " os.chown(copied,metadata.st_uid,metadata.st_gid,follow_symlinks=False)", + "def replace_host(source,staged_root):", + " if source.is_dir():", + " for item in source.iterdir():", + " shutil.rmtree(item) if item.is_dir() and not item.is_symlink() else item.unlink()", + " for item in staged_root.iterdir(): copy_owned(item,source/item.name)", + " else: copy_owned(staged_root,source)", "try:", + " writable_target=control/'binds'/'writable'", + " subprocess.run([mount,'-t','tmpfs','-o',f\"size={limits['writable']},mode=0700,nosuid,nodev\",'tmpfs',str(writable_target)],check=True)", + " staged.append(writable_target)", + " mount_lines=pathlib.Path('/proc/self/mountinfo').read_text(encoding='utf-8').splitlines()", + " mount_fields=[line.split() for line in mount_lines]", + " mounted=next((fields for fields in mount_fields if len(fields)>6 and pathlib.Path(fields[4])==writable_target),None)", + " if mounted is None or '-' not in mounted or mounted[mounted.index('-')+1]!='tmpfs': raise RuntimeError('writable tmpfs mount probe failed')", + " capacity=os.statvfs(writable_target).f_blocks*os.statvfs(writable_target).f_frsize", + " if capacity > limits['writable']+os.sysconf('SC_PAGE_SIZE'): raise RuntimeError('writable tmpfs size probe failed')", + " writable_paths=[]", + " for index,source in enumerate(writable_roots):", + " target=writable_target/str(index); copy_owned(source,target); writable_paths.append(target)", + " if sum(validate_tree(path) for path in writable_paths) > limits['writable']: raise RuntimeError('initial writable quota exceeded')", " for source,target in zip(paths,targets):", " subprocess.run([mount,'--bind',source,str(target)],check=True)", " staged.append(target)", - " path_map={token:str(staged[index]) for index,token in enumerate(path_tokens)}", + " path_map={token:str(targets[index]) for index,token in enumerate(path_tokens)}", + " for token,index,relative in writable_specs: path_map[token]=str(writable_paths[index]/relative)", " argv=[path_map.get(value,value) for value in argv]", " cgroup_target=control/'binds'/'cgroup'", " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)],check=True)", @@ -283,18 +326,26 @@ def _staged_bwrap( " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)],check=True)", " staged.append(cgroup_target)", " argv=[str(cgroup_target) if value == '@PDD-CGROUP@' else value for value in argv]", + " if result_fifo:", + " result_write=os.open(result_fifo,os.O_WRONLY|os.O_CLOEXEC)", + " os.unlink(result_fifo)", + " os.dup2(result_write,3,inheritable=True)", " (control/'ready').write_text('ready',encoding='ascii')", " wait_for('start')", " pid=os.fork()", " if pid == 0:", + " if result_fifo: os.set_inheritable(3,True)", " os.execvpe(argv[0],argv,os.environ)", " _wait,status=os.waitpid(pid,0)", " result=os.waitstatus_to_exitcode(status)", + " if sum(validate_tree(path) for path in writable_paths) > limits['writable']: raise RuntimeError('final writable quota exceeded')", + " for source,staged_root in zip(writable_roots,writable_paths): replace_host(source,staged_root)", " temporary=control/'result.tmp'", " temporary.write_text(json.dumps({'returncode':result}),encoding='ascii')", " os.replace(temporary,control/'result.json')", " wait_for('finish')", "finally:", + " if result_write is not None: os.close(result_write)", " for target in reversed(staged):", " completed=subprocess.run([umount,str(target)],capture_output=True," "text=True,check=False)", @@ -307,7 +358,7 @@ def _staged_bwrap( )) plan = _ScopePlan( unit_name, control_directory, helper, tuple(argv), tuple(sources), - (*staging_targets, cgroup_target), tools, + (*staging_targets, writable_target, cgroup_target), tools, ) command = [ str(tools.sudo), "-n", "-E", str(tools.systemd_run), "--scope", "--quiet", @@ -315,27 +366,29 @@ def _staged_bwrap( "--property=MemorySwapMax=0", f"--property=TasksMax={limits.max_processes}", "--property=OOMPolicy=kill", "--property=KillMode=control-group", "--", str(_SUPERVISOR_EXECUTABLE), "-c", helper, str(control_directory), - str(tools.mount), str(tools.umount), json.dumps(path_tokens), json.dumps(argv), + str(tools.mount), str(tools.umount), + json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), + str(result_fifo or ""), json.dumps(path_tokens), json.dumps(argv), json.dumps([str(path) for path in sources]), unit_name, - json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes}), + json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, + "writable": limits.max_writable_bytes}), ] return command, plan def _private_result_command( - command: list[str], result_fifo: Path, result_fd: int, + command: list[str], result_fd: int, source_fd: int = 3, ) -> list[str]: - """Open and unlink a checker FIFO before candidate code can execute.""" + """Move the helper-opened private result descriptor to its fixed number.""" script = ( "import os,sys;" - "path=sys.argv[1];target=int(sys.argv[2]);" - "source=os.open(path,os.O_WRONLY);os.unlink(path);" + "source=int(sys.argv[1]);target=int(sys.argv[2]);" "os.dup2(source,target);" "os.close(source) if source!=target else None;" "os.execvpe(sys.argv[3],sys.argv[3:],os.environ)" ) return [str(_SUPERVISOR_EXECUTABLE), "-c", script, - str(result_fifo), str(result_fd), *command] + str(source_fd), str(result_fd), *command] def _supervised_descendants(token: str) -> set[int]: """Find descendants carrying the unforgeable per-run environment marker.""" @@ -417,22 +470,44 @@ def _live_processes(pids: dict[int, str | None]) -> set[int]: def _writable_size(roots: tuple[Path, ...]) -> int: - """Measure a concurrently mutable tree without allowing races to escape.""" + """Measure logical bytes for diagnostics, failing on incomplete traversal.""" total = 0 - for root in roots: - try: - paths = root.rglob("*") + try: + for root in roots: + paths = (root, *root.rglob("*")) if root.is_dir() else (root,) for path in paths: - try: - if path.is_file() and not path.is_symlink(): - total += path.stat().st_size - except OSError: - continue - except OSError: - continue + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode): + raise RuntimeError("writable accounting rejects symlinks") + if stat.S_ISREG(metadata.st_mode): + total += metadata.st_size + elif not stat.S_ISDIR(metadata.st_mode): + raise RuntimeError("writable accounting rejects special files") + except OSError as exc: + raise RuntimeError("writable accounting failed") from exc return total +def _writable_storage_roots(paths: tuple[Path, ...]) -> tuple[Path, ...]: + """Return disjoint real roots mirrored into one bounded writable filesystem.""" + resolved = [] + for path in paths: + try: + if path.is_symlink(): + raise RuntimeError("writable storage root cannot be a symlink") + value = path.resolve(strict=True) + except OSError as exc: + raise RuntimeError("writable storage root is unavailable") from exc + if not (value.is_dir() or value.is_file()): + raise RuntimeError("writable storage root must be a regular file or directory") + resolved.append(value) + roots = [] + for path in sorted(set(resolved), key=lambda item: (len(item.parts), str(item))): + if not any(path == root or path.is_relative_to(root) for root in roots): + roots.append(path) + return tuple(roots) + + def _root_environment() -> dict[str, str]: """Return the fixed environment used for every privileged tool invocation.""" return {"PATH": _TRUSTED_ROOT_PATH, "LANG": "C", "LC_ALL": "C"} @@ -572,11 +647,12 @@ def _prepare_staging(plan: _ScopePlan) -> None: """Create private bind targets before the privileged scope helper starts.""" binds = plan.control_directory / "binds" binds.mkdir(mode=0o700) - for source, target in zip(plan.sources, plan.staging_targets[:-1]): + for source, target in zip(plan.sources, plan.staging_targets[:-2]): if source.is_dir(): target.mkdir(mode=0o700) else: target.touch(mode=0o600) + plan.staging_targets[-2].mkdir(mode=0o700) plan.staging_targets[-1].mkdir(mode=0o700) @@ -642,17 +718,31 @@ def _sandbox_command( ).returncode != 0: raise RuntimeError("protected sandbox requires privileged bind staging") workdir = (cwd or Path.cwd()).resolve() + writable_sources = tuple( + source for source, _destination in writable_bindings + ) + (*writable_roots, *writable_files) + storage_roots = _writable_storage_roots(writable_sources) + if _writable_size(storage_roots) > limits.max_writable_bytes: + raise RuntimeError("initial writable quota exceeded") argv = [str(tools.bwrap), "--unshare-ipc", "--unshare-pid", "--unshare-net", "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp", "--dir", "/sys", "--dir", "/sys/fs", "--dir", "/sys/fs/cgroup"] sources: list[Path] = [] path_tokens: list[str] = [] + writable_specs: list[tuple[str, int, str]] = [] destination_dirs = {Path("/tmp")} mounted: dict[Path, tuple[str, Path]] = {} - def stage_source(source: Path) -> str: + def stage_source(source: Path, writable: bool = False) -> str: token = f"@PDD-PATH-{uuid.uuid4().hex}@" + if writable: + for index, root in enumerate(storage_roots): + if source == root or source.is_relative_to(root): + relative = source.relative_to(root).as_posix() or "." + writable_specs.append((token, index, relative)) + return token + raise RuntimeError("writable source is outside bounded storage") sources.append(source) path_tokens.append(token) return token @@ -679,7 +769,7 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: ) mounted[destination] = binding ensure_destination_parent(destination) - argv.extend((option, stage_source(source), str(destination))) + argv.extend((option, stage_source(source, option == "--bind"), str(destination))) if destination.is_dir(): destination_dirs.add(destination) @@ -705,11 +795,6 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: bind("--bind", item.resolve()) for item in writable_files: bind("--bind", item.resolve()) - if result_fifo is not None: - # The coordinator wrapper opens and unlinks the FIFO before it - # executes any candidate-controlled code. Binding only its - # dedicated directory keeps the reporter and toolchain immutable. - bind("--bind", result_fifo.parent.resolve()) argv.extend(("--chdir", str(workdir))) drop = ( [str(tools.setpriv), "--reuid", str(os.getuid()), "--regid", str(os.getgid()), @@ -717,10 +802,12 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: ) sandboxed = _limited_command(command, limits) if result_fifo is not None: - sandboxed = _private_result_command(sandboxed, result_fifo, result_fd) + argv[1:1] = ["--preserve-fds", "1"] + sandboxed = _private_result_command(sandboxed, result_fd, 3) argv.extend(("--", *drop, *sandboxed)) return _staged_bwrap( - argv, sources, path_tokens, + argv, sources, path_tokens, writable_roots=storage_roots, + writable_specs=writable_specs, result_fifo=result_fifo, unit_name=unit_name or _scope_unit_name(), control_directory=control_directory or ( Path(tempfile.gettempdir()) / f"pdd-scope-{uuid.uuid4().hex}" @@ -745,8 +832,13 @@ def run_supervised( # pylint: disable=consider-using-with,too-many-locals,too-many-branches,too-many-statements token = uuid.uuid4().hex unit_name = _scope_unit_name() - stdout_file = tempfile.TemporaryFile(mode="w+b") - stderr_file = tempfile.TemporaryFile(mode="w+b") + stdout_buffer = bytearray() + stderr_buffer = bytearray() + diagnostics = bytearray() + output_lock = threading.Lock() + output_size = 0 + output_overflow = False + output_threads: list[threading.Thread] = [] timed_out = False failed_closed = False surviving = False @@ -761,18 +853,29 @@ def run_supervised( tracker: threading.Thread | None = None phase = "construction" + def add_diagnostic(value: str) -> None: + data = value.encode("utf-8", errors="replace") + remaining = max(0, limits.max_output_bytes - len(diagnostics)) + diagnostics.extend(data[:remaining]) + + def drain_output(stream, buffer: bytearray) -> None: + nonlocal output_size, output_overflow + while chunk := stream.read(65536): + with output_lock: + remaining = max(0, limits.max_output_bytes - output_size) + buffer.extend(chunk[:remaining]) + output_size += len(chunk) + if len(chunk) > remaining: + output_overflow = True + def limit_error() -> str | None: - writable_size = _writable_size(writable_roots) - if writable_size > limits.max_writable_bytes: - return ( - "protected supervisor writable quota exceeded: " - f"{writable_size}>{limits.max_writable_bytes}" - ) - output_size = stdout_file.tell() + stderr_file.tell() - if output_size > limits.max_output_bytes: + with output_lock: + exceeded = output_overflow + observed = output_size + if exceeded: return ( "protected supervisor output quota exceeded: " - f"{output_size}>{limits.max_output_bytes}" + f"{observed}>{limits.max_output_bytes}" ) return None @@ -782,7 +885,7 @@ def fail_for_limit() -> bool: if error is None: return False failed_closed = True - stderr_file.write((f"protected supervisor phase={phase}: {error}\n").encode()) + add_diagnostic(f"protected supervisor phase={phase}: {error}\n") return True def record_events() -> None: @@ -813,9 +916,9 @@ def record_events() -> None: except RuntimeError: pass if oom_delta > 0: - stderr_file.write(f"cgroup memory.events oom delta={oom_delta}\n".encode()) + add_diagnostic(f"cgroup memory.events oom delta={oom_delta}\n") if pids_delta > 0: - stderr_file.write(f"cgroup pids.events max delta={pids_delta}\n".encode()) + add_diagnostic(f"cgroup pids.events max delta={pids_delta}\n") try: with tempfile.TemporaryDirectory(prefix="pdd-scope-") as control_value: @@ -831,8 +934,6 @@ def record_events() -> None: ) _prepare_staging(plan) except (OSError, RuntimeError) as exc: - stdout_file.close() - stderr_file.close() return subprocess.CompletedProcess( command, 125, "", f"protected supervisor phase=construction: {exc}" ), False @@ -849,17 +950,27 @@ def record_events() -> None: sandbox_environment["LD_LIBRARY_PATH"] = library_path try: process = subprocess.Popen( - argv, cwd=cwd, stdout=stdout_file, stderr=stderr_file, + argv, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=sandbox_environment, start_new_session=True, ) except OSError as exc: _cleanup_staging(plan) - stdout_file.close() - stderr_file.close() return subprocess.CompletedProcess( command, 125, "", f"protected supervisor phase=launch: {exc}" ), False + assert process.stdout is not None and process.stderr is not None + output_threads = [ + threading.Thread( + target=drain_output, args=(process.stdout, stdout_buffer), daemon=True + ), + threading.Thread( + target=drain_output, args=(process.stderr, stderr_buffer), daemon=True + ), + ] + for output_thread in output_threads: + output_thread.start() + def track_process_tree() -> None: while not tracking_done.wait(0.005): for pid in _process_descendants(process.pid): @@ -902,9 +1013,9 @@ def track_process_tree() -> None: record_events() if candidate_returncode is None and not timed_out and not failed_closed: failed_closed = True - stderr_file.write( - b"protected supervisor phase=candidate-execution: " - b"scope produced no candidate result\n" + add_diagnostic( + "protected supervisor phase=candidate-execution: " + "scope produced no candidate result\n" ) if process.poll() is None and not timed_out and not failed_closed: (control / "finish").write_text("finish", encoding="ascii") @@ -912,19 +1023,15 @@ def track_process_tree() -> None: process.wait(timeout=5) except subprocess.TimeoutExpired: failed_closed = True - stderr_file.write( - b"protected supervisor result-handoff did not finish\n" + add_diagnostic( + "protected supervisor result-handoff did not finish\n" ) except (OSError, ValueError, KeyError, RuntimeError) as exc: failed_closed = True - stderr_file.write( - (f"protected supervisor phase={phase}: {exc}\n").encode() - ) + add_diagnostic(f"protected supervisor phase={phase}: {exc}\n") finally: if timed_out: - stderr_file.write( - f"protected supervisor timeout phase={phase}\n".encode() - ) + add_diagnostic(f"protected supervisor timeout phase={phase}\n") if cgroup is None and process.poll() is None: try: os.killpg(process.pid, signal.SIGKILL) @@ -935,9 +1042,7 @@ def track_process_tree() -> None: _stop_scope(plan.unit_name, plan.tools) except RuntimeError as exc: failed_closed = True - stderr_file.write( - (f"protected supervisor phase=scope-cleanup: {exc}\n").encode() - ) + add_diagnostic(f"protected supervisor phase=scope-cleanup: {exc}\n") if process.poll() is None: try: os.killpg(process.pid, signal.SIGKILL) @@ -948,13 +1053,16 @@ def track_process_tree() -> None: _cleanup_staging(plan) except RuntimeError as exc: failed_closed = True - stderr_file.write( - (f"protected supervisor phase=mount-cleanup: {exc}\n").encode() - ) + add_diagnostic(f"protected supervisor phase=mount-cleanup: {exc}\n") finally: tracking_done.set() if tracker is not None: tracker.join(timeout=1) + for output_thread in output_threads: + output_thread.join(timeout=1) + if output_thread.is_alive(): + failed_closed = True + add_diagnostic("protected supervisor output drain did not finish\n") observed = set() if process is not None: observed = _supervised_descendants(token) @@ -971,16 +1079,13 @@ def track_process_tree() -> None: except ProcessLookupError: pass - stdout_file.seek(0) - stderr_file.seek(0) - remaining = limits.max_output_bytes - stdout_bytes = stdout_file.read(remaining) - remaining -= len(stdout_bytes) - stderr_bytes = stderr_file.read(remaining) - if stdout_file.read(1) or stderr_file.read(1): + stdout_bytes = bytes(stdout_buffer) + remaining = max(0, limits.max_output_bytes - len(stdout_bytes)) + stderr_bytes = bytes(stderr_buffer[:remaining]) + remaining -= len(stderr_bytes) + stderr_bytes += bytes(diagnostics[:remaining]) + if output_overflow: failed_closed = True - stdout_file.close() - stderr_file.close() return subprocess.CompletedProcess( command, 125 if failed_closed else (124 if timed_out else candidate_returncode), diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index b79c0acc2d..a2b7a681e7 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -640,6 +640,7 @@ def test_playwright_rejects_candidate_package_self_reference_before_execution( ) _git(root, "add", "package.json") _git(root, "commit", "-q", "-m", "candidate package self reference") + commit = _git(root, "rev-parse", "HEAD") launches = 0 def supervised(*_args, **_kwargs): diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index d8642424d0..88c0afa635 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -53,6 +53,8 @@ def test_private_result_wrapper_unlinks_channel_before_candidate( fifo = channel / "result.fifo" os.mkfifo(fifo, mode=0o600) read_fd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK) + write_fd = os.open(fifo, os.O_WRONLY) + fifo.unlink() result_fd = 17 candidate = [ sys.executable, @@ -61,10 +63,11 @@ def test_private_result_wrapper_unlinks_channel_before_candidate( ] completed = subprocess.run( - _private_result_command(candidate, fifo, result_fd), + _private_result_command(candidate, result_fd, write_fd), capture_output=True, text=True, timeout=10, + pass_fds=(write_fd,), check=False, ) try: @@ -72,6 +75,7 @@ def test_private_result_wrapper_unlinks_channel_before_candidate( assert not fifo.exists() assert os.read(read_fd, 1024) == b"trusted-result" finally: + os.close(write_fd) os.close(read_fd) @@ -206,8 +210,15 @@ def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( assert bwrap[destination_index - 2] == "--bind" assert destination_index < bwrap.index("--ro-bind") placeholder = bwrap[destination_index - 1] - tokens = json.loads(argv[-5]) - assert sources[tokens.index(placeholder)] == str(scratch.resolve()) + writable_roots = json.loads(argv[-8]) + writable_specs = json.loads(argv[-7]) + token, root_index, relative = next( + spec for spec in writable_specs if spec[0] == placeholder + ) + assert token == placeholder + assert writable_roots[root_index] == str(scratch.resolve()) + assert relative == "." + assert str(scratch.resolve()) not in sources def test_linux_sandbox_deduplicates_identical_read_only_bindings( @@ -298,10 +309,12 @@ def test_linux_sandbox_opens_and_unlinks_checker_fifo_before_candidate( channel = tmp_path / "channel" channel.mkdir(mode=0o700) + scratch = tmp_path / "scratch" + scratch.mkdir(mode=0o700) fifo = channel / "checker.fifo" os.mkfifo(fifo) argv, plan = _sandbox_command( - ["/bin/true"], (tmp_path,), result_fifo=fifo, result_fd=198 + ["/bin/true"], (scratch,), result_fifo=fifo, result_fd=198 ) assert plan.unit_name.startswith("pdd-validator-") @@ -508,9 +521,12 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert helper.index("start") < helper.index("os.fork()") assert "result.json" in helper and "finish" in helper assert "-t','tmpfs" in helper + assert "/proc/self/mountinfo" in helper + assert "writable tmpfs mount probe failed" in helper assert "statvfs" in helper assert "writable quota" in helper assert "copytree" in helper + assert helper.index("mount_lines=") < helper.index("ready") assert "@PDD-CGROUP@" in plan.bwrap_argv cgroup_source = plan.bwrap_argv.index("@PDD-CGROUP@") assert plan.bwrap_argv[cgroup_source - 1] == "--ro-bind" @@ -813,64 +829,14 @@ def test_memory_disk_and_process_limits_fail_closed(tmp_path: Path, program: str assert result.returncode != 0 -def test_writable_quota_is_rechecked_after_fast_sparse_result( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> None: - """A result racing the monitor cannot hide a sparse logical allocation.""" - helper = """import json,pathlib,subprocess,sys,time -control=pathlib.Path(sys.argv[1]) -(control/'ready').write_text('ready',encoding='ascii') -while not (control/'start').exists(): time.sleep(.001) -completed=subprocess.run(json.loads(sys.argv[2]),check=False) -(control/'result.json').write_text(json.dumps({'returncode':completed.returncode}),encoding='ascii') -while not (control/'finish').exists(): time.sleep(.001) -""" - cgroup = tmp_path / "cgroup" - cgroup.mkdir() - (cgroup / "memory.events").write_text("oom 0\noom_kill 0\n", encoding="ascii") - (cgroup / "pids.events").write_text("max 0\n", encoding="ascii") - - def sandbox(command, _writable_roots, **kwargs): - plan = SimpleNamespace( - unit_name="pdd-validator-00000000000000000000000000000000.scope", - tools=SimpleNamespace(), - ) - return [ - sys.executable, "-c", helper, str(kwargs["control_directory"]), - json.dumps(command), - ], plan - - monkeypatch.setattr(supervisor, "_sandbox_command", sandbox) - monkeypatch.setattr(supervisor, "_prepare_staging", lambda _plan: None) - monkeypatch.setattr( - supervisor, "_probe_scope", - lambda _plan, _limits: (cgroup, {"oom": 0, "oom_kill": 0}, {"max": 0}), - ) - monkeypatch.setattr(supervisor, "_stop_scope", lambda *_args: None) - monkeypatch.setattr(supervisor, "_cleanup_staging", lambda _plan: None) - monkeypatch.setattr( - supervisor, "_scope_properties", lambda *_args: {"Result": "success"} - ) - limits = SupervisorLimits(max_writable_bytes=1024 * 1024) - - result, surviving = run_supervised( - [sys.executable, "-c", "open('large','wb').truncate(8*1024*1024)"], - cwd=tmp_path, timeout=5, env=dict(os.environ), - writable_roots=(tmp_path,), limits=limits, - ) - - assert result.returncode == 125 - assert "writable quota" in result.stderr - assert (tmp_path / "large").stat().st_size == 8 * 1024 * 1024 - assert surviving is False - - def test_writable_accounting_errors_fail_closed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: inaccessible = tmp_path / "inaccessible" inaccessible.mkdir() - monkeypatch.setattr(Path, "iterdir", lambda _path: (_ for _ in ()).throw(OSError("denied"))) + monkeypatch.setattr( + Path, "rglob", lambda _path, _pattern: (_ for _ in ()).throw(OSError("denied")) + ) with pytest.raises(RuntimeError, match="writable accounting failed"): supervisor._writable_size((inaccessible,)) From f330c0934827e12ee5ebdc565eb27b6dd4c353f9 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 21:54:29 -0700 Subject: [PATCH 075/233] fix(sync): satisfy supervisor lint structure --- pdd/sync_core/supervisor.py | 56 ++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 3c7529089e..7bd34a423c 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1,5 +1,5 @@ """Fail-closed OS sandbox and complete process-group supervision.""" -# pylint: disable=too-many-arguments +# pylint: disable=too-many-arguments,too-many-lines from __future__ import annotations @@ -251,10 +251,9 @@ def _staged_bwrap( ) -> tuple[list[str], _ScopePlan]: """Build one scope-held helper that releases Bubblewrap after verification.""" unit_name = _validated_scope_unit(unit_name) - staging = control_directory / "binds" - staging_targets = tuple(staging / str(index) for index in range(len(sources))) - writable_target = staging / "writable" - cgroup_target = staging / "cgroup" + staging_targets = tuple( + control_directory / "binds" / str(index) for index in range(len(sources)) + ) helper = "\n".join(( "import json,os,pathlib,shutil,stat,subprocess,sys,time", "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", @@ -280,15 +279,18 @@ def _staged_bwrap( " total=0", " for path in (root,*root.rglob('*')):", " metadata=path.lstat()", - " if stat.S_ISLNK(metadata.st_mode): raise RuntimeError('writable tree contains symlink')", + " if stat.S_ISLNK(metadata.st_mode): " + "raise RuntimeError('writable tree contains symlink')", " if stat.S_ISREG(metadata.st_mode): total+=metadata.st_size", - " elif not stat.S_ISDIR(metadata.st_mode): raise RuntimeError('writable tree contains special file')", + " elif not stat.S_ISDIR(metadata.st_mode): " + "raise RuntimeError('writable tree contains special file')", " return total", "def copy_owned(source,target):", " if source.is_dir(): shutil.copytree(source,target)", " else: shutil.copy2(source,target)", " pairs=[(source,target)]", - " if source.is_dir(): pairs.extend((item,target/item.relative_to(source)) for item in source.rglob('*'))", + " if source.is_dir(): pairs.extend((item,target/item.relative_to(source)) " + "for item in source.rglob('*'))", " for original,copied in pairs:", " metadata=original.stat(follow_symlinks=False)", " os.chown(copied,metadata.st_uid,metadata.st_gid,follow_symlinks=False)", @@ -300,23 +302,34 @@ def _staged_bwrap( " else: copy_owned(staged_root,source)", "try:", " writable_target=control/'binds'/'writable'", - " subprocess.run([mount,'-t','tmpfs','-o',f\"size={limits['writable']},mode=0700,nosuid,nodev\",'tmpfs',str(writable_target)],check=True)", + " subprocess.run([mount,'-t','tmpfs','-o'," + "f\"size={limits['writable']},mode=0700,nosuid,nodev\",'tmpfs'," + "str(writable_target)],check=True)", " staged.append(writable_target)", - " mount_lines=pathlib.Path('/proc/self/mountinfo').read_text(encoding='utf-8').splitlines()", + " mount_lines=pathlib.Path('/proc/self/mountinfo').read_text(" + "encoding='utf-8').splitlines()", " mount_fields=[line.split() for line in mount_lines]", - " mounted=next((fields for fields in mount_fields if len(fields)>6 and pathlib.Path(fields[4])==writable_target),None)", - " if mounted is None or '-' not in mounted or mounted[mounted.index('-')+1]!='tmpfs': raise RuntimeError('writable tmpfs mount probe failed')", - " capacity=os.statvfs(writable_target).f_blocks*os.statvfs(writable_target).f_frsize", - " if capacity > limits['writable']+os.sysconf('SC_PAGE_SIZE'): raise RuntimeError('writable tmpfs size probe failed')", + " mounted=next((fields for fields in mount_fields if len(fields)>6 and " + "pathlib.Path(fields[4])==writable_target),None)", + " if mounted is None or '-' not in mounted or " + "mounted[mounted.index('-')+1]!='tmpfs': " + "raise RuntimeError('writable tmpfs mount probe failed')", + " capacity=os.statvfs(writable_target).f_blocks*" + "os.statvfs(writable_target).f_frsize", + " if capacity > limits['writable']+os.sysconf('SC_PAGE_SIZE'): " + "raise RuntimeError('writable tmpfs size probe failed')", " writable_paths=[]", " for index,source in enumerate(writable_roots):", - " target=writable_target/str(index); copy_owned(source,target); writable_paths.append(target)", - " if sum(validate_tree(path) for path in writable_paths) > limits['writable']: raise RuntimeError('initial writable quota exceeded')", + " target=writable_target/str(index); copy_owned(source,target); " + "writable_paths.append(target)", + " if sum(validate_tree(path) for path in writable_paths) > " + "limits['writable']: raise RuntimeError('initial writable quota exceeded')", " for source,target in zip(paths,targets):", " subprocess.run([mount,'--bind',source,str(target)],check=True)", " staged.append(target)", " path_map={token:str(targets[index]) for index,token in enumerate(path_tokens)}", - " for token,index,relative in writable_specs: path_map[token]=str(writable_paths[index]/relative)", + " for token,index,relative in writable_specs: " + "path_map[token]=str(writable_paths[index]/relative)", " argv=[path_map.get(value,value) for value in argv]", " cgroup_target=control/'binds'/'cgroup'", " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)],check=True)", @@ -338,8 +351,10 @@ def _staged_bwrap( " os.execvpe(argv[0],argv,os.environ)", " _wait,status=os.waitpid(pid,0)", " result=os.waitstatus_to_exitcode(status)", - " if sum(validate_tree(path) for path in writable_paths) > limits['writable']: raise RuntimeError('final writable quota exceeded')", - " for source,staged_root in zip(writable_roots,writable_paths): replace_host(source,staged_root)", + " if sum(validate_tree(path) for path in writable_paths) > " + "limits['writable']: raise RuntimeError('final writable quota exceeded')", + " for source,staged_root in zip(writable_roots,writable_paths): " + "replace_host(source,staged_root)", " temporary=control/'result.tmp'", " temporary.write_text(json.dumps({'returncode':result}),encoding='ascii')", " os.replace(temporary,control/'result.json')", @@ -358,7 +373,8 @@ def _staged_bwrap( )) plan = _ScopePlan( unit_name, control_directory, helper, tuple(argv), tuple(sources), - (*staging_targets, writable_target, cgroup_target), tools, + (*staging_targets, control_directory / "binds" / "writable", + control_directory / "binds" / "cgroup"), tools, ) command = [ str(tools.sudo), "-n", "-E", str(tools.systemd_run), "--scope", "--quiet", From d10704c2fe0ded1edc1893b7a3eb8c831563326e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 22:07:11 -0700 Subject: [PATCH 076/233] test(sync): require transactional outputs and node trust --- tests/test_sync_core_runner_playwright.py | 77 +++++++++++++++ tests/test_sync_core_supervisor.py | 115 ++++++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index a2b7a681e7..e013adc827 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -687,6 +687,83 @@ def supervised(*_args, **_kwargs): assert launches == 0 +@pytest.mark.parametrize("module_type", ["commonjs", "module"]) +@pytest.mark.parametrize("reserved", ["@playwright/test", "playwright", "playwright-core"]) +def test_playwright_rejects_nested_reserved_package_scopes( + tmp_path: Path, module_type: str, reserved: str, +) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/package.json").write_text( + json.dumps({"name": reserved, "type": module_type}), encoding="utf-8" + ) + _git(root, "add", "tests/package.json") + _git(root, "commit", "-q", "-m", "nested package self reference") + + with pytest.raises(ValueError, match="reserved package self-reference"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize("module_type", ["commonjs", "module"]) +@pytest.mark.parametrize("node_modules_type", ["directory", "symlink", "file"]) +def test_playwright_rejects_nested_node_modules_before_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + module_type: str, node_modules_type: str, +) -> None: + root, commit = _repository(tmp_path) + (root / "tests/package.json").write_text( + json.dumps({"name": "candidate-tests", "type": module_type}), + encoding="utf-8", + ) + _git(root, "add", "tests/package.json") + _git(root, "commit", "-q", "-m", "nested module mode") + commit = _git(root, "rev-parse", "HEAD") + destination = root / "tests/node_modules" + if node_modules_type == "directory": + destination.mkdir() + elif node_modules_type == "symlink": + outside = tmp_path / "outside-modules" + outside.mkdir() + destination.symlink_to(outside, target_is_directory=True) + else: + destination.write_text("shadow", encoding="utf-8") + launches = 0 + + def supervised(*_args, **_kwargs): + nonlocal launches + launches += 1 + raise AssertionError("nested node_modules must fail before execution") + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + execution, _identities = runner_module._run_playwright_in_tree( + root, (PurePosixPath("tests/widget.spec.ts"),), 2, + _trusted_playwright_config(tmp_path / "trusted", _fake_playwright(tmp_path)), + expected_commit=commit, + ) + + assert execution.outcome is EvidenceOutcome.ERROR + assert "tests/node_modules" in execution.detail + assert launches == 0 + + +@pytest.mark.parametrize("module_type", ["commonjs", "module"]) +def test_playwright_accepts_normal_nested_package_scope( + tmp_path: Path, module_type: str, +) -> None: + root, _commit = _repository(tmp_path) + (root / "tests/package.json").write_text( + json.dumps({"name": "candidate-tests", "type": module_type}), + encoding="utf-8", + ) + _git(root, "add", "tests/package.json") + _git(root, "commit", "-q", "-m", "normal nested package") + + assert playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + @pytest.mark.skipif(not shutil.which("node"), reason="requires Node module resolution") @pytest.mark.parametrize("module_type", ["commonjs", "module"]) def test_trusted_playwright_package_resolves_for_real_node_modes( diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 88c0afa635..ca542ec473 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4,6 +4,7 @@ import json import math import shutil +import stat import subprocess import sys import time @@ -842,6 +843,120 @@ def test_writable_accounting_errors_fail_closed( supervisor._writable_size((inaccessible,)) +def _file_state(path: Path) -> tuple[bytes, int, int, int]: + metadata = path.stat() + return ( + path.read_bytes(), stat.S_IMODE(metadata.st_mode), + metadata.st_uid, metadata.st_gid, + ) + + +def test_declared_writable_files_publish_without_copying_scratch(tmp_path: Path) -> None: + scratch = tmp_path / "scratch" + scratch.mkdir() + (scratch / "host.txt").write_text("host scratch", encoding="utf-8") + staged_scratch = tmp_path / "staged-scratch" + staged_scratch.mkdir() + (staged_scratch / "host.txt").write_text("candidate scratch", encoding="utf-8") + output = tmp_path / "result.json" + output.write_text("original", encoding="utf-8") + output.chmod(0o640) + staged_output = tmp_path / "staged-result.json" + staged_output.write_text("published", encoding="utf-8") + staged_output.chmod(0o640) + expected = supervisor._writable_file_identity(output) + + supervisor._publish_writable_files(((staged_output, output, expected),)) + + assert output.read_text(encoding="utf-8") == "published" + assert stat.S_IMODE(output.stat().st_mode) == 0o640 + assert (scratch / "host.txt").read_text(encoding="utf-8") == "host scratch" + + +@pytest.mark.parametrize("failure", ["copy", "fsync", "replace"]) +def test_writable_file_publication_rolls_back_all_outputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str, +) -> None: + destinations = (tmp_path / "first.json", tmp_path / "second.json") + staged = (tmp_path / "staged-first.json", tmp_path / "staged-second.json") + for index, path in enumerate(destinations): + path.write_text(f"original-{index}", encoding="utf-8") + path.chmod(0o640 + index) + for index, path in enumerate(staged): + path.write_text(f"replacement-{index}", encoding="utf-8") + path.chmod(0o640 + index) + before = tuple(_file_state(path) for path in destinations) + publication = tuple( + (source, destination, supervisor._writable_file_identity(destination)) + for source, destination in zip(staged, destinations) + ) + + if failure == "copy": + original = shutil.copyfileobj + calls = 0 + + def fail_copy(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("injected copy failure") + return original(*args, **kwargs) + + monkeypatch.setattr(supervisor.shutil, "copyfileobj", fail_copy) + elif failure == "fsync": + original = os.fsync + calls = 0 + + def fail_fsync(descriptor): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("injected fsync failure") + return original(descriptor) + + monkeypatch.setattr(supervisor.os, "fsync", fail_fsync) + else: + original = os.replace + calls = 0 + + def fail_replace(source, destination): + nonlocal calls + calls += 1 + if calls == 4: + raise OSError("injected rename failure") + return original(source, destination) + + monkeypatch.setattr(supervisor.os, "replace", fail_replace) + + with pytest.raises(RuntimeError, match="publication"): + supervisor._publish_writable_files(publication) + + assert tuple(_file_state(path) for path in destinations) == before + + +@pytest.mark.parametrize("duplicate", [True, False]) +def test_writable_files_reject_duplicates_and_root_overlap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, duplicate: bool, +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + supervisor.subprocess, "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr(supervisor, "released_runtime_closure_paths", lambda: ()) + scratch = tmp_path / "scratch" + scratch.mkdir() + output = scratch / "result.json" + output.write_text("original", encoding="utf-8") + files = (output, output) if duplicate else (output,) + + with pytest.raises(RuntimeError, match="duplicate|overlap"): + _sandbox_command( + [sys.executable, "-c", "pass"], (scratch,), writable_files=files, + ) + + def test_macos_fails_closed_without_kernel_lifetime_containment( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 680a1125eab1a335ecac7e99cdd69e5d73bcd261 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 22:18:29 -0700 Subject: [PATCH 077/233] fix(sync): publish outputs and bind Node trust --- pdd/sync_core/runner.py | 60 +++++++- pdd/sync_core/supervisor.py | 216 +++++++++++++++++++++++++++-- tests/test_sync_core_supervisor.py | 36 ++++- 3 files changed, 293 insertions(+), 19 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index fd0a8e434d..4b116121b2 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1524,6 +1524,11 @@ def vitest_validator_config_digest( return digest.hexdigest() +_PLAYWRIGHT_RESERVED_PACKAGES = frozenset({ + "@playwright/test", "playwright", "playwright-core", +}) + + def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: """Return the single protected Playwright configuration source.""" found = [ @@ -1542,11 +1547,59 @@ def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: raise ValueError("Playwright config package scope is invalid") from exc if not isinstance(package, dict): raise ValueError("Playwright config package scope must be an object") - if package.get("name") in {"@playwright/test", "playwright", "playwright-core"}: + if package.get("name") in _PLAYWRIGHT_RESERVED_PACKAGES: raise ValueError("Playwright config uses a reserved package self-reference") return found[0], content +def _playwright_node_trust_manifests( + root: Path, ref: str, executable_paths: set[PurePosixPath], +) -> set[PurePosixPath]: + """Validate every Node package scope and search path used by the closure.""" + manifests: set[PurePosixPath] = set() + destination_error = _playwright_node_modules_destination_error(root) + if destination_error is not None: + raise ValueError(destination_error) + for source_path in executable_paths: + directory = source_path.parent + while True: + manifest = directory / "package.json" + raw = read_git_blob(root, ref, manifest) + if raw is not None: + regular = read_git_regular_blob(root, ref, manifest) + if regular is None: + raise ValueError( + f"Playwright package scope must be a regular file: {manifest}" + ) + try: + package = json.loads(regular.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError( + f"Playwright package scope is invalid: {manifest}" + ) from exc + if not isinstance(package, dict): + raise ValueError( + f"Playwright package scope must be an object: {manifest}" + ) + if package.get("name") in _PLAYWRIGHT_RESERVED_PACKAGES: + raise ValueError( + "Playwright closure uses a reserved package self-reference: " + + manifest.as_posix() + ) + manifests.add(manifest) + if directory != PurePosixPath("."): + node_modules = root / directory / "node_modules" + if os.path.lexists(node_modules): + raise ValueError( + "Playwright closure has a candidate Node resolution path: " + + (directory / "node_modules").as_posix() + ) + if directory == PurePosixPath("."): + break + directory = directory.parent + return manifests + + def _playwright_static_config( path: PurePosixPath, source: bytes, *, commonjs: bool = False, ) -> set[PurePosixPath]: @@ -1864,6 +1917,11 @@ def _playwright_support_closure( pending.append((mapped, owners)) if has_snapshot: snapshot_owners.update(owners) + executable_paths = { + path for path in {config_path, *visited, *product_paths} + if path.suffix in _JAVASCRIPT_SUFFIXES + } + paths.update(_playwright_node_trust_manifests(root, ref, executable_paths)) for owner in snapshot_owners: snapshot_prefix = PurePosixPath(f"{owner.as_posix()}-snapshots") listed = subprocess.run( diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 7bd34a423c..f29711e1e2 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -3,8 +3,10 @@ from __future__ import annotations -import os +import hashlib +import inspect import json +import os import re import shutil import signal @@ -242,9 +244,142 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: str(limits.max_cpu_seconds), str(limits.max_writable_bytes), "256", *command] +def _writable_file_identity(path: Path) -> tuple[int, int, int, int, str]: + """Return a no-follow identity for one publishable regular file.""" + try: + metadata = path.lstat() + if not stat.S_ISREG(metadata.st_mode) or path.is_symlink(): + raise RuntimeError("publishable writable output must be a regular file") + digest = hashlib.sha256() + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + try: + with os.fdopen(descriptor, "rb", closefd=False) as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + finally: + os.close(descriptor) + current = path.lstat() + except OSError as exc: + raise RuntimeError("publishable writable output is unavailable") from exc + if (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns) != ( + current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns, + ): + raise RuntimeError("publishable writable output changed during validation") + return ( + current.st_size, stat.S_IMODE(current.st_mode), current.st_uid, + current.st_gid, digest.hexdigest(), + ) + + +def _fsync_directory(directory: Path) -> None: + """Persist directory entry changes on a publication destination filesystem.""" + descriptor = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _publish_writable_files( + publications: tuple[ + tuple[Path, Path, tuple[int, int, int, int, str]], ... + ], +) -> None: + """Atomically publish a set of staged files, rolling back the whole set.""" + destinations = tuple( + destination for _source, destination, _expected in publications + ) + if len(set(destinations)) != len(destinations): + raise RuntimeError("writable output publication has duplicate destinations") + prepared: list[tuple[Path, Path, Path]] = [] + backups: list[tuple[Path, Path]] = [] + installed: set[Path] = set() + try: + for source, destination, expected in publications: + if _writable_file_identity(destination) != expected: + raise RuntimeError("writable output changed before publication") + source_identity = _writable_file_identity(source) + if source_identity[1:4] != expected[1:4]: + raise RuntimeError("staged writable output metadata changed") + descriptor, temporary_name = tempfile.mkstemp( + prefix=".pdd-publish-", dir=destination.parent + ) + temporary = Path(temporary_name) + try: + with source.open("rb") as source_stream, os.fdopen( + descriptor, "wb" + ) as destination_stream: + shutil.copyfileobj(source_stream, destination_stream) + destination_stream.flush() + os.fchmod(destination_stream.fileno(), expected[1]) + os.fchown(destination_stream.fileno(), expected[2], expected[3]) + os.fsync(destination_stream.fileno()) + except BaseException: + temporary.unlink(missing_ok=True) + raise + prepared.append((temporary, destination, source)) + replacement_identity = _writable_file_identity(temporary) + if replacement_identity != ( + source_identity[0], expected[1], expected[2], expected[3], + source_identity[4], + ): + raise RuntimeError("staged writable output replacement is invalid") + for _temporary, destination, _source in prepared: + descriptor, backup_name = tempfile.mkstemp( + prefix=".pdd-rollback-", dir=destination.parent + ) + os.close(descriptor) + backup = Path(backup_name) + backup.unlink() + os.link(destination, backup, follow_symlinks=False) + backups.append((backup, destination)) + expected = next( + identity for _staged, item, identity in publications + if item == destination + ) + if _writable_file_identity(backup) != expected: + raise RuntimeError("writable output changed before rollback snapshot") + for temporary, destination, _source in prepared: + os.replace(temporary, destination) + installed.add(destination) + for _temporary, destination, source in prepared: + published = _writable_file_identity(destination) + source_identity = _writable_file_identity(source) + if published[0] != source_identity[0] or published[4] != source_identity[4]: + raise RuntimeError("published writable output verification failed") + for parent in {destination.parent for destination in destinations}: + _fsync_directory(parent) + except BaseException as exc: + rollback_errors = [] + for backup, destination in reversed(backups): + try: + if destination in installed: + os.replace(backup, destination) + else: + backup.unlink(missing_ok=True) + except OSError as rollback_exc: + rollback_errors.append(str(rollback_exc)) + for temporary, _destination, _source in prepared: + try: + temporary.unlink(missing_ok=True) + except OSError as cleanup_exc: + rollback_errors.append(str(cleanup_exc)) + for parent in {destination.parent for destination in destinations}: + try: + _fsync_directory(parent) + except OSError as rollback_exc: + rollback_errors.append(str(rollback_exc)) + if rollback_errors: + raise RuntimeError("writable output publication rollback failed") from exc + raise RuntimeError("writable output publication failed") from exc + for backup, _destination in backups: + backup.unlink() + + def _staged_bwrap( argv: list[str], sources: list[Path], path_tokens: list[str], *, writable_roots: tuple[Path, ...], writable_specs: list[tuple[str, int, str]], + publish_indexes: tuple[int, ...], result_fifo: Path | None, unit_name: str, control_directory: Path, limits: SupervisorLimits, tools: _TrustedTools, @@ -254,11 +389,20 @@ def _staged_bwrap( staging_targets = tuple( control_directory / "binds" / str(index) for index in range(len(sources)) ) + publication_source = "\n\n".join( + inspect.getsource(function) for function in ( + _writable_file_identity, _fsync_directory, _publish_writable_files, + ) + ) helper = "\n".join(( - "import json,os,pathlib,shutil,stat,subprocess,sys,time", + "from __future__ import annotations", + "import hashlib,json,os,pathlib,shutil,stat,subprocess,sys,tempfile,time", + "from pathlib import Path", + publication_source, "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[4])]", - "writable_specs=json.loads(sys.argv[5]); result_fifo=sys.argv[6] or None", + "writable_specs=json.loads(sys.argv[5]); publish_indexes=json.loads(sys.argv[6])", + "result_fifo=sys.argv[7] or None", "limits=json.loads(sys.argv[-1])", "path_tokens=json.loads(sys.argv[-5]); argv=json.loads(sys.argv[-4]); " "paths=json.loads(sys.argv[-3])", @@ -294,12 +438,6 @@ def _staged_bwrap( " for original,copied in pairs:", " metadata=original.stat(follow_symlinks=False)", " os.chown(copied,metadata.st_uid,metadata.st_gid,follow_symlinks=False)", - "def replace_host(source,staged_root):", - " if source.is_dir():", - " for item in source.iterdir():", - " shutil.rmtree(item) if item.is_dir() and not item.is_symlink() else item.unlink()", - " for item in staged_root.iterdir(): copy_owned(item,source/item.name)", - " else: copy_owned(staged_root,source)", "try:", " writable_target=control/'binds'/'writable'", " subprocess.run([mount,'-t','tmpfs','-o'," @@ -318,10 +456,15 @@ def _staged_bwrap( "os.statvfs(writable_target).f_frsize", " if capacity > limits['writable']+os.sysconf('SC_PAGE_SIZE'): " "raise RuntimeError('writable tmpfs size probe failed')", + " publish_expected={index:_writable_file_identity(writable_roots[index]) " + "for index in publish_indexes}", " writable_paths=[]", " for index,source in enumerate(writable_roots):", " target=writable_target/str(index); copy_owned(source,target); " "writable_paths.append(target)", + " for index in publish_indexes:", + " if _writable_file_identity(writable_paths[index]) != " + "publish_expected[index]: raise RuntimeError('writable output staging changed')", " if sum(validate_tree(path) for path in writable_paths) > " "limits['writable']: raise RuntimeError('initial writable quota exceeded')", " for source,target in zip(paths,targets):", @@ -353,8 +496,9 @@ def _staged_bwrap( " result=os.waitstatus_to_exitcode(status)", " if sum(validate_tree(path) for path in writable_paths) > " "limits['writable']: raise RuntimeError('final writable quota exceeded')", - " for source,staged_root in zip(writable_roots,writable_paths): " - "replace_host(source,staged_root)", + " publications=tuple((writable_paths[index],writable_roots[index]," + "publish_expected[index]) for index in publish_indexes)", + " _publish_writable_files(publications)", " temporary=control/'result.tmp'", " temporary.write_text(json.dumps({'returncode':result}),encoding='ascii')", " os.replace(temporary,control/'result.json')", @@ -384,7 +528,8 @@ def _staged_bwrap( str(_SUPERVISOR_EXECUTABLE), "-c", helper, str(control_directory), str(tools.mount), str(tools.umount), json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), - str(result_fifo or ""), json.dumps(path_tokens), json.dumps(argv), + json.dumps(publish_indexes), str(result_fifo or ""), + json.dumps(path_tokens), json.dumps(argv), json.dumps([str(path) for path in sources]), unit_name, json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, "writable": limits.max_writable_bytes}), @@ -524,6 +669,41 @@ def _writable_storage_roots(paths: tuple[Path, ...]) -> tuple[Path, ...]: return tuple(roots) +def _writable_publication_files( + paths: tuple[Path, ...], storage_roots: tuple[Path, ...], +) -> tuple[Path, ...]: + """Validate distinct regular outputs that do not overlap ephemeral storage.""" + resolved = [] + identities = [] + for path in paths: + try: + if path.is_symlink(): + raise RuntimeError("publishable writable output cannot be a symlink") + value = path.resolve(strict=True) + except OSError as exc: + raise RuntimeError("publishable writable output is unavailable") from exc + if not value.is_file(): + raise RuntimeError("publishable writable output must be a regular file") + resolved.append(value) + metadata = value.stat() + identities.append((metadata.st_dev, metadata.st_ino)) + if len(set(resolved)) != len(resolved) or len(set(identities)) != len(identities): + raise RuntimeError("publishable writable outputs contain duplicates") + root_identities = { + (metadata.st_dev, metadata.st_ino) + for root in storage_roots + if stat.S_ISREG((metadata := root.stat()).st_mode) + } + if root_identities.intersection(identities): + raise RuntimeError("publishable writable output overlaps a writable root") + if any( + path == root or path.is_relative_to(root) or root.is_relative_to(path) + for path in resolved for root in storage_roots + ): + raise RuntimeError("publishable writable output overlaps a writable root") + return tuple(resolved) + + def _root_environment() -> dict[str, str]: """Return the fixed environment used for every privileged tool invocation.""" return {"PATH": _TRUSTED_ROOT_PATH, "LANG": "C", "LC_ALL": "C"} @@ -736,8 +916,13 @@ def _sandbox_command( workdir = (cwd or Path.cwd()).resolve() writable_sources = tuple( source for source, _destination in writable_bindings - ) + (*writable_roots, *writable_files) - storage_roots = _writable_storage_roots(writable_sources) + ) + writable_roots + ephemeral_roots = _writable_storage_roots(writable_sources) + publication_files = _writable_publication_files( + writable_files, ephemeral_roots + ) + storage_roots = (*ephemeral_roots, *publication_files) + publish_indexes = tuple(range(len(ephemeral_roots), len(storage_roots))) if _writable_size(storage_roots) > limits.max_writable_bytes: raise RuntimeError("initial writable quota exceeded") argv = [str(tools.bwrap), "--unshare-ipc", "--unshare-pid", "--unshare-net", @@ -823,7 +1008,8 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: argv.extend(("--", *drop, *sandboxed)) return _staged_bwrap( argv, sources, path_tokens, writable_roots=storage_roots, - writable_specs=writable_specs, result_fifo=result_fifo, + writable_specs=writable_specs, publish_indexes=publish_indexes, + result_fifo=result_fifo, unit_name=unit_name or _scope_unit_name(), control_directory=control_directory or ( Path(tempfile.gettempdir()) / f"pdd-scope-{uuid.uuid4().hex}" diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index ca542ec473..a5107acca2 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -211,8 +211,8 @@ def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( assert bwrap[destination_index - 2] == "--bind" assert destination_index < bwrap.index("--ro-bind") placeholder = bwrap[destination_index - 1] - writable_roots = json.loads(argv[-8]) - writable_specs = json.loads(argv[-7]) + writable_roots = json.loads(argv[-9]) + writable_specs = json.loads(argv[-8]) token, root_index, relative = next( spec for spec in writable_specs if spec[0] == placeholder ) @@ -527,6 +527,11 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert "statvfs" in helper assert "writable quota" in helper assert "copytree" in helper + assert "replace_host" not in helper + assert "_publish_writable_files(publications)" in helper + assert helper.index("_publish_writable_files(publications)") < helper.index( + "result.tmp" + ) assert helper.index("mount_lines=") < helper.index("ready") assert "@PDD-CGROUP@" in plan.bwrap_argv cgroup_source = plan.bwrap_argv.index("@PDD-CGROUP@") @@ -794,6 +799,30 @@ def test_immediate_detached_child_cannot_forge_checker_result_channel( assert result_channel.read_text(encoding="utf-8") == "checker-owned" +@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") +def test_only_declared_output_is_published_from_protected_tmpfs(tmp_path: Path) -> None: + scratch = tmp_path / "scratch" + scratch.mkdir() + output = tmp_path / "result.json" + output.write_text("original", encoding="utf-8") + program = ( + "import pathlib,sys; " + "pathlib.Path('ephemeral').write_text('discarded'); " + "pathlib.Path(sys.argv[1]).write_text('published')" + ) + + result, surviving = run_supervised( + [sys.executable, "-c", program, str(output)], cwd=scratch, + timeout=10, env=dict(os.environ), writable_roots=(scratch,), + writable_files=(output,), + ) + + assert result.returncode == 0, result.stderr + assert surviving is False + assert output.read_text(encoding="utf-8") == "published" + assert not (scratch / "ephemeral").exists() + + @pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") def test_output_is_bounded(tmp_path: Path) -> None: result, _surviving = run_supervised( @@ -922,7 +951,7 @@ def fail_fsync(descriptor): def fail_replace(source, destination): nonlocal calls calls += 1 - if calls == 4: + if calls == 2: raise OSError("injected rename failure") return original(source, destination) @@ -1012,3 +1041,4 @@ def test_writable_churn_cannot_escape_supervisor_cleanup(tmp_path: Path) -> None ) assert result.returncode == 0 assert surviving is False + assert not (tmp_path / "churn").exists() From 53d2d60da0cb402c34cb2cd59418a915c9b661b1 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 22:18:35 -0700 Subject: [PATCH 078/233] test(sync): cover executable Node search paths --- tests/test_sync_core_runner_playwright.py | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index e013adc827..986ec636d0 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -764,6 +764,37 @@ def test_playwright_accepts_normal_nested_package_scope( ) +@pytest.mark.parametrize("member", ["support", "product"]) +def test_playwright_checks_node_resolution_for_all_executable_closure_members( + tmp_path: Path, member: str, +) -> None: + root, _commit = _repository(tmp_path) + if member == "support": + source = root / "helpers/deep/support.ts" + source.parent.mkdir(parents=True) + source.write_text("export const value = true;\n", encoding="utf-8") + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "import { value } from '../helpers/deep/support';\n" + "test('widget works', () => value);\n", + encoding="utf-8", + ) + products: tuple[PurePosixPath, ...] = () + else: + source = root / "src/deep/widget.ts" + source.parent.mkdir(parents=True) + source.write_text("export const value = true;\n", encoding="utf-8") + products = (PurePosixPath("src/deep/widget.ts"),) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "nested executable closure member") + (source.parent / "node_modules").mkdir() + + with pytest.raises(ValueError, match="candidate Node resolution path"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),), products + ) + + @pytest.mark.skipif(not shutil.which("node"), reason="requires Node module resolution") @pytest.mark.parametrize("module_type", ["commonjs", "module"]) def test_trusted_playwright_package_resolves_for_real_node_modes( From b8224188a85b9093d6d16a3750f08255d43eca37 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 22:25:23 -0700 Subject: [PATCH 079/233] refactor(sync): split writable publication transaction --- pdd/sync_core/supervisor.py | 209 +++++++++++++++++++++--------------- 1 file changed, 122 insertions(+), 87 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index f29711e1e2..326dd40f47 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -244,20 +244,20 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: str(limits.max_cpu_seconds), str(limits.max_writable_bytes), "256", *command] +def _writable_file_digest(path: Path) -> str: + """Hash one regular file through a no-follow descriptor.""" + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(descriptor, "rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + def _writable_file_identity(path: Path) -> tuple[int, int, int, int, str]: """Return a no-follow identity for one publishable regular file.""" try: metadata = path.lstat() if not stat.S_ISREG(metadata.st_mode) or path.is_symlink(): raise RuntimeError("publishable writable output must be a regular file") - digest = hashlib.sha256() - descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) - try: - with os.fdopen(descriptor, "rb", closefd=False) as stream: - for chunk in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(chunk) - finally: - os.close(descriptor) + digest = _writable_file_digest(path) current = path.lstat() except OSError as exc: raise RuntimeError("publishable writable output is unavailable") from exc @@ -267,7 +267,7 @@ def _writable_file_identity(path: Path) -> tuple[int, int, int, int, str]: raise RuntimeError("publishable writable output changed during validation") return ( current.st_size, stat.S_IMODE(current.st_mode), current.st_uid, - current.st_gid, digest.hexdigest(), + current.st_gid, digest, ) @@ -280,96 +280,129 @@ def _fsync_directory(directory: Path) -> None: os.close(descriptor) +def _prepare_writable_files( + publications: tuple[ + tuple[Path, Path, tuple[int, int, int, int, str]], ... + ], + prepared: list[tuple[Path, Path, Path]], +) -> None: + """Copy and fsync every replacement before any destination changes.""" + for source, destination, expected in publications: + if _writable_file_identity(destination) != expected: + raise RuntimeError("writable output changed before publication") + source_identity = _writable_file_identity(source) + if source_identity[1:4] != expected[1:4]: + raise RuntimeError("staged writable output metadata changed") + descriptor, name = tempfile.mkstemp( + prefix=".pdd-publish-", dir=destination.parent + ) + temporary = Path(name) + try: + with source.open("rb") as source_stream, os.fdopen( + descriptor, "wb" + ) as destination_stream: + shutil.copyfileobj(source_stream, destination_stream) + destination_stream.flush() + os.fchmod(destination_stream.fileno(), expected[1]) + os.fchown(destination_stream.fileno(), expected[2], expected[3]) + os.fsync(destination_stream.fileno()) + except BaseException: + temporary.unlink(missing_ok=True) + raise + prepared.append((temporary, destination, source)) + replacement = _writable_file_identity(temporary) + wanted = ( + source_identity[0], expected[1], expected[2], expected[3], + source_identity[4], + ) + if replacement != wanted: + raise RuntimeError("staged writable output replacement is invalid") + + +def _snapshot_writable_files( + publications: tuple[ + tuple[Path, Path, tuple[int, int, int, int, str]], ... + ], backups: list[tuple[Path, Path]], +) -> None: + """Create same-filesystem rollback links for every original output.""" + expected_by_destination = { + destination: expected for _source, destination, expected in publications + } + for destination, expected in expected_by_destination.items(): + descriptor, name = tempfile.mkstemp( + prefix=".pdd-rollback-", dir=destination.parent + ) + os.close(descriptor) + backup = Path(name) + backup.unlink() + os.link(destination, backup, follow_symlinks=False) + backups.append((backup, destination)) + if _writable_file_identity(backup) != expected: + raise RuntimeError("writable output changed before rollback snapshot") + + +def _verify_published_files(prepared: list[tuple[Path, Path, Path]]) -> None: + """Verify published bytes and sizes against immutable staged files.""" + for _temporary, destination, source in prepared: + published = _writable_file_identity(destination) + staged = _writable_file_identity(source) + if published[0] != staged[0] or published[4] != staged[4]: + raise RuntimeError("published writable output verification failed") + + +def _rollback_writable_files( + destinations: tuple[Path, ...], prepared: list[tuple[Path, Path, Path]], + backups: list[tuple[Path, Path]], installed: set[Path], +) -> list[str]: + """Restore every original and remove unpublished transaction files.""" + errors = [] + for backup, destination in reversed(backups): + try: + if destination in installed: + os.replace(backup, destination) + else: + backup.unlink(missing_ok=True) + except OSError as exc: + errors.append(str(exc)) + for temporary, _destination, _source in prepared: + try: + temporary.unlink(missing_ok=True) + except OSError as exc: + errors.append(str(exc)) + for parent in {destination.parent for destination in destinations}: + try: + _fsync_directory(parent) + except OSError as exc: + errors.append(str(exc)) + return errors + + def _publish_writable_files( publications: tuple[ tuple[Path, Path, tuple[int, int, int, int, str]], ... ], ) -> None: """Atomically publish a set of staged files, rolling back the whole set.""" - destinations = tuple( - destination for _source, destination, _expected in publications - ) + destinations = tuple(item[1] for item in publications) if len(set(destinations)) != len(destinations): raise RuntimeError("writable output publication has duplicate destinations") prepared: list[tuple[Path, Path, Path]] = [] backups: list[tuple[Path, Path]] = [] installed: set[Path] = set() try: - for source, destination, expected in publications: - if _writable_file_identity(destination) != expected: - raise RuntimeError("writable output changed before publication") - source_identity = _writable_file_identity(source) - if source_identity[1:4] != expected[1:4]: - raise RuntimeError("staged writable output metadata changed") - descriptor, temporary_name = tempfile.mkstemp( - prefix=".pdd-publish-", dir=destination.parent - ) - temporary = Path(temporary_name) - try: - with source.open("rb") as source_stream, os.fdopen( - descriptor, "wb" - ) as destination_stream: - shutil.copyfileobj(source_stream, destination_stream) - destination_stream.flush() - os.fchmod(destination_stream.fileno(), expected[1]) - os.fchown(destination_stream.fileno(), expected[2], expected[3]) - os.fsync(destination_stream.fileno()) - except BaseException: - temporary.unlink(missing_ok=True) - raise - prepared.append((temporary, destination, source)) - replacement_identity = _writable_file_identity(temporary) - if replacement_identity != ( - source_identity[0], expected[1], expected[2], expected[3], - source_identity[4], - ): - raise RuntimeError("staged writable output replacement is invalid") - for _temporary, destination, _source in prepared: - descriptor, backup_name = tempfile.mkstemp( - prefix=".pdd-rollback-", dir=destination.parent - ) - os.close(descriptor) - backup = Path(backup_name) - backup.unlink() - os.link(destination, backup, follow_symlinks=False) - backups.append((backup, destination)) - expected = next( - identity for _staged, item, identity in publications - if item == destination - ) - if _writable_file_identity(backup) != expected: - raise RuntimeError("writable output changed before rollback snapshot") + _prepare_writable_files(publications, prepared) + _snapshot_writable_files(publications, backups) for temporary, destination, _source in prepared: os.replace(temporary, destination) installed.add(destination) - for _temporary, destination, source in prepared: - published = _writable_file_identity(destination) - source_identity = _writable_file_identity(source) - if published[0] != source_identity[0] or published[4] != source_identity[4]: - raise RuntimeError("published writable output verification failed") + _verify_published_files(prepared) for parent in {destination.parent for destination in destinations}: _fsync_directory(parent) except BaseException as exc: - rollback_errors = [] - for backup, destination in reversed(backups): - try: - if destination in installed: - os.replace(backup, destination) - else: - backup.unlink(missing_ok=True) - except OSError as rollback_exc: - rollback_errors.append(str(rollback_exc)) - for temporary, _destination, _source in prepared: - try: - temporary.unlink(missing_ok=True) - except OSError as cleanup_exc: - rollback_errors.append(str(cleanup_exc)) - for parent in {destination.parent for destination in destinations}: - try: - _fsync_directory(parent) - except OSError as rollback_exc: - rollback_errors.append(str(rollback_exc)) - if rollback_errors: + errors = _rollback_writable_files( + destinations, prepared, backups, installed + ) + if errors: raise RuntimeError("writable output publication rollback failed") from exc raise RuntimeError("writable output publication failed") from exc for backup, _destination in backups: @@ -389,16 +422,18 @@ def _staged_bwrap( staging_targets = tuple( control_directory / "binds" / str(index) for index in range(len(sources)) ) - publication_source = "\n\n".join( - inspect.getsource(function) for function in ( - _writable_file_identity, _fsync_directory, _publish_writable_files, - ) - ) helper = "\n".join(( "from __future__ import annotations", "import hashlib,json,os,pathlib,shutil,stat,subprocess,sys,tempfile,time", "from pathlib import Path", - publication_source, + "\n\n".join( + inspect.getsource(function) for function in ( + _writable_file_digest, _writable_file_identity, _fsync_directory, + _prepare_writable_files, _snapshot_writable_files, + _verify_published_files, _rollback_writable_files, + _publish_writable_files, + ) + ), "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[4])]", "writable_specs=json.loads(sys.argv[5]); publish_indexes=json.loads(sys.argv[6])", From fccb9438b1617b968ea031fa4f4ca19df2d46cb1 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 22:38:15 -0700 Subject: [PATCH 080/233] test(sync): require private results and full Node trust --- .github/workflows/unit-tests.yml | 2 +- tests/test_sync_core_runner.py | 20 ++- tests/test_sync_core_runner_playwright.py | 67 ++++++++ tests/test_sync_core_supervisor.py | 178 ++++++---------------- 4 files changed, 125 insertions(+), 142 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 8dc2cd223a..ecd6543855 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -212,7 +212,7 @@ jobs: ) assert result.returncode == 0, result.stderr assert not surviving - assert (root / "scratch" / "ok").read_text() == "ok" + assert not (root / "scratch" / "ok").exists() assert not (root / "outside").exists() PY diff --git a/tests/test_sync_core_runner.py b/tests/test_sync_core_runner.py index 6a1a3f252c..63b0549466 100644 --- a/tests/test_sync_core_runner.py +++ b/tests/test_sync_core_runner.py @@ -795,18 +795,18 @@ def test_candidate_pytest_module_cannot_forge_collection_or_junit_pass(tmp_path) assert not (root / "candidate-fixed-probe-loaded").exists() -def test_execution_precreates_private_junit_channel( +def test_execution_uses_private_junit_descriptor_channel( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: def supervised(command, **kwargs): del command - junit, = kwargs["writable_files"] - assert junit.is_file() - assert junit.stat().st_mode & 0o777 == 0o600 - junit.write_text( - '', - encoding="utf-8", + assert "writable_files" not in kwargs + writer = os.open(kwargs["result_fifo"], os.O_WRONLY) + os.write( + writer, + b'', ) + os.close(writer) return subprocess.CompletedProcess([], 0, "", ""), False monkeypatch.setattr(runner_module, "_managed_subprocess", supervised) @@ -816,6 +816,12 @@ def supervised(command, **kwargs): assert execution.outcome is EvidenceOutcome.PASS +def test_jest_reporter_uses_private_result_descriptor() -> None: + source = runner_module._jest_reporter_source(198) + assert "writeSync(RESULT_FD" in source + assert "PDD_TRUSTED_JEST_OUTPUT" not in source + + def test_deselected_declared_test_cannot_pass(tmp_path) -> None: content = "def test_keep(): assert True\ndef test_drop(): assert True\n" root, head = _repository(tmp_path, content) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 986ec636d0..6eda07cf1a 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -795,6 +795,73 @@ def test_playwright_checks_node_resolution_for_all_executable_closure_members( ) +@pytest.mark.parametrize("attack", ["reserved", "node_modules"]) +def test_playwright_rejects_node_trust_attacks_anywhere_in_phase_tree( + tmp_path: Path, attack: str, +) -> None: + root, _commit = _repository(tmp_path) + unrelated = root / "unrelated/deep" + unrelated.mkdir(parents=True) + if attack == "reserved": + (unrelated / "package.json").write_text( + json.dumps({"name": "@playwright/test"}), encoding="utf-8" + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "unrelated reserved package") + match = "reserved package self-reference" + else: + (unrelated / "node_modules").mkdir() + match = "candidate node_modules" + + with pytest.raises(ValueError, match=match): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@pytest.mark.parametrize("suffix", ["", ".node", ".txt"]) +def test_playwright_rejects_unsupported_executable_local_imports( + tmp_path: Path, suffix: str, +) -> None: + root, _commit = _repository(tmp_path) + imported = root / f"tests/native{suffix}" + imported.write_bytes(b"module payload") + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + f"import './native{suffix}';\n" + "test('widget works', () => {});\n", + encoding="utf-8", + ) + _git(root, "add", ".") + _git(root, "commit", "-q", "-m", "unsupported local executable") + + with pytest.raises(ValueError, match="unsupported executable|native module"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),) + ) + + +def test_playwright_full_tree_policy_covers_dynamic_product_dependencies( + tmp_path: Path, +) -> None: + root, _commit = _repository(tmp_path) + product = root / "src/dynamic.ts" + product.parent.mkdir() + product.write_text( + "export async function load() { return import('./feature'); }\n", + encoding="utf-8", + ) + (root / "src/node_modules").mkdir() + _git(root, "add", "src/dynamic.ts") + _git(root, "commit", "-q", "-m", "dynamic product dependency") + + with pytest.raises(ValueError, match="candidate node_modules"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),), + (PurePosixPath("src/dynamic.ts"),), + ) + + @pytest.mark.skipif(not shutil.which("node"), reason="requires Node module resolution") @pytest.mark.parametrize("module_type", ["commonjs", "module"]) def test_trusted_playwright_package_resolves_for_real_node_modes( diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index a5107acca2..2a36756527 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1,10 +1,10 @@ """Adversarial tests for complete protected subprocess supervision.""" import os +import inspect import json import math import shutil -import stat import subprocess import sys import time @@ -528,10 +528,8 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert "writable quota" in helper assert "copytree" in helper assert "replace_host" not in helper - assert "_publish_writable_files(publications)" in helper - assert helper.index("_publish_writable_files(publications)") < helper.index( - "result.tmp" - ) + assert "_publish_writable_files" not in helper + assert helper.index("candidate.json") < helper.index("result.tmp") assert helper.index("mount_lines=") < helper.index("ready") assert "@PDD-CGROUP@" in plan.bwrap_argv cgroup_source = plan.bwrap_argv.index("@PDD-CGROUP@") @@ -792,37 +790,13 @@ def test_immediate_detached_child_cannot_forge_checker_result_channel( completed, _surviving = run_supervised( [sys.executable, "-c", parent, child, str(result_channel)], cwd=scratch, timeout=10, env=dict(os.environ), - writable_roots=(scratch,), writable_files=(result_channel,), + writable_roots=(scratch,), ) assert completed.returncode == 0 time.sleep(0.3) assert result_channel.read_text(encoding="utf-8") == "checker-owned" -@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") -def test_only_declared_output_is_published_from_protected_tmpfs(tmp_path: Path) -> None: - scratch = tmp_path / "scratch" - scratch.mkdir() - output = tmp_path / "result.json" - output.write_text("original", encoding="utf-8") - program = ( - "import pathlib,sys; " - "pathlib.Path('ephemeral').write_text('discarded'); " - "pathlib.Path(sys.argv[1]).write_text('published')" - ) - - result, surviving = run_supervised( - [sys.executable, "-c", program, str(output)], cwd=scratch, - timeout=10, env=dict(os.environ), writable_roots=(scratch,), - writable_files=(output,), - ) - - assert result.returncode == 0, result.stderr - assert surviving is False - assert output.read_text(encoding="utf-8") == "published" - assert not (scratch / "ephemeral").exists() - - @pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") def test_output_is_bounded(tmp_path: Path) -> None: result, _surviving = run_supervised( @@ -872,118 +846,54 @@ def test_writable_accounting_errors_fail_closed( supervisor._writable_size((inaccessible,)) -def _file_state(path: Path) -> tuple[bytes, int, int, int]: - metadata = path.stat() - return ( - path.read_bytes(), stat.S_IMODE(metadata.st_mode), - metadata.st_uid, metadata.st_gid, - ) +def test_supervisor_has_no_host_output_publication_api() -> None: + assert "writable_files" not in inspect.signature(run_supervised).parameters + assert not hasattr(supervisor, "_publish_writable_files") -def test_declared_writable_files_publish_without_copying_scratch(tmp_path: Path) -> None: - scratch = tmp_path / "scratch" - scratch.mkdir() - (scratch / "host.txt").write_text("host scratch", encoding="utf-8") - staged_scratch = tmp_path / "staged-scratch" - staged_scratch.mkdir() - (staged_scratch / "host.txt").write_text("candidate scratch", encoding="utf-8") - output = tmp_path / "result.json" - output.write_text("original", encoding="utf-8") - output.chmod(0o640) - staged_output = tmp_path / "staged-result.json" - staged_output.write_text("published", encoding="utf-8") - staged_output.chmod(0o640) - expected = supervisor._writable_file_identity(output) - - supervisor._publish_writable_files(((staged_output, output, expected),)) - - assert output.read_text(encoding="utf-8") == "published" - assert stat.S_IMODE(output.stat().st_mode) == 0o640 - assert (scratch / "host.txt").read_text(encoding="utf-8") == "host scratch" - - -@pytest.mark.parametrize("failure", ["copy", "fsync", "replace"]) -def test_writable_file_publication_rolls_back_all_outputs( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str, +def test_candidate_deadline_stops_before_trusted_postprocessing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - destinations = (tmp_path / "first.json", tmp_path / "second.json") - staged = (tmp_path / "staged-first.json", tmp_path / "staged-second.json") - for index, path in enumerate(destinations): - path.write_text(f"original-{index}", encoding="utf-8") - path.chmod(0o640 + index) - for index, path in enumerate(staged): - path.write_text(f"replacement-{index}", encoding="utf-8") - path.chmod(0o640 + index) - before = tuple(_file_state(path) for path in destinations) - publication = tuple( - (source, destination, supervisor._writable_file_identity(destination)) - for source, destination in zip(staged, destinations) - ) - - if failure == "copy": - original = shutil.copyfileobj - calls = 0 - - def fail_copy(*args, **kwargs): - nonlocal calls - calls += 1 - if calls == 2: - raise OSError("injected copy failure") - return original(*args, **kwargs) - - monkeypatch.setattr(supervisor.shutil, "copyfileobj", fail_copy) - elif failure == "fsync": - original = os.fsync - calls = 0 - - def fail_fsync(descriptor): - nonlocal calls - calls += 1 - if calls == 2: - raise OSError("injected fsync failure") - return original(descriptor) - - monkeypatch.setattr(supervisor.os, "fsync", fail_fsync) - else: - original = os.replace - calls = 0 - - def fail_replace(source, destination): - nonlocal calls - calls += 1 - if calls == 2: - raise OSError("injected rename failure") - return original(source, destination) - - monkeypatch.setattr(supervisor.os, "replace", fail_replace) - - with pytest.raises(RuntimeError, match="publication"): - supervisor._publish_writable_files(publication) - - assert tuple(_file_state(path) for path in destinations) == before + helper = """import json,pathlib,sys,time +control=pathlib.Path(sys.argv[1]) +(control/'ready').write_text('ready',encoding='ascii') +while not (control/'start').exists(): time.sleep(.001) +(control/'candidate.json').write_text(json.dumps({'returncode':0}),encoding='ascii') +time.sleep(.2) +(control/'result.json').write_text(json.dumps({'returncode':0}),encoding='ascii') +while not (control/'finish').exists(): time.sleep(.001) +""" + cgroup = tmp_path / "cgroup" + cgroup.mkdir() + (cgroup / "memory.events").write_text("oom 0\noom_kill 0\n", encoding="ascii") + (cgroup / "pids.events").write_text("max 0\n", encoding="ascii") + def sandbox(_command, _writable_roots, **kwargs): + plan = SimpleNamespace( + unit_name="pdd-validator-00000000000000000000000000000000.scope", + tools=SimpleNamespace(), + ) + return [sys.executable, "-c", helper, str(kwargs["control_directory"])], plan -@pytest.mark.parametrize("duplicate", [True, False]) -def test_writable_files_reject_duplicates_and_root_overlap( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, duplicate: bool, -) -> None: - monkeypatch.setattr(sys, "platform", "linux") - _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr(supervisor, "_sandbox_command", sandbox) + monkeypatch.setattr(supervisor, "_prepare_staging", lambda _plan: None) monkeypatch.setattr( - supervisor.subprocess, "run", - lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + supervisor, "_probe_scope", + lambda _plan, _limits: (cgroup, {"oom": 0, "oom_kill": 0}, {"max": 0}), + ) + monkeypatch.setattr(supervisor, "_stop_scope", lambda *_args: None) + monkeypatch.setattr(supervisor, "_cleanup_staging", lambda _plan: None) + monkeypatch.setattr( + supervisor, "_scope_properties", lambda *_args: {"Result": "success"} ) - monkeypatch.setattr(supervisor, "released_runtime_closure_paths", lambda: ()) - scratch = tmp_path / "scratch" - scratch.mkdir() - output = scratch / "result.json" - output.write_text("original", encoding="utf-8") - files = (output, output) if duplicate else (output,) - with pytest.raises(RuntimeError, match="duplicate|overlap"): - _sandbox_command( - [sys.executable, "-c", "pass"], (scratch,), writable_files=files, - ) + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 0, result.stderr + assert surviving is False def test_macos_fails_closed_without_kernel_lifetime_containment( From ef02c2fd3d30d0ce0979ef2cf441e35d35fca497 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 22:53:31 -0700 Subject: [PATCH 081/233] fix(sync): isolate results and enforce full Node trust --- pdd/sync_core/runner.py | 208 ++++++++++------ pdd/sync_core/supervisor.py | 284 ++++------------------ tests/test_sync_core_runner_playwright.py | 8 +- 3 files changed, 173 insertions(+), 327 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 4b116121b2..04c2740ba8 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -1527,6 +1527,7 @@ def vitest_validator_config_digest( _PLAYWRIGHT_RESERVED_PACKAGES = frozenset({ "@playwright/test", "playwright", "playwright-core", }) +_PLAYWRIGHT_EXECUTABLE_SUFFIXES = frozenset(_JAVASCRIPT_SUFFIXES) - {".json"} def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: @@ -1552,51 +1553,42 @@ def _playwright_config(root: Path, ref: str) -> tuple[PurePosixPath, bytes]: return found[0], content -def _playwright_node_trust_manifests( - root: Path, ref: str, executable_paths: set[PurePosixPath], -) -> set[PurePosixPath]: - """Validate every Node package scope and search path used by the closure.""" +def _playwright_tree_trust_manifests(root: Path, ref: str) -> set[PurePosixPath]: + """Apply conservative Node trust policy to the complete exact phase tree.""" manifests: set[PurePosixPath] = set() - destination_error = _playwright_node_modules_destination_error(root) - if destination_error is not None: - raise ValueError(destination_error) - for source_path in executable_paths: - directory = source_path.parent - while True: - manifest = directory / "package.json" - raw = read_git_blob(root, ref, manifest) - if raw is not None: - regular = read_git_regular_blob(root, ref, manifest) - if regular is None: - raise ValueError( - f"Playwright package scope must be a regular file: {manifest}" - ) - try: - package = json.loads(regular.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ValueError( - f"Playwright package scope is invalid: {manifest}" - ) from exc - if not isinstance(package, dict): - raise ValueError( - f"Playwright package scope must be an object: {manifest}" - ) - if package.get("name") in _PLAYWRIGHT_RESERVED_PACKAGES: - raise ValueError( - "Playwright closure uses a reserved package self-reference: " - + manifest.as_posix() - ) - manifests.add(manifest) - if directory != PurePosixPath("."): - node_modules = root / directory / "node_modules" - if os.path.lexists(node_modules): - raise ValueError( - "Playwright closure has a candidate Node resolution path: " - + (directory / "node_modules").as_posix() - ) - if directory == PurePosixPath("."): - break - directory = directory.parent + actual_node_modules = next(root.rglob("node_modules"), None) + if actual_node_modules is not None: + relative = actual_node_modules.relative_to(root).as_posix() + raise ValueError(f"candidate node_modules is forbidden in phase tree: {relative}") + listed = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", ref], cwd=root, + capture_output=True, text=True, check=True, + ) + for name in listed.stdout.splitlines(): + path = PurePosixPath(name) + if "node_modules" in path.parts: + raise ValueError( + f"candidate node_modules is forbidden in exact tree: {path}" + ) + if path.suffix == ".node": + raise ValueError(f"Playwright native module is forbidden: {path}") + if path.name != "package.json": + continue + regular = read_git_regular_blob(root, ref, path) + if regular is None: + raise ValueError(f"Playwright package scope must be a regular file: {path}") + try: + package = json.loads(regular.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"Playwright package scope is invalid: {path}") from exc + if not isinstance(package, dict): + raise ValueError(f"Playwright package scope must be an object: {path}") + if package.get("name") in _PLAYWRIGHT_RESERVED_PACKAGES: + raise ValueError( + "Playwright closure uses a reserved package self-reference: " + + path.as_posix() + ) + manifests.add(path) return manifests @@ -1867,7 +1859,13 @@ def _playwright_support_closure( f"Playwright closure member must be a regular non-symlink file: {path}" ) paths.add(path) - if path.suffix in _JAVASCRIPT_SUFFIXES: + if path.suffix == ".json": + continue + if path.suffix not in _PLAYWRIGHT_EXECUTABLE_SUFFIXES: + raise ValueError( + f"Playwright local import has unsupported executable extension: {path}" + ) + if path.suffix in _PLAYWRIGHT_EXECUTABLE_SUFFIXES: imports, bare_imports, has_snapshot, resources = _playwright_source_syntax( path, source ) @@ -1917,11 +1915,7 @@ def _playwright_support_closure( pending.append((mapped, owners)) if has_snapshot: snapshot_owners.update(owners) - executable_paths = { - path for path in {config_path, *visited, *product_paths} - if path.suffix in _JAVASCRIPT_SUFFIXES - } - paths.update(_playwright_node_trust_manifests(root, ref, executable_paths)) + paths.update(_playwright_tree_trust_manifests(root, ref)) for owner in snapshot_owners: snapshot_prefix = PurePosixPath(f"{owner.as_posix()}-snapshots") listed = subprocess.run( @@ -2515,13 +2509,14 @@ def _config_loads_plugin(root: Path, ref: str) -> bool: def _managed_subprocess( command: list[str], *, cwd: Path, timeout: int, env: dict[str, str], - writable_roots: tuple[Path, ...], writable_files: tuple[Path, ...] = (), - readable_roots: tuple[Path, ...] = (), + writable_roots: tuple[Path, ...], readable_roots: tuple[Path, ...] = (), + result_fifo: Path | None = None, result_fd: int = 198, ) -> tuple[subprocess.CompletedProcess[str], bool]: """Run an untrusted command in a networkless sandbox and reap its group.""" return run_supervised(command, cwd=cwd, timeout=timeout, env=env, - writable_roots=writable_roots, writable_files=writable_files, - readable_roots=readable_roots) + writable_roots=writable_roots, + readable_roots=readable_roots, + result_fifo=result_fifo, result_fd=result_fd) def runner_identity_digest( @@ -3581,11 +3576,11 @@ def _changed_paths( def _junit_outcome( - path: Path, returncode: int, output: str, minimum_tests: int + content: bytes, returncode: int, output: str, minimum_tests: int ) -> tuple[EvidenceOutcome, str]: try: - root = ET.parse(path).getroot() - except (ET.ParseError, OSError) as exc: + root = ET.fromstring(content) + except ET.ParseError as exc: return EvidenceOutcome.COLLECTION_ERROR, f"cannot parse JUnit result: {exc}" suites = [root] if root.tag == "testsuite" else list(root.iter("testsuite")) totals = { @@ -3818,27 +3813,30 @@ def _vitest_command_error(root: Path, command: tuple[str, ...]) -> str | None: return _protected_command_error(root, command) -_JEST_REPORTER = """class PddTrustedReporter { - constructor() { this.tests = []; } - onTestResult(test, result) { - for (const assertion of result.testResults || []) { +def _jest_reporter_source(result_fd: int) -> str: + """Return a checker-owned Jest reporter for the private result descriptor.""" + return f"""const RESULT_FD = {result_fd}; +class PddTrustedReporter {{ + constructor() {{ this.tests = []; }} + onTestResult(test, result) {{ + for (const assertion of result.testResults || []) {{ const names = [...(assertion.ancestorTitles || []), assertion.title].join(' > '); - this.tests.push({identity: require('path').relative(process.cwd(), test.path) + '::' + names, status: assertion.status}); - } - } - onRunComplete() { require('fs').writeFileSync(process.env.PDD_TRUSTED_JEST_OUTPUT, JSON.stringify({tests: this.tests})); } -} + this.tests.push({{identity: require('path').relative(process.cwd(), test.path) + '::' + names, status: assertion.status}}); + }} + }} + onRunComplete() {{ require('fs').writeSync(RESULT_FD, JSON.stringify({{tests: this.tests}})); }} +}} module.exports = PddTrustedReporter; """ def _jest_result( - output: Path, returncode: int, expected: tuple[str, ...] | None + output: bytes, returncode: int, expected: tuple[str, ...] | None ) -> tuple[EvidenceOutcome, str, tuple[str, ...]]: # pylint: disable=too-many-return-statements """Validate trusted reporter data and normalize every non-pass state.""" try: - payload = json.loads(output.read_text(encoding="utf-8")) + payload = json.loads(output.decode("utf-8")) tests = payload["tests"] if not isinstance(tests, list) or not all( isinstance(item, dict) @@ -3847,7 +3845,7 @@ def _jest_result( for item in tests ): raise ValueError("malformed Jest reporter payload") - except (OSError, ValueError, json.JSONDecodeError, KeyError): + except (UnicodeDecodeError, ValueError, json.JSONDecodeError, KeyError): return EvidenceOutcome.COLLECTION_ERROR, "trusted Jest reporter produced malformed JSON", () identities = tuple(sorted(item["identity"] for item in tests)) if not identities: @@ -3926,9 +3924,19 @@ def _run_jest( home = scratch / "home" home.mkdir(mode=0o700, parents=True) reporter = controllers / "reporter.cjs" - output = controllers / "results.json" - reporter.write_text(_JEST_REPORTER, encoding="utf-8") - output.touch(mode=0o600) + result_directory = temporary / f"channel-{os.urandom(16).hex()}" + result_directory.mkdir(mode=0o700) + result_fifo = result_directory / "result.fifo" + os.mkfifo(result_fifo, mode=0o600) + read_fd = os.open(result_fifo, os.O_RDONLY | os.O_NONBLOCK) + drain_finished = threading.Event() + drained: dict[str, object] = {} + drain_thread = threading.Thread( + target=_drain_result_pipe, args=(read_fd, drain_finished, drained), daemon=True + ) + drain_thread.start() + result_fd = 198 + reporter.write_text(_jest_reporter_source(result_fd), encoding="utf-8") command = [ *command_prefix, *(path.as_posix() for path in paths), @@ -3944,10 +3952,8 @@ def _run_jest( command, cwd=root, timeout=timeout_seconds, - env=_jest_environment(home) - | {"PDD_TRUSTED_JEST_OUTPUT": str(output)}, + env=_jest_environment(home), writable_roots=(scratch,), - writable_files=(output,), readable_roots=( root, reporter, @@ -3958,14 +3964,22 @@ def _run_jest( ), *_jest_toolchain_roots(command_prefix), ), + result_fifo=result_fifo, + result_fd=result_fd, ) except OSError as exc: + drain_finished.set() + drain_thread.join(timeout=1) + os.close(read_fd) return RunnerExecution( "jest", EvidenceOutcome.ERROR, digest, f"Jest process launch failed: {exc}", ), () + drain_finished.set() + drain_thread.join(timeout=2) + os.close(read_fd) if surviving: return RunnerExecution( "jest", @@ -3985,6 +3999,14 @@ def _run_jest( return RunnerExecution( "jest", EvidenceOutcome.ERROR, digest, detail ), () + if "error" in drained or drained.get("overflow"): + return RunnerExecution( + "jest", EvidenceOutcome.COLLECTION_ERROR, digest, + "Jest private result transport failed", + ), () + output = drained.get("data", b"") + if not isinstance(output, bytes): + output = b"" outcome, detail, identities = _jest_result(output, result.returncode, expected) return RunnerExecution("jest", outcome, digest, detail), identities @@ -4975,17 +4997,31 @@ def _run_test_node( controllers.mkdir(mode=0o700) home = temporary / "scratch" / "home" home.mkdir(mode=0o700, parents=True) - junit = controllers / f"result-{os.urandom(16).hex()}.xml" - junit.touch(mode=0o600) + result_directory = temporary / f"channel-{os.urandom(16).hex()}" + result_directory.mkdir(mode=0o700) + result_fifo = result_directory / "result.fifo" + os.mkfifo(result_fifo, mode=0o600) + read_fd = os.open(result_fifo, os.O_RDONLY | os.O_NONBLOCK) + drain_finished = threading.Event() + drained: dict[str, object] = {} + drain_thread = threading.Thread( + target=_drain_result_pipe, args=(read_fd, drain_finished, drained), daemon=True + ) + drain_thread.start() + result_fd = 198 worker = _trusted_execution_runner( - controllers, root, [*pytest_args, f"--junitxml={junit}"] + controllers, root, + [*pytest_args, f"--junitxml=/proc/self/fd/{result_fd}"], ) result, surviving = _managed_subprocess( [sys.executable, "-P", str(worker)], cwd=controllers, timeout=timeout_seconds, env=_pytest_environment(home), - writable_roots=(home.parent,), - writable_files=(junit,), readable_roots=(root,), + writable_roots=(home.parent,), readable_roots=(root,), + result_fifo=result_fifo, result_fd=result_fd, ) + drain_finished.set() + drain_thread.join(timeout=2) + os.close(read_fd) if result.returncode == 124: return RunnerExecution( node_id, @@ -4999,7 +5035,17 @@ def _run_test_node( "validator left a surviving process-group descendant", ) output = result.stdout + "\n" + result.stderr - outcome, detail = _junit_outcome(junit, result.returncode, output, 1) + private_output = drained.get("data", b"") + if "error" in drained or drained.get("overflow") or not isinstance( + private_output, bytes + ): + return RunnerExecution( + node_id, EvidenceOutcome.COLLECTION_ERROR, command_digest, + "pytest private result transport failed", + ) + outcome, detail = _junit_outcome( + private_output, result.returncode, output, 1 + ) return RunnerExecution(node_id, outcome, command_digest, detail) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 326dd40f47..e4122c0b1a 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -3,8 +3,6 @@ from __future__ import annotations -import hashlib -import inspect import json import os import re @@ -29,6 +27,7 @@ _SUPERVISOR_EXECUTABLE = Path(sys.executable) _TRUSTED_ROOT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" _SCOPE_PATTERN = re.compile(r"pdd-validator-[0-9a-f]{32}\.scope") +_TRUSTED_POSTPROCESS_SECONDS = 5 @dataclass(frozen=True) @@ -244,175 +243,9 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: str(limits.max_cpu_seconds), str(limits.max_writable_bytes), "256", *command] -def _writable_file_digest(path: Path) -> str: - """Hash one regular file through a no-follow descriptor.""" - descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) - with os.fdopen(descriptor, "rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - -def _writable_file_identity(path: Path) -> tuple[int, int, int, int, str]: - """Return a no-follow identity for one publishable regular file.""" - try: - metadata = path.lstat() - if not stat.S_ISREG(metadata.st_mode) or path.is_symlink(): - raise RuntimeError("publishable writable output must be a regular file") - digest = _writable_file_digest(path) - current = path.lstat() - except OSError as exc: - raise RuntimeError("publishable writable output is unavailable") from exc - if (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns) != ( - current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns, - ): - raise RuntimeError("publishable writable output changed during validation") - return ( - current.st_size, stat.S_IMODE(current.st_mode), current.st_uid, - current.st_gid, digest, - ) - - -def _fsync_directory(directory: Path) -> None: - """Persist directory entry changes on a publication destination filesystem.""" - descriptor = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - - -def _prepare_writable_files( - publications: tuple[ - tuple[Path, Path, tuple[int, int, int, int, str]], ... - ], - prepared: list[tuple[Path, Path, Path]], -) -> None: - """Copy and fsync every replacement before any destination changes.""" - for source, destination, expected in publications: - if _writable_file_identity(destination) != expected: - raise RuntimeError("writable output changed before publication") - source_identity = _writable_file_identity(source) - if source_identity[1:4] != expected[1:4]: - raise RuntimeError("staged writable output metadata changed") - descriptor, name = tempfile.mkstemp( - prefix=".pdd-publish-", dir=destination.parent - ) - temporary = Path(name) - try: - with source.open("rb") as source_stream, os.fdopen( - descriptor, "wb" - ) as destination_stream: - shutil.copyfileobj(source_stream, destination_stream) - destination_stream.flush() - os.fchmod(destination_stream.fileno(), expected[1]) - os.fchown(destination_stream.fileno(), expected[2], expected[3]) - os.fsync(destination_stream.fileno()) - except BaseException: - temporary.unlink(missing_ok=True) - raise - prepared.append((temporary, destination, source)) - replacement = _writable_file_identity(temporary) - wanted = ( - source_identity[0], expected[1], expected[2], expected[3], - source_identity[4], - ) - if replacement != wanted: - raise RuntimeError("staged writable output replacement is invalid") - - -def _snapshot_writable_files( - publications: tuple[ - tuple[Path, Path, tuple[int, int, int, int, str]], ... - ], backups: list[tuple[Path, Path]], -) -> None: - """Create same-filesystem rollback links for every original output.""" - expected_by_destination = { - destination: expected for _source, destination, expected in publications - } - for destination, expected in expected_by_destination.items(): - descriptor, name = tempfile.mkstemp( - prefix=".pdd-rollback-", dir=destination.parent - ) - os.close(descriptor) - backup = Path(name) - backup.unlink() - os.link(destination, backup, follow_symlinks=False) - backups.append((backup, destination)) - if _writable_file_identity(backup) != expected: - raise RuntimeError("writable output changed before rollback snapshot") - - -def _verify_published_files(prepared: list[tuple[Path, Path, Path]]) -> None: - """Verify published bytes and sizes against immutable staged files.""" - for _temporary, destination, source in prepared: - published = _writable_file_identity(destination) - staged = _writable_file_identity(source) - if published[0] != staged[0] or published[4] != staged[4]: - raise RuntimeError("published writable output verification failed") - - -def _rollback_writable_files( - destinations: tuple[Path, ...], prepared: list[tuple[Path, Path, Path]], - backups: list[tuple[Path, Path]], installed: set[Path], -) -> list[str]: - """Restore every original and remove unpublished transaction files.""" - errors = [] - for backup, destination in reversed(backups): - try: - if destination in installed: - os.replace(backup, destination) - else: - backup.unlink(missing_ok=True) - except OSError as exc: - errors.append(str(exc)) - for temporary, _destination, _source in prepared: - try: - temporary.unlink(missing_ok=True) - except OSError as exc: - errors.append(str(exc)) - for parent in {destination.parent for destination in destinations}: - try: - _fsync_directory(parent) - except OSError as exc: - errors.append(str(exc)) - return errors - - -def _publish_writable_files( - publications: tuple[ - tuple[Path, Path, tuple[int, int, int, int, str]], ... - ], -) -> None: - """Atomically publish a set of staged files, rolling back the whole set.""" - destinations = tuple(item[1] for item in publications) - if len(set(destinations)) != len(destinations): - raise RuntimeError("writable output publication has duplicate destinations") - prepared: list[tuple[Path, Path, Path]] = [] - backups: list[tuple[Path, Path]] = [] - installed: set[Path] = set() - try: - _prepare_writable_files(publications, prepared) - _snapshot_writable_files(publications, backups) - for temporary, destination, _source in prepared: - os.replace(temporary, destination) - installed.add(destination) - _verify_published_files(prepared) - for parent in {destination.parent for destination in destinations}: - _fsync_directory(parent) - except BaseException as exc: - errors = _rollback_writable_files( - destinations, prepared, backups, installed - ) - if errors: - raise RuntimeError("writable output publication rollback failed") from exc - raise RuntimeError("writable output publication failed") from exc - for backup, _destination in backups: - backup.unlink() - - def _staged_bwrap( argv: list[str], sources: list[Path], path_tokens: list[str], *, writable_roots: tuple[Path, ...], writable_specs: list[tuple[str, int, str]], - publish_indexes: tuple[int, ...], result_fifo: Path | None, unit_name: str, control_directory: Path, limits: SupervisorLimits, tools: _TrustedTools, @@ -423,21 +256,10 @@ def _staged_bwrap( control_directory / "binds" / str(index) for index in range(len(sources)) ) helper = "\n".join(( - "from __future__ import annotations", - "import hashlib,json,os,pathlib,shutil,stat,subprocess,sys,tempfile,time", - "from pathlib import Path", - "\n\n".join( - inspect.getsource(function) for function in ( - _writable_file_digest, _writable_file_identity, _fsync_directory, - _prepare_writable_files, _snapshot_writable_files, - _verify_published_files, _rollback_writable_files, - _publish_writable_files, - ) - ), + "import json,os,pathlib,shutil,stat,subprocess,sys,time", "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[4])]", - "writable_specs=json.loads(sys.argv[5]); publish_indexes=json.loads(sys.argv[6])", - "result_fifo=sys.argv[7] or None", + "writable_specs=json.loads(sys.argv[5]); result_fifo=sys.argv[6] or None", "limits=json.loads(sys.argv[-1])", "path_tokens=json.loads(sys.argv[-5]); argv=json.loads(sys.argv[-4]); " "paths=json.loads(sys.argv[-3])", @@ -491,15 +313,10 @@ def _staged_bwrap( "os.statvfs(writable_target).f_frsize", " if capacity > limits['writable']+os.sysconf('SC_PAGE_SIZE'): " "raise RuntimeError('writable tmpfs size probe failed')", - " publish_expected={index:_writable_file_identity(writable_roots[index]) " - "for index in publish_indexes}", " writable_paths=[]", " for index,source in enumerate(writable_roots):", " target=writable_target/str(index); copy_owned(source,target); " "writable_paths.append(target)", - " for index in publish_indexes:", - " if _writable_file_identity(writable_paths[index]) != " - "publish_expected[index]: raise RuntimeError('writable output staging changed')", " if sum(validate_tree(path) for path in writable_paths) > " "limits['writable']: raise RuntimeError('initial writable quota exceeded')", " for source,target in zip(paths,targets):", @@ -529,11 +346,11 @@ def _staged_bwrap( " os.execvpe(argv[0],argv,os.environ)", " _wait,status=os.waitpid(pid,0)", " result=os.waitstatus_to_exitcode(status)", + " candidate=control/'candidate.tmp'", + " candidate.write_text(json.dumps({'returncode':result}),encoding='ascii')", + " os.replace(candidate,control/'candidate.json')", " if sum(validate_tree(path) for path in writable_paths) > " "limits['writable']: raise RuntimeError('final writable quota exceeded')", - " publications=tuple((writable_paths[index],writable_roots[index]," - "publish_expected[index]) for index in publish_indexes)", - " _publish_writable_files(publications)", " temporary=control/'result.tmp'", " temporary.write_text(json.dumps({'returncode':result}),encoding='ascii')", " os.replace(temporary,control/'result.json')", @@ -563,8 +380,7 @@ def _staged_bwrap( str(_SUPERVISOR_EXECUTABLE), "-c", helper, str(control_directory), str(tools.mount), str(tools.umount), json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), - json.dumps(publish_indexes), str(result_fifo or ""), - json.dumps(path_tokens), json.dumps(argv), + str(result_fifo or ""), json.dumps(path_tokens), json.dumps(argv), json.dumps([str(path) for path in sources]), unit_name, json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, "writable": limits.max_writable_bytes}), @@ -704,41 +520,6 @@ def _writable_storage_roots(paths: tuple[Path, ...]) -> tuple[Path, ...]: return tuple(roots) -def _writable_publication_files( - paths: tuple[Path, ...], storage_roots: tuple[Path, ...], -) -> tuple[Path, ...]: - """Validate distinct regular outputs that do not overlap ephemeral storage.""" - resolved = [] - identities = [] - for path in paths: - try: - if path.is_symlink(): - raise RuntimeError("publishable writable output cannot be a symlink") - value = path.resolve(strict=True) - except OSError as exc: - raise RuntimeError("publishable writable output is unavailable") from exc - if not value.is_file(): - raise RuntimeError("publishable writable output must be a regular file") - resolved.append(value) - metadata = value.stat() - identities.append((metadata.st_dev, metadata.st_ino)) - if len(set(resolved)) != len(resolved) or len(set(identities)) != len(identities): - raise RuntimeError("publishable writable outputs contain duplicates") - root_identities = { - (metadata.st_dev, metadata.st_ino) - for root in storage_roots - if stat.S_ISREG((metadata := root.stat()).st_mode) - } - if root_identities.intersection(identities): - raise RuntimeError("publishable writable output overlaps a writable root") - if any( - path == root or path.is_relative_to(root) or root.is_relative_to(path) - for path in resolved for root in storage_roots - ): - raise RuntimeError("publishable writable output overlaps a writable root") - return tuple(resolved) - - def _root_environment() -> dict[str, str]: """Return the fixed environment used for every privileged tool invocation.""" return {"PATH": _TRUSTED_ROOT_PATH, "LANG": "C", "LC_ALL": "C"} @@ -921,7 +702,7 @@ def _cleanup_staging(plan: _ScopePlan) -> None: def _sandbox_command( command: list[str], writable_roots: tuple[Path, ...], *, cwd: Path | None = None, - writable_files: tuple[Path, ...] = (), limits: SupervisorLimits = SupervisorLimits(), + limits: SupervisorLimits = SupervisorLimits(), readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), @@ -952,12 +733,7 @@ def _sandbox_command( writable_sources = tuple( source for source, _destination in writable_bindings ) + writable_roots - ephemeral_roots = _writable_storage_roots(writable_sources) - publication_files = _writable_publication_files( - writable_files, ephemeral_roots - ) - storage_roots = (*ephemeral_roots, *publication_files) - publish_indexes = tuple(range(len(ephemeral_roots), len(storage_roots))) + storage_roots = _writable_storage_roots(writable_sources) if _writable_size(storage_roots) > limits.max_writable_bytes: raise RuntimeError("initial writable quota exceeded") argv = [str(tools.bwrap), "--unshare-ipc", "--unshare-pid", "--unshare-net", @@ -1029,8 +805,6 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: argv.extend(("--dev", "/dev")) for item in writable_roots: bind("--bind", item.resolve()) - for item in writable_files: - bind("--bind", item.resolve()) argv.extend(("--chdir", str(workdir))) drop = ( [str(tools.setpriv), "--reuid", str(os.getuid()), "--regid", str(os.getgid()), @@ -1043,8 +817,7 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: argv.extend(("--", *drop, *sandboxed)) return _staged_bwrap( argv, sources, path_tokens, writable_roots=storage_roots, - writable_specs=writable_specs, publish_indexes=publish_indexes, - result_fifo=result_fifo, + writable_specs=writable_specs, result_fifo=result_fifo, unit_name=unit_name or _scope_unit_name(), control_directory=control_directory or ( Path(tempfile.gettempdir()) / f"pdd-scope-{uuid.uuid4().hex}" @@ -1056,7 +829,7 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: def run_supervised( command: list[str], *, cwd: Path, timeout: int, env: dict[str, str], - writable_roots: tuple[Path, ...], writable_files: tuple[Path, ...] = (), + writable_roots: tuple[Path, ...], limits: SupervisorLimits = SupervisorLimits(), readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), @@ -1162,7 +935,7 @@ def record_events() -> None: control = Path(control_value) try: argv, plan = _sandbox_command( - command, writable_roots, cwd=cwd, writable_files=writable_files, + command, writable_roots, cwd=cwd, limits=limits, readable_roots=readable_roots, readable_bindings=readable_bindings, writable_bindings=writable_bindings, @@ -1231,7 +1004,7 @@ def track_process_tree() -> None: cgroup, memory_before, pids_before = _probe_scope(plan, limits) (control / "start").write_text("start", encoding="ascii") phase = "candidate-execution" - while not (control / "result.json").exists(): + while not (control / "candidate.json").exists(): if process.poll() is not None: break if time.monotonic() >= deadline: @@ -1240,12 +1013,35 @@ def track_process_tree() -> None: if fail_for_limit(): break time.sleep(.01) + if (control / "candidate.json").exists(): + payload = json.loads( + (control / "candidate.json").read_text(encoding="ascii") + ) + candidate_returncode = int(payload["returncode"]) + phase = "trusted-postprocessing" + postprocess_deadline = ( + time.monotonic() + _TRUSTED_POSTPROCESS_SECONDS + ) + while not (control / "result.json").exists(): + if process.poll() is not None: + break + if time.monotonic() >= postprocess_deadline: + failed_closed = True + add_diagnostic( + "protected supervisor trusted postprocessing " + "did not finish\n" + ) + break + if fail_for_limit(): + break + time.sleep(.01) if (control / "result.json").exists(): phase = "result-handoff" payload = json.loads( (control / "result.json").read_text(encoding="ascii") ) - candidate_returncode = int(payload["returncode"]) + if int(payload["returncode"]) != candidate_returncode: + raise RuntimeError("candidate result changed during handoff") fail_for_limit() record_events() if candidate_returncode is None and not timed_out and not failed_closed: @@ -1254,6 +1050,12 @@ def track_process_tree() -> None: "protected supervisor phase=candidate-execution: " "scope produced no candidate result\n" ) + elif not (control / "result.json").exists() and not failed_closed: + failed_closed = True + add_diagnostic( + "protected supervisor phase=trusted-postprocessing: " + "scope produced no validated result\n" + ) if process.poll() is None and not timed_out and not failed_closed: (control / "finish").write_text("finish", encoding="ascii") try: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 6eda07cf1a..b92387eaf6 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -789,7 +789,7 @@ def test_playwright_checks_node_resolution_for_all_executable_closure_members( _git(root, "commit", "-q", "-m", "nested executable closure member") (source.parent / "node_modules").mkdir() - with pytest.raises(ValueError, match="candidate Node resolution path"): + with pytest.raises(ValueError, match="candidate node_modules"): playwright_validator_config_digest( root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),), products ) @@ -2123,10 +2123,8 @@ def test_default_candidate_node_modules_playwright_is_not_trusted(tmp_path: Path encoding="utf-8", ) - _envelope, executions = _run_default_playwright(root, commit, commit) - - assert executions[0].outcome is EvidenceOutcome.ERROR - assert "candidate node_modules" in executions[0].detail + with pytest.raises(ValueError, match="candidate node_modules"): + _run_default_playwright(root, commit, commit) def test_playwright_result_resolves_relative_spec_file_from_runner_root( From 1f82a4eefef4b674621ffb0a2638d8781fc19da6 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 22:53:40 -0700 Subject: [PATCH 082/233] test(sync): align transient helper contract --- tests/test_sync_core_supervisor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 2a36756527..abbf95ec48 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -211,8 +211,8 @@ def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( assert bwrap[destination_index - 2] == "--bind" assert destination_index < bwrap.index("--ro-bind") placeholder = bwrap[destination_index - 1] - writable_roots = json.loads(argv[-9]) - writable_specs = json.loads(argv[-8]) + writable_roots = json.loads(argv[-8]) + writable_specs = json.loads(argv[-7]) token, root_index, relative = next( spec for spec in writable_specs if spec[0] == placeholder ) From e51ede4c30a3efbf2e0f7e370908440edeb0afa8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 23:32:21 -0700 Subject: [PATCH 083/233] test(sync): define verification assurance boundary --- tests/test_sync_core_runner.py | 79 +++++++++++ tests/test_sync_core_runner_jest.py | 40 +++--- tests/test_sync_core_runner_vitest.py | 4 +- tests/test_sync_core_verification_profiles.py | 124 ++++++++++++++---- 4 files changed, 204 insertions(+), 43 deletions(-) diff --git a/tests/test_sync_core_runner.py b/tests/test_sync_core_runner.py index 63b0549466..5670815867 100644 --- a/tests/test_sync_core_runner.py +++ b/tests/test_sync_core_runner.py @@ -13,6 +13,7 @@ import pdd.sync_core.runner as runner_module from pdd.sync_core import ( + AssuranceLevel, AttestationSigner, AttestationIssue, EvidenceOutcome, @@ -94,6 +95,84 @@ def _run( ) +@pytest.mark.parametrize("validator_id", ["pytest", "jest", "vitest", "playwright"]) +def test_in_process_adapter_cannot_satisfy_isolated_black_box_assurance( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, validator_id: str +) -> None: + root, commit = _repository(tmp_path, "def test_widget(): assert True\n") + obligation = VerificationObligation( + validator_id, + "test", + validator_id, + "config-v1", + ("REQ-1",), + (PurePosixPath("tests/test_widget.py"),), + ) + profile = VerificationProfile( + UNIT, + (obligation,), + ("REQ-1",), + "profile-v1", + AssuranceLevel.ISOLATED_BLACK_BOX, + ) + + def must_not_execute(*_args, **_kwargs): + raise AssertionError("unsupported in-process adapter executed") + + monkeypatch.setattr(runner_module, "run_obligation", must_not_execute) + monkeypatch.setattr(runner_module, "runner_identity_digest", lambda *_args, **_kwargs: "runner-v1") + envelope, executions = run_profile( + root, + profile, + RunBinding("snapshot-v1", commit, commit), + AttestationIssue( + AttestationSigner("trusted-ci", b"v" * 32), + "attestation-1", + "nonce-1", + datetime(2026, 7, 10, 12, 0, tzinfo=timezone.utc), + ), + ) + + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "isolated_black_box" in executions[0].detail + assert envelope.results[0].outcome is EvidenceOutcome.ERROR + + +def test_standard_framework_assurance_preserves_adapter_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, commit = _repository(tmp_path, "def test_widget(): assert True\n") + profile = _profile(root, commit) + observed: list[str] = [] + + def passing(_root, obligation, **_kwargs): + observed.append(obligation.validator_id) + return runner_module.RunnerExecution( + obligation.obligation_id, + EvidenceOutcome.PASS, + "command-v1", + "framework observation passed", + ) + + monkeypatch.setattr(runner_module, "run_obligation", passing) + monkeypatch.setattr(runner_module, "runner_identity_digest", lambda *_args, **_kwargs: "runner-v1") + envelope, executions = run_profile( + root, + profile, + RunBinding("snapshot-v1", commit, commit), + AttestationIssue( + AttestationSigner("trusted-ci", b"v" * 32), + "attestation-1", + "nonce-1", + datetime(2026, 7, 10, 12, 0, tzinfo=timezone.utc), + ), + ) + + assert observed == ["pytest"] + assert executions[0].outcome is EvidenceOutcome.PASS + assert envelope.results[0].outcome is EvidenceOutcome.PASS + + class _BarrierWithArrivalSignal: """Expose when a worker has entered a two-party test barrier.""" diff --git a/tests/test_sync_core_runner_jest.py b/tests/test_sync_core_runner_jest.py index 39d007b4fc..1398f2be1f 100644 --- a/tests/test_sync_core_runner_jest.py +++ b/tests/test_sync_core_runner_jest.py @@ -1,6 +1,7 @@ """Contract tests for the fail-closed trusted Jest adapter.""" import json +import os import subprocess import sys from datetime import datetime, timezone @@ -31,19 +32,30 @@ @pytest.fixture(autouse=True) def _local_managed_subprocess(monkeypatch: pytest.MonkeyPatch) -> None: """Execute adapter unit tests without requiring a host namespace sandbox.""" - def execute(command, *, cwd, timeout, env, **_limits): + def execute( + command, *, cwd, timeout, env, result_fifo=None, result_fd=198, **_limits + ): + write_fd = os.open(result_fifo, os.O_WRONLY) if result_fifo else None + if write_fd is not None: + os.dup2(write_fd, result_fd) + if write_fd != result_fd: + os.close(write_fd) try: result = subprocess.run( command, cwd=cwd, timeout=timeout, env=env, + pass_fds=((result_fd,) if result_fifo else ()), capture_output=True, text=True, check=False, ) except subprocess.TimeoutExpired: result = subprocess.CompletedProcess(command, 124, "", "timeout") + finally: + if result_fifo: + os.close(result_fd) return result, False monkeypatch.setattr("pdd.sync_core.runner._managed_subprocess", execute) @@ -62,12 +74,12 @@ def _fake_jest(tmp_path: Path) -> Path: "root = pathlib.Path.cwd()\n" "mode = (root / 'source.js').read_text().strip() if (root / 'source.js').exists() else 'pass'\n" "if mode == 'timeout': time.sleep(5)\n" - "if mode == 'malformed': pathlib.Path(os.environ['PDD_TRUSTED_JEST_OUTPUT']).write_text('{')\n" + "if mode == 'malformed': os.write(198, b'{')\n" "else:\n" " tests = [] if mode == 'zero' else [{'identity': 'tests/widget.test.js::widget works', 'status': {'fail': 'failed', 'skip': 'pending', 'todo': 'todo'}.get(mode, 'passed')}]\n" " if mode == 'mismatch': tests = [{'identity': 'tests/widget.test.js::other', 'status': 'passed'}]\n" " if mode == 'candidate': tests.append({'identity': 'tests/widget.test.js::candidate only', 'status': 'passed'})\n" - " pathlib.Path(os.environ['PDD_TRUSTED_JEST_OUTPUT']).write_text(json.dumps({'tests': tests}))\n" + " os.write(198, json.dumps({'tests': tests}).encode())\n" ) return runner @@ -661,12 +673,9 @@ def test_jest_uses_managed_containment_and_cleans_scratch( def inspect_managed(command, **kwargs): environment = kwargs["env"] - output = Path(environment["PDD_TRUSTED_JEST_OUTPUT"]) - output.write_text( - json.dumps( - {"tests": [{"identity": IDENTITY, "status": "passed"}]} - ) - ) + writer = os.open(kwargs["result_fifo"], os.O_WRONLY) + os.write(writer, json.dumps({"tests": [{"identity": IDENTITY, "status": "passed"}]}).encode()) + os.close(writer) calls.append({"command": command, **kwargs}) return subprocess.CompletedProcess(command, 0, "", ""), False @@ -684,7 +693,10 @@ def inspect_managed(command, **kwargs): } & environment.keys() assert call["cwd"] in call["readable_roots"] assert call["cwd"] not in call["writable_roots"] - assert Path(environment["PDD_TRUSTED_JEST_OUTPUT"]) in call["writable_files"] + assert "PDD_TRUSTED_JEST_OUTPUT" not in environment + assert call["result_fd"] == 198 + assert call["result_fifo"] + assert "writable_files" not in call assert not Path(environment["HOME"]).exists() @@ -694,11 +706,9 @@ def test_jest_surviving_descendant_is_error( root, commit = _repository(tmp_path) def surviving(command, **kwargs): - Path(kwargs["env"]["PDD_TRUSTED_JEST_OUTPUT"]).write_text( - json.dumps( - {"tests": [{"identity": IDENTITY, "status": "passed"}]} - ) - ) + writer = os.open(kwargs["result_fifo"], os.O_WRONLY) + os.write(writer, json.dumps({"tests": [{"identity": IDENTITY, "status": "passed"}]}).encode()) + os.close(writer) return subprocess.CompletedProcess(command, 0, "", ""), True monkeypatch.setattr("pdd.sync_core.runner._managed_subprocess", surviving) diff --git a/tests/test_sync_core_runner_vitest.py b/tests/test_sync_core_runner_vitest.py index ffe45ec9f6..1c1034adc0 100644 --- a/tests/test_sync_core_runner_vitest.py +++ b/tests/test_sync_core_runner_vitest.py @@ -1854,8 +1854,8 @@ def test_mixed_adapter_identities_survive_manifest_removal_and_round_trip( commit = _git(root, "rev-parse", "HEAD") fake_jest = tmp_path / "fake_jest.py" fake_jest.write_text( - "import json, os, pathlib\n" - "pathlib.Path(os.environ['PDD_TRUSTED_JEST_OUTPUT']).write_text(json.dumps({'tests':[{'identity':'tests/widget.test.js::widget works','status':'passed'}]}))\n", + "import json, os\n" + "os.write(198, json.dumps({'tests':[{'identity':'tests/widget.test.js::widget works','status':'passed'}]}).encode())\n", encoding="utf-8", ) vitest = _fake_vitest(tmp_path) diff --git a/tests/test_sync_core_verification_profiles.py b/tests/test_sync_core_verification_profiles.py index f40d181142..8ae7feb6fd 100644 --- a/tests/test_sync_core_verification_profiles.py +++ b/tests/test_sync_core_verification_profiles.py @@ -5,7 +5,11 @@ import subprocess from pathlib import Path -from pdd.sync_core import build_unit_manifest, load_verification_profiles +from pdd.sync_core import ( + AssuranceLevel, + build_unit_manifest, + load_verification_profiles, +) from pdd.sync_core.identity import initialize_repository_identity @@ -24,32 +28,33 @@ def _commit(root: Path, message: str) -> str: return _git(root, "rev-parse", "HEAD") -def _profile(requirements=None, obligations=None): +def _profile(requirements=None, obligations=None, assurance=None): + profile = { + "prompt_path": "prompts/widget_python.prompt", + "language_id": "python", + "required_requirement_ids": ( + ["REQ-1"] if requirements is None else requirements + ), + "obligations": ( + [ + { + "obligation_id": "pytest", + "kind": "test", + "validator_id": "pytest", + "validator_config_digest": "pytest-v1", + "requirement_ids": ["REQ-1"], + "artifact_paths": ["tests/test_widget.py"], + "required": True, + } + ] + if obligations is None + else obligations + ), + } + if assurance is not None: + profile["assurance"] = assurance return { - "profiles": [ - { - "prompt_path": "prompts/widget_python.prompt", - "language_id": "python", - "required_requirement_ids": ( - ["REQ-1"] if requirements is None else requirements - ), - "obligations": ( - [ - { - "obligation_id": "pytest", - "kind": "test", - "validator_id": "pytest", - "validator_config_digest": "pytest-v1", - "requirement_ids": ["REQ-1"], - "artifact_paths": ["tests/test_widget.py"], - "required": True, - } - ] - if obligations is None - else obligations - ), - } - ] + "profiles": [profile] } @@ -117,6 +122,73 @@ def test_complete_protected_profile_has_full_coverage(tmp_path) -> None: profiles = load_verification_profiles(root, _manifest(root, commit, commit)) assert profiles.coverage == 1.0 assert not profiles.invalid_reasons + assert profiles.profiles[0].assurance is AssuranceLevel.STANDARD_FRAMEWORK + + +def test_profile_assurance_parses_and_changes_digest(tmp_path) -> None: + root = _repository(tmp_path) + profile_path = root / ".pdd/verification-profiles.json" + profile_path.write_text(json.dumps(_profile())) + standard = _commit(root, "standard assurance") + standard_profile = load_verification_profiles( + root, _manifest(root, standard, standard) + ).profiles[0] + + profile_path.write_text( + json.dumps(_profile(assurance="isolated_black_box")) + ) + isolated = _commit(root, "isolated assurance") + isolated_profile = load_verification_profiles( + root, _manifest(root, isolated, isolated) + ).profiles[0] + + assert isolated_profile.assurance is AssuranceLevel.ISOLATED_BLACK_BOX + assert standard_profile.profile_digest != isolated_profile.profile_digest + + +def test_unknown_profile_assurance_fails_closed(tmp_path) -> None: + root = _repository(tmp_path) + (root / ".pdd/verification-profiles.json").write_text( + json.dumps(_profile(assurance="best_effort")) + ) + commit = _commit(root, "unknown assurance") + + profiles = load_verification_profiles(root, _manifest(root, commit, commit)) + + assert not profiles.profiles + assert any("assurance" in item for item in profiles.invalid_reasons) + + +def test_candidate_cannot_downgrade_protected_assurance(tmp_path) -> None: + root = _repository(tmp_path) + profile_path = root / ".pdd/verification-profiles.json" + profile_path.write_text( + json.dumps(_profile(assurance="isolated_black_box")) + ) + base = _commit(root, "protected isolated assurance") + profile_path.write_text(json.dumps(_profile(assurance="standard_framework"))) + head = _commit(root, "attempt assurance downgrade") + + profiles = load_verification_profiles(root, _manifest(root, base, head)) + + assert profiles.profiles[0].assurance is AssuranceLevel.ISOLATED_BLACK_BOX + assert any("downgrade protected assurance" in item for item in profiles.invalid_reasons) + + +def test_candidate_may_raise_effective_assurance(tmp_path) -> None: + root = _repository(tmp_path) + profile_path = root / ".pdd/verification-profiles.json" + profile_path.write_text(json.dumps(_profile(assurance="standard_framework"))) + base = _commit(root, "protected standard assurance") + profile_path.write_text( + json.dumps(_profile(assurance="isolated_black_box")) + ) + head = _commit(root, "raise assurance") + + profiles = load_verification_profiles(root, _manifest(root, base, head)) + + assert profiles.profiles[0].assurance is AssuranceLevel.ISOLATED_BLACK_BOX + assert not profiles.invalid_reasons def test_missing_profile_is_explicit_and_incomplete(tmp_path) -> None: From 0cb422ca7aec3794043366ed2311c01574a467c7 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 23:41:49 -0700 Subject: [PATCH 084/233] fix(sync): enforce verification assurance levels --- pdd/sync_core/__init__.py | 2 + pdd/sync_core/runner.py | 54 ++++++++++++------- pdd/sync_core/types.py | 25 +++++++++ pdd/sync_core/verification.py | 44 +++++++++++++-- tests/test_sync_core_runner_jest.py | 16 ++++-- tests/test_sync_core_supervisor.py | 4 +- tests/test_sync_core_verification_profiles.py | 8 ++- 7 files changed, 125 insertions(+), 28 deletions(-) diff --git a/pdd/sync_core/__init__.py b/pdd/sync_core/__init__.py index cfec759f6a..66fc6c455e 100644 --- a/pdd/sync_core/__init__.py +++ b/pdd/sync_core/__init__.py @@ -121,6 +121,7 @@ ) from .waivers import SyncWaiver, WaiverSet, load_sync_waivers from .types import ( + AssuranceLevel, ArtifactSnapshot, BaselineStatus, CandidateId, @@ -137,6 +138,7 @@ ) __all__ = [ + "AssuranceLevel", "ArtifactSnapshot", "ALIAS_POLICY_PATH", "AttestationBinding", diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 04c2740ba8..5a1ad90312 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -42,6 +42,7 @@ from .isolation import untrusted_child_environment from .git_io import read_git_blob, read_git_mode, read_git_regular_blob from .types import ( + AssuranceLevel, EvidenceOutcome, ObligationEvidence, UnitId, @@ -52,6 +53,9 @@ TRUSTED_RUNNER_VERSION = "pdd-trusted-runner-v2" +_IN_PROCESS_FRAMEWORK_ADAPTERS = frozenset( + {"pytest", "jest", "vitest", "playwright"} +) PYTEST_CONFIG_PATHS = ( PurePosixPath("pytest.ini"), PurePosixPath("pyproject.toml"), @@ -2529,6 +2533,7 @@ def runner_identity_digest( "python_runtime": _measured_python_runtime(), "released_runtime_digest": _released_runtime_closure_digest(), "checker_artifact_digest": _checker_artifact_digest(), + "assurance": profile.assurance.value, "pytest_command": [ "", "-P", @@ -2538,7 +2543,7 @@ def runner_identity_digest( "-q", *PYTEST_PROTECTED_FLAGS, "", - "--junitxml=", + "--junitxml=", ], "pytest_collection_command": [ "", @@ -2562,7 +2567,7 @@ def runner_identity_digest( "", "--config=", "--reporter=json", - "--outputFile=", + "--outputFile=", ], "vitest_environment": {"NODE_ENV": "test"}, "playwright_command": ["", "", "test", "", "--config=", "--reporter="], @@ -3816,7 +3821,7 @@ def _vitest_command_error(root: Path, command: tuple[str, ...]) -> str | None: def _jest_reporter_source(result_fd: int) -> str: """Return a checker-owned Jest reporter for the private result descriptor.""" return f"""const RESULT_FD = {result_fd}; -class PddTrustedReporter {{ +class PddFrameworkReporter {{ constructor() {{ this.tests = []; }} onTestResult(test, result) {{ for (const assertion of result.testResults || []) {{ @@ -3826,7 +3831,7 @@ class PddTrustedReporter {{ }} onRunComplete() {{ require('fs').writeSync(RESULT_FD, JSON.stringify({{tests: this.tests}})); }} }} -module.exports = PddTrustedReporter; +module.exports = PddFrameworkReporter; """ @@ -3834,7 +3839,7 @@ def _jest_result( output: bytes, returncode: int, expected: tuple[str, ...] | None ) -> tuple[EvidenceOutcome, str, tuple[str, ...]]: # pylint: disable=too-many-return-statements - """Validate trusted reporter data and normalize every non-pass state.""" + """Validate checker-owned framework observations and normalize non-pass states.""" try: payload = json.loads(output.decode("utf-8")) tests = payload["tests"] @@ -3846,7 +3851,7 @@ def _jest_result( ): raise ValueError("malformed Jest reporter payload") except (UnicodeDecodeError, ValueError, json.JSONDecodeError, KeyError): - return EvidenceOutcome.COLLECTION_ERROR, "trusted Jest reporter produced malformed JSON", () + return EvidenceOutcome.COLLECTION_ERROR, "Jest reporter produced malformed JSON", () identities = tuple(sorted(item["identity"] for item in tests)) if not identities: return ( @@ -3889,7 +3894,7 @@ def _run_jest( config: RunnerConfig, expected: tuple[str, ...] | None = None, ) -> tuple[RunnerExecution, tuple[str, ...]]: # pylint: disable=too-many-return-statements - """Run exact protected Jest paths using a temporary trusted reporter.""" + """Run exact protected Jest paths using a checker-owned reporter.""" command_prefix = _jest_command(config) if command_prefix is None: if (root / "node_modules" / "jest" / "bin" / "jest.js").is_file(): @@ -4091,7 +4096,7 @@ def _vitest_reporter_source(result_fd: int) -> str: return f"""import fs from 'node:fs'; import path from 'node:path'; const RESULT_FD = {result_fd}; -export default class PddTrustedVitestReporter {{ +export default class PddFrameworkVitestReporter {{ constructor() {{ this.tests = []; }} onTestCaseResult(test) {{ const result = test.result(); @@ -4357,7 +4362,7 @@ def _playwright_reporter_source(result_fd: int) -> str: return f"""const fs = require('fs'); const path = require('path'); const RESULT_FD = {result_fd}; -class PddTrustedReporter {{ +class PddFrameworkReporter {{ constructor() {{ this.tests = new Map(); }} identity(test) {{ const project = test.parent && test.parent.project ? test.parent.project().name : ''; @@ -4383,7 +4388,7 @@ class PddTrustedReporter {{ fs.writeSync(RESULT_FD, JSON.stringify({{pdd_playwright_reporter: 1, tests}})); }} }} -module.exports = PddTrustedReporter; +module.exports = PddFrameworkReporter; """ @@ -4461,7 +4466,7 @@ def _playwright_result( tests = payload.get("tests") if payload.get("pdd_playwright_reporter") == 1: if not isinstance(tests, list): - raise ValueError("malformed trusted Playwright reporter payload") + raise ValueError("malformed Playwright reporter payload") else: tests = [] def visit(suite: object, parents: tuple[str, ...] = ()) -> None: @@ -4521,7 +4526,7 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: ): raise ValueError("malformed Playwright reporter payload") except (KeyError, TypeError, ValueError, json.JSONDecodeError): - return EvidenceOutcome.COLLECTION_ERROR, "trusted Playwright reporter produced malformed JSON", () + return EvidenceOutcome.COLLECTION_ERROR, "Playwright reporter produced malformed JSON", () identities = tuple(sorted(item["identity"] for item in tests)) if not identities: return EvidenceOutcome.NOT_COLLECTED, "zero protected Playwright test identities collected", () @@ -5444,7 +5449,7 @@ def _collect_jest_at_base( paths: tuple[PurePosixPath, ...], config: RunnerConfig, ) -> tuple[RunnerExecution, tuple[str, ...]]: - """Run protected-base Jest with the trusted reporter to establish identities.""" + """Run protected-base Jest to observe framework test identities.""" with tempfile.TemporaryDirectory(prefix="pdd-jest-protected-base-") as directory: clone = Path(directory) / "repository" cloned = subprocess.run( @@ -5929,18 +5934,31 @@ def run_profile( RunnerExecution( obligation.obligation_id, EvidenceOutcome.ERROR, - "adapter-identity", - "configured adapter initial capture failed: " - + capture_errors[obligation.validator_id], + profile.profile_digest, + "isolated_black_box assurance requires an external SUT adapter; " + f"the in-process {obligation.validator_id} adapter is unsupported", + ) + if ( + profile.assurance is AssuranceLevel.ISOLATED_BLACK_BOX + and obligation.validator_id in _IN_PROCESS_FRAMEWORK_ADAPTERS ) - if obligation.validator_id in capture_errors - else run_obligation( + else ( + RunnerExecution( + obligation.obligation_id, + EvidenceOutcome.ERROR, + "adapter-identity", + "configured adapter initial capture failed: " + + capture_errors[obligation.validator_id], + ) + if obligation.validator_id in capture_errors + else run_obligation( root, obligation, base_sha=binding.base_sha, head_sha=binding.head_sha, config=config, ) + ) for obligation in profile.obligations ) if config.adapter_identities and not capture_errors: diff --git a/pdd/sync_core/types.py b/pdd/sync_core/types.py index 8e807d6c54..bdc3ebde91 100644 --- a/pdd/sync_core/types.py +++ b/pdd/sync_core/types.py @@ -54,6 +54,25 @@ class EvidenceOutcome(str, Enum): FLAKY = "FLAKY" +class AssuranceLevel(str, Enum): + """Strength of the execution boundary required by a verification profile.""" + + STANDARD_FRAMEWORK = "standard_framework" + ISOLATED_BLACK_BOX = "isolated_black_box" + + @property + def strength(self) -> int: + """Return the monotonic protection rank used during profile merging.""" + return { + AssuranceLevel.STANDARD_FRAMEWORK: 0, + AssuranceLevel.ISOLATED_BLACK_BOX: 1, + }[self] + + def protects_at_least(self, other: AssuranceLevel) -> bool: + """Return whether this level is no weaker than ``other``.""" + return self.strength >= other.strength + + def _validate_repository_id(repository_id: str) -> None: if not repository_id or repository_id.strip() != repository_id: raise ValueError("repository_id must be a non-empty canonical identifier") @@ -209,6 +228,7 @@ class VerificationProfile: obligations: tuple[VerificationObligation, ...] required_requirement_ids: tuple[str, ...] profile_digest: str + assurance: AssuranceLevel = AssuranceLevel.STANDARD_FRAMEWORK def __post_init__(self) -> None: obligation_ids = [item.obligation_id for item in self.obligations] @@ -218,6 +238,11 @@ def __post_init__(self) -> None: raise ValueError("verification profile contains duplicate requirement IDs") if not self.profile_digest: raise ValueError("verification profile digest must not be empty") + if not isinstance(self.assurance, AssuranceLevel): + try: + object.__setattr__(self, "assurance", AssuranceLevel(self.assurance)) + except ValueError as exc: + raise ValueError("unsupported verification assurance level") from exc @property def complete(self) -> bool: diff --git a/pdd/sync_core/verification.py b/pdd/sync_core/verification.py index fe60580a9e..b8d6d12d59 100644 --- a/pdd/sync_core/verification.py +++ b/pdd/sync_core/verification.py @@ -12,7 +12,12 @@ from .alias_policy import load_protected_aliases from .manifest import UnitManifest from .git_io import read_git_blob -from .types import UnitId, VerificationObligation, VerificationProfile +from .types import ( + AssuranceLevel, + UnitId, + VerificationObligation, + VerificationProfile, +) PROFILE_PATH = PurePosixPath(".pdd/verification-profiles.json") @@ -53,6 +58,7 @@ class _ProfileInput: requirements: tuple[str, ...] obligations: tuple[VerificationObligation, ...] + assurance: AssuranceLevel @dataclass(frozen=True) @@ -113,6 +119,17 @@ def _obligation(payload: Mapping[str, Any]) -> VerificationObligation: raise VerificationProfileError("verification obligation is malformed") from exc +def _assurance(payload: Mapping[str, Any]) -> AssuranceLevel: + """Parse one profile assurance with a field-specific fail-closed error.""" + raw = payload.get("assurance", AssuranceLevel.STANDARD_FRAMEWORK.value) + try: + return AssuranceLevel(str(raw)) + except ValueError as exc: + raise VerificationProfileError( + f"unsupported verification assurance: {raw}" + ) from exc + + def _load_inputs( root: Path, ref: str, @@ -153,8 +170,9 @@ def _load_inputs( parsed = _ProfileInput( tuple(sorted(requirements)), tuple(sorted(_obligation(item) for item in obligations)), + _assurance(row), ) - except (KeyError, TypeError, VerificationProfileError) as exc: + except (KeyError, TypeError, ValueError, VerificationProfileError) as exc: invalid.append(f"{ref}: invalid profile entry: {exc}") continue prompt_relpath = unit_id.prompt_relpath @@ -193,6 +211,7 @@ def _profile_digest( unit_id: UnitId, requirements: tuple[str, ...], obligations: tuple[VerificationObligation, ...], + assurance: AssuranceLevel, ) -> str: payload = { "unit": { @@ -201,6 +220,7 @@ def _profile_digest( "language_id": unit_id.language_id, }, "required_requirement_ids": requirements, + "assurance": assurance.value, "obligations": [ { "obligation_id": item.obligation_id, @@ -275,7 +295,10 @@ def _rotation_updates( candidate = next( ( item - for item in head.get(unit_id, _ProfileInput((), ())).obligations + for item in head.get( + unit_id, + _ProfileInput((), (), AssuranceLevel.STANDARD_FRAMEWORK), + ).obligations if item.obligation_id == obligation.obligation_id ), None, @@ -372,11 +395,24 @@ def _effective_profile( for item in sorted(set(base_obligations) - set(head_obligations)) ) obligations = tuple(sorted(effective.values())) + base_assurance = ( + base.assurance if base is not None else AssuranceLevel.STANDARD_FRAMEWORK + ) + head_assurance = ( + head.assurance if head is not None else AssuranceLevel.STANDARD_FRAMEWORK + ) + if not head_assurance.protects_at_least(base_assurance): + invalid.append( + f"{unit_id.prompt_relpath}: candidate cannot downgrade protected " + f"assurance from {base_assurance.value} to {head_assurance.value}" + ) + assurance = max((base_assurance, head_assurance), key=lambda item: item.strength) profile = VerificationProfile( unit_id, obligations, requirements, - _profile_digest(unit_id, requirements, obligations), + _profile_digest(unit_id, requirements, obligations, assurance), + assurance, ) if not profile.complete: invalid.append(f"{unit_id.prompt_relpath}: verification profile is incomplete") diff --git a/tests/test_sync_core_runner_jest.py b/tests/test_sync_core_runner_jest.py index 1398f2be1f..24bc580ed1 100644 --- a/tests/test_sync_core_runner_jest.py +++ b/tests/test_sync_core_runner_jest.py @@ -1,4 +1,4 @@ -"""Contract tests for the fail-closed trusted Jest adapter.""" +"""Contract tests for the fail-closed standard-framework Jest adapter.""" import json import os @@ -674,7 +674,12 @@ def test_jest_uses_managed_containment_and_cleans_scratch( def inspect_managed(command, **kwargs): environment = kwargs["env"] writer = os.open(kwargs["result_fifo"], os.O_WRONLY) - os.write(writer, json.dumps({"tests": [{"identity": IDENTITY, "status": "passed"}]}).encode()) + os.write( + writer, + json.dumps( + {"tests": [{"identity": IDENTITY, "status": "passed"}]} + ).encode(), + ) os.close(writer) calls.append({"command": command, **kwargs}) return subprocess.CompletedProcess(command, 0, "", ""), False @@ -707,7 +712,12 @@ def test_jest_surviving_descendant_is_error( def surviving(command, **kwargs): writer = os.open(kwargs["result_fifo"], os.O_WRONLY) - os.write(writer, json.dumps({"tests": [{"identity": IDENTITY, "status": "passed"}]}).encode()) + os.write( + writer, + json.dumps( + {"tests": [{"identity": IDENTITY, "status": "passed"}]} + ).encode(), + ) os.close(writer) return subprocess.CompletedProcess(command, 0, "", ""), True diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index abbf95ec48..0c64ee19f7 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -60,7 +60,7 @@ def test_private_result_wrapper_unlinks_channel_before_candidate( candidate = [ sys.executable, "-c", - f"import os;os.write({result_fd},b'trusted-result')", + f"import os;os.write({result_fd},b'framework-result')", ] completed = subprocess.run( @@ -74,7 +74,7 @@ def test_private_result_wrapper_unlinks_channel_before_candidate( try: assert completed.returncode == 0, completed.stderr assert not fifo.exists() - assert os.read(read_fd, 1024) == b"trusted-result" + assert os.read(read_fd, 1024) == b"framework-result" finally: os.close(write_fd) os.close(read_fd) diff --git a/tests/test_sync_core_verification_profiles.py b/tests/test_sync_core_verification_profiles.py index 8ae7feb6fd..663cce3143 100644 --- a/tests/test_sync_core_verification_profiles.py +++ b/tests/test_sync_core_verification_profiles.py @@ -155,7 +155,7 @@ def test_unknown_profile_assurance_fails_closed(tmp_path) -> None: profiles = load_verification_profiles(root, _manifest(root, commit, commit)) - assert not profiles.profiles + assert profiles.coverage == 0.0 assert any("assurance" in item for item in profiles.invalid_reasons) @@ -189,6 +189,12 @@ def test_candidate_may_raise_effective_assurance(tmp_path) -> None: assert profiles.profiles[0].assurance is AssuranceLevel.ISOLATED_BLACK_BOX assert not profiles.invalid_reasons + assert AssuranceLevel.ISOLATED_BLACK_BOX.protects_at_least( + AssuranceLevel.STANDARD_FRAMEWORK + ) + assert not AssuranceLevel.STANDARD_FRAMEWORK.protects_at_least( + AssuranceLevel.ISOLATED_BLACK_BOX + ) def test_missing_profile_is_explicit_and_incomplete(tmp_path) -> None: From 605c442d91dedc7b0289518986a2299d18746db9 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 23:41:49 -0700 Subject: [PATCH 085/233] docs(sync): define framework assurance boundary --- docs/global_sync_resolution_plan.md | 38 ++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/global_sync_resolution_plan.md b/docs/global_sync_resolution_plan.md index 9b0a7c6af9..a802088594 100644 --- a/docs/global_sync_resolution_plan.md +++ b/docs/global_sync_resolution_plan.md @@ -495,9 +495,38 @@ class VerificationObligation: class VerificationProfile: unit_id: UnitId obligations: tuple[VerificationObligation, ...] + required_requirement_ids: tuple[str, ...] profile_digest: str + assurance: AssuranceLevel = AssuranceLevel.STANDARD_FRAMEWORK ``` +`AssuranceLevel` is an ordered, digest-bound profile property: + +- `standard_framework` is the compatibility default. It assumes the selected + pytest, Jest, Vitest, or Playwright framework and its in-process hooks report + honestly. The checker owns and bounds the private FIFO/file-descriptor transport, + but that transport carries framework observations; it is not authenticated + evidence against candidate code in the same address space and descriptor table. +- `isolated_black_box` is stronger. It requires candidate code to execute only as + an external SUT behind a process boundary that cannot mutate checker state or its + observation channel. No current in-process framework adapter satisfies this + assurance. + +Protected base/head reconciliation takes the stronger assurance level and records +an attempted downgrade as invalid. Assurance is included in `profile_digest`, so +evidence cannot be replayed across assurance levels. Until an external SUT adapter +is implemented, an in-process adapter selected by an `isolated_black_box` profile +returns a deterministic non-pass result and the unit remains semantic `UNKNOWN`; +it cannot issue a passing result for that obligation. + +This boundary is fundamental rather than a missing FIFO, proxy, seccomp, or +cryptographic feature. Hostile code executing inside the test framework's process +can alter framework callbacks, memory, and inherited descriptors before the +checker observes them. Signing the resulting statement authenticates who signed +it and what was bound, but cannot retroactively make the in-process observation +Byzantine-resistant. The isolated-black-box follow-up therefore requires a truly +out-of-process adapter, not another in-process reporter transport. + The profile accounts for every structured prompt requirement/contract identifier, declared interface, required story/example, PDD-owned and human-owned validation test, policy/security check, and dependency-closure obligation. A completeness @@ -1132,7 +1161,9 @@ Tasks: - Deploy the evidence trust plane before PR 13: - Protected-base/control-plane `AttestationTrustPolicy` loader and verifier. - Post-validation signer using dedicated workload identity and no candidate code. - - Authenticated trusted-runner result channel plus a nonce/check-run authority. + - Checker-owned bounded framework-observation transport for standard-framework + adapters, plus an authenticated external observation boundary before any + isolated-black-box adapter is supported. - Transparency/audit record store and evidence cache invalidation service. - Threshold protected human-review attestation workflow for non-machine-verifiable obligations. @@ -1583,6 +1614,11 @@ The tracking epic records owner, PR, state, and exit-gate evidence for every row ## 11. Definition of Done +The global predicate described below is not currently achieved. In particular, +standard-framework observations do not satisfy a Byzantine-resistant +isolated-black-box claim, the external SUT adapter does not yet exist, and the +release/nightly evidence gates remain outstanding. + The global sync epic may close only when all conditions below hold with attached commands, commit SHAs, and reports. From 078d9e0b4bbd46218238938348f7e723b23bac51 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 23:54:59 -0700 Subject: [PATCH 086/233] test(sync): restamp assurance-bound profile digest --- tests/test_sync_core_pdd_rollout_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sync_core_pdd_rollout_policy.py b/tests/test_sync_core_pdd_rollout_policy.py index a2873624c8..e953ae5e07 100644 --- a/tests/test_sync_core_pdd_rollout_policy.py +++ b/tests/test_sync_core_pdd_rollout_policy.py @@ -31,7 +31,7 @@ ) FOUNDATION_PROFILE = "pdd/prompts/durable_sync_runner_python.prompt" FOUNDATION_PROFILE_DIGEST = ( - "3fb63c651345467be6b2cb445b34edf979b35ffba1bb1ebb44a81f1313beb244" + "1d719c645ecc9c77014b6ba3e7531e9827185a6cbd32e85ffd45cfeb6d5df1d7" ) FOUNDATION_OBLIGATIONS = { "pytest-descriptor-store": { From 058bc1abc4958217a8e6e343609a74953d8b53fb Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Mon, 13 Jul 2026 23:56:52 -0700 Subject: [PATCH 087/233] refactor(sync): isolate assurance reconciliation --- pdd/sync_core/verification.py | 100 +++++++++++++++++++--------- tests/test_sync_core_runner_jest.py | 1 - 2 files changed, 67 insertions(+), 34 deletions(-) diff --git a/pdd/sync_core/verification.py b/pdd/sync_core/verification.py index b8d6d12d59..ac836ac717 100644 --- a/pdd/sync_core/verification.py +++ b/pdd/sync_core/verification.py @@ -359,27 +359,19 @@ def _authorized_rotation_updates( return updates, invalid -def _effective_profile( +def _merge_obligations( unit_id: UnitId, base: _ProfileInput | None, head: _ProfileInput | None, authorized_updates: dict[tuple[UnitId, str], VerificationObligation], -) -> tuple[VerificationProfile, list[str]]: +) -> tuple[tuple[VerificationObligation, ...], list[str]]: + """Merge obligation maps while preserving protected-base authority.""" invalid: list[str] = [] - if base is None and head is not None: - invalid.append( - f"{unit_id.prompt_relpath}: candidate-only profile lacks protected approval" - ) - head = None - base_requirements = set(base.requirements if base else ()) - if base_requirements - set(head.requirements if head else ()): - invalid.append(f"{unit_id.prompt_relpath}: candidate removed protected requirements") - requirements = tuple(sorted(base_requirements | set(head.requirements if head else ()))) - base_obligations = {item.obligation_id: item for item in (base.obligations if base else ())} - head_obligations = {item.obligation_id: item for item in (head.obligations if head else ())} - effective = dict(base_obligations) - for obligation_id, obligation in head_obligations.items(): - protected = base_obligations.get(obligation_id) + base_items = {item.obligation_id: item for item in (base.obligations if base else ())} + head_items = {item.obligation_id: item for item in (head.obligations if head else ())} + effective = dict(base_items) + for obligation_id, obligation in head_items.items(): + protected = base_items.get(obligation_id) if protected is not None and protected != obligation: if authorized_updates.get((unit_id, obligation_id)) == obligation: effective[obligation_id] = obligation @@ -392,21 +384,50 @@ def _effective_profile( effective[obligation_id] = obligation invalid.extend( f"{unit_id.prompt_relpath}: candidate removed protected obligation {item}" - for item in sorted(set(base_obligations) - set(head_obligations)) - ) - obligations = tuple(sorted(effective.values())) - base_assurance = ( - base.assurance if base is not None else AssuranceLevel.STANDARD_FRAMEWORK + for item in sorted(set(base_items) - set(head_items)) ) - head_assurance = ( - head.assurance if head is not None else AssuranceLevel.STANDARD_FRAMEWORK - ) - if not head_assurance.protects_at_least(base_assurance): - invalid.append( + return tuple(sorted(effective.values())), invalid + + +def _effective_assurance( + unit_id: UnitId, + base: _ProfileInput | None, + head: _ProfileInput | None, +) -> tuple[AssuranceLevel, tuple[str, ...]]: + """Return the strongest assurance and any attempted-downgrade violation.""" + base_level = base.assurance if base else AssuranceLevel.STANDARD_FRAMEWORK + head_level = head.assurance if head else AssuranceLevel.STANDARD_FRAMEWORK + invalid = () + if not head_level.protects_at_least(base_level): + invalid = ( f"{unit_id.prompt_relpath}: candidate cannot downgrade protected " - f"assurance from {base_assurance.value} to {head_assurance.value}" + f"assurance from {base_level.value} to {head_level.value}", ) - assurance = max((base_assurance, head_assurance), key=lambda item: item.strength) + return max((base_level, head_level), key=lambda item: item.strength), invalid + + +def _effective_profile( + unit_id: UnitId, + base: _ProfileInput | None, + head: _ProfileInput | None, + authorized_updates: dict[tuple[UnitId, str], VerificationObligation], +) -> tuple[VerificationProfile, list[str]]: + invalid: list[str] = [] + if base is None and head is not None: + invalid.append( + f"{unit_id.prompt_relpath}: candidate-only profile lacks protected approval" + ) + head = None + base_requirements = set(base.requirements if base else ()) + if base_requirements - set(head.requirements if head else ()): + invalid.append(f"{unit_id.prompt_relpath}: candidate removed protected requirements") + requirements = tuple(sorted(base_requirements | set(head.requirements if head else ()))) + obligations, obligation_invalid = _merge_obligations( + unit_id, base, head, authorized_updates + ) + invalid.extend(obligation_invalid) + assurance, assurance_invalid = _effective_assurance(unit_id, base, head) + invalid.extend(assurance_invalid) profile = VerificationProfile( unit_id, obligations, @@ -419,8 +440,15 @@ def _effective_profile( return profile, invalid -def load_verification_profiles(root: Path, manifest: UnitManifest) -> ProfileSet: - """Load the protected base/candidate union for every expected-managed unit.""" +def _protected_profile_inputs( + root: Path, manifest: UnitManifest +) -> tuple[ + dict[UnitId, _ProfileInput], + dict[UnitId, _ProfileInput], + dict[tuple[UnitId, str], VerificationObligation], + list[str], +]: + """Load protected inputs and rotation authority for profile reconciliation.""" alias_invalid: list[str] = [] try: approved_aliases = load_protected_aliases(root, manifest) @@ -433,15 +461,21 @@ def load_verification_profiles(root: Path, manifest: UnitManifest) -> ProfileSet head, head_invalid = _load_inputs( root, manifest.head_ref, manifest.repository_id, approved_aliases ) - invalid = alias_invalid + base_invalid + head_invalid - authorized_updates, rotation_invalid = _authorized_rotation_updates( + updates, rotation_invalid = _authorized_rotation_updates( root, manifest, base, head, _load_rotation_authorizations(root, manifest.base_ref), ) - invalid.extend(rotation_invalid) + return base, head, updates, ( + alias_invalid + base_invalid + head_invalid + rotation_invalid + ) + + +def load_verification_profiles(root: Path, manifest: UnitManifest) -> ProfileSet: + """Load the protected base/candidate union for every expected-managed unit.""" + base, head, authorized_updates, invalid = _protected_profile_inputs(root, manifest) profiles: list[VerificationProfile] = [] expected = set(manifest.expected_managed) unknown = (set(base) | set(head)) - expected diff --git a/tests/test_sync_core_runner_jest.py b/tests/test_sync_core_runner_jest.py index 24bc580ed1..0db0f3ed8b 100644 --- a/tests/test_sync_core_runner_jest.py +++ b/tests/test_sync_core_runner_jest.py @@ -672,7 +672,6 @@ def test_jest_uses_managed_containment_and_cleans_scratch( monkeypatch.setenv("PDD_RELEASED_CHECKER_COMMAND", "must-not-leak") def inspect_managed(command, **kwargs): - environment = kwargs["env"] writer = os.open(kwargs["result_fifo"], os.O_WRONLY) os.write( writer, From 2de6b99f8bb8767700fae5946da267c450c7f3cf Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 00:33:47 -0700 Subject: [PATCH 088/233] test(sync): reject invalid profile reconciliation --- tests/test_sync_core_reporting.py | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/test_sync_core_reporting.py b/tests/test_sync_core_reporting.py index ea835ed434..6d1d5c25ef 100644 --- a/tests/test_sync_core_reporting.py +++ b/tests/test_sync_core_reporting.py @@ -230,6 +230,35 @@ def _options(tmp_path: Path, commit: str) -> CanonicalReportOptions: ) +def _durable_state(root: Path) -> dict[PurePosixPath, bytes]: + """Capture checker-owned evidence and fingerprints for no-write assertions.""" + return { + PurePosixPath(path.relative_to(root).as_posix()): path.read_bytes() + for directory in (root / ".pdd/evidence/v2", root / ".pdd/meta/v2") + if directory.exists() + for path in directory.rglob("*") + if path.is_file() + } + + +def _invalid_candidate_profile(root: Path, mutation: str) -> None: + """Commit one invalid candidate profile transition over a protected base.""" + profile_path = root / ".pdd/verification-profiles.json" + if mutation == "deleted": + profile_path.unlink() + else: + payload = json.loads(profile_path.read_text(encoding="utf-8")) + payload["profiles"][0]["assurance"] = "unrecognized_assurance" + profile_path.write_text(json.dumps(payload), encoding="utf-8") + if mutation == "malformed-with-requirement": + with (root / "prompts/widget_python.prompt").open( + "a", encoding="utf-8" + ) as prompt: + prompt.write("REQ-2: Reject invalid widgets\n") + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", f"invalid candidate profile: {mutation}") + + def test_trusted_transactional_baseline_passes_canonical_predicate(tmp_path) -> None: root, commit = _repository(tmp_path) _finalize_trusted_baseline(root, commit) @@ -241,6 +270,38 @@ def test_trusted_transactional_baseline_passes_canonical_predicate(tmp_path) -> assert report["units"][0]["in_sync"] is True +def test_invalid_profile_reconciliation_cannot_reuse_verified_evidence( + tmp_path: Path, +) -> None: + root, base = _repository(tmp_path) + _finalize_trusted_baseline(root, base) + _invalid_candidate_profile(root, "malformed") + head = _git(root, "rev-parse", "HEAD") + + with patch( + "pdd.sync_core.reporting._evidence", + side_effect=AssertionError("invalid profile attempted evidence verification"), + ): + report = build_canonical_report( + root, + CanonicalReportOptions( + base_ref=base, + head_ref=head, + replay_ledger_path=tmp_path / "external-trust/invalid-profile.json", + now=NOW, + ), + ) + + assert report["ok"] is False + assert report["counts"]["invalid"] == 1 + assert report["counts"]["unknown"] == 1 + assert report["counts"]["trusted_current_evidence"] == 0 + assert report["counts"]["trusted_in_sync"] == 0 + assert report["units"][0]["semantic"] == "UNKNOWN" + assert report["units"][0]["evidence_complete"] is False + assert report["units"][0]["in_sync"] is False + + def test_managed_waiver_is_counted_and_blocks_certificate_predicate(tmp_path) -> None: root, _commit = _repository(tmp_path) (root / ".pdd/sync-waivers.json").write_text( @@ -977,6 +1038,46 @@ def test_trusted_finalizer_rejects_invalid_next_protected_base(tmp_path) -> None ) +@pytest.mark.parametrize( + "mutation", ["malformed", "deleted", "malformed-with-requirement"] +) +def test_trusted_finalizer_rejects_invalid_profile_before_trusted_state( + tmp_path: Path, mutation: str +) -> None: + root, base = _repository(tmp_path) + _finalize_trusted_baseline(root, base) + before = _durable_state(root) + _invalid_candidate_profile(root, mutation) + head = _git(root, "rev-parse", "HEAD") + + with patch( + "pdd.sync_core.finalize._reusable_result", + side_effect=AssertionError("invalid profile attempted evidence reuse"), + ) as reusable, patch( + "pdd.sync_core.finalize.run_profile", + side_effect=AssertionError("invalid profile invoked runner"), + ) as runner, patch( + "pdd.sync_core.finalize.TransactionManager", + side_effect=AssertionError("invalid profile created transaction manager"), + ) as transaction_manager: + with pytest.raises( + ValueError, match="valid protected verification profiles" + ): + finalize_unit( + root, + PurePosixPath("prompts/widget_python.prompt"), + base_ref=base, + head_ref=head, + signer=SIGNER, + replay_ledger_path=tmp_path / f"external-trust/{mutation}.json", + ) + + reusable.assert_not_called() + runner.assert_not_called() + transaction_manager.assert_not_called() + assert _durable_state(root) == before + + @pytest.mark.skipif( sys.platform == "darwin", reason="protected subprocess validation requires Linux process isolation", From 074bb5de74598df22370e079ba98c16a1ad0f2dc Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 00:35:24 -0700 Subject: [PATCH 089/233] fix(sync): block invalid profile evidence reuse --- pdd/sync_core/finalize.py | 4 ++++ pdd/sync_core/reporting.py | 13 +++++++++++++ tests/test_sync_core_reporting.py | 9 ++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/pdd/sync_core/finalize.py b/pdd/sync_core/finalize.py index 2356cf7102..a5b7714489 100644 --- a/pdd/sync_core/finalize.py +++ b/pdd/sync_core/finalize.py @@ -327,6 +327,10 @@ def finalize_unit( if len(matches) != 1: raise ValueError(f"finalization requires exactly one managed unit: {module}") profiles = load_verification_profiles(repository_root, manifest) + if profiles.invalid_reasons: + raise ValueError( + "canonical finalization requires valid protected verification profiles" + ) profile = profiles.for_unit(matches[0].unit_id) if profile is None or not profile.complete: raise ValueError("finalization requires a complete protected profile") diff --git a/pdd/sync_core/reporting.py b/pdd/sync_core/reporting.py index 12d5652b46..851b14a3f1 100644 --- a/pdd/sync_core/reporting.py +++ b/pdd/sync_core/reporting.py @@ -126,6 +126,17 @@ def _error_verdict(unit: ManifestUnit, baseline: BaselineStatus, reason: str) -> ) +def _invalid_profile_verdict(unit: ManifestUnit) -> SyncVerdict: + """Return a non-current unknown verdict without consulting stale evidence.""" + return SyncVerdict( + unit.unit_id, + InventoryStatus.MANAGED, + BaselineStatus.CORRUPT, + SemanticStatus.UNKNOWN, + VerdictDetails((), "verification profile reconciliation is invalid"), + ) + + def _evidence( context: ReportContext, expectation: EvidenceExpectation, @@ -180,6 +191,8 @@ def _evidence( def _unit_verdict(context: ReportContext, unit: ManifestUnit) -> SyncVerdict: if context.manifest.invalid_reasons: return _error_verdict(unit, BaselineStatus.CORRUPT, "manifest is invalid") + if context.profiles.invalid_reasons: + return _invalid_profile_verdict(unit) profile = context.profiles.for_unit(unit.unit_id) if profile is None: return _error_verdict(unit, BaselineStatus.CORRUPT, "profile is missing") diff --git a/tests/test_sync_core_reporting.py b/tests/test_sync_core_reporting.py index 6d1d5c25ef..dc98d3e541 100644 --- a/tests/test_sync_core_reporting.py +++ b/tests/test_sync_core_reporting.py @@ -255,7 +255,14 @@ def _invalid_candidate_profile(root: Path, mutation: str) -> None: "a", encoding="utf-8" ) as prompt: prompt.write("REQ-2: Reject invalid widgets\n") - _git(root, "add", "-A") + _git( + root, + "add", + "-u", + "--", + ".pdd/verification-profiles.json", + "prompts/widget_python.prompt", + ) _git(root, "commit", "-q", "-m", f"invalid candidate profile: {mutation}") From 59d92cc85a891b804cc1eacbbd213fcc47dd0b18 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 00:51:56 -0700 Subject: [PATCH 090/233] test(sync): cover portable observations and timeout precedence --- tests/test_sync_core_supervisor.py | 82 ++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 0c64ee19f7..ef0a91f4ee 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -19,7 +19,7 @@ _linked_libraries, _limited_command, _live_processes, - _private_result_command, + _framework_observation_command, _sandbox_library_path, _sandbox_command, _runtime_directories, @@ -45,17 +45,15 @@ def _mock_linux_tools( return tools -def test_private_result_wrapper_unlinks_channel_before_candidate( +def test_framework_observation_wrapper_opens_portable_fifo_path( tmp_path: Path, ) -> None: - """Exercise the pre-exec channel handoff without a Linux sandbox.""" + """Exercise the portable standard-framework handoff without Bubblewrap.""" channel = tmp_path / "channel" channel.mkdir(mode=0o700) fifo = channel / "result.fifo" os.mkfifo(fifo, mode=0o600) read_fd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK) - write_fd = os.open(fifo, os.O_WRONLY) - fifo.unlink() result_fd = 17 candidate = [ sys.executable, @@ -64,19 +62,17 @@ def test_private_result_wrapper_unlinks_channel_before_candidate( ] completed = subprocess.run( - _private_result_command(candidate, result_fd, write_fd), + _framework_observation_command(candidate, result_fd, fifo), capture_output=True, text=True, timeout=10, - pass_fds=(write_fd,), check=False, ) try: assert completed.returncode == 0, completed.stderr - assert not fifo.exists() + assert fifo.exists() assert os.read(read_fd, 1024) == b"framework-result" finally: - os.close(write_fd) os.close(read_fd) @@ -295,7 +291,7 @@ def test_linux_sandbox_fails_closed_for_root_caller( assert surviving is False -def test_linux_sandbox_opens_and_unlinks_checker_fifo_before_candidate( +def test_linux_sandbox_uses_portable_framework_observation_fifo( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(sys, "platform", "linux") @@ -322,17 +318,73 @@ def test_linux_sandbox_opens_and_unlinks_checker_fifo_before_candidate( assert argv[:3] == [str(plan.tools.sudo), "-n", "-E"] assert "-C" not in argv[:6] bwrap = json.loads(argv[-4]) - assert bwrap[bwrap.index("--preserve-fds") + 1] == "1" - assert json.loads(argv[-3]) + assert "--preserve-fds" not in bwrap + observation_path = "/run/pdd-framework-observation" + observation_index = bwrap.index(observation_path) + assert bwrap[observation_index - 2] == "--bind" + observation_token = bwrap[observation_index - 1] + tokens = json.loads(argv[-5]) + sources = json.loads(argv[-3]) + assert sources[tokens.index(observation_token)] == str(fifo.resolve()) assert str(channel) not in bwrap separator = bwrap.index("--") candidate_argv = bwrap[separator + 1:] assert str(fifo) not in candidate_argv wrapper = candidate_argv[candidate_argv.index("-c") + 1] assert "os.dup2(source,target)" in wrapper - assert "os.open" not in wrapper - assert "os.open(result_fifo" in plan.helper_source - assert "os.unlink(result_fifo)" in plan.helper_source + assert "os.open(path" in wrapper + assert "result_fifo" not in plan.helper_source + + +def test_candidate_timeout_survives_missing_helper_result_and_exact_cleanup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A proven candidate deadline remains 124 after authoritative cleanup.""" + helper = """import pathlib,sys,time +control=pathlib.Path(sys.argv[1]) +(control/'ready').write_text('ready',encoding='ascii') +while not (control/'start').exists(): time.sleep(.001) +time.sleep(30) +""" + cgroup = tmp_path / "cgroup" + cgroup.mkdir() + (cgroup / "memory.events").write_text("oom 0\noom_kill 0\n", encoding="ascii") + (cgroup / "pids.events").write_text("max 0\n", encoding="ascii") + cleanup: list[str] = [] + + def sandbox(_command, _writable_roots, **kwargs): + plan = SimpleNamespace( + unit_name="pdd-validator-00000000000000000000000000000000.scope", + tools=SimpleNamespace(), + ) + return [sys.executable, "-c", helper, str(kwargs["control_directory"])], plan + + monkeypatch.setattr(supervisor, "_sandbox_command", sandbox) + monkeypatch.setattr(supervisor, "_prepare_staging", lambda _plan: None) + monkeypatch.setattr( + supervisor, "_probe_scope", + lambda _plan, _limits: (cgroup, {"oom": 0, "oom_kill": 0}, {"max": 0}), + ) + monkeypatch.setattr( + supervisor, "_stop_scope", lambda *_args: cleanup.append("scope") + ) + monkeypatch.setattr( + supervisor, "_cleanup_staging", lambda _plan: cleanup.append("mounts") + ) + monkeypatch.setattr( + supervisor, "_scope_properties", lambda *_args: {"Result": "success"} + ) + + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.05, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 124, result.stderr + assert "timeout phase=candidate-execution" in result.stderr + assert "scope produced no validated result" not in result.stderr + assert cleanup == ["scope", "mounts"] + assert surviving is False def test_sandbox_directory_bind_provides_parent_for_nested_file( From 238299248bedf7d97ec78173e4d1f2ec4c0bbb7e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 00:54:43 -0700 Subject: [PATCH 091/233] fix(sync): make framework observations portable --- pdd/sync_core/supervisor.py | 55 +++++++++++++++++++----------- tests/test_sync_core_supervisor.py | 4 +-- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index e4122c0b1a..665eb3600a 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -26,6 +26,7 @@ # spelling must never become a measured file or sandbox mount source. _SUPERVISOR_EXECUTABLE = Path(sys.executable) _TRUSTED_ROOT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +_FRAMEWORK_OBSERVATION_PATH = Path("/run/pdd-framework-observation") _SCOPE_PATTERN = re.compile(r"pdd-validator-[0-9a-f]{32}\.scope") _TRUSTED_POSTPROCESS_SECONDS = 5 @@ -246,7 +247,6 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: def _staged_bwrap( argv: list[str], sources: list[Path], path_tokens: list[str], *, writable_roots: tuple[Path, ...], writable_specs: list[tuple[str, int, str]], - result_fifo: Path | None, unit_name: str, control_directory: Path, limits: SupervisorLimits, tools: _TrustedTools, ) -> tuple[list[str], _ScopePlan]: @@ -259,12 +259,12 @@ def _staged_bwrap( "import json,os,pathlib,shutil,stat,subprocess,sys,time", "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[4])]", - "writable_specs=json.loads(sys.argv[5]); result_fifo=sys.argv[6] or None", + "writable_specs=json.loads(sys.argv[5])", "limits=json.loads(sys.argv[-1])", "path_tokens=json.loads(sys.argv[-5]); argv=json.loads(sys.argv[-4]); " "paths=json.loads(sys.argv[-3])", "targets=[control/'binds'/str(index) for index in range(len(paths))]", - "staged=[]; result=None; cleanup_error=None; result_write=None", + "staged=[]; result=None; cleanup_error=None", "def wait_for(name):", " path=control/name", " while not path.exists(): time.sleep(.01)", @@ -334,15 +334,10 @@ def _staged_bwrap( " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)],check=True)", " staged.append(cgroup_target)", " argv=[str(cgroup_target) if value == '@PDD-CGROUP@' else value for value in argv]", - " if result_fifo:", - " result_write=os.open(result_fifo,os.O_WRONLY|os.O_CLOEXEC)", - " os.unlink(result_fifo)", - " os.dup2(result_write,3,inheritable=True)", " (control/'ready').write_text('ready',encoding='ascii')", " wait_for('start')", " pid=os.fork()", " if pid == 0:", - " if result_fifo: os.set_inheritable(3,True)", " os.execvpe(argv[0],argv,os.environ)", " _wait,status=os.waitpid(pid,0)", " result=os.waitstatus_to_exitcode(status)", @@ -356,7 +351,6 @@ def _staged_bwrap( " os.replace(temporary,control/'result.json')", " wait_for('finish')", "finally:", - " if result_write is not None: os.close(result_write)", " for target in reversed(staged):", " completed=subprocess.run([umount,str(target)],capture_output=True," "text=True,check=False)", @@ -380,7 +374,7 @@ def _staged_bwrap( str(_SUPERVISOR_EXECUTABLE), "-c", helper, str(control_directory), str(tools.mount), str(tools.umount), json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), - str(result_fifo or ""), json.dumps(path_tokens), json.dumps(argv), + json.dumps(path_tokens), json.dumps(argv), json.dumps([str(path) for path in sources]), unit_name, json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, "writable": limits.max_writable_bytes}), @@ -388,19 +382,20 @@ def _staged_bwrap( return command, plan -def _private_result_command( - command: list[str], result_fd: int, source_fd: int = 3, +def _framework_observation_command( + command: list[str], result_fd: int, source_path: Path, ) -> list[str]: - """Move the helper-opened private result descriptor to its fixed number.""" + """Open the namespace-local standard-framework observation channel.""" script = ( "import os,sys;" - "source=int(sys.argv[1]);target=int(sys.argv[2]);" + "path=sys.argv[1];target=int(sys.argv[2]);" + "source=os.open(path,os.O_WRONLY|os.O_CLOEXEC);" "os.dup2(source,target);" "os.close(source) if source!=target else None;" "os.execvpe(sys.argv[3],sys.argv[3:],os.environ)" ) return [str(_SUPERVISOR_EXECUTABLE), "-c", script, - str(source_fd), str(result_fd), *command] + str(source_path), str(result_fd), *command] def _supervised_descendants(token: str) -> set[int]: """Find descendants carrying the unforgeable per-run environment marker.""" @@ -802,6 +797,23 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: bind("--ro-bind", item.resolve()) for source, destination in readable_bindings: bind("--ro-bind", source.resolve(), destination) + if result_fifo is not None: + observation_source = result_fifo.resolve(strict=True) + if not stat.S_ISFIFO(observation_source.lstat().st_mode): + raise RuntimeError("framework observation channel must be a FIFO") + if _FRAMEWORK_OBSERVATION_PATH in mounted: + raise RuntimeError("framework observation destination conflicts") + mounted[_FRAMEWORK_OBSERVATION_PATH] = ( + "--bind", observation_source + ) + ensure_destination_parent(_FRAMEWORK_OBSERVATION_PATH) + argv.extend( + ( + "--bind", + stage_source(observation_source), + str(_FRAMEWORK_OBSERVATION_PATH), + ) + ) argv.extend(("--dev", "/dev")) for item in writable_roots: bind("--bind", item.resolve()) @@ -812,12 +824,13 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: ) sandboxed = _limited_command(command, limits) if result_fifo is not None: - argv[1:1] = ["--preserve-fds", "1"] - sandboxed = _private_result_command(sandboxed, result_fd, 3) + sandboxed = _framework_observation_command( + sandboxed, result_fd, _FRAMEWORK_OBSERVATION_PATH + ) argv.extend(("--", *drop, *sandboxed)) return _staged_bwrap( argv, sources, path_tokens, writable_roots=storage_roots, - writable_specs=writable_specs, result_fifo=result_fifo, + writable_specs=writable_specs, unit_name=unit_name or _scope_unit_name(), control_directory=control_directory or ( Path(tempfile.gettempdir()) / f"pdd-scope-{uuid.uuid4().hex}" @@ -1050,7 +1063,11 @@ def track_process_tree() -> None: "protected supervisor phase=candidate-execution: " "scope produced no candidate result\n" ) - elif not (control / "result.json").exists() and not failed_closed: + elif ( + not (control / "result.json").exists() + and not failed_closed + and not timed_out + ): failed_closed = True add_diagnostic( "protected supervisor phase=trusted-postprocessing: " diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index ef0a91f4ee..588ae110bd 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -207,8 +207,8 @@ def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( assert bwrap[destination_index - 2] == "--bind" assert destination_index < bwrap.index("--ro-bind") placeholder = bwrap[destination_index - 1] - writable_roots = json.loads(argv[-8]) - writable_specs = json.loads(argv[-7]) + writable_roots = json.loads(argv[-7]) + writable_specs = json.loads(argv[-6]) token, root_index, relative = next( spec for spec in writable_specs if spec[0] == placeholder ) From 14d2b668aff5cec39dbcefc53a6aab4810e8bd6f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 00:57:35 -0700 Subject: [PATCH 092/233] fix(sync): describe framework observations accurately --- pdd/sync_core/runner.py | 26 +++++++++++------------ tests/test_sync_core_runner.py | 4 ++-- tests/test_sync_core_runner_playwright.py | 26 +++++++++++------------ tests/test_sync_core_runner_vitest.py | 2 +- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 5a1ad90312..2b1b707dcc 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -2543,7 +2543,7 @@ def runner_identity_digest( "-q", *PYTEST_PROTECTED_FLAGS, "", - "--junitxml=", + "--junitxml=", ], "pytest_collection_command": [ "", @@ -2567,10 +2567,10 @@ def runner_identity_digest( "", "--config=", "--reporter=json", - "--outputFile=", + "--outputFile=", ], "vitest_environment": {"NODE_ENV": "test"}, - "playwright_command": ["", "", "test", "", "--config=", "--reporter="], + "playwright_command": ["", "", "test", "", "--config=", "--reporter="], "playwright_environment": {"NODE_ENV": "test"}, "obligations": [ { @@ -3819,7 +3819,7 @@ def _vitest_command_error(root: Path, command: tuple[str, ...]) -> str | None: def _jest_reporter_source(result_fd: int) -> str: - """Return a checker-owned Jest reporter for the private result descriptor.""" + """Return a checker-owned Jest reporter for the observation descriptor.""" return f"""const RESULT_FD = {result_fd}; class PddFrameworkReporter {{ constructor() {{ this.tests = []; }} @@ -4007,7 +4007,7 @@ def _run_jest( if "error" in drained or drained.get("overflow"): return RunnerExecution( "jest", EvidenceOutcome.COLLECTION_ERROR, digest, - "Jest private result transport failed", + "Jest bounded observation transport failed", ), () output = drained.get("data", b"") if not isinstance(output, bytes): @@ -4117,7 +4117,7 @@ def _vitest_reporter_source(result_fd: int) -> str: def _drain_result_pipe( read_fd: int, finished: threading.Event, result: dict[str, object] ) -> None: - """Drain the private FIFO while the child runs, discarding over-cap bytes.""" + """Drain the observation FIFO while the child runs, discarding over-cap bytes.""" chunks: list[bytes] = [] size = 0 overflow = False @@ -4358,7 +4358,7 @@ def _playwright_environment( def _playwright_reporter_source(result_fd: int) -> str: - """Return the checker-owned reporter for the private result descriptor.""" + """Return the checker-owned reporter for the observation descriptor.""" return f"""const fs = require('fs'); const path = require('path'); const RESULT_FD = {result_fd}; @@ -4399,7 +4399,7 @@ def _playwright_missing_result_detail( diagnostic = " ".join(result.stderr.split()) if len(diagnostic) > 512: diagnostic = diagnostic[:509] + "..." - detail = f"Playwright reporter produced no private result (exit {result.returncode})" + detail = f"Playwright reporter produced no observation (exit {result.returncode})" return f"{detail}: {diagnostic}" if diagnostic else detail @@ -4711,7 +4711,7 @@ def _run_playwright_in_tree( expected_commit: str | None = None, code_under_test_paths: tuple[PurePosixPath, ...] = (), ) -> tuple[RunnerExecution, tuple[str, ...]]: - """Execute exact paths through Playwright's private reporter channel.""" + """Execute exact paths through Playwright's bounded observation channel.""" tool_root = command_root or root destination_error = _playwright_node_modules_destination_error(root) if destination_error is not None: @@ -5040,16 +5040,16 @@ def _run_test_node( "validator left a surviving process-group descendant", ) output = result.stdout + "\n" + result.stderr - private_output = drained.get("data", b"") + observation_output = drained.get("data", b"") if "error" in drained or drained.get("overflow") or not isinstance( - private_output, bytes + observation_output, bytes ): return RunnerExecution( node_id, EvidenceOutcome.COLLECTION_ERROR, command_digest, - "pytest private result transport failed", + "pytest bounded observation transport failed", ) outcome, detail = _junit_outcome( - private_output, result.returncode, output, 1 + observation_output, result.returncode, output, 1 ) return RunnerExecution(node_id, outcome, command_digest, detail) diff --git a/tests/test_sync_core_runner.py b/tests/test_sync_core_runner.py index 5670815867..cfe7bdc479 100644 --- a/tests/test_sync_core_runner.py +++ b/tests/test_sync_core_runner.py @@ -874,7 +874,7 @@ def test_candidate_pytest_module_cannot_forge_collection_or_junit_pass(tmp_path) assert not (root / "candidate-fixed-probe-loaded").exists() -def test_execution_uses_private_junit_descriptor_channel( +def test_execution_uses_checker_owned_junit_observation_channel( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: def supervised(command, **kwargs): @@ -895,7 +895,7 @@ def supervised(command, **kwargs): assert execution.outcome is EvidenceOutcome.PASS -def test_jest_reporter_uses_private_result_descriptor() -> None: +def test_jest_reporter_uses_framework_observation_descriptor() -> None: source = runner_module._jest_reporter_source(198) assert "writeSync(RESULT_FD" in source assert "PDD_TRUSTED_JEST_OUTPUT" not in source diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index b92387eaf6..6fe5d7ebe7 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -47,12 +47,12 @@ @pytest.fixture(autouse=True) -def _simulate_private_result_for_synthetic_playwright( +def _simulate_framework_observation_for_synthetic_playwright( monkeypatch: pytest.MonkeyPatch, ) -> None: """Model the Linux-only inherited descriptor for the local fake runner. - The production supervisor intentionally does not emulate the private + The production supervisor intentionally does not emulate the observation descriptor on macOS. These synthetic runner tests therefore write the checker-owned FIFO from the trusted test process, while real execution is reserved for the Linux/bwrap contract tests. @@ -102,7 +102,7 @@ def supervised(command, **kwargs): monkeypatch.setattr(runner_module, "run_supervised", supervised) -def _write_private_result(kwargs: dict, payload: dict) -> None: +def _write_framework_observation(kwargs: dict, payload: dict) -> None: """Model the checker-owned reporter transport in supervisor fakes.""" fifo = kwargs["result_fifo"] writer = os.open(fifo, os.O_WRONLY) @@ -607,7 +607,7 @@ def supervised(command, **_kwargs): dependency_bindings.append(_kwargs["readable_bindings"]) temp_directories.append(_kwargs["temp_directory"]) phase_roots.append(_kwargs["cwd"]) - _write_private_result(_kwargs, { + _write_framework_observation(_kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) return subprocess.CompletedProcess(command, 0, "forged stdout is ignored", ""), False @@ -919,7 +919,7 @@ def supervised(command, *, cwd, writable_roots, writable_bindings, **kwargs): source, destination = writable_bindings[0] tmp_sources.append(source) tmp_destinations.append(destination) - _write_private_result(kwargs, { + _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) return subprocess.CompletedProcess(command, 0, "", ""), False @@ -1058,7 +1058,7 @@ def run_supervised(command, **kwargs): root / "node_modules", ), ) - _write_private_result(kwargs, { + _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) return subprocess.CompletedProcess(command, 0, "", ""), False @@ -1100,7 +1100,7 @@ def counted_native_bindings(self): def run_supervised(command, **kwargs): assert kwargs["readable_bindings"] - _write_private_result(kwargs, { + _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) return subprocess.CompletedProcess(command, 0, "", ""), False @@ -1229,7 +1229,7 @@ def __exit__(self, *args): raise PermissionError("temporary directory cleanup denied") def supervised(command, **kwargs): - _write_private_result(kwargs, { + _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) return subprocess.CompletedProcess(command, 0, "", ""), False @@ -2509,7 +2509,7 @@ def supervised(command, *, cwd, writable_roots, **_kwargs): phase_roots.append(cwd) assert cwd not in writable_roots assert cwd / ".git" not in writable_roots - _write_private_result(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) + _write_framework_observation(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) return subprocess.CompletedProcess(command, 0, "", ""), False monkeypatch.setattr(runner_module, "run_supervised", supervised) @@ -2549,7 +2549,7 @@ def supervised(command, *, cwd, **_kwargs): else: (cwd / "candidate-cache").mkdir() (cwd / "candidate-cache/state").write_text("candidate", encoding="utf-8") - _write_private_result(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) + _write_framework_observation(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) return subprocess.CompletedProcess(command, 0, "", ""), False monkeypatch.setattr(runner_module, "run_supervised", supervised) @@ -2715,7 +2715,7 @@ def test_playwright_stdout_result_forgery_is_not_a_reporter_result(tmp_path: Pat assert not identities -def test_playwright_missing_private_result_has_bounded_diagnostics() -> None: +def test_playwright_missing_observation_has_bounded_diagnostics() -> None: result = subprocess.CompletedProcess( ["playwright"], 17, "", "mount failed\n" + ("x" * 600) ) @@ -2734,7 +2734,7 @@ def test_playwright_timeout_preserves_phase_reporter_and_cgroup_diagnostics( def supervised(command, **kwargs): collection = "--list" in command - _write_private_result(kwargs, { + _write_framework_observation(kwargs, { "tests": [{ "identity": IDENTITY, "status": "collected" if collection else "timedOut", @@ -2768,7 +2768,7 @@ def test_playwright_uses_two_gib_physical_and_64_gib_virtual_limits( def supervised(command, **kwargs): observed.append(kwargs) - _write_private_result(kwargs, { + _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) return subprocess.CompletedProcess(command, 0, "", ""), False diff --git a/tests/test_sync_core_runner_vitest.py b/tests/test_sync_core_runner_vitest.py index 1c1034adc0..6a91fc720d 100644 --- a/tests/test_sync_core_runner_vitest.py +++ b/tests/test_sync_core_runner_vitest.py @@ -1558,7 +1558,7 @@ def test_real_vitest_runs_copied_entrypoint_without_candidate_result_access( (root / "tests").mkdir() (root / "tests/widget.test.ts").write_text( "import { expect, test } from 'vitest';\n" - "test('result channel is private', () => {\n" + "test('observation environment is absent', () => {\n" " expect(process.env.PDD_TRUSTED_VITEST_OUTPUT).toBeUndefined();\n" "});\n", encoding="utf-8", From 40a31a0c13b4b6287e324f386ec1f32ba0708fec Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 01:17:29 -0700 Subject: [PATCH 093/233] test(sync): reserve timeout status for proven workloads --- tests/test_sync_core_supervisor.py | 171 +++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 12 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 588ae110bd..e1c0f7f203 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -336,18 +336,13 @@ def test_linux_sandbox_uses_portable_framework_observation_fifo( assert "result_fifo" not in plan.helper_source -def test_candidate_timeout_survives_missing_helper_result_and_exact_cleanup( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> None: - """A proven candidate deadline remains 124 after authoritative cleanup.""" - helper = """import pathlib,sys,time -control=pathlib.Path(sys.argv[1]) -(control/'ready').write_text('ready',encoding='ascii') -while not (control/'start').exists(): time.sleep(.001) -time.sleep(30) -""" +def _mock_scope_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, helper: str, + *, stop_scope=None, +) -> list[str]: + """Install one deterministic transient-scope process for state-machine tests.""" cgroup = tmp_path / "cgroup" - cgroup.mkdir() + cgroup.mkdir(exist_ok=True) (cgroup / "memory.events").write_text("oom 0\noom_kill 0\n", encoding="ascii") (cgroup / "pids.events").write_text("max 0\n", encoding="ascii") cleanup: list[str] = [] @@ -366,7 +361,8 @@ def sandbox(_command, _writable_roots, **kwargs): lambda _plan, _limits: (cgroup, {"oom": 0, "oom_kill": 0}, {"max": 0}), ) monkeypatch.setattr( - supervisor, "_stop_scope", lambda *_args: cleanup.append("scope") + supervisor, "_stop_scope", + stop_scope or (lambda *_args: cleanup.append("scope")), ) monkeypatch.setattr( supervisor, "_cleanup_staging", lambda _plan: cleanup.append("mounts") @@ -374,6 +370,40 @@ def sandbox(_command, _writable_roots, **kwargs): monkeypatch.setattr( supervisor, "_scope_properties", lambda *_args: {"Result": "success"} ) + return cleanup + + +def test_scope_setup_deadline_is_not_candidate_timeout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + cleanup = _mock_scope_run(tmp_path, monkeypatch, "import time;time.sleep(30)") + + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.03, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "phase=scope-setup" in result.stderr + assert cleanup == ["scope", "mounts"] + assert surviving is False + + +def test_candidate_timeout_requires_live_start_proof_and_exact_cleanup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A proven candidate deadline remains 124 after authoritative cleanup.""" + helper = """import json,pathlib,sys,time +control=pathlib.Path(sys.argv[1]) +(control/'ready').write_text('ready',encoding='ascii') +while not (control/'start').exists(): time.sleep(.001) +(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'}),encoding='ascii') +time.sleep(30) +""" + cleanup = _mock_scope_run(tmp_path, monkeypatch, helper) + proof = SimpleNamespace(pid=321, identity="start", cgroup=tmp_path / "cgroup") + monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) + monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: True) result, surviving = run_supervised( [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.05, @@ -387,6 +417,82 @@ def sandbox(_command, _writable_roots, **kwargs): assert surviving is False +def test_proven_timeout_with_cleanup_failure_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + helper = """import json,pathlib,sys,time +control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') +while not (control/'start').exists(): time.sleep(.001) +(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'})) +time.sleep(30) +""" + def fail_cleanup(*_args): + raise RuntimeError("scope remained loaded") + + _mock_scope_run(tmp_path, monkeypatch, helper, stop_scope=fail_cleanup) + proof = SimpleNamespace(pid=321, identity="start", cgroup=tmp_path / "cgroup") + monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) + monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: True) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.03, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "scope-cleanup" in result.stderr + + +def test_helper_stall_after_candidate_exit_is_not_timeout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + helper = """import json,pathlib,sys,time +control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') +while not (control/'start').exists(): time.sleep(.001) +(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'})) +(control/'candidate.json').write_text(json.dumps({'returncode':0})) +time.sleep(30) +""" + _mock_scope_run(tmp_path, monkeypatch, helper) + proof = SimpleNamespace(pid=321, identity="start", cgroup=tmp_path / "cgroup") + monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) + monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: False) + monkeypatch.setattr(supervisor, "_TRUSTED_POSTPROCESS_SECONDS", .03) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "trusted postprocessing did not finish" in result.stderr + + +def test_ordinary_candidate_exit_124_is_reserved_and_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + helper = """import json,pathlib,sys,time +control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') +while not (control/'start').exists(): time.sleep(.001) +(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'})) +for name in ('candidate.json','result.json'): + (control/name).write_text(json.dumps({'returncode':124})) +while not (control/'finish').exists(): time.sleep(.001) +""" + _mock_scope_run(tmp_path, monkeypatch, helper) + proof = SimpleNamespace(pid=321, identity="start", cgroup=tmp_path / "cgroup") + monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) + monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: False) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "reserved exit status 124" in result.stderr + + def test_sandbox_directory_bind_provides_parent_for_nested_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -987,6 +1093,47 @@ def test_live_processes_rejects_reused_pid_identity(monkeypatch) -> None: assert _live_processes({123: "old"}) == set() +@pytest.mark.parametrize( + ("payload", "identity", "membership", "match"), + [ + ({"pid": "bad", "identity": "start", "cgroup": "/scope"}, "start", "/scope", "invalid"), + ({"pid": 321, "identity": "old", "cgroup": "/scope"}, "new", "/scope", "identity"), + ({"pid": 321, "identity": "start", "cgroup": "/scope"}, "start", "/sibling", "cgroup"), + ], +) +def test_candidate_start_proof_rejects_malformed_reused_or_outside_process( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, payload: dict[str, object], + identity: str, membership: str, match: str, +) -> None: + marker = tmp_path / "candidate-start.json" + marker.write_text(json.dumps(payload), encoding="ascii") + cgroup = Path("/sys/fs/cgroup/scope") + monkeypatch.setattr(supervisor, "_process_identity", lambda _pid: identity) + monkeypatch.setattr(supervisor, "_process_cgroup", lambda _pid: membership) + monkeypatch.setattr(os, "kill", lambda *_args: None) + + with pytest.raises(RuntimeError, match=match): + supervisor._load_candidate_proof(marker, cgroup) + + +def test_candidate_start_proof_rejects_stale_dead_process( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + marker = tmp_path / "candidate-start.json" + marker.write_text( + json.dumps({"pid": 321, "identity": "start", "cgroup": "/scope"}), + encoding="ascii", + ) + monkeypatch.setattr(supervisor, "_process_identity", lambda _pid: "start") + monkeypatch.setattr(supervisor, "_process_cgroup", lambda _pid: "/scope") + monkeypatch.setattr( + os, "kill", lambda *_args: (_ for _ in ()).throw(ProcessLookupError()) + ) + + with pytest.raises(RuntimeError, match="not live"): + supervisor._load_candidate_proof(marker, Path("/sys/fs/cgroup/scope")) + + @pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") def test_writable_churn_cannot_escape_supervisor_cleanup(tmp_path: Path) -> None: program = ( From 47438cedea1d52fe765a75ff360151c267c7c061 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 01:22:01 -0700 Subject: [PATCH 094/233] fix(sync): prove candidate execution timeouts --- docs/global_sync_resolution_plan.md | 9 +- pdd/sync_core/runner.py | 2 +- pdd/sync_core/supervisor.py | 178 +++++++++++++++++++++++++--- tests/test_sync_core_supervisor.py | 29 +++++ 4 files changed, 198 insertions(+), 20 deletions(-) diff --git a/docs/global_sync_resolution_plan.md b/docs/global_sync_resolution_plan.md index a802088594..0ff11afb45 100644 --- a/docs/global_sync_resolution_plan.md +++ b/docs/global_sync_resolution_plan.md @@ -504,9 +504,10 @@ class VerificationProfile: - `standard_framework` is the compatibility default. It assumes the selected pytest, Jest, Vitest, or Playwright framework and its in-process hooks report - honestly. The checker owns and bounds the private FIFO/file-descriptor transport, - but that transport carries framework observations; it is not authenticated - evidence against candidate code in the same address space and descriptor table. + honestly. The checker creates, owns, and bounds the FIFO/file-descriptor framework + observation transport. It is candidate-visible under `standard_framework` and is + not authenticated evidence against candidate code in the same address space and + descriptor table. - `isolated_black_box` is stronger. It requires candidate code to execute only as an external SUT behind a process boundary that cannot mutate checker state or its observation channel. No current in-process framework adapter satisfies this @@ -1162,7 +1163,7 @@ Tasks: - Protected-base/control-plane `AttestationTrustPolicy` loader and verifier. - Post-validation signer using dedicated workload identity and no candidate code. - Checker-owned bounded framework-observation transport for standard-framework - adapters, plus an authenticated external observation boundary before any + adapters, plus an isolated external observation boundary before any isolated-black-box adapter is supported. - Transparency/audit record store and evidence cache invalidation service. - Threshold protected human-review attestation workflow for non-machine-verifiable diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 2b1b707dcc..c3fcd3ef08 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4165,7 +4165,7 @@ def _run_vitest( phase_toolchain: VitestPhaseToolchain | None = None, ) -> tuple[RunnerExecution, tuple[str, ...]]: # pylint: disable=too-many-return-statements - """Run exact protected Vitest paths with a private coordinator reporter.""" + """Run Vitest with a bounded checker-created framework observation channel.""" tool_root = command_root or root command_prefix = _vitest_command(config) if command_prefix is None: diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 665eb3600a..e5d53ecbe8 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -69,6 +69,15 @@ class _ScopePlan: tools: _TrustedTools +@dataclass(frozen=True) +class _CandidateProof: + """Kernel identity and exact cgroup membership for one released workload.""" + + pid: int + identity: str + cgroup: str + + def _scope_unit_name() -> str: """Return an unguessable unit name reserved to one supervisor invocation.""" return f"pdd-validator-{uuid.uuid4().hex}.scope" @@ -264,7 +273,8 @@ def _staged_bwrap( "path_tokens=json.loads(sys.argv[-5]); argv=json.loads(sys.argv[-4]); " "paths=json.loads(sys.argv[-3])", "targets=[control/'binds'/str(index) for index in range(len(paths))]", - "staged=[]; result=None; cleanup_error=None", + "staged=[]; result=None; cleanup_error=None; pid=None; child_reaped=False; " + "release_write=None", "def wait_for(name):", " path=control/name", " while not path.exists(): time.sleep(.01)", @@ -276,6 +286,14 @@ def _staged_bwrap( " if source == pathlib.Path('/sys/fs/cgroup') or not source.is_dir(): " "raise RuntimeError('protected scope cgroup is invalid')", " return source", + "def process_state(pid):", + " fields=pathlib.Path(f'/proc/{pid}/stat').read_text(encoding='ascii')" + ".rsplit(')',1)[1].split()", + " identity=fields[19]", + " lines=pathlib.Path(f'/proc/{pid}/cgroup').read_text(encoding='ascii').splitlines()", + " cgroup=next((line[3:] for line in lines if line.startswith('0::')),None)", + " if not identity or not cgroup: raise RuntimeError('candidate start proof unavailable')", + " return identity,cgroup", "def validate_tree(root):", " total=0", " for path in (root,*root.rglob('*')):", @@ -336,10 +354,25 @@ def _staged_bwrap( " argv=[str(cgroup_target) if value == '@PDD-CGROUP@' else value for value in argv]", " (control/'ready').write_text('ready',encoding='ascii')", " wait_for('start')", + " release_read,release_write=os.pipe()", " pid=os.fork()", " if pid == 0:", - " os.execvpe(argv[0],argv,os.environ)", + " os.close(release_write)", + " try: released=os.read(release_read,1)", + " except OSError: os._exit(125)", + " os.close(release_read)", + " if released != b'1': os._exit(125)", + " try: os.execvpe(argv[0],argv,os.environ)", + " except OSError: os._exit(125)", + " os.close(release_read)", + " identity,cgroup=process_state(pid)", + " started=control/'candidate-start.tmp'", + " started.write_text(json.dumps({'pid':pid,'identity':identity," + "'cgroup':cgroup}),encoding='ascii')", + " os.replace(started,control/'candidate-start.json')", + " os.write(release_write,b'1'); os.close(release_write); release_write=None", " _wait,status=os.waitpid(pid,0)", + " child_reaped=True", " result=os.waitstatus_to_exitcode(status)", " candidate=control/'candidate.tmp'", " candidate.write_text(json.dumps({'returncode':result}),encoding='ascii')", @@ -351,6 +384,12 @@ def _staged_bwrap( " os.replace(temporary,control/'result.json')", " wait_for('finish')", "finally:", + " if release_write is not None: os.close(release_write)", + " if pid is not None and not child_reaped:", + " try: os.kill(pid,9)", + " except ProcessLookupError: pass", + " try: os.waitpid(pid,0)", + " except ChildProcessError: pass", " for target in reversed(staged):", " completed=subprocess.run([umount,str(target)],capture_output=True," "text=True,check=False)", @@ -462,6 +501,65 @@ def _process_identity(pid: int) -> str | None: return None +def _process_cgroup(pid: int) -> str | None: + """Return one process's unified cgroup-v2 membership.""" + if not sys.platform.startswith("linux"): + return None + try: + lines = Path(f"/proc/{pid}/cgroup").read_text(encoding="ascii").splitlines() + except OSError: + return None + memberships = [line[3:] for line in lines if line.startswith("0::")] + return memberships[0] if len(memberships) == 1 and memberships[0] else None + + +def _candidate_proof_is_live(proof: _CandidateProof) -> bool: + """Revalidate a workload PID without accepting PID reuse or cgroup migration.""" + if ( + _process_identity(proof.pid) != proof.identity + or _process_cgroup(proof.pid) != proof.cgroup + ): + return False + try: + os.kill(proof.pid, 0) + except (ProcessLookupError, PermissionError): + return False + return True + + +def _load_candidate_proof(marker: Path, cgroup: Path) -> _CandidateProof: + """Validate the helper's bounded pre-exec workload identity marker.""" + try: + encoded = marker.read_bytes() + if len(encoded) > 4096: + raise RuntimeError("candidate start proof is invalid") + payload = json.loads(encoded) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise RuntimeError("candidate start proof is invalid") from exc + if not isinstance(payload, dict) or set(payload) != {"pid", "identity", "cgroup"}: + raise RuntimeError("candidate start proof is invalid") + pid = payload["pid"] + identity = payload["identity"] + membership = payload["cgroup"] + valid_pid = isinstance(pid, int) and not isinstance(pid, bool) and pid > 1 + valid_identity = isinstance(identity, str) and bool(identity) + valid_membership = isinstance(membership, str) and membership.startswith("/") + if not (valid_pid and valid_identity and valid_membership): + raise RuntimeError("candidate start proof is invalid") + try: + expected = "/" + cgroup.relative_to(Path("/sys/fs/cgroup")).as_posix() + except ValueError as exc: + raise RuntimeError("candidate start proof cgroup is invalid") from exc + if membership != expected or _process_cgroup(pid) != expected: + raise RuntimeError("candidate start proof cgroup does not match exact scope") + if _process_identity(pid) != identity: + raise RuntimeError("candidate start proof identity is stale or reused") + proof = _CandidateProof(pid, identity, membership) + if not _candidate_proof_is_live(proof): + raise RuntimeError("candidate start proof process is not live") + return proof + + def _live_processes(pids: dict[int, str | None]) -> set[int]: """Return only observations whose stable process identity still matches.""" live = set() @@ -862,10 +960,11 @@ def run_supervised( output_size = 0 output_overflow = False output_threads: list[threading.Thread] = [] - timed_out = False + candidate_timed_out = False failed_closed = False surviving = False candidate_returncode: int | None = None + candidate_proof: _CandidateProof | None = None process: subprocess.Popen[bytes] | None = None plan: _ScopePlan | None = None cgroup: Path | None = None @@ -1001,27 +1100,60 @@ def track_process_tree() -> None: tracker = threading.Thread(target=track_process_tree, daemon=True) tracker.start() - deadline = time.monotonic() + timeout + setup_deadline = time.monotonic() + timeout phase = "scope-setup" try: while not (control / "ready").exists(): if process.poll() is not None: raise RuntimeError("protected scope exited before verification") - if time.monotonic() >= deadline: - timed_out = True + if time.monotonic() >= setup_deadline: + failed_closed = True + add_diagnostic( + "protected supervisor phase=scope-setup: " + "scope construction did not finish\n" + ) break if fail_for_limit(): break time.sleep(.01) - if not timed_out and not failed_closed: + if not failed_closed: cgroup, memory_before, pids_before = _probe_scope(plan, limits) (control / "start").write_text("start", encoding="ascii") phase = "candidate-execution" + candidate_deadline = time.monotonic() + timeout + while not (control / "candidate-start.json").exists(): + if process.poll() is not None or (control / "candidate.json").exists(): + raise RuntimeError( + "scope exited without validated candidate start proof" + ) + if time.monotonic() >= candidate_deadline: + raise RuntimeError( + "scope produced no validated candidate start proof" + ) + if fail_for_limit(): + break + time.sleep(.01) + if not failed_closed: + candidate_proof = _load_candidate_proof( + control / "candidate-start.json", cgroup + ) while not (control / "candidate.json").exists(): + if failed_closed: + break if process.poll() is not None: break - if time.monotonic() >= deadline: - timed_out = True + if time.monotonic() >= candidate_deadline: + if ( + candidate_proof is not None + and _candidate_proof_is_live(candidate_proof) + ): + candidate_timed_out = True + else: + failed_closed = True + add_diagnostic( + "protected supervisor phase=candidate-execution: " + "candidate was not live at deadline\n" + ) break if fail_for_limit(): break @@ -1057,7 +1189,11 @@ def track_process_tree() -> None: raise RuntimeError("candidate result changed during handoff") fail_for_limit() record_events() - if candidate_returncode is None and not timed_out and not failed_closed: + if ( + candidate_returncode is None + and not candidate_timed_out + and not failed_closed + ): failed_closed = True add_diagnostic( "protected supervisor phase=candidate-execution: " @@ -1066,14 +1202,24 @@ def track_process_tree() -> None: elif ( not (control / "result.json").exists() and not failed_closed - and not timed_out + and not candidate_timed_out ): failed_closed = True add_diagnostic( "protected supervisor phase=trusted-postprocessing: " "scope produced no validated result\n" ) - if process.poll() is None and not timed_out and not failed_closed: + if ( + candidate_returncode == 124 + and (control / "result.json").exists() + and not failed_closed + ): + failed_closed = True + add_diagnostic( + "protected supervisor phase=result-handoff: " + "candidate used reserved exit status 124\n" + ) + if process.poll() is None and not candidate_timed_out and not failed_closed: (control / "finish").write_text("finish", encoding="ascii") try: process.wait(timeout=5) @@ -1086,8 +1232,10 @@ def track_process_tree() -> None: failed_closed = True add_diagnostic(f"protected supervisor phase={phase}: {exc}\n") finally: - if timed_out: - add_diagnostic(f"protected supervisor timeout phase={phase}\n") + if candidate_timed_out: + add_diagnostic( + "protected supervisor timeout phase=candidate-execution\n" + ) if cgroup is None and process.poll() is None: try: os.killpg(process.pid, signal.SIGKILL) @@ -1144,7 +1292,7 @@ def track_process_tree() -> None: failed_closed = True return subprocess.CompletedProcess( command, - 125 if failed_closed else (124 if timed_out else candidate_returncode), + 125 if failed_closed else (124 if candidate_timed_out else candidate_returncode), stdout_bytes.decode("utf-8", errors="replace"), stderr_bytes.decode("utf-8", errors="replace"), ), surviving diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index e1c0f7f203..dee27a1f9b 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -493,6 +493,27 @@ def test_ordinary_candidate_exit_124_is_reserved_and_fails_closed( assert "reserved exit status 124" in result.stderr +def test_candidate_result_without_start_proof_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + helper = """import json,pathlib,sys,time +control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') +while not (control/'start').exists(): time.sleep(.001) +(control/'candidate.json').write_text(json.dumps({'returncode':0})) +(control/'result.json').write_text(json.dumps({'returncode':0})) +time.sleep(30) +""" + _mock_scope_run(tmp_path, monkeypatch, helper) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "without validated candidate start proof" in result.stderr + + def test_sandbox_directory_bind_provides_parent_for_nested_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -678,6 +699,10 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert "memory.max" not in helper assert "ready" in helper and "start" in helper assert helper.index("start") < helper.index("os.fork()") + assert "release_read,release_write=os.pipe()" in helper + assert "released=os.read(release_read,1)" in helper + assert "identity,cgroup=process_state(pid)" in helper + assert "candidate-start.json" in helper assert "result.json" in helper and "finish" in helper assert "-t','tmpfs" in helper assert "/proc/self/mountinfo" in helper @@ -1016,6 +1041,7 @@ def test_candidate_deadline_stops_before_trusted_postprocessing( control=pathlib.Path(sys.argv[1]) (control/'ready').write_text('ready',encoding='ascii') while not (control/'start').exists(): time.sleep(.001) +(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'}),encoding='ascii') (control/'candidate.json').write_text(json.dumps({'returncode':0}),encoding='ascii') time.sleep(.2) (control/'result.json').write_text(json.dumps({'returncode':0}),encoding='ascii') @@ -1044,6 +1070,9 @@ def sandbox(_command, _writable_roots, **kwargs): monkeypatch.setattr( supervisor, "_scope_properties", lambda *_args: {"Result": "success"} ) + proof = SimpleNamespace(pid=321, identity="start", cgroup=cgroup) + monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) + monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: False) result, surviving = run_supervised( [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, From 6ed4cf018e8c506df580ac97b7300c12bd741657 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 01:44:50 -0700 Subject: [PATCH 095/233] test(sync): cover completed workload proof race --- tests/test_sync_core_supervisor.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index dee27a1f9b..6888f1d4cd 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1163,6 +1163,28 @@ def test_candidate_start_proof_rejects_stale_dead_process( supervisor._load_candidate_proof(marker, Path("/sys/fs/cgroup/scope")) +def test_completed_candidate_accepts_historical_trusted_start_proof( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + marker = tmp_path / "candidate-start.json" + marker.write_text( + json.dumps({"pid": 321, "identity": "start", "cgroup": "/scope"}), + encoding="ascii", + ) + completed = tmp_path / "candidate.json" + completed.write_text(json.dumps({"returncode": 0}), encoding="ascii") + monkeypatch.setattr(supervisor, "_process_identity", lambda _pid: None) + monkeypatch.setattr(supervisor, "_process_cgroup", lambda _pid: None) + + proof = supervisor._load_candidate_proof( + marker, Path("/sys/fs/cgroup/scope"), completed + ) + + assert proof.pid == 321 + assert proof.identity == "start" + assert proof.cgroup == "/scope" + + @pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") def test_writable_churn_cannot_escape_supervisor_cleanup(tmp_path: Path) -> None: program = ( From eef407b36515436fb13708d934ef560664d12b27 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 01:46:40 -0700 Subject: [PATCH 096/233] fix(sync): preserve completed workload proofs --- pdd/sync_core/supervisor.py | 57 ++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index e5d53ecbe8..56ee9c7e00 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -78,6 +78,10 @@ class _CandidateProof: cgroup: str +class _CandidateNotLive(RuntimeError): + """The marked workload exited before its trusted completion record appeared.""" + + def _scope_unit_name() -> str: """Return an unguessable unit name reserved to one supervisor invocation.""" return f"pdd-validator-{uuid.uuid4().hex}.scope" @@ -527,7 +531,9 @@ def _candidate_proof_is_live(proof: _CandidateProof) -> bool: return True -def _load_candidate_proof(marker: Path, cgroup: Path) -> _CandidateProof: +def _load_candidate_proof( + marker: Path, cgroup: Path, completion_marker: Path | None = None, +) -> _CandidateProof: """Validate the helper's bounded pre-exec workload identity marker.""" try: encoded = marker.read_bytes() @@ -541,23 +547,31 @@ def _load_candidate_proof(marker: Path, cgroup: Path) -> _CandidateProof: pid = payload["pid"] identity = payload["identity"] membership = payload["cgroup"] - valid_pid = isinstance(pid, int) and not isinstance(pid, bool) and pid > 1 - valid_identity = isinstance(identity, str) and bool(identity) - valid_membership = isinstance(membership, str) and membership.startswith("/") - if not (valid_pid and valid_identity and valid_membership): + valid_fields = ( + isinstance(pid, int) and not isinstance(pid, bool) and pid > 1, + isinstance(identity, str) and bool(identity), + isinstance(membership, str) and membership.startswith("/"), + ) + if not all(valid_fields): raise RuntimeError("candidate start proof is invalid") try: expected = "/" + cgroup.relative_to(Path("/sys/fs/cgroup")).as_posix() except ValueError as exc: raise RuntimeError("candidate start proof cgroup is invalid") from exc - if membership != expected or _process_cgroup(pid) != expected: + if membership != expected: raise RuntimeError("candidate start proof cgroup does not match exact scope") - if _process_identity(pid) != identity: + current_identity = _process_identity(pid) + current_cgroup = _process_cgroup(pid) + if current_identity is not None and current_identity != identity: raise RuntimeError("candidate start proof identity is stale or reused") + if current_cgroup is not None and current_cgroup != expected: + raise RuntimeError("candidate start proof cgroup does not match exact scope") proof = _CandidateProof(pid, identity, membership) - if not _candidate_proof_is_live(proof): - raise RuntimeError("candidate start proof process is not live") - return proof + if _candidate_proof_is_live(proof): + return proof + if completion_marker is not None and completion_marker.is_file(): + return proof + raise _CandidateNotLive("candidate start proof process is not live") def _live_processes(pids: dict[int, str | None]) -> set[int]: @@ -1133,10 +1147,25 @@ def track_process_tree() -> None: if fail_for_limit(): break time.sleep(.01) - if not failed_closed: - candidate_proof = _load_candidate_proof( - control / "candidate-start.json", cgroup - ) + while not failed_closed: + try: + candidate_proof = _load_candidate_proof( + control / "candidate-start.json", cgroup, + control / "candidate.json", + ) + break + except _CandidateNotLive: + if process.poll() is not None: + raise RuntimeError( + "scope exited before validated candidate result" + ) from None + if time.monotonic() >= candidate_deadline: + raise RuntimeError( + "candidate exited before validated result" + ) from None + if fail_for_limit(): + break + time.sleep(.01) while not (control / "candidate.json").exists(): if failed_closed: break From 0b8536ebbf6055a02401edc5cd19bdcd0bf07219 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 02:00:56 -0700 Subject: [PATCH 097/233] test(sync): move timeout authority into privileged helper --- tests/test_sync_core_supervisor.py | 309 +++++++++++++++++------------ 1 file changed, 182 insertions(+), 127 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 6888f1d4cd..53fe9d2106 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -373,6 +373,30 @@ def sandbox(_command, _writable_roots, **kwargs): return cleanup +def _terminal_helper( + returncode: int, timed_out: bool, *, write_result: bool = True, + result_record: dict[str, object] | None = None, output: str = "", +) -> str: + record = { + "version": 1, "state": "terminal", + "returncode": returncode, "timed_out": timed_out, + } + result = result_record if result_record is not None else record + result_source = ( + f"(control/'result.json').write_text({json.dumps(json.dumps(result))})" + if write_result else "" + ) + return f"""import pathlib,time +control=pathlib.Path(__import__('sys').argv[1]) +(control/'ready').write_text('ready') +while not (control/'start').exists(): time.sleep(.001) +print({output!r},flush=True) +(control/'candidate.json').write_text({json.dumps(json.dumps(record))}) +{result_source} +while not (control/'finish').exists(): time.sleep(.001) +""" + + def test_scope_setup_deadline_is_not_candidate_timeout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -389,24 +413,31 @@ def test_scope_setup_deadline_is_not_candidate_timeout( assert surviving is False -def test_candidate_timeout_requires_live_start_proof_and_exact_cleanup( +@pytest.mark.parametrize("returncode", [0, 1]) +def test_normal_candidate_status_is_preserved_from_protected_record( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, returncode: int, +) -> None: + cleanup = _mock_scope_run( + tmp_path, monkeypatch, _terminal_helper(returncode, False) + ) + + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == returncode, result.stderr + assert cleanup == ["scope", "mounts"] + assert surviving is False + + +def test_helper_pidfd_timeout_record_requires_exact_cleanup( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A proven candidate deadline remains 124 after authoritative cleanup.""" - helper = """import json,pathlib,sys,time -control=pathlib.Path(sys.argv[1]) -(control/'ready').write_text('ready',encoding='ascii') -while not (control/'start').exists(): time.sleep(.001) -(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'}),encoding='ascii') -time.sleep(30) -""" - cleanup = _mock_scope_run(tmp_path, monkeypatch, helper) - proof = SimpleNamespace(pid=321, identity="start", cgroup=tmp_path / "cgroup") - monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) - monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: True) + cleanup = _mock_scope_run(tmp_path, monkeypatch, _terminal_helper(124, True)) result, surviving = run_supervised( - [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.05, + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, env=dict(os.environ), writable_roots=(tmp_path,), ) @@ -417,22 +448,15 @@ def test_candidate_timeout_requires_live_start_proof_and_exact_cleanup( assert surviving is False -def test_proven_timeout_with_cleanup_failure_fails_closed( +def test_helper_timeout_with_cleanup_failure_fails_closed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - helper = """import json,pathlib,sys,time -control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') -while not (control/'start').exists(): time.sleep(.001) -(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'})) -time.sleep(30) -""" def fail_cleanup(*_args): raise RuntimeError("scope remained loaded") - _mock_scope_run(tmp_path, monkeypatch, helper, stop_scope=fail_cleanup) - proof = SimpleNamespace(pid=321, identity="start", cgroup=tmp_path / "cgroup") - monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) - monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: True) + _mock_scope_run( + tmp_path, monkeypatch, _terminal_helper(124, True), stop_scope=fail_cleanup + ) result, _surviving = run_supervised( [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.03, @@ -446,17 +470,12 @@ def fail_cleanup(*_args): def test_helper_stall_after_candidate_exit_is_not_timeout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - helper = """import json,pathlib,sys,time + helper = """import pathlib,sys,time control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') while not (control/'start').exists(): time.sleep(.001) -(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'})) -(control/'candidate.json').write_text(json.dumps({'returncode':0})) time.sleep(30) """ _mock_scope_run(tmp_path, monkeypatch, helper) - proof = SimpleNamespace(pid=321, identity="start", cgroup=tmp_path / "cgroup") - monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) - monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: False) monkeypatch.setattr(supervisor, "_TRUSTED_POSTPROCESS_SECONDS", .03) result, _surviving = run_supervised( @@ -465,24 +484,19 @@ def test_helper_stall_after_candidate_exit_is_not_timeout( ) assert result.returncode == 125 - assert "trusted postprocessing did not finish" in result.stderr + assert "protected candidate record" in result.stderr -def test_ordinary_candidate_exit_124_is_reserved_and_fails_closed( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize("failure", ["pidfd_open", "poll", "waitpid"]) +def test_helper_pidfd_failure_without_terminal_record_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str, ) -> None: - helper = """import json,pathlib,sys,time + helper = f"""import pathlib,sys,time control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') while not (control/'start').exists(): time.sleep(.001) -(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'})) -for name in ('candidate.json','result.json'): - (control/name).write_text(json.dumps({'returncode':124})) -while not (control/'finish').exists(): time.sleep(.001) +raise RuntimeError({failure!r}) """ _mock_scope_run(tmp_path, monkeypatch, helper) - proof = SimpleNamespace(pid=321, identity="start", cgroup=tmp_path / "cgroup") - monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) - monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: False) result, _surviving = run_supervised( [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, @@ -490,19 +504,43 @@ def test_ordinary_candidate_exit_124_is_reserved_and_fails_closed( ) assert result.returncode == 125 - assert "reserved exit status 124" in result.stderr + assert "timeout phase=candidate-execution" not in result.stderr -def test_candidate_result_without_start_proof_fails_closed( +def test_ordinary_candidate_exit_124_is_reserved_and_fails_closed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - helper = """import json,pathlib,sys,time -control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') -while not (control/'start').exists(): time.sleep(.001) -(control/'candidate.json').write_text(json.dumps({'returncode':0})) -(control/'result.json').write_text(json.dumps({'returncode':0})) -time.sleep(30) -""" + _mock_scope_run(tmp_path, monkeypatch, _terminal_helper(124, False)) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "reserved exit status 124" in result.stderr + + +@pytest.mark.parametrize( + "record", + [ + {"version": 1, "state": "terminal", "returncode": 124}, + {"version": 1, "state": "terminal", "returncode": 124, "timed_out": "yes"}, + {"version": 1, "state": "running", "returncode": 124, "timed_out": True}, + {"version": 1, "state": "terminal", "returncode": 0, "timed_out": True}, + ], +) +def test_malformed_candidate_terminal_record_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, record: dict[str, object], +) -> None: + helper = _terminal_helper(0, False).replace( + json.dumps(json.dumps({ + "version": 1, "state": "terminal", "returncode": 0, + "timed_out": False, + })), + json.dumps(json.dumps(record)), + 1, + ) _mock_scope_run(tmp_path, monkeypatch, helper) result, _surviving = run_supervised( @@ -511,7 +549,86 @@ def test_candidate_result_without_start_proof_fails_closed( ) assert result.returncode == 125 - assert "without validated candidate start proof" in result.stderr + + +def test_inconsistent_candidate_and_result_records_fail_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + inconsistent = { + "version": 1, "state": "terminal", "returncode": 124, "timed_out": True, + } + _mock_scope_run( + tmp_path, monkeypatch, + _terminal_helper(0, False, result_record=inconsistent), + ) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "changed during handoff" in result.stderr + + +def test_helper_timeout_with_quota_postprocessing_failure_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _mock_scope_run( + tmp_path, monkeypatch, _terminal_helper(124, True, write_result=False) + ) + monkeypatch.setattr(supervisor, "_TRUSTED_POSTPROCESS_SECONDS", .03) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "trusted postprocessing did not finish" in result.stderr + + +def test_helper_timeout_with_output_violation_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _mock_scope_run( + tmp_path, monkeypatch, _terminal_helper(124, True, output="x" * 4096) + ) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + limits=SupervisorLimits(max_output_bytes=1024), + ) + + assert result.returncode == 125 + + +def test_helper_timeout_with_surviving_process_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _mock_scope_run(tmp_path, monkeypatch, _terminal_helper(124, True)) + monkeypatch.setattr(supervisor, "_supervised_descendants", lambda _token: {999}) + monkeypatch.setattr(supervisor, "_process_identity", lambda _pid: "identity") + monkeypatch.setattr(supervisor, "_live_processes", lambda _tracked: {999}) + monkeypatch.setattr(os, "kill", lambda *_args: None) + + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert surviving is True + + +def test_parent_timeout_protocol_never_observes_candidate_pid() -> None: + source = inspect.getsource(run_supervised) + + assert "candidate-start" not in source + assert "_candidate_proof" not in source + assert "_process_cgroup" not in source + assert "kill(pid, 0)" not in source def test_sandbox_directory_bind_provides_parent_for_nested_file( @@ -691,7 +808,9 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () ) - _argv, plan = _sandbox_command([sys.executable, "-c", "pass"], (tmp_path,)) + argv, plan = _sandbox_command( + [sys.executable, "-c", "pass"], (tmp_path,), candidate_timeout=17 + ) helper = plan.helper_source compile(helper, "", "exec") @@ -699,10 +818,13 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert "memory.max" not in helper assert "ready" in helper and "start" in helper assert helper.index("start") < helper.index("os.fork()") - assert "release_read,release_write=os.pipe()" in helper - assert "released=os.read(release_read,1)" in helper - assert "identity,cgroup=process_state(pid)" in helper - assert "candidate-start.json" in helper + assert "os.pidfd_open(pid" in helper + assert "select.poll()" in helper + assert "poller.poll" in helper + assert "os.waitpid(pid,0)" in helper + assert "timed_out" in helper + assert "candidate-start.json" not in helper + assert json.loads(argv[-1])["timeout"] == 17 assert "result.json" in helper and "finish" in helper assert "-t','tmpfs" in helper assert "/proc/self/mountinfo" in helper @@ -1041,10 +1163,10 @@ def test_candidate_deadline_stops_before_trusted_postprocessing( control=pathlib.Path(sys.argv[1]) (control/'ready').write_text('ready',encoding='ascii') while not (control/'start').exists(): time.sleep(.001) -(control/'candidate-start.json').write_text(json.dumps({'pid':321,'identity':'start','cgroup':'scope'}),encoding='ascii') -(control/'candidate.json').write_text(json.dumps({'returncode':0}),encoding='ascii') +record={'version':1,'state':'terminal','returncode':0,'timed_out':False} +(control/'candidate.json').write_text(json.dumps(record),encoding='ascii') time.sleep(.2) -(control/'result.json').write_text(json.dumps({'returncode':0}),encoding='ascii') +(control/'result.json').write_text(json.dumps(record),encoding='ascii') while not (control/'finish').exists(): time.sleep(.001) """ cgroup = tmp_path / "cgroup" @@ -1070,10 +1192,6 @@ def sandbox(_command, _writable_roots, **kwargs): monkeypatch.setattr( supervisor, "_scope_properties", lambda *_args: {"Result": "success"} ) - proof = SimpleNamespace(pid=321, identity="start", cgroup=cgroup) - monkeypatch.setattr(supervisor, "_load_candidate_proof", lambda *_args: proof) - monkeypatch.setattr(supervisor, "_candidate_proof_is_live", lambda _proof: False) - result, surviving = run_supervised( [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, env=dict(os.environ), writable_roots=(tmp_path,), @@ -1122,69 +1240,6 @@ def test_live_processes_rejects_reused_pid_identity(monkeypatch) -> None: assert _live_processes({123: "old"}) == set() -@pytest.mark.parametrize( - ("payload", "identity", "membership", "match"), - [ - ({"pid": "bad", "identity": "start", "cgroup": "/scope"}, "start", "/scope", "invalid"), - ({"pid": 321, "identity": "old", "cgroup": "/scope"}, "new", "/scope", "identity"), - ({"pid": 321, "identity": "start", "cgroup": "/scope"}, "start", "/sibling", "cgroup"), - ], -) -def test_candidate_start_proof_rejects_malformed_reused_or_outside_process( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, payload: dict[str, object], - identity: str, membership: str, match: str, -) -> None: - marker = tmp_path / "candidate-start.json" - marker.write_text(json.dumps(payload), encoding="ascii") - cgroup = Path("/sys/fs/cgroup/scope") - monkeypatch.setattr(supervisor, "_process_identity", lambda _pid: identity) - monkeypatch.setattr(supervisor, "_process_cgroup", lambda _pid: membership) - monkeypatch.setattr(os, "kill", lambda *_args: None) - - with pytest.raises(RuntimeError, match=match): - supervisor._load_candidate_proof(marker, cgroup) - - -def test_candidate_start_proof_rejects_stale_dead_process( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> None: - marker = tmp_path / "candidate-start.json" - marker.write_text( - json.dumps({"pid": 321, "identity": "start", "cgroup": "/scope"}), - encoding="ascii", - ) - monkeypatch.setattr(supervisor, "_process_identity", lambda _pid: "start") - monkeypatch.setattr(supervisor, "_process_cgroup", lambda _pid: "/scope") - monkeypatch.setattr( - os, "kill", lambda *_args: (_ for _ in ()).throw(ProcessLookupError()) - ) - - with pytest.raises(RuntimeError, match="not live"): - supervisor._load_candidate_proof(marker, Path("/sys/fs/cgroup/scope")) - - -def test_completed_candidate_accepts_historical_trusted_start_proof( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> None: - marker = tmp_path / "candidate-start.json" - marker.write_text( - json.dumps({"pid": 321, "identity": "start", "cgroup": "/scope"}), - encoding="ascii", - ) - completed = tmp_path / "candidate.json" - completed.write_text(json.dumps({"returncode": 0}), encoding="ascii") - monkeypatch.setattr(supervisor, "_process_identity", lambda _pid: None) - monkeypatch.setattr(supervisor, "_process_cgroup", lambda _pid: None) - - proof = supervisor._load_candidate_proof( - marker, Path("/sys/fs/cgroup/scope"), completed - ) - - assert proof.pid == 321 - assert proof.identity == "start" - assert proof.cgroup == "/scope" - - @pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") def test_writable_churn_cannot_escape_supervisor_cleanup(tmp_path: Path) -> None: program = ( From a8744c11b698a451ab0cf2d4f9b6b66297c87fc7 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 02:07:23 -0700 Subject: [PATCH 098/233] fix(sync): make privileged helper own candidate deadline --- pdd/sync_core/supervisor.py | 287 +++++++++-------------------- tests/test_sync_core_supervisor.py | 1 + 2 files changed, 91 insertions(+), 197 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 56ee9c7e00..bf65fe75c0 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import math import os import re import shutil @@ -70,16 +71,11 @@ class _ScopePlan: @dataclass(frozen=True) -class _CandidateProof: - """Kernel identity and exact cgroup membership for one released workload.""" +class _CandidateRecord: + """Validated terminal status emitted by the privileged direct-child helper.""" - pid: int - identity: str - cgroup: str - - -class _CandidateNotLive(RuntimeError): - """The marked workload exited before its trusted completion record appeared.""" + returncode: int + timed_out: bool def _scope_unit_name() -> str: @@ -261,7 +257,7 @@ def _staged_bwrap( argv: list[str], sources: list[Path], path_tokens: list[str], *, writable_roots: tuple[Path, ...], writable_specs: list[tuple[str, int, str]], unit_name: str, control_directory: Path, limits: SupervisorLimits, - tools: _TrustedTools, + candidate_timeout: float, tools: _TrustedTools, ) -> tuple[list[str], _ScopePlan]: """Build one scope-held helper that releases Bubblewrap after verification.""" unit_name = _validated_scope_unit(unit_name) @@ -269,7 +265,7 @@ def _staged_bwrap( control_directory / "binds" / str(index) for index in range(len(sources)) ) helper = "\n".join(( - "import json,os,pathlib,shutil,stat,subprocess,sys,time", + "import json,math,os,pathlib,select,shutil,stat,subprocess,sys,time", "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[4])]", "writable_specs=json.loads(sys.argv[5])", @@ -277,8 +273,8 @@ def _staged_bwrap( "path_tokens=json.loads(sys.argv[-5]); argv=json.loads(sys.argv[-4]); " "paths=json.loads(sys.argv[-3])", "targets=[control/'binds'/str(index) for index in range(len(paths))]", - "staged=[]; result=None; cleanup_error=None; pid=None; child_reaped=False; " - "release_write=None", + "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None; " + "pidfd=None; child_reaped=False", "def wait_for(name):", " path=control/name", " while not path.exists(): time.sleep(.01)", @@ -290,14 +286,6 @@ def _staged_bwrap( " if source == pathlib.Path('/sys/fs/cgroup') or not source.is_dir(): " "raise RuntimeError('protected scope cgroup is invalid')", " return source", - "def process_state(pid):", - " fields=pathlib.Path(f'/proc/{pid}/stat').read_text(encoding='ascii')" - ".rsplit(')',1)[1].split()", - " identity=fields[19]", - " lines=pathlib.Path(f'/proc/{pid}/cgroup').read_text(encoding='ascii').splitlines()", - " cgroup=next((line[3:] for line in lines if line.startswith('0::')),None)", - " if not identity or not cgroup: raise RuntimeError('candidate start proof unavailable')", - " return identity,cgroup", "def validate_tree(root):", " total=0", " for path in (root,*root.rglob('*')):", @@ -358,37 +346,37 @@ def _staged_bwrap( " argv=[str(cgroup_target) if value == '@PDD-CGROUP@' else value for value in argv]", " (control/'ready').write_text('ready',encoding='ascii')", " wait_for('start')", - " release_read,release_write=os.pipe()", " pid=os.fork()", " if pid == 0:", - " os.close(release_write)", - " try: released=os.read(release_read,1)", - " except OSError: os._exit(125)", - " os.close(release_read)", - " if released != b'1': os._exit(125)", " try: os.execvpe(argv[0],argv,os.environ)", " except OSError: os._exit(125)", - " os.close(release_read)", - " identity,cgroup=process_state(pid)", - " started=control/'candidate-start.tmp'", - " started.write_text(json.dumps({'pid':pid,'identity':identity," - "'cgroup':cgroup}),encoding='ascii')", - " os.replace(started,control/'candidate-start.json')", - " os.write(release_write,b'1'); os.close(release_write); release_write=None", + " if not hasattr(os,'pidfd_open'): raise RuntimeError('pidfd_open unavailable')", + " pidfd=os.pidfd_open(pid,0)", + " poller=select.poll(); poller.register(pidfd,select.POLLIN)", + " timeout_ms=math.ceil(limits['timeout']*1000)", + " events=poller.poll(timeout_ms)", + " if events and not any(fd==pidfd and mask&select.POLLIN for fd,mask in events):", + " raise RuntimeError('pidfd poll returned invalid events')", + " if not events:", + " timed_out=True; os.kill(pid,9)", " _wait,status=os.waitpid(pid,0)", + " if _wait != pid: raise RuntimeError('waitpid returned wrong child')", " child_reaped=True", - " result=os.waitstatus_to_exitcode(status)", + " result=124 if timed_out else os.waitstatus_to_exitcode(status)", + " os.close(pidfd); pidfd=None", + " record={'version':1,'state':'terminal','returncode':result," + "'timed_out':timed_out}", " candidate=control/'candidate.tmp'", - " candidate.write_text(json.dumps({'returncode':result}),encoding='ascii')", + " candidate.write_text(json.dumps(record),encoding='ascii')", " os.replace(candidate,control/'candidate.json')", " if sum(validate_tree(path) for path in writable_paths) > " "limits['writable']: raise RuntimeError('final writable quota exceeded')", " temporary=control/'result.tmp'", - " temporary.write_text(json.dumps({'returncode':result}),encoding='ascii')", + " temporary.write_text(json.dumps(record),encoding='ascii')", " os.replace(temporary,control/'result.json')", " wait_for('finish')", "finally:", - " if release_write is not None: os.close(release_write)", + " if pidfd is not None: os.close(pidfd)", " if pid is not None and not child_reaped:", " try: os.kill(pid,9)", " except ProcessLookupError: pass", @@ -420,7 +408,8 @@ def _staged_bwrap( json.dumps(path_tokens), json.dumps(argv), json.dumps([str(path) for path in sources]), unit_name, json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, - "writable": limits.max_writable_bytes}), + "writable": limits.max_writable_bytes, + "timeout": candidate_timeout}), ] return command, plan @@ -469,29 +458,6 @@ def _supervised_descendants(token: str) -> set[int]: return found -def _process_descendants(root_pid: int) -> set[int]: - """Return the current transitive process tree without trusting child state.""" - listing = subprocess.run( - ["ps", "-axo", "pid=,ppid="], capture_output=True, text=True, check=False, - ) - children: dict[int, set[int]] = {} - for line in listing.stdout.splitlines(): - try: - pid, parent = (int(value) for value in line.split()) - except (ValueError, TypeError): - continue - children.setdefault(parent, set()).add(pid) - found: set[int] = set() - pending = [root_pid] - while pending: - parent = pending.pop() - for child in children.get(parent, ()): - if child not in found: - found.add(child) - pending.append(child) - return found - - def _process_identity(pid: int) -> str | None: """Return a stable Linux process identity, including kernel start time.""" if sys.platform.startswith("linux"): @@ -505,73 +471,32 @@ def _process_identity(pid: int) -> str | None: return None -def _process_cgroup(pid: int) -> str | None: - """Return one process's unified cgroup-v2 membership.""" - if not sys.platform.startswith("linux"): - return None +def _load_candidate_record(path: Path) -> _CandidateRecord: + """Read one strict bounded terminal record from the protected helper.""" try: - lines = Path(f"/proc/{pid}/cgroup").read_text(encoding="ascii").splitlines() - except OSError: - return None - memberships = [line[3:] for line in lines if line.startswith("0::")] - return memberships[0] if len(memberships) == 1 and memberships[0] else None - - -def _candidate_proof_is_live(proof: _CandidateProof) -> bool: - """Revalidate a workload PID without accepting PID reuse or cgroup migration.""" - if ( - _process_identity(proof.pid) != proof.identity - or _process_cgroup(proof.pid) != proof.cgroup - ): - return False - try: - os.kill(proof.pid, 0) - except (ProcessLookupError, PermissionError): - return False - return True - - -def _load_candidate_proof( - marker: Path, cgroup: Path, completion_marker: Path | None = None, -) -> _CandidateProof: - """Validate the helper's bounded pre-exec workload identity marker.""" - try: - encoded = marker.read_bytes() + encoded = path.read_bytes() if len(encoded) > 4096: - raise RuntimeError("candidate start proof is invalid") + raise RuntimeError("protected candidate record is invalid") payload = json.loads(encoded) except (OSError, UnicodeError, json.JSONDecodeError) as exc: - raise RuntimeError("candidate start proof is invalid") from exc - if not isinstance(payload, dict) or set(payload) != {"pid", "identity", "cgroup"}: - raise RuntimeError("candidate start proof is invalid") - pid = payload["pid"] - identity = payload["identity"] - membership = payload["cgroup"] - valid_fields = ( - isinstance(pid, int) and not isinstance(pid, bool) and pid > 1, - isinstance(identity, str) and bool(identity), - isinstance(membership, str) and membership.startswith("/"), + raise RuntimeError("protected candidate record is invalid") from exc + expected_keys = {"version", "state", "returncode", "timed_out"} + if not isinstance(payload, dict) or set(payload) != expected_keys: + raise RuntimeError("protected candidate record is invalid") + returncode = payload["returncode"] + timed_out = payload["timed_out"] + valid_header = payload["version"] == 1 and payload["state"] == "terminal" + valid_returncode = ( + isinstance(returncode, int) + and not isinstance(returncode, bool) + and -255 <= returncode <= 255 ) - if not all(valid_fields): - raise RuntimeError("candidate start proof is invalid") - try: - expected = "/" + cgroup.relative_to(Path("/sys/fs/cgroup")).as_posix() - except ValueError as exc: - raise RuntimeError("candidate start proof cgroup is invalid") from exc - if membership != expected: - raise RuntimeError("candidate start proof cgroup does not match exact scope") - current_identity = _process_identity(pid) - current_cgroup = _process_cgroup(pid) - if current_identity is not None and current_identity != identity: - raise RuntimeError("candidate start proof identity is stale or reused") - if current_cgroup is not None and current_cgroup != expected: - raise RuntimeError("candidate start proof cgroup does not match exact scope") - proof = _CandidateProof(pid, identity, membership) - if _candidate_proof_is_live(proof): - return proof - if completion_marker is not None and completion_marker.is_file(): - return proof - raise _CandidateNotLive("candidate start proof process is not live") + valid_timeout = isinstance(timed_out, bool) and ( + not timed_out or returncode == 124 + ) + if not (valid_header and valid_returncode and valid_timeout): + raise RuntimeError("protected candidate record is invalid") + return _CandidateRecord(returncode, timed_out) def _live_processes(pids: dict[int, str | None]) -> set[int]: @@ -810,6 +735,7 @@ def _cleanup_staging(plan: _ScopePlan) -> None: def _sandbox_command( command: list[str], writable_roots: tuple[Path, ...], *, cwd: Path | None = None, limits: SupervisorLimits = SupervisorLimits(), + candidate_timeout: float = 300, readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), @@ -825,6 +751,13 @@ def _sandbox_command( "unsupported protected sandbox: macOS cannot prove process lifetime isolation" ) if sys.platform.startswith("linux"): + if ( + isinstance(candidate_timeout, bool) + or not isinstance(candidate_timeout, (int, float)) + or candidate_timeout <= 0 + or not math.isfinite(candidate_timeout) + ): + raise RuntimeError("protected sandbox requires a finite positive timeout") tools = _trusted_tools() if os.getuid() == 0: raise RuntimeError( @@ -947,7 +880,7 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: control_directory=control_directory or ( Path(tempfile.gettempdir()) / f"pdd-scope-{uuid.uuid4().hex}" ), - limits=limits, tools=tools, + limits=limits, candidate_timeout=candidate_timeout, tools=tools, ) raise RuntimeError("unsupported sandbox platform or mechanism") @@ -978,15 +911,13 @@ def run_supervised( failed_closed = False surviving = False candidate_returncode: int | None = None - candidate_proof: _CandidateProof | None = None + candidate_record: _CandidateRecord | None = None process: subprocess.Popen[bytes] | None = None plan: _ScopePlan | None = None cgroup: Path | None = None memory_before: dict[str, int] = {} pids_before: dict[str, int] = {} tracked: dict[int, str | None] = {} - tracking_done = threading.Event() - tracker: threading.Thread | None = None phase = "construction" def add_diagnostic(value: str) -> None: @@ -1066,6 +997,7 @@ def record_events() -> None: readable_bindings=readable_bindings, writable_bindings=writable_bindings, result_fifo=result_fifo, result_fd=result_fd, + candidate_timeout=timeout, unit_name=unit_name, control_directory=control, ) _prepare_staging(plan) @@ -1107,13 +1039,6 @@ def record_events() -> None: for output_thread in output_threads: output_thread.start() - def track_process_tree() -> None: - while not tracking_done.wait(0.005): - for pid in _process_descendants(process.pid): - tracked.setdefault(pid, _process_identity(pid)) - - tracker = threading.Thread(target=track_process_tree, daemon=True) - tracker.start() setup_deadline = time.monotonic() + timeout phase = "scope-setup" try: @@ -1134,67 +1059,32 @@ def track_process_tree() -> None: cgroup, memory_before, pids_before = _probe_scope(plan, limits) (control / "start").write_text("start", encoding="ascii") phase = "candidate-execution" - candidate_deadline = time.monotonic() + timeout - while not (control / "candidate-start.json").exists(): - if process.poll() is not None or (control / "candidate.json").exists(): - raise RuntimeError( - "scope exited without validated candidate start proof" - ) - if time.monotonic() >= candidate_deadline: - raise RuntimeError( - "scope produced no validated candidate start proof" - ) - if fail_for_limit(): - break - time.sleep(.01) - while not failed_closed: - try: - candidate_proof = _load_candidate_proof( - control / "candidate-start.json", cgroup, - control / "candidate.json", - ) - break - except _CandidateNotLive: - if process.poll() is not None: - raise RuntimeError( - "scope exited before validated candidate result" - ) from None - if time.monotonic() >= candidate_deadline: - raise RuntimeError( - "candidate exited before validated result" - ) from None - if fail_for_limit(): - break - time.sleep(.01) + helper_deadline = ( + time.monotonic() + timeout + _TRUSTED_POSTPROCESS_SECONDS + ) while not (control / "candidate.json").exists(): if failed_closed: break if process.poll() is not None: break - if time.monotonic() >= candidate_deadline: - if ( - candidate_proof is not None - and _candidate_proof_is_live(candidate_proof) - ): - candidate_timed_out = True - else: - failed_closed = True - add_diagnostic( - "protected supervisor phase=candidate-execution: " - "candidate was not live at deadline\n" - ) + if time.monotonic() >= helper_deadline: + failed_closed = True + add_diagnostic( + "protected supervisor phase=candidate-execution: " + "protected candidate record did not arrive\n" + ) break if fail_for_limit(): break time.sleep(.01) if (control / "candidate.json").exists(): - payload = json.loads( - (control / "candidate.json").read_text(encoding="ascii") + candidate_record = _load_candidate_record( + control / "candidate.json" ) - candidate_returncode = int(payload["returncode"]) phase = "trusted-postprocessing" - postprocess_deadline = ( - time.monotonic() + _TRUSTED_POSTPROCESS_SECONDS + postprocess_deadline = min( + helper_deadline, + time.monotonic() + _TRUSTED_POSTPROCESS_SECONDS, ) while not (control / "result.json").exists(): if process.poll() is not None: @@ -1211,27 +1101,25 @@ def track_process_tree() -> None: time.sleep(.01) if (control / "result.json").exists(): phase = "result-handoff" - payload = json.loads( - (control / "result.json").read_text(encoding="ascii") - ) - if int(payload["returncode"]) != candidate_returncode: + result_record = _load_candidate_record(control / "result.json") + if result_record != candidate_record: raise RuntimeError("candidate result changed during handoff") + candidate_returncode = result_record.returncode + candidate_timed_out = result_record.timed_out fail_for_limit() record_events() if ( - candidate_returncode is None - and not candidate_timed_out + candidate_record is None and not failed_closed ): failed_closed = True add_diagnostic( "protected supervisor phase=candidate-execution: " - "scope produced no candidate result\n" + "scope produced no protected candidate record\n" ) elif ( not (control / "result.json").exists() and not failed_closed - and not candidate_timed_out ): failed_closed = True add_diagnostic( @@ -1240,6 +1128,7 @@ def track_process_tree() -> None: ) if ( candidate_returncode == 124 + and not candidate_timed_out and (control / "result.json").exists() and not failed_closed ): @@ -1248,15 +1137,22 @@ def track_process_tree() -> None: "protected supervisor phase=result-handoff: " "candidate used reserved exit status 124\n" ) - if process.poll() is None and not candidate_timed_out and not failed_closed: + if process.poll() is None and not failed_closed: (control / "finish").write_text("finish", encoding="ascii") try: - process.wait(timeout=5) + remaining = max(0, postprocess_deadline - time.monotonic()) + process.wait(timeout=remaining) except subprocess.TimeoutExpired: failed_closed = True add_diagnostic( "protected supervisor result-handoff did not finish\n" ) + if (control / "cleanup-error").exists(): + failed_closed = True + add_diagnostic( + "protected supervisor phase=mount-cleanup: " + "privileged helper cleanup failed\n" + ) except (OSError, ValueError, KeyError, RuntimeError) as exc: failed_closed = True add_diagnostic(f"protected supervisor phase={phase}: {exc}\n") @@ -1288,9 +1184,6 @@ def track_process_tree() -> None: failed_closed = True add_diagnostic(f"protected supervisor phase=mount-cleanup: {exc}\n") finally: - tracking_done.set() - if tracker is not None: - tracker.join(timeout=1) for output_thread in output_threads: output_thread.join(timeout=1) if output_thread.is_alive(): diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 53fe9d2106..36fc65e34c 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -627,6 +627,7 @@ def test_parent_timeout_protocol_never_observes_candidate_pid() -> None: assert "candidate-start" not in source assert "_candidate_proof" not in source + assert "_process_descendants" not in source assert "_process_cgroup" not in source assert "kill(pid, 0)" not in source From b72a8b1d79000f514300c6e39929d5798b8997a4 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 03:00:08 -0700 Subject: [PATCH 099/233] test(sync): cover trusted timeout failure boundaries --- tests/test_sync_core_runner_vitest.py | 30 +++ tests/test_sync_core_supervisor.py | 270 ++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) diff --git a/tests/test_sync_core_runner_vitest.py b/tests/test_sync_core_runner_vitest.py index 6a91fc720d..2ac3cbc2e3 100644 --- a/tests/test_sync_core_runner_vitest.py +++ b/tests/test_sync_core_runner_vitest.py @@ -42,6 +42,36 @@ from pdd.sync_core.evidence_store import attestation_payload, decode_attestation +def test_framework_observation_fifo_eof_waits_for_late_writer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Completion: + def __init__(self) -> None: + self.checks = 0 + self.waits = [] + + def is_set(self) -> bool: + self.checks += 1 + return self.checks > 3 + + def wait(self, timeout: float) -> bool: + self.waits.append(timeout) + return False + + completion = Completion() + result: dict[str, object] = {} + monkeypatch.setattr( + runner_module.select, "select", lambda *_args: ([17], [], []) + ) + monkeypatch.setattr(runner_module.os, "read", lambda *_args: b"") + + runner_module._drain_result_pipe(17, completion, result) + + assert completion.waits + assert all(wait == .01 for wait in completion.waits) + assert result["data"] == b"" + + UNIT = UnitId("repository-1", PurePosixPath("prompts/widget_ts.prompt"), "typescript") IDENTITY = "tests/widget.test.ts::widget works" diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 36fc65e34c..c43f5a34d9 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4,6 +4,7 @@ import inspect import json import math +import signal import shutil import subprocess import sys @@ -376,6 +377,7 @@ def sandbox(_command, _writable_roots, **kwargs): def _terminal_helper( returncode: int, timed_out: bool, *, write_result: bool = True, result_record: dict[str, object] | None = None, output: str = "", + helper_exit: int | None = None, ) -> str: record = { "version": 1, "state": "terminal", @@ -386,6 +388,10 @@ def _terminal_helper( f"(control/'result.json').write_text({json.dumps(json.dumps(result))})" if write_result else "" ) + encoded_exit = ( + helper_exit if helper_exit is not None + else returncode if returncode >= 0 else 128 - returncode + ) return f"""import pathlib,time control=pathlib.Path(__import__('sys').argv[1]) (control/'ready').write_text('ready') @@ -394,6 +400,7 @@ def _terminal_helper( (control/'candidate.json').write_text({json.dumps(json.dumps(record))}) {result_source} while not (control/'finish').exists(): time.sleep(.001) +raise SystemExit({encoded_exit}) """ @@ -448,6 +455,36 @@ def test_helper_pidfd_timeout_record_requires_exact_cleanup( assert surviving is False +def test_helper_exit_must_match_validated_timeout_record( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _mock_scope_run( + tmp_path, monkeypatch, + _terminal_helper(124, True, helper_exit=125), + ) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "helper exit" in result.stderr + + +def test_signaled_candidate_helper_exit_encoding_is_preserved( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _mock_scope_run(tmp_path, monkeypatch, _terminal_helper(-signal.SIGTERM, False)) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == -signal.SIGTERM, result.stderr + + def test_helper_timeout_with_cleanup_failure_fails_closed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -525,6 +562,8 @@ def test_ordinary_candidate_exit_124_is_reserved_and_fails_closed( "record", [ {"version": 1, "state": "terminal", "returncode": 124}, + {"version": True, "state": "terminal", "returncode": 0, "timed_out": False}, + {"version": 1.0, "state": "terminal", "returncode": 0, "timed_out": False}, {"version": 1, "state": "terminal", "returncode": 124, "timed_out": "yes"}, {"version": 1, "state": "running", "returncode": 124, "timed_out": True}, {"version": 1, "state": "terminal", "returncode": 0, "timed_out": True}, @@ -622,6 +661,24 @@ def test_helper_timeout_with_surviving_process_fails_closed( assert surviving is True +def test_helper_timeout_with_unreadable_post_run_cgroup_events_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _mock_scope_run(tmp_path, monkeypatch, _terminal_helper(124, True)) + monkeypatch.setattr( + supervisor, "_cgroup_events", + lambda *_args: (_ for _ in ()).throw(RuntimeError("events unreadable")), + ) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "events unreadable" in result.stderr + + def test_parent_timeout_protocol_never_observes_candidate_pid() -> None: source = inspect.getsource(run_supervised) @@ -748,6 +805,22 @@ def test_protected_runner_declares_finite_resource_limits() -> None: assert 0 < limits.max_processes <= 256 +@pytest.mark.parametrize("field", tuple(SupervisorLimits.__dataclass_fields__)) +def test_security_limits_reject_boolean_numeric_values( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, field: str, +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + _mock_linux_tools(tmp_path, monkeypatch) + values = vars(SupervisorLimits()) | {field: True} + + with pytest.raises(RuntimeError, match="invalid protected supervisor limits"): + _sandbox_command( + [sys.executable, "-c", "pass"], (tmp_path,), + limits=SupervisorLimits(**values), + ) + + def test_supervisor_separates_physical_and_virtual_memory_limits() -> None: """Keep physical memory bounded when one trusted tool needs more address space.""" limits = SupervisorLimits( @@ -825,6 +898,8 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert "os.waitpid(pid,0)" in helper assert "timed_out" in helper assert "candidate-start.json" not in helper + assert supervisor._PIDFD_PROTOCOL_SOURCE.strip() in helper + assert "timeout=limits['trusted_timeout']" in helper assert json.loads(argv[-1])["timeout"] == 17 assert "result.json" in helper and "finish" in helper assert "-t','tmpfs" in helper @@ -843,6 +918,113 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert plan.bwrap_argv[cgroup_source + 1] == "/sys/fs/cgroup" +def _generated_pidfd_protocol(namespace: dict[str, object] | None = None): + values = {"os": os, "select": __import__("select"), "math": math} + if namespace: + values.update(namespace) + exec(supervisor._PIDFD_PROTOCOL_SOURCE, values) # pylint: disable=exec-used + return values["_supervise_candidate"] + + +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not hasattr(os, "pidfd_open"), + reason="requires Linux pidfds", +) +@pytest.mark.parametrize( + ("mode", "expected"), + [ + ("zero", (0, False)), + ("signal", (-signal.SIGTERM, False)), + ("exit124", (124, False)), + ("timeout", (124, True)), + ], +) +def test_generated_pidfd_protocol_real_child_lifecycle( + mode: str, expected: tuple[int, bool], +) -> None: + protocol = _generated_pidfd_protocol() + before = set(os.listdir("/proc/self/fd")) + pid = os.fork() + if pid == 0: + if mode == "signal": + os.kill(os.getpid(), signal.SIGTERM) + elif mode == "exit124": + os._exit(124) + elif mode == "timeout": + time.sleep(5) + os._exit(0) + try: + assert protocol(pid, .03 if mode == "timeout" else 2) == expected + with pytest.raises(ChildProcessError): + os.waitpid(pid, os.WNOHANG) + assert set(os.listdir("/proc/self/fd")) == before + finally: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + + +@pytest.mark.parametrize("failure", ["pidfd_open", "poll", "waitpid"]) +def test_generated_pidfd_protocol_errors_reap_once_and_close( + failure: str, +) -> None: + class FakeSystem: + def __init__(self) -> None: + self.closed = [] + self.killed = [] + self.wait_calls = 0 + self.reaps = 0 + + def pidfd_open(self, _pid, _flags): + if failure == "pidfd_open": + raise OSError("pidfd failed") + return 17 + + def close(self, descriptor): + self.closed.append(descriptor) + + def kill(self, pid, sig): + self.killed.append((pid, sig)) + + def waitpid(self, pid, _options): + self.wait_calls += 1 + if failure == "waitpid" and self.wait_calls == 1: + raise OSError("wait failed") + self.reaps += 1 + return pid, 0 + + @staticmethod + def waitstatus_to_exitcode(_status): + return 0 + + class FakePoll: + def register(self, *_args): + return None + + def poll(self, _timeout): + if failure == "poll": + raise OSError("poll failed") + return [(17, 1)] + + fake_system = FakeSystem() + fake_select = SimpleNamespace(POLLIN=1, poll=lambda: FakePoll()) + protocol = _generated_pidfd_protocol( + {"os": fake_system, "select": fake_select} + ) + + with pytest.raises(OSError, match="failed"): + protocol(321, 1) + + assert fake_system.reaps == 1 + assert fake_system.killed == [(321, signal.SIGKILL)] + assert fake_system.closed == ([] if failure == "pidfd_open" else [17]) + + def test_scope_unit_name_is_unique_and_strictly_validated() -> None: first = supervisor._scope_unit_name() second = supervisor._scope_unit_name() @@ -877,6 +1059,77 @@ def test_scope_cleanup_error_fails_closed( ) +def test_scope_cleanup_does_not_misclassify_bus_error_as_absent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + tools = supervisor._trusted_tools() + monkeypatch.setattr( + supervisor, "_root_run", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + [], 1, "", "Failed to connect to bus: unit not found" + ), + ) + + with pytest.raises(RuntimeError, match="teardown failed"): + supervisor._stop_scope(supervisor._scope_unit_name(), tools) + + +def test_root_tool_timeout_is_normalized_to_infrastructure_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + tools = supervisor._trusted_tools() + monkeypatch.setattr( + supervisor.subprocess, "run", + lambda *args, **kwargs: (_ for _ in ()).throw( + subprocess.TimeoutExpired(args[0], kwargs.get("timeout", 0)) + ), + ) + + with pytest.raises(RuntimeError, match="timed out"): + supervisor._root_run(tools, ["show", supervisor._scope_unit_name()]) + + +def test_mountinfo_read_error_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + original = Path.read_text + + def read_text(path: Path, *args, **kwargs): + if path == Path("/proc/self/mountinfo"): + raise OSError("mountinfo unavailable") + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", read_text) + + with pytest.raises(RuntimeError, match="mount table"): + supervisor._mounted_paths() + + +def test_staging_umount_timeout_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + tools = supervisor._trusted_tools() + target = tmp_path / "mounted" + target.mkdir() + plan = supervisor._ScopePlan( + supervisor._scope_unit_name(), tmp_path, "", (), (), (target,), tools, + ) + monkeypatch.setattr(supervisor, "_mounted_paths", lambda: {target}) + monkeypatch.setattr( + supervisor.subprocess, "run", + lambda *args, **kwargs: (_ for _ in ()).throw( + subprocess.TimeoutExpired(args[0], kwargs.get("timeout", 0)) + ), + ) + + with pytest.raises(RuntimeError, match="timed out"): + supervisor._cleanup_staging(plan) + + def test_scope_probe_requires_systemd_and_kernel_limits_before_release( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -919,6 +1172,23 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( supervisor._probe_scope(plan, SupervisorLimits()) +@pytest.mark.parametrize( + ("filename", "content", "missing"), + [ + ("memory.events", "oom 0\n", "oom_kill"), + ("memory.events", "oom_kill 0\n", "oom"), + ("pids.events", "other 0\n", "max"), + ], +) +def test_cgroup_event_counters_require_authoritative_keys( + tmp_path: Path, filename: str, content: str, missing: str, +) -> None: + (tmp_path / filename).write_text(content, encoding="ascii") + + with pytest.raises(RuntimeError, match=missing): + supervisor._cgroup_events(tmp_path, filename) + + def test_scope_cleanup_targets_only_validated_unique_unit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: From 5394b67075e8bd949a90b927ea8ca5b70b3f510c Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 03:18:47 -0700 Subject: [PATCH 100/233] fix(sync): harden trusted timeout teardown evidence --- pdd/sync_core/runner.py | 1 + pdd/sync_core/supervisor.py | 281 +++++++++++++++++++++++++----------- 2 files changed, 194 insertions(+), 88 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index c3fcd3ef08..f60691e66f 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4131,6 +4131,7 @@ def _drain_result_pipe( except BlockingIOError: continue if not chunk: + finished.wait(.01) continue size += len(chunk) if size > VITEST_RESULT_MAX_BYTES: diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index bf65fe75c0..68c8da063b 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -30,6 +30,49 @@ _FRAMEWORK_OBSERVATION_PATH = Path("/run/pdd-framework-observation") _SCOPE_PATTERN = re.compile(r"pdd-validator-[0-9a-f]{32}\.scope") _TRUSTED_POSTPROCESS_SECONDS = 5 +_TRUSTED_COMMAND_SECONDS = 5 + +_PIDFD_PROTOCOL_SOURCE = """ +def _supervise_candidate(pid, timeout): + pidfd = None + reaped = False + try: + if not hasattr(os, 'pidfd_open'): + raise RuntimeError('pidfd_open unavailable') + pidfd = os.pidfd_open(pid, 0) + poller = select.poll() + poller.register(pidfd, select.POLLIN) + events = poller.poll(math.ceil(timeout * 1000)) + if events and not any( + fd == pidfd and mask & select.POLLIN for fd, mask in events + ): + raise RuntimeError('pidfd poll returned invalid events') + timed_out = not events + if timed_out: + os.kill(pid, 9) + waited, status = os.waitpid(pid,0) + if waited != pid: + raise RuntimeError('waitpid returned wrong child') + reaped = True + return ( + 124 if timed_out else os.waitstatus_to_exitcode(status), + timed_out, + ) + finally: + if pidfd is not None: + os.close(pidfd) + if not reaped: + try: + os.kill(pid, 9) + except ProcessLookupError: + pass + try: + waited, _status = os.waitpid(pid,0) + if waited != pid: + raise RuntimeError('waitpid returned wrong child') + except ChildProcessError: + pass +""" @dataclass(frozen=True) @@ -119,9 +162,13 @@ def _linked_libraries(path: Path) -> tuple[Path, ...]: """Resolve loader-visible and physical paths for ELF dependencies.""" if not sys.platform.startswith("linux"): return () - result = subprocess.run( - ["ldd", str(path)], capture_output=True, text=True, check=False - ) + try: + result = subprocess.run( + ["ldd", str(path)], capture_output=True, text=True, check=False, + timeout=_TRUSTED_COMMAND_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("trusted ldd command timed out") from exc libraries: set[Path] = set() for line in result.stdout.splitlines(): fields = line.strip().split() @@ -238,8 +285,20 @@ def _sandbox_library_path(environment: dict[str, str]) -> str: return os.pathsep.join(dict.fromkeys(directories)) +def _validate_limits(limits: SupervisorLimits) -> None: + """Require exact positive integers for every authoritative numeric limit.""" + # Exact built-in integers reject bool and authority-confusing subclasses. + # pylint: disable=unidiomatic-typecheck + if any( + type(value) is not int or value <= 0 for value in vars(limits).values() + ): + raise RuntimeError("invalid protected supervisor limits") + # pylint: enable=unidiomatic-typecheck + + def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: """Apply non-raiseable POSIX limits after the namespace uid drop.""" + _validate_limits(limits) script = ( "import os,resource,sys;" "v=[int(x) for x in sys.argv[1:5]];" @@ -273,8 +332,8 @@ def _staged_bwrap( "path_tokens=json.loads(sys.argv[-5]); argv=json.loads(sys.argv[-4]); " "paths=json.loads(sys.argv[-3])", "targets=[control/'binds'/str(index) for index in range(len(paths))]", - "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None; " - "pidfd=None; child_reaped=False", + "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None", + _PIDFD_PROTOCOL_SOURCE.strip(), "def wait_for(name):", " path=control/name", " while not path.exists(): time.sleep(.01)", @@ -309,7 +368,7 @@ def _staged_bwrap( " writable_target=control/'binds'/'writable'", " subprocess.run([mount,'-t','tmpfs','-o'," "f\"size={limits['writable']},mode=0700,nosuid,nodev\",'tmpfs'," - "str(writable_target)],check=True)", + "str(writable_target)],check=True,timeout=limits['trusted_timeout'])", " staged.append(writable_target)", " mount_lines=pathlib.Path('/proc/self/mountinfo').read_text(" "encoding='utf-8').splitlines()", @@ -330,18 +389,22 @@ def _staged_bwrap( " if sum(validate_tree(path) for path in writable_paths) > " "limits['writable']: raise RuntimeError('initial writable quota exceeded')", " for source,target in zip(paths,targets):", - " subprocess.run([mount,'--bind',source,str(target)],check=True)", + " subprocess.run([mount,'--bind',source,str(target)],check=True," + "timeout=limits['trusted_timeout'])", " staged.append(target)", " path_map={token:str(targets[index]) for index,token in enumerate(path_tokens)}", " for token,index,relative in writable_specs: " "path_map[token]=str(writable_paths[index]/relative)", " argv=[path_map.get(value,value) for value in argv]", " cgroup_target=control/'binds'/'cgroup'", - " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)],check=True)", + " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)]," + "check=True,timeout=limits['trusted_timeout'])", " staged.append(cgroup_target)", - " subprocess.run([umount,str(cgroup_target)],check=True)", + " subprocess.run([umount,str(cgroup_target)],check=True," + "timeout=limits['trusted_timeout'])", " staged.pop()", - " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)],check=True)", + " subprocess.run([mount,'--bind',str(own_cgroup()),str(cgroup_target)]," + "check=True,timeout=limits['trusted_timeout'])", " staged.append(cgroup_target)", " argv=[str(cgroup_target) if value == '@PDD-CGROUP@' else value for value in argv]", " (control/'ready').write_text('ready',encoding='ascii')", @@ -350,20 +413,7 @@ def _staged_bwrap( " if pid == 0:", " try: os.execvpe(argv[0],argv,os.environ)", " except OSError: os._exit(125)", - " if not hasattr(os,'pidfd_open'): raise RuntimeError('pidfd_open unavailable')", - " pidfd=os.pidfd_open(pid,0)", - " poller=select.poll(); poller.register(pidfd,select.POLLIN)", - " timeout_ms=math.ceil(limits['timeout']*1000)", - " events=poller.poll(timeout_ms)", - " if events and not any(fd==pidfd and mask&select.POLLIN for fd,mask in events):", - " raise RuntimeError('pidfd poll returned invalid events')", - " if not events:", - " timed_out=True; os.kill(pid,9)", - " _wait,status=os.waitpid(pid,0)", - " if _wait != pid: raise RuntimeError('waitpid returned wrong child')", - " child_reaped=True", - " result=124 if timed_out else os.waitstatus_to_exitcode(status)", - " os.close(pidfd); pidfd=None", + " result,timed_out=_supervise_candidate(pid,limits['timeout'])", " record={'version':1,'state':'terminal','returncode':result," "'timed_out':timed_out}", " candidate=control/'candidate.tmp'", @@ -376,15 +426,12 @@ def _staged_bwrap( " os.replace(temporary,control/'result.json')", " wait_for('finish')", "finally:", - " if pidfd is not None: os.close(pidfd)", - " if pid is not None and not child_reaped:", - " try: os.kill(pid,9)", - " except ProcessLookupError: pass", - " try: os.waitpid(pid,0)", - " except ChildProcessError: pass", " for target in reversed(staged):", - " completed=subprocess.run([umount,str(target)],capture_output=True," - "text=True,check=False)", + " try:", + " completed=subprocess.run([umount,str(target)],capture_output=True," + "text=True,check=False,timeout=limits['trusted_timeout'])", + " except subprocess.TimeoutExpired:", + " cleanup_error='umount timed out'; continue", " if completed.returncode != 0: cleanup_error=completed.stderr or 'umount failed'", "if cleanup_error:", " (control/'cleanup-error').write_text(cleanup_error,encoding='utf-8')", @@ -409,7 +456,8 @@ def _staged_bwrap( json.dumps([str(path) for path in sources]), unit_name, json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, "writable": limits.max_writable_bytes, - "timeout": candidate_timeout}), + "timeout": candidate_timeout, + "trusted_timeout": _TRUSTED_COMMAND_SECONDS}), ] return command, plan @@ -443,10 +491,13 @@ def _supervised_descendants(token: str) -> set[int]: if f"PDD_SUPERVISION_TOKEN={token}".encode() in environment.split(b"\0"): found.add(int(entry.name)) return found - listing = subprocess.run( - ["ps", "eww", "-axo", "pid=,command="], capture_output=True, - text=True, check=False, - ) + try: + listing = subprocess.run( + ["ps", "eww", "-axo", "pid=,command="], capture_output=True, + text=True, check=False, timeout=_TRUSTED_COMMAND_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("trusted process inventory timed out") from exc marker = f"PDD_SUPERVISION_TOKEN={token}" for line in listing.stdout.splitlines(): if marker not in line: @@ -485,7 +536,11 @@ def _load_candidate_record(path: Path) -> _CandidateRecord: raise RuntimeError("protected candidate record is invalid") returncode = payload["returncode"] timed_out = payload["timed_out"] - valid_header = payload["version"] == 1 and payload["state"] == "terminal" + valid_header = ( + type(payload["version"]) is int # pylint: disable=unidiomatic-typecheck + and payload["version"] == 1 + and payload["state"] == "terminal" + ) valid_returncode = ( isinstance(returncode, int) and not isinstance(returncode, bool) @@ -561,10 +616,14 @@ def _root_run( tools: _TrustedTools, arguments: list[str], *, check: bool = False, ) -> subprocess.CompletedProcess[str]: """Invoke systemctl through the exact sudo/systemctl identities already probed.""" - return subprocess.run( - [str(tools.sudo), "-n", str(tools.systemctl), *arguments], - capture_output=True, text=True, check=check, env=_root_environment(), - ) + try: + return subprocess.run( + [str(tools.sudo), "-n", str(tools.systemctl), *arguments], + capture_output=True, text=True, check=check, env=_root_environment(), + timeout=_TRUSTED_COMMAND_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("trusted systemctl command timed out") from exc def _scope_properties(unit_name: str, tools: _TrustedTools) -> dict[str, str]: @@ -609,9 +668,20 @@ def _cgroup_events(cgroup: Path, filename: str) -> dict[str, int]: """Read one kernel cgroup event file as non-negative counters.""" try: lines = (cgroup / filename).read_text(encoding="ascii").splitlines() - events = {name: int(value) for name, value in (line.split() for line in lines)} + events = {} + for line in lines: + fields = line.split() + if len(fields) != 2 or fields[0] in events: + raise ValueError("invalid event record") + events[fields[0]] = int(fields[1]) except (OSError, ValueError) as exc: raise RuntimeError(f"protected scope cannot read {filename}") from exc + required = {"memory.events": {"oom", "oom_kill"}, "pids.events": {"max"}} + missing = required.get(filename, set()) - events.keys() + if missing: + raise RuntimeError( + f"protected scope has invalid {filename}: missing {','.join(sorted(missing))}" + ) if any(value < 0 for value in events.values()): raise RuntimeError(f"protected scope has invalid {filename}") return events @@ -655,10 +725,7 @@ def _stop_scope(unit_name: str, tools: _TrustedTools) -> None: """Kill the exact whole scope and prove that systemd removed it.""" unit_name = _validated_scope_unit(unit_name) initial = _root_run(tools, ["show", unit_name, "--property=LoadState"]) - if initial.stdout.strip() == "LoadState=not-found" or ( - initial.returncode != 0 - and any(value in initial.stderr.lower() for value in ("not loaded", "not found")) - ): + if initial.returncode == 0 and initial.stdout.strip() == "LoadState=not-found": return if initial.returncode != 0: raise RuntimeError( @@ -672,13 +739,16 @@ def _stop_scope(unit_name: str, tools: _TrustedTools) -> None: ["reset-failed", unit_name], ): completed = _root_run(tools, arguments) - if completed.returncode != 0 and "not loaded" not in completed.stderr.lower(): + if completed.returncode != 0: errors.append(completed.stderr.strip() or " ".join(arguments) + " failed") deadline = time.monotonic() + 5 while time.monotonic() < deadline: completed = _root_run(tools, ["show", unit_name, "--property=LoadState"]) output = completed.stdout.strip() - if completed.returncode != 0 or output == "LoadState=not-found": + if completed.returncode != 0: + errors.append(completed.stderr.strip() or "cannot probe removed scope") + break + if output == "LoadState=not-found": break time.sleep(.05) else: @@ -705,8 +775,8 @@ def _mounted_paths() -> set[Path]: paths = set() try: lines = Path("/proc/self/mountinfo").read_text(encoding="utf-8").splitlines() - except OSError: - return paths + except OSError as exc: + raise RuntimeError("protected mount table is unavailable") from exc for line in lines: fields = line.split() if len(fields) > 4: @@ -720,10 +790,15 @@ def _cleanup_staging(plan: _ScopePlan) -> None: for target in reversed(plan.staging_targets): if target not in _mounted_paths(): continue - completed = subprocess.run( - [str(plan.tools.sudo), "-n", str(plan.tools.umount), str(target)], - capture_output=True, text=True, check=False, env=_root_environment(), - ) + try: + completed = subprocess.run( + [str(plan.tools.sudo), "-n", str(plan.tools.umount), str(target)], + capture_output=True, text=True, check=False, env=_root_environment(), + timeout=_TRUSTED_COMMAND_SECONDS, + ) + except subprocess.TimeoutExpired: + errors.append(f"trusted umount timed out for {target}") + continue if completed.returncode != 0: errors.append(completed.stderr.strip() or f"cannot unmount {target}") if any(target in _mounted_paths() for target in plan.staging_targets): @@ -746,6 +821,7 @@ def _sandbox_command( ) -> tuple[list[str], _ScopePlan]: # pylint: disable=too-many-locals,too-many-branches,too-many-statements """Return an explicitly detected macOS/Linux sandbox command.""" + _validate_limits(limits) if sys.platform == "darwin": raise RuntimeError( "unsupported protected sandbox: macOS cannot prove process lifetime isolation" @@ -764,10 +840,15 @@ def _sandbox_command( "protected sandbox requires a non-root caller so process limits " "remain kernel-enforced" ) - if subprocess.run( - [str(tools.sudo), "-n", str(_SUPERVISOR_EXECUTABLE), "-c", "pass"], - capture_output=True, check=False, env=_root_environment(), - ).returncode != 0: + try: + privilege_probe = subprocess.run( + [str(tools.sudo), "-n", str(_SUPERVISOR_EXECUTABLE), "-c", "pass"], + capture_output=True, check=False, env=_root_environment(), + timeout=_TRUSTED_COMMAND_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("trusted sudo probe timed out") from exc + if privilege_probe.returncode != 0: raise RuntimeError("protected sandbox requires privileged bind staging") workdir = (cwd or Path.cwd()).resolve() writable_sources = tuple( @@ -956,32 +1037,15 @@ def fail_for_limit() -> bool: return True def record_events() -> None: - nonlocal failed_closed if cgroup is None: return - memory_after = None - pids_after = None - try: - memory_after = _cgroup_events(cgroup, "memory.events") - pids_after = _cgroup_events(cgroup, "pids.events") - except RuntimeError: - pass - oom_delta = 0 - pids_delta = 0 - if memory_after is not None: - oom_delta = max( - memory_after.get("oom", 0) - memory_before.get("oom", 0), - memory_after.get("oom_kill", 0) - memory_before.get("oom_kill", 0), - ) - if pids_after is not None: - pids_delta = pids_after.get("max", 0) - pids_before.get("max", 0) - if oom_delta <= 0: - try: - properties = _scope_properties(plan.unit_name, plan.tools) - if properties.get("Result") == "oom-kill" or properties.get("OOMKilled") == "yes": - oom_delta = 1 - except RuntimeError: - pass + memory_after = _cgroup_events(cgroup, "memory.events") + pids_after = _cgroup_events(cgroup, "pids.events") + oom_delta = max( + memory_after["oom"] - memory_before["oom"], + memory_after["oom_kill"] - memory_before["oom_kill"], + ) + pids_delta = pids_after["max"] - pids_before["max"] if oom_delta > 0: add_diagnostic(f"cgroup memory.events oom delta={oom_delta}\n") if pids_delta > 0: @@ -1022,7 +1086,14 @@ def record_events() -> None: env=sandbox_environment, start_new_session=True, ) except OSError as exc: - _cleanup_staging(plan) + try: + _cleanup_staging(plan) + except RuntimeError as cleanup_exc: + return subprocess.CompletedProcess( + command, 125, "", + "protected supervisor phase=launch: " + f"{exc}; cleanup failed: {cleanup_exc}", + ), False return subprocess.CompletedProcess( command, 125, "", f"protected supervisor phase=launch: {exc}" ), False @@ -1147,6 +1218,22 @@ def record_events() -> None: add_diagnostic( "protected supervisor result-handoff did not finish\n" ) + if ( + candidate_returncode is not None + and process.poll() is not None + and not failed_closed + ): + expected_helper_exit = ( + candidate_returncode + if candidate_returncode >= 0 + else 128 - candidate_returncode + ) + if process.returncode != expected_helper_exit: + failed_closed = True + add_diagnostic( + "protected supervisor phase=result-handoff: " + "helper exit status mismatch\n" + ) if (control / "cleanup-error").exists(): failed_closed = True add_diagnostic( @@ -1166,7 +1253,14 @@ def record_events() -> None: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass - process.wait() + try: + process.wait(timeout=_TRUSTED_COMMAND_SECONDS) + except subprocess.TimeoutExpired: + failed_closed = True + add_diagnostic( + "protected supervisor phase=scope-cleanup: " + "helper did not terminate\n" + ) try: _stop_scope(plan.unit_name, plan.tools) except RuntimeError as exc: @@ -1177,7 +1271,14 @@ def record_events() -> None: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass - process.wait() + try: + process.wait(timeout=_TRUSTED_COMMAND_SECONDS) + except subprocess.TimeoutExpired: + failed_closed = True + add_diagnostic( + "protected supervisor phase=scope-cleanup: " + "helper did not terminate after scope stop\n" + ) try: _cleanup_staging(plan) except RuntimeError as exc: @@ -1191,8 +1292,12 @@ def record_events() -> None: add_diagnostic("protected supervisor output drain did not finish\n") observed = set() if process is not None: - observed = _supervised_descendants(token) - observed.discard(process.pid) + try: + observed = _supervised_descendants(token) + observed.discard(process.pid) + except RuntimeError as exc: + failed_closed = True + add_diagnostic(f"protected supervisor process cleanup failed: {exc}\n") for pid in observed: tracked.setdefault(pid, _process_identity(pid)) descendants = _live_processes(tracked) From 0e3d558d582eeb53714cdd33720261148d8572c8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 04:04:10 -0700 Subject: [PATCH 101/233] test(sync): cover hosted Linux staging regressions --- tests/test_sync_core_lifecycle_scenarios.py | 2 + tests/test_sync_core_runner_playwright.py | 26 +++++++++++ tests/test_sync_core_supervisor.py | 49 +++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/tests/test_sync_core_lifecycle_scenarios.py b/tests/test_sync_core_lifecycle_scenarios.py index f28d7a07cd..e002af4b9d 100644 --- a/tests/test_sync_core_lifecycle_scenarios.py +++ b/tests/test_sync_core_lifecycle_scenarios.py @@ -282,6 +282,8 @@ def fake_run(command, **kwargs): lock_text = combined_lock.read_text(encoding="utf-8") assert str(wheel.resolve()) in lock_text assert f"--hash=sha256:{hashlib.sha256(wheel.read_bytes()).hexdigest()}" in lock_text + create_command = calls[0][0] + assert create_command[-2:] == ("--copies", str(tmp_path / "candidate-venv")) def test_candidate_install_proves_isolated_module_entrypoint( diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 6fe5d7ebe7..a46a5a3b2b 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -623,6 +623,32 @@ def supervised(command, **_kwargs): dependency_source, dependency_destination = dependency_bindings[0][-1] assert dependency_source.name == "node_modules" assert dependency_destination == phase_roots[0] / "node_modules" + assert dependency_destination.is_dir() + + +def test_playwright_rejects_candidate_node_modules_directory_before_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + root, commit = _repository(tmp_path) + (root / "node_modules").mkdir() + launches = 0 + + def supervised(*_args, **_kwargs): + nonlocal launches + launches += 1 + raise AssertionError("candidate node_modules must fail before execution") + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + + execution, _identities = runner_module._run_playwright_in_tree( + root, (PurePosixPath("tests/widget.spec.ts"),), 2, + _trusted_playwright_config(tmp_path / "trusted", _fake_playwright(tmp_path)), + expected_commit=commit, + ) + + assert execution.outcome is EvidenceOutcome.ERROR + assert "candidate node_modules" in execution.detail + assert launches == 0 @pytest.mark.parametrize("reserved", ["@playwright/test", "playwright", "playwright-core"]) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index c43f5a34d9..9a969d41bf 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -407,6 +407,7 @@ def _terminal_helper( def test_scope_setup_deadline_is_not_candidate_timeout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(supervisor, "_TRUSTED_SETUP_SECONDS", .03) cleanup = _mock_scope_run(tmp_path, monkeypatch, "import time;time.sleep(30)") result, surviving = run_supervised( @@ -420,6 +421,25 @@ def test_scope_setup_deadline_is_not_candidate_timeout( assert surviving is False +def test_slow_scope_setup_does_not_consume_tiny_candidate_timeout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + helper = _terminal_helper(0, False).replace( + "(control/'ready').write_text('ready')", + "time.sleep(.15);(control/'ready').write_text('ready')", + ) + cleanup = _mock_scope_run(tmp_path, monkeypatch, helper) + + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.03, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 0, result.stderr + assert cleanup == ["scope", "mounts"] + assert surviving is False + + @pytest.mark.parametrize("returncode", [0, 1]) def test_normal_candidate_status_is_preserved_from_protected_record( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, returncode: int, @@ -679,6 +699,35 @@ def test_helper_timeout_with_unreadable_post_run_cgroup_events_fails_closed( assert "events unreadable" in result.stderr +def test_helper_failure_without_result_preserves_original_diagnostic( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + helper = _terminal_helper( + 125, False, write_result=False, output="helper staging failed" + ).replace( + "while not (control/'finish').exists(): time.sleep(.001)", "" + ) + _mock_scope_run(tmp_path, monkeypatch, helper) + reads = 0 + + def unreadable_events(*_args): + nonlocal reads + reads += 1 + raise RuntimeError("memory.events unavailable") + + monkeypatch.setattr(supervisor, "_cgroup_events", unreadable_events) + + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert "helper staging failed" in result.stdout + assert "memory.events unavailable" not in result.stderr + assert reads == 0 + + def test_parent_timeout_protocol_never_observes_candidate_pid() -> None: source = inspect.getsource(run_supervised) From 13a27e9a5ea0c570abddc2cb2e143cef930db1cb Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 04:07:35 -0700 Subject: [PATCH 102/233] fix(sync): repair hosted Linux staging order --- pdd/sync_core/lifecycle.py | 4 +- pdd/sync_core/runner.py | 50 ++++++++++++++++++++++- pdd/sync_core/supervisor.py | 5 ++- tests/test_sync_core_runner_playwright.py | 6 ++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/pdd/sync_core/lifecycle.py b/pdd/sync_core/lifecycle.py index d712ddcbe9..8fd081435f 100644 --- a/pdd/sync_core/lifecycle.py +++ b/pdd/sync_core/lifecycle.py @@ -201,7 +201,9 @@ def _install_candidate_wheel( environment = temporary / "candidate-venv" isolated = _isolated_lifecycle_environment(home) created = _lifecycle_command( - [sys.executable, "-m", "venv", str(environment)], temporary, home + [sys.executable, "-m", "venv", "--copies", str(environment)], + temporary, + home, ) candidate_python = environment / ( "Scripts/python.exe" if os.name == "nt" else "bin/python" diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index f60691e66f..2593aeb1ee 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4343,6 +4343,44 @@ def _playwright_node_modules_destination_error(root: Path) -> str | None: return None +def _create_playwright_node_modules_mountpoint(root: Path) -> tuple[Path, int, int]: + """Create one checker-owned target after exact-tree trust validation.""" + destination = root / "node_modules" + try: + destination.mkdir(mode=0o700) + metadata = destination.lstat() + except OSError as exc: + raise ValueError("cannot create trusted node_modules mountpoint") from exc + if ( + not stat.S_ISDIR(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + or metadata.st_uid != os.getuid() + ): + raise ValueError("trusted node_modules mountpoint identity is invalid") + return destination, metadata.st_dev, metadata.st_ino + + +def _remove_playwright_node_modules_mountpoint( + identity: tuple[Path, int, int], +) -> None: + """Remove only the unchanged empty checker-owned binding target.""" + destination, expected_device, expected_inode = identity + try: + metadata = destination.lstat() + if ( + not stat.S_ISDIR(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + or metadata.st_uid != os.getuid() + or (metadata.st_dev, metadata.st_ino) + != (expected_device, expected_inode) + or any(destination.iterdir()) + ): + raise ValueError("trusted node_modules mountpoint changed") + destination.rmdir() + except OSError as exc: + raise ValueError("cannot remove trusted node_modules mountpoint") from exc + + def _playwright_environment( home: Path, dependencies: Path | None, browser_runtime: Path | None = None ) -> dict[str, str]: @@ -4797,11 +4835,13 @@ def _run_playwright_in_tree( ["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, check=True, ).stdout.strip() - tree_identity = _playwright_execution_tree_identity(root) try: closure_identity = _playwright_protected_worktree_identity( root, commit, paths, code_under_test_paths ) + tree_identity = _playwright_execution_tree_identity(root) + mountpoint_identity = _create_playwright_node_modules_mountpoint(root) + dependency_destination = mountpoint_identity[0] except ValueError as exc: return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) @@ -4831,7 +4871,7 @@ def _run_playwright_in_tree( temp_directory=Path("/tmp"), readable_roots=(reporter, *roles.readable_roots), readable_bindings=( - *native_bindings, (roles.dependencies, root / "node_modules"), + *native_bindings, (roles.dependencies, dependency_destination), ), limits=PLAYWRIGHT_SUPERVISOR_LIMITS, result_fifo=result_fifo, @@ -4857,6 +4897,12 @@ def _run_playwright_in_tree( ), () finally: os.close(read_fd) + try: + _remove_playwright_node_modules_mountpoint(mountpoint_identity) + except ValueError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, digest, str(exc) + ), () if result.returncode == 124: return RunnerExecution( "playwright", EvidenceOutcome.TIMEOUT, digest, diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 68c8da063b..530c74f4b6 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -31,6 +31,7 @@ _SCOPE_PATTERN = re.compile(r"pdd-validator-[0-9a-f]{32}\.scope") _TRUSTED_POSTPROCESS_SECONDS = 5 _TRUSTED_COMMAND_SECONDS = 5 +_TRUSTED_SETUP_SECONDS = 30 _PIDFD_PROTOCOL_SOURCE = """ def _supervise_candidate(pid, timeout): @@ -1110,7 +1111,7 @@ def record_events() -> None: for output_thread in output_threads: output_thread.start() - setup_deadline = time.monotonic() + timeout + setup_deadline = time.monotonic() + _TRUSTED_SETUP_SECONDS phase = "scope-setup" try: while not (control / "ready").exists(): @@ -1178,7 +1179,7 @@ def record_events() -> None: candidate_returncode = result_record.returncode candidate_timed_out = result_record.timed_out fail_for_limit() - record_events() + record_events() if ( candidate_record is None and not failed_closed diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index a46a5a3b2b..afec380839 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -600,11 +600,15 @@ def test_playwright_execution_uses_process_group_supervisor( dependency_bindings = [] temp_directories = [] phase_roots = [] + dependency_targets_ready = [] def supervised(command, **_kwargs): calls.append(command) scratch_bindings.append(_kwargs["writable_bindings"]) dependency_bindings.append(_kwargs["readable_bindings"]) + dependency_targets_ready.append( + _kwargs["readable_bindings"][-1][1].is_dir() + ) temp_directories.append(_kwargs["temp_directory"]) phase_roots.append(_kwargs["cwd"]) _write_framework_observation(_kwargs, { @@ -623,7 +627,7 @@ def supervised(command, **_kwargs): dependency_source, dependency_destination = dependency_bindings[0][-1] assert dependency_source.name == "node_modules" assert dependency_destination == phase_roots[0] / "node_modules" - assert dependency_destination.is_dir() + assert dependency_targets_ready == [True, True, True] def test_playwright_rejects_candidate_node_modules_directory_before_execution( From 68ce4adb9d1988cd2e811b450ee1053f3a0c92ac Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 04:33:46 -0700 Subject: [PATCH 103/233] test(sync): reproduce CPython venv alias staging --- tests/test_sync_core_lifecycle_scenarios.py | 91 ++++++++++++++++++++- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/tests/test_sync_core_lifecycle_scenarios.py b/tests/test_sync_core_lifecycle_scenarios.py index e002af4b9d..1262d62e39 100644 --- a/tests/test_sync_core_lifecycle_scenarios.py +++ b/tests/test_sync_core_lifecycle_scenarios.py @@ -5,8 +5,10 @@ import hashlib import os import json +import stat import subprocess import sys +import venv import zipfile from pathlib import Path, PurePosixPath @@ -29,6 +31,88 @@ TransactionManager, build_unit_manifest, ) + + +@pytest.fixture(scope="module") +def real_wrapper_environment(tmp_path_factory): + root = tmp_path_factory.mktemp("real-copied-venv") + environment = root / "candidate-venv" + command = lifecycle_module._candidate_venv_command(environment) + completed = subprocess.run( + command, cwd=root, capture_output=True, text=True, check=False, + timeout=120, + ) + assert completed.returncode == 0, completed.stderr + return environment, command + + +def _production_venv_validator(): + namespace = {"os": os, "pathlib": __import__("pathlib"), "stat": stat} + exec( # pylint: disable=exec-used + lifecycle_module._VENV_TREE_VALIDATOR_SOURCE, namespace + ) + return namespace["_normalize_and_validate_environment"] + + +def test_candidate_venv_wrapper_creates_only_regular_tree_entries( + real_wrapper_environment, +) -> None: + environment, command = real_wrapper_environment + + assert command[:3] == [sys.executable, "-I", "-c"] + assert command[-1] == str(environment.resolve()) + for path in (environment, *environment.rglob("*")): + metadata = path.lstat() + assert not stat.S_ISLNK(metadata.st_mode), path + assert stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode), path + + +@pytest.mark.skipif( + not sys.platform.startswith("linux") or sys.maxsize <= 2**32, + reason="CPython lib64 alias is specific to 64-bit Linux", +) +def test_candidate_venv_wrapper_removes_cpython_lib64_alias( + tmp_path: Path, real_wrapper_environment, +) -> None: + plain = tmp_path / "plain-venv" + venv.EnvBuilder(symlinks=False, with_pip=False).create(plain) + assert (plain / "lib64").is_symlink() + assert os.readlink(plain / "lib64") == "lib" + + environment, _command = real_wrapper_environment + assert not os.path.lexists(environment / "lib64") + + +@pytest.mark.parametrize("target", ["../lib", "/tmp/lib", "./lib"]) +def test_candidate_venv_validator_rejects_wrong_lib64_target( + tmp_path: Path, target: str, +) -> None: + environment = tmp_path / "candidate-venv" + (environment / "lib").mkdir(parents=True) + (environment / "lib64").symlink_to(target, target_is_directory=True) + + with pytest.raises(RuntimeError, match="lib64"): + _production_venv_validator()(environment) + + assert os.path.lexists(environment / "lib64") + + +@pytest.mark.parametrize("entry_type", ["symlink", "fifo"]) +def test_candidate_venv_validator_rejects_extra_nonregular_entry( + tmp_path: Path, entry_type: str, +) -> None: + environment = tmp_path / "candidate-venv" + (environment / "lib").mkdir(parents=True) + (environment / "bin").mkdir() + (environment / "lib64").symlink_to("lib", target_is_directory=True) + extra = environment / "bin" / "extra" + if entry_type == "symlink": + extra.symlink_to("../lib", target_is_directory=True) + else: + os.mkfifo(extra) + + with pytest.raises(RuntimeError, match="symlink|special"): + _production_venv_validator()(environment) from pdd.sync_core.identity import initialize_repository_identity @@ -254,7 +338,7 @@ def test_candidate_install_uses_hash_pinned_wheelhouse_no_index( def fake_run(command, **kwargs): calls.append((tuple(str(item) for item in command), kwargs)) - if "-m" in command and "venv" in command: + if command[:3] == [sys.executable, "-I", "-c"]: (tmp_path / "candidate-venv" / ("Scripts" if os.name == "nt" else "bin")).mkdir( parents=True ) @@ -283,7 +367,8 @@ def fake_run(command, **kwargs): assert str(wheel.resolve()) in lock_text assert f"--hash=sha256:{hashlib.sha256(wheel.read_bytes()).hexdigest()}" in lock_text create_command = calls[0][0] - assert create_command[-2:] == ("--copies", str(tmp_path / "candidate-venv")) + assert create_command[:3] == (sys.executable, "-I", "-c") + assert create_command[-1] == str((tmp_path / "candidate-venv").resolve()) def test_candidate_install_proves_isolated_module_entrypoint( @@ -299,7 +384,7 @@ def test_candidate_install_proves_isolated_module_entrypoint( def fake_run(command, **_kwargs): calls.append(tuple(str(item) for item in command)) - if "-m" in command and "venv" in command: + if command[:3] == [sys.executable, "-I", "-c"]: (tmp_path / "candidate-venv" / ("Scripts" if os.name == "nt" else "bin")).mkdir( parents=True ) From 87fb4a1de3be5d28e23e8b521962404df93a390a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 04:35:35 -0700 Subject: [PATCH 104/233] fix(sync): normalize supervised CPython venv tree --- pdd/sync_core/lifecycle.py | 65 ++++++++++++++++++++- tests/test_sync_core_lifecycle_scenarios.py | 2 +- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/pdd/sync_core/lifecycle.py b/pdd/sync_core/lifecycle.py index 8fd081435f..e0d0b520b2 100644 --- a/pdd/sync_core/lifecycle.py +++ b/pdd/sync_core/lifecycle.py @@ -23,6 +23,69 @@ from .supervisor import run_supervised +_VENV_TREE_VALIDATOR_SOURCE = """ +def _normalize_and_validate_environment(root): + root = pathlib.Path(root) + root_metadata = root.lstat() + if not stat.S_ISDIR(root_metadata.st_mode) or stat.S_ISLNK(root_metadata.st_mode): + raise RuntimeError('candidate environment root is not a real directory') + alias = root / 'lib64' + if os.path.lexists(alias): + alias_metadata = alias.lstat() + if not stat.S_ISLNK(alias_metadata.st_mode) or os.readlink(alias) != 'lib': + raise RuntimeError('candidate environment lib64 alias is invalid') + library = root / 'lib' + library_metadata = library.lstat() + if not stat.S_ISDIR(library_metadata.st_mode) or stat.S_ISLNK( + library_metadata.st_mode + ): + raise RuntimeError('candidate environment lib directory is invalid') + alias.unlink() + + def raise_walk_error(error): + raise error + + for current, directories, files in os.walk( + root, topdown=True, onerror=raise_walk_error, followlinks=False + ): + parent = pathlib.Path(current) + for name in (*directories, *files): + path = parent / name + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode): + raise RuntimeError('candidate environment contains a symlink') + if not ( + stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode) + ): + raise RuntimeError('candidate environment contains a special file') +""" + +_VENV_CREATION_SOURCE = "\n".join(( + "import os,pathlib,stat,sys,venv", + _VENV_TREE_VALIDATOR_SOURCE.strip(), + "if len(sys.argv) != 2: raise RuntimeError('invalid candidate environment argv')", + "root=pathlib.Path(sys.argv[1])", + "if not root.is_absolute(): raise RuntimeError('candidate environment is not absolute')", + "parent_metadata=root.parent.lstat()", + "if not stat.S_ISDIR(parent_metadata.st_mode) or " + "stat.S_ISLNK(parent_metadata.st_mode): " + "raise RuntimeError('candidate environment parent is invalid')", + "if os.path.lexists(root): raise RuntimeError('candidate environment already exists')", + "venv.EnvBuilder(symlinks=False,with_pip=True).create(root)", + "_normalize_and_validate_environment(root)", +)) + + +def _candidate_venv_command(environment: Path) -> list[str]: + """Build the exact isolated wrapper command for one checker-owned venv path.""" + try: + parent = environment.parent.resolve(strict=True) + except OSError as exc: + raise ValueError("candidate environment parent is unavailable") from exc + destination = parent / environment.name + return [sys.executable, "-I", "-c", _VENV_CREATION_SOURCE, str(destination)] + + def _isolated_lifecycle_environment(home: Path) -> dict[str, str]: """Build a credential-free environment with no source import overrides.""" return untrusted_child_environment( @@ -201,7 +264,7 @@ def _install_candidate_wheel( environment = temporary / "candidate-venv" isolated = _isolated_lifecycle_environment(home) created = _lifecycle_command( - [sys.executable, "-m", "venv", "--copies", str(environment)], + _candidate_venv_command(environment), temporary, home, ) diff --git a/tests/test_sync_core_lifecycle_scenarios.py b/tests/test_sync_core_lifecycle_scenarios.py index 1262d62e39..c8fc1abe93 100644 --- a/tests/test_sync_core_lifecycle_scenarios.py +++ b/tests/test_sync_core_lifecycle_scenarios.py @@ -31,6 +31,7 @@ TransactionManager, build_unit_manifest, ) +from pdd.sync_core.identity import initialize_repository_identity @pytest.fixture(scope="module") @@ -113,7 +114,6 @@ def test_candidate_venv_validator_rejects_extra_nonregular_entry( with pytest.raises(RuntimeError, match="symlink|special"): _production_venv_validator()(environment) -from pdd.sync_core.identity import initialize_repository_identity def _run(root: Path, *args: str, env=None) -> subprocess.CompletedProcess[str]: From fb9bd40948b1ac80d6819137ce25ba37387a87ea Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 04:56:40 -0700 Subject: [PATCH 105/233] test(sync): require atomic lifecycle environment transaction --- tests/test_sync_core_lifecycle_scenarios.py | 188 +++++++++++++++++--- 1 file changed, 163 insertions(+), 25 deletions(-) diff --git a/tests/test_sync_core_lifecycle_scenarios.py b/tests/test_sync_core_lifecycle_scenarios.py index c8fc1abe93..f5e4412aa5 100644 --- a/tests/test_sync_core_lifecycle_scenarios.py +++ b/tests/test_sync_core_lifecycle_scenarios.py @@ -68,6 +68,18 @@ def test_candidate_venv_wrapper_creates_only_regular_tree_entries( assert stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode), path +def test_candidate_venv_command_rejects_symlinked_parent_before_resolution( + tmp_path: Path, +) -> None: + real_parent = tmp_path / "real" + real_parent.mkdir() + linked_parent = tmp_path / "linked" + linked_parent.symlink_to(real_parent, target_is_directory=True) + + with pytest.raises(ValueError, match="parent"): + lifecycle_module._candidate_venv_command(linked_parent / "candidate-venv") + + @pytest.mark.skipif( not sys.platform.startswith("linux") or sys.maxsize <= 2**32, reason="CPython lib64 alias is specific to 64-bit Linux", @@ -174,6 +186,78 @@ def _write_wheel( return wheel +def _run_candidate_transaction_wrapper( + root: Path, candidate: Path, *, cli_source: str +): + wheelhouse = root / "wheelhouse" + wheelhouse.mkdir(exist_ok=True) + candidate = _write_wheel( + candidate.parent, + distribution="pdd-cli", + version="1.0.0", + files={"pdd/__init__.py": "", "pdd/cli.py": cli_source}, + ) + lock = root / "candidate-install.lock" + lock.write_text( + f"{candidate.resolve().as_posix()} --hash=sha256:" + f"{hashlib.sha256(candidate.read_bytes()).hexdigest()}\n", + encoding="utf-8", + ) + environment = root / "candidate-venv" + command = lifecycle_module._candidate_transaction_command( + environment, wheelhouse, lock, 90, () + ) + completed = subprocess.run( + command, cwd=root, capture_output=True, text=True, check=False, timeout=120 + ) + return completed, lifecycle_module._parse_candidate_transaction_receipt(completed) + + +def test_candidate_transaction_real_wrapper_installs_and_proves_in_one_process( + tmp_path: Path, +) -> None: + completed, receipt = _run_candidate_transaction_wrapper( + tmp_path, + tmp_path / "pdd_cli-1.0.0-py3-none-any.whl", + cli_source=( + "import sys\n" + "if __name__ == '__main__':\n" + " print('candidate help')\n" + " raise SystemExit(0 if '--help' in sys.argv else 2)\n" + ), + ) + + assert completed.returncode == 0, completed.stderr + assert receipt is not None + assert len(receipt.dependency_digest) == 64 + assert any(row[2] == "pdd/cli.py" for row in receipt.installed_files) + assert not (tmp_path / "candidate-venv" / "lib64").is_symlink() + + +@pytest.mark.parametrize( + "cli_source", + [ + "raise SystemExit(7)\n", + ( + "import pathlib,sys\n" + "if __name__ == '__main__':\n" + " pathlib.Path(sys.prefix, 'mutated').write_text('changed')\n" + ), + ], +) +def test_candidate_transaction_fails_on_proof_or_closure_mutation( + tmp_path: Path, cli_source: str, +) -> None: + completed, receipt = _run_candidate_transaction_wrapper( + tmp_path, + tmp_path / "pdd_cli-1.0.0-py3-none-any.whl", + cli_source=cli_source, + ) + + assert completed.returncode != 0 + assert receipt is None + + def test_lifecycle_command_maps_inputs_read_only_and_environment_immutable( tmp_path, monkeypatch ) -> None: @@ -336,13 +420,19 @@ def test_candidate_install_uses_hash_pinned_wheelhouse_no_index( ) calls = [] - def fake_run(command, **kwargs): + def fake_run(command, *_args, **kwargs): calls.append((tuple(str(item) for item in command), kwargs)) - if command[:3] == [sys.executable, "-I", "-c"]: - (tmp_path / "candidate-venv" / ("Scripts" if os.name == "nt" else "bin")).mkdir( - parents=True - ) - return subprocess.CompletedProcess(command, 0, "ok", "") + receipt = { + "dependency_digest": "d" * 64, + "installed_files": [["candidate-environment", "1", "pdd/cli.py", "e" * 64]], + "scenario_returncode": None, + "scenario_stdout": None, + "status": "ok", + "version": 1, + } + return subprocess.CompletedProcess( + command, 0, json.dumps(receipt, separators=(",", ":"), sort_keys=True), "" + ) monkeypatch.setattr( "pdd.sync_core.lifecycle._lifecycle_command", @@ -356,19 +446,20 @@ def fake_run(command, **kwargs): lock, ) assert installed is not None - install_command = calls[1][0] - assert "--no-index" in install_command - assert "--require-hashes" in install_command - assert "--find-links" in install_command - assert str(wheelhouse.resolve()) in install_command - assert "--no-deps" not in install_command - combined_lock = Path(install_command[install_command.index("-r") + 1]) + assert len(calls) == 1 + transaction_command = calls[0][0] + wrapper_source = transaction_command[3] + assert "--no-index" in wrapper_source + assert "--require-hashes" in wrapper_source + assert "--find-links" in wrapper_source + assert "--only-binary=:all:" in wrapper_source + assert "--no-deps" not in wrapper_source + combined_lock = tmp_path / "candidate-install.lock" lock_text = combined_lock.read_text(encoding="utf-8") assert str(wheel.resolve()) in lock_text assert f"--hash=sha256:{hashlib.sha256(wheel.read_bytes()).hexdigest()}" in lock_text - create_command = calls[0][0] - assert create_command[:3] == (sys.executable, "-I", "-c") - assert create_command[-1] == str((tmp_path / "candidate-venv").resolve()) + assert transaction_command[:3] == (sys.executable, "-I", "-c") + assert str(wheelhouse.resolve()) in transaction_command def test_candidate_install_proves_isolated_module_entrypoint( @@ -382,13 +473,19 @@ def test_candidate_install_proves_isolated_module_entrypoint( lock.write_text("", encoding="utf-8") calls = [] - def fake_run(command, **_kwargs): + def fake_run(command, *_args, **_kwargs): calls.append(tuple(str(item) for item in command)) - if command[:3] == [sys.executable, "-I", "-c"]: - (tmp_path / "candidate-venv" / ("Scripts" if os.name == "nt" else "bin")).mkdir( - parents=True - ) - return subprocess.CompletedProcess(command, 0, "ok", "") + receipt = { + "dependency_digest": "d" * 64, + "installed_files": [["candidate-environment", "1", "pdd/cli.py", "e" * 64]], + "scenario_returncode": None, + "scenario_stdout": None, + "status": "ok", + "version": 1, + } + return subprocess.CompletedProcess( + command, 0, json.dumps(receipt, separators=(",", ":"), sort_keys=True), "" + ) monkeypatch.setattr( "pdd.sync_core.lifecycle._lifecycle_command", @@ -401,11 +498,52 @@ def fake_run(command, **_kwargs): wheelhouse, lock, ) - assert any( - command[-4:] == ("-I", "-m", "pdd.cli", "--help") - for command in calls + assert len(calls) == 1 + assert "['-I', '-m', 'pdd.cli', '--help']" in calls[0][3] + + +@pytest.mark.parametrize( + "mutator", + [ + lambda receipt: {**receipt, "version": True}, + lambda receipt: {**receipt, "dependency_digest": "short"}, + lambda receipt: {**receipt, "scenario_returncode": True, "scenario_stdout": ""}, + lambda receipt: {**receipt, "unexpected": 1}, + ], +) +def test_candidate_transaction_receipt_rejects_malformed_authority(mutator) -> None: + receipt = { + "dependency_digest": "d" * 64, + "installed_files": [["candidate-environment", "1", "pdd/cli.py", "e" * 64]], + "scenario_returncode": None, + "scenario_stdout": None, + "status": "ok", + "version": 1, + } + encoded = json.dumps(mutator(receipt), separators=(",", ":"), sort_keys=True) + completed = subprocess.CompletedProcess(["wrapper"], 0, encoded, "") + + assert lifecycle_module._parse_candidate_transaction_receipt(completed) is None + + +def test_candidate_transaction_receipt_rejects_extra_or_oversize_output() -> None: + receipt = { + "dependency_digest": "d" * 64, + "installed_files": [["candidate-environment", "1", "pdd/cli.py", "e" * 64]], + "scenario_returncode": None, + "scenario_stdout": None, + "status": "ok", + "version": 1, + } + encoded = json.dumps(receipt, separators=(",", ":"), sort_keys=True) + extra = subprocess.CompletedProcess(["wrapper"], 0, "candidate output\n" + encoded, "") + oversized = subprocess.CompletedProcess( + ["wrapper"], 0, "x" * (lifecycle_module._LIFECYCLE_RECEIPT_MAX_BYTES + 1), "" ) + assert lifecycle_module._parse_candidate_transaction_receipt(extra) is None + assert lifecycle_module._parse_candidate_transaction_receipt(oversized) is None + @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="protected lifecycle requires Linux" From db1016d46cbb0ff160033621910a9541fe9b18b2 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 05:04:25 -0700 Subject: [PATCH 106/233] fix(sync): keep candidate lifecycle in one transaction --- pdd/sync_core/lifecycle.py | 362 ++++++++++++++++---- tests/test_sync_core_lifecycle_scenarios.py | 80 ++--- 2 files changed, 320 insertions(+), 122 deletions(-) diff --git a/pdd/sync_core/lifecycle.py b/pdd/sync_core/lifecycle.py index e0d0b520b2..f89eacb83d 100644 --- a/pdd/sync_core/lifecycle.py +++ b/pdd/sync_core/lifecycle.py @@ -9,6 +9,7 @@ import subprocess import sys import tempfile +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -23,6 +24,9 @@ from .supervisor import run_supervised +_LIFECYCLE_RECEIPT_MAX_BYTES = 4 * 1024 * 1024 +_CHILD_OUTPUT_MAX_BYTES = 1024 * 1024 + _VENV_TREE_VALIDATOR_SOURCE = """ def _normalize_and_validate_environment(root): root = pathlib.Path(root) @@ -75,17 +79,159 @@ def raise_walk_error(error): "_normalize_and_validate_environment(root)", )) +_CANDIDATE_TRANSACTION_SOURCE = "\n".join(( + "import hashlib,json,os,pathlib,signal,stat,subprocess,sys,time,venv", + _VENV_TREE_VALIDATOR_SOURCE.strip(), + "if len(sys.argv) != 6: raise RuntimeError('invalid lifecycle transaction argv')", + "root=pathlib.Path(sys.argv[1])", + "wheelhouse=pathlib.Path(sys.argv[2])", + "requirements=pathlib.Path(sys.argv[3])", + "timeout=float(sys.argv[4])", + "scenario=json.loads(sys.argv[5])", + "if not root.is_absolute(): raise RuntimeError('candidate environment is not absolute')", + "parent_metadata=root.parent.lstat()", + "if not stat.S_ISDIR(parent_metadata.st_mode) or stat.S_ISLNK(parent_metadata.st_mode): " + "raise RuntimeError('candidate environment parent is invalid')", + "if os.path.lexists(root): raise RuntimeError('candidate environment already exists')", + "if type(timeout) is not float or not 0 < timeout <= 86400: " + "raise RuntimeError('invalid lifecycle child timeout')", + "if not isinstance(scenario,list) or not all(isinstance(item,str) for item in scenario): " + "raise RuntimeError('invalid lifecycle scenario command')", + f"output_limit={_CHILD_OUTPUT_MAX_BYTES}", + "deadline=time.monotonic()+timeout", + "def run_child(argv):", + " remaining=deadline-time.monotonic()", + " if remaining <= 0: raise RuntimeError('lifecycle child deadline expired')", + " child=subprocess.Popen(argv,stdout=subprocess.PIPE,stderr=subprocess.PIPE," + "text=False,start_new_session=True)", + " try:", + " stdout,stderr=child.communicate(timeout=remaining)", + " except subprocess.TimeoutExpired:", + " os.killpg(child.pid,signal.SIGKILL)", + " child.communicate()", + " raise RuntimeError('lifecycle child deadline expired') from None", + " try:", + " os.killpg(child.pid,0)", + " except ProcessLookupError:", + " pass", + " else:", + " os.killpg(child.pid,signal.SIGKILL)", + " raise RuntimeError('lifecycle child left surviving descendants')", + " if len(stdout)>output_limit or len(stderr)>output_limit: " + "raise RuntimeError('lifecycle child output exceeded limit')", + " return child.returncode,stdout,stderr", + "def closure():", + " rows=[]", + " for path in sorted(root.rglob('*')):", + " metadata=path.lstat()", + " if stat.S_ISLNK(metadata.st_mode): " + "raise RuntimeError('candidate environment contains a symlink')", + " if stat.S_ISDIR(metadata.st_mode): continue", + " if not stat.S_ISREG(metadata.st_mode): " + "raise RuntimeError('candidate environment contains a special file')", + " relative=path.relative_to(root).as_posix()", + " rows.append(('candidate-environment','1',relative," + "hashlib.sha256(path.read_bytes()).hexdigest()))", + " return tuple(rows)", + "venv.EnvBuilder(symlinks=False,with_pip=True).create(root)", + "_normalize_and_validate_environment(root)", + "candidate_python=root/('Scripts/python.exe' if os.name=='nt' else 'bin/python')", + "pip_command=[str(candidate_python),'-m','pip','install','--no-index','--find-links'," + "str(wheelhouse),'--require-hashes','--only-binary=:all:'," + "'--disable-pip-version-check','--force-reinstall','-r',str(requirements)]", + "pip_status,_,_=run_child(pip_command)", + "if pip_status != 0: raise RuntimeError('candidate installation failed')", + "_normalize_and_validate_environment(root)", + "installed=closure()", + "proof_status,_,_=run_child([str(candidate_python),'-I','-m','pdd.cli','--help'])", + "if proof_status != 0: raise RuntimeError('candidate proof failed')", + "_normalize_and_validate_environment(root)", + "if closure()!=installed: raise RuntimeError('candidate proof mutated environment')", + "scenario_status=None", + "scenario_stdout=None", + "if scenario:", + " scenario_status,scenario_bytes,_=run_child(scenario)", + " scenario_stdout=scenario_bytes.decode('utf-8','strict')", + " _normalize_and_validate_environment(root)", + " if closure()!=installed: raise RuntimeError('candidate scenario mutated environment')", + "digest=hashlib.sha256(json.dumps(installed,separators=(',',':')).encode()).hexdigest()", + "receipt={'dependency_digest':digest,'installed_files':installed," + "'scenario_returncode':scenario_status,'scenario_stdout':scenario_stdout," + "'status':'ok','version':1}", + "encoded=json.dumps(receipt,separators=(',',':'),sort_keys=True)", + f"if len(encoded.encode())>{_LIFECYCLE_RECEIPT_MAX_BYTES}: " + "raise RuntimeError('lifecycle receipt exceeded limit')", + "sys.stdout.write(encoded)", +)) -def _candidate_venv_command(environment: Path) -> list[str]: - """Build the exact isolated wrapper command for one checker-owned venv path.""" + +@dataclass(frozen=True) +class _CandidateTransactionReceipt: + dependency_digest: str + installed_files: tuple[tuple[str, str, str, str], ...] + scenario_returncode: int | None + scenario_stdout: str | None + + +def _lexical_real_directory(path: Path) -> Path: + """Validate every lexical path component before canonicalizing it.""" + if not path.is_absolute(): + raise ValueError("candidate environment parent is not absolute") + current = Path(path.anchor) try: - parent = environment.parent.resolve(strict=True) + for component in path.parts[1:]: + current /= component + metadata = current.lstat() + if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise ValueError("candidate environment parent is invalid") + resolved = path.resolve(strict=True) except OSError as exc: raise ValueError("candidate environment parent is unavailable") from exc + if resolved != path: + raise ValueError("candidate environment parent changed during validation") + return resolved + + +def _candidate_venv_command(environment: Path) -> list[str]: + """Build the exact isolated wrapper command for one checker-owned venv path.""" + parent = _lexical_real_directory(environment.parent) + if os.path.lexists(environment): + raise ValueError("candidate environment destination already exists") destination = parent / environment.name return [sys.executable, "-I", "-c", _VENV_CREATION_SOURCE, str(destination)] +def _candidate_transaction_command( + environment: Path, + wheelhouse: Path, + requirements: Path, + timeout_seconds: int, + scenario_command: tuple[str, ...], +) -> list[str]: + """Build one trusted wrapper command for create, install, proof, and scenario.""" + if ( + type(timeout_seconds) is not int # pylint: disable=unidiomatic-typecheck + or not 0 < timeout_seconds <= 86400 + or not isinstance(scenario_command, tuple) + or not all(isinstance(item, str) for item in scenario_command) + ): + raise ValueError("candidate transaction arguments are invalid") + parent = _lexical_real_directory(environment.parent) + if os.path.lexists(environment): + raise ValueError("candidate environment destination already exists") + return [ + sys.executable, + "-I", + "-c", + _CANDIDATE_TRANSACTION_SOURCE, + str(parent / environment.name), + str(wheelhouse.resolve(strict=True)), + str(requirements.resolve(strict=True)), + str(timeout_seconds), + json.dumps(scenario_command, separators=(",", ":")), + ] + + def _isolated_lifecycle_environment(home: Path) -> dict[str, str]: """Build a credential-free environment with no source import overrides.""" return untrusted_child_environment( @@ -247,67 +393,140 @@ def _combined_candidate_lock(temporary: Path, runtime_lock: Path, wheel: Path) - return combined -def _install_candidate_wheel( +def _parse_candidate_transaction_receipt( + completed: subprocess.CompletedProcess[str], +) -> _CandidateTransactionReceipt | None: + # pylint: disable=too-many-return-statements,too-many-boolean-expressions + # pylint: disable=unidiomatic-typecheck + """Parse the wrapper's exact canonical, bounded receipt fail closed.""" + stdout = completed.stdout + if ( + completed.returncode != 0 + or not isinstance(stdout, str) + or not isinstance(completed.stderr, str) + or completed.stderr + or len(stdout.encode("utf-8")) > _LIFECYCLE_RECEIPT_MAX_BYTES + ): + return None + try: + payload = json.loads(stdout) + except (TypeError, ValueError, json.JSONDecodeError): + return None + expected_keys = { + "dependency_digest", + "installed_files", + "scenario_returncode", + "scenario_stdout", + "status", + "version", + } + if ( + not isinstance(payload, dict) + or set(payload) != expected_keys + or type(payload["version"]) is not int + or payload["version"] != 1 + or payload["status"] != "ok" + or not isinstance(payload["dependency_digest"], str) + or len(payload["dependency_digest"]) != 64 + or any(character not in "0123456789abcdef" for character in payload["dependency_digest"]) + or json.dumps(payload, separators=(",", ":"), sort_keys=True) != stdout + ): + return None + raw_files = payload["installed_files"] + if not isinstance(raw_files, list): + return None + installed_files: list[tuple[str, str, str, str]] = [] + seen_paths: set[str] = set() + for row in raw_files: + if ( + not isinstance(row, list) + or len(row) != 4 + or row[0:2] != ["candidate-environment", "1"] + or not isinstance(row[2], str) + or not row[2] + or row[2] in seen_paths + or Path(row[2]).is_absolute() + or ".." in Path(row[2]).parts + or not isinstance(row[3], str) + or len(row[3]) != 64 + or any(character not in "0123456789abcdef" for character in row[3]) + ): + return None + seen_paths.add(row[2]) + installed_files.append(tuple(row)) + if installed_files != sorted(installed_files, key=lambda row: Path(row[2]).parts): + return None + expected_digest = hashlib.sha256( + json.dumps(tuple(installed_files), separators=(",", ":")).encode() + ).hexdigest() + if payload["dependency_digest"] != expected_digest: + return None + scenario_returncode = payload["scenario_returncode"] + scenario_stdout = payload["scenario_stdout"] + scenario_absent = scenario_returncode is None and scenario_stdout is None + scenario_present = ( + type(scenario_returncode) is int + and -255 <= scenario_returncode <= 255 + and isinstance(scenario_stdout, str) + and len(scenario_stdout.encode("utf-8")) <= _CHILD_OUTPUT_MAX_BYTES + ) + if not scenario_absent and not scenario_present: + return None + return _CandidateTransactionReceipt( + payload["dependency_digest"], + tuple(installed_files), + scenario_returncode, + scenario_stdout, + ) + + +def _run_candidate_transaction( temporary: Path, home: Path, wheel: Path, wheelhouse: Path, runtime_lock: Path, -) -> tuple[Path, str] | None: - # pylint: disable=too-many-return-statements - """Install the exact candidate wheel and runtime deps from a pinned wheelhouse.""" + *, + timeout_seconds: int = 1200, + scenario_command: tuple[str, ...] = (), + scenario_readable_roots: tuple[Path, ...] = (), +) -> tuple[_CandidateTransactionReceipt | None, int]: + # pylint: disable=too-many-arguments + """Run the complete ephemeral candidate lifecycle in one supervised command.""" if not wheelhouse.is_dir() or not runtime_lock.is_file(): - return None + return None, 125 combined_lock = _combined_candidate_lock(temporary, runtime_lock, wheel) if combined_lock is None: - return None + return None, 125 environment = temporary / "candidate-venv" - isolated = _isolated_lifecycle_environment(home) - created = _lifecycle_command( - _candidate_venv_command(environment), + try: + command = _candidate_transaction_command( + environment, wheelhouse, combined_lock, timeout_seconds, scenario_command + ) + except (OSError, ValueError): + return None, 125 + completed = _lifecycle_command( + command, temporary, home, + timeout_seconds, + readable_roots=(wheelhouse, wheel, runtime_lock, *scenario_readable_roots), ) - candidate_python = environment / ( - "Scripts/python.exe" if os.name == "nt" else "bin/python" - ) - if created.returncode != 0: - return None - installed = _lifecycle_command( - [ - str(candidate_python), - "-m", - "pip", - "install", - "--no-index", - "--find-links", - str(wheelhouse.resolve()), - "--require-hashes", - "--only-binary=:all:", - "--disable-pip-version-check", - "--force-reinstall", - "-r", - str(combined_lock), - ], - temporary, home, readable_roots=(wheelhouse, wheel, runtime_lock), - ) - if installed.returncode != 0: - return None - closure = _installed_file_closure(candidate_python) - proof_scratch = temporary / "proof-scratch" - proof_scratch.mkdir(mode=0o700) - proved = _lifecycle_command( - [str(candidate_python), "-I", "-m", "pdd.cli", "--help"], temporary, home, - readable_roots=(environment,), writable_roots=(proof_scratch,), cwd=proof_scratch, + return _parse_candidate_transaction_receipt(completed), completed.returncode + + +def _install_candidate_wheel( + temporary: Path, + home: Path, + wheel: Path, + wheelhouse: Path, + runtime_lock: Path, +) -> _CandidateTransactionReceipt | None: + """Install and prove the candidate within one ephemeral transaction.""" + receipt, _returncode = _run_candidate_transaction( + temporary, home, wheel, wheelhouse, runtime_lock ) - if proved.returncode != 0: - return None - if _installed_file_closure(candidate_python) != closure: - return None - dependency_digest = _dependency_environment_digest(candidate_python, isolated) - if len(dependency_digest) != 64: - return None - return candidate_python, dependency_digest + return receipt def run_lifecycle_matrix( @@ -372,17 +591,9 @@ def run_lifecycle_matrix( except (OSError, CandidateArtifactProvenanceError): return _failed_result() (temporary / "home").mkdir(mode=0o700) - installed_candidate = _install_candidate_wheel( - temporary, - temporary / "home", - protected_wheel, - Path(candidate_wheelhouse), - protected_lock, + candidate_python = temporary / "candidate-venv" / ( + "Scripts/python.exe" if os.name == "nt" else "bin/python" ) - if installed_candidate is None: - return _failed_result() - candidate_python, dependency_digest = installed_candidate - installed_files = _installed_file_closure(candidate_python) measured_python = _candidate_interpreter_identity( candidate_python, _isolated_lifecycle_environment(temporary / "home") ) @@ -410,21 +621,28 @@ def run_lifecycle_matrix( "--candidate-python", str(candidate_python), ] - scenario_scratch = temporary / "scenario-scratch" - scenario_scratch.mkdir(mode=0o700) - completed = _lifecycle_command( - command, temporary, temporary / "home", timeout_seconds, - readable_roots=(candidate_python.parents[1], Path(cloud_root).resolve()), - writable_roots=(scenario_scratch,), cwd=scenario_scratch, + receipt, transaction_returncode = _run_candidate_transaction( + temporary, + temporary / "home", + protected_wheel, + Path(candidate_wheelhouse), + protected_lock, + timeout_seconds=timeout_seconds, + scenario_command=tuple(command), + scenario_readable_roots=(Path(cloud_root).resolve(),), ) - if _installed_file_closure(candidate_python) != installed_files: + if receipt is None: + return _failed_result(timeout=transaction_returncode == 124) + dependency_digest = receipt.dependency_digest + installed_files = receipt.installed_files + if receipt.scenario_returncode is None or receipt.scenario_stdout is None: return _failed_result() - if completed.returncode == 124: + if receipt.scenario_returncode == 124: return _failed_result(timeout=True) try: - lines = [line for line in completed.stdout.splitlines() if line.strip()] + lines = [line for line in receipt.scenario_stdout.splitlines() if line.strip()] results = _normalized_results(json.loads(lines[-1])) - except (OSError, ValueError, json.JSONDecodeError): + except (IndexError, OSError, ValueError, json.JSONDecodeError): return _failed_result() missing = tuple( sorted( @@ -434,7 +652,7 @@ def run_lifecycle_matrix( ) ) failures = sum(row["status"] != "PASS" for row in results.values()) - if completed.returncode != 0 and failures == 0: + if receipt.scenario_returncode != 0 and failures == 0: failures = 1 return LifecycleResult( failures, diff --git a/tests/test_sync_core_lifecycle_scenarios.py b/tests/test_sync_core_lifecycle_scenarios.py index f5e4412aa5..cffa29b453 100644 --- a/tests/test_sync_core_lifecycle_scenarios.py +++ b/tests/test_sync_core_lifecycle_scenarios.py @@ -1,5 +1,6 @@ """Process-level merge, wheel, and real-consumer lifecycle scenarios.""" # pylint: disable=missing-function-docstring,protected-access,redefined-outer-name +# pylint: disable=import-outside-toplevel import argparse import hashlib @@ -213,6 +214,21 @@ def _run_candidate_transaction_wrapper( return completed, lifecycle_module._parse_candidate_transaction_receipt(completed) +def _valid_transaction_receipt() -> dict: + files = [["candidate-environment", "1", "pdd/cli.py", "e" * 64]] + digest = hashlib.sha256( + json.dumps(tuple(tuple(row) for row in files), separators=(",", ":")).encode() + ).hexdigest() + return { + "dependency_digest": digest, + "installed_files": files, + "scenario_returncode": None, + "scenario_stdout": None, + "status": "ok", + "version": 1, + } + + def test_candidate_transaction_real_wrapper_installs_and_proves_in_one_process( tmp_path: Path, ) -> None: @@ -228,9 +244,10 @@ def test_candidate_transaction_real_wrapper_installs_and_proves_in_one_process( ) assert completed.returncode == 0, completed.stderr - assert receipt is not None + assert receipt is not None, completed.stdout + assert "candidate help" not in completed.stdout assert len(receipt.dependency_digest) == 64 - assert any(row[2] == "pdd/cli.py" for row in receipt.installed_files) + assert any(row[2].endswith("/pdd/cli.py") for row in receipt.installed_files) assert not (tmp_path / "candidate-venv" / "lib64").is_symlink() @@ -389,7 +406,7 @@ def test_lifecycle_matrix_rejects_actual_runtime_lock_mismatch(tmp_path, monkeyp lambda *_args, **_kwargs: object(), ) monkeypatch.setattr( - "pdd.sync_core.lifecycle._install_candidate_wheel", + "pdd.sync_core.lifecycle._run_candidate_transaction", lambda *_args, **_kwargs: pytest.fail("mismatched lock reached installation"), ) result = run_lifecycle_matrix( @@ -422,14 +439,7 @@ def test_candidate_install_uses_hash_pinned_wheelhouse_no_index( def fake_run(command, *_args, **kwargs): calls.append((tuple(str(item) for item in command), kwargs)) - receipt = { - "dependency_digest": "d" * 64, - "installed_files": [["candidate-environment", "1", "pdd/cli.py", "e" * 64]], - "scenario_returncode": None, - "scenario_stdout": None, - "status": "ok", - "version": 1, - } + receipt = _valid_transaction_receipt() return subprocess.CompletedProcess( command, 0, json.dumps(receipt, separators=(",", ":"), sort_keys=True), "" ) @@ -475,14 +485,7 @@ def test_candidate_install_proves_isolated_module_entrypoint( def fake_run(command, *_args, **_kwargs): calls.append(tuple(str(item) for item in command)) - receipt = { - "dependency_digest": "d" * 64, - "installed_files": [["candidate-environment", "1", "pdd/cli.py", "e" * 64]], - "scenario_returncode": None, - "scenario_stdout": None, - "status": "ok", - "version": 1, - } + receipt = _valid_transaction_receipt() return subprocess.CompletedProcess( command, 0, json.dumps(receipt, separators=(",", ":"), sort_keys=True), "" ) @@ -499,7 +502,7 @@ def fake_run(command, *_args, **_kwargs): lock, ) assert len(calls) == 1 - assert "['-I', '-m', 'pdd.cli', '--help']" in calls[0][3] + assert "[str(candidate_python),'-I','-m','pdd.cli','--help']" in calls[0][3] @pytest.mark.parametrize( @@ -512,14 +515,7 @@ def fake_run(command, *_args, **_kwargs): ], ) def test_candidate_transaction_receipt_rejects_malformed_authority(mutator) -> None: - receipt = { - "dependency_digest": "d" * 64, - "installed_files": [["candidate-environment", "1", "pdd/cli.py", "e" * 64]], - "scenario_returncode": None, - "scenario_stdout": None, - "status": "ok", - "version": 1, - } + receipt = _valid_transaction_receipt() encoded = json.dumps(mutator(receipt), separators=(",", ":"), sort_keys=True) completed = subprocess.CompletedProcess(["wrapper"], 0, encoded, "") @@ -527,14 +523,7 @@ def test_candidate_transaction_receipt_rejects_malformed_authority(mutator) -> N def test_candidate_transaction_receipt_rejects_extra_or_oversize_output() -> None: - receipt = { - "dependency_digest": "d" * 64, - "installed_files": [["candidate-environment", "1", "pdd/cli.py", "e" * 64]], - "scenario_returncode": None, - "scenario_stdout": None, - "status": "ok", - "version": 1, - } + receipt = _valid_transaction_receipt() encoded = json.dumps(receipt, separators=(",", ":"), sort_keys=True) extra = subprocess.CompletedProcess(["wrapper"], 0, "candidate output\n" + encoded, "") oversized = subprocess.CompletedProcess( @@ -602,20 +591,11 @@ def traced_command(*args, **kwargs): assert installed is not None, "\n".join( result.stderr for result in commands if result.stderr ) - candidate_python, dependency_digest = installed - pyvenv = candidate_python.parents[1] / "pyvenv.cfg" - assert "include-system-site-packages = false" in pyvenv.read_text(encoding="utf-8") - probe = _run( - tmp_path, - str(candidate_python), - "-I", - "-m", - "pdd.cli", - "--help", - ) - assert probe.returncode == 0 - assert "runtime wheel loaded" in probe.stdout - assert len(dependency_digest) == 64 + assert len(commands) == 1 + assert len(installed.dependency_digest) == 64 + assert any(row[2].endswith("/runtime_dep.py") for row in installed.installed_files) + assert any(row[2].endswith("/pdd/cli.py") for row in installed.installed_files) + assert not (tmp_path / "candidate-venv").exists() def test_lifecycle_environment_strips_signer_capabilities(tmp_path, monkeypatch) -> None: From 103a2d74f331b85195c873ed5806a4c7b8d50cbf Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 05:37:05 -0700 Subject: [PATCH 107/233] test(sync): require OOM-surviving cgroup monitor leaf --- tests/test_sync_core_supervisor.py | 113 ++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 12 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 9a969d41bf..c02b66cbca 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -907,18 +907,19 @@ def test_linux_sandbox_binds_probe_identity_to_systemd_scope_execution( assert "--wait" not in argv assert "--collect" not in argv assert f"--unit={plan.unit_name}" in argv - assert "--property=MemoryMax=2147483648" in argv - assert "--property=MemorySwapMax=0" in argv - assert "--property=TasksMax=128" in argv - assert "--property=OOMPolicy=kill" in argv + assert "--property=Delegate=yes" in argv + assert "--property=MemoryMax=infinity" in argv + assert "--property=MemorySwapMax=infinity" in argv + assert "--property=TasksMax=infinity" in argv + assert "--property=OOMPolicy=continue" in argv assert "--property=KillMode=control-group" in argv assert plan.bwrap_argv[0] == tools["bwrap"] -def test_linux_sandbox_releases_candidate_only_after_scope_probe( +def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """The privileged helper must configure the aggregate cgroup before release.""" + """The privileged helper must move the blocked child before untrusted exec.""" monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) @@ -937,10 +938,25 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( helper = plan.helper_source compile(helper, "", "exec") - assert "cgroup.procs" not in helper - assert "memory.max" not in helper + assert "monitor_cgroup=scope_cgroup/'monitor'" in helper + assert "candidate_cgroup=scope_cgroup/'candidate'" in helper + assert "(monitor_cgroup/'cgroup.procs').write_text(str(os.getpid())" in helper + assert "(candidate_cgroup/'memory.max').write_text(str(limits['memory'])" in helper + assert "(candidate_cgroup/'memory.swap.max').write_text('0'" in helper + assert "(candidate_cgroup/'memory.oom.group').write_text('1'" in helper + assert "(candidate_cgroup/'pids.max').write_text(str(limits['pids'])" in helper + assert "(candidate_cgroup/'cgroup.kill').write_text('1'" in helper assert "ready" in helper and "start" in helper assert helper.index("start") < helper.index("os.fork()") + assert "release_read,release_write=os.pipe()" in helper + assert "os.read(release_read,1)" in helper + assert "(candidate_cgroup/'cgroup.procs').write_text(str(pid)" in helper + assert helper.index("(candidate_cgroup/'cgroup.procs').write_text(str(pid)") < helper.index( + "os.write(release_write,b'1')" + ) + assert helper.index("os.read(release_read,1)") < helper.index( + "os.execvpe(argv[0],argv,os.environ)" + ) assert "os.pidfd_open(pid" in helper assert "select.poll()" in helper assert "poller.poll" in helper @@ -951,6 +967,9 @@ def test_linux_sandbox_releases_candidate_only_after_scope_probe( assert "timeout=limits['trusted_timeout']" in helper assert json.loads(argv[-1])["timeout"] == 17 assert "result.json" in helper and "finish" in helper + assert "candidate cgroup remained populated" in helper + assert "candidate_cgroup.rmdir()" in helper + assert "monitor_cgroup.rmdir()" in helper assert "-t','tmpfs" in helper assert "/proc/self/mountinfo" in helper assert "writable tmpfs mount probe failed" in helper @@ -1188,6 +1207,8 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( tools = supervisor._trusted_tools() cgroup = tmp_path / "scope-cgroup" cgroup.mkdir() + candidate = cgroup / "candidate" + candidate.mkdir() values = { "memory.max": "2147483648", "memory.swap.max": "0", "memory.oom.group": "1", "pids.max": "128", @@ -1195,12 +1216,12 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( "pids.events": "max 4\n", } for name, value in values.items(): - (cgroup / name).write_text(value, encoding="ascii") + (candidate / name).write_text(value, encoding="ascii") properties = { "LoadState": "loaded", "ActiveState": "active", "ControlGroup": "/system.slice/example.scope", - "MemoryMax": "2147483648", "MemorySwapMax": "0", - "TasksMax": "128", "OOMPolicy": "kill", + "MemoryMax": "infinity", "MemorySwapMax": "infinity", + "TasksMax": "infinity", "OOMPolicy": "continue", "Delegate": "yes", "KillMode": "control-group", "Result": "success", } monkeypatch.setattr(supervisor, "_scope_properties", lambda *_args: properties) @@ -1213,7 +1234,7 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( plan, SupervisorLimits() ) - assert actual_cgroup == cgroup + assert actual_cgroup == candidate assert memory["oom_kill"] == 2 assert pids["max"] == 4 properties["KillMode"] = "process" @@ -1221,6 +1242,74 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( supervisor._probe_scope(plan, SupervisorLimits()) +def test_scope_probe_rejects_missing_candidate_leaf( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The parent scope is never accepted as the candidate resource domain.""" + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + tools = supervisor._trusted_tools() + parent = tmp_path / "scope-cgroup" + parent.mkdir() + properties = { + "LoadState": "loaded", "ActiveState": "active", + "ControlGroup": "/system.slice/example.scope", + "MemoryMax": "infinity", "MemorySwapMax": "infinity", + "TasksMax": "infinity", "OOMPolicy": "continue", "Delegate": "yes", + "KillMode": "control-group", "Result": "success", + } + monkeypatch.setattr(supervisor, "_scope_properties", lambda *_args: properties) + monkeypatch.setattr(supervisor, "_scope_cgroup", lambda _properties: parent) + plan = supervisor._ScopePlan( + supervisor._scope_unit_name(), tmp_path, "", (), (), (), tools, + ) + + with pytest.raises(RuntimeError, match="candidate leaf"): + supervisor._probe_scope(plan, SupervisorLimits()) + + +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not shutil.which("bwrap"), + reason="requires Linux cgroup-v2 and bubblewrap", +) +@pytest.mark.parametrize("case", ["oom", "pids", "timeout"]) +def test_exact_supervisor_candidate_leaf_preserves_monitor_and_events( + tmp_path: Path, case: str, +) -> None: + """Exercise the generated helper against real delegated cgroup-v2 limits.""" + commands = { + "oom": [sys.executable, "-c", "bytearray(256 * 1024 * 1024)"], + "pids": [ + sys.executable, + "-c", + "import subprocess,sys;" + "children=[subprocess.Popen([sys.executable,'-c','import time;time.sleep(2)']) " + "for _ in range(16)];[child.wait() for child in children]", + ], + "timeout": [sys.executable, "-c", "import time;time.sleep(30)"], + } + limits = SupervisorLimits( + max_memory_bytes=96 * 1024 * 1024, + max_virtual_memory_bytes=512 * 1024 * 1024, + max_processes=8, + ) + result, surviving = run_supervised( + commands[case], cwd=tmp_path, timeout=.1 if case == "timeout" else 10, + env=dict(os.environ), writable_roots=(tmp_path,), limits=limits, + ) + + assert not surviving + assert "scope produced no protected candidate record" not in result.stderr + if case == "oom": + assert result.returncode != 125, result.stderr + assert "cgroup memory.events oom delta=" in result.stderr + elif case == "pids": + assert result.returncode != 0, result.stderr + assert "cgroup pids.events max delta=" in result.stderr + else: + assert result.returncode == 124, result.stderr + + @pytest.mark.parametrize( ("filename", "content", "missing"), [ From f5d3c9114f658bc65948295a757fa08163b8f4f1 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 05:42:28 -0700 Subject: [PATCH 108/233] fix(sync): isolate cgroup monitor from candidate OOM --- .github/workflows/unit-tests.yml | 6 +- pdd/sync_core/supervisor.py | 125 ++++++++++++++++++++++++++--- tests/test_sync_core_supervisor.py | 5 ++ 3 files changed, 122 insertions(+), 14 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ecd6543855..a8fe93796d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -280,7 +280,8 @@ jobs: max_virtual_memory_bytes=2 * 1024 * 1024 * 1024, ), ) - assert oom.returncode != 0, oom.stderr + assert oom.returncode not in (0, 125), oom.stderr + assert 'scope produced no protected candidate record' not in oom.stderr assert 'cgroup memory.events oom delta=' in oom.stderr, oom.stderr assert not surviving assert_no_leak() @@ -290,6 +291,8 @@ jobs: writable_roots=(root / 'scratch',), limits=SupervisorLimits(max_processes=8), ) assert tasks.returncode != 0, tasks.stderr + assert tasks.returncode != 125, tasks.stderr + assert 'scope produced no protected candidate record' not in tasks.stderr assert 'cgroup pids.events max delta=' in tasks.stderr, tasks.stderr assert not surviving assert_no_leak() @@ -299,6 +302,7 @@ jobs: writable_roots=(root / 'scratch',), limits=SupervisorLimits(), ) assert timed_out.returncode == 124, timed_out.stderr + assert 'scope produced no protected candidate record' not in timed_out.stderr assert not surviving assert_no_leak() output, surviving = run_supervised( diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 530c74f4b6..059dcd12c7 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -334,6 +334,7 @@ def _staged_bwrap( "paths=json.loads(sys.argv[-3])", "targets=[control/'binds'/str(index) for index in range(len(paths))]", "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None", + "scope_cgroup=None; monitor_cgroup=None; candidate_cgroup=None", _PIDFD_PROTOCOL_SOURCE.strip(), "def wait_for(name):", " path=control/name", @@ -346,6 +347,48 @@ def _staged_bwrap( " if source == pathlib.Path('/sys/fs/cgroup') or not source.is_dir(): " "raise RuntimeError('protected scope cgroup is invalid')", " return source", + "def flat_values(path):", + " values={}", + " for line in path.read_text(encoding='ascii').splitlines():", + " fields=line.split()", + " if len(fields)!=2 or fields[0] in values: " + "raise RuntimeError(f'invalid cgroup event file: {path.name}')", + " values[fields[0]]=fields[1]", + " return values", + "def wait_candidate_empty():", + " deadline=time.monotonic()+limits['trusted_timeout']", + " while time.monotonic() dict[str, str]: unit_name = _validated_scope_unit(unit_name) names = ( "LoadState", "ActiveState", "ControlGroup", "MemoryMax", - "MemorySwapMax", "TasksMax", "OOMPolicy", "KillMode", "Result", + "MemorySwapMax", "TasksMax", "OOMPolicy", "KillMode", "Delegate", "Result", "OOMKilled", ) completed = _root_run( @@ -691,13 +762,14 @@ def _cgroup_events(cgroup: Path, filename: str) -> dict[str, int]: def _probe_scope( plan: _ScopePlan, limits: SupervisorLimits, ) -> tuple[Path, dict[str, int], dict[str, int]]: - """Prove systemd and kernel limits before releasing the candidate.""" + # pylint: disable=too-many-locals,too-many-boolean-expressions + """Prove the delegated candidate leaf limits before releasing the child.""" properties = _scope_properties(plan.unit_name, plan.tools) expected = { "LoadState": "loaded", "ActiveState": "active", - "MemoryMax": str(limits.max_memory_bytes), "MemorySwapMax": "0", - "TasksMax": str(limits.max_processes), "OOMPolicy": "kill", - "KillMode": "control-group", + "MemoryMax": "infinity", "MemorySwapMax": "infinity", + "TasksMax": "infinity", "OOMPolicy": "continue", + "KillMode": "control-group", "Delegate": "yes", } differences = [ f"{name}={properties.get(name, '')}" @@ -705,7 +777,34 @@ def _probe_scope( ] if differences: raise RuntimeError("protected scope properties unverified: " + ", ".join(differences)) - cgroup = _scope_cgroup(properties) + parent = _scope_cgroup(properties) + cgroup = parent / "candidate" + monitor = parent / "monitor" + try: + parent_resolved = parent.resolve(strict=True) + metadata = cgroup.lstat() + monitor_metadata = monitor.lstat() + if ( + not stat.S_ISDIR(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISDIR(monitor_metadata.st_mode) + or stat.S_ISLNK(monitor_metadata.st_mode) + or cgroup.resolve(strict=True).parent != parent_resolved + or monitor.resolve(strict=True).parent != parent_resolved + ): + raise RuntimeError("protected scope candidate leaf topology is invalid") + parent_members = (parent / "cgroup.procs").read_text(encoding="ascii").split() + monitor_members = (monitor / "cgroup.procs").read_text(encoding="ascii").split() + candidate_members = (cgroup / "cgroup.procs").read_text(encoding="ascii").split() + except OSError as exc: + raise RuntimeError("protected scope candidate leaf is unavailable") from exc + if ( + parent_members + or len(monitor_members) != 1 + or not monitor_members[0].isdecimal() + or candidate_members + ): + raise RuntimeError("protected scope candidate leaf membership is invalid") kernel_limits = { "memory.max": str(limits.max_memory_bytes), "memory.swap.max": "0", "memory.oom.group": "1", "pids.max": str(limits.max_processes), @@ -978,7 +1077,7 @@ def run_supervised( result_fifo: Path | None = None, result_fd: int = 198, ) -> tuple[subprocess.CompletedProcess[str], bool]: - """Run only after proving one aggregate systemd scope, then remove it.""" + """Run after proving one delegated candidate leaf, then remove its scope.""" # pylint: disable=consider-using-with,too-many-locals,too-many-branches,too-many-statements token = uuid.uuid4().hex unit_name = _scope_unit_name() diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index c02b66cbca..82191716d4 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1209,6 +1209,11 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( cgroup.mkdir() candidate = cgroup / "candidate" candidate.mkdir() + monitor = cgroup / "monitor" + monitor.mkdir() + (cgroup / "cgroup.procs").write_text("", encoding="ascii") + (monitor / "cgroup.procs").write_text("123\n", encoding="ascii") + (candidate / "cgroup.procs").write_text("", encoding="ascii") values = { "memory.max": "2147483648", "memory.swap.max": "0", "memory.oom.group": "1", "pids.max": "128", From 5d47596bd0d1c9938c12acce9626591a43d11c7e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 05:39:19 -0700 Subject: [PATCH 109/233] test(sync): cover Playwright list reporter protocol --- tests/test_sync_core_runner_playwright.py | 99 +++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index afec380839..886de4e599 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -382,6 +382,105 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( assert dict(envelope.binding.adapter_identities)["playwright"] +@pytest.mark.skipif( + not sys.platform.startswith("linux") + or not os.environ.get("PDD_RUN_REAL_PLAYWRIGHT") + or not os.environ.get("PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"), + reason="requires the mandatory hosted Linux Playwright protocol lane", +) +@pytest.mark.parametrize( + ("suffix", "js_scope"), + [ + (".js", "commonjs"), + (".js", "module"), + (".cjs", None), + (".mjs", None), + (".ts", None), + (".cts", None), + (".mts", None), + ], +) +def test_real_playwright_1_55_list_protocol_emits_canonical_identities( + tmp_path: Path, suffix: str, js_scope: str | None, +) -> None: + """Use the exact reporter with every admitted config syntax and ``--list``.""" + if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): + module_path = Path(runner_module.__file__).resolve() + assert "site-packages" in module_path.parts, module_path + + manifest = Path(os.environ["PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"]) + roles = json.loads(manifest.read_text(encoding="utf-8"))["roles"] + root = tmp_path / "candidate" + controller = tmp_path / "controller" + root.mkdir() + controller.mkdir() + (root / "tests").mkdir() + (root / "tests/widget.spec.ts").write_text( + "import { test } from '@playwright/test';\n" + "test.describe('widget suite', () => {\n" + " test('list protocol works', () => {});\n" + "});\n", + encoding="utf-8", + ) + commonjs = suffix == ".cjs" or ( + suffix == ".js" and js_scope == "commonjs" + ) + config = ( + "module.exports = { testDir: './tests', projects: [{ name: 'chromium' }, { name: 'firefox' }] };\n" + if commonjs + else "export default { testDir: './tests', projects: [{ name: 'chromium' }, { name: 'firefox' }] };\n" + ) + config_path = root / f"playwright.config{suffix}" + config_path.write_text(config, encoding="utf-8") + if suffix == ".js": + assert js_scope is not None + (root / "package.json").write_text( + json.dumps({"type": js_scope}), encoding="utf-8" + ) + read_fd, write_fd = os.pipe() + result_fd = 198 + reporter = controller / "reporter.cjs" + reporter.write_text(_playwright_reporter_source(result_fd), encoding="utf-8") + try: + saved_fd = os.dup(result_fd) + except OSError: + saved_fd = None + try: + os.dup2(write_fd, result_fd) + result = subprocess.run( + [ + roles["launcher"], roles["entrypoint"], "test", + "tests/widget.spec.ts", f"--config={config_path}", + f"--reporter={reporter}", "--list", + ], + cwd=root, + capture_output=True, + text=True, + env={**os.environ, "NODE_PATH": roles["dependencies"]}, + pass_fds=(result_fd,), + check=False, + ) + finally: + os.close(write_fd) + if saved_fd is None: + os.close(result_fd) + else: + os.dup2(saved_fd, result_fd) + os.close(saved_fd) + payload = os.read(read_fd, 64 * 1024).decode("utf-8") + os.close(read_fd) + + outcome, detail, identities = _playwright_result( + root, payload, result.returncode, None, collection=True, + ) + + assert outcome is EvidenceOutcome.PASS, f"{detail}: {result.stderr}" + assert identities == ( + "chromium::tests/widget.spec.ts::widget suite > list protocol works", + "firefox::tests/widget.spec.ts::widget suite > list protocol works", + ) + + def test_playwright_binds_static_runtime_resources_and_rejects_reflection( tmp_path: Path, ) -> None: From 6c608d7a6a6cc24fd8bf4d254837e4dcff70c4aa Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 05:45:54 -0700 Subject: [PATCH 110/233] fix(sync): canonicalize Playwright list identities --- pdd/sync_core/runner.py | 28 ++++++++++++++++++++--- tests/test_sync_core_runner_playwright.py | 6 ++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 2593aeb1ee..b90da853fa 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4403,12 +4403,34 @@ def _playwright_reporter_source(result_fd: int) -> str: const RESULT_FD = {result_fd}; class PddFrameworkReporter {{ constructor() {{ this.tests = new Map(); }} + version() {{ return 'v2'; }} identity(test) {{ - const project = test.parent && test.parent.project ? test.parent.project().name : ''; + const titlePath = test.titlePath(); + if (!Array.isArray(titlePath) || titlePath.length < 4 + || titlePath[0] !== '' || !titlePath.every((item) => typeof item === 'string')) {{ + throw new Error('malformed Playwright test title path'); + }} + const [, titleProject, titleFile, ...titles] = titlePath; + const project = test.parent && test.parent.project ? test.parent.project().name : null; + if (typeof project !== 'string' || project !== titleProject + || !titles.length || titles.some((title) => !title) + || [project, titleFile, ...titles].some((item) => item.includes('::') + || item.includes(' > ') || /[\\0\\r\\n]/.test(item))) {{ + throw new Error('malformed Playwright test project or title'); + }} + if (!test.location || typeof test.location.file !== 'string' + || !path.isAbsolute(test.location.file)) {{ + throw new Error('malformed Playwright test location'); + }} const file = path.relative(process.cwd(), test.location.file); - return `${{project}}::${{file}}::${{test.titlePath().join(' > ')}}`; + if (!file || path.isAbsolute(file) || file === '..' + || file.startsWith(`..${{path.sep}}`) || path.basename(file) !== titleFile + || file.includes('::') || /[\\0\\r\\n]/.test(file)) {{ + throw new Error('malformed Playwright test location'); + }} + return `${{project}}::${{file}}::${{titles.join(' > ')}}`; }} - onBegin(_config, suite) {{ + onBegin(suite) {{ for (const test of suite.allTests()) {{ const identity = this.identity(test); if (this.tests.has(identity)) throw new Error(`duplicate Playwright identity: ${{identity}}`); diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 886de4e599..0edb4c18f0 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -414,6 +414,7 @@ def test_real_playwright_1_55_list_protocol_emits_canonical_identities( controller = tmp_path / "controller" root.mkdir() controller.mkdir() + os.symlink(roles["dependencies"], root / "node_modules", target_is_directory=True) (root / "tests").mkdir() (root / "tests/widget.spec.ts").write_text( "import { test } from '@playwright/test';\n" @@ -3058,10 +3059,13 @@ def test_playwright_rejects_unprovenanced_or_shadowed_bindings( def test_playwright_reporter_collects_each_identity_before_execution() -> None: + """Keep the generated reporter on Playwright's v2 list lifecycle.""" source = _playwright_reporter_source(198) - assert "onBegin(_config, suite)" in source + assert "version() { return 'v2'; }" in source + assert "onBegin(suite)" in source assert "suite.allTests()" in source assert "this.tests = new Map()" in source + assert "titles.join(' > ')" in source assert "onTestEnd(test, result)" in source From 791c081275cc1df839617644283a10ecde0ee747 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 06:17:16 -0700 Subject: [PATCH 111/233] test(sync): reject partial Playwright reporter receipts --- tests/test_sync_core_runner_playwright.py | 237 +++++++++++++++++++++- 1 file changed, 236 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 0edb4c18f0..b1b3802073 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -114,6 +114,46 @@ def _write_framework_observation(kwargs: dict, payload: dict) -> None: os.close(writer) +def _reporter_callback_receipt(tmp_path: Path, callbacks: str) -> dict: + """Run generated reporter callbacks through Playwright's swallowed-error shape.""" + node = shutil.which("node") + if node is None: + pytest.skip("requires Node") + read_fd, write_fd = os.pipe() + reporter = tmp_path / "reporter.cjs" + driver = tmp_path / "reporter-driver.cjs" + reporter.write_text(_playwright_reporter_source(write_fd), encoding="utf-8") + driver.write_text( + "const path = require('path');\n" + "const Reporter = require(process.argv[2]);\n" + "const reporter = new Reporter();\n" + "const valid = (title = 'widget works') => ({\n" + " titlePath: () => ['', 'chromium', 'widget.spec.ts', title],\n" + " parent: { project: () => ({ name: 'chromium' }) },\n" + " location: { file: path.join(process.cwd(), 'tests/widget.spec.ts') },\n" + "});\n" + f"{callbacks}\n", + encoding="utf-8", + ) + try: + completed = subprocess.run( + [node, str(driver), str(reporter)], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=10, + pass_fds=(write_fd,), + check=False, + ) + finally: + os.close(write_fd) + output = os.read(read_fd, 64 * 1024) + os.close(read_fd) + assert completed.returncode == 0, completed.stderr + assert output, completed.stderr + return json.loads(output) + + def _git(root: Path, *args: str) -> str: return subprocess.run( ["git", *args], cwd=root, capture_output=True, text=True, check=True @@ -482,6 +522,76 @@ def test_real_playwright_1_55_list_protocol_emits_canonical_identities( ) +@pytest.mark.skipif( + not sys.platform.startswith("linux") + or not os.environ.get("PDD_RUN_REAL_PLAYWRIGHT") + or not os.environ.get("PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"), + reason="requires the mandatory hosted Linux Playwright protocol lane", +) +@pytest.mark.parametrize( + "test_source", + [ + ( + "import { test } from '@playwright/test';\n" + "test('valid identity', () => {});\n" + "test.skip('bad::identity', () => {});\n" + ), + ( + "import { test } from '@playwright/test';\n" + "test('duplicate identity', () => {});\n" + "test('duplicate identity', () => {});\n" + ), + ], +) +def test_real_playwright_1_55_rejects_partial_list_receipts( + tmp_path: Path, test_source: str, +) -> None: + """Real list callbacks latch malformed skipped and duplicate identities.""" + manifest = Path(os.environ["PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"]) + roles = json.loads(manifest.read_text(encoding="utf-8"))["roles"] + package = Path(roles["dependencies"]) / "@playwright/test/package.json" + assert json.loads(package.read_text(encoding="utf-8"))["version"].startswith("1.55.") + root = tmp_path / "candidate" + controller = tmp_path / "controller" + root.mkdir() + controller.mkdir() + os.symlink(roles["dependencies"], root / "node_modules", target_is_directory=True) + (root / "tests").mkdir() + (root / "tests/widget.spec.ts").write_text(test_source, encoding="utf-8") + config = root / "playwright.config.ts" + config.write_text( + "export default { testDir: './tests', projects: [{ name: 'chromium' }] };\n", + encoding="utf-8", + ) + read_fd, write_fd = os.pipe() + reporter = controller / "reporter.cjs" + reporter.write_text(_playwright_reporter_source(write_fd), encoding="utf-8") + try: + result = subprocess.run( + [ + roles["launcher"], roles["entrypoint"], "test", + "tests/widget.spec.ts", f"--config={config}", + f"--reporter={reporter}", "--list", + ], + cwd=root, + capture_output=True, + text=True, + env={**os.environ, "NODE_PATH": roles["dependencies"]}, + pass_fds=(write_fd,), + check=False, + ) + finally: + os.close(write_fd) + output = os.read(read_fd, 64 * 1024) + os.close(read_fd) + + assert output, result.stderr + assert json.loads(output) == { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + } + + def test_playwright_binds_static_runtime_resources_and_rejects_reflection( tmp_path: Path, ) -> None: @@ -1616,6 +1726,126 @@ def test_playwright_malformed_json_shapes_fail_closed( assert identities == () +@pytest.mark.parametrize( + "callbacks", + [ + ( + "const bad = valid('bad'); bad.titlePath = () => { throw new Error('bad'); };\n" + "try { reporter.onBegin({ allTests: () => [valid(), bad] }); } catch {}\n" + "reporter.onEnd();" + ), + ( + "try { reporter.onBegin({ allTests: () => [valid(), valid()] }); } catch {}\n" + "reporter.onEnd();" + ), + ( + "try { reporter.onBegin({ allTests: () => { throw new Error('bad'); } }); } catch {}\n" + "reporter.onEnd();" + ), + ( + "reporter.onBegin({ allTests: () => [valid()] });\n" + "try { reporter.onTestEnd(valid(), null); } catch {}\n" + "reporter.onEnd();" + ), + ], +) +def test_playwright_reporter_latches_swallowed_callback_errors( + tmp_path: Path, callbacks: str, +) -> None: + """A swallowed callback failure must replace every partial observation.""" + receipt = _reporter_callback_receipt(tmp_path, callbacks) + + assert receipt == { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + } + outcome, _detail, identities = _playwright_result( + tmp_path, json.dumps(receipt), 0, None, collection=True, + ) + assert outcome is EvidenceOutcome.COLLECTION_ERROR + assert not identities + + +def test_playwright_reporter_on_end_uses_minimal_error_fallback( + tmp_path: Path, +) -> None: + """Serialization preparation failure emits only the fixed error receipt.""" + receipt = _reporter_callback_receipt( + tmp_path, + "reporter.onBegin({ allTests: () => [valid()] });\n" + "reporter.tests = { [Symbol.iterator]: () => { throw new Error('bad'); } };\n" + "try { reporter.onEnd(); } catch {}", + ) + + assert receipt == { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + } + + +@pytest.mark.parametrize( + "receipt", + [ + { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "tests": [{"identity": IDENTITY, "status": "collected"}], + }, + { + "pdd_playwright_reporter": True, + "reporter_error": "invalid_reporter_state", + }, + {"pdd_playwright_reporter": 1, "reporter_error": "candidate title"}, + { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "extra": False, + }, + {"reporter_error": "invalid_reporter_state"}, + ], +) +def test_playwright_reporter_error_receipt_schema_is_strict( + tmp_path: Path, receipt: dict, +) -> None: + outcome, _detail, identities = _playwright_result( + tmp_path, json.dumps(receipt), 0, None, collection=True, + ) + + assert outcome is EvidenceOutcome.COLLECTION_ERROR + assert not identities + + +@pytest.mark.parametrize( + "payload", + [ + { + "pdd_playwright_reporter": 1, + "tests": [{"identity": IDENTITY, "status": "collected"}], + "extra": None, + }, + { + "pdd_playwright_reporter": 1, + "tests": [{ + "identity": IDENTITY, "status": "collected", "extra": None, + }], + }, + { + "pdd_playwright_reporter": 1, + "tests": [{"identity": IDENTITY, "status": True}], + }, + ], +) +def test_playwright_success_receipt_schema_rejects_extras_and_bool_types( + tmp_path: Path, payload: dict, +) -> None: + outcome, _detail, identities = _playwright_result( + tmp_path, json.dumps(payload), 0, None, collection=True, + ) + + assert outcome is EvidenceOutcome.COLLECTION_ERROR + assert not identities + + @pytest.mark.parametrize( "config", [ @@ -3059,12 +3289,17 @@ def test_playwright_rejects_unprovenanced_or_shadowed_bindings( def test_playwright_reporter_collects_each_identity_before_execution() -> None: - """Keep the generated reporter on Playwright's v2 list lifecycle.""" + """Keep v2 collection on an irreversible fixed-error receipt contract.""" source = _playwright_reporter_source(198) assert "version() { return 'v2'; }" in source assert "onBegin(suite)" in source assert "suite.allTests()" in source assert "this.tests = new Map()" in source + assert "invalid_reporter_state" in source + assert "invalidate()" in source + assert "this.reporterError" in source + assert "catch" in source + assert "throw new Error" not in source assert "titles.join(' > ')" in source assert "onTestEnd(test, result)" in source From a864bb1a95ec040ddb264c43a2e9c422c083421a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 06:29:27 -0700 Subject: [PATCH 112/233] fix(sync): latch Playwright reporter errors --- pdd/sync_core/runner.py | 144 ++++++++++++++++++---- tests/test_sync_core_runner_playwright.py | 1 + 2 files changed, 118 insertions(+), 27 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index b90da853fa..a9952e6fc3 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4401,52 +4401,109 @@ def _playwright_reporter_source(result_fd: int) -> str: return f"""const fs = require('fs'); const path = require('path'); const RESULT_FD = {result_fd}; +const REPORTER_ERROR = 'invalid_reporter_state'; +const ERROR_RECEIPT = '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state"}}'; +const EXECUTION_STATUSES = new Set(['passed', 'failed', 'skipped', 'timedOut', 'interrupted']); class PddFrameworkReporter {{ - constructor() {{ this.tests = new Map(); }} + constructor() {{ + this.tests = new Map(); + this.reporterError = null; + }} version() {{ return 'v2'; }} + invalidate() {{ + this.reporterError = REPORTER_ERROR; + this.tests = null; + }} identity(test) {{ + if (!test || typeof test !== 'object' || typeof test.titlePath !== 'function') return null; const titlePath = test.titlePath(); if (!Array.isArray(titlePath) || titlePath.length < 4 || titlePath[0] !== '' || !titlePath.every((item) => typeof item === 'string')) {{ - throw new Error('malformed Playwright test title path'); + return null; }} const [, titleProject, titleFile, ...titles] = titlePath; const project = test.parent && test.parent.project ? test.parent.project().name : null; if (typeof project !== 'string' || project !== titleProject || !titles.length || titles.some((title) => !title) || [project, titleFile, ...titles].some((item) => item.includes('::') - || item.includes(' > ') || /[\\0\\r\\n]/.test(item))) {{ - throw new Error('malformed Playwright test project or title'); + || item.includes(' > ') || /[\\0\\r\\n]/.test(item) || item.length > 1024)) {{ + return null; }} if (!test.location || typeof test.location.file !== 'string' || !path.isAbsolute(test.location.file)) {{ - throw new Error('malformed Playwright test location'); + return null; }} const file = path.relative(process.cwd(), test.location.file); if (!file || path.isAbsolute(file) || file === '..' || file.startsWith(`..${{path.sep}}`) || path.basename(file) !== titleFile - || file.includes('::') || /[\\0\\r\\n]/.test(file)) {{ - throw new Error('malformed Playwright test location'); + || file.includes('::') || /[\\0\\r\\n]/.test(file) || file.length > 4096) {{ + return null; }} return `${{project}}::${{file}}::${{titles.join(' > ')}}`; }} onBegin(suite) {{ - for (const test of suite.allTests()) {{ - const identity = this.identity(test); - if (this.tests.has(identity)) throw new Error(`duplicate Playwright identity: ${{identity}}`); - this.tests.set(identity, {{status: 'collected'}}); + if (this.reporterError) return; + try {{ + if (!suite || typeof suite.allTests !== 'function') {{ this.invalidate(); return; }} + const allTests = suite.allTests(); + if (!Array.isArray(allTests)) {{ this.invalidate(); return; }} + const collected = new Map(); + for (const test of allTests) {{ + const identity = this.identity(test); + if (identity === null || collected.has(identity)) {{ this.invalidate(); return; }} + collected.set(identity, {{status: 'collected'}}); + }} + this.tests = collected; + }} catch (_error) {{ + this.invalidate(); }} }} onTestEnd(test, result) {{ - const error = result.error && typeof result.error.message === 'string' - ? result.error.message : ''; - this.tests.set(this.identity(test), {{status: result.status, error}}); + if (this.reporterError) return; + try {{ + const identity = this.identity(test); + if (identity === null || !this.tests.has(identity) || !result + || typeof result !== 'object' || !EXECUTION_STATUSES.has(result.status)) {{ + this.invalidate(); + return; + }} + let error = ''; + if (result.error !== undefined && result.error !== null) {{ + if (typeof result.error !== 'object' + || typeof result.error.message !== 'string') {{ + this.invalidate(); + return; + }} + error = result.error.message.slice(0, 4096); + }} + this.tests.set(identity, {{status: result.status, error}}); + }} catch (_error) {{ + this.invalidate(); + }} + }} + onError(_error) {{ + this.invalidate(); + }} + writeErrorReceipt() {{ + try {{ fs.writeSync(RESULT_FD, ERROR_RECEIPT); }} catch (_error) {{}} }} onEnd() {{ - const tests = Array.from( - this.tests, ([identity, result]) => ({{identity, ...result}}) - ); - fs.writeSync(RESULT_FD, JSON.stringify({{pdd_playwright_reporter: 1, tests}})); + if (this.reporterError) {{ this.writeErrorReceipt(); return; }} + try {{ + const tests = Array.from( + this.tests, ([identity, result]) => ({{identity, ...result}}) + ); + const receipt = JSON.stringify({{pdd_playwright_reporter: 1, tests}}); + if (typeof receipt !== 'string') {{ this.invalidate(); this.writeErrorReceipt(); return; }} + const written = fs.writeSync(RESULT_FD, receipt); + if (written !== Buffer.byteLength(receipt)) {{ + this.invalidate(); + this.writeErrorReceipt(); + }} + }} catch (_error) {{ + this.invalidate(); + this.writeErrorReceipt(); + }} }} }} module.exports = PddFrameworkReporter; @@ -4524,10 +4581,48 @@ def _playwright_result( payload = json.loads(output) if not isinstance(payload, dict): raise ValueError("malformed Playwright reporter payload") - tests = payload.get("tests") - if payload.get("pdd_playwright_reporter") == 1: - if not isinstance(tests, list): + marker = payload.get("pdd_playwright_reporter") + tests: list[dict[str, object]] + if "pdd_playwright_reporter" in payload: + if type(marker) is not int or marker != 1: # pylint: disable=unidiomatic-typecheck + raise ValueError("malformed Playwright reporter payload") + if set(payload) == {"pdd_playwright_reporter", "reporter_error"}: + if payload["reporter_error"] != "invalid_reporter_state": + raise ValueError("malformed Playwright reporter error") + return ( + EvidenceOutcome.COLLECTION_ERROR, + "Playwright reporter rejected an invalid framework observation", + (), + ) + if set(payload) != {"pdd_playwright_reporter", "tests"}: raise ValueError("malformed Playwright reporter payload") + raw_tests = payload["tests"] + if not isinstance(raw_tests, list): + raise ValueError("malformed Playwright reporter payload") + tests = raw_tests + statuses = { + "collected", "passed", "failed", "skipped", "timedOut", "interrupted" + } + for item in tests: + if ( + not isinstance(item, dict) + or set(item) not in ( + {"identity", "status"}, {"identity", "status", "error"} + ) + or not isinstance(item.get("identity"), str) + or not item["identity"] + or len(item["identity"]) > 8192 + or not isinstance(item.get("status"), str) + or item["status"] not in statuses + or ( + "error" in item + and ( + not isinstance(item["error"], str) + or len(item["error"]) > 4096 + ) + ) + ): + raise ValueError("malformed Playwright reporter test") else: tests = [] def visit(suite: object, parents: tuple[str, ...] = ()) -> None: @@ -4579,12 +4674,7 @@ def visit(suite: object, parents: tuple[str, ...] = ()) -> None: raise ValueError("untrusted Playwright result payload") for suite in payload["suites"]: visit(suite) - if not isinstance(tests, list) or not all( - isinstance(item, dict) and isinstance(item.get("identity"), str) - and isinstance(item.get("status"), str) - and (item.get("error") is None or isinstance(item.get("error"), str)) - for item in tests - ): + if not isinstance(tests, list): raise ValueError("malformed Playwright reporter payload") except (KeyError, TypeError, ValueError, json.JSONDecodeError): return EvidenceOutcome.COLLECTION_ERROR, "Playwright reporter produced malformed JSON", () diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index b1b3802073..1c27c0753e 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -3302,6 +3302,7 @@ def test_playwright_reporter_collects_each_identity_before_execution() -> None: assert "throw new Error" not in source assert "titles.join(' > ')" in source assert "onTestEnd(test, result)" in source + assert "onError(_error)" in source def test_playwright_package_import_mapping_is_bound_with_nearest_manifest(tmp_path: Path) -> None: From f3e7c95f63b3c5235c3d628554c60b83ceb4bdd4 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 06:43:10 -0700 Subject: [PATCH 113/233] test(sync): reproduce inaccessible delegated cgroup leaf --- tests/test_sync_core_supervisor.py | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 82191716d4..d561bd1fd3 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -940,6 +940,10 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( compile(helper, "", "exec") assert "monitor_cgroup=scope_cgroup/'monitor'" in helper assert "candidate_cgroup=scope_cgroup/'candidate'" in helper + assert "monitor_cgroup.mkdir(mode=0o755)" in helper + assert "candidate_cgroup.mkdir(mode=0o755)" in helper + assert "monitor_cgroup.chmod(0o755)" in helper + assert "candidate_cgroup.chmod(0o755)" in helper assert "(monitor_cgroup/'cgroup.procs').write_text(str(os.getpid())" in helper assert "(candidate_cgroup/'memory.max').write_text(str(limits['memory'])" in helper assert "(candidate_cgroup/'memory.swap.max').write_text('0'" in helper @@ -1359,6 +1363,33 @@ def run(_tools, arguments, **_kwargs): assert commands[1][:3] == ["kill", "--kill-whom=all", "--signal=SIGKILL"] +def test_scope_cleanup_accepts_exact_unit_disappearance_after_kill( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A successful kill may synchronously collect the exact transient scope.""" + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + tools = supervisor._trusted_tools() + unit = supervisor._scope_unit_name() + commands = [] + + def run(_tools, arguments, **_kwargs): + commands.append(arguments) + if arguments[0] == "show" and len(commands) == 1: + return subprocess.CompletedProcess(arguments, 0, "LoadState=loaded\n", "") + if arguments[0] == "kill": + return subprocess.CompletedProcess(arguments, 0, "", "") + if arguments[0] == "show": + return subprocess.CompletedProcess(arguments, 0, "LoadState=not-found\n", "") + pytest.fail(f"cleanup acted on a collected scope: {arguments}") + + monkeypatch.setattr(supervisor, "_root_run", run) + + supervisor._stop_scope(unit, tools) + + assert [command[0] for command in commands] == ["show", "kill", "show"] + + @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap"), reason="requires Linux kernel namespace containment", From 1068aa6cae5cc068c23f71aa96f71de2d8709d0c Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 06:45:40 -0700 Subject: [PATCH 114/233] fix(sync): expose delegated leaf state to coordinator --- pdd/sync_core/supervisor.py | 39 +++++++++++++++++++----------- tests/test_sync_core_supervisor.py | 4 ++- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 059dcd12c7..3cbf4e78d3 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -369,7 +369,8 @@ def _staged_bwrap( " scope_cgroup=own_cgroup()", " monitor_cgroup=scope_cgroup/'monitor'", " candidate_cgroup=scope_cgroup/'candidate'", - " monitor_cgroup.mkdir(mode=0o700); candidate_cgroup.mkdir(mode=0o700)", + " monitor_cgroup.mkdir(mode=0o755); monitor_cgroup.chmod(0o755)", + " candidate_cgroup.mkdir(mode=0o755); candidate_cgroup.chmod(0o755)", " (monitor_cgroup/'cgroup.procs').write_text(str(os.getpid()),encoding='ascii')", " available=set((scope_cgroup/'cgroup.controllers').read_text(" "encoding='ascii').split())", @@ -787,8 +788,10 @@ def _probe_scope( if ( not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o755 or not stat.S_ISDIR(monitor_metadata.st_mode) or stat.S_ISLNK(monitor_metadata.st_mode) + or stat.S_IMODE(monitor_metadata.st_mode) != 0o755 or cgroup.resolve(strict=True).parent != parent_resolved or monitor.resolve(strict=True).parent != parent_resolved ): @@ -824,14 +827,21 @@ def _probe_scope( def _stop_scope(unit_name: str, tools: _TrustedTools) -> None: """Kill the exact whole scope and prove that systemd removed it.""" unit_name = _validated_scope_unit(unit_name) - initial = _root_run(tools, ["show", unit_name, "--property=LoadState"]) - if initial.returncode == 0 and initial.stdout.strip() == "LoadState=not-found": + + def absent() -> bool: + completed = _root_run(tools, ["show", unit_name, "--property=LoadState"]) + if completed.returncode != 0: + raise RuntimeError( + "protected scope teardown failed: " + + (completed.stderr.strip() or "cannot probe scope") + ) + output = completed.stdout.strip() + if not output.startswith("LoadState=") or "\n" in output: + raise RuntimeError("protected scope teardown returned invalid load state") + return output == "LoadState=not-found" + + if absent(): return - if initial.returncode != 0: - raise RuntimeError( - "protected scope teardown failed: " - + (initial.stderr.strip() or "cannot probe scope") - ) errors = [] for arguments in ( ["kill", "--kill-whom=all", "--signal=SIGKILL", unit_name], @@ -841,14 +851,15 @@ def _stop_scope(unit_name: str, tools: _TrustedTools) -> None: completed = _root_run(tools, arguments) if completed.returncode != 0: errors.append(completed.stderr.strip() or " ".join(arguments) + " failed") + if absent(): + if errors: + # An exact post-action not-found probe proves narrow collection; + # command errors are stale state only when the unit is now absent. + errors.clear() + return deadline = time.monotonic() + 5 while time.monotonic() < deadline: - completed = _root_run(tools, ["show", unit_name, "--property=LoadState"]) - output = completed.stdout.strip() - if completed.returncode != 0: - errors.append(completed.stderr.strip() or "cannot probe removed scope") - break - if output == "LoadState=not-found": + if absent(): break time.sleep(.05) else: diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index d561bd1fd3..763a3209ba 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1213,8 +1213,10 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( cgroup.mkdir() candidate = cgroup / "candidate" candidate.mkdir() + candidate.chmod(0o755) monitor = cgroup / "monitor" monitor.mkdir() + monitor.chmod(0o755) (cgroup / "cgroup.procs").write_text("", encoding="ascii") (monitor / "cgroup.procs").write_text("123\n", encoding="ascii") (candidate / "cgroup.procs").write_text("", encoding="ascii") @@ -1357,7 +1359,7 @@ def run(_tools, arguments, **_kwargs): supervisor._stop_scope(unit, tools) assert [command[0] for command in commands] == [ - "show", "kill", "stop", "reset-failed", "show", + "show", "kill", "show", "stop", "show", ] assert all(unit in command for command in commands) assert commands[1][:3] == ["kill", "--kill-whom=all", "--signal=SIGKILL"] From 58c2b7db59c0ba4d85fae7ac566833c6706c1c5e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 07:09:05 -0700 Subject: [PATCH 115/233] test(sync): diagnose Playwright reporter rejection --- tests/test_sync_core_runner_playwright.py | 103 ++++++++++++++++++---- 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 1c27c0753e..56eaf87d8a 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -44,6 +44,16 @@ UNIT = UnitId("repository-1", PurePosixPath("prompts/widget_ts.prompt"), "typescript") IDENTITY = "chromium::tests/widget.spec.ts::widget works" +REPORTER_ERROR_REASONS = ( + "invalid_suite", + "suite_traversal", + "invalid_identity", + "duplicate_identity", + "invalid_test_result", + "unknown_test", + "framework_error", + "serialization_failure", +) @pytest.fixture(autouse=True) @@ -529,22 +539,22 @@ def test_real_playwright_1_55_list_protocol_emits_canonical_identities( reason="requires the mandatory hosted Linux Playwright protocol lane", ) @pytest.mark.parametrize( - "test_source", + ("test_source", "reason"), [ - ( + (( "import { test } from '@playwright/test';\n" "test('valid identity', () => {});\n" "test.skip('bad::identity', () => {});\n" - ), - ( + ), "invalid_identity"), + (( "import { test } from '@playwright/test';\n" "test('duplicate identity', () => {});\n" "test('duplicate identity', () => {});\n" - ), + ), "framework_error"), ], ) def test_real_playwright_1_55_rejects_partial_list_receipts( - tmp_path: Path, test_source: str, + tmp_path: Path, test_source: str, reason: str, ) -> None: """Real list callbacks latch malformed skipped and duplicate identities.""" manifest = Path(os.environ["PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"]) @@ -589,6 +599,7 @@ def test_real_playwright_1_55_rejects_partial_list_receipts( assert json.loads(output) == { "pdd_playwright_reporter": 1, "reporter_error": "invalid_reporter_state", + "reason": reason, } @@ -853,6 +864,13 @@ def supervised(*_args, **_kwargs): raise AssertionError("candidate node_modules must fail before execution") monkeypatch.setattr(runner_module, "run_supervised", supervised) + monkeypatch.setattr( + runner_module, + "_playwright_toolchain_identity", + lambda *_args: (_ for _ in ()).throw( + ValueError("toolchain validation must follow candidate preflight") + ), + ) execution, _identities = runner_module._run_playwright_in_tree( root, (PurePosixPath("tests/widget.spec.ts"),), 2, @@ -976,6 +994,13 @@ def supervised(*_args, **_kwargs): raise AssertionError("nested node_modules must fail before execution") monkeypatch.setattr(runner_module, "run_supervised", supervised) + monkeypatch.setattr( + runner_module, + "_playwright_toolchain_identity", + lambda *_args: (_ for _ in ()).throw( + ValueError("toolchain validation must follow candidate preflight") + ), + ) execution, _identities = runner_module._run_playwright_in_tree( root, (PurePosixPath("tests/widget.spec.ts"),), 2, _trusted_playwright_config(tmp_path / "trusted", _fake_playwright(tmp_path)), @@ -1727,30 +1752,30 @@ def test_playwright_malformed_json_shapes_fail_closed( @pytest.mark.parametrize( - "callbacks", + ("callbacks", "reason"), [ - ( + (( "const bad = valid('bad'); bad.titlePath = () => { throw new Error('bad'); };\n" "try { reporter.onBegin({ allTests: () => [valid(), bad] }); } catch {}\n" "reporter.onEnd();" - ), - ( + ), "invalid_identity"), + (( "try { reporter.onBegin({ allTests: () => [valid(), valid()] }); } catch {}\n" "reporter.onEnd();" - ), - ( + ), "duplicate_identity"), + (( "try { reporter.onBegin({ allTests: () => { throw new Error('bad'); } }); } catch {}\n" "reporter.onEnd();" - ), - ( + ), "suite_traversal"), + (( "reporter.onBegin({ allTests: () => [valid()] });\n" "try { reporter.onTestEnd(valid(), null); } catch {}\n" "reporter.onEnd();" - ), + ), "invalid_test_result"), ], ) def test_playwright_reporter_latches_swallowed_callback_errors( - tmp_path: Path, callbacks: str, + tmp_path: Path, callbacks: str, reason: str, ) -> None: """A swallowed callback failure must replace every partial observation.""" receipt = _reporter_callback_receipt(tmp_path, callbacks) @@ -1758,6 +1783,7 @@ def test_playwright_reporter_latches_swallowed_callback_errors( assert receipt == { "pdd_playwright_reporter": 1, "reporter_error": "invalid_reporter_state", + "reason": reason, } outcome, _detail, identities = _playwright_result( tmp_path, json.dumps(receipt), 0, None, collection=True, @@ -1780,28 +1806,69 @@ def test_playwright_reporter_on_end_uses_minimal_error_fallback( assert receipt == { "pdd_playwright_reporter": 1, "reporter_error": "invalid_reporter_state", + "reason": "serialization_failure", } +@pytest.mark.parametrize("reason", REPORTER_ERROR_REASONS) +def test_playwright_reporter_error_reason_is_closed_and_bounded( + tmp_path: Path, reason: str, +) -> None: + receipt = { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "reason": reason, + } + + outcome, detail, identities = _playwright_result( + tmp_path, json.dumps(receipt), 0, None, collection=True, + ) + + assert outcome is EvidenceOutcome.COLLECTION_ERROR + assert detail.endswith(f" ({reason})") + assert not identities + + @pytest.mark.parametrize( "receipt", [ { "pdd_playwright_reporter": 1, "reporter_error": "invalid_reporter_state", + "reason": "invalid_identity", "tests": [{"identity": IDENTITY, "status": "collected"}], }, { "pdd_playwright_reporter": True, "reporter_error": "invalid_reporter_state", + "reason": "invalid_identity", + }, + { + "pdd_playwright_reporter": 1, + "reporter_error": "candidate title", + "reason": "invalid_identity", }, - {"pdd_playwright_reporter": 1, "reporter_error": "candidate title"}, { "pdd_playwright_reporter": 1, "reporter_error": "invalid_reporter_state", + "reason": "invalid_identity", "extra": False, }, {"reporter_error": "invalid_reporter_state"}, + { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + }, + { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "reason": "candidate-derived-value", + }, + { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "reason": True, + }, ], ) def test_playwright_reporter_error_receipt_schema_is_strict( @@ -3296,6 +3363,8 @@ def test_playwright_reporter_collects_each_identity_before_execution() -> None: assert "suite.allTests()" in source assert "this.tests = new Map()" in source assert "invalid_reporter_state" in source + assert "REPORTER_ERROR_REASONS" in source + assert '"reason"' in source assert "invalidate()" in source assert "this.reporterError" in source assert "catch" in source From a5892480b22c74647761f534d8d73cbb889ccbd6 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 07:27:08 -0700 Subject: [PATCH 116/233] fix(sync): diagnose Playwright reporter invariants --- pdd/sync_core/runner.py | 128 ++++++++++++++++++---- tests/test_sync_core_runner_playwright.py | 21 ++-- 2 files changed, 117 insertions(+), 32 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index a9952e6fc3..aa7fdf6d6b 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4340,7 +4340,18 @@ def _playwright_node_modules_destination_error(root: Path) -> str | None: return "candidate node_modules destination is a symlink" if not stat.S_ISDIR(metadata.st_mode): return "candidate node_modules destination is not a real directory" - return None + return "candidate node_modules destination already exists" + + +def _playwright_nested_node_modules_error(root: Path) -> str | None: + """Reject candidate package trees before trusted toolchain validation.""" + nested = next(root.rglob("node_modules"), None) + if nested is None: + return None + return ( + "candidate nested node_modules destination is not trusted: " + f"{nested.relative_to(root).as_posix()}" + ) def _create_playwright_node_modules_mountpoint(root: Path) -> tuple[Path, int, int]: @@ -4402,7 +4413,24 @@ def _playwright_reporter_source(result_fd: int) -> str: const path = require('path'); const RESULT_FD = {result_fd}; const REPORTER_ERROR = 'invalid_reporter_state'; -const ERROR_RECEIPT = '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state"}}'; +const REPORTER_ERROR_REASONS = new Set([ + 'invalid_suite', 'suite_traversal', 'invalid_title_path', + 'invalid_project_title', 'invalid_location', 'duplicate_identity', + 'invalid_test_result', 'unknown_test', 'framework_error', + 'serialization_failure', +]); +const ERROR_RECEIPTS = Object.freeze({{ + invalid_suite: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_suite"}}', + suite_traversal: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"suite_traversal"}}', + invalid_title_path: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_title_path"}}', + invalid_project_title: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_project_title"}}', + invalid_location: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_location"}}', + duplicate_identity: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"duplicate_identity"}}', + invalid_test_result: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_test_result"}}', + unknown_test: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"unknown_test"}}', + framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error"}}', + serialization_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"serialization_failure"}}', +}}); const EXECUTION_STATUSES = new Set(['passed', 'failed', 'skipped', 'timedOut', 'interrupted']); class PddFrameworkReporter {{ constructor() {{ @@ -4410,17 +4438,21 @@ class PddFrameworkReporter {{ this.reporterError = null; }} version() {{ return 'v2'; }} - invalidate() {{ - this.reporterError = REPORTER_ERROR; + invalidate(reason) {{ + if (this.reporterError) return; + this.reporterError = REPORTER_ERROR_REASONS.has(reason) + ? reason : 'serialization_failure'; this.tests = null; }} identity(test) {{ + this.identityError = 'invalid_title_path'; if (!test || typeof test !== 'object' || typeof test.titlePath !== 'function') return null; const titlePath = test.titlePath(); if (!Array.isArray(titlePath) || titlePath.length < 4 || titlePath[0] !== '' || !titlePath.every((item) => typeof item === 'string')) {{ return null; }} + this.identityError = 'invalid_project_title'; const [, titleProject, titleFile, ...titles] = titlePath; const project = test.parent && test.parent.project ? test.parent.project().name : null; if (typeof project !== 'string' || project !== titleProject @@ -4429,6 +4461,7 @@ class PddFrameworkReporter {{ || item.includes(' > ') || /[\\0\\r\\n]/.test(item) || item.length > 1024)) {{ return null; }} + this.identityError = 'invalid_location'; if (!test.location || typeof test.location.file !== 'string' || !path.isAbsolute(test.location.file)) {{ return null; @@ -4439,53 +4472,81 @@ class PddFrameworkReporter {{ || file.includes('::') || /[\\0\\r\\n]/.test(file) || file.length > 4096) {{ return null; }} + this.identityError = null; return `${{project}}::${{file}}::${{titles.join(' > ')}}`; }} onBegin(suite) {{ if (this.reporterError) return; + if (!suite || typeof suite.allTests !== 'function') {{ + this.invalidate('invalid_suite'); + return; + }} + let allTests; + try {{ + allTests = suite.allTests(); + }} catch (_error) {{ + this.invalidate('suite_traversal'); + return; + }} + if (!Array.isArray(allTests)) {{ + this.invalidate('suite_traversal'); + return; + }} try {{ - if (!suite || typeof suite.allTests !== 'function') {{ this.invalidate(); return; }} - const allTests = suite.allTests(); - if (!Array.isArray(allTests)) {{ this.invalidate(); return; }} const collected = new Map(); for (const test of allTests) {{ - const identity = this.identity(test); - if (identity === null || collected.has(identity)) {{ this.invalidate(); return; }} + let identity; + try {{ identity = this.identity(test); }} catch (_error) {{ + this.invalidate(this.identityError || 'invalid_title_path'); + return; + }} + if (identity === null) {{ + this.invalidate(this.identityError || 'invalid_title_path'); + return; + }} + if (collected.has(identity)) {{ this.invalidate('duplicate_identity'); return; }} collected.set(identity, {{status: 'collected'}}); }} this.tests = collected; }} catch (_error) {{ - this.invalidate(); + this.invalidate('suite_traversal'); }} }} onTestEnd(test, result) {{ if (this.reporterError) return; try {{ const identity = this.identity(test); - if (identity === null || !this.tests.has(identity) || !result - || typeof result !== 'object' || !EXECUTION_STATUSES.has(result.status)) {{ - this.invalidate(); + if (identity === null) {{ + this.invalidate(this.identityError || 'invalid_title_path'); + return; + }} + if (!this.tests.has(identity)) {{ this.invalidate('unknown_test'); return; }} + if (!result || typeof result !== 'object' + || !EXECUTION_STATUSES.has(result.status)) {{ + this.invalidate('invalid_test_result'); return; }} let error = ''; if (result.error !== undefined && result.error !== null) {{ if (typeof result.error !== 'object' || typeof result.error.message !== 'string') {{ - this.invalidate(); + this.invalidate('invalid_test_result'); return; }} error = result.error.message.slice(0, 4096); }} this.tests.set(identity, {{status: result.status, error}}); }} catch (_error) {{ - this.invalidate(); + this.invalidate('invalid_test_result'); }} }} onError(_error) {{ - this.invalidate(); + this.invalidate('framework_error'); }} writeErrorReceipt() {{ - try {{ fs.writeSync(RESULT_FD, ERROR_RECEIPT); }} catch (_error) {{}} + const receipt = ERROR_RECEIPTS[this.reporterError] + || ERROR_RECEIPTS.serialization_failure; + try {{ fs.writeSync(RESULT_FD, receipt); }} catch (_error) {{}} }} onEnd() {{ if (this.reporterError) {{ this.writeErrorReceipt(); return; }} @@ -4494,14 +4555,18 @@ class PddFrameworkReporter {{ this.tests, ([identity, result]) => ({{identity, ...result}}) ); const receipt = JSON.stringify({{pdd_playwright_reporter: 1, tests}}); - if (typeof receipt !== 'string') {{ this.invalidate(); this.writeErrorReceipt(); return; }} + if (typeof receipt !== 'string') {{ + this.invalidate('serialization_failure'); + this.writeErrorReceipt(); + return; + }} const written = fs.writeSync(RESULT_FD, receipt); if (written !== Buffer.byteLength(receipt)) {{ - this.invalidate(); + this.invalidate('serialization_failure'); this.writeErrorReceipt(); }} }} catch (_error) {{ - this.invalidate(); + this.invalidate('serialization_failure'); this.writeErrorReceipt(); }} }} @@ -4586,12 +4651,25 @@ def _playwright_result( if "pdd_playwright_reporter" in payload: if type(marker) is not int or marker != 1: # pylint: disable=unidiomatic-typecheck raise ValueError("malformed Playwright reporter payload") - if set(payload) == {"pdd_playwright_reporter", "reporter_error"}: + error_keys = { + "pdd_playwright_reporter", "reporter_error", "reason" + } + if set(payload) == error_keys: if payload["reporter_error"] != "invalid_reporter_state": raise ValueError("malformed Playwright reporter error") + reasons = { + "invalid_suite", "suite_traversal", "invalid_title_path", + "invalid_project_title", "invalid_location", + "duplicate_identity", "invalid_test_result", "unknown_test", + "framework_error", "serialization_failure", + } + reason = payload["reason"] + if not isinstance(reason, str) or reason not in reasons: + raise ValueError("malformed Playwright reporter error") return ( EvidenceOutcome.COLLECTION_ERROR, - "Playwright reporter rejected an invalid framework observation", + "Playwright reporter rejected an invalid framework observation " + f"({reason})", (), ) if set(payload) != {"pdd_playwright_reporter", "tests"}: @@ -4870,6 +4948,12 @@ def _run_playwright_in_tree( "playwright", EvidenceOutcome.ERROR, "playwright-untrusted", destination_error, ), () + nested_destination_error = _playwright_nested_node_modules_error(root) + if nested_destination_error is not None: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, + "playwright-untrusted", nested_destination_error, + ), () if _playwright_candidate_toolchain(tool_root): return RunnerExecution("playwright", EvidenceOutcome.ERROR, "playwright-untrusted", "candidate node_modules Playwright toolchain is not trusted"), () prefix = _playwright_command(config) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 56eaf87d8a..052b4baf42 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -47,7 +47,9 @@ REPORTER_ERROR_REASONS = ( "invalid_suite", "suite_traversal", - "invalid_identity", + "invalid_title_path", + "invalid_project_title", + "invalid_location", "duplicate_identity", "invalid_test_result", "unknown_test", @@ -545,7 +547,7 @@ def test_real_playwright_1_55_list_protocol_emits_canonical_identities( "import { test } from '@playwright/test';\n" "test('valid identity', () => {});\n" "test.skip('bad::identity', () => {});\n" - ), "invalid_identity"), + ), "invalid_project_title"), (( "import { test } from '@playwright/test';\n" "test('duplicate identity', () => {});\n" @@ -601,8 +603,6 @@ def test_real_playwright_1_55_rejects_partial_list_receipts( "reporter_error": "invalid_reporter_state", "reason": reason, } - - def test_playwright_binds_static_runtime_resources_and_rejects_reflection( tmp_path: Path, ) -> None: @@ -1758,7 +1758,7 @@ def test_playwright_malformed_json_shapes_fail_closed( "const bad = valid('bad'); bad.titlePath = () => { throw new Error('bad'); };\n" "try { reporter.onBegin({ allTests: () => [valid(), bad] }); } catch {}\n" "reporter.onEnd();" - ), "invalid_identity"), + ), "invalid_title_path"), (( "try { reporter.onBegin({ allTests: () => [valid(), valid()] }); } catch {}\n" "reporter.onEnd();" @@ -1835,23 +1835,23 @@ def test_playwright_reporter_error_reason_is_closed_and_bounded( { "pdd_playwright_reporter": 1, "reporter_error": "invalid_reporter_state", - "reason": "invalid_identity", + "reason": "invalid_title_path", "tests": [{"identity": IDENTITY, "status": "collected"}], }, { "pdd_playwright_reporter": True, "reporter_error": "invalid_reporter_state", - "reason": "invalid_identity", + "reason": "invalid_title_path", }, { "pdd_playwright_reporter": 1, "reporter_error": "candidate title", - "reason": "invalid_identity", + "reason": "invalid_title_path", }, { "pdd_playwright_reporter": 1, "reporter_error": "invalid_reporter_state", - "reason": "invalid_identity", + "reason": "invalid_title_path", "extra": False, }, {"reporter_error": "invalid_reporter_state"}, @@ -3365,7 +3365,8 @@ def test_playwright_reporter_collects_each_identity_before_execution() -> None: assert "invalid_reporter_state" in source assert "REPORTER_ERROR_REASONS" in source assert '"reason"' in source - assert "invalidate()" in source + assert "invalidate(reason)" in source + assert "if (this.reporterError) return;" in source assert "this.reporterError" in source assert "catch" in source assert "throw new Error" not in source From f1e273928782d8cb7f14de0a93779962d54aefbc Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 07:44:26 -0700 Subject: [PATCH 117/233] test(sync): distinguish Playwright callback failures --- tests/test_sync_core_runner_playwright.py | 88 ++++++++++++++++++++--- 1 file changed, 80 insertions(+), 8 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 052b4baf42..f8e14cdcbd 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -46,14 +46,21 @@ IDENTITY = "chromium::tests/widget.spec.ts::widget works" REPORTER_ERROR_REASONS = ( "invalid_suite", - "suite_traversal", + "suite_all_tests_call", "invalid_title_path", + "title_path_call", "invalid_project_title", + "project_access", + "project_call", "invalid_location", + "location_access", + "path_operation", "duplicate_identity", "invalid_test_result", "unknown_test", + "invalid_framework_error", "framework_error", + "invalid_run_result", "serialization_failure", ) @@ -1757,21 +1764,43 @@ def test_playwright_malformed_json_shapes_fail_closed( (( "const bad = valid('bad'); bad.titlePath = () => { throw new Error('bad'); };\n" "try { reporter.onBegin({ allTests: () => [valid(), bad] }); } catch {}\n" - "reporter.onEnd();" - ), "invalid_title_path"), + "reporter.onEnd({ status: 'passed' });" + ), "title_path_call"), (( "try { reporter.onBegin({ allTests: () => [valid(), valid()] }); } catch {}\n" - "reporter.onEnd();" + "reporter.onEnd({ status: 'passed' });" ), "duplicate_identity"), (( "try { reporter.onBegin({ allTests: () => { throw new Error('bad'); } }); } catch {}\n" - "reporter.onEnd();" - ), "suite_traversal"), + "reporter.onEnd({ status: 'passed' });" + ), "suite_all_tests_call"), (( "reporter.onBegin({ allTests: () => [valid()] });\n" "try { reporter.onTestEnd(valid(), null); } catch {}\n" - "reporter.onEnd();" + "reporter.onEnd({ status: 'passed' });" ), "invalid_test_result"), + (( + "const bad = valid('bad');\n" + "Object.defineProperty(bad, 'parent', { get: () => { throw new Error('bad'); } });\n" + "reporter.onBegin({ allTests: () => [valid(), bad] });\n" + "reporter.onEnd({ status: 'passed' });" + ), "project_access"), + (( + "const bad = valid('bad'); bad.parent.project = () => { throw new Error('bad'); };\n" + "reporter.onBegin({ allTests: () => [valid(), bad] });\n" + "reporter.onEnd({ status: 'passed' });" + ), "project_call"), + (( + "const bad = valid('bad');\n" + "Object.defineProperty(bad, 'location', { get: () => { throw new Error('bad'); } });\n" + "reporter.onBegin({ allTests: () => [valid(), bad] });\n" + "reporter.onEnd({ status: 'passed' });" + ), "location_access"), + (( + "const path = require('path'); path.relative = () => { throw new Error('bad'); };\n" + "reporter.onBegin({ allTests: () => [valid()] });\n" + "reporter.onEnd({ status: 'passed' });" + ), "path_operation"), ], ) def test_playwright_reporter_latches_swallowed_callback_errors( @@ -1800,7 +1829,7 @@ def test_playwright_reporter_on_end_uses_minimal_error_fallback( tmp_path, "reporter.onBegin({ allTests: () => [valid()] });\n" "reporter.tests = { [Symbol.iterator]: () => { throw new Error('bad'); } };\n" - "try { reporter.onEnd(); } catch {}", + "try { reporter.onEnd({ status: 'passed' }); } catch {}", ) assert receipt == { @@ -1810,6 +1839,49 @@ def test_playwright_reporter_on_end_uses_minimal_error_fallback( } +def test_playwright_reporter_defers_well_formed_framework_error_to_run_status( + tmp_path: Path, +) -> None: + """A normal onError callback is interpreted with Playwright's final status.""" + receipt = _reporter_callback_receipt( + tmp_path, + "reporter.onError({ message: 'framework detail' });\n" + "reporter.onBegin({ allTests: () => [valid()] });\n" + "reporter.onEnd({ status: 'passed' });", + ) + + assert receipt == { + "pdd_playwright_reporter": 1, + "tests": [{"identity": IDENTITY, "status": "collected"}], + } + + +@pytest.mark.parametrize( + ("error", "status", "reason"), + [ + ("null", "passed", "invalid_framework_error"), + ("{ message: 1 }", "passed", "invalid_framework_error"), + ("{ message: 'failed' }", "failed", "framework_error"), + ("{ message: 'failed' }", "candidate", "invalid_run_result"), + ], +) +def test_playwright_reporter_framework_error_status_contract( + tmp_path: Path, error: str, status: str, reason: str, +) -> None: + receipt = _reporter_callback_receipt( + tmp_path, + f"reporter.onError({error});\n" + "reporter.onBegin({ allTests: () => [valid()] });\n" + f"reporter.onEnd({{ status: '{status}' }});", + ) + + assert receipt == { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "reason": reason, + } + + @pytest.mark.parametrize("reason", REPORTER_ERROR_REASONS) def test_playwright_reporter_error_reason_is_closed_and_bounded( tmp_path: Path, reason: str, From 0e93cb918243afda3af0352647fa6c7a59c5b244 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 07:57:01 -0700 Subject: [PATCH 118/233] fix(sync): honor Playwright final reporter status --- pdd/sync_core/runner.py | 171 ++++++++++++++++------ tests/test_sync_core_runner_playwright.py | 15 +- 2 files changed, 135 insertions(+), 51 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index aa7fdf6d6b..2a4d3c1b92 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4414,28 +4414,40 @@ def _playwright_reporter_source(result_fd: int) -> str: const RESULT_FD = {result_fd}; const REPORTER_ERROR = 'invalid_reporter_state'; const REPORTER_ERROR_REASONS = new Set([ - 'invalid_suite', 'suite_traversal', 'invalid_title_path', - 'invalid_project_title', 'invalid_location', 'duplicate_identity', - 'invalid_test_result', 'unknown_test', 'framework_error', - 'serialization_failure', + 'invalid_suite', 'suite_all_tests_access', 'suite_all_tests_call', + 'invalid_title_path', + 'title_path_call', 'invalid_project_title', 'project_access', 'project_call', + 'invalid_location', 'location_access', 'path_operation', 'duplicate_identity', + 'invalid_test_result', 'unknown_test', 'invalid_framework_error', + 'framework_error', 'invalid_run_result', 'serialization_failure', 'write_failure', ]); const ERROR_RECEIPTS = Object.freeze({{ invalid_suite: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_suite"}}', - suite_traversal: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"suite_traversal"}}', + suite_all_tests_access: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"suite_all_tests_access"}}', + suite_all_tests_call: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"suite_all_tests_call"}}', invalid_title_path: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_title_path"}}', + title_path_call: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"title_path_call"}}', invalid_project_title: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_project_title"}}', + project_access: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"project_access"}}', + project_call: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"project_call"}}', invalid_location: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_location"}}', + location_access: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"location_access"}}', + path_operation: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"path_operation"}}', duplicate_identity: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"duplicate_identity"}}', invalid_test_result: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_test_result"}}', unknown_test: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"unknown_test"}}', + invalid_framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_framework_error"}}', framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error"}}', + invalid_run_result: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_run_result"}}', serialization_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"serialization_failure"}}', + write_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"write_failure"}}', }}); const EXECUTION_STATUSES = new Set(['passed', 'failed', 'skipped', 'timedOut', 'interrupted']); class PddFrameworkReporter {{ constructor() {{ this.tests = new Map(); this.reporterError = null; + this.frameworkError = false; }} version() {{ return 'v2'; }} invalidate(reason) {{ @@ -4445,81 +4457,115 @@ class PddFrameworkReporter {{ this.tests = null; }} identity(test) {{ - this.identityError = 'invalid_title_path'; - if (!test || typeof test !== 'object' || typeof test.titlePath !== 'function') return null; - const titlePath = test.titlePath(); + if (!test || typeof test !== 'object') return {{reason: 'invalid_title_path'}}; + let titlePathFunction; + try {{ titlePathFunction = test.titlePath; }} catch (_error) {{ + return {{reason: 'title_path_call'}}; + }} + if (typeof titlePathFunction !== 'function') return {{reason: 'invalid_title_path'}}; + let titlePath; + try {{ titlePath = titlePathFunction.call(test); }} catch (_error) {{ + return {{reason: 'title_path_call'}}; + }} if (!Array.isArray(titlePath) || titlePath.length < 4 || titlePath[0] !== '' || !titlePath.every((item) => typeof item === 'string')) {{ - return null; + return {{reason: 'invalid_title_path'}}; }} - this.identityError = 'invalid_project_title'; const [, titleProject, titleFile, ...titles] = titlePath; - const project = test.parent && test.parent.project ? test.parent.project().name : null; + let parent; + let projectFunction; + let projectObject; + let project; + try {{ + parent = test.parent; + projectFunction = parent && parent.project; + }} catch (_error) {{ return {{reason: 'project_access'}}; }} + if (typeof projectFunction !== 'function') {{ + return {{reason: 'invalid_project_title'}}; + }} + try {{ projectObject = projectFunction.call(parent); }} catch (_error) {{ + return {{reason: 'project_call'}}; + }} + try {{ project = projectObject && projectObject.name; }} catch (_error) {{ + return {{reason: 'project_access'}}; + }} if (typeof project !== 'string' || project !== titleProject || !titles.length || titles.some((title) => !title) || [project, titleFile, ...titles].some((item) => item.includes('::') || item.includes(' > ') || /[\\0\\r\\n]/.test(item) || item.length > 1024)) {{ - return null; + return {{reason: 'invalid_project_title'}}; }} - this.identityError = 'invalid_location'; - if (!test.location || typeof test.location.file !== 'string' - || !path.isAbsolute(test.location.file)) {{ - return null; + let location; + let locationFile; + try {{ + location = test.location; + locationFile = location && location.file; + }} catch (_error) {{ return {{reason: 'location_access'}}; }} + if (typeof locationFile !== 'string') {{ + return {{reason: 'invalid_location'}}; }} - const file = path.relative(process.cwd(), test.location.file); + let absolute; + let file; + let basename; + try {{ + absolute = path.isAbsolute(locationFile); + file = path.relative(process.cwd(), locationFile); + basename = path.basename(file); + }} catch (_error) {{ return {{reason: 'path_operation'}}; }} + if (!absolute) return {{reason: 'invalid_location'}}; if (!file || path.isAbsolute(file) || file === '..' - || file.startsWith(`..${{path.sep}}`) || path.basename(file) !== titleFile + || file.startsWith(`..${{path.sep}}`) || basename !== titleFile || file.includes('::') || /[\\0\\r\\n]/.test(file) || file.length > 4096) {{ - return null; + return {{reason: 'invalid_location'}}; }} - this.identityError = null; - return `${{project}}::${{file}}::${{titles.join(' > ')}}`; + return {{value: `${{project}}::${{file}}::${{titles.join(' > ')}}`}}; }} onBegin(suite) {{ if (this.reporterError) return; - if (!suite || typeof suite.allTests !== 'function') {{ + if (!suite || typeof suite !== 'object') {{ + this.invalidate('invalid_suite'); + return; + }} + let allTestsFunction; + try {{ allTestsFunction = suite.allTests; }} catch (_error) {{ + this.invalidate('suite_all_tests_access'); + return; + }} + if (typeof allTestsFunction !== 'function') {{ this.invalidate('invalid_suite'); return; }} let allTests; try {{ - allTests = suite.allTests(); + allTests = allTestsFunction.call(suite); }} catch (_error) {{ - this.invalidate('suite_traversal'); + this.invalidate('suite_all_tests_call'); return; }} if (!Array.isArray(allTests)) {{ - this.invalidate('suite_traversal'); + this.invalidate('suite_all_tests_call'); return; }} try {{ const collected = new Map(); for (const test of allTests) {{ - let identity; - try {{ identity = this.identity(test); }} catch (_error) {{ - this.invalidate(this.identityError || 'invalid_title_path'); - return; - }} - if (identity === null) {{ - this.invalidate(this.identityError || 'invalid_title_path'); - return; - }} + const observed = this.identity(test); + if (!observed.value) {{ this.invalidate(observed.reason); return; }} + const identity = observed.value; if (collected.has(identity)) {{ this.invalidate('duplicate_identity'); return; }} collected.set(identity, {{status: 'collected'}}); }} this.tests = collected; }} catch (_error) {{ - this.invalidate('suite_traversal'); + this.invalidate('suite_all_tests_call'); }} }} onTestEnd(test, result) {{ if (this.reporterError) return; try {{ - const identity = this.identity(test); - if (identity === null) {{ - this.invalidate(this.identityError || 'invalid_title_path'); - return; - }} + const observed = this.identity(test); + if (!observed.value) {{ this.invalidate(observed.reason); return; }} + const identity = observed.value; if (!this.tests.has(identity)) {{ this.invalidate('unknown_test'); return; }} if (!result || typeof result !== 'object' || !EXECUTION_STATUSES.has(result.status)) {{ @@ -4540,16 +4586,42 @@ class PddFrameworkReporter {{ this.invalidate('invalid_test_result'); }} }} - onError(_error) {{ - this.invalidate('framework_error'); + onError(error) {{ + if (this.reporterError) return; + try {{ + if (!error || typeof error !== 'object' || typeof error.message !== 'string') {{ + this.invalidate('invalid_framework_error'); + return; + }} + }} catch (_error) {{ + this.invalidate('invalid_framework_error'); + return; + }} + this.frameworkError = true; }} writeErrorReceipt() {{ const receipt = ERROR_RECEIPTS[this.reporterError] || ERROR_RECEIPTS.serialization_failure; try {{ fs.writeSync(RESULT_FD, receipt); }} catch (_error) {{}} }} - onEnd() {{ + onEnd(result) {{ if (this.reporterError) {{ this.writeErrorReceipt(); return; }} + let status; + try {{ status = result && result.status; }} catch (_error) {{ + this.invalidate('invalid_run_result'); + this.writeErrorReceipt(); + return; + }} + if (!new Set(['passed', 'failed', 'timedout', 'interrupted']).has(status)) {{ + this.invalidate('invalid_run_result'); + this.writeErrorReceipt(); + return; + }} + if (this.frameworkError && status !== 'passed') {{ + this.invalidate('framework_error'); + this.writeErrorReceipt(); + return; + }} try {{ const tests = Array.from( this.tests, ([identity, result]) => ({{identity, ...result}}) @@ -4562,7 +4634,7 @@ class PddFrameworkReporter {{ }} const written = fs.writeSync(RESULT_FD, receipt); if (written !== Buffer.byteLength(receipt)) {{ - this.invalidate('serialization_failure'); + this.invalidate('write_failure'); this.writeErrorReceipt(); }} }} catch (_error) {{ @@ -4658,10 +4730,13 @@ def _playwright_result( if payload["reporter_error"] != "invalid_reporter_state": raise ValueError("malformed Playwright reporter error") reasons = { - "invalid_suite", "suite_traversal", "invalid_title_path", - "invalid_project_title", "invalid_location", - "duplicate_identity", "invalid_test_result", "unknown_test", - "framework_error", "serialization_failure", + "invalid_suite", "suite_all_tests_access", + "suite_all_tests_call", "invalid_title_path", + "title_path_call", "invalid_project_title", "project_access", + "project_call", "invalid_location", "location_access", + "path_operation", "duplicate_identity", "invalid_test_result", + "unknown_test", "invalid_framework_error", "framework_error", + "invalid_run_result", "serialization_failure", "write_failure", } reason = payload["reason"] if not isinstance(reason, str) or reason not in reasons: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index f8e14cdcbd..20e427f224 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -46,6 +46,7 @@ IDENTITY = "chromium::tests/widget.spec.ts::widget works" REPORTER_ERROR_REASONS = ( "invalid_suite", + "suite_all_tests_access", "suite_all_tests_call", "invalid_title_path", "title_path_call", @@ -62,6 +63,7 @@ "framework_error", "invalid_run_result", "serialization_failure", + "write_failure", ) @@ -1774,6 +1776,11 @@ def test_playwright_malformed_json_shapes_fail_closed( "try { reporter.onBegin({ allTests: () => { throw new Error('bad'); } }); } catch {}\n" "reporter.onEnd({ status: 'passed' });" ), "suite_all_tests_call"), + (( + "const suite = {};\n" + "Object.defineProperty(suite, 'allTests', { get: () => { throw new Error('bad'); } });\n" + "reporter.onBegin(suite); reporter.onEnd({ status: 'passed' });" + ), "suite_all_tests_access"), (( "reporter.onBegin({ allTests: () => [valid()] });\n" "try { reporter.onTestEnd(valid(), null); } catch {}\n" @@ -1797,7 +1804,7 @@ def test_playwright_malformed_json_shapes_fail_closed( "reporter.onEnd({ status: 'passed' });" ), "location_access"), (( - "const path = require('path'); path.relative = () => { throw new Error('bad'); };\n" + "path.relative = () => { throw new Error('bad'); };\n" "reporter.onBegin({ allTests: () => [valid()] });\n" "reporter.onEnd({ status: 'passed' });" ), "path_operation"), @@ -3432,7 +3439,7 @@ def test_playwright_reporter_collects_each_identity_before_execution() -> None: source = _playwright_reporter_source(198) assert "version() { return 'v2'; }" in source assert "onBegin(suite)" in source - assert "suite.allTests()" in source + assert "allTestsFunction.call(suite)" in source assert "this.tests = new Map()" in source assert "invalid_reporter_state" in source assert "REPORTER_ERROR_REASONS" in source @@ -3444,7 +3451,9 @@ def test_playwright_reporter_collects_each_identity_before_execution() -> None: assert "throw new Error" not in source assert "titles.join(' > ')" in source assert "onTestEnd(test, result)" in source - assert "onError(_error)" in source + assert "onError(error)" in source + assert "onEnd(result)" in source + assert "this.frameworkError && status !== 'passed'" in source def test_playwright_package_import_mapping_is_bound_with_nearest_manifest(tmp_path: Path) -> None: From 3c3b44dc17b2089f2787310e237afc79a1ca7572 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 08:26:15 -0700 Subject: [PATCH 119/233] test(sync): classify Playwright read-only framework errors --- tests/test_sync_core_runner_playwright.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 20e427f224..19eb823f40 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1889,6 +1889,24 @@ def test_playwright_reporter_framework_error_status_contract( } +def test_playwright_reporter_classifies_read_only_framework_error( + tmp_path: Path, +) -> None: + """Keep the hosted immutable-tree diagnostic finite and non-sensitive.""" + receipt = _reporter_callback_receipt( + tmp_path, + "reporter.onError({ message: 'candidate detail', code: 'EROFS' });\n" + "reporter.onBegin({ allTests: () => [valid()] });\n" + "reporter.onEnd({ status: 'failed' });", + ) + + assert receipt == { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "reason": "framework_error_read_only", + } + + @pytest.mark.parametrize("reason", REPORTER_ERROR_REASONS) def test_playwright_reporter_error_reason_is_closed_and_bounded( tmp_path: Path, reason: str, From ec8b7569835803eb123f03a8fcdac2693440f238 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 08:26:55 -0700 Subject: [PATCH 120/233] fix(sync): classify Playwright framework filesystem errors --- pdd/sync_core/runner.py | 16 +++++++++++----- tests/test_sync_core_runner_playwright.py | 2 ++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 2a4d3c1b92..3fad0fd054 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4419,7 +4419,8 @@ def _playwright_reporter_source(result_fd: int) -> str: 'title_path_call', 'invalid_project_title', 'project_access', 'project_call', 'invalid_location', 'location_access', 'path_operation', 'duplicate_identity', 'invalid_test_result', 'unknown_test', 'invalid_framework_error', - 'framework_error', 'invalid_run_result', 'serialization_failure', 'write_failure', + 'framework_error', 'framework_error_permission', 'framework_error_read_only', + 'invalid_run_result', 'serialization_failure', 'write_failure', ]); const ERROR_RECEIPTS = Object.freeze({{ invalid_suite: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_suite"}}', @@ -4438,6 +4439,8 @@ def _playwright_reporter_source(result_fd: int) -> str: unknown_test: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"unknown_test"}}', invalid_framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_framework_error"}}', framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error"}}', + framework_error_permission: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_permission"}}', + framework_error_read_only: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_read_only"}}', invalid_run_result: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_run_result"}}', serialization_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"serialization_failure"}}', write_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"write_failure"}}', @@ -4588,16 +4591,18 @@ class PddFrameworkReporter {{ }} onError(error) {{ if (this.reporterError) return; + let code; try {{ if (!error || typeof error !== 'object' || typeof error.message !== 'string') {{ this.invalidate('invalid_framework_error'); return; }} + code = error.code; }} catch (_error) {{ - this.invalidate('invalid_framework_error'); - return; + code = undefined; }} - this.frameworkError = true; + this.frameworkError = code === 'EACCES' ? 'framework_error_permission' + : code === 'EROFS' ? 'framework_error_read_only' : 'framework_error'; }} writeErrorReceipt() {{ const receipt = ERROR_RECEIPTS[this.reporterError] @@ -4618,7 +4623,7 @@ class PddFrameworkReporter {{ return; }} if (this.frameworkError && status !== 'passed') {{ - this.invalidate('framework_error'); + this.invalidate(this.frameworkError); this.writeErrorReceipt(); return; }} @@ -4736,6 +4741,7 @@ def _playwright_result( "project_call", "invalid_location", "location_access", "path_operation", "duplicate_identity", "invalid_test_result", "unknown_test", "invalid_framework_error", "framework_error", + "framework_error_permission", "framework_error_read_only", "invalid_run_result", "serialization_failure", "write_failure", } reason = payload["reason"] diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 19eb823f40..52b70b3cfa 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -61,6 +61,8 @@ "unknown_test", "invalid_framework_error", "framework_error", + "framework_error_permission", + "framework_error_read_only", "invalid_run_result", "serialization_failure", "write_failure", From 432f481c78e7ae70240d879d418b3ed7860dab04 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 08:35:03 -0700 Subject: [PATCH 121/233] test(sync): require Node 26 for protected Playwright --- tests/test_sync_core_runner_playwright.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 52b70b3cfa..858090bc8c 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -383,6 +383,14 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( manifest = Path(os.environ["PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"]) roles = json.loads(manifest.read_text(encoding="utf-8"))["roles"] + node_version = subprocess.run( + [roles["launcher"], "--version"], + capture_output=True, + check=True, + text=True, + ).stdout.strip() + node_major = int(node_version.removeprefix("v").split(".", maxsplit=1)[0]) + assert node_major >= 26, node_version root = tmp_path / "real-playwright-repo" root.mkdir() _git(root, "init", "-q") From 2aa78bf2964cec59821f55347b7e34d1370e8814 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 08:35:14 -0700 Subject: [PATCH 122/233] fix(ci): use Node 26 for protected Playwright --- .github/workflows/unit-tests.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a8fe93796d..947be8b6d1 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -94,6 +94,11 @@ jobs: handle.write(f"PDD_REAL_VITEST_TOOLCHAIN_MANIFEST={manifest}\n") PY + - name: Set up Node for real Playwright source coverage + uses: actions/setup-node@v6 + with: + node-version: '26' + - name: Provision identity-bound Playwright Chromium toolchain shell: bash run: | @@ -521,7 +526,7 @@ jobs: - name: Set up Node for real Playwright wheel coverage uses: actions/setup-node@v6 with: - node-version: '22' + node-version: '26' - name: Provision identity-bound Playwright Chromium toolchain shell: bash From ebd857df139563d9f3556fa6c651ade1f3f93a8e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 08:41:18 -0700 Subject: [PATCH 123/233] fix(sync): remove unverified framework error categories --- pdd/sync_core/runner.py | 16 +++++----------- tests/test_sync_core_runner_playwright.py | 20 -------------------- 2 files changed, 5 insertions(+), 31 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 3fad0fd054..2a4d3c1b92 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4419,8 +4419,7 @@ def _playwright_reporter_source(result_fd: int) -> str: 'title_path_call', 'invalid_project_title', 'project_access', 'project_call', 'invalid_location', 'location_access', 'path_operation', 'duplicate_identity', 'invalid_test_result', 'unknown_test', 'invalid_framework_error', - 'framework_error', 'framework_error_permission', 'framework_error_read_only', - 'invalid_run_result', 'serialization_failure', 'write_failure', + 'framework_error', 'invalid_run_result', 'serialization_failure', 'write_failure', ]); const ERROR_RECEIPTS = Object.freeze({{ invalid_suite: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_suite"}}', @@ -4439,8 +4438,6 @@ def _playwright_reporter_source(result_fd: int) -> str: unknown_test: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"unknown_test"}}', invalid_framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_framework_error"}}', framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error"}}', - framework_error_permission: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_permission"}}', - framework_error_read_only: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_read_only"}}', invalid_run_result: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_run_result"}}', serialization_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"serialization_failure"}}', write_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"write_failure"}}', @@ -4591,18 +4588,16 @@ class PddFrameworkReporter {{ }} onError(error) {{ if (this.reporterError) return; - let code; try {{ if (!error || typeof error !== 'object' || typeof error.message !== 'string') {{ this.invalidate('invalid_framework_error'); return; }} - code = error.code; }} catch (_error) {{ - code = undefined; + this.invalidate('invalid_framework_error'); + return; }} - this.frameworkError = code === 'EACCES' ? 'framework_error_permission' - : code === 'EROFS' ? 'framework_error_read_only' : 'framework_error'; + this.frameworkError = true; }} writeErrorReceipt() {{ const receipt = ERROR_RECEIPTS[this.reporterError] @@ -4623,7 +4618,7 @@ class PddFrameworkReporter {{ return; }} if (this.frameworkError && status !== 'passed') {{ - this.invalidate(this.frameworkError); + this.invalidate('framework_error'); this.writeErrorReceipt(); return; }} @@ -4741,7 +4736,6 @@ def _playwright_result( "project_call", "invalid_location", "location_access", "path_operation", "duplicate_identity", "invalid_test_result", "unknown_test", "invalid_framework_error", "framework_error", - "framework_error_permission", "framework_error_read_only", "invalid_run_result", "serialization_failure", "write_failure", } reason = payload["reason"] diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 858090bc8c..d15edc0e9f 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -61,8 +61,6 @@ "unknown_test", "invalid_framework_error", "framework_error", - "framework_error_permission", - "framework_error_read_only", "invalid_run_result", "serialization_failure", "write_failure", @@ -1899,24 +1897,6 @@ def test_playwright_reporter_framework_error_status_contract( } -def test_playwright_reporter_classifies_read_only_framework_error( - tmp_path: Path, -) -> None: - """Keep the hosted immutable-tree diagnostic finite and non-sensitive.""" - receipt = _reporter_callback_receipt( - tmp_path, - "reporter.onError({ message: 'candidate detail', code: 'EROFS' });\n" - "reporter.onBegin({ allTests: () => [valid()] });\n" - "reporter.onEnd({ status: 'failed' });", - ) - - assert receipt == { - "pdd_playwright_reporter": 1, - "reporter_error": "invalid_reporter_state", - "reason": "framework_error_read_only", - } - - @pytest.mark.parametrize("reason", REPORTER_ERROR_REASONS) def test_playwright_reporter_error_reason_is_closed_and_bounded( tmp_path: Path, reason: str, From cb44046a2cf6223247f41027fa423478d244b9db Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 08:54:46 -0700 Subject: [PATCH 124/233] test(sync): require Node 24 LTS for protected Playwright --- tests/test_sync_core_runner_playwright.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index d15edc0e9f..6a7d3a4481 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -388,7 +388,7 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( text=True, ).stdout.strip() node_major = int(node_version.removeprefix("v").split(".", maxsplit=1)[0]) - assert node_major >= 26, node_version + assert node_major == 24, node_version root = tmp_path / "real-playwright-repo" root.mkdir() _git(root, "init", "-q") From 778695af46c77716c026044913bd03102ac1e7ab Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 08:55:02 -0700 Subject: [PATCH 125/233] fix(ci): use Node 24 LTS for protected Playwright --- .github/workflows/unit-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 947be8b6d1..53f08c2c51 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -97,7 +97,7 @@ jobs: - name: Set up Node for real Playwright source coverage uses: actions/setup-node@v6 with: - node-version: '26' + node-version: '24' - name: Provision identity-bound Playwright Chromium toolchain shell: bash @@ -526,7 +526,7 @@ jobs: - name: Set up Node for real Playwright wheel coverage uses: actions/setup-node@v6 with: - node-version: '26' + node-version: '24' - name: Provision identity-bound Playwright Chromium toolchain shell: bash From dc824e27a802e3b5ef958d8446aa4b32a58bad13 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:04:39 -0700 Subject: [PATCH 126/233] fix(ci): remove disproven Playwright runtime workaround --- .github/workflows/unit-tests.yml | 7 +------ tests/test_sync_core_runner_playwright.py | 8 -------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 53f08c2c51..a8fe93796d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -94,11 +94,6 @@ jobs: handle.write(f"PDD_REAL_VITEST_TOOLCHAIN_MANIFEST={manifest}\n") PY - - name: Set up Node for real Playwright source coverage - uses: actions/setup-node@v6 - with: - node-version: '24' - - name: Provision identity-bound Playwright Chromium toolchain shell: bash run: | @@ -526,7 +521,7 @@ jobs: - name: Set up Node for real Playwright wheel coverage uses: actions/setup-node@v6 with: - node-version: '24' + node-version: '22' - name: Provision identity-bound Playwright Chromium toolchain shell: bash diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 6a7d3a4481..20e427f224 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -381,14 +381,6 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( manifest = Path(os.environ["PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"]) roles = json.loads(manifest.read_text(encoding="utf-8"))["roles"] - node_version = subprocess.run( - [roles["launcher"], "--version"], - capture_output=True, - check=True, - text=True, - ).stdout.strip() - node_major = int(node_version.removeprefix("v").split(".", maxsplit=1)[0]) - assert node_major == 24, node_version root = tmp_path / "real-playwright-repo" root.mkdir() _git(root, "init", "-q") From 8810b522a9dcc1de384fe995d5ba102166c9f109 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:05:54 -0700 Subject: [PATCH 127/233] test(sync): classify bounded Playwright framework errors --- tests/test_sync_core_runner_playwright.py | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 20e427f224..db0904cf38 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -61,6 +61,9 @@ "unknown_test", "invalid_framework_error", "framework_error", + "framework_error_read_only", + "framework_error_permission", + "framework_error_module_resolution", "invalid_run_result", "serialization_failure", "write_failure", @@ -1889,6 +1892,33 @@ def test_playwright_reporter_framework_error_status_contract( } +@pytest.mark.parametrize( + ("message", "reason"), + [ + ("EROFS: read-only file system, mkdir '/candidate-secret'", "framework_error_read_only"), + ("EACCES: permission denied, open '/candidate-secret'", "framework_error_permission"), + ("Cannot find module '/candidate-secret'", "framework_error_module_resolution"), + ], +) +def test_playwright_reporter_classifies_framework_errors_without_detail( + tmp_path: Path, message: str, reason: str, +) -> None: + """Emit only a finite checker-owned framework-error classification.""" + receipt = _reporter_callback_receipt( + tmp_path, + f"reporter.onError({{ message: {message!r} }});\n" + "reporter.onBegin({ allTests: () => [valid()] });\n" + "reporter.onEnd({ status: 'failed' });", + ) + + assert receipt == { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "reason": reason, + } + assert "candidate-secret" not in json.dumps(receipt) + + @pytest.mark.parametrize("reason", REPORTER_ERROR_REASONS) def test_playwright_reporter_error_reason_is_closed_and_bounded( tmp_path: Path, reason: str, From 49cea0a5559fc50cc3c889aaa1254f161c4f1c38 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:06:53 -0700 Subject: [PATCH 128/233] fix(sync): classify bounded Playwright framework errors --- pdd/sync_core/runner.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 2a4d3c1b92..956e08634c 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4419,7 +4419,9 @@ def _playwright_reporter_source(result_fd: int) -> str: 'title_path_call', 'invalid_project_title', 'project_access', 'project_call', 'invalid_location', 'location_access', 'path_operation', 'duplicate_identity', 'invalid_test_result', 'unknown_test', 'invalid_framework_error', - 'framework_error', 'invalid_run_result', 'serialization_failure', 'write_failure', + 'framework_error', 'framework_error_read_only', 'framework_error_permission', + 'framework_error_module_resolution', 'invalid_run_result', 'serialization_failure', + 'write_failure', ]); const ERROR_RECEIPTS = Object.freeze({{ invalid_suite: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_suite"}}', @@ -4438,6 +4440,9 @@ def _playwright_reporter_source(result_fd: int) -> str: unknown_test: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"unknown_test"}}', invalid_framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_framework_error"}}', framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error"}}', + framework_error_read_only: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_read_only"}}', + framework_error_permission: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_permission"}}', + framework_error_module_resolution: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_module_resolution"}}', invalid_run_result: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_run_result"}}', serialization_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"serialization_failure"}}', write_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"write_failure"}}', @@ -4588,16 +4593,27 @@ class PddFrameworkReporter {{ }} onError(error) {{ if (this.reporterError) return; + let message; try {{ if (!error || typeof error !== 'object' || typeof error.message !== 'string') {{ this.invalidate('invalid_framework_error'); return; }} + message = error.message; }} catch (_error) {{ this.invalidate('invalid_framework_error'); return; }} - this.frameworkError = true; + try {{ + this.frameworkError = /(?:EROFS|read-only file system)/i.test(message) + ? 'framework_error_read_only' + : /(?:EACCES|permission denied)/i.test(message) + ? 'framework_error_permission' + : /(?:ERR_MODULE_NOT_FOUND|Cannot find module)/i.test(message) + ? 'framework_error_module_resolution' : 'framework_error'; + }} catch (_error) {{ + this.frameworkError = 'framework_error'; + }} }} writeErrorReceipt() {{ const receipt = ERROR_RECEIPTS[this.reporterError] @@ -4618,7 +4634,7 @@ class PddFrameworkReporter {{ return; }} if (this.frameworkError && status !== 'passed') {{ - this.invalidate('framework_error'); + this.invalidate(this.frameworkError); this.writeErrorReceipt(); return; }} @@ -4736,6 +4752,8 @@ def _playwright_result( "project_call", "invalid_location", "location_access", "path_operation", "duplicate_identity", "invalid_test_result", "unknown_test", "invalid_framework_error", "framework_error", + "framework_error_read_only", "framework_error_permission", + "framework_error_module_resolution", "invalid_run_result", "serialization_failure", "write_failure", } reason = payload["reason"] From b3172e9c7d314783f6a78a97ba93342caf3ec608 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:15:18 -0700 Subject: [PATCH 129/233] fix(sync): remove inconclusive framework error categories --- pdd/sync_core/runner.py | 24 +++--------------- tests/test_sync_core_runner_playwright.py | 30 ----------------------- 2 files changed, 3 insertions(+), 51 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 956e08634c..2a4d3c1b92 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -4419,9 +4419,7 @@ def _playwright_reporter_source(result_fd: int) -> str: 'title_path_call', 'invalid_project_title', 'project_access', 'project_call', 'invalid_location', 'location_access', 'path_operation', 'duplicate_identity', 'invalid_test_result', 'unknown_test', 'invalid_framework_error', - 'framework_error', 'framework_error_read_only', 'framework_error_permission', - 'framework_error_module_resolution', 'invalid_run_result', 'serialization_failure', - 'write_failure', + 'framework_error', 'invalid_run_result', 'serialization_failure', 'write_failure', ]); const ERROR_RECEIPTS = Object.freeze({{ invalid_suite: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_suite"}}', @@ -4440,9 +4438,6 @@ def _playwright_reporter_source(result_fd: int) -> str: unknown_test: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"unknown_test"}}', invalid_framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_framework_error"}}', framework_error: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error"}}', - framework_error_read_only: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_read_only"}}', - framework_error_permission: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_permission"}}', - framework_error_module_resolution: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"framework_error_module_resolution"}}', invalid_run_result: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"invalid_run_result"}}', serialization_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"serialization_failure"}}', write_failure: '{{"pdd_playwright_reporter":1,"reporter_error":"invalid_reporter_state","reason":"write_failure"}}', @@ -4593,27 +4588,16 @@ class PddFrameworkReporter {{ }} onError(error) {{ if (this.reporterError) return; - let message; try {{ if (!error || typeof error !== 'object' || typeof error.message !== 'string') {{ this.invalidate('invalid_framework_error'); return; }} - message = error.message; }} catch (_error) {{ this.invalidate('invalid_framework_error'); return; }} - try {{ - this.frameworkError = /(?:EROFS|read-only file system)/i.test(message) - ? 'framework_error_read_only' - : /(?:EACCES|permission denied)/i.test(message) - ? 'framework_error_permission' - : /(?:ERR_MODULE_NOT_FOUND|Cannot find module)/i.test(message) - ? 'framework_error_module_resolution' : 'framework_error'; - }} catch (_error) {{ - this.frameworkError = 'framework_error'; - }} + this.frameworkError = true; }} writeErrorReceipt() {{ const receipt = ERROR_RECEIPTS[this.reporterError] @@ -4634,7 +4618,7 @@ class PddFrameworkReporter {{ return; }} if (this.frameworkError && status !== 'passed') {{ - this.invalidate(this.frameworkError); + this.invalidate('framework_error'); this.writeErrorReceipt(); return; }} @@ -4752,8 +4736,6 @@ def _playwright_result( "project_call", "invalid_location", "location_access", "path_operation", "duplicate_identity", "invalid_test_result", "unknown_test", "invalid_framework_error", "framework_error", - "framework_error_read_only", "framework_error_permission", - "framework_error_module_resolution", "invalid_run_result", "serialization_failure", "write_failure", } reason = payload["reason"] diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index db0904cf38..20e427f224 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -61,9 +61,6 @@ "unknown_test", "invalid_framework_error", "framework_error", - "framework_error_read_only", - "framework_error_permission", - "framework_error_module_resolution", "invalid_run_result", "serialization_failure", "write_failure", @@ -1892,33 +1889,6 @@ def test_playwright_reporter_framework_error_status_contract( } -@pytest.mark.parametrize( - ("message", "reason"), - [ - ("EROFS: read-only file system, mkdir '/candidate-secret'", "framework_error_read_only"), - ("EACCES: permission denied, open '/candidate-secret'", "framework_error_permission"), - ("Cannot find module '/candidate-secret'", "framework_error_module_resolution"), - ], -) -def test_playwright_reporter_classifies_framework_errors_without_detail( - tmp_path: Path, message: str, reason: str, -) -> None: - """Emit only a finite checker-owned framework-error classification.""" - receipt = _reporter_callback_receipt( - tmp_path, - f"reporter.onError({{ message: {message!r} }});\n" - "reporter.onBegin({ allTests: () => [valid()] });\n" - "reporter.onEnd({ status: 'failed' });", - ) - - assert receipt == { - "pdd_playwright_reporter": 1, - "reporter_error": "invalid_reporter_state", - "reason": reason, - } - assert "candidate-secret" not in json.dumps(receipt) - - @pytest.mark.parametrize("reason", REPORTER_ERROR_REASONS) def test_playwright_reporter_error_reason_is_closed_and_bounded( tmp_path: Path, reason: str, From d0324ec1da3ef1ec06e5129d2cf63916ab1dd777 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:17:15 -0700 Subject: [PATCH 130/233] test(sync): fingerprint controlled Playwright framework errors --- .github/workflows/unit-tests.yml | 2 + tests/test_sync_core_runner_playwright.py | 50 +++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a8fe93796d..576bc7b2e5 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -405,6 +405,7 @@ jobs: - name: Run real protected Playwright source protocol env: PDD_RUN_REAL_PLAYWRIGHT: '1' + PDD_PLAYWRIGHT_FRAMEWORK_DIAGNOSTIC: '1' run: > pytest -q tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir @@ -605,6 +606,7 @@ jobs: env: PDD_RUN_REAL_PLAYWRIGHT: '1' PDD_REQUIRE_INSTALLED_WHEEL: '1' + PDD_PLAYWRIGHT_FRAMEWORK_DIAGNOSTIC: '1' run: | set -euo pipefail smoke_dir="$(mktemp -d)" diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 20e427f224..759fb5ea3f 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -2,6 +2,7 @@ import json import os +import re import shutil import subprocess import sys @@ -373,6 +374,7 @@ def _trusted_playwright_config( ) def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( tmp_path: Path, suffix: str, js_scope: str | None, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Run every admitted config syntax through Playwright and Chromium.""" if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): @@ -424,6 +426,49 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( (obligation,), ("REQ-1",), "profile-v1", ) + diagnostic: dict[str, str] = {} + if os.environ.get("PDD_PLAYWRIGHT_FRAMEWORK_DIAGNOSTIC"): + def diagnostic_reporter_source(result_fd: int) -> str: + return f"""const crypto = require('crypto'); +const fs = require('fs'); +const RESULT_FD = {result_fd}; +class PddDiagnosticReporter {{ + constructor() {{ this.digest = '0'.repeat(64); }} + onError(error) {{ + try {{ + if (error && typeof error.message === 'string') {{ + this.digest = crypto.createHash('sha256').update(error.message, 'utf8').digest('hex'); + }} + }} catch (_error) {{}} + }} + onEnd() {{ + fs.writeSync(RESULT_FD, JSON.stringify({{ + pdd_playwright_reporter: 1, + reporter_error: 'invalid_reporter_state', + reason: 'framework_error', + diagnostic: this.digest, + }})); + }} +}} +module.exports = PddDiagnosticReporter; +""" + + original_result = runner_module._playwright_result + + def diagnostic_result(*args, **kwargs): + payload = json.loads(args[1]) + value = payload.pop("diagnostic", None) + assert isinstance(value, str) + assert re.fullmatch(r"[0-9a-f]{{64}}", value) + diagnostic["sha256"] = value + replaced = (args[0], json.dumps(payload), *args[2:]) + return original_result(*replaced, **kwargs) + + monkeypatch.setattr( + runner_module, "_playwright_reporter_source", diagnostic_reporter_source, + ) + monkeypatch.setattr(runner_module, "_playwright_result", diagnostic_result) + envelope, executions = run_profile( root, profile, @@ -439,6 +484,11 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( ), ) + if diagnostic: + assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR + pytest.fail( + "controlled Playwright framework SHA-256=" + diagnostic["sha256"], + ) assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] From 287ec59f6af96d0cb48582313a732d55d0bc44ca Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:28:00 -0700 Subject: [PATCH 131/233] test(sync): map controlled Playwright config-load causes --- tests/test_sync_core_runner_playwright.py | 32 ++++++++++++++--------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 759fb5ea3f..399dbbaa2c 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -2,7 +2,6 @@ import json import os -import re import shutil import subprocess import sys @@ -429,16 +428,23 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( diagnostic: dict[str, str] = {} if os.environ.get("PDD_PLAYWRIGHT_FRAMEWORK_DIAGNOSTIC"): def diagnostic_reporter_source(result_fd: int) -> str: - return f"""const crypto = require('crypto'); -const fs = require('fs'); + return f"""const fs = require('fs'); const RESULT_FD = {result_fd}; class PddDiagnosticReporter {{ - constructor() {{ this.digest = '0'.repeat(64); }} + constructor() {{ this.cause = 'config_unknown'; }} onError(error) {{ try {{ - if (error && typeof error.message === 'string') {{ - this.digest = crypto.createHash('sha256').update(error.message, 'utf8').digest('hex'); - }} + const message = typeof error?.message === 'string' ? error.message : ''; + const stack = typeof error?.stack === 'string' ? error.stack : ''; + const code = typeof error?.code === 'string' ? error.code : ''; + const detail = `${{message}}\\n${{stack}}\\n${{code}}`; + this.cause = /transform[/\\\\]esmLoader|esmLoaderHost/.test(detail) + ? 'config_esm_loader' + : /compilationCache/.test(detail) ? 'config_cache' + : /transform[/\\\\]transform/.test(detail) ? 'config_transform' + : /(?:[/\\\\]tmp[/\\\\]|TMPDIR|cache|EROFS|EACCES)/i.test(detail) + ? 'config_temp_cache' : /(?:node:internal|ERR_MODULE|worker_threads|MessagePort|module\\.register)/.test(detail) + ? 'config_runtime_closure' : 'config_unknown'; }} catch (_error) {{}} }} onEnd() {{ @@ -446,7 +452,7 @@ class PddDiagnosticReporter {{ pdd_playwright_reporter: 1, reporter_error: 'invalid_reporter_state', reason: 'framework_error', - diagnostic: this.digest, + diagnostic: this.cause, }})); }} }} @@ -458,9 +464,11 @@ class PddDiagnosticReporter {{ def diagnostic_result(*args, **kwargs): payload = json.loads(args[1]) value = payload.pop("diagnostic", None) - assert isinstance(value, str) - assert re.fullmatch(r"[0-9a-f]{{64}}", value) - diagnostic["sha256"] = value + assert value in { + "config_esm_loader", "config_cache", "config_transform", + "config_temp_cache", "config_runtime_closure", "config_unknown", + } + diagnostic["cause"] = value replaced = (args[0], json.dumps(payload), *args[2:]) return original_result(*replaced, **kwargs) @@ -487,7 +495,7 @@ def diagnostic_result(*args, **kwargs): if diagnostic: assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR pytest.fail( - "controlled Playwright framework SHA-256=" + diagnostic["sha256"], + "controlled Playwright config-load cause=" + diagnostic["cause"], ) assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] From ee9d06fde55e5fbdfcadef2c9b8a9afefc0aaf56 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:37:16 -0700 Subject: [PATCH 132/233] fix(sync): remove inconclusive config-load diagnostics --- .github/workflows/unit-tests.yml | 2 - tests/test_sync_core_runner_playwright.py | 58 ----------------------- 2 files changed, 60 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 576bc7b2e5..a8fe93796d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -405,7 +405,6 @@ jobs: - name: Run real protected Playwright source protocol env: PDD_RUN_REAL_PLAYWRIGHT: '1' - PDD_PLAYWRIGHT_FRAMEWORK_DIAGNOSTIC: '1' run: > pytest -q tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir @@ -606,7 +605,6 @@ jobs: env: PDD_RUN_REAL_PLAYWRIGHT: '1' PDD_REQUIRE_INSTALLED_WHEEL: '1' - PDD_PLAYWRIGHT_FRAMEWORK_DIAGNOSTIC: '1' run: | set -euo pipefail smoke_dir="$(mktemp -d)" diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 399dbbaa2c..20e427f224 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -373,7 +373,6 @@ def _trusted_playwright_config( ) def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( tmp_path: Path, suffix: str, js_scope: str | None, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Run every admitted config syntax through Playwright and Chromium.""" if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): @@ -425,58 +424,6 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( (obligation,), ("REQ-1",), "profile-v1", ) - diagnostic: dict[str, str] = {} - if os.environ.get("PDD_PLAYWRIGHT_FRAMEWORK_DIAGNOSTIC"): - def diagnostic_reporter_source(result_fd: int) -> str: - return f"""const fs = require('fs'); -const RESULT_FD = {result_fd}; -class PddDiagnosticReporter {{ - constructor() {{ this.cause = 'config_unknown'; }} - onError(error) {{ - try {{ - const message = typeof error?.message === 'string' ? error.message : ''; - const stack = typeof error?.stack === 'string' ? error.stack : ''; - const code = typeof error?.code === 'string' ? error.code : ''; - const detail = `${{message}}\\n${{stack}}\\n${{code}}`; - this.cause = /transform[/\\\\]esmLoader|esmLoaderHost/.test(detail) - ? 'config_esm_loader' - : /compilationCache/.test(detail) ? 'config_cache' - : /transform[/\\\\]transform/.test(detail) ? 'config_transform' - : /(?:[/\\\\]tmp[/\\\\]|TMPDIR|cache|EROFS|EACCES)/i.test(detail) - ? 'config_temp_cache' : /(?:node:internal|ERR_MODULE|worker_threads|MessagePort|module\\.register)/.test(detail) - ? 'config_runtime_closure' : 'config_unknown'; - }} catch (_error) {{}} - }} - onEnd() {{ - fs.writeSync(RESULT_FD, JSON.stringify({{ - pdd_playwright_reporter: 1, - reporter_error: 'invalid_reporter_state', - reason: 'framework_error', - diagnostic: this.cause, - }})); - }} -}} -module.exports = PddDiagnosticReporter; -""" - - original_result = runner_module._playwright_result - - def diagnostic_result(*args, **kwargs): - payload = json.loads(args[1]) - value = payload.pop("diagnostic", None) - assert value in { - "config_esm_loader", "config_cache", "config_transform", - "config_temp_cache", "config_runtime_closure", "config_unknown", - } - diagnostic["cause"] = value - replaced = (args[0], json.dumps(payload), *args[2:]) - return original_result(*replaced, **kwargs) - - monkeypatch.setattr( - runner_module, "_playwright_reporter_source", diagnostic_reporter_source, - ) - monkeypatch.setattr(runner_module, "_playwright_result", diagnostic_result) - envelope, executions = run_profile( root, profile, @@ -492,11 +439,6 @@ def diagnostic_result(*args, **kwargs): ), ) - if diagnostic: - assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR - pytest.fail( - "controlled Playwright config-load cause=" + diagnostic["cause"], - ) assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] From 3a0c2cd7818f9fed2e252c46c394d63aaaf9b8a7 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:38:20 -0700 Subject: [PATCH 133/233] test(sync): reveal controlled Playwright config error --- .github/workflows/unit-tests.yml | 2 + tests/test_sync_core_runner_playwright.py | 45 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a8fe93796d..6279a186ee 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -405,6 +405,7 @@ jobs: - name: Run real protected Playwright source protocol env: PDD_RUN_REAL_PLAYWRIGHT: '1' + PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR: '1' run: > pytest -q tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir @@ -605,6 +606,7 @@ jobs: env: PDD_RUN_REAL_PLAYWRIGHT: '1' PDD_REQUIRE_INSTALLED_WHEEL: '1' + PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR: '1' run: | set -euo pipefail smoke_dir="$(mktemp -d)" diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 20e427f224..10723a37e2 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -373,6 +373,7 @@ def _trusted_playwright_config( ) def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( tmp_path: Path, suffix: str, js_scope: str | None, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Run every admitted config syntax through Playwright and Chromium.""" if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): @@ -424,6 +425,45 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( (obligation,), ("REQ-1",), "profile-v1", ) + diagnostic: dict[str, str] = {} + if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR"): + def diagnostic_reporter_source(result_fd: int) -> str: + return f"""const fs = require('fs'); +const RESULT_FD = {result_fd}; +class PddControlledConfigErrorReporter {{ + constructor() {{ this.message = 'missing config-load error'; }} + onError(error) {{ + if (error && typeof error.message === 'string') this.message = error.message; + }} + onEnd() {{ + fs.writeSync(RESULT_FD, JSON.stringify({{ + pdd_playwright_reporter: 1, + reporter_error: 'invalid_reporter_state', + reason: 'framework_error', + diagnostic: this.message, + }})); + }} +}} +module.exports = PddControlledConfigErrorReporter; +""" + + original_result = runner_module._playwright_result + + def diagnostic_result(*args, **kwargs): + payload = json.loads(args[1]) + value = payload.pop("diagnostic", None) + assert isinstance(value, str) + assert value + assert len(value) <= 4096 + diagnostic["message"] = value + replaced = (args[0], json.dumps(payload), *args[2:]) + return original_result(*replaced, **kwargs) + + monkeypatch.setattr( + runner_module, "_playwright_reporter_source", diagnostic_reporter_source, + ) + monkeypatch.setattr(runner_module, "_playwright_result", diagnostic_result) + envelope, executions = run_profile( root, profile, @@ -439,6 +479,11 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( ), ) + if diagnostic: + assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR + pytest.fail( + "controlled Playwright config-load error=" + diagnostic["message"], + ) assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] From 08b7d181f60d9e7d9d990dab6012494e7f2f957e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:47:19 -0700 Subject: [PATCH 134/233] fix(sync): remove controlled config diagnostic --- .github/workflows/unit-tests.yml | 2 - tests/test_sync_core_runner_playwright.py | 45 ----------------------- 2 files changed, 47 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 6279a186ee..a8fe93796d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -405,7 +405,6 @@ jobs: - name: Run real protected Playwright source protocol env: PDD_RUN_REAL_PLAYWRIGHT: '1' - PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR: '1' run: > pytest -q tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir @@ -606,7 +605,6 @@ jobs: env: PDD_RUN_REAL_PLAYWRIGHT: '1' PDD_REQUIRE_INSTALLED_WHEEL: '1' - PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR: '1' run: | set -euo pipefail smoke_dir="$(mktemp -d)" diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 10723a37e2..20e427f224 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -373,7 +373,6 @@ def _trusted_playwright_config( ) def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( tmp_path: Path, suffix: str, js_scope: str | None, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Run every admitted config syntax through Playwright and Chromium.""" if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): @@ -425,45 +424,6 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( (obligation,), ("REQ-1",), "profile-v1", ) - diagnostic: dict[str, str] = {} - if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR"): - def diagnostic_reporter_source(result_fd: int) -> str: - return f"""const fs = require('fs'); -const RESULT_FD = {result_fd}; -class PddControlledConfigErrorReporter {{ - constructor() {{ this.message = 'missing config-load error'; }} - onError(error) {{ - if (error && typeof error.message === 'string') this.message = error.message; - }} - onEnd() {{ - fs.writeSync(RESULT_FD, JSON.stringify({{ - pdd_playwright_reporter: 1, - reporter_error: 'invalid_reporter_state', - reason: 'framework_error', - diagnostic: this.message, - }})); - }} -}} -module.exports = PddControlledConfigErrorReporter; -""" - - original_result = runner_module._playwright_result - - def diagnostic_result(*args, **kwargs): - payload = json.loads(args[1]) - value = payload.pop("diagnostic", None) - assert isinstance(value, str) - assert value - assert len(value) <= 4096 - diagnostic["message"] = value - replaced = (args[0], json.dumps(payload), *args[2:]) - return original_result(*replaced, **kwargs) - - monkeypatch.setattr( - runner_module, "_playwright_reporter_source", diagnostic_reporter_source, - ) - monkeypatch.setattr(runner_module, "_playwright_result", diagnostic_result) - envelope, executions = run_profile( root, profile, @@ -479,11 +439,6 @@ def diagnostic_result(*args, **kwargs): ), ) - if diagnostic: - assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR - pytest.fail( - "controlled Playwright config-load error=" + diagnostic["message"], - ) assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] From e069f373334ccbd34dd01d2a2f7d54a0bf76aebe Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:49:25 -0700 Subject: [PATCH 135/233] test(sync): require absolute protected Playwright test paths --- tests/test_sync_core_runner_playwright.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 20e427f224..9dfe886bc5 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -854,6 +854,7 @@ def supervised(command, **_kwargs): assert scratch_bindings[0][0][0].name == "tmp" assert scratch_bindings[0][0][0].parent.name == "scratch" assert temp_directories[0] == Path("/tmp") + assert calls[0][3] == str(phase_roots[0] / "tests/widget.spec.ts") dependency_source, dependency_destination = dependency_bindings[0][-1] assert dependency_source.name == "node_modules" assert dependency_destination == phase_roots[0] / "node_modules" From a7e656364350eafaf390213c297ec92fe6287a45 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:50:18 -0700 Subject: [PATCH 136/233] fix(sync): use absolute protected Playwright test paths --- pdd/sync_core/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 2a4d3c1b92..6ff0753bb1 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -5119,7 +5119,7 @@ def _run_playwright_in_tree( ), () command = [ *_playwright_runtime_prefix(prefix, roles.launcher), - "test", *(path.as_posix() for path in paths), + "test", *(str(root / path) for path in paths), f"--config={root / config_path}", f"--reporter={reporter}", "--update-snapshots=none", f"--output={scratch / 'results'}", ] From 465baaf26c673efa1d22b5252b8748af008beb2c Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 09:56:55 -0700 Subject: [PATCH 137/233] test(sync): inspect controlled Playwright discovery state --- .github/workflows/unit-tests.yml | 2 + tests/test_sync_core_runner_playwright.py | 52 +++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a8fe93796d..6279a186ee 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -405,6 +405,7 @@ jobs: - name: Run real protected Playwright source protocol env: PDD_RUN_REAL_PLAYWRIGHT: '1' + PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR: '1' run: > pytest -q tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir @@ -605,6 +606,7 @@ jobs: env: PDD_RUN_REAL_PLAYWRIGHT: '1' PDD_REQUIRE_INSTALLED_WHEEL: '1' + PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR: '1' run: | set -euo pipefail smoke_dir="$(mktemp -d)" diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 9dfe886bc5..f0edfff085 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -373,6 +373,7 @@ def _trusted_playwright_config( ) def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( tmp_path: Path, suffix: str, js_scope: str | None, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Run every admitted config syntax through Playwright and Chromium.""" if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): @@ -424,6 +425,54 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( (obligation,), ("REQ-1",), "profile-v1", ) + diagnostic: dict[str, bool] = {} + if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR"): + def diagnostic_reporter_source(result_fd: int) -> str: + return f"""const fs = require('fs'); +const path = require('path'); +const RESULT_FD = {result_fd}; +class PddControlledConfigErrorReporter {{ + constructor() {{ this.state = {{ configured: false, test_dir_exists: false, test_file_exists: false }}; }} + onConfigure(config) {{ + const testDir = config && Array.isArray(config.projects) && config.projects[0] + ? config.projects[0].testDir : ''; + this.state = {{ + configured: typeof testDir === 'string', + test_dir_exists: typeof testDir === 'string' && fs.existsSync(testDir), + test_file_exists: typeof testDir === 'string' && fs.existsSync(path.join(testDir, 'widget.spec.ts')), + }}; + }} + onEnd() {{ + fs.writeSync(RESULT_FD, JSON.stringify({{ + pdd_playwright_reporter: 1, + reporter_error: 'invalid_reporter_state', + reason: 'framework_error', + diagnostic: this.state, + }})); + }} +}} +module.exports = PddControlledConfigErrorReporter; +""" + + original_result = runner_module._playwright_result + + def diagnostic_result(*args, **kwargs): + payload = json.loads(args[1]) + value = payload.pop("diagnostic", None) + assert isinstance(value, dict) + assert set(value) == { + "configured", "test_dir_exists", "test_file_exists", + } + assert all(isinstance(item, bool) for item in value.values()) + diagnostic.update(value) + replaced = (args[0], json.dumps(payload), *args[2:]) + return original_result(*replaced, **kwargs) + + monkeypatch.setattr( + runner_module, "_playwright_reporter_source", diagnostic_reporter_source, + ) + monkeypatch.setattr(runner_module, "_playwright_result", diagnostic_result) + envelope, executions = run_profile( root, profile, @@ -439,6 +488,9 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( ), ) + if diagnostic: + assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR + pytest.fail("controlled Playwright discovery state=" + repr(diagnostic)) assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] From 2d96673c01fa9e6254285dd868b0d645d54f2938 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 10:03:32 -0700 Subject: [PATCH 138/233] test(sync): capture controlled Playwright error sequence --- tests/test_sync_core_runner_playwright.py | 31 +++++++++-------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index f0edfff085..54fb3d99cc 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -425,29 +425,23 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( (obligation,), ("REQ-1",), "profile-v1", ) - diagnostic: dict[str, bool] = {} + diagnostic: list[str] = [] if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR"): def diagnostic_reporter_source(result_fd: int) -> str: return f"""const fs = require('fs'); -const path = require('path'); const RESULT_FD = {result_fd}; class PddControlledConfigErrorReporter {{ - constructor() {{ this.state = {{ configured: false, test_dir_exists: false, test_file_exists: false }}; }} - onConfigure(config) {{ - const testDir = config && Array.isArray(config.projects) && config.projects[0] - ? config.projects[0].testDir : ''; - this.state = {{ - configured: typeof testDir === 'string', - test_dir_exists: typeof testDir === 'string' && fs.existsSync(testDir), - test_file_exists: typeof testDir === 'string' && fs.existsSync(path.join(testDir, 'widget.spec.ts')), - }}; + constructor() {{ this.errors = []; }} + onError(error) {{ + if (error && typeof error.message === 'string' && this.errors.length < 2) + this.errors.push(error.message); }} onEnd() {{ fs.writeSync(RESULT_FD, JSON.stringify({{ pdd_playwright_reporter: 1, reporter_error: 'invalid_reporter_state', reason: 'framework_error', - diagnostic: this.state, + diagnostic: this.errors, }})); }} }} @@ -459,12 +453,11 @@ class PddControlledConfigErrorReporter {{ def diagnostic_result(*args, **kwargs): payload = json.loads(args[1]) value = payload.pop("diagnostic", None) - assert isinstance(value, dict) - assert set(value) == { - "configured", "test_dir_exists", "test_file_exists", - } - assert all(isinstance(item, bool) for item in value.values()) - diagnostic.update(value) + assert isinstance(value, list) + assert value + assert len(value) <= 2 + assert all(isinstance(item, str) and len(item) <= 4096 for item in value) + diagnostic.extend(value) replaced = (args[0], json.dumps(payload), *args[2:]) return original_result(*replaced, **kwargs) @@ -490,7 +483,7 @@ def diagnostic_result(*args, **kwargs): if diagnostic: assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR - pytest.fail("controlled Playwright discovery state=" + repr(diagnostic)) + pytest.fail("controlled Playwright errors=" + repr(diagnostic)) assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] From 20aab6b527c907f565df24a372992ae6d52bcc71 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 10:04:43 -0700 Subject: [PATCH 139/233] test(sync): split protected Playwright file filtering --- .github/workflows/unit-tests.yml | 4 +- pdd/sync_core/runner.py | 6 ++- tests/test_sync_core_runner_playwright.py | 50 +++-------------------- 3 files changed, 12 insertions(+), 48 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 6279a186ee..65532e0377 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -405,7 +405,7 @@ jobs: - name: Run real protected Playwright source protocol env: PDD_RUN_REAL_PLAYWRIGHT: '1' - PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR: '1' + PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER: '1' run: > pytest -q tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir @@ -606,7 +606,7 @@ jobs: env: PDD_RUN_REAL_PLAYWRIGHT: '1' PDD_REQUIRE_INSTALLED_WHEEL: '1' - PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR: '1' + PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER: '1' run: | set -euo pipefail smoke_dir="$(mktemp -d)" diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 6ff0753bb1..63e798d2a6 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -5117,9 +5117,13 @@ def _run_playwright_in_tree( return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) ), () + command_paths = ( + () if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER") + else tuple(str(root / path) for path in paths) + ) command = [ *_playwright_runtime_prefix(prefix, roles.launcher), - "test", *(str(root / path) for path in paths), + "test", *command_paths, f"--config={root / config_path}", f"--reporter={reporter}", "--update-snapshots=none", f"--output={scratch / 'results'}", ] diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 54fb3d99cc..dfe9cf1f44 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -373,7 +373,6 @@ def _trusted_playwright_config( ) def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( tmp_path: Path, suffix: str, js_scope: str | None, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Run every admitted config syntax through Playwright and Chromium.""" if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): @@ -425,47 +424,6 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( (obligation,), ("REQ-1",), "profile-v1", ) - diagnostic: list[str] = [] - if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_CONFIG_ERROR"): - def diagnostic_reporter_source(result_fd: int) -> str: - return f"""const fs = require('fs'); -const RESULT_FD = {result_fd}; -class PddControlledConfigErrorReporter {{ - constructor() {{ this.errors = []; }} - onError(error) {{ - if (error && typeof error.message === 'string' && this.errors.length < 2) - this.errors.push(error.message); - }} - onEnd() {{ - fs.writeSync(RESULT_FD, JSON.stringify({{ - pdd_playwright_reporter: 1, - reporter_error: 'invalid_reporter_state', - reason: 'framework_error', - diagnostic: this.errors, - }})); - }} -}} -module.exports = PddControlledConfigErrorReporter; -""" - - original_result = runner_module._playwright_result - - def diagnostic_result(*args, **kwargs): - payload = json.loads(args[1]) - value = payload.pop("diagnostic", None) - assert isinstance(value, list) - assert value - assert len(value) <= 2 - assert all(isinstance(item, str) and len(item) <= 4096 for item in value) - diagnostic.extend(value) - replaced = (args[0], json.dumps(payload), *args[2:]) - return original_result(*replaced, **kwargs) - - monkeypatch.setattr( - runner_module, "_playwright_reporter_source", diagnostic_reporter_source, - ) - monkeypatch.setattr(runner_module, "_playwright_result", diagnostic_result) - envelope, executions = run_profile( root, profile, @@ -481,9 +439,11 @@ def diagnostic_result(*args, **kwargs): ), ) - if diagnostic: - assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR - pytest.fail("controlled Playwright errors=" + repr(diagnostic)) + if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER"): + pytest.fail( + "controlled Playwright no-filter result=" + executions[0].outcome.value + ) + assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] From 1303be0cc24eb7ac2fcdb2e94c44e2083d72d068 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 10:11:48 -0700 Subject: [PATCH 140/233] test(sync): capture no-filter Playwright load errors --- tests/test_sync_core_runner_playwright.py | 31 ++++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index dfe9cf1f44..e09c562f08 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -372,7 +372,7 @@ def _trusted_playwright_config( ], ) def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( - tmp_path: Path, suffix: str, js_scope: str | None, + tmp_path: Path, suffix: str, js_scope: str | None, monkeypatch: pytest.MonkeyPatch, ) -> None: """Run every admitted config syntax through Playwright and Chromium.""" if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): @@ -423,6 +423,31 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( UnitId("repo", PurePosixPath("prompts/widget_ts.prompt"), "typescript"), (obligation,), ("REQ-1",), "profile-v1", ) + diagnostic: list[str] = [] + if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER"): + def reporter_source(result_fd: int) -> str: + return f"""const fs = require('fs'); +const RESULT_FD = {result_fd}; +class PddControlledReporter {{ + constructor() {{ this.errors = []; }} + version() {{ return 'v2'; }} + onError(error) {{ + if (error && typeof error.message === 'string' && this.errors.length < 2) this.errors.push(error.message); + }} + onEnd() {{ fs.writeSync(RESULT_FD, JSON.stringify({{pdd_playwright_reporter:1,reporter_error:'invalid_reporter_state',reason:'framework_error',diagnostic:this.errors}})); }} +}} +module.exports = PddControlledReporter; +""" + original_result = runner_module._playwright_result + def capture_result(*args, **kwargs): + payload = json.loads(args[1]) + value = payload.pop("diagnostic") + assert isinstance(value, list) and value and len(value) <= 2 + assert all(isinstance(item, str) and len(item) <= 4096 for item in value) + diagnostic.extend(value) + return original_result(args[0], json.dumps(payload), *args[2:], **kwargs) + monkeypatch.setattr(runner_module, "_playwright_reporter_source", reporter_source) + monkeypatch.setattr(runner_module, "_playwright_result", capture_result) envelope, executions = run_profile( root, @@ -440,9 +465,7 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( ) if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER"): - pytest.fail( - "controlled Playwright no-filter result=" + executions[0].outcome.value - ) + pytest.fail("controlled Playwright no-filter errors=" + repr(diagnostic)) assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] From 3211082dcb6900a565def7192ce32b0e2fbb773d Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 10:18:01 -0700 Subject: [PATCH 141/233] fix(sync): preserve canonical Playwright package identity --- .github/workflows/unit-tests.yml | 2 -- pdd/sync_core/runner.py | 18 ++-------- tests/test_sync_core_runner_playwright.py | 40 +---------------------- 3 files changed, 3 insertions(+), 57 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 65532e0377..a8fe93796d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -405,7 +405,6 @@ jobs: - name: Run real protected Playwright source protocol env: PDD_RUN_REAL_PLAYWRIGHT: '1' - PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER: '1' run: > pytest -q tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir @@ -606,7 +605,6 @@ jobs: env: PDD_RUN_REAL_PLAYWRIGHT: '1' PDD_REQUIRE_INSTALLED_WHEEL: '1' - PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER: '1' run: | set -euo pipefail smoke_dir="$(mktemp -d)" diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 63e798d2a6..a8af29a1ba 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -5111,19 +5111,13 @@ def _run_playwright_in_tree( root, commit, paths, code_under_test_paths ) tree_identity = _playwright_execution_tree_identity(root) - mountpoint_identity = _create_playwright_node_modules_mountpoint(root) - dependency_destination = mountpoint_identity[0] except ValueError as exc: return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) ), () - command_paths = ( - () if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER") - else tuple(str(root / path) for path in paths) - ) command = [ *_playwright_runtime_prefix(prefix, roles.launcher), - "test", *command_paths, + "test", *(str(root / path) for path in paths), f"--config={root / config_path}", f"--reporter={reporter}", "--update-snapshots=none", f"--output={scratch / 'results'}", ] @@ -5145,9 +5139,7 @@ def _run_playwright_in_tree( writable_bindings=((sandbox_tmp, Path("/tmp")),), temp_directory=Path("/tmp"), readable_roots=(reporter, *roles.readable_roots), - readable_bindings=( - *native_bindings, (roles.dependencies, dependency_destination), - ), + readable_bindings=native_bindings, limits=PLAYWRIGHT_SUPERVISOR_LIMITS, result_fifo=result_fifo, result_fd=result_fd, @@ -5172,12 +5164,6 @@ def _run_playwright_in_tree( ), () finally: os.close(read_fd) - try: - _remove_playwright_node_modules_mountpoint(mountpoint_identity) - except ValueError as exc: - return RunnerExecution( - "playwright", EvidenceOutcome.ERROR, digest, str(exc) - ), () if result.returncode == 124: return RunnerExecution( "playwright", EvidenceOutcome.TIMEOUT, digest, diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index e09c562f08..8c4fdc5256 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -372,7 +372,7 @@ def _trusted_playwright_config( ], ) def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( - tmp_path: Path, suffix: str, js_scope: str | None, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, suffix: str, js_scope: str | None, ) -> None: """Run every admitted config syntax through Playwright and Chromium.""" if os.environ.get("PDD_REQUIRE_INSTALLED_WHEEL"): @@ -423,31 +423,6 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( UnitId("repo", PurePosixPath("prompts/widget_ts.prompt"), "typescript"), (obligation,), ("REQ-1",), "profile-v1", ) - diagnostic: list[str] = [] - if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER"): - def reporter_source(result_fd: int) -> str: - return f"""const fs = require('fs'); -const RESULT_FD = {result_fd}; -class PddControlledReporter {{ - constructor() {{ this.errors = []; }} - version() {{ return 'v2'; }} - onError(error) {{ - if (error && typeof error.message === 'string' && this.errors.length < 2) this.errors.push(error.message); - }} - onEnd() {{ fs.writeSync(RESULT_FD, JSON.stringify({{pdd_playwright_reporter:1,reporter_error:'invalid_reporter_state',reason:'framework_error',diagnostic:this.errors}})); }} -}} -module.exports = PddControlledReporter; -""" - original_result = runner_module._playwright_result - def capture_result(*args, **kwargs): - payload = json.loads(args[1]) - value = payload.pop("diagnostic") - assert isinstance(value, list) and value and len(value) <= 2 - assert all(isinstance(item, str) and len(item) <= 4096 for item in value) - diagnostic.extend(value) - return original_result(args[0], json.dumps(payload), *args[2:], **kwargs) - monkeypatch.setattr(runner_module, "_playwright_reporter_source", reporter_source) - monkeypatch.setattr(runner_module, "_playwright_result", capture_result) envelope, executions = run_profile( root, @@ -464,9 +439,6 @@ def capture_result(*args, **kwargs): ), ) - if os.environ.get("PDD_PLAYWRIGHT_CONTROLLED_NO_FILTER"): - pytest.fail("controlled Playwright no-filter errors=" + repr(diagnostic)) - assert executions[0].outcome is EvidenceOutcome.PASS, executions[0].detail assert dict(envelope.binding.adapter_identities)["playwright"] @@ -855,18 +827,12 @@ def test_playwright_execution_uses_process_group_supervisor( root, commit = _repository(tmp_path) calls: list[list[str]] = [] scratch_bindings = [] - dependency_bindings = [] temp_directories = [] phase_roots = [] - dependency_targets_ready = [] def supervised(command, **_kwargs): calls.append(command) scratch_bindings.append(_kwargs["writable_bindings"]) - dependency_bindings.append(_kwargs["readable_bindings"]) - dependency_targets_ready.append( - _kwargs["readable_bindings"][-1][1].is_dir() - ) temp_directories.append(_kwargs["temp_directory"]) phase_roots.append(_kwargs["cwd"]) _write_framework_observation(_kwargs, { @@ -883,10 +849,6 @@ def supervised(command, **_kwargs): assert scratch_bindings[0][0][0].parent.name == "scratch" assert temp_directories[0] == Path("/tmp") assert calls[0][3] == str(phase_roots[0] / "tests/widget.spec.ts") - dependency_source, dependency_destination = dependency_bindings[0][-1] - assert dependency_source.name == "node_modules" - assert dependency_destination == phase_roots[0] / "node_modules" - assert dependency_targets_ready == [True, True, True] def test_playwright_rejects_candidate_node_modules_directory_before_execution( From 1b945d9bd78a554cc1cb31976aef5b75923c0780 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 10:43:54 -0700 Subject: [PATCH 142/233] test(sync): require canonical Playwright topology and exact filters --- tests/test_sync_core_runner_playwright.py | 43 ++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 8c4fdc5256..138778dfa5 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -2,6 +2,7 @@ import json import os +import re import shutil import subprocess import sys @@ -826,12 +827,14 @@ def test_playwright_execution_uses_process_group_supervisor( ) -> None: root, commit = _repository(tmp_path) calls: list[list[str]] = [] + readable_bindings = [] scratch_bindings = [] temp_directories = [] phase_roots = [] def supervised(command, **_kwargs): calls.append(command) + readable_bindings.append(_kwargs["readable_bindings"]) scratch_bindings.append(_kwargs["writable_bindings"]) temp_directories.append(_kwargs["temp_directory"]) phase_roots.append(_kwargs["cwd"]) @@ -848,7 +851,45 @@ def supervised(command, **_kwargs): assert scratch_bindings[0][0][0].name == "tmp" assert scratch_bindings[0][0][0].parent.name == "scratch" assert temp_directories[0] == Path("/tmp") - assert calls[0][3] == str(phase_roots[0] / "tests/widget.spec.ts") + exact_path = str(phase_roots[0] / "tests/widget.spec.ts") + assert calls[0][1] == str( + phase_roots[0] / "node_modules/@playwright/test/cli.py" + ) + assert calls[0][3] == f"^{re.escape(exact_path)}$" + dependency_source, dependency_destination = readable_bindings[0][-1] + assert dependency_source.name == "node_modules" + assert dependency_destination == phase_roots[0] / "node_modules" + + +def test_playwright_exact_filter_escapes_regex_metacharacters( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Select one literal test path even when its name contains regex syntax.""" + root, commit = _repository(tmp_path) + original = root / "tests/widget.spec.ts" + selected = root / "tests/widget[1]+.spec.ts" + original.rename(selected) + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", "regex metacharacter test path") + commit = _git(root, "rev-parse", "HEAD") + calls: list[list[str]] = [] + + def supervised(command, **kwargs): + calls.append(command) + _write_framework_observation(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return subprocess.CompletedProcess(command, 0, "", ""), False + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + execution, _identities = runner_module._run_playwright_in_tree( + root, (PurePosixPath("tests/widget[1]+.spec.ts"),), 2, + _trusted_playwright_config(tmp_path / "trusted", _fake_playwright(tmp_path)), + expected_commit=commit, + ) + + assert execution.outcome is EvidenceOutcome.PASS + assert calls[0][3] == f"^{re.escape(str(selected))}$" def test_playwright_rejects_candidate_node_modules_directory_before_execution( From 30fb6fb4774583b711c25897ced73083fed1a056 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 10:46:40 -0700 Subject: [PATCH 143/233] fix(sync): canonicalize protected Playwright package topology --- pdd/sync_core/runner.py | 24 +++++++++++++++++------ tests/test_sync_core_runner_playwright.py | 2 +- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index a8af29a1ba..86faddef42 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -214,8 +214,6 @@ def readable_roots(self) -> tuple[Path, ...]: """Return complete non-native roots mounted at their host paths.""" return ( self.launcher, - self.entrypoint, - self.dependencies, self.browser_runtime, self.lockfile, ) @@ -5111,13 +5109,19 @@ def _run_playwright_in_tree( root, commit, paths, code_under_test_paths ) tree_identity = _playwright_execution_tree_identity(root) + mountpoint_identity = _create_playwright_node_modules_mountpoint(root) + dependency_destination = mountpoint_identity[0] + canonical_entrypoint = dependency_destination / roles.entrypoint.relative_to( + roles.dependencies + ) except ValueError as exc: return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) ), () command = [ - *_playwright_runtime_prefix(prefix, roles.launcher), - "test", *(str(root / path) for path in paths), + _playwright_runtime_prefix(prefix, roles.launcher)[0], + str(canonical_entrypoint), + "test", *(f"^{re.escape(str(root / path))}$" for path in paths), f"--config={root / config_path}", f"--reporter={reporter}", "--update-snapshots=none", f"--output={scratch / 'results'}", ] @@ -5132,14 +5136,16 @@ def _run_playwright_in_tree( timeout=timeout_seconds, env=_playwright_environment( home, - roles.dependencies, + dependency_destination, roles.browser_runtime, ), writable_roots=(scratch,), writable_bindings=((sandbox_tmp, Path("/tmp")),), temp_directory=Path("/tmp"), readable_roots=(reporter, *roles.readable_roots), - readable_bindings=native_bindings, + readable_bindings=( + *native_bindings, (roles.dependencies, dependency_destination), + ), limits=PLAYWRIGHT_SUPERVISOR_LIMITS, result_fifo=result_fifo, result_fd=result_fd, @@ -5164,6 +5170,12 @@ def _run_playwright_in_tree( ), () finally: os.close(read_fd) + try: + _remove_playwright_node_modules_mountpoint(mountpoint_identity) + except ValueError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, digest, str(exc) + ), () if result.returncode == 124: return RunnerExecution( "playwright", EvidenceOutcome.TIMEOUT, digest, diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 138778dfa5..f3703257c8 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1237,7 +1237,7 @@ def supervised(command, *, cwd, writable_roots, writable_bindings, **kwargs): assert len(phase_roots) == 3 assert len(readable_roots) == len(phase_roots) assert len(readable_bindings) == len(phase_roots) - assert all(len(roots) == 6 for roots in readable_roots) + assert all(len(roots) == 4 for roots in readable_roots) assert all(len(bindings) == 2 for bindings in readable_bindings) sandbox_tmp = Path("/tmp").resolve() for path in (*phase_roots, *scratch_roots, *fifo_roots): From d5d2a6c3abe80261410222059da3c68ab6e01b63 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 12:21:51 -0700 Subject: [PATCH 144/233] test(sync): require bounded Playwright renderer address space --- tests/test_sync_core_runner_playwright.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index f3703257c8..38c25c65b2 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -862,7 +862,7 @@ def supervised(command, **_kwargs): def test_playwright_exact_filter_escapes_regex_metacharacters( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Select one literal test path even when its name contains regex syntax.""" root, commit = _repository(tmp_path) @@ -884,7 +884,7 @@ def supervised(command, **kwargs): monkeypatch.setattr(runner_module, "run_supervised", supervised) execution, _identities = runner_module._run_playwright_in_tree( root, (PurePosixPath("tests/widget[1]+.spec.ts"),), 2, - _trusted_playwright_config(tmp_path / "trusted", _fake_playwright(tmp_path)), + _trusted_playwright_config(trusted_toolchain_dir, _fake_playwright(tmp_path)), expected_commit=commit, ) @@ -3297,7 +3297,7 @@ def supervised(command, **kwargs): assert len(executions[0].detail) < 2500 -def test_playwright_uses_two_gib_physical_and_64_gib_virtual_limits( +def test_playwright_uses_two_gib_physical_and_256_gib_virtual_limits( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Chromium receives address space without relaxing aggregate physical memory.""" @@ -3318,7 +3318,7 @@ def supervised(command, **kwargs): assert len(observed) == 3 assert all(kwargs["limits"] == PLAYWRIGHT_SUPERVISOR_LIMITS for kwargs in observed) assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_memory_bytes == 2 * 1024 * 1024 * 1024 - assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_virtual_memory_bytes == 64 * 1024 * 1024 * 1024 + assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_virtual_memory_bytes == 256 * 1024 * 1024 * 1024 assert PLAYWRIGHT_SUPERVISOR_LIMITS.max_processes == 128 assert all("private_overlays" not in kwargs for kwargs in observed) assert all("readable_data" not in kwargs for kwargs in observed) From a47c16e3ac97d32ec3991755c397e31e5e5c57c2 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 12:22:32 -0700 Subject: [PATCH 145/233] fix(sync): budget protected Playwright renderer address space --- pdd/sync_core/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 86faddef42..9b1f51cfb0 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -194,7 +194,7 @@ class VitestPhaseToolchain: } PLAYWRIGHT_SUPERVISOR_LIMITS = SupervisorLimits( max_memory_bytes=2 * 1024 * 1024 * 1024, - max_virtual_memory_bytes=64 * 1024 * 1024 * 1024, + max_virtual_memory_bytes=256 * 1024 * 1024 * 1024, ) From 226b00ac98da331c5e44109faf9750682f415076 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 14:29:50 -0700 Subject: [PATCH 146/233] test(ci): require full unit job timeout budget --- tests/test_unit_tests_workflow.py | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/test_unit_tests_workflow.py diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py new file mode 100644 index 0000000000..479fa3f63c --- /dev/null +++ b/tests/test_unit_tests_workflow.py @@ -0,0 +1,37 @@ +"""Structural contracts for the Unit Tests GitHub Actions workflow.""" + +from __future__ import annotations + +import math +from pathlib import Path + +import yaml + + +WORKFLOW_PATH = Path(".github/workflows/unit-tests.yml") +SETUP_AND_FOCUSED_SECONDS = 16 * 60 + 37 +BROAD_SUITE_SECONDS = 30 * 60 +FULL_JOB_SECONDS = SETUP_AND_FOCUSED_SECONDS + BROAD_SUITE_SECONDS +HEADROOM_FRACTION = 0.50 +REQUIRED_TIMEOUT_MINUTES = math.ceil( + FULL_JOB_SECONDS * (1 + HEADROOM_FRACTION) / 60 +) + + +def test_unit_tests_timeout_covers_documented_full_job_budget() -> None: + """The broad-suite margin must account for required prior job work too.""" + workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") + workflow = yaml.safe_load(workflow_text) + timeout_minutes = workflow["jobs"]["unit-tests"]["timeout-minutes"] + + assert isinstance(timeout_minutes, int) + assert timeout_minutes > 0 + assert timeout_minutes >= REQUIRED_TIMEOUT_MINUTES, ( + "Unit Tests timeout must cover the 16m37s setup/protected/browser budget " + "plus the ~30 minute broad suite with 50% headroom " + f"({REQUIRED_TIMEOUT_MINUTES} minutes minimum)." + ) + assert "16m37s" in workflow_text + assert "~30 minutes" in workflow_text + assert "46m37s" in workflow_text + assert "50% headroom" in workflow_text From fd99236ea11e448bfeefda8da5cbd30bc17e1a35 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 14:30:24 -0700 Subject: [PATCH 147/233] fix(ci): budget full unit test job duration --- .github/workflows/unit-tests.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a8fe93796d..462348c20c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -29,8 +29,9 @@ jobs: if: github.event.pull_request.draft != true name: Run Unit Tests runs-on: ubuntu-latest - # The measured green broad suite takes ~30 minutes; retain bounded 50% headroom. - timeout-minutes: 45 + # 16m37s setup/protected/browser + ~30 minutes broad suite = 46m37s; + # 50% headroom needs 69m55.5s, rounded up to a finite 70 minutes. + timeout-minutes: 70 env: PDD_PATH: ${{ github.workspace }}/pdd From f8b3a6f397587196f55d3191ec1d330a123e4036 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 14:48:07 -0700 Subject: [PATCH 148/233] test(sync): cover bound synthetic Playwright entrypoint --- tests/test_sync_core_runner_playwright.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 38c25c65b2..4f80ce152d 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -1719,6 +1719,16 @@ def _run( ) +def test_synthetic_playwright_supervisor_reads_dependency_bound_entrypoint( + tmp_path: Path, +) -> None: + """Use the host fake runner after production relocates it into a mount.""" + root, commit = _repository(tmp_path) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert executions[0].outcome is EvidenceOutcome.PASS + + @pytest.mark.parametrize( ("candidate_mode", "expected"), [("candidate-pass", EvidenceOutcome.PASS), ("fail", EvidenceOutcome.FAIL)], From 0e30ed317de55f100d4e469d06a850250c0a8265 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 15:03:42 -0700 Subject: [PATCH 149/233] fix(sync): resolve synthetic Playwright entrypoint bindings --- tests/test_sync_core_runner_playwright.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 4f80ce152d..918b8e7746 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -85,6 +85,12 @@ def _simulate_framework_observation_for_synthetic_playwright( def supervised(command, **kwargs): entrypoint = Path(command[1]) if len(command) > 1 else Path() + for source_root, destination_root in kwargs.get("readable_bindings", ()): + try: + entrypoint = source_root / entrypoint.relative_to(destination_root) + break + except ValueError: + continue try: source = entrypoint.read_text(encoding="utf-8") synthetic = ( @@ -95,6 +101,8 @@ def supervised(command, **kwargs): synthetic = False if not synthetic: return original(command, **kwargs) + synthetic_command = [*command] + synthetic_command[1] = str(entrypoint) result_fd = kwargs["result_fd"] writer = os.open(kwargs["result_fifo"], os.O_WRONLY) try: @@ -105,7 +113,7 @@ def supervised(command, **kwargs): os.dup2(writer, result_fd) try: result = subprocess.run( - command, cwd=kwargs["cwd"], env=kwargs["env"], text=True, + synthetic_command, cwd=kwargs["cwd"], env=kwargs["env"], text=True, capture_output=True, timeout=kwargs["timeout"], pass_fds=(result_fd,), check=False, ) From ee90f2d059db1d00b302ff30977a9d66e66b98b5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 15:17:33 -0700 Subject: [PATCH 150/233] test(sync): require unit workflow ownership preauthorization --- tests/test_sync_core_pdd_rollout_policy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_sync_core_pdd_rollout_policy.py b/tests/test_sync_core_pdd_rollout_policy.py index e953ae5e07..3689fbd64a 100644 --- a/tests/test_sync_core_pdd_rollout_policy.py +++ b/tests/test_sync_core_pdd_rollout_policy.py @@ -59,6 +59,7 @@ "tests/test_sync_core_runner_jest.py", "tests/test_sync_core_runner_vitest.py", "tests/test_sync_core_runner_playwright.py", + "tests/test_unit_tests_workflow.py", "tests/test_cloud_global_dry_run.py", "tests/test_continuous_sync_path_policy.py", "pdd/sync_core/human_attestation.py", From 0baa97e6395b4d9357137cd5612d51f8c4172836 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 15:18:20 -0700 Subject: [PATCH 151/233] test(sync): cover declared loader mount precedence --- tests/test_sync_core_supervisor.py | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 763a3209ba..5677d02fac 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -247,6 +247,44 @@ def test_linux_sandbox_deduplicates_identical_read_only_bindings( assert bwrap.count(str(native)) == 1 +def test_linux_sandbox_prefers_declared_copied_loader_over_inferred_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A copied native toolchain member owns its declared loader destination.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + host_loader = tmp_path / "ld-linux-x86-64.so.2" + host_loader.write_bytes(b"native-loader") + copied_loader = tmp_path / "copied-loader" + copied_loader.write_bytes(host_loader.read_bytes()) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) + ) + + argv, _profile = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + ) + + bwrap = json.loads(argv[-4]) + sources = json.loads(argv[-3]) + destination_index = bwrap.index(str(host_loader)) + assert bwrap.count(str(host_loader)) == 1 + assert sources[json.loads(argv[-5]).index(bwrap[destination_index - 1])] == str( + copied_loader.resolve() + ) + + def test_linux_sandbox_rejects_conflicting_bindings( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 9dcb98bb6db21472267c97e19ad973effa2f252a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 15:19:37 -0700 Subject: [PATCH 152/233] fix(sync): prioritize declared native runtime mounts --- .pdd/sync-ownership.json | 7 +++++++ pdd/sync_core/supervisor.py | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.pdd/sync-ownership.json b/.pdd/sync-ownership.json index 8010ee6c36..4cfc227c34 100644 --- a/.pdd/sync-ownership.json +++ b/.pdd/sync-ownership.json @@ -13243,6 +13243,13 @@ "preauthorize_absent": true, "role": "human-maintained" }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": "tests/test_unit_tests_workflow.py", + "preauthorize_absent": true, + "role": "human-maintained" + }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 3cbf4e78d3..3d56ffffd1 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1019,9 +1019,21 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: for source, destination in writable_bindings: bind("--bind", source.resolve(), destination) + # Toolchain members copied into the phase must retain their declared + # loader spellings. Install and validate those explicit mappings + # before adding the inferred process/runtime closure. An inferred + # root at the same destination is redundant; distinct *declared* + # sources still go through bind() and fail closed above. + declared_readable_destinations = { + destination for _source, destination in readable_bindings + } + for source, destination in readable_bindings: + bind("--ro-bind", source.resolve(), destination) for item in _runtime_roots(command, workdir): # A host bind follows symlinks, but the process command and ELF # loader retain their original spellings in the new namespace. + if item in declared_readable_destinations: + continue bind("--ro-bind", item.resolve(), item) # The helper replaces this placeholder with only its systemd scope. argv.extend(("--ro-bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) @@ -1032,8 +1044,6 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: bind("--ro-bind", item.resolve(), item) for item in readable_roots: bind("--ro-bind", item.resolve()) - for source, destination in readable_bindings: - bind("--ro-bind", source.resolve(), destination) if result_fifo is not None: observation_source = result_fifo.resolve(strict=True) if not stat.S_ISFIFO(observation_source.lstat().st_mode): From 9f4ce539063aa19af8624aceb242227fa183314e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 20:00:53 -0700 Subject: [PATCH 153/233] test(sync): reject unproven copied runtime binding --- tests/test_sync_core_supervisor.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 5677d02fac..0f585cb5ea 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -247,10 +247,10 @@ def test_linux_sandbox_deduplicates_identical_read_only_bindings( assert bwrap.count(str(native)) == 1 -def test_linux_sandbox_prefers_declared_copied_loader_over_inferred_runtime( +def test_linux_sandbox_rejects_declared_copied_loader_at_inferred_runtime_without_proof( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A copied native toolchain member owns its declared loader destination.""" + """A distinct declared source cannot replace an inferred runtime source.""" monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) @@ -270,19 +270,12 @@ def test_linux_sandbox_prefers_declared_copied_loader_over_inferred_runtime( "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) ) - argv, _profile = _sandbox_command( - ["/bin/true"], - (tmp_path,), - readable_bindings=((copied_loader, host_loader),), - ) - - bwrap = json.loads(argv[-4]) - sources = json.loads(argv[-3]) - destination_index = bwrap.index(str(host_loader)) - assert bwrap.count(str(host_loader)) == 1 - assert sources[json.loads(argv[-5]).index(bwrap[destination_index - 1])] == str( - copied_loader.resolve() - ) + with pytest.raises(RuntimeError, match="conflicting bindings"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + ) def test_linux_sandbox_rejects_conflicting_bindings( From adad2e3a40ea0e3187d83f682fade89d4a9969e0 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 20:08:05 -0700 Subject: [PATCH 154/233] fix(sync): prove copied runtime binding identity --- pdd/sync_core/runner.py | 39 +++++++++- pdd/sync_core/supervisor.py | 105 +++++++++++++++++++++++--- tests/test_sync_core_runner_vitest.py | 60 ++++++++++++++- tests/test_sync_core_supervisor.py | 91 ++++++++++++++++++++++ 4 files changed, 283 insertions(+), 12 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 9b1f51cfb0..b633874dc5 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -49,7 +49,12 @@ VerificationObligation, VerificationProfile, ) -from .supervisor import SupervisorLimits, released_runtime_closure_paths, run_supervised +from .supervisor import ( + ImmutableBindingProof, + SupervisorLimits, + released_runtime_closure_paths, + run_supervised, +) TRUSTED_RUNNER_VERSION = "pdd-trusted-runner-v2" @@ -174,6 +179,7 @@ class VitestPhaseToolchain: native_runtime: tuple[Path, ...] readable_roots: tuple[Path, ...] readable_bindings: tuple[tuple[Path, Path], ...] + immutable_binding_proofs: tuple[ImmutableBindingProof, ...] dependencies: Path controller: Path descriptor: VitestToolchainDescriptor @@ -2956,6 +2962,29 @@ def _vitest_role_members( return tuple(member for member in descriptor.members if member.role == role) +def _vitest_immutable_binding_proofs( + native_runtime: tuple[Path, ...], descriptor: VitestToolchainDescriptor, +) -> tuple[ImmutableBindingProof, ...]: + """Bind copied native files to their captured descriptor identities.""" + members = _vitest_role_members(descriptor, "native_runtime") + if len(native_runtime) != len(members): + raise ValueError("Vitest copied native runtime proof is incomplete") + proofs = [] + for copied, protected, member in zip( + native_runtime, descriptor.native_runtime, members, strict=True + ): + if member.kind != "file" or member.content_digest is None: + raise ValueError("Vitest native runtime descriptor member is malformed") + proofs.append(ImmutableBindingProof( + copied_source=copied, + protected_source=protected, + descriptor_identity=descriptor.identity, + member_digest=member.content_digest, + member_mode=member.mode, + )) + return tuple(proofs) + + def _assert_vitest_members( actual: tuple[VitestToolchainMember, ...], expected: tuple[VitestToolchainMember, ...], @@ -3027,6 +3056,10 @@ def _verify_vitest_phase_toolchain(phase: VitestPhaseToolchain) -> None: _vitest_role_members(descriptor, "native_runtime"), "copied native runtime", ) + if phase.immutable_binding_proofs != _vitest_immutable_binding_proofs( + phase.native_runtime, descriptor + ): + raise ValueError("Vitest copied native runtime proof mismatch") expected_controller = { PurePosixPath("launcher"), PurePosixPath("lockfile"), PurePosixPath("native") } | { @@ -3080,6 +3113,9 @@ def _prepare_vitest_toolchain( native_runtime=tuple(native_runtime), readable_roots=(), readable_bindings=tuple(zip(native_runtime, descriptor.native_runtime)), + immutable_binding_proofs=_vitest_immutable_binding_proofs( + tuple(native_runtime), descriptor + ), dependencies=destination, controller=controller, descriptor=descriptor, @@ -4231,6 +4267,7 @@ def _run_vitest( writable_roots=(scratch, *cache_roots), readable_roots=(reporter, *phase_toolchain.readable_roots), readable_bindings=phase_toolchain.readable_bindings, + immutable_binding_proofs=phase_toolchain.immutable_binding_proofs, result_fifo=result_fifo, result_fd=result_fd, ) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 3d56ffffd1..4dfc17573e 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import hashlib import math import os import re @@ -88,6 +89,17 @@ class SupervisorLimits: max_processes: int = 128 +@dataclass(frozen=True) +class ImmutableBindingProof: + """Descriptor-bound identity authorizing one copied runtime replacement.""" + + copied_source: Path + protected_source: Path + descriptor_identity: str + member_digest: str + member_mode: int + + @dataclass(frozen=True) class _TrustedTools: """Exact privileged executable identities used by one protected run.""" @@ -122,6 +134,30 @@ class _CandidateRecord: timed_out: bool +def _immutable_member_matches( + path: Path, *, digest: str, mode: int, +) -> bool: + """Return whether one no-follow regular file matches a descriptor member.""" + if not re.fullmatch(r"[0-9a-f]{64}", digest) or not isinstance(mode, int): + return False + try: + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + except OSError: + return False + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != mode: + return False + actual = hashlib.sha256() + while chunk := os.read(descriptor, 1024 * 1024): + actual.update(chunk) + return actual.hexdigest() == digest + except OSError: + return False + finally: + os.close(descriptor) + + def _scope_unit_name() -> str: """Return an unguessable unit name reserved to one supervisor invocation.""" return f"pdd-validator-{uuid.uuid4().hex}.scope" @@ -924,6 +960,7 @@ def _sandbox_command( candidate_timeout: float = 300, readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), + immutable_binding_proofs: tuple[ImmutableBindingProof, ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), result_fifo: Path | None = None, result_fd: int = 198, @@ -977,6 +1014,28 @@ def _sandbox_command( writable_specs: list[tuple[str, int, str]] = [] destination_dirs = {Path("/tmp")} mounted: dict[Path, tuple[str, Path]] = {} + proofs = {} + for proof in immutable_binding_proofs: + if ( + not isinstance(proof, ImmutableBindingProof) + or re.fullmatch(r"[0-9a-f]{64}", proof.descriptor_identity) is None + ): + raise RuntimeError("protected sandbox immutable binding proof is malformed") + try: + key = ( + proof.copied_source.resolve(strict=True), + proof.protected_source.resolve(strict=True), + ) + except OSError as exc: + raise RuntimeError( + "protected sandbox immutable binding proof is incomplete" + ) from exc + if key in proofs: + raise RuntimeError("protected sandbox has duplicate immutable binding proofs") + proofs[key] = proof + if len(proofs) != len(immutable_binding_proofs): + raise RuntimeError("protected sandbox has duplicate immutable binding proofs") + accepted_proofs: set[ImmutableBindingProof] = set() def stage_source(source: Path, writable: bool = False) -> str: token = f"@PDD-PATH-{uuid.uuid4().hex}@" @@ -1008,6 +1067,24 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: if previous == binding: return if previous is not None and previous[1] != binding[1]: + proof = proofs.get((previous[1], binding[1])) + if ( + option == "--ro-bind" + and previous[0] == "--ro-bind" + and proof is not None + and _immutable_member_matches( + proof.copied_source, + digest=proof.member_digest, + mode=proof.member_mode, + ) + and _immutable_member_matches( + proof.protected_source, + digest=proof.member_digest, + mode=proof.member_mode, + ) + ): + accepted_proofs.add(proof) + return raise RuntimeError( f"protected sandbox has conflicting bindings for {destination}" ) @@ -1019,21 +1096,11 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: for source, destination in writable_bindings: bind("--bind", source.resolve(), destination) - # Toolchain members copied into the phase must retain their declared - # loader spellings. Install and validate those explicit mappings - # before adding the inferred process/runtime closure. An inferred - # root at the same destination is redundant; distinct *declared* - # sources still go through bind() and fail closed above. - declared_readable_destinations = { - destination for _source, destination in readable_bindings - } for source, destination in readable_bindings: bind("--ro-bind", source.resolve(), destination) for item in _runtime_roots(command, workdir): # A host bind follows symlinks, but the process command and ELF # loader retain their original spellings in the new namespace. - if item in declared_readable_destinations: - continue bind("--ro-bind", item.resolve(), item) # The helper replaces this placeholder with only its systemd scope. argv.extend(("--ro-bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) @@ -1075,6 +1142,22 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: sandboxed, result_fd, _FRAMEWORK_OBSERVATION_PATH ) argv.extend(("--", *drop, *sandboxed)) + for proof in accepted_proofs: + if not ( + _immutable_member_matches( + proof.copied_source, + digest=proof.member_digest, + mode=proof.member_mode, + ) + and _immutable_member_matches( + proof.protected_source, + digest=proof.member_digest, + mode=proof.member_mode, + ) + ): + raise RuntimeError( + "protected sandbox immutable binding proof changed during assembly" + ) return _staged_bwrap( argv, sources, path_tokens, writable_roots=storage_roots, writable_specs=writable_specs, @@ -1093,6 +1176,7 @@ def run_supervised( limits: SupervisorLimits = SupervisorLimits(), readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), + immutable_binding_proofs: tuple[ImmutableBindingProof, ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), temp_directory: Path | None = None, result_fifo: Path | None = None, @@ -1180,6 +1264,7 @@ def record_events() -> None: command, writable_roots, cwd=cwd, limits=limits, readable_roots=readable_roots, readable_bindings=readable_bindings, + immutable_binding_proofs=immutable_binding_proofs, writable_bindings=writable_bindings, result_fifo=result_fifo, result_fd=result_fd, candidate_timeout=timeout, diff --git a/tests/test_sync_core_runner_vitest.py b/tests/test_sync_core_runner_vitest.py index 2ac3cbc2e3..62bf93e4ba 100644 --- a/tests/test_sync_core_runner_vitest.py +++ b/tests/test_sync_core_runner_vitest.py @@ -743,6 +743,59 @@ def test_vitest_toolchain_entrypoint_is_copied_into_phase_tree( assert (phase_root / "node_modules/.vite").is_dir() +def test_vitest_phase_native_runtime_proof_is_bound_to_descriptor( + tmp_path: Path, +) -> None: + runner = _fake_vitest(tmp_path) + config = _runner_config(tmp_path, runner) + descriptor = _load_vitest_toolchain_descriptor(tmp_path / "repo", config) + phase_root = tmp_path / "phase" + phase_root.mkdir() + + phase = _prepare_vitest_toolchain(phase_root, descriptor) + + member = next( + item for item in descriptor.members if item.role == "native_runtime" + ) + proof = phase.immutable_binding_proofs[0] + assert proof.copied_source == phase.native_runtime[0] + assert proof.protected_source == descriptor.native_runtime[0] + assert proof.descriptor_identity == descriptor.identity + assert proof.member_digest == member.content_digest + assert proof.member_mode == member.mode + + +def test_vitest_rejects_phase_with_mismatched_native_runtime_proof( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + root, _commit = _repository(tmp_path) + config = _runner_config(tmp_path, _fake_vitest(tmp_path)) + descriptor = _load_vitest_toolchain_descriptor(root, config) + phase = _prepare_vitest_toolchain(root, descriptor) + phase = replace( + phase, + immutable_binding_proofs=(replace( + phase.immutable_binding_proofs[0], descriptor_identity="0" * 64, + ),), + ) + monkeypatch.setattr( + "pdd.sync_core.runner.run_supervised", + lambda *_args, **_kwargs: pytest.fail("mismatched proof reached execution"), + ) + + execution, identities = _run_vitest( + root, + (PurePosixPath("tests/widget.test.ts"),), + 2, + config, + phase_toolchain=phase, + ) + + assert execution.outcome is EvidenceOutcome.ERROR + assert "proof mismatch" in execution.detail + assert not identities + + def test_vitest_phase_rejects_dependency_mutated_during_copy( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1849,9 +1902,11 @@ def test_vitest_linux_command_binds_wasm_guard(tmp_path: Path, monkeypatch: pyte root, _commit = _repository(tmp_path) config = _runner_config(tmp_path, _fake_vitest(tmp_path)) observed: list[list[str]] = [] + proofs = [] - def capture(command, *, result_fifo, result_fd, **_kwargs): + def capture(command, *, result_fifo, result_fd, **kwargs): observed.append(command) + proofs.append(kwargs["immutable_binding_proofs"]) writer = os.open(result_fifo, os.O_WRONLY) try: os.write( @@ -1870,6 +1925,9 @@ def capture(command, *, result_fifo, result_fd, **_kwargs): assert execution.outcome is EvidenceOutcome.PASS assert observed[0][1] == "--disable-wasm-trap-handler" + assert proofs[0][0].descriptor_identity == _load_vitest_toolchain_descriptor( + root, config + ).identity def test_mixed_adapter_identities_survive_manifest_removal_and_round_trip( diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 0f585cb5ea..600aa21382 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1,6 +1,7 @@ """Adversarial tests for complete protected subprocess supervision.""" import os +import hashlib import inspect import json import math @@ -16,6 +17,7 @@ from pdd.sync_core import supervisor from pdd.sync_core.supervisor import ( + ImmutableBindingProof, SupervisorLimits, _linked_libraries, _limited_command, @@ -278,6 +280,95 @@ def test_linux_sandbox_rejects_declared_copied_loader_at_inferred_runtime_withou ) +def test_linux_sandbox_allows_descriptor_proven_copied_loader_at_inferred_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An explicit protected descriptor identity authorizes one copied loader.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + host_loader = tmp_path / "ld-linux-x86-64.so.2" + host_loader.write_bytes(b"native-loader") + copied_loader = tmp_path / "copied-loader" + copied_loader.write_bytes(host_loader.read_bytes()) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) + ) + proof = ImmutableBindingProof( + copied_loader, + host_loader, + "a" * 64, + hashlib.sha256(host_loader.read_bytes()).hexdigest(), + 0o644, + ) + + argv, _profile = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + immutable_binding_proofs=(proof,), + ) + + bwrap = json.loads(argv[-4]) + sources = json.loads(argv[-3]) + destination_index = bwrap.index(str(host_loader)) + assert bwrap.count(str(host_loader)) == 1 + assert sources[json.loads(argv[-5]).index(bwrap[destination_index - 1])] == str( + copied_loader.resolve() + ) + + +@pytest.mark.parametrize("mutation", ["copied", "protected", "digest"]) +def test_linux_sandbox_rejects_tampered_descriptor_proof( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str, +) -> None: + """A copied loader must still match its descriptor identity at assembly.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + host_loader = tmp_path / "ld-linux-x86-64.so.2" + host_loader.write_bytes(b"native-loader") + copied_loader = tmp_path / "copied-loader" + copied_loader.write_bytes(host_loader.read_bytes()) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) + ) + digest = hashlib.sha256(host_loader.read_bytes()).hexdigest() + if mutation == "copied": + copied_loader.write_bytes(b"tampered-copy") + elif mutation == "protected": + host_loader.write_bytes(b"tampered-host") + else: + digest = "0" * 64 + proof = ImmutableBindingProof( + copied_loader, host_loader, "a" * 64, digest, 0o644, + ) + + with pytest.raises(RuntimeError, match="conflicting bindings"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + immutable_binding_proofs=(proof,), + ) + + def test_linux_sandbox_rejects_conflicting_bindings( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 6e2abc2802ada32fe363bee279902f3ce700b401 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 20:17:22 -0700 Subject: [PATCH 155/233] test(sync): recheck copied runtime proof before staging --- tests/test_sync_core_supervisor.py | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 600aa21382..f5cc7d3499 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -369,6 +369,56 @@ def test_linux_sandbox_rejects_tampered_descriptor_proof( ) +def test_linux_sandbox_revalidates_descriptor_proof_before_staging( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A mutation after collision authorization fails before source handoff.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + host_loader = tmp_path / "ld-linux-x86-64.so.2" + host_loader.write_bytes(b"native-loader") + copied_loader = tmp_path / "copied-loader" + copied_loader.write_bytes(host_loader.read_bytes()) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) + ) + proof = ImmutableBindingProof( + copied_loader, + host_loader, + "a" * 64, + hashlib.sha256(host_loader.read_bytes()).hexdigest(), + 0o644, + ) + original_matches = supervisor._immutable_member_matches + + def mutate_after_authorization(path: Path, *, digest: str, mode: int) -> bool: + matched = original_matches(path, digest=digest, mode=mode) + if path == host_loader: + copied_loader.write_bytes(b"tampered-after-authorization") + return matched + + monkeypatch.setattr( + supervisor, "_immutable_member_matches", mutate_after_authorization + ) + + with pytest.raises(RuntimeError, match="proof changed during assembly"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + immutable_binding_proofs=(proof,), + ) + + def test_linux_sandbox_rejects_conflicting_bindings( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From e8d619ee8fbedde5fc0849d5e9c64499809ce8b5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 20:32:07 -0700 Subject: [PATCH 156/233] test(sync): expose copied runtime proof authority gaps --- tests/test_sync_core_runner_vitest.py | 14 +- tests/test_sync_core_supervisor.py | 365 ++++++++++++++++++++++++-- 2 files changed, 348 insertions(+), 31 deletions(-) diff --git a/tests/test_sync_core_runner_vitest.py b/tests/test_sync_core_runner_vitest.py index 62bf93e4ba..800acf3e09 100644 --- a/tests/test_sync_core_runner_vitest.py +++ b/tests/test_sync_core_runner_vitest.py @@ -760,9 +760,19 @@ def test_vitest_phase_native_runtime_proof_is_bound_to_descriptor( proof = phase.immutable_binding_proofs[0] assert proof.copied_source == phase.native_runtime[0] assert proof.protected_source == descriptor.native_runtime[0] + assert proof.destination == descriptor.native_runtime[0] assert proof.descriptor_identity == descriptor.identity - assert proof.member_digest == member.content_digest - assert proof.member_mode == member.mode + assert proof.member_role == "native_runtime" + assert proof.member_path == "0" + assert proof.collision_category == "vitest_inferred_runtime" + attestation = json.loads(proof.descriptor_attestation) + attested_member = next( + item for item in attestation["members"] + if item["role"] == "native_runtime" and item["path"] == "0" + ) + assert attested_member["digest"] == member.content_digest + assert attested_member["mode"] == member.mode + assert set(attestation) == {"adapter", "launch_policy", "members", "schema"} def test_vitest_rejects_phase_with_mismatched_native_runtime_proof( diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index f5cc7d3499..797ff6f679 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -10,7 +10,9 @@ import subprocess import sys import time +from dataclasses import replace from pathlib import Path +from pathlib import PurePosixPath from types import SimpleNamespace import pytest @@ -48,6 +50,97 @@ def _mock_linux_tools( return tools +def _descriptor_runtime_proof( + copied: Path, protected: Path, *, destination: Path | None = None, +): + """Create one proof through the real validated-descriptor adapter path.""" + from pdd.sync_core.runner import ( # pylint: disable=import-outside-toplevel + VitestToolchainDescriptor, + VitestToolchainMember, + _vitest_immutable_binding_proofs, + _vitest_members_identity, + ) + + digest = hashlib.sha256(protected.read_bytes()).hexdigest() + members = tuple(sorted(( + VitestToolchainMember( + "dependencies", PurePosixPath("."), "directory", 0o755 + ), + VitestToolchainMember( + "entrypoint", PurePosixPath("."), "file", 0o644, digest + ), + VitestToolchainMember( + "launcher", PurePosixPath("."), "file", 0o644, digest + ), + VitestToolchainMember( + "lockfile", PurePosixPath("."), "file", 0o644, digest + ), + VitestToolchainMember( + "native_runtime", PurePosixPath("0"), "file", 0o644, digest + ), + ), key=lambda item: (item.role, item.relative_path.as_posix()))) + identity = hashlib.sha256(json.dumps({ + "members": _vitest_members_identity(members), + "launch_policy": {"linux_wasm_trap_handler_disabled": True}, + }, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + descriptor = VitestToolchainDescriptor( + protected.parent / "vitest-toolchain.json", + protected, + protected, + protected.parent, + (protected,), + protected, + identity, + "0" * 64, + members, + ) + proof = _vitest_immutable_binding_proofs((copied,), descriptor)[0] + if destination is not None: + proof = replace(proof, destination=destination) + return proof + + +def _proof_with_native_member_field(proof, field: str, value): + """Return a proof whose canonical descriptor member has one altered field.""" + payload = json.loads(proof.descriptor_attestation) + member = next( + item for item in payload["members"] + if item["role"] == "native_runtime" and item["path"] == "0" + ) + + +def _mock_runtime_collision( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> tuple[Path, Path]: + """Install one synthetic inferred runtime collision for proof tests.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + protected = tmp_path / "ld-linux-x86-64.so.2" + protected.write_bytes(b"native-loader") + copied = tmp_path / "copied-loader" + copied.write_bytes(protected.read_bytes()) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (protected,) + ) + return protected, copied + member[field] = value + return replace( + proof, + descriptor_attestation=json.dumps( + payload, sort_keys=True, separators=(",", ":") + ), + ) + + def test_framework_observation_wrapper_opens_portable_fifo_path( tmp_path: Path, ) -> None: @@ -302,13 +395,9 @@ def test_linux_sandbox_allows_descriptor_proven_copied_loader_at_inferred_runtim monkeypatch.setattr( "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) ) - proof = ImmutableBindingProof( - copied_loader, - host_loader, - "a" * 64, - hashlib.sha256(host_loader.read_bytes()).hexdigest(), - 0o644, - ) + proof = _descriptor_runtime_proof(copied_loader, host_loader) + + assert json.loads(proof.descriptor_attestation)["adapter"] == "vitest" argv, _profile = _sandbox_command( ["/bin/true"], @@ -326,7 +415,7 @@ def test_linux_sandbox_allows_descriptor_proven_copied_loader_at_inferred_runtim ) -@pytest.mark.parametrize("mutation", ["copied", "protected", "digest"]) +@pytest.mark.parametrize("mutation", ["copied", "protected", "identity"]) def test_linux_sandbox_rejects_tampered_descriptor_proof( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str, ) -> None: @@ -349,16 +438,13 @@ def test_linux_sandbox_rejects_tampered_descriptor_proof( monkeypatch.setattr( "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) ) - digest = hashlib.sha256(host_loader.read_bytes()).hexdigest() + proof = _descriptor_runtime_proof(copied_loader, host_loader) if mutation == "copied": copied_loader.write_bytes(b"tampered-copy") elif mutation == "protected": host_loader.write_bytes(b"tampered-host") else: - digest = "0" * 64 - proof = ImmutableBindingProof( - copied_loader, host_loader, "a" * 64, digest, 0o644, - ) + proof = replace(proof, descriptor_identity="a" * 64) with pytest.raises(RuntimeError, match="conflicting bindings"): _sandbox_command( @@ -369,10 +455,10 @@ def test_linux_sandbox_rejects_tampered_descriptor_proof( ) -def test_linux_sandbox_revalidates_descriptor_proof_before_staging( +def test_linux_sandbox_helper_rejects_replacement_after_command_construction( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A mutation after collision authorization fails before source handoff.""" + """The exact root-helper protocol rejects a post-construction replacement.""" monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) @@ -391,26 +477,67 @@ def test_linux_sandbox_revalidates_descriptor_proof_before_staging( monkeypatch.setattr( "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) ) - proof = ImmutableBindingProof( - copied_loader, - host_loader, - "a" * 64, - hashlib.sha256(host_loader.read_bytes()).hexdigest(), - 0o644, + proof = _descriptor_runtime_proof(copied_loader, host_loader) + + _argv, plan = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + immutable_binding_proofs=(proof,), ) - original_matches = supervisor._immutable_member_matches + copied_loader.unlink() + copied_loader.write_bytes(b"replaced-after-command-construction") + namespace = {} + exec( # pylint: disable=exec-used + "import hashlib,json,os,pathlib,stat\n" + + supervisor._IMMUTABLE_STAGING_SOURCE, + namespace, + ) + target = tmp_path / "root-owned-snapshot" + target.touch(mode=0o600) + + with pytest.raises(RuntimeError, match="immutable binding"): + namespace["_stage_immutable_snapshot"]( + plan.immutable_binding_proofs[0], target + ) + assert supervisor._IMMUTABLE_STAGING_SOURCE in plan.helper_source - def mutate_after_authorization(path: Path, *, digest: str, mode: int) -> bool: - matched = original_matches(path, digest=digest, mode=mode) - if path == host_loader: - copied_loader.write_bytes(b"tampered-after-authorization") - return matched +@pytest.mark.parametrize( + ("field", "value"), + [ + ("descriptor_identity", "a" * 64), + ("member_role", "launcher"), + ("member_path", "1"), + ("collision_category", "playwright_inferred_runtime"), + ], +) +def test_linux_sandbox_rejects_proof_authority_outside_exact_vitest_member( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, field: str, value: str, +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + host_loader = tmp_path / "ld-linux-x86-64.so.2" + host_loader.write_bytes(b"native-loader") + copied_loader = tmp_path / "copied-loader" + copied_loader.write_bytes(host_loader.read_bytes()) + proof = replace( + _descriptor_runtime_proof(copied_loader, host_loader), **{field: value} + ) monkeypatch.setattr( - supervisor, "_immutable_member_matches", mutate_after_authorization + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) ) - with pytest.raises(RuntimeError, match="proof changed during assembly"): + with pytest.raises(RuntimeError, match="immutable binding proof"): _sandbox_command( ["/bin/true"], (tmp_path,), @@ -419,6 +546,186 @@ def mutate_after_authorization(path: Path, *, digest: str, mode: int) -> bool: ) +def test_linux_sandbox_rejects_symlink_spelled_proof_destination( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + host_loader = tmp_path / "ld-linux-x86-64.so.2" + host_loader.write_bytes(b"native-loader") + alias = tmp_path / "loader-alias" + alias.symlink_to(host_loader) + copied_loader = tmp_path / "copied-loader" + copied_loader.write_bytes(host_loader.read_bytes()) + proof = _descriptor_runtime_proof( + copied_loader, host_loader, destination=alias + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (alias,) + ) + + with pytest.raises(RuntimeError, match="immutable binding proof|conflicting"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, alias),), + immutable_binding_proofs=(proof,), + ) + + +@pytest.mark.parametrize( + "mutation", + [ + "copied_path_type", + "missing_protected_path", + "descriptor_identity_type", + "descriptor_identity_subclass", + "attestation_type", + "digest_type", + "digest_spelling", + "mode_type", + "mode_bool", + "mode_range", + "member_absent", + "category_subclass", + ], +) +def test_linux_sandbox_normalizes_malformed_proof_rejection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str, +) -> None: + protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) + proof = _descriptor_runtime_proof(copied, protected) + + class Text(str): + """Authority-confusing string subclass.""" + + if mutation == "copied_path_type": + proof = replace(proof, copied_source=str(copied)) + elif mutation == "missing_protected_path": + proof = replace(proof, protected_source=tmp_path / "missing") + elif mutation == "descriptor_identity_type": + proof = replace(proof, descriptor_identity=7) + elif mutation == "descriptor_identity_subclass": + proof = replace(proof, descriptor_identity=Text(proof.descriptor_identity)) + elif mutation == "attestation_type": + proof = replace(proof, descriptor_attestation=b"{}") + elif mutation == "digest_type": + proof = _proof_with_native_member_field(proof, "digest", 7) + elif mutation == "digest_spelling": + proof = _proof_with_native_member_field(proof, "digest", "not-a-digest") + elif mutation == "mode_type": + proof = _proof_with_native_member_field(proof, "mode", "420") + elif mutation == "mode_bool": + proof = _proof_with_native_member_field(proof, "mode", True) + elif mutation == "mode_range": + proof = _proof_with_native_member_field(proof, "mode", 0o1000) + elif mutation == "member_absent": + payload = json.loads(proof.descriptor_attestation) + payload["members"] = [ + item for item in payload["members"] + if item["role"] != "native_runtime" + ] + proof = replace( + proof, + descriptor_attestation=json.dumps( + payload, sort_keys=True, separators=(",", ":") + ), + ) + else: + proof = replace( + proof, collision_category=Text("vitest_inferred_runtime") + ) + + with pytest.raises(RuntimeError, match="immutable binding proof"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=(proof,), + ) + + +def test_linux_sandbox_rejects_unused_immutable_binding_proof( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) + proof = _descriptor_runtime_proof(copied, protected) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: () + ) + + with pytest.raises(RuntimeError, match="unused immutable binding proof"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=(proof,), + ) + + +def test_linux_sandbox_rejects_duplicate_or_ambiguous_binding_proofs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) + proof = _descriptor_runtime_proof(copied, protected) + ambiguous = replace(proof, member_path="1") + + for proofs in ((proof, proof), (proof, ambiguous)): + with pytest.raises(RuntimeError, match="duplicate|ambiguous"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=proofs, + ) + + +def test_linux_sandbox_rejects_multiply_consumed_binding_proof( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) + proof = _descriptor_runtime_proof(copied, protected) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", + lambda *_args: (protected, protected), + ) + + with pytest.raises(RuntimeError, match="multiply consumed"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=(proof,), + ) + + +def test_linux_sandbox_rejects_proof_for_declared_binding_collision( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) + proof = _descriptor_runtime_proof(copied, protected) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: () + ) + + with pytest.raises(RuntimeError, match="conflicting bindings"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected), (protected, protected)), + immutable_binding_proofs=(proof,), + ) + + def test_linux_sandbox_rejects_conflicting_bindings( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 58fdeea9fd0557ab49fe72318c99f74dfc89627d Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 20:34:05 -0700 Subject: [PATCH 157/233] test(sync): bind proof to native runtime topology --- tests/test_sync_core_runner_vitest.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_vitest.py b/tests/test_sync_core_runner_vitest.py index 800acf3e09..8fa082b29f 100644 --- a/tests/test_sync_core_runner_vitest.py +++ b/tests/test_sync_core_runner_vitest.py @@ -772,7 +772,10 @@ def test_vitest_phase_native_runtime_proof_is_bound_to_descriptor( ) assert attested_member["digest"] == member.content_digest assert attested_member["mode"] == member.mode - assert set(attestation) == {"adapter", "launch_policy", "members", "schema"} + assert set(attestation) == { + "adapter", "launch_policy", "members", "native_runtime", "schema" + } + assert attestation["native_runtime"] == [str(descriptor.native_runtime[0])] def test_vitest_rejects_phase_with_mismatched_native_runtime_proof( From 3ba36efda86a931609421aa5b30cf54f027b58db Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 20:58:43 -0700 Subject: [PATCH 158/233] fix(sync): close immutable runtime staging authority gaps --- pdd/sync_core/runner.py | 44 +- pdd/sync_core/supervisor.py | 677 +++++++++++++++++++++++++---- tests/test_sync_core_supervisor.py | 6 +- 3 files changed, 620 insertions(+), 107 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index b633874dc5..661a2ffc04 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -52,6 +52,7 @@ from .supervisor import ( ImmutableBindingProof, SupervisorLimits, + _vitest_descriptor_attestation, released_runtime_closure_paths, run_supervised, ) @@ -2765,6 +2766,18 @@ def _vitest_members_identity( ).hexdigest() +def _vitest_member_payload(member: VitestToolchainMember) -> dict[str, object]: + """Return the canonical descriptor-attestation shape for one member.""" + return { + "role": member.role, + "path": member.relative_path.as_posix(), + "kind": member.kind, + "mode": member.mode, + "digest": member.content_digest, + "target": member.link_target, + } + + def _descriptor_vitest_members( launcher: Path, entrypoint: Path, @@ -2865,12 +2878,11 @@ def _load_vitest_toolchain_descriptor( member for member in members if member.role == "dependencies" ) dependencies_identity = _vitest_members_identity(dependency_members) - identity = hashlib.sha256(json.dumps({ - "members": _vitest_members_identity(members), - "launch_policy": { - "linux_wasm_trap_handler_disabled": sys.platform.startswith("linux"), - }, - }, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + _attestation, identity = _vitest_descriptor_attestation( + tuple(_vitest_member_payload(member) for member in members), + native_runtime, + linux_wasm_trap_handler_disabled=sys.platform.startswith("linux"), + ) if config.vitest_toolchain_identity not in {None, identity}: raise ValueError("Vitest toolchain changed across protocol execution") return VitestToolchainDescriptor( @@ -2969,18 +2981,28 @@ def _vitest_immutable_binding_proofs( members = _vitest_role_members(descriptor, "native_runtime") if len(native_runtime) != len(members): raise ValueError("Vitest copied native runtime proof is incomplete") + attestation, identity = _vitest_descriptor_attestation( + tuple(_vitest_member_payload(member) for member in descriptor.members), + descriptor.native_runtime, + linux_wasm_trap_handler_disabled=sys.platform.startswith("linux"), + ) + if identity != descriptor.identity: + raise ValueError("Vitest native runtime descriptor identity mismatch") proofs = [] - for copied, protected, member in zip( + for index, (copied, protected, member) in enumerate(zip( native_runtime, descriptor.native_runtime, members, strict=True - ): + )): if member.kind != "file" or member.content_digest is None: raise ValueError("Vitest native runtime descriptor member is malformed") proofs.append(ImmutableBindingProof( copied_source=copied, protected_source=protected, - descriptor_identity=descriptor.identity, - member_digest=member.content_digest, - member_mode=member.mode, + destination=protected, + descriptor_attestation=attestation, + descriptor_identity=identity, + member_role="native_runtime", + member_path=str(index), + collision_category="vitest_inferred_runtime", )) return tuple(proofs) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 4dfc17573e..8d987b10ce 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -19,7 +19,7 @@ import uuid from functools import lru_cache from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath import sysconfig @@ -33,6 +33,12 @@ _TRUSTED_POSTPROCESS_SECONDS = 5 _TRUSTED_COMMAND_SECONDS = 5 _TRUSTED_SETUP_SECONDS = 30 +_MAX_BINDING_ATTESTATION_BYTES = 4 * 1024 * 1024 +_VITEST_ATTESTATION_SCHEMA = "pdd-vitest-toolchain-attestation-v1" +_BINDING_RECORD_SCHEMA = "pdd-immutable-binding-record-v1" +_VITEST_MEMBER_ROLES = frozenset({ + "launcher", "entrypoint", "dependencies", "native_runtime", "lockfile", +}) _PIDFD_PROTOCOL_SOURCE = """ def _supervise_candidate(pid, timeout): @@ -90,12 +96,33 @@ class SupervisorLimits: @dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes class ImmutableBindingProof: """Descriptor-bound identity authorizing one copied runtime replacement.""" copied_source: Path protected_source: Path + destination: Path + descriptor_attestation: str descriptor_identity: str + member_role: str + member_path: str + collision_category: str + + +@dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes +class _ValidatedBindingProof: + """Canonical immutable binding authority ready for helper serialization.""" + + copied_source: Path + protected_source: Path + destination: Path + descriptor_attestation: str + descriptor_identity: str + member_role: str + member_path: str + collision_category: str member_digest: str member_mode: int @@ -114,6 +141,7 @@ class _TrustedTools: @dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes class _ScopePlan: """Immutable transient-scope construction and cleanup state.""" @@ -124,6 +152,7 @@ class _ScopePlan: sources: tuple[Path, ...] staging_targets: tuple[Path, ...] tools: _TrustedTools + immutable_binding_proofs: tuple[str, ...] = () @dataclass(frozen=True) @@ -134,30 +163,422 @@ class _CandidateRecord: timed_out: bool -def _immutable_member_matches( - path: Path, *, digest: str, mode: int, -) -> bool: - """Return whether one no-follow regular file matches a descriptor member.""" - if not re.fullmatch(r"[0-9a-f]{64}", digest) or not isinstance(mode, int): - return False +def _canonical_json(payload: object) -> str: + """Return the one accepted compact, key-sorted JSON spelling.""" + return json.dumps(payload, sort_keys=True, separators=(",", ":")) + + +def _validate_vitest_member_payload(member: object) -> dict[str, object]: + """Validate one exact typed member from a canonical Vitest descriptor.""" + # pylint: disable=unidiomatic-typecheck + if type(member) is not dict or set(member) != { + "role", "path", "kind", "mode", "digest", "target", + }: + raise ValueError("invalid member fields") + role = member["role"] + relative = member["path"] + kind = member["kind"] + mode = member["mode"] + digest = member["digest"] + target = member["target"] + if type(role) is not str or role not in _VITEST_MEMBER_ROLES: + raise ValueError("invalid member role") + if type(relative) is not str or not relative or len(relative) > 4096: + raise ValueError("invalid member path") + parsed = PurePosixPath(relative) + if ( + parsed.is_absolute() + or ".." in parsed.parts + or parsed.as_posix() != relative + ): + raise ValueError("non-canonical member path") + if type(kind) is not str or kind not in {"file", "directory", "symlink"}: + raise ValueError("invalid member kind") + if type(mode) is not int or not 0 <= mode <= 0o777: + raise ValueError("invalid member mode") + if kind == "file": + if ( + type(digest) is not str + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + or target is not None + ): + raise ValueError("invalid regular member identity") + elif kind == "directory": + if digest is not None or target is not None: + raise ValueError("invalid directory member identity") + elif ( + digest is not None + or type(target) is not str + or not target + or len(target) > 4096 + ): + raise ValueError("invalid symlink member identity") + return member + + +def _parse_vitest_descriptor_attestation( + encoded: str, +) -> tuple[dict[str, object], str]: + """Independently validate and identify one canonical Vitest attestation.""" + # pylint: disable=too-many-locals,too-many-branches,unidiomatic-typecheck + if ( + type(encoded) is not str + or not encoded + or len(encoded.encode("utf-8")) > _MAX_BINDING_ATTESTATION_BYTES + ): + raise ValueError("invalid attestation encoding") + payload = json.loads(encoded) + if type(payload) is not dict or set(payload) != { + "schema", "adapter", "members", "native_runtime", "launch_policy", + }: + raise ValueError("invalid attestation fields") + if ( + payload["schema"] != _VITEST_ATTESTATION_SCHEMA + or type(payload["schema"]) is not str + or payload["adapter"] != "vitest" + or type(payload["adapter"]) is not str + ): + raise ValueError("invalid attestation authority") + policy = payload["launch_policy"] + if ( + type(policy) is not dict + or set(policy) != {"linux_wasm_trap_handler_disabled"} + or type(policy["linux_wasm_trap_handler_disabled"]) is not bool + ): + raise ValueError("invalid attestation launch policy") + members = payload["members"] + if type(members) is not list or not members: + raise ValueError("invalid attestation members") + validated = [_validate_vitest_member_payload(member) for member in members] + keys = [(member["role"], member["path"]) for member in validated] + if keys != sorted(keys) or len(keys) != len(set(keys)): + raise ValueError("non-canonical attestation members") + if _VITEST_MEMBER_ROLES - {member["role"] for member in validated}: + raise ValueError("incomplete attestation roles") + native_members = [ + member for member in validated if member["role"] == "native_runtime" + ] + native_runtime = payload["native_runtime"] + if ( + type(native_runtime) is not list + or len(native_runtime) != len(native_members) + or not native_runtime + ): + raise ValueError("invalid native runtime topology") + path_type = type(Path()) + for index, (value, member) in enumerate(zip( + native_runtime, native_members, strict=True + )): + if ( + type(value) is not str + or not value + or len(value) > 4096 + or member["path"] != str(index) + ): + raise ValueError("invalid native runtime destination") + path = Path(value) + if type(path) is not path_type or not path.is_absolute(): + raise ValueError("invalid native runtime destination") + try: + metadata = path.lstat() + canonical = path.resolve(strict=True) + except OSError as exc: + raise ValueError("unresolvable native runtime destination") from exc + if ( + canonical != path + or not stat.S_ISREG(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + ): + raise ValueError("non-canonical native runtime destination") + if encoded != _canonical_json(payload): + raise ValueError("non-canonical attestation encoding") + members_identity = hashlib.sha256( + _canonical_json(validated).encode("utf-8") + ).hexdigest() + descriptor_identity = hashlib.sha256(_canonical_json({ + "members": members_identity, + "launch_policy": policy, + }).encode("utf-8")).hexdigest() + return payload, descriptor_identity + + +def _vitest_descriptor_attestation( + members: tuple[dict[str, object], ...], native_runtime: tuple[Path, ...], + *, linux_wasm_trap_handler_disabled: bool, +) -> tuple[str, str]: + """Build and independently reparse one canonical descriptor attestation.""" + payload = { + "schema": _VITEST_ATTESTATION_SCHEMA, + "adapter": "vitest", + "members": list(members), + "native_runtime": [str(path) for path in native_runtime], + "launch_policy": { + "linux_wasm_trap_handler_disabled": linux_wasm_trap_handler_disabled, + }, + } + encoded = _canonical_json(payload) + _payload, identity = _parse_vitest_descriptor_attestation(encoded) + return encoded, identity + + +def _regular_file_matches(path: Path, digest: str, mode: int) -> bool: + """Match a no-follow regular file to one canonical descriptor member.""" try: - descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + descriptor = os.open( + path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | os.O_CLOEXEC + ) except OSError: return False try: - metadata = os.fstat(descriptor) - if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != mode: + before = os.fstat(descriptor) + if ( + not stat.S_ISREG(before.st_mode) + or stat.S_IMODE(before.st_mode) != mode + ): return False actual = hashlib.sha256() while chunk := os.read(descriptor, 1024 * 1024): actual.update(chunk) - return actual.hexdigest() == digest + after = os.fstat(descriptor) + stable = ( + before.st_dev, before.st_ino, before.st_size, + before.st_mtime_ns, before.st_ctime_ns, + ) == ( + after.st_dev, after.st_ino, after.st_size, + after.st_mtime_ns, after.st_ctime_ns, + ) + return stable and actual.hexdigest() == digest except OSError: return False finally: os.close(descriptor) +def _validate_immutable_binding_proof( + proof: ImmutableBindingProof, +) -> _ValidatedBindingProof: + """Validate exact proof authority, topology, member, and live identities.""" + # pylint: disable=unidiomatic-typecheck + try: + if type(proof) is not ImmutableBindingProof: + raise ValueError("invalid proof type") + path_type = type(Path()) + if any(type(value) is not path_type for value in ( + proof.copied_source, proof.protected_source, proof.destination + )): + raise ValueError("invalid proof path type") + if any(type(value) is not str for value in ( + proof.descriptor_attestation, + proof.descriptor_identity, + proof.member_role, + proof.member_path, + proof.collision_category, + )): + raise ValueError("invalid proof scalar type") + if ( + re.fullmatch(r"[0-9a-f]{64}", proof.descriptor_identity) is None + or proof.member_role != "native_runtime" + or not proof.member_path.isdecimal() + or str(int(proof.member_path)) != proof.member_path + or proof.collision_category != "vitest_inferred_runtime" + ): + raise ValueError("invalid proof authority") + payload, descriptor_identity = _parse_vitest_descriptor_attestation( + proof.descriptor_attestation + ) + if descriptor_identity != proof.descriptor_identity: + raise ValueError("descriptor identity mismatch") + native_members = [ + member for member in payload["members"] + if member["role"] == "native_runtime" + ] + member_index = int(proof.member_path) + if member_index >= len(native_members): + raise ValueError("descriptor member is absent") + member = native_members[member_index] + if member["path"] != proof.member_path or member["kind"] != "file": + raise ValueError("descriptor member mismatch") + copied = proof.copied_source.resolve(strict=True) + protected = proof.protected_source.resolve(strict=True) + destination = proof.destination + if ( + copied != proof.copied_source + or protected != proof.protected_source + or destination != protected + or payload["native_runtime"][member_index] != str(protected) + ): + raise ValueError("proof destination topology mismatch") + for path in (copied, protected): + metadata = path.lstat() + if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise ValueError("proof source is not a regular file") + digest = member["digest"] + mode = member["mode"] + if not ( + _regular_file_matches(copied, digest, mode) + and _regular_file_matches(protected, digest, mode) + ): + raise ValueError("proof member identity mismatch") + return _ValidatedBindingProof( + copied, protected, destination, + proof.descriptor_attestation, descriptor_identity, + proof.member_role, proof.member_path, proof.collision_category, + digest, mode, + ) + except (AttributeError, json.JSONDecodeError, OSError, TypeError, ValueError) as exc: + raise RuntimeError("protected sandbox immutable binding proof is malformed") from exc + + +_IMMUTABLE_STAGING_SOURCE = r''' +def _immutable_failure(): + raise RuntimeError("protected sandbox immutable binding proof failed at staging") + +def _canonical_staging_json(value): + return json.dumps(value,sort_keys=True,separators=(",",":")) + +def _staging_member(member): + if type(member) is not dict or set(member)!={"role","path","kind","mode","digest","target"}: + _immutable_failure() + role=member["role"]; relative=member["path"]; kind=member["kind"] + mode=member["mode"]; digest=member["digest"]; target=member["target"] + if type(role) is not str or role not in {"launcher","entrypoint","dependencies","native_runtime","lockfile"}: + _immutable_failure() + if type(relative) is not str or not relative or len(relative)>4096: + _immutable_failure() + parsed=pathlib.PurePosixPath(relative) + if parsed.is_absolute() or ".." in parsed.parts or parsed.as_posix()!=relative: + _immutable_failure() + if type(kind) is not str or kind not in {"file","directory","symlink"}: + _immutable_failure() + if type(mode) is not int or not 0<=mode<=0o777: + _immutable_failure() + if kind=="file": + if type(digest) is not str or len(digest)!=64 or any(value not in "0123456789abcdef" for value in digest) or target is not None: + _immutable_failure() + elif kind=="directory": + if digest is not None or target is not None: _immutable_failure() + elif digest is not None or type(target) is not str or not target or len(target)>4096: + _immutable_failure() + return member + +def _staging_attestation(encoded,expected_identity): + if type(encoded) is not str or not encoded or len(encoded.encode("utf-8"))>4194304: + _immutable_failure() + try: payload=json.loads(encoded) + except (TypeError,ValueError): _immutable_failure() + if type(payload) is not dict or set(payload)!={"schema","adapter","members","native_runtime","launch_policy"}: + _immutable_failure() + if type(payload["schema"]) is not str or payload["schema"]!="pdd-vitest-toolchain-attestation-v1" or type(payload["adapter"]) is not str or payload["adapter"]!="vitest": + _immutable_failure() + policy=payload["launch_policy"] + if type(policy) is not dict or set(policy)!={"linux_wasm_trap_handler_disabled"} or type(policy["linux_wasm_trap_handler_disabled"]) is not bool: + _immutable_failure() + members=payload["members"] + if type(members) is not list or not members: _immutable_failure() + members=[_staging_member(member) for member in members] + keys=[(member["role"],member["path"]) for member in members] + if keys!=sorted(keys) or len(keys)!=len(set(keys)): + _immutable_failure() + if {"launcher","entrypoint","dependencies","native_runtime","lockfile"}-{member["role"] for member in members}: + _immutable_failure() + native=[member for member in members if member["role"]=="native_runtime"] + destinations=payload["native_runtime"] + if type(destinations) is not list or not destinations or len(destinations)!=len(native): + _immutable_failure() + for index,(value,member) in enumerate(zip(destinations,native,strict=True)): + if type(value) is not str or not value or len(value)>4096 or member["path"]!=str(index): + _immutable_failure() + if str(_canonical_staging_path(value))!=value: + _immutable_failure() + if encoded!=_canonical_staging_json(payload): _immutable_failure() + members_identity=hashlib.sha256(_canonical_staging_json(members).encode("utf-8")).hexdigest() + identity=hashlib.sha256(_canonical_staging_json({"members":members_identity,"launch_policy":policy}).encode("utf-8")).hexdigest() + if type(expected_identity) is not str or identity!=expected_identity: + _immutable_failure() + return payload,native + +def _canonical_staging_path(value): + if type(value) is not str or not value or len(value)>4096: + _immutable_failure() + path=pathlib.Path(value) + try: metadata=path.lstat(); canonical=path.resolve(strict=True) + except OSError: _immutable_failure() + if not path.is_absolute() or canonical!=path or not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + _immutable_failure() + return path + +def _verified_staging_fd(path,digest,mode): + try: descriptor=os.open(path,os.O_RDONLY|getattr(os,"O_NOFOLLOW",0)|os.O_CLOEXEC) + except OSError: _immutable_failure() + try: + before=os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or stat.S_IMODE(before.st_mode)!=mode: + _immutable_failure() + actual=hashlib.sha256() + while True: + chunk=os.read(descriptor,1048576) + if not chunk: break + actual.update(chunk) + after=os.fstat(descriptor) + before_identity=(before.st_dev,before.st_ino,before.st_size,before.st_mtime_ns,before.st_ctime_ns) + after_identity=(after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns) + if before_identity!=after_identity or actual.hexdigest()!=digest: + _immutable_failure() + os.lseek(descriptor,0,os.SEEK_SET) + return descriptor + except BaseException: + os.close(descriptor) + raise + +def _stage_immutable_snapshot(encoded,target): + if type(encoded) is not str or not encoded or len(encoded.encode("utf-8"))>8388608: + _immutable_failure() + try: record=json.loads(encoded) + except (TypeError,ValueError): _immutable_failure() + fields={"schema","source_index","copied_source","protected_source","destination","descriptor_attestation","descriptor_identity","member_role","member_path","collision_category"} + if type(record) is not dict or set(record)!=fields or encoded!=_canonical_staging_json(record): + _immutable_failure() + if type(record["schema"]) is not str or record["schema"]!="pdd-immutable-binding-record-v1": + _immutable_failure() + if type(record["source_index"]) is not int or record["source_index"]<0: + _immutable_failure() + for name in fields-{"source_index"}: + if type(record[name]) is not str: _immutable_failure() + if record["member_role"]!="native_runtime" or not record["member_path"].isdecimal() or str(int(record["member_path"]))!=record["member_path"] or record["collision_category"]!="vitest_inferred_runtime": + _immutable_failure() + payload,native=_staging_attestation(record["descriptor_attestation"],record["descriptor_identity"]) + index=int(record["member_path"]) + if index>=len(native): _immutable_failure() + member=native[index] + if member["path"]!=record["member_path"] or member["kind"]!="file": + _immutable_failure() + copied=_canonical_staging_path(record["copied_source"]) + protected=_canonical_staging_path(record["protected_source"]) + if record["destination"]!=str(protected) or payload["native_runtime"][index]!=str(protected): + _immutable_failure() + copied_fd=_verified_staging_fd(copied,member["digest"],member["mode"]) + protected_fd=_verified_staging_fd(protected,member["digest"],member["mode"]) + target_fd=None + try: + target_fd=os.open(target,os.O_WRONLY|os.O_TRUNC|getattr(os,"O_NOFOLLOW",0)|os.O_CLOEXEC) + actual=hashlib.sha256() + while True: + chunk=os.read(copied_fd,1048576) + if not chunk: break + actual.update(chunk); os.write(target_fd,chunk) + if actual.hexdigest()!=member["digest"]: _immutable_failure() + os.fchmod(target_fd,0o400); os.fsync(target_fd) + except OSError: + _immutable_failure() + finally: + if target_fd is not None: os.close(target_fd) + os.close(protected_fd); os.close(copied_fd) + snapshot_fd=_verified_staging_fd(target,member["digest"],0o400) + os.close(snapshot_fd) + return record["source_index"] +'''.strip() + + def _scope_unit_name() -> str: """Return an unguessable unit name reserved to one supervisor invocation.""" return f"pdd-validator-{uuid.uuid4().hex}.scope" @@ -352,26 +773,32 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: def _staged_bwrap( argv: list[str], sources: list[Path], path_tokens: list[str], *, writable_roots: tuple[Path, ...], writable_specs: list[tuple[str, int, str]], + immutable_binding_proofs: tuple[str, ...], unit_name: str, control_directory: Path, limits: SupervisorLimits, candidate_timeout: float, tools: _TrustedTools, ) -> tuple[list[str], _ScopePlan]: """Build one scope-held helper that releases Bubblewrap after verification.""" + # pylint: disable=too-many-locals unit_name = _validated_scope_unit(unit_name) - staging_targets = tuple( + staging_root = control_directory / "binds" + source_targets = tuple( control_directory / "binds" / str(index) for index in range(len(sources)) ) helper = "\n".join(( - "import json,math,os,pathlib,select,shutil,stat,subprocess,sys,time", + "import hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,time", + "if len(sys.argv)!=12: raise RuntimeError('invalid protected helper protocol')", "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", - "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[4])]", - "writable_specs=json.loads(sys.argv[5])", - "limits=json.loads(sys.argv[-1])", - "path_tokens=json.loads(sys.argv[-5]); argv=json.loads(sys.argv[-4]); " - "paths=json.loads(sys.argv[-3])", + "proof_records=json.loads(sys.argv[4])", + "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[5])]", + "writable_specs=json.loads(sys.argv[6])", + "path_tokens=json.loads(sys.argv[7]); " + "argv=json.loads(sys.argv[8]); paths=json.loads(sys.argv[9])", + "limits=json.loads(sys.argv[11])", "targets=[control/'binds'/str(index) for index in range(len(paths))]", "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None", "scope_cgroup=None; monitor_cgroup=None; candidate_cgroup=None", _PIDFD_PROTOCOL_SOURCE.strip(), + _IMMUTABLE_STAGING_SOURCE, "def wait_for(name):", " path=control/name", " while not path.exists(): time.sleep(.01)", @@ -446,7 +873,38 @@ def _staged_bwrap( " metadata=original.stat(follow_symlinks=False)", " os.chown(copied,metadata.st_uid,metadata.st_gid,follow_symlinks=False)", "try:", + " staging_root=control/'binds'", + " subprocess.run([mount,'-t','tmpfs','-o'," + "f\"size={limits['staging']},mode=0700,nosuid,nodev\",'tmpfs'," + "str(staging_root)],check=True,timeout=limits['trusted_timeout'])", + " staged.append(staging_root)", + " mount_lines=pathlib.Path('/proc/self/mountinfo').read_text(" + "encoding='utf-8').splitlines()", + " mount_fields=[line.split() for line in mount_lines]", + " staging_mount=next((fields for fields in mount_fields if len(fields)>6 and " + "pathlib.Path(fields[4])==staging_root),None)", + " if staging_mount is None or '-' not in staging_mount or " + "staging_mount[staging_mount.index('-')+1]!='tmpfs': " + "raise RuntimeError('protected staging tmpfs mount probe failed')", + " if type(proof_records) is not list or " + "any(type(value) is not str for value in proof_records): " + "raise RuntimeError('invalid immutable binding proof protocol')", + " proof_by_index={}", + " for encoded in proof_records:", + " try: preliminary=json.loads(encoded); source_index=preliminary['source_index']", + " except (TypeError,ValueError,KeyError): _immutable_failure()", + " if type(source_index) is not int or source_index<0 or " + "source_index>=len(paths) or source_index in proof_by_index: " + "_immutable_failure()", + " proof_by_index[source_index]=encoded", + " for index,(source,target) in enumerate(zip(paths,targets)):", + " if index in proof_by_index or pathlib.Path(source).is_file(): target.touch(mode=0o600)", + " elif pathlib.Path(source).is_dir(): target.mkdir(mode=0o700)", + " else: raise RuntimeError('protected staging source type is invalid')", " writable_target=control/'binds'/'writable'", + " writable_target.mkdir(mode=0o700)", + " cgroup_target=control/'binds'/'cgroup'", + " cgroup_target.mkdir(mode=0o700)", " subprocess.run([mount,'-t','tmpfs','-o'," "f\"size={limits['writable']},mode=0700,nosuid,nodev\",'tmpfs'," "str(writable_target)],check=True,timeout=limits['trusted_timeout'])", @@ -469,16 +927,20 @@ def _staged_bwrap( "writable_paths.append(target)", " if sum(validate_tree(path) for path in writable_paths) > " "limits['writable']: raise RuntimeError('initial writable quota exceeded')", - " for source,target in zip(paths,targets):", - " subprocess.run([mount,'--bind',source,str(target)],check=True," + " for index,(source,target) in enumerate(zip(paths,targets)):", + " if index in proof_by_index:", + " if _stage_immutable_snapshot(proof_by_index[index],target)!=index: " + "_immutable_failure()", + " os.chown(target,0,0,follow_symlinks=False)", + " else:", + " subprocess.run([mount,'--bind',source,str(target)],check=True," "timeout=limits['trusted_timeout'])", - " staged.append(target)", + " staged.append(target)", " path_map={token:str(targets[index]) for index,token in enumerate(path_tokens)}", " for token,index,relative in writable_specs: " "path_map[token]=str(writable_paths[index]/relative)", " argv=[path_map.get(value,value) for value in argv]", " configure_candidate_leaf()", - " cgroup_target=control/'binds'/'cgroup'", " subprocess.run([mount,'--bind',str(candidate_cgroup),str(cgroup_target)]," "check=True,timeout=limits['trusted_timeout'])", " staged.append(cgroup_target)", @@ -549,8 +1011,9 @@ def _staged_bwrap( )) plan = _ScopePlan( unit_name, control_directory, helper, tuple(argv), tuple(sources), - (*staging_targets, control_directory / "binds" / "writable", + (staging_root, *source_targets, control_directory / "binds" / "writable", control_directory / "binds" / "cgroup"), tools, + immutable_binding_proofs, ) command = [ str(tools.sudo), "-n", "-E", str(tools.systemd_run), "--scope", "--quiet", @@ -560,11 +1023,18 @@ def _staged_bwrap( "--property=KillMode=control-group", "--", str(_SUPERVISOR_EXECUTABLE), "-c", helper, str(control_directory), str(tools.mount), str(tools.umount), + json.dumps(list(immutable_binding_proofs)), json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), json.dumps(path_tokens), json.dumps(argv), - json.dumps([str(path) for path in sources]), unit_name, + json.dumps([str(path) for path in sources]), + unit_name, json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, "writable": limits.max_writable_bytes, + "staging": max( + 1024 * 1024, + sum(path.stat().st_size for path in sources if path.is_file()) + + 1024 * 1024, + ), "timeout": candidate_timeout, "trusted_timeout": _TRUSTED_COMMAND_SECONDS}), ] @@ -905,16 +1375,9 @@ def absent() -> bool: def _prepare_staging(plan: _ScopePlan) -> None: - """Create private bind targets before the privileged scope helper starts.""" + """Create only the mountpoint for the helper-owned staging tmpfs.""" binds = plan.control_directory / "binds" binds.mkdir(mode=0o700) - for source, target in zip(plan.sources, plan.staging_targets[:-2]): - if source.is_dir(): - target.mkdir(mode=0o700) - else: - target.touch(mode=0o600) - plan.staging_targets[-2].mkdir(mode=0o700) - plan.staging_targets[-1].mkdir(mode=0o700) def _mounted_paths() -> set[Path]: @@ -968,6 +1431,7 @@ def _sandbox_command( control_directory: Path | None = None, ) -> tuple[list[str], _ScopePlan]: # pylint: disable=too-many-locals,too-many-branches,too-many-statements + # pylint: disable=unidiomatic-typecheck """Return an explicitly detected macOS/Linux sandbox command.""" _validate_limits(limits) if sys.platform == "darwin": @@ -1013,42 +1477,56 @@ def _sandbox_command( path_tokens: list[str] = [] writable_specs: list[tuple[str, int, str]] = [] destination_dirs = {Path("/tmp")} - mounted: dict[Path, tuple[str, Path]] = {} - proofs = {} + mounted: dict[Path, tuple[str, Path, str, int | None]] = {} + proofs: dict[tuple[Path, Path, Path], _ValidatedBindingProof] = {} + raw_proofs: dict[tuple[Path, Path, Path], ImmutableBindingProof] = {} for proof in immutable_binding_proofs: - if ( - not isinstance(proof, ImmutableBindingProof) - or re.fullmatch(r"[0-9a-f]{64}", proof.descriptor_identity) is None - ): - raise RuntimeError("protected sandbox immutable binding proof is malformed") try: - key = ( + if type(proof) is not ImmutableBindingProof: + raise ValueError("invalid proof type") + path_type = type(Path()) + if any(type(value) is not path_type for value in ( + proof.copied_source, proof.protected_source, proof.destination + )): + raise ValueError("invalid proof path type") + raw_key = ( proof.copied_source.resolve(strict=True), proof.protected_source.resolve(strict=True), + proof.destination, ) - except OSError as exc: + except (AttributeError, OSError, TypeError, ValueError) as exc: raise RuntimeError( - "protected sandbox immutable binding proof is incomplete" + "protected sandbox immutable binding proof is malformed" ) from exc - if key in proofs: - raise RuntimeError("protected sandbox has duplicate immutable binding proofs") - proofs[key] = proof - if len(proofs) != len(immutable_binding_proofs): - raise RuntimeError("protected sandbox has duplicate immutable binding proofs") - accepted_proofs: set[ImmutableBindingProof] = set() - - def stage_source(source: Path, writable: bool = False) -> str: + if raw_key in raw_proofs: + kind = "duplicate" if raw_proofs[raw_key] == proof else "ambiguous" + raise RuntimeError( + f"protected sandbox has {kind} immutable binding proofs" + ) + raw_proofs[raw_key] = proof + validated = _validate_immutable_binding_proof(proof) + key = ( + validated.copied_source, + validated.protected_source, + validated.destination, + ) + proofs[key] = validated + consumed_proofs: set[tuple[Path, Path, Path]] = set() + accepted_records: list[str] = [] + + def stage_source(source: Path, writable: bool = False) -> tuple[str, int | None]: token = f"@PDD-PATH-{uuid.uuid4().hex}@" if writable: for index, root in enumerate(storage_roots): if source == root or source.is_relative_to(root): relative = source.relative_to(root).as_posix() or "." writable_specs.append((token, index, relative)) - return token + return token, None raise RuntimeError("writable source is outside bounded storage") + source_index = len(sources) sources.append(source) path_tokens.append(token) - return token + return token, source_index def ensure_destination_parent(destination: Path) -> None: missing = [] @@ -1060,57 +1538,81 @@ def ensure_destination_parent(destination: Path) -> None: argv.extend(("--dir", str(directory))) destination_dirs.add(directory) - def bind(option: str, source: Path, destination: Path | None = None) -> None: + def bind( + option: str, source: Path, destination: Path | None = None, *, + category: str, + ) -> None: destination = destination or source - binding = (option, source.resolve()) + resolved_source = source.resolve() previous = mounted.get(destination) - if previous == binding: + if previous is not None and previous[:2] == (option, resolved_source): return - if previous is not None and previous[1] != binding[1]: - proof = proofs.get((previous[1], binding[1])) + if previous is not None and previous[1] != resolved_source: + key = (previous[1], resolved_source, destination) + proof = proofs.get(key) if ( option == "--ro-bind" and previous[0] == "--ro-bind" + and previous[2] == "declared_readable" + and category == "inferred_runtime" and proof is not None - and _immutable_member_matches( - proof.copied_source, - digest=proof.member_digest, - mode=proof.member_mode, - ) - and _immutable_member_matches( - proof.protected_source, - digest=proof.member_digest, - mode=proof.member_mode, - ) ): - accepted_proofs.add(proof) + if key in consumed_proofs: + raise RuntimeError( + "protected sandbox immutable binding proof was multiply consumed" + ) + if previous[3] is None: + raise RuntimeError( + "protected sandbox immutable binding proof has no source" + ) + consumed_proofs.add(key) + accepted_records.append(_canonical_json({ + "schema": _BINDING_RECORD_SCHEMA, + "source_index": previous[3], + "copied_source": str(proof.copied_source), + "protected_source": str(proof.protected_source), + "destination": str(proof.destination), + "descriptor_attestation": proof.descriptor_attestation, + "descriptor_identity": proof.descriptor_identity, + "member_role": proof.member_role, + "member_path": proof.member_path, + "collision_category": proof.collision_category, + })) return raise RuntimeError( f"protected sandbox has conflicting bindings for {destination}" ) - mounted[destination] = binding + token, source_index = stage_source(source, option == "--bind") + mounted[destination] = ( + option, resolved_source, category, source_index + ) ensure_destination_parent(destination) - argv.extend((option, stage_source(source, option == "--bind"), str(destination))) + argv.extend((option, token, str(destination))) if destination.is_dir(): destination_dirs.add(destination) for source, destination in writable_bindings: - bind("--bind", source.resolve(), destination) + bind("--bind", source.resolve(), destination, category="writable") for source, destination in readable_bindings: - bind("--ro-bind", source.resolve(), destination) + bind( + "--ro-bind", source.resolve(), destination, + category="declared_readable", + ) for item in _runtime_roots(command, workdir): # A host bind follows symlinks, but the process command and ELF # loader retain their original spellings in the new namespace. - bind("--ro-bind", item.resolve(), item) + bind( + "--ro-bind", item.resolve(), item, category="inferred_runtime" + ) # The helper replaces this placeholder with only its systemd scope. argv.extend(("--ro-bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) # ``setpriv`` executes after the namespace root is installed, so bind # it and its ELF closure directly even when PATH resolution differs. if tools.setpriv: for item in (tools.setpriv, *_linked_libraries(tools.setpriv)): - bind("--ro-bind", item.resolve(), item) + bind("--ro-bind", item.resolve(), item, category="trusted_runtime") for item in readable_roots: - bind("--ro-bind", item.resolve()) + bind("--ro-bind", item.resolve(), category="readable_root") if result_fifo is not None: observation_source = result_fifo.resolve(strict=True) if not stat.S_ISFIFO(observation_source.lstat().st_mode): @@ -1118,19 +1620,19 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: if _FRAMEWORK_OBSERVATION_PATH in mounted: raise RuntimeError("framework observation destination conflicts") mounted[_FRAMEWORK_OBSERVATION_PATH] = ( - "--bind", observation_source + "--bind", observation_source, "observation", len(sources) ) ensure_destination_parent(_FRAMEWORK_OBSERVATION_PATH) argv.extend( ( "--bind", - stage_source(observation_source), + stage_source(observation_source)[0], str(_FRAMEWORK_OBSERVATION_PATH), ) ) argv.extend(("--dev", "/dev")) for item in writable_roots: - bind("--bind", item.resolve()) + bind("--bind", item.resolve(), category="writable") argv.extend(("--chdir", str(workdir))) drop = ( [str(tools.setpriv), "--reuid", str(os.getuid()), "--regid", str(os.getgid()), @@ -1142,25 +1644,12 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: sandboxed, result_fd, _FRAMEWORK_OBSERVATION_PATH ) argv.extend(("--", *drop, *sandboxed)) - for proof in accepted_proofs: - if not ( - _immutable_member_matches( - proof.copied_source, - digest=proof.member_digest, - mode=proof.member_mode, - ) - and _immutable_member_matches( - proof.protected_source, - digest=proof.member_digest, - mode=proof.member_mode, - ) - ): - raise RuntimeError( - "protected sandbox immutable binding proof changed during assembly" - ) + if consumed_proofs != proofs.keys(): + raise RuntimeError("protected sandbox has unused immutable binding proof") return _staged_bwrap( argv, sources, path_tokens, writable_roots=storage_roots, writable_specs=writable_specs, + immutable_binding_proofs=tuple(accepted_records), unit_name=unit_name or _scope_unit_name(), control_directory=control_directory or ( Path(tempfile.gettempdir()) / f"pdd-scope-{uuid.uuid4().hex}" diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 797ff6f679..a6e8023c23 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -446,7 +446,7 @@ def test_linux_sandbox_rejects_tampered_descriptor_proof( else: proof = replace(proof, descriptor_identity="a" * 64) - with pytest.raises(RuntimeError, match="conflicting bindings"): + with pytest.raises(RuntimeError, match="immutable binding proof|conflicting bindings"): _sandbox_command( ["/bin/true"], (tmp_path,), @@ -566,6 +566,8 @@ def test_linux_sandbox_rejects_symlink_spelled_proof_destination( alias.symlink_to(host_loader) copied_loader = tmp_path / "copied-loader" copied_loader.write_bytes(host_loader.read_bytes()) + scratch = tmp_path / "scratch" + scratch.mkdir() proof = _descriptor_runtime_proof( copied_loader, host_loader, destination=alias ) @@ -576,7 +578,7 @@ def test_linux_sandbox_rejects_symlink_spelled_proof_destination( with pytest.raises(RuntimeError, match="immutable binding proof|conflicting"): _sandbox_command( ["/bin/true"], - (tmp_path,), + (scratch,), readable_bindings=((copied_loader, alias),), immutable_binding_proofs=(proof,), ) From d676a15b3a164fb8391d65371100623fee6a01cc Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 21:35:57 -0700 Subject: [PATCH 159/233] test(sync): cover canonical immutable snapshot modes --- tests/test_sync_core_supervisor.py | 105 ++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 8 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index a6e8023c23..e8fd670757 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -7,6 +7,7 @@ import math import signal import shutil +import stat import subprocess import sys import time @@ -52,6 +53,7 @@ def _mock_linux_tools( def _descriptor_runtime_proof( copied: Path, protected: Path, *, destination: Path | None = None, + native_mode: int = 0o644, ): """Create one proof through the real validated-descriptor adapter path.""" from pdd.sync_core.runner import ( # pylint: disable=import-outside-toplevel @@ -76,7 +78,7 @@ def _descriptor_runtime_proof( "lockfile", PurePosixPath("."), "file", 0o644, digest ), VitestToolchainMember( - "native_runtime", PurePosixPath("0"), "file", 0o644, digest + "native_runtime", PurePosixPath("0"), "file", native_mode, digest ), ), key=lambda item: (item.role, item.relative_path.as_posix()))) identity = hashlib.sha256(json.dumps({ @@ -107,6 +109,13 @@ def _proof_with_native_member_field(proof, field: str, value): item for item in payload["members"] if item["role"] == "native_runtime" and item["path"] == "0" ) + member[field] = value + return replace( + proof, + descriptor_attestation=json.dumps( + payload, sort_keys=True, separators=(",", ":") + ), + ) def _mock_runtime_collision( @@ -132,13 +141,6 @@ def _mock_runtime_collision( "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (protected,) ) return protected, copied - member[field] = value - return replace( - proof, - descriptor_attestation=json.dumps( - payload, sort_keys=True, separators=(",", ":") - ), - ) def test_framework_observation_wrapper_opens_portable_fifo_path( @@ -503,6 +505,79 @@ def test_linux_sandbox_helper_rejects_replacement_after_command_construction( assert supervisor._IMMUTABLE_STAGING_SOURCE in plan.helper_source +@pytest.mark.parametrize( + ("mode", "candidate_permission"), + [(0o644, stat.S_IROTH), (0o755, stat.S_IXOTH)], +) +def test_linux_sandbox_helper_stages_descriptor_mode_for_candidate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: int, + candidate_permission: int, +) -> None: + """A root-owned snapshot retains the descriptor mode for the candidate.""" + protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) + payload = b"#!/bin/sh\nexit 0\n" if mode == 0o755 else b"native-library" + protected.write_bytes(payload) + copied.write_bytes(payload) + protected.chmod(mode) + copied.chmod(mode) + proof = _descriptor_runtime_proof(copied, protected, native_mode=mode) + argv, plan = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=(proof,), + ) + namespace = {} + exec( # pylint: disable=exec-used + "import hashlib,json,os,pathlib,stat\n" + + supervisor._IMMUTABLE_STAGING_SOURCE, + namespace, + ) + target = tmp_path / "root-owned-snapshot" + target.touch(mode=0o600) + + assert namespace["_stage_immutable_snapshot"]( + plan.immutable_binding_proofs[0], target + ) == 0 + snapshot_mode = stat.S_IMODE(target.stat().st_mode) + assert snapshot_mode == mode + assert snapshot_mode & candidate_permission + assert target.read_bytes() == payload + if mode == 0o755: + assert subprocess.run([target], check=False).returncode == 0 + bwrap = json.loads(argv[-4]) + destination_index = bwrap.index(str(protected)) + assert bwrap[destination_index - 2] == "--ro-bind" + + +def test_linux_sandbox_helper_rejects_mode_change_after_command_construction( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The helper revalidates the descriptor mode at the bind boundary.""" + protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) + proof = _descriptor_runtime_proof(copied, protected) + _argv, plan = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=(proof,), + ) + copied.chmod(0o600) + namespace = {} + exec( # pylint: disable=exec-used + "import hashlib,json,os,pathlib,stat\n" + + supervisor._IMMUTABLE_STAGING_SOURCE, + namespace, + ) + target = tmp_path / "root-owned-snapshot" + target.touch(mode=0o600) + + with pytest.raises(RuntimeError, match="immutable binding"): + namespace["_stage_immutable_snapshot"]( + plan.immutable_binding_proofs[0], target + ) + + @pytest.mark.parametrize( ("field", "value"), [ @@ -647,6 +722,20 @@ class Text(str): proof, collision_category=Text("vitest_inferred_runtime") ) + if mutation in {"digest_type", "digest_spelling", "mode_type", "mode_bool", "mode_range"}: + field = "digest" if mutation.startswith("digest") else "mode" + member = next( + item for item in json.loads(proof.descriptor_attestation)["members"] + if item["role"] == "native_runtime" and item["path"] == "0" + ) + assert member[field] == { + "digest_type": 7, + "digest_spelling": "not-a-digest", + "mode_type": "420", + "mode_bool": True, + "mode_range": 0o1000, + }[mutation] + with pytest.raises(RuntimeError, match="immutable binding proof"): _sandbox_command( ["/bin/true"], From 0fa5d65dc1cc9dd38360467174b8b1fb8c76fd03 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 21:36:41 -0700 Subject: [PATCH 160/233] fix(sync): preserve immutable snapshot descriptor mode --- pdd/sync_core/supervisor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 8d987b10ce..5bc17edeca 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -567,13 +567,13 @@ def _stage_immutable_snapshot(encoded,target): if not chunk: break actual.update(chunk); os.write(target_fd,chunk) if actual.hexdigest()!=member["digest"]: _immutable_failure() - os.fchmod(target_fd,0o400); os.fsync(target_fd) + os.fchmod(target_fd,member["mode"]); os.fsync(target_fd) except OSError: _immutable_failure() finally: if target_fd is not None: os.close(target_fd) os.close(protected_fd); os.close(copied_fd) - snapshot_fd=_verified_staging_fd(target,member["digest"],0o400) + snapshot_fd=_verified_staging_fd(target,member["digest"],member["mode"]) os.close(snapshot_fd) return record["source_index"] '''.strip() From 015a791637efcee24225f6292655a36f1f07cdbf Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 21:53:16 -0700 Subject: [PATCH 161/233] test(sync): cover candidate-owned runtime snapshots --- tests/test_sync_core_supervisor.py | 95 ++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index e8fd670757..a80760ae1a 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -119,12 +119,13 @@ def _proof_with_native_member_field(proof, field: str, value): def _mock_runtime_collision( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, + candidate_uid: int = 1234, candidate_gid: int = 2345, ) -> tuple[Path, Path]: """Install one synthetic inferred runtime collision for proof tests.""" monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(os, "getuid", lambda: 1234) - monkeypatch.setattr(os, "getgid", lambda: 2345) + monkeypatch.setattr(os, "getuid", lambda: candidate_uid) + monkeypatch.setattr(os, "getgid", lambda: candidate_gid) _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( "pdd.sync_core.supervisor.subprocess.run", @@ -500,22 +501,35 @@ def test_linux_sandbox_helper_rejects_replacement_after_command_construction( with pytest.raises(RuntimeError, match="immutable binding"): namespace["_stage_immutable_snapshot"]( - plan.immutable_binding_proofs[0], target + plan.immutable_binding_proofs[0], target, 1234, 2345 ) assert supervisor._IMMUTABLE_STAGING_SOURCE in plan.helper_source @pytest.mark.parametrize( ("mode", "candidate_permission"), - [(0o644, stat.S_IROTH), (0o755, stat.S_IXOTH)], + [ + (0o600, stat.S_IRUSR), + (0o640, stat.S_IRUSR), + (0o644, stat.S_IROTH), + (0o700, stat.S_IXUSR), + (0o750, stat.S_IXUSR), + (0o755, stat.S_IXOTH), + ], ) def test_linux_sandbox_helper_stages_descriptor_mode_for_candidate( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: int, candidate_permission: int, ) -> None: - """A root-owned snapshot retains the descriptor mode for the candidate.""" - protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) - payload = b"#!/bin/sh\nexit 0\n" if mode == 0o755 else b"native-library" + """The validated candidate owns and can use each staged descriptor mode.""" + candidate_uid = os.getuid() + candidate_gid = os.getgid() + protected, copied = _mock_runtime_collision( + tmp_path, monkeypatch, + candidate_uid=candidate_uid, candidate_gid=candidate_gid, + ) + executable = bool(mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)) + payload = b"#!/bin/sh\nexit 0\n" if executable else b"native-library" protected.write_bytes(payload) copied.write_bytes(payload) protected.chmod(mode) @@ -535,19 +549,76 @@ def test_linux_sandbox_helper_stages_descriptor_mode_for_candidate( ) target = tmp_path / "root-owned-snapshot" target.touch(mode=0o600) + monkeypatch.setenv("SUDO_UID", str(candidate_uid)) + monkeypatch.setenv("SUDO_GID", str(candidate_gid)) + monkeypatch.setattr(os, "geteuid", lambda: 0) + monkeypatch.setattr(os, "getegid", lambda: 0) + validated_uid, validated_gid = namespace["_validated_candidate_identity"]( + argv[-9], json.loads(argv[-4]) + ) assert namespace["_stage_immutable_snapshot"]( - plan.immutable_binding_proofs[0], target + plan.immutable_binding_proofs[0], target, validated_uid, validated_gid ) == 0 - snapshot_mode = stat.S_IMODE(target.stat().st_mode) + metadata = target.stat() + snapshot_mode = stat.S_IMODE(metadata.st_mode) assert snapshot_mode == mode + assert (metadata.st_uid, metadata.st_gid) == (candidate_uid, candidate_gid) assert snapshot_mode & candidate_permission assert target.read_bytes() == payload - if mode == 0o755: + if executable: assert subprocess.run([target], check=False).returncode == 0 bwrap = json.loads(argv[-4]) destination_index = bwrap.index(str(protected)) assert bwrap[destination_index - 2] == "--ro-bind" + assert "mode=0700,nosuid,nodev" in plan.helper_source + assert "os.fchown" in plan.helper_source + + +@pytest.mark.parametrize( + ("identity", "sudo_uid", "sudo_gid", "argv_mutation"), + [ + ({"schema": "pdd-candidate-identity-v1", "uid": "1234", "gid": 2345}, + "1234", "2345", None), + ({"schema": "pdd-candidate-identity-v1", "uid": 1234, "gid": True}, + "1234", "2345", None), + ({"schema": "pdd-candidate-identity-v1", "uid": 1234, "gid": 2345}, + "9999", "2345", None), + ({"schema": "pdd-candidate-identity-v1", "uid": 1234, "gid": 2345}, + "1234", "2345", "forged-drop"), + ], +) +def test_linux_sandbox_helper_rejects_forged_candidate_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, identity: dict[str, object], + sudo_uid: str, sudo_gid: str, argv_mutation: str | None, +) -> None: + """Candidate ownership is bound to root invocation and the exact UID drop.""" + protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) + proof = _descriptor_runtime_proof(copied, protected) + command, _plan = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=(proof,), + ) + bwrap = json.loads(command[-4]) + if argv_mutation: + bwrap[bwrap.index("--reuid") + 1] = "9999" + namespace = {} + exec( # pylint: disable=exec-used + "import hashlib,json,os,pathlib,stat\n" + + supervisor._IMMUTABLE_STAGING_SOURCE, + namespace, + ) + monkeypatch.setenv("SUDO_UID", sudo_uid) + monkeypatch.setenv("SUDO_GID", sudo_gid) + monkeypatch.setattr(os, "geteuid", lambda: 0) + monkeypatch.setattr(os, "getegid", lambda: 0) + + with pytest.raises(RuntimeError, match="immutable binding"): + namespace["_validated_candidate_identity"]( + json.dumps(identity, sort_keys=True, separators=(",", ":")), bwrap + ) def test_linux_sandbox_helper_rejects_mode_change_after_command_construction( @@ -574,7 +645,7 @@ def test_linux_sandbox_helper_rejects_mode_change_after_command_construction( with pytest.raises(RuntimeError, match="immutable binding"): namespace["_stage_immutable_snapshot"]( - plan.immutable_binding_proofs[0], target + plan.immutable_binding_proofs[0], target, 1234, 2345 ) From 4c3c1e01e96ba4081423007af004d3895f6366a4 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 21:55:34 -0700 Subject: [PATCH 162/233] fix(sync): bind runtime snapshots to candidate ownership --- pdd/sync_core/supervisor.py | 75 +++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 5bc17edeca..251158ac8a 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -36,6 +36,7 @@ _MAX_BINDING_ATTESTATION_BYTES = 4 * 1024 * 1024 _VITEST_ATTESTATION_SCHEMA = "pdd-vitest-toolchain-attestation-v1" _BINDING_RECORD_SCHEMA = "pdd-immutable-binding-record-v1" +_CANDIDATE_IDENTITY_SCHEMA = "pdd-candidate-identity-v1" _VITEST_MEMBER_ROLES = frozenset({ "launcher", "entrypoint", "dependencies", "native_runtime", "lockfile", }) @@ -436,6 +437,29 @@ def _immutable_failure(): def _canonical_staging_json(value): return json.dumps(value,sort_keys=True,separators=(",",":")) +def _validated_candidate_identity(encoded,argv): + if type(encoded) is not str or not encoded or len(encoded.encode("utf-8"))>1024: + _immutable_failure() + try: identity=json.loads(encoded) + except (TypeError,ValueError): _immutable_failure() + if type(identity) is not dict or set(identity)!={"schema","uid","gid"} or encoded!=_canonical_staging_json(identity): + _immutable_failure() + uid=identity["uid"]; gid=identity["gid"] + if type(identity["schema"]) is not str or identity["schema"]!="pdd-candidate-identity-v1": + _immutable_failure() + if type(uid) is not int or not 08388608: _immutable_failure() try: record=json.loads(encoded) @@ -540,6 +566,8 @@ def _stage_immutable_snapshot(encoded,target): _immutable_failure() if type(record["schema"]) is not str or record["schema"]!="pdd-immutable-binding-record-v1": _immutable_failure() + if type(candidate_uid) is not int or not 0 tuple[list[str], _ScopePlan]: @@ -786,19 +816,21 @@ def _staged_bwrap( ) helper = "\n".join(( "import hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,time", - "if len(sys.argv)!=12: raise RuntimeError('invalid protected helper protocol')", + "if len(sys.argv)!=13: raise RuntimeError('invalid protected helper protocol')", "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", - "proof_records=json.loads(sys.argv[4])", - "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[5])]", - "writable_specs=json.loads(sys.argv[6])", - "path_tokens=json.loads(sys.argv[7]); " - "argv=json.loads(sys.argv[8]); paths=json.loads(sys.argv[9])", - "limits=json.loads(sys.argv[11])", + "candidate_identity=sys.argv[4]; proof_records=json.loads(sys.argv[5])", + "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[6])]", + "writable_specs=json.loads(sys.argv[7])", + "path_tokens=json.loads(sys.argv[8]); " + "argv=json.loads(sys.argv[9]); paths=json.loads(sys.argv[10])", + "limits=json.loads(sys.argv[12])", "targets=[control/'binds'/str(index) for index in range(len(paths))]", "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None", "scope_cgroup=None; monitor_cgroup=None; candidate_cgroup=None", _PIDFD_PROTOCOL_SOURCE.strip(), _IMMUTABLE_STAGING_SOURCE, + "candidate_uid,candidate_gid=" + "_validated_candidate_identity(candidate_identity,argv)", "def wait_for(name):", " path=control/name", " while not path.exists(): time.sleep(.01)", @@ -929,9 +961,8 @@ def _staged_bwrap( "limits['writable']: raise RuntimeError('initial writable quota exceeded')", " for index,(source,target) in enumerate(zip(paths,targets)):", " if index in proof_by_index:", - " if _stage_immutable_snapshot(proof_by_index[index],target)!=index: " - "_immutable_failure()", - " os.chown(target,0,0,follow_symlinks=False)", + " if _stage_immutable_snapshot(proof_by_index[index],target," + "candidate_uid,candidate_gid)!=index: _immutable_failure()", " else:", " subprocess.run([mount,'--bind',source,str(target)],check=True," "timeout=limits['trusted_timeout'])", @@ -1023,6 +1054,7 @@ def _staged_bwrap( "--property=KillMode=control-group", "--", str(_SUPERVISOR_EXECUTABLE), "-c", helper, str(control_directory), str(tools.mount), str(tools.umount), + candidate_identity, json.dumps(list(immutable_binding_proofs)), json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), json.dumps(path_tokens), json.dumps(argv), @@ -1447,11 +1479,22 @@ def _sandbox_command( ): raise RuntimeError("protected sandbox requires a finite positive timeout") tools = _trusted_tools() - if os.getuid() == 0: + candidate_uid = os.getuid() + candidate_gid = os.getgid() + if type(candidate_uid) is not int or type(candidate_gid) is not int: + raise RuntimeError("protected sandbox caller identity is invalid") + if candidate_uid == 0: raise RuntimeError( "protected sandbox requires a non-root caller so process limits " "remain kernel-enforced" ) + if not 0 Date: Tue, 14 Jul 2026 23:10:47 -0700 Subject: [PATCH 163/233] fix(sync): stage protected observation fifo --- pdd/sync_core/supervisor.py | 25 ++++++++++++++++++++++--- tests/test_sync_core_supervisor.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index b057bd31ea..fb86d38227 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -816,6 +816,17 @@ def _staged_bwrap( source_targets = tuple( control_directory / "binds" / str(index) for index in range(len(sources)) ) + fifo_source_indices = [] + for index, value in enumerate(argv): + if ( + value == str(_FRAMEWORK_OBSERVATION_PATH) + and index >= 2 + and argv[index - 2] == "--bind" + and argv[index - 1] in path_tokens + ): + fifo_source_indices.append(path_tokens.index(argv[index - 1])) + if len(fifo_source_indices) > 1: + raise RuntimeError("protected sandbox has ambiguous observation staging") helper = "\n".join(( "import hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,time", "if len(sys.argv)!=13: raise RuntimeError('invalid protected helper protocol')", @@ -825,6 +836,7 @@ def _staged_bwrap( "writable_specs=json.loads(sys.argv[7])", "path_tokens=json.loads(sys.argv[8]); " "argv=json.loads(sys.argv[9]); paths=json.loads(sys.argv[10])", + "fifo_indices=json.loads(sys.argv[11])", "limits=json.loads(sys.argv[12])", "targets=[control/'binds'/str(index) for index in range(len(paths))]", "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None", @@ -931,9 +943,16 @@ def _staged_bwrap( "source_index>=len(paths) or source_index in proof_by_index: " "_immutable_failure()", " proof_by_index[source_index]=encoded", + " if type(fifo_indices) is not list or len(fifo_indices)>1 or " + "any(type(value) is not int or value<0 or value>=len(paths) " + "for value in fifo_indices): " + "raise RuntimeError('invalid protected FIFO staging protocol')", " for index,(source,target) in enumerate(zip(paths,targets)):", - " if index in proof_by_index or pathlib.Path(source).is_file(): target.touch(mode=0o600)", - " elif pathlib.Path(source).is_dir(): target.mkdir(mode=0o700)", + " metadata=pathlib.Path(source).lstat()", + " if index in proof_by_index or stat.S_ISREG(metadata.st_mode): target.touch(mode=0o600)", + " elif stat.S_ISDIR(metadata.st_mode): target.mkdir(mode=0o700)", + " elif index in fifo_indices and stat.S_ISFIFO(metadata.st_mode): " + "os.mkfifo(target,mode=0o600)", " else: raise RuntimeError('protected staging source type is invalid')", " writable_target=control/'binds'/'writable'", " writable_target.mkdir(mode=0o700)", @@ -1062,7 +1081,7 @@ def _staged_bwrap( json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), json.dumps(path_tokens), json.dumps(argv), json.dumps([str(path) for path in sources]), - unit_name, + json.dumps(fifo_source_indices), json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, "writable": limits.max_writable_bytes, "staging": max( diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index b4cc0884de..5f4d758c1a 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -969,6 +969,9 @@ def test_linux_sandbox_uses_portable_framework_observation_fifo( tokens = json.loads(argv[-5]) sources = json.loads(argv[-3]) assert sources[tokens.index(observation_token)] == str(fifo.resolve()) + assert json.loads(argv[-2]) == [tokens.index(observation_token)] + assert "stat.S_ISFIFO(metadata.st_mode)" in plan.helper_source + assert "os.mkfifo(target,mode=0o600)" in plan.helper_source assert str(channel) not in bwrap separator = bwrap.index("--") candidate_argv = bwrap[separator + 1:] @@ -979,6 +982,32 @@ def test_linux_sandbox_uses_portable_framework_observation_fifo( assert "result_fifo" not in plan.helper_source +def test_linux_sandbox_does_not_authorize_declared_fifo_staging( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only the fixed framework observation mount may stage a FIFO target.""" + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + scratch = tmp_path / "scratch" + scratch.mkdir() + candidate_fifo = tmp_path / "candidate.fifo" + os.mkfifo(candidate_fifo) + + argv, _plan = _sandbox_command( + ["/bin/true"], (scratch,), + readable_bindings=((candidate_fifo, Path("/run/candidate.fifo")),), + ) + + assert json.loads(argv[-2]) == [] + + def _mock_scope_run( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, helper: str, *, stop_scope=None, From c2f72f23fc57cc244819d769e17bc32d94aadce4 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 23:22:48 -0700 Subject: [PATCH 164/233] fix(sync): preserve nested toolchain mounts --- pdd/sync_core/supervisor.py | 11 +++++--- tests/test_sync_core_supervisor.py | 40 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index fb86d38227..157f770dfb 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1541,6 +1541,7 @@ def _sandbox_command( sources: list[Path] = [] path_tokens: list[str] = [] writable_specs: list[tuple[str, int, str]] = [] + deferred_readable_mounts: list[str] = [] destination_dirs = {Path("/tmp")} mounted: dict[Path, tuple[str, Path, str, int | None]] = {} proofs: dict[tuple[Path, Path, Path], _ValidatedBindingProof] = {} @@ -1605,7 +1606,7 @@ def ensure_destination_parent(destination: Path) -> None: def bind( option: str, source: Path, destination: Path | None = None, *, - category: str, + category: str, defer_mount: bool = False, ) -> None: destination = destination or source resolved_source = source.resolve() @@ -1652,7 +1653,8 @@ def bind( option, resolved_source, category, source_index ) ensure_destination_parent(destination) - argv.extend((option, token, str(destination))) + mount_argv = deferred_readable_mounts if defer_mount else argv + mount_argv.extend((option, token, str(destination))) if destination.is_dir(): destination_dirs.add(destination) @@ -1661,7 +1663,7 @@ def bind( for source, destination in readable_bindings: bind( "--ro-bind", source.resolve(), destination, - category="declared_readable", + category="declared_readable", defer_mount=True, ) for item in _runtime_roots(command, workdir): # A host bind follows symlinks, but the process command and ELF @@ -1669,6 +1671,9 @@ def bind( bind( "--ro-bind", item.resolve(), item, category="inferred_runtime" ) + # Nested declared toolchain mounts must be installed after broader + # inferred roots or Bubblewrap would hide them with the later root bind. + argv.extend(deferred_readable_mounts) # The helper replaces this placeholder with only its systemd scope. argv.extend(("--ro-bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) # ``setpriv`` executes after the namespace root is installed, so bind diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 5f4d758c1a..23234d9310 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -377,6 +377,46 @@ def test_linux_sandbox_rejects_declared_copied_loader_at_inferred_runtime_withou ) +def test_linux_sandbox_mounts_nested_declared_toolchain_after_phase_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A broad phase bind must not hide its protected node_modules overlay.""" + monkeypatch.setattr(sys, "platform", "linux") + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + phase_root = tmp_path / "repository" + phase_root.mkdir() + dependency_source = tmp_path / "toolchain" / "node_modules" + dependency_source.mkdir(parents=True) + dependency_destination = phase_root / "node_modules" + dependency_destination.mkdir() + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", + lambda *_args: (phase_root,), + ) + + argv, _plan = _sandbox_command( + ["/bin/true"], (tmp_path,), cwd=phase_root, + readable_bindings=((dependency_source, dependency_destination),), + ) + + bwrap = json.loads(argv[-4]) + + def mount_index(destination: Path) -> int: + return next( + index for index, value in enumerate(bwrap) + if value == str(destination) and bwrap[index - 2] == "--ro-bind" + ) + + assert mount_index(phase_root) < mount_index(dependency_destination) + + def test_linux_sandbox_allows_descriptor_proven_copied_loader_at_inferred_runtime( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From e6f2109f39d22d4f572e53688b352717e713b8e5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Tue, 14 Jul 2026 23:41:27 -0700 Subject: [PATCH 165/233] test(sync): align candidate runtime integration contract --- tests/test_sync_core_supervisor.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 200f42e792..8f6341df08 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -253,7 +253,9 @@ def test_runtime_roots_include_candidate_interpreter_native_stdlib( monkeypatch.setattr(supervisor, "_runtime_directories", lambda: ()) monkeypatch.setattr(supervisor, "released_runtime_closure_paths", lambda: ()) - roots = _runtime_roots([str(candidate_python), "-c", "pass"], workdir) + roots = supervisor._runtime_roots( + [str(candidate_python), "-c", "pass"], workdir, + ) assert native_stdlib.resolve() in roots @@ -294,7 +296,7 @@ def guarded_resolve(path, *args, **kwargs): monkeypatch.setattr(supervisor, "released_runtime_closure_paths", lambda: ()) def fail_closed(command, *_args, **_kwargs): - _runtime_roots(command, workdir) + supervisor._runtime_roots(command, workdir) raise RuntimeError("protected candidate runtime unavailable") monkeypatch.setattr(supervisor, "_sandbox_command", fail_closed) @@ -304,7 +306,10 @@ def fail_closed(command, *_args, **_kwargs): ) assert result.returncode == 125 - assert result.stderr == "protected candidate runtime unavailable" + assert result.stderr == ( + "protected supervisor phase=construction: " + "protected candidate runtime unavailable" + ) assert surviving is False From 3f1a575990caf5c188d9eb7988e09e5a57f45b70 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 00:13:18 -0700 Subject: [PATCH 166/233] fix(sync): harden privileged playwright staging --- pdd/sync_core/runner.py | 82 +++++ pdd/sync_core/supervisor.py | 381 ++++++++++++++++++++-- tests/test_sync_core_runner_playwright.py | 7 + tests/test_sync_core_supervisor.py | 90 ++++- 4 files changed, 533 insertions(+), 27 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 661a2ffc04..166adc4222 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -51,6 +51,7 @@ ) from .supervisor import ( ImmutableBindingProof, + SnapshotBindingProof, SupervisorLimits, _vitest_descriptor_attestation, released_runtime_closure_paths, @@ -3157,6 +3158,83 @@ def _prepare_vitest_toolchain( return phase +def _snapshot_link_is_contained(relative: PurePosixPath, target: str) -> bool: + """Accept only a relative link whose lexical target remains in its root.""" + if PurePosixPath(target).is_absolute(): + return False + parts: list[str] = [] + for part in (*relative.parent.parts, *PurePosixPath(target).parts): + if part in {"", "."}: + continue + if part == "..": + if not parts: + return False + parts.pop() + else: + parts.append(part) + return True + + +def _snapshot_binding_proof(source: Path, destination: Path) -> SnapshotBindingProof: + """Capture a complete no-follow regular-file tree for helper-owned staging.""" + root = source.resolve(strict=True) + members: list[dict[str, object]] = [] + + def capture(path: Path, relative: PurePosixPath) -> None: + metadata = path.lstat() + mode = stat.S_IMODE(metadata.st_mode) + member: dict[str, object] = { + "path": relative.as_posix(), "mode": mode, + "digest": None, "target": None, + } + if stat.S_ISDIR(metadata.st_mode): + member["kind"] = "directory" + members.append(member) + for child in sorted(path.iterdir(), key=lambda item: item.name): + capture(child, relative / child.name) + elif stat.S_ISREG(metadata.st_mode): + member["kind"] = "file" + descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) + try: + digest = hashlib.sha256() + while chunk := os.read(descriptor, 1024 * 1024): + digest.update(chunk) + member["digest"] = digest.hexdigest() + finally: + os.close(descriptor) + members.append(member) + elif stat.S_ISLNK(metadata.st_mode): + target = os.readlink(path) + if not _snapshot_link_is_contained(relative, target): + raise ValueError("Playwright snapshot symlink escapes its declared root") + member["kind"] = "symlink" + member["target"] = target + members.append(member) + else: + raise ValueError("Playwright snapshot contains an unsupported special file") + + capture(root, PurePosixPath(".")) + members.sort(key=lambda member: str(member["path"])) + attestation = json.dumps({ + "schema": "pdd-snapshot-binding-v1", "source": str(root), + "destination": str(destination), "members": members, + }, sort_keys=True, separators=(",", ":")) + return SnapshotBindingProof(root, destination, attestation) + + +def _playwright_snapshot_binding_proofs( + reporter: Path, roles: PlaywrightToolchainRoles, dependency_destination: Path, +) -> tuple[SnapshotBindingProof, ...]: + """Bind every Playwright-owned executable and tree to a helper snapshot.""" + pairs = ( + (reporter, reporter), (roles.launcher, roles.launcher), + (roles.browser_runtime, roles.browser_runtime), (roles.lockfile, roles.lockfile), + (roles.dependencies, dependency_destination), + *((source, destination) for source, destination in roles.native_bindings), + ) + return tuple(_snapshot_binding_proof(source, destination) for source, destination in pairs) + + def _directory_identity(path: Path) -> str: """Return a stable digest for files under a protected dependency directory.""" digest = hashlib.sha256() @@ -5173,6 +5251,9 @@ def _run_playwright_in_tree( canonical_entrypoint = dependency_destination / roles.entrypoint.relative_to( roles.dependencies ) + snapshot_binding_proofs = _playwright_snapshot_binding_proofs( + reporter, roles, dependency_destination + ) except ValueError as exc: return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) @@ -5205,6 +5286,7 @@ def _run_playwright_in_tree( readable_bindings=( *native_bindings, (roles.dependencies, dependency_destination), ), + snapshot_binding_proofs=snapshot_binding_proofs, limits=PLAYWRIGHT_SUPERVISOR_LIMITS, result_fifo=result_fifo, result_fd=result_fd, diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 52b6747832..342ae970ed 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1,5 +1,5 @@ """Fail-closed OS sandbox and complete process-group supervision.""" -# pylint: disable=too-many-arguments,too-many-lines +# pylint: disable=too-many-arguments,too-many-lines,line-too-long from __future__ import annotations @@ -36,6 +36,7 @@ _MAX_BINDING_ATTESTATION_BYTES = 4 * 1024 * 1024 _VITEST_ATTESTATION_SCHEMA = "pdd-vitest-toolchain-attestation-v1" _BINDING_RECORD_SCHEMA = "pdd-immutable-binding-record-v1" +_SNAPSHOT_BINDING_SCHEMA = "pdd-snapshot-binding-v1" _CANDIDATE_IDENTITY_SCHEMA = "pdd-candidate-identity-v1" _VITEST_MEMBER_ROLES = frozenset({ "launcher", "entrypoint", "dependencies", "native_runtime", "lockfile", @@ -111,6 +112,15 @@ class ImmutableBindingProof: collision_category: str +@dataclass(frozen=True) +class SnapshotBindingProof: + """Complete no-follow tree identity for one read-only helper snapshot.""" + + source: Path + destination: Path + attestation: str + + @dataclass(frozen=True) # pylint: disable-next=too-many-instance-attributes class _ValidatedBindingProof: @@ -128,6 +138,26 @@ class _ValidatedBindingProof: member_mode: int +@dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes +class _ExecutableIdentity: + """Immutable identity for one executable crossing the root boundary.""" + + path: Path + stat_identity: tuple[int, int, int, int, int, int] + sha256: str + require_root: bool = True + + def payload(self) -> dict[str, object]: + """Serialize the exact root-side revalidation authority.""" + device, inode, mode, uid, size, mtime_ns = self.stat_identity + return { + "path": str(self.path), "device": device, "inode": inode, + "mode": mode, "uid": uid, "size": size, "mtime_ns": mtime_ns, + "sha256": self.sha256, + } + + @dataclass(frozen=True) # pylint: disable-next=too-many-instance-attributes class _TrustedTools: @@ -141,6 +171,8 @@ class _TrustedTools: systemd_run: Path umount: Path unshare: Path + helper_python: Path + identities: tuple[_ExecutableIdentity, ...] @dataclass(frozen=True) @@ -432,6 +464,147 @@ def _validate_immutable_binding_proof( raise RuntimeError("protected sandbox immutable binding proof is malformed") from exc +def _validate_snapshot_binding_proof(proof: SnapshotBindingProof) -> None: + """Validate canonical source/destination authority before helper handoff.""" + # Exact built-in types keep the serialized authority unambiguous. + # pylint: disable=unidiomatic-typecheck,too-many-boolean-expressions + try: + if type(proof) is not SnapshotBindingProof: + raise ValueError("invalid snapshot proof type") + if type(proof.source) is not type(Path()) or type(proof.destination) is not type(Path()): + raise ValueError("invalid snapshot proof paths") + if type(proof.attestation) is not str or len(proof.attestation.encode()) > _MAX_BINDING_ATTESTATION_BYTES: + raise ValueError("invalid snapshot proof attestation") + payload = json.loads(proof.attestation) + if ( + type(payload) is not dict + or set(payload) != {"schema", "source", "destination", "members"} + or payload["schema"] != _SNAPSHOT_BINDING_SCHEMA + or payload["source"] != str(proof.source.resolve(strict=True)) + or payload["destination"] != str(proof.destination) + or proof.attestation != _canonical_json(payload) + or type(payload["members"]) is not list + or not payload["members"] + ): + raise ValueError("invalid snapshot proof authority") + members = payload["members"] + paths = [] + for member in members: + if type(member) is not dict or set(member) != {"path", "kind", "mode", "digest", "target"}: + raise ValueError("invalid snapshot member") + path = member["path"] + if type(path) is not str or not path or len(path) > 4096: + raise ValueError("invalid snapshot member path") + parsed = PurePosixPath(path) + if parsed.is_absolute() or ".." in parsed.parts or parsed.as_posix() != path: + raise ValueError("invalid snapshot member path") + if member["kind"] not in {"file", "directory", "symlink"} or type(member["mode"]) is not int: + raise ValueError("invalid snapshot member type") + paths.append(path) + if paths != sorted(paths) or paths[0] != "." or len(paths) != len(set(paths)): + raise ValueError("non-canonical snapshot members") + except (AttributeError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise RuntimeError("protected sandbox snapshot binding proof is malformed") from exc + + +_SNAPSHOT_STAGING_SOURCE = r''' +def _snapshot_failure(): + raise RuntimeError("protected sandbox snapshot attestation failed at staging") + +def _snapshot_canonical_json(value): + return json.dumps(value,sort_keys=True,separators=(",",":")) + +def _snapshot_member_fd(root_fd,relative,directory=False): + parts=pathlib.PurePosixPath(relative).parts + descriptor=os.dup(root_fd) + try: + for part in parts: + if part==".": continue + next_fd=os.open(part,os.O_RDONLY|os.O_CLOEXEC|getattr(os,"O_NOFOLLOW",0)|(os.O_DIRECTORY if directory or part!=parts[-1] else 0),dir_fd=descriptor) + os.close(descriptor); descriptor=next_fd + return descriptor + except BaseException: + os.close(descriptor); raise + +def _snapshot_parent_fd(root_fd,relative): + parts=pathlib.PurePosixPath(relative).parts + descriptor=os.dup(root_fd) + try: + for part in parts[:-1]: + if part==".": continue + next_fd=os.open(part,os.O_RDONLY|os.O_CLOEXEC|os.O_DIRECTORY|getattr(os,"O_NOFOLLOW",0),dir_fd=descriptor) + os.close(descriptor); descriptor=next_fd + return descriptor,parts[-1] + except BaseException: + os.close(descriptor); raise + +def _snapshot_link_is_contained(relative,target): + if pathlib.PurePosixPath(target).is_absolute(): return False + parts=[] + for part in (*pathlib.PurePosixPath(relative).parent.parts,*pathlib.PurePosixPath(target).parts): + if part in {"","."}: continue + if part=="..": + if not parts: return False + parts.pop() + else: parts.append(part) + return True + +def _stage_snapshot(encoded,source,target): + try: record=json.loads(encoded); payload=json.loads(record["attestation"]); payload["source_index"]=record["source_index"] + except (TypeError,ValueError): _snapshot_failure() + if type(payload) is not dict or set(payload)!={"schema","source","destination","members","source_index"} or payload["schema"]!="pdd-snapshot-binding-v1" or encoded!=_snapshot_canonical_json(record): _snapshot_failure() + if payload["source"]!=str(source) or type(payload["members"]) is not list or not payload["members"]: _snapshot_failure() + members=payload["members"]; names=[] + for member in members: + if type(member) is not dict or set(member)!={"path","kind","mode","digest","target"}: _snapshot_failure() + relative=member["path"]; kind=member["kind"] + parsed=pathlib.PurePosixPath(relative) if type(relative) is str else None + if not relative or parsed.is_absolute() or ".." in parsed.parts or parsed.as_posix()!=relative or kind not in {"file","directory","symlink"} or type(member["mode"]) is not int: _snapshot_failure() + if kind=="file" and (type(member["digest"]) is not str or member["target"] is not None): _snapshot_failure() + if kind=="directory" and (member["digest"] is not None or member["target"] is not None): _snapshot_failure() + if kind=="symlink" and (member["digest"] is not None or type(member["target"]) is not str or not member["target"] or not _snapshot_link_is_contained(relative,member["target"])): _snapshot_failure() + names.append(relative) + if names!=sorted(names) or names[0]!="." or len(names)!=len(set(names)): _snapshot_failure() + root_fd=os.open(source,os.O_RDONLY|os.O_CLOEXEC|getattr(os,"O_NOFOLLOW",0)|(os.O_DIRECTORY if members[0]["kind"]=="directory" else 0)) + try: + for member in members: + relative=member["path"]; kind=member["kind"]; descriptor=None + if relative==".": metadata=os.fstat(root_fd) + elif kind=="symlink": + descriptor,name=_snapshot_parent_fd(root_fd,relative) + try: metadata=os.stat(name,dir_fd=descriptor,follow_symlinks=False); target_text=os.readlink(name,dir_fd=descriptor) + finally: os.close(descriptor) + else: + descriptor=_snapshot_member_fd(root_fd,relative,directory=kind=="directory") + metadata=os.fstat(descriptor) + if stat.S_IMODE(metadata.st_mode)!=member["mode"]: _snapshot_failure() + destination=pathlib.Path(target) if relative=="." else pathlib.Path(target)/relative + if kind=="directory": + if not stat.S_ISDIR(metadata.st_mode): _snapshot_failure() + if relative!=".": destination.mkdir(mode=member["mode"]) + if descriptor is not None: os.close(descriptor) + elif kind=="symlink": + if not stat.S_ISLNK(metadata.st_mode) or target_text!=member["target"]: _snapshot_failure() + os.symlink(member["target"],destination) + else: + if not stat.S_ISREG(metadata.st_mode): _snapshot_failure() + source_fd=descriptor + try: + digest=hashlib.sha256(); destination_fd=os.open(destination,os.O_WRONLY|os.O_CREAT|os.O_TRUNC|getattr(os,"O_NOFOLLOW",0),member["mode"]) + try: + while True: + chunk=os.read(source_fd,1048576) + if not chunk: break + digest.update(chunk); os.write(destination_fd,chunk) + os.fchmod(destination_fd,member["mode"]) + finally: os.close(destination_fd) + finally: os.close(source_fd) + if digest.hexdigest()!=member["digest"]: _snapshot_failure() + except OSError: _snapshot_failure() + finally: os.close(root_fd) +'''.strip() + + _IMMUTABLE_STAGING_SOURCE = r''' def _immutable_failure(): raise RuntimeError("protected sandbox immutable binding proof failed at staging") @@ -622,29 +795,121 @@ def _validated_scope_unit(unit_name: str) -> str: return unit_name -def _trusted_executable(name: str) -> Path: - """Resolve one regular executable from the fixed trusted root PATH.""" +def _validate_trusted_executable_chain(path: Path) -> None: + """Reject executable ancestors writable by an unprivileged caller.""" + if not path.is_absolute(): + raise RuntimeError("protected executable path is not absolute") + for directory in reversed(path.parents): + try: + metadata = directory.lstat() + except OSError as exc: + raise RuntimeError("protected executable ancestor is unavailable") from exc + if ( + not stat.S_ISDIR(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + or metadata.st_uid != 0 + or metadata.st_mode & 0o022 + ): + raise RuntimeError("protected executable ancestor is attacker-writable") + + +def _executable_identity(path: Path, *, require_root: bool = True) -> _ExecutableIdentity: + """Measure one root-owned executable using a no-follow descriptor.""" + descriptor = None + try: + resolved = path.resolve(strict=True) + if require_root: + _validate_trusted_executable_chain(resolved) + descriptor = os.open( + resolved, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0) + ) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or not metadata.st_mode & 0o111 + or (require_root and metadata.st_uid != 0) + or metadata.st_mode & 0o022 + ): + raise RuntimeError("protected sandbox requires a trusted root-owned executable") + digest = hashlib.sha256() + while chunk := os.read(descriptor, 1024 * 1024): + digest.update(chunk) + final_metadata = os.fstat(descriptor) + if require_root: + _validate_trusted_executable_chain(resolved) + except OSError as exc: + raise RuntimeError("protected sandbox requires a trusted root-owned executable") from exc + finally: + if descriptor is not None: + os.close(descriptor) + measured = ( + metadata.st_dev, metadata.st_ino, stat.S_IMODE(metadata.st_mode), + metadata.st_uid, metadata.st_size, metadata.st_mtime_ns, + ) + final = ( + final_metadata.st_dev, final_metadata.st_ino, + stat.S_IMODE(final_metadata.st_mode), final_metadata.st_uid, + final_metadata.st_size, final_metadata.st_mtime_ns, + ) + if measured != final: + raise RuntimeError("protected executable identity changed during measurement") + return _ExecutableIdentity(resolved, measured, digest.hexdigest(), require_root) + + +def _revalidate_executable(expected: _ExecutableIdentity) -> None: + """Fail closed when a privileged executable or an ancestor changed.""" + try: + current = _executable_identity(expected.path, require_root=expected.require_root) + except RuntimeError as exc: + raise RuntimeError("protected executable identity changed") from exc + if current != expected: + raise RuntimeError("protected executable identity changed") + + +def _trusted_executable(name: str) -> _ExecutableIdentity: + """Resolve one root-owned executable from the fixed trusted PATH.""" value = shutil.which(name, path=_TRUSTED_ROOT_PATH) if value is None: raise RuntimeError(f"protected sandbox requires trusted {name}") - try: - path = Path(value).resolve(strict=True) - except OSError as exc: - raise RuntimeError(f"protected sandbox cannot resolve trusted {name}") from exc - if not path.is_file() or not os.access(path, os.X_OK): - raise RuntimeError(f"protected sandbox trusted {name} is not executable") - return path + return _executable_identity(Path(value)) + + +def _trusted_helper_python() -> _ExecutableIdentity: + """Select an independently root-owned interpreter for the root helper.""" + for candidate in (Path("/usr/bin/python3"), Path("/bin/python3")): + try: + return _executable_identity(candidate) + except RuntimeError: + continue + return _trusted_executable("python3") def _trusted_tools() -> _TrustedTools: """Resolve the complete privileged toolchain once for probe and execution.""" - return _TrustedTools(**{ + identities = { name.replace("-", "_"): _trusted_executable(name) for name in ( "bwrap", "mount", "setpriv", "sudo", "systemctl", "systemd-run", "umount", "unshare", ) - }) + } + helper = _trusted_helper_python() + return _TrustedTools( + **{name: identity.path for name, identity in identities.items()}, + helper_python=helper.path, + identities=tuple((*identities.values(), helper)), + ) + + +def _revalidate_trusted_tools(tools: _TrustedTools) -> None: + """Revalidate every executable immediately before a privileged transition.""" + for identity in getattr(tools, "identities", ()): + _revalidate_executable(identity) + + +def _privileged_helper_environment() -> dict[str, str]: + """Return a constant root helper environment with no Python startup hooks.""" + return {"HOME": "/root", "LANG": "C", "LC_ALL": "C", "PATH": _TRUSTED_ROOT_PATH} def _linked_libraries(path: Path) -> tuple[Path, ...]: @@ -852,7 +1117,7 @@ def released_runtime_closure_paths() -> tuple[tuple[str, Path], ...]: "umount", "unshare", ): if shutil.which(name, path=_TRUSTED_ROOT_PATH): - sandbox_commands[name] = _trusted_executable(name) + sandbox_commands[name] = _trusted_executable(name).path for name, value in sandbox_commands.items(): path = Path(value).resolve() entries[f"sandbox/{name}"] = path @@ -937,6 +1202,7 @@ def _staged_bwrap( argv: list[str], sources: list[Path], path_tokens: list[str], *, writable_roots: tuple[Path, ...], writable_specs: list[tuple[str, int, str]], immutable_binding_proofs: tuple[str, ...], + snapshot_binding_proofs: tuple[str, ...], candidate_identity: str, unit_name: str, control_directory: Path, limits: SupervisorLimits, candidate_timeout: float, tools: _TrustedTools, @@ -944,6 +1210,13 @@ def _staged_bwrap( """Build one scope-held helper that releases Bubblewrap after verification.""" # pylint: disable=too-many-locals unit_name = _validated_scope_unit(unit_name) + tool_manifest = _canonical_json({ + name: identity.payload() for name, identity in zip( + ("bwrap", "mount", "setpriv", "sudo", "systemctl", "systemd-run", "umount", "unshare", "python"), + tools.identities, + strict=True, + ) + }) staging_root = control_directory / "binds" source_targets = tuple( control_directory / "binds" / str(index) for index in range(len(sources)) @@ -970,11 +1243,37 @@ def _staged_bwrap( "argv=json.loads(sys.argv[9]); paths=json.loads(sys.argv[10])", "fifo_indices=json.loads(sys.argv[11])", "limits=json.loads(sys.argv[12])", + "tool_manifest=json.loads(" + repr(tool_manifest) + ")", + "def verify_tool(name):", + " expected=tool_manifest[name]; path=pathlib.Path(expected['path'])", + " for parent in reversed(path.parents):", + " metadata=parent.lstat()", + " if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode) or metadata.st_uid!=0 or metadata.st_mode&0o022: raise RuntimeError('protected executable ancestor changed')", + " fd=os.open(path,os.O_RDONLY|os.O_CLOEXEC|getattr(os,'O_NOFOLLOW',0))", + " try:", + " before=os.fstat(fd); digest=hashlib.sha256()", + " while True:", + " chunk=os.read(fd,1048576)", + " if not chunk: break", + " digest.update(chunk)", + " after=os.fstat(fd)", + " finally: os.close(fd)", + " actual={'path':str(path.resolve(strict=True)),'device':before.st_dev,'inode':before.st_ino,'mode':stat.S_IMODE(before.st_mode),'uid':before.st_uid,'size':before.st_size,'mtime_ns':before.st_mtime_ns,'sha256':digest.hexdigest()}", + " if actual!=expected or before.st_uid!=0 or before.st_mode&0o022 or not stat.S_ISREG(before.st_mode) or not before.st_mode&0o111 or (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 RuntimeError('protected executable identity changed')", + " return str(path)", + "_subprocess_run=subprocess.run", + "def trusted_run(arguments,**kwargs):", + " if arguments and arguments[0] in {mount,umount}: verify_tool('mount' if arguments[0]==mount else 'umount')", + " return _subprocess_run(arguments,**kwargs)", + "subprocess.run=trusted_run", + "if set(tool_manifest)!={'bwrap','mount','setpriv','sudo','systemctl','systemd-run','umount','unshare','python'}: raise RuntimeError('invalid protected executable manifest')", + "for tool_name in tool_manifest: verify_tool(tool_name)", "targets=[control/'binds'/str(index) for index in range(len(paths))]", "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None", "scope_cgroup=None; monitor_cgroup=None; candidate_cgroup=None", _PIDFD_PROTOCOL_SOURCE.strip(), _IMMUTABLE_STAGING_SOURCE, + _SNAPSHOT_STAGING_SOURCE, "candidate_uid,candidate_gid=" "_validated_candidate_identity(candidate_identity,argv)", "def wait_for(name):", @@ -1067,21 +1366,27 @@ def _staged_bwrap( " if type(proof_records) is not list or " "any(type(value) is not str for value in proof_records): " "raise RuntimeError('invalid immutable binding proof protocol')", - " proof_by_index={}", + " proof_by_index={}; snapshot_by_index={}", " for encoded in proof_records:", - " try: preliminary=json.loads(encoded); source_index=preliminary['source_index']", + " try: preliminary=json.loads(encoded); source_index=preliminary['source_index']; schema=preliminary['schema']", " except (TypeError,ValueError,KeyError): _immutable_failure()", " if type(source_index) is not int or source_index<0 or " - "source_index>=len(paths) or source_index in proof_by_index: " + "source_index>=len(paths) or source_index in proof_by_index or source_index in snapshot_by_index: " "_immutable_failure()", - " proof_by_index[source_index]=encoded", + " if schema=='pdd-immutable-binding-record-v1': proof_by_index[source_index]=encoded", + " elif schema=='pdd-snapshot-binding-record-v1': snapshot_by_index[source_index]=encoded", + " else: _immutable_failure()", " if type(fifo_indices) is not list or len(fifo_indices)>1 or " "any(type(value) is not int or value<0 or value>=len(paths) " "for value in fifo_indices): " "raise RuntimeError('invalid protected FIFO staging protocol')", " for index,(source,target) in enumerate(zip(paths,targets)):", " metadata=pathlib.Path(source).lstat()", - " if index in proof_by_index or stat.S_ISREG(metadata.st_mode): target.touch(mode=0o600)", + " if index in snapshot_by_index:", + " try: root_member=json.loads(json.loads(snapshot_by_index[index])['attestation'])['members'][0]", + " except (KeyError,TypeError,ValueError,IndexError): _snapshot_failure()", + " target.mkdir(mode=0o700) if root_member.get('kind')=='directory' else target.touch(mode=0o600)", + " elif index in proof_by_index or stat.S_ISREG(metadata.st_mode): target.touch(mode=0o600)", " elif stat.S_ISDIR(metadata.st_mode): target.mkdir(mode=0o700)", " elif index in fifo_indices and stat.S_ISFIFO(metadata.st_mode): " "os.mkfifo(target,mode=0o600)", @@ -1113,7 +1418,9 @@ def _staged_bwrap( " if sum(validate_tree(path) for path in writable_paths) > " "limits['writable']: raise RuntimeError('initial writable quota exceeded')", " for index,(source,target) in enumerate(zip(paths,targets)):", - " if index in proof_by_index:", + " if index in snapshot_by_index:", + " _stage_snapshot(snapshot_by_index[index],source,target)", + " elif index in proof_by_index:", " if _stage_immutable_snapshot(proof_by_index[index],target," "candidate_uid,candidate_gid)!=index: _immutable_failure()", " else:", @@ -1138,6 +1445,7 @@ def _staged_bwrap( " (control/'ready').write_text('ready',encoding='ascii')", " wait_for('start')", " release_read,release_write=os.pipe()", + " verify_tool('bwrap'); verify_tool('setpriv')", " pid=os.fork()", " if pid == 0:", " os.close(release_write)", @@ -1200,16 +1508,16 @@ def _staged_bwrap( immutable_binding_proofs, ) command = [ - str(tools.sudo), "-n", "-E", str(tools.systemd_run), "--scope", "--quiet", + str(tools.sudo), "-n", str(tools.systemd_run), "--scope", "--quiet", f"--unit={unit_name}", "--property=Delegate=yes", "--property=MemoryMax=infinity", "--property=MemorySwapMax=infinity", "--property=TasksMax=infinity", "--property=OOMPolicy=continue", "--property=KillMode=control-group", "--", str(tools.unshare), "--mount", "--propagation", "private", "--wd", "/", - str(_SUPERVISOR_EXECUTABLE), "-c", helper, str(control_directory), + str(tools.helper_python), "-I", "-S", "-c", helper, str(control_directory), str(tools.mount), str(tools.umount), candidate_identity, - json.dumps(list(immutable_binding_proofs)), + json.dumps(list((*immutable_binding_proofs, *snapshot_binding_proofs))), json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), json.dumps(path_tokens), json.dumps(argv), json.dumps([str(path) for path in sources]), @@ -1381,6 +1689,7 @@ def _root_run( tools: _TrustedTools, arguments: list[str], *, check: bool = False, ) -> subprocess.CompletedProcess[str]: """Invoke systemctl through the exact sudo/systemctl identities already probed.""" + _revalidate_trusted_tools(tools) try: return subprocess.run( [str(tools.sudo), "-n", str(tools.systemctl), *arguments], @@ -1610,6 +1919,7 @@ def _sandbox_command( readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), immutable_binding_proofs: tuple[ImmutableBindingProof, ...] = (), + snapshot_binding_proofs: tuple[SnapshotBindingProof, ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), result_fifo: Path | None = None, result_fd: int = 198, @@ -1678,6 +1988,13 @@ def _sandbox_command( mounted: dict[Path, tuple[str, Path, str, int | None]] = {} proofs: dict[tuple[Path, Path, Path], _ValidatedBindingProof] = {} raw_proofs: dict[tuple[Path, Path, Path], ImmutableBindingProof] = {} + snapshots: dict[tuple[Path, Path], SnapshotBindingProof] = {} + for proof in snapshot_binding_proofs: + _validate_snapshot_binding_proof(proof) + key = (proof.source.resolve(strict=True), proof.destination) + if key in snapshots: + raise RuntimeError("protected sandbox has duplicate snapshot binding proofs") + snapshots[key] = proof for proof in immutable_binding_proofs: try: if type(proof) is not ImmutableBindingProof: @@ -1711,6 +2028,7 @@ def _sandbox_command( proofs[key] = validated consumed_proofs: set[tuple[Path, Path, Path]] = set() accepted_records: list[str] = [] + accepted_snapshots: list[str] = [] def stage_source(source: Path, writable: bool = False) -> tuple[str, int | None]: token = f"@PDD-PATH-{uuid.uuid4().hex}@" @@ -1781,6 +2099,15 @@ def bind( f"protected sandbox has conflicting bindings for {destination}" ) token, source_index = stage_source(source, option == "--bind") + snapshot = snapshots.get((resolved_source, destination)) + if snapshot is not None: + if option != "--ro-bind": + raise RuntimeError("snapshot binding must be read-only") + accepted_snapshots.append(_canonical_json({ + "schema": "pdd-snapshot-binding-record-v1", + "source_index": source_index, + "attestation": snapshot.attestation, + })) mounted[destination] = ( option, resolved_source, category, source_index ) @@ -1849,10 +2176,13 @@ def bind( argv.extend(("--", *drop, *sandboxed)) if consumed_proofs != proofs.keys(): raise RuntimeError("protected sandbox has unused immutable binding proof") + if len(accepted_snapshots) != len(snapshots): + raise RuntimeError("protected sandbox has unused snapshot binding proof") return _staged_bwrap( argv, sources, path_tokens, writable_roots=storage_roots, writable_specs=writable_specs, immutable_binding_proofs=tuple(accepted_records), + snapshot_binding_proofs=tuple(accepted_snapshots), candidate_identity=candidate_identity, unit_name=unit_name or _scope_unit_name(), control_directory=control_directory or ( @@ -1870,6 +2200,7 @@ def run_supervised( readable_roots: tuple[Path, ...] = (), readable_bindings: tuple[tuple[Path, Path], ...] = (), immutable_binding_proofs: tuple[ImmutableBindingProof, ...] = (), + snapshot_binding_proofs: tuple[SnapshotBindingProof, ...] = (), writable_bindings: tuple[tuple[Path, Path], ...] = (), temp_directory: Path | None = None, result_fifo: Path | None = None, @@ -1958,6 +2289,7 @@ def record_events() -> None: limits=limits, readable_roots=readable_roots, readable_bindings=readable_bindings, immutable_binding_proofs=immutable_binding_proofs, + snapshot_binding_proofs=snapshot_binding_proofs, writable_bindings=writable_bindings, result_fifo=result_fifo, result_fd=result_fd, candidate_timeout=timeout, @@ -1980,9 +2312,10 @@ def record_events() -> None: if library_path: sandbox_environment["LD_LIBRARY_PATH"] = library_path try: + _revalidate_trusted_tools(plan.tools) process = subprocess.Popen( - argv, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - env=sandbox_environment, start_new_session=True, + argv, cwd=Path("/"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=_privileged_helper_environment(), start_new_session=True, ) except OSError as exc: try: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 918b8e7746..bc7a8a2bac 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -836,6 +836,7 @@ def test_playwright_execution_uses_process_group_supervisor( root, commit = _repository(tmp_path) calls: list[list[str]] = [] readable_bindings = [] + snapshot_proofs = [] scratch_bindings = [] temp_directories = [] phase_roots = [] @@ -843,6 +844,7 @@ def test_playwright_execution_uses_process_group_supervisor( def supervised(command, **_kwargs): calls.append(command) readable_bindings.append(_kwargs["readable_bindings"]) + snapshot_proofs.append(_kwargs["snapshot_binding_proofs"]) scratch_bindings.append(_kwargs["writable_bindings"]) temp_directories.append(_kwargs["temp_directory"]) phase_roots.append(_kwargs["cwd"]) @@ -867,6 +869,11 @@ def supervised(command, **_kwargs): dependency_source, dependency_destination = readable_bindings[0][-1] assert dependency_source.name == "node_modules" assert dependency_destination == phase_roots[0] / "node_modules" + assert {proof.destination for proof in snapshot_proofs[0]} >= { + dependency_destination, + calls[0][0] and Path(calls[0][0]), + } + assert all("pdd-snapshot-binding-v1" in proof.attestation for proof in snapshot_proofs[0]) def test_playwright_exact_filter_escapes_regex_metacharacters( diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 8f6341df08..7182b7f2ba 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -49,9 +49,93 @@ def _mock_linux_tools( path.chmod(0o755) tools[name] = str(path) monkeypatch.setattr(shutil, "which", lambda name, path=None: tools.get(name)) + # These unit tests inspect command construction on macOS with synthetic + # paths. Production identity validation is covered separately below. + def fake_identity(name: str): + path = Path(tools[name]) + metadata = path.stat() + return supervisor._ExecutableIdentity( + path, + (metadata.st_dev, metadata.st_ino, stat.S_IMODE(metadata.st_mode), + 0, metadata.st_size, metadata.st_mtime_ns), + hashlib.sha256(path.read_bytes()).hexdigest(), + ) + monkeypatch.setattr(supervisor, "_trusted_executable", fake_identity) + monkeypatch.setattr( + supervisor, "_trusted_helper_python", lambda: fake_identity("bwrap") + ) + monkeypatch.setattr(supervisor, "_revalidate_trusted_tools", lambda _tools: None) return tools +def test_privileged_helper_environment_excludes_hostile_python_startup() -> None: + """The root helper never inherits candidate Python startup controls.""" + assert supervisor._privileged_helper_environment() == { + "HOME": "/root", "LANG": "C", "LC_ALL": "C", + "PATH": supervisor._TRUSTED_ROOT_PATH, + } + + +@pytest.mark.parametrize("swap", ["content", "mode", "rename", "symlink"]) +def test_privileged_helper_rejects_executable_swaps( + tmp_path: Path, swap: str, +) -> None: + """Digest, metadata, and path replacement changes all fail closed.""" + executable = tmp_path / "tool" + executable.write_bytes(b"trusted") + executable.chmod(0o755) + measured = supervisor._executable_identity(executable, require_root=False) + if swap == "content": + executable.write_bytes(b"replacement") + elif swap == "mode": + executable.chmod(0o777) + else: + original = tmp_path / "original" + executable.rename(original) + if swap == "symlink": + executable.symlink_to(original) + else: + executable.write_bytes(b"replacement") + executable.chmod(0o755) + with pytest.raises(RuntimeError, match="identity changed"): + supervisor._revalidate_executable(measured) + + +def test_privileged_helper_rejects_user_owned_executable(tmp_path: Path) -> None: + """A caller-controlled PATH result cannot become a privileged tool.""" + executable = tmp_path / "tool" + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + with pytest.raises(RuntimeError, match="ancestor|root-owned"): + supervisor._executable_identity(executable) + + +def test_helper_snapshot_rejects_attested_file_swap(tmp_path: Path) -> None: + """The helper copies only bytes that still match the runner attestation.""" + from pdd.sync_core.runner import _snapshot_binding_proof # pylint: disable=import-outside-toplevel + + source = tmp_path / "toolchain" + source.mkdir() + member = source / "launcher" + member.write_bytes(b"measured") + proof = _snapshot_binding_proof(source, Path("/opt/toolchain")) + member.write_bytes(b"swapped-and-restored-later") + target = tmp_path / "snapshot" + target.mkdir() + namespace = { + "hashlib": hashlib, "json": json, "os": os, "pathlib": __import__("pathlib"), + "stat": stat, + } + exec(supervisor._SNAPSHOT_STAGING_SOURCE, namespace) # pylint: disable=exec-used + with pytest.raises(RuntimeError, match="snapshot attestation"): + namespace["_stage_snapshot"]( + json.dumps({ + "schema": "pdd-snapshot-binding-record-v1", "source_index": 0, + "attestation": proof.attestation, + }, sort_keys=True, separators=(",", ":")), source, target, + ) + + def _descriptor_runtime_proof( copied: Path, protected: Path, *, destination: Path | None = None, native_mode: int = 0o644, @@ -412,7 +496,7 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( ) argv, plan = _sandbox_command(["/bin/true"], (tmp_path,)) assert plan.unit_name.startswith("pdd-validator-") - assert argv[:3] == [str(plan.tools.sudo), "-n", "-E"] + assert argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] bwrap = json.loads(argv[-4]) assert {"--unshare-pid", "--unshare-net", "--unshare-cgroup"} <= set(bwrap) assert "--unshare-user" not in bwrap @@ -1177,7 +1261,7 @@ def test_linux_sandbox_uses_portable_framework_observation_fifo( ) assert plan.unit_name.startswith("pdd-validator-") - assert argv[:3] == [str(plan.tools.sudo), "-n", "-E"] + assert argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] assert "-C" not in argv[:6] bwrap = json.loads(argv[-4]) assert "--preserve-fds" not in bwrap @@ -1792,7 +1876,7 @@ def test_linux_sandbox_binds_probe_identity_to_systemd_scope_execution( argv, plan = _sandbox_command([sys.executable, "-c", "pass"], (tmp_path,)) - assert argv[:4] == [tools["sudo"], "-n", "-E", tools["systemd-run"]] + assert argv[:3] == [tools["sudo"], "-n", tools["systemd-run"]] separator = argv.index("--") assert argv[separator + 1:separator + 7] == [ tools["unshare"], "--mount", "--propagation", "private", "--wd", "/", From 11e93e3337f3ab31ba0ddebf50591d69c6be1b6a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 00:13:56 -0700 Subject: [PATCH 167/233] fix(sync): isolate privileged probe interpreter --- pdd/sync_core/supervisor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 342ae970ed..508bd2794e 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1961,7 +1961,7 @@ def _sandbox_command( }) try: privilege_probe = subprocess.run( - [str(tools.sudo), "-n", str(_SUPERVISOR_EXECUTABLE), "-c", "pass"], + [str(tools.sudo), "-n", str(tools.helper_python), "-I", "-S", "-c", "pass"], capture_output=True, check=False, env=_root_environment(), timeout=_TRUSTED_COMMAND_SECONDS, ) From 34ec1a37f4ea4b9da163ebb5a40dd5244eafc1fb Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 00:21:14 -0700 Subject: [PATCH 168/233] fix(sync): bind exact Playwright launcher snapshot --- pdd/sync_core/runner.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 166adc4222..79230b788e 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -3223,14 +3223,18 @@ def capture(path: Path, relative: PurePosixPath) -> None: def _playwright_snapshot_binding_proofs( - reporter: Path, roles: PlaywrightToolchainRoles, dependency_destination: Path, + reporter: Path, + roles: PlaywrightToolchainRoles, + launcher_destination: Path, + dependency_destination: Path, + native_bindings: tuple[tuple[Path, Path], ...], ) -> tuple[SnapshotBindingProof, ...]: """Bind every Playwright-owned executable and tree to a helper snapshot.""" pairs = ( - (reporter, reporter), (roles.launcher, roles.launcher), + (reporter, reporter), (roles.launcher, launcher_destination), (roles.browser_runtime, roles.browser_runtime), (roles.lockfile, roles.lockfile), (roles.dependencies, dependency_destination), - *((source, destination) for source, destination in roles.native_bindings), + *native_bindings, ) return tuple(_snapshot_binding_proof(source, destination) for source, destination in pairs) @@ -5194,6 +5198,7 @@ def _run_playwright_in_tree( ), () roles = _toolchain_manifest_roles(config.playwright_toolchain_manifest) native_bindings = roles.native_bindings + runtime_prefix = _playwright_runtime_prefix(prefix, roles.launcher) destination_error = _playwright_sandbox_destination_error( roles, native_bindings ) @@ -5252,14 +5257,18 @@ def _run_playwright_in_tree( roles.dependencies ) snapshot_binding_proofs = _playwright_snapshot_binding_proofs( - reporter, roles, dependency_destination + reporter, + roles, + Path(runtime_prefix[0]), + dependency_destination, + native_bindings, ) except ValueError as exc: return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) ), () command = [ - _playwright_runtime_prefix(prefix, roles.launcher)[0], + runtime_prefix[0], str(canonical_entrypoint), "test", *(f"^{re.escape(str(root / path))}$" for path in paths), f"--config={root / config_path}", f"--reporter={reporter}", From 09c6715b992ed46108896f25532b9a06dc9b80fd Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 00:44:29 -0700 Subject: [PATCH 169/233] fix(sync): bind playwright snapshots to accepted identity --- pdd/sync_core/runner.py | 183 ++++++++++++++++++++-- pdd/sync_core/supervisor.py | 51 ++++++ tests/test_sync_core_runner_playwright.py | 146 +++++++++++++++++ tests/test_sync_core_supervisor.py | 78 +++++++++ 4 files changed, 449 insertions(+), 9 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 79230b788e..5e50dbda8f 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -195,6 +195,8 @@ class VitestPhaseToolchain: _PLAYWRIGHT_TEMP_FAILURE_DIGEST = hashlib.sha256( b"playwright-temporary-directory" ).hexdigest() +_PLAYWRIGHT_SNAPSHOT_TOOLCHAIN_SCHEMA = "pdd-playwright-snapshot-toolchain-v1" +_PLAYWRIGHT_SNAPSHOT_AGGREGATE_SCHEMA = "pdd-playwright-snapshot-aggregate-v1" PLAYWRIGHT_TOOLCHAIN_ROLES = { "launcher", "entrypoint", "dependencies", "browser_runtime", @@ -3239,6 +3241,141 @@ def _playwright_snapshot_binding_proofs( return tuple(_snapshot_binding_proof(source, destination) for source, destination in pairs) +def _snapshot_binding_members(proof: SnapshotBindingProof) -> list[dict[str, object]]: + """Return the canonical member set from one locally constructed snapshot.""" + # Exact built-in containers keep the role identity serialization unambiguous. + # pylint: disable=unidiomatic-typecheck + try: + payload = json.loads(proof.attestation) + if ( + type(payload) is not dict + or set(payload) != {"schema", "source", "destination", "members"} + or payload["schema"] != "pdd-snapshot-binding-v1" + or payload["source"] != str(proof.source) + or payload["destination"] != str(proof.destination) + or proof.attestation != json.dumps( + payload, sort_keys=True, separators=(",", ":") + ) + or type(payload["members"]) is not list + ): + raise ValueError("invalid Playwright snapshot attestation") + return payload["members"] + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise ValueError("invalid Playwright snapshot attestation") from exc + + +def _playwright_snapshot_toolchain_payload( + launcher_members: list[dict[str, object]], + dependency_members: list[dict[str, object]], + browser_members: list[dict[str, object]], + native_members: tuple[list[dict[str, object]], ...], + lockfile_members: list[dict[str, object]], + entrypoint_relative: PurePosixPath, +) -> dict[str, object]: + """Build the relocation-stable typed identity payload from snapshot members.""" + entrypoint_path = entrypoint_relative.as_posix() + entrypoint = [ + member for member in dependency_members + if member.get("path") == entrypoint_path and member.get("kind") == "file" + ] + if len(entrypoint) != 1: + raise ValueError("Playwright snapshot does not contain the declared entrypoint") + return { + "schema": _PLAYWRIGHT_SNAPSHOT_TOOLCHAIN_SCHEMA, + "roles": [ + {"role": "browser_runtime", "members": browser_members}, + {"role": "dependencies", "members": dependency_members}, + {"role": "entrypoint", "members": entrypoint}, + {"role": "launcher", "members": launcher_members}, + *( + { + "role": "native_runtime", + "path": str(index), + "members": members, + } + for index, members in enumerate(native_members) + ), + {"role": "lockfile", "members": lockfile_members}, + ], + } + + +def _playwright_snapshot_toolchain_identity( + launcher_members: list[dict[str, object]], + dependency_members: list[dict[str, object]], + browser_members: list[dict[str, object]], + native_members: tuple[list[dict[str, object]], ...], + lockfile_members: list[dict[str, object]], + entrypoint_relative: PurePosixPath, +) -> str: + """Hash the typed immutable role membership shared by identity and staging.""" + payload = _playwright_snapshot_toolchain_payload( + launcher_members, + dependency_members, + browser_members, + native_members, + lockfile_members, + entrypoint_relative, + ) + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +def _playwright_snapshot_aggregate_identity( + proofs: tuple[SnapshotBindingProof, ...], + reporter: Path, + roles: PlaywrightToolchainRoles, + launcher_destination: Path, + dependency_destination: Path, + native_bindings: tuple[tuple[Path, Path], ...], +) -> tuple[str, str]: + """Bind exact staged snapshots to the previously measured toolchain roles.""" + expected = ( + ("reporter", reporter, reporter), + ("launcher", roles.launcher, launcher_destination), + ("browser_runtime", roles.browser_runtime, roles.browser_runtime), + ("lockfile", roles.lockfile, roles.lockfile), + ("dependencies", roles.dependencies, dependency_destination), + *((f"native_runtime/{index}", source, destination) + for index, (source, destination) in enumerate(native_bindings)), + ) + if len(proofs) != len(expected): + raise ValueError("Playwright snapshot aggregate is incomplete") + role_members: dict[str, list[dict[str, object]]] = {} + aggregate_members = [] + for proof, (role, source, destination) in zip(proofs, expected, strict=True): + if proof.source != source.resolve(strict=True) or proof.destination != destination: + raise ValueError("Playwright snapshot aggregate topology mismatch") + members = _snapshot_binding_members(proof) + role_members[role] = members + aggregate_members.append({"role": role, "attestation": proof.attestation}) + native_members = tuple( + role_members[f"native_runtime/{index}"] + for index in range(len(native_bindings)) + ) + try: + entrypoint_relative = roles.entrypoint.relative_to(roles.dependencies) + except ValueError as exc: + raise ValueError("Playwright snapshot entrypoint is outside dependencies") from exc + toolchain_identity = _playwright_snapshot_toolchain_identity( + role_members["launcher"], + role_members["dependencies"], + role_members["browser_runtime"], + native_members, + role_members["lockfile"], + PurePosixPath(entrypoint_relative.as_posix()), + ) + aggregate = { + "schema": _PLAYWRIGHT_SNAPSHOT_AGGREGATE_SCHEMA, + "toolchain_identity": toolchain_identity, + "members": aggregate_members, + } + return toolchain_identity, hashlib.sha256( + json.dumps(aggregate, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + def _directory_identity(path: Path) -> str: """Return a stable digest for files under a protected dependency directory.""" digest = hashlib.sha256() @@ -3333,7 +3470,7 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: ) ): raise ValueError("Playwright toolchain manifest roles are incomplete") - digest = hashlib.sha256(b"pdd-playwright-toolchain-v4\0") + captured: dict[str, list[dict[str, object]]] = {} for role in sorted(scalar_roles): item = roles[role] declared = Path(item) @@ -3344,19 +3481,33 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: raise ValueError(f"Playwright toolchain role {role} must be a file") if role in {"dependencies", "browser_runtime"} and not canonical.is_dir(): raise ValueError(f"Playwright toolchain role {role} must be a directory") - digest.update(role.encode() + b"\0") - digest.update(_manifest_path_identity(declared, set(), PurePosixPath(role))) - digest.update(b"\0") - digest.update(b"native_runtime\0") + captured[role] = _snapshot_binding_members( + _snapshot_binding_proof(canonical, Path(f"/{role}")) + ) + native_members = [] for index, item in enumerate(native_values): declared = Path(item) if not declared.exists() or not declared.resolve(strict=True).is_file(): raise ValueError("Playwright native_runtime role must contain files") - digest.update(_manifest_path_identity( - declared, set(), PurePosixPath("native_runtime") / str(index) + native_members.append(_snapshot_binding_members( + _snapshot_binding_proof( + declared.resolve(strict=True), Path(f"/native_runtime/{index}") + ) )) - digest.update(b"\0") - return digest.hexdigest() + entrypoint = Path(roles["entrypoint"]).resolve(strict=True) + dependencies = Path(roles["dependencies"]).resolve(strict=True) + try: + entrypoint_relative = PurePosixPath(entrypoint.relative_to(dependencies).as_posix()) + except ValueError as exc: + raise ValueError("Playwright entrypoint must be inside dependencies role") from exc + return _playwright_snapshot_toolchain_identity( + captured["launcher"], + captured["dependencies"], + captured["browser_runtime"], + tuple(native_members), + captured["lockfile"], + entrypoint_relative, + ) def _toolchain_manifest_roles(manifest_path: Path) -> PlaywrightToolchainRoles: @@ -5263,6 +5414,20 @@ def _run_playwright_in_tree( dependency_destination, native_bindings, ) + snapshot_identity, _snapshot_aggregate = ( + _playwright_snapshot_aggregate_identity( + snapshot_binding_proofs, + reporter, + roles, + Path(runtime_prefix[0]), + dependency_destination, + native_bindings, + ) + ) + if snapshot_identity != toolchain_identity: + raise ValueError( + "Playwright snapshot does not match the accepted toolchain identity" + ) except ValueError as exc: return RunnerExecution( "playwright", EvidenceOutcome.ERROR, "playwright-closure", str(exc) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 508bd2794e..3068f58c27 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -2030,6 +2030,33 @@ def _sandbox_command( accepted_records: list[str] = [] accepted_snapshots: list[str] = [] + def copied_binding_proof( + source: Path, destination: Path, + ) -> tuple[tuple[Path, Path, Path], _ValidatedBindingProof] | None: + """Return one exact descriptor authority for a copied native bind.""" + matches = [ + (key, proof) for key, proof in proofs.items() + if key[0] == source and key[2] == destination + ] + if len(matches) > 1: + raise RuntimeError("protected sandbox has ambiguous immutable binding proof") + return matches[0] if matches else None + + def equivalent_native_authority( + first: _ValidatedBindingProof, second: _ValidatedBindingProof, + ) -> bool: + """Allow only duplicate copied aliases of one descriptor-native member.""" + return ( + first.protected_source == second.protected_source + and first.destination == second.destination + and first.descriptor_attestation == second.descriptor_attestation + and first.descriptor_identity == second.descriptor_identity + and first.member_role == second.member_role == "native_runtime" + and first.collision_category == second.collision_category + and first.member_digest == second.member_digest + and first.member_mode == second.member_mode + ) + def stage_source(source: Path, writable: bool = False) -> tuple[str, int | None]: token = f"@PDD-PATH-{uuid.uuid4().hex}@" if writable: @@ -2095,6 +2122,30 @@ def bind( "collision_category": proof.collision_category, })) return + previous_authority = copied_binding_proof(previous[1], destination) + current_authority = copied_binding_proof(resolved_source, destination) + duplicate_declared_read_only = ( + option == "--ro-bind" + and previous[0] == "--ro-bind" + and previous[2] == "declared_readable" + and category == "declared_readable" + ) + if ( + duplicate_declared_read_only + and previous_authority is not None + and current_authority is not None + and equivalent_native_authority( + previous_authority[1], current_authority[1] + ) + ): + if current_authority[0] in consumed_proofs: + raise RuntimeError( + "protected sandbox immutable binding proof was multiply consumed" + ) + # Keep the first descriptor-proven copy. The later alias + # never mounts, but is consumed so proof accounting stays exact. + consumed_proofs.add(current_authority[0]) + return raise RuntimeError( f"protected sandbox has conflicting bindings for {destination}" ) diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index bc7a8a2bac..b6cecd1e31 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -36,7 +36,10 @@ _playwright_reported_failure_detail, _playwright_result, _playwright_runtime_prefix, + _playwright_snapshot_aggregate_identity, + _playwright_snapshot_binding_proofs, _playwright_host_temp_parent, + _toolchain_manifest_roles, _toolchain_manifest_identity, _playwright_toolchain_identity, playwright_validator_config_digest, @@ -876,6 +879,149 @@ def supervised(command, **_kwargs): assert all("pdd-snapshot-binding-v1" in proof.attestation for proof in snapshot_proofs[0]) +@pytest.mark.parametrize( + "role", ["launcher", "dependencies", "browser_runtime", "lockfile", "native_runtime"] +) +def test_playwright_snapshot_aggregate_binds_every_toolchain_role( + tmp_path: Path, role: str, +) -> None: + """The helper snapshot identity covers every accepted external role.""" + launcher = tmp_path / "node" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + launcher.chmod(0o755) + entrypoint = _fake_playwright(tmp_path) + manifest = _toolchain_manifest(tmp_path / "toolchain", launcher, entrypoint) + roles = _toolchain_manifest_roles(manifest) + reporter = tmp_path / "reporter.cjs" + reporter.write_text("reporter", encoding="utf-8") + dependency_destination = tmp_path / "phase" / "node_modules" + runtime_prefix = _playwright_runtime_prefix( + (str(roles.launcher), str(roles.entrypoint)), roles.launcher + ) + accepted = _toolchain_manifest_identity(manifest) + + proofs = _playwright_snapshot_binding_proofs( + reporter, + roles, + Path(runtime_prefix[0]), + dependency_destination, + roles.native_bindings, + ) + identity, aggregate = _playwright_snapshot_aggregate_identity( + proofs, + reporter, + roles, + Path(runtime_prefix[0]), + dependency_destination, + roles.native_bindings, + ) + assert identity == accepted + reporter.write_text("changed reporter", encoding="utf-8") + reporter_proofs = _playwright_snapshot_binding_proofs( + reporter, + roles, + Path(runtime_prefix[0]), + dependency_destination, + roles.native_bindings, + ) + reporter_identity, reporter_aggregate = _playwright_snapshot_aggregate_identity( + reporter_proofs, + reporter, + roles, + Path(runtime_prefix[0]), + dependency_destination, + roles.native_bindings, + ) + assert reporter_identity == accepted + assert reporter_aggregate != aggregate + + if role == "launcher": + roles.launcher.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + elif role == "dependencies": + (roles.dependencies / "changed.js").write_text("changed", encoding="utf-8") + elif role == "browser_runtime": + (roles.browser_runtime / "changed").write_text("changed", encoding="utf-8") + elif role == "lockfile": + roles.lockfile.write_text('{"changed":true}\n', encoding="utf-8") + else: + roles.native_runtime[0].write_bytes(b"changed-native") + + changed = _playwright_snapshot_binding_proofs( + reporter, + roles, + Path(runtime_prefix[0]), + dependency_destination, + roles.native_bindings, + ) + changed_identity, changed_aggregate = _playwright_snapshot_aggregate_identity( + changed, + reporter, + roles, + Path(runtime_prefix[0]), + dependency_destination, + roles.native_bindings, + ) + assert changed_identity != accepted + assert changed_aggregate != aggregate + + +@pytest.mark.parametrize("swap", ["launcher", "dependency"]) +def test_playwright_rejects_swap_between_accepted_identity_and_snapshot( + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, + swap: str, +) -> None: + """A replacement cannot self-attest after the accepted identity is measured.""" + root, commit = _repository(tmp_path) + fake = _fake_playwright(tmp_path) + launcher = trusted_toolchain_dir / "node" + launcher.write_text( + f"#!/bin/sh\nexec {sys.executable!s} \"$@\"\n", encoding="utf-8" + ) + launcher.chmod(0o755) + manifest = _toolchain_manifest(trusted_toolchain_dir / "toolchain", launcher, fake) + roles = _toolchain_manifest_roles(manifest) + config = RunnerConfig( + timeout_seconds=2, + playwright_command=(str(launcher), str(roles.entrypoint)), + playwright_toolchain_manifest=manifest, + playwright_toolchain_identity=_playwright_toolchain_identity( + root, (str(launcher), str(roles.entrypoint)), manifest + ), + ) + original = runner_module._playwright_snapshot_binding_proofs + supervised = False + + def swap_before_snapshot(*args, **kwargs): + if swap == "launcher": + roles.launcher.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + else: + (roles.dependencies / "attacker.js").write_text( + "module.exports = 'attacker';\n", encoding="utf-8" + ) + return original(*args, **kwargs) + + def must_not_supervise(*_args, **_kwargs): + nonlocal supervised + supervised = True + raise AssertionError("snapshot mismatch must reject before helper handoff") + + monkeypatch.setattr( + runner_module, "_playwright_snapshot_binding_proofs", swap_before_snapshot + ) + monkeypatch.setattr(runner_module, "run_supervised", must_not_supervise) + execution, _identities = runner_module._run_playwright_in_tree( + root, + (PurePosixPath("tests/widget.spec.ts"),), + 2, + config, + expected_commit=commit, + ) + + assert execution.outcome is EvidenceOutcome.ERROR + assert "snapshot" in execution.detail.lower() + assert supervised is False + + def test_playwright_exact_filter_escapes_regex_metacharacters( tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 7182b7f2ba..84d462cdbf 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -722,6 +722,84 @@ def test_linux_sandbox_allows_descriptor_proven_copied_loader_at_inferred_runtim ) +def test_linux_sandbox_coalesces_descriptor_proven_loader_aliases( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two copied aliases of one native loader retain one descriptor-proven mount.""" + from pdd.sync_core.runner import ( # pylint: disable=import-outside-toplevel + VitestToolchainDescriptor, + VitestToolchainMember, + _vitest_descriptor_attestation, + _vitest_immutable_binding_proofs, + _vitest_member_payload, + _vitest_members_identity, + ) + + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + host_loader = tmp_path / "ld-linux-x86-64.so.2" + host_loader.write_bytes(b"native-loader") + copied_loaders = (tmp_path / "copied-loader-a", tmp_path / "copied-loader-b") + for copied in copied_loaders: + copied.write_bytes(host_loader.read_bytes()) + digest = hashlib.sha256(host_loader.read_bytes()).hexdigest() + members = tuple(sorted(( + VitestToolchainMember("dependencies", PurePosixPath("."), "directory", 0o755), + VitestToolchainMember("entrypoint", PurePosixPath("."), "file", 0o644, digest), + VitestToolchainMember("launcher", PurePosixPath("."), "file", 0o644, digest), + VitestToolchainMember("lockfile", PurePosixPath("."), "file", 0o644, digest), + VitestToolchainMember("native_runtime", PurePosixPath("0"), "file", 0o644, digest), + VitestToolchainMember("native_runtime", PurePosixPath("1"), "file", 0o644, digest), + ), key=lambda item: (item.role, item.relative_path.as_posix()))) + attestation, identity = _vitest_descriptor_attestation( + tuple(_vitest_member_payload(member) for member in members), + (host_loader, host_loader), + linux_wasm_trap_handler_disabled=True, + ) + descriptor = VitestToolchainDescriptor( + host_loader.parent / "vitest-toolchain.json", + host_loader, + host_loader, + host_loader.parent, + (host_loader, host_loader), + host_loader, + identity, + _vitest_members_identity(tuple( + member for member in members if member.role == "dependencies" + )), + members, + ) + proofs = _vitest_immutable_binding_proofs(copied_loaders, descriptor) + assert all(proof.descriptor_attestation == attestation for proof in proofs) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) + ) + + argv, _profile = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=tuple((copied, host_loader) for copied in copied_loaders), + immutable_binding_proofs=proofs, + ) + + bwrap = json.loads(argv[-4]) + sources = json.loads(argv[-3]) + destination_index = bwrap.index(str(host_loader)) + assert bwrap.count(str(host_loader)) == 1 + assert sources[json.loads(argv[-5]).index(bwrap[destination_index - 1])] == str( + copied_loaders[0].resolve() + ) + + @pytest.mark.parametrize("mutation", ["copied", "protected", "identity"]) def test_linux_sandbox_rejects_tampered_descriptor_proof( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str, From 54066ed69d45cc940cd218f40e38b5763d868bf2 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 00:58:34 -0700 Subject: [PATCH 170/233] fix(sync): preserve playwright entrypoint identity contract --- pdd/sync_core/runner.py | 74 +++++++++++++++++------ tests/test_sync_core_runner_playwright.py | 69 ++++++++++++++++++++- 2 files changed, 125 insertions(+), 18 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 5e50dbda8f..9c96c996a9 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -226,6 +226,8 @@ def readable_roots(self) -> tuple[Path, ...]: self.launcher, self.browser_runtime, self.lockfile, + *((self.entrypoint,) + if not _path_is_relative_to(self.entrypoint, self.dependencies) else ()), ) @property @@ -3232,10 +3234,16 @@ def _playwright_snapshot_binding_proofs( native_bindings: tuple[tuple[Path, Path], ...], ) -> tuple[SnapshotBindingProof, ...]: """Bind every Playwright-owned executable and tree to a helper snapshot.""" + try: + roles.entrypoint.relative_to(roles.dependencies) + entrypoint_pair: tuple[tuple[Path, Path], ...] = () + except ValueError: + entrypoint_pair = ((roles.entrypoint, roles.entrypoint),) pairs = ( (reporter, reporter), (roles.launcher, launcher_destination), (roles.browser_runtime, roles.browser_runtime), (roles.lockfile, roles.lockfile), (roles.dependencies, dependency_destination), + *entrypoint_pair, *native_bindings, ) return tuple(_snapshot_binding_proof(source, destination) for source, destination in pairs) @@ -3267,25 +3275,45 @@ def _snapshot_binding_members(proof: SnapshotBindingProof) -> list[dict[str, obj def _playwright_snapshot_toolchain_payload( launcher_members: list[dict[str, object]], dependency_members: list[dict[str, object]], + entrypoint_members: list[dict[str, object]] | None, browser_members: list[dict[str, object]], native_members: tuple[list[dict[str, object]], ...], lockfile_members: list[dict[str, object]], - entrypoint_relative: PurePosixPath, + entrypoint_relative: PurePosixPath | None, ) -> dict[str, object]: """Build the relocation-stable typed identity payload from snapshot members.""" - entrypoint_path = entrypoint_relative.as_posix() - entrypoint = [ - member for member in dependency_members - if member.get("path") == entrypoint_path and member.get("kind") == "file" - ] - if len(entrypoint) != 1: - raise ValueError("Playwright snapshot does not contain the declared entrypoint") + if entrypoint_relative is None: + if ( + entrypoint_members is None + or len(entrypoint_members) != 1 + or entrypoint_members[0].get("path") != "." + or entrypoint_members[0].get("kind") != "file" + ): + raise ValueError("Playwright snapshot entrypoint role is malformed") + entrypoint_role: dict[str, object] = { + "role": "entrypoint", "members": entrypoint_members, + } + else: + entrypoint_path = entrypoint_relative.as_posix() + entrypoint = [ + member for member in dependency_members + if member.get("path") == entrypoint_path and member.get("kind") == "file" + ] + if len(entrypoint) != 1: + raise ValueError("Playwright snapshot does not contain the declared entrypoint") + normalized_entrypoint = [{**entrypoint[0], "path": "."}] + if entrypoint_members is not None and entrypoint_members != normalized_entrypoint: + raise ValueError("Playwright entrypoint role differs from dependency member") + entrypoint_role = { + "role": "entrypoint", + "binding": {"role": "dependencies", "path": entrypoint_path}, + } return { "schema": _PLAYWRIGHT_SNAPSHOT_TOOLCHAIN_SCHEMA, "roles": [ {"role": "browser_runtime", "members": browser_members}, {"role": "dependencies", "members": dependency_members}, - {"role": "entrypoint", "members": entrypoint}, + entrypoint_role, {"role": "launcher", "members": launcher_members}, *( { @@ -3303,15 +3331,17 @@ def _playwright_snapshot_toolchain_payload( def _playwright_snapshot_toolchain_identity( launcher_members: list[dict[str, object]], dependency_members: list[dict[str, object]], + entrypoint_members: list[dict[str, object]] | None, browser_members: list[dict[str, object]], native_members: tuple[list[dict[str, object]], ...], lockfile_members: list[dict[str, object]], - entrypoint_relative: PurePosixPath, + entrypoint_relative: PurePosixPath | None, ) -> str: """Hash the typed immutable role membership shared by identity and staging.""" payload = _playwright_snapshot_toolchain_payload( launcher_members, dependency_members, + entrypoint_members, browser_members, native_members, lockfile_members, @@ -3337,6 +3367,10 @@ def _playwright_snapshot_aggregate_identity( ("browser_runtime", roles.browser_runtime, roles.browser_runtime), ("lockfile", roles.lockfile, roles.lockfile), ("dependencies", roles.dependencies, dependency_destination), + *( + (("entrypoint", roles.entrypoint, roles.entrypoint),) + if not _path_is_relative_to(roles.entrypoint, roles.dependencies) else () + ), *((f"native_runtime/{index}", source, destination) for index, (source, destination) in enumerate(native_bindings)), ) @@ -3354,17 +3388,18 @@ def _playwright_snapshot_aggregate_identity( role_members[f"native_runtime/{index}"] for index in range(len(native_bindings)) ) - try: - entrypoint_relative = roles.entrypoint.relative_to(roles.dependencies) - except ValueError as exc: - raise ValueError("Playwright snapshot entrypoint is outside dependencies") from exc + entrypoint_relative = ( + PurePosixPath(roles.entrypoint.relative_to(roles.dependencies).as_posix()) + if _path_is_relative_to(roles.entrypoint, roles.dependencies) else None + ) toolchain_identity = _playwright_snapshot_toolchain_identity( role_members["launcher"], role_members["dependencies"], + role_members.get("entrypoint"), role_members["browser_runtime"], native_members, role_members["lockfile"], - PurePosixPath(entrypoint_relative.as_posix()), + entrypoint_relative, ) aggregate = { "schema": _PLAYWRIGHT_SNAPSHOT_AGGREGATE_SCHEMA, @@ -3481,6 +3516,7 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: raise ValueError(f"Playwright toolchain role {role} must be a file") if role in {"dependencies", "browser_runtime"} and not canonical.is_dir(): raise ValueError(f"Playwright toolchain role {role} must be a directory") + _manifest_path_identity(declared, set(), PurePosixPath(role)) captured[role] = _snapshot_binding_members( _snapshot_binding_proof(canonical, Path(f"/{role}")) ) @@ -3489,6 +3525,9 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: declared = Path(item) if not declared.exists() or not declared.resolve(strict=True).is_file(): raise ValueError("Playwright native_runtime role must contain files") + _manifest_path_identity( + declared, set(), PurePosixPath("native_runtime") / str(index) + ) native_members.append(_snapshot_binding_members( _snapshot_binding_proof( declared.resolve(strict=True), Path(f"/native_runtime/{index}") @@ -3498,11 +3537,12 @@ def _toolchain_manifest_identity(manifest_path: Path) -> str: dependencies = Path(roles["dependencies"]).resolve(strict=True) try: entrypoint_relative = PurePosixPath(entrypoint.relative_to(dependencies).as_posix()) - except ValueError as exc: - raise ValueError("Playwright entrypoint must be inside dependencies role") from exc + except ValueError: + entrypoint_relative = None return _playwright_snapshot_toolchain_identity( captured["launcher"], captured["dependencies"], + captured["entrypoint"], captured["browser_runtime"], tuple(native_members), captured["lockfile"], diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index b6cecd1e31..cbb55e6c22 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -880,7 +880,10 @@ def supervised(command, **_kwargs): @pytest.mark.parametrize( - "role", ["launcher", "dependencies", "browser_runtime", "lockfile", "native_runtime"] + "role", [ + "launcher", "entrypoint", "dependencies", "browser_runtime", "lockfile", + "native_runtime", + ] ) def test_playwright_snapshot_aggregate_binds_every_toolchain_role( tmp_path: Path, role: str, @@ -916,6 +919,7 @@ def test_playwright_snapshot_aggregate_binds_every_toolchain_role( roles.native_bindings, ) assert identity == accepted + assert all(proof.source != roles.entrypoint for proof in proofs) reporter.write_text("changed reporter", encoding="utf-8") reporter_proofs = _playwright_snapshot_binding_proofs( reporter, @@ -937,6 +941,8 @@ def test_playwright_snapshot_aggregate_binds_every_toolchain_role( if role == "launcher": roles.launcher.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + elif role == "entrypoint": + roles.entrypoint.write_text("changed entrypoint", encoding="utf-8") elif role == "dependencies": (roles.dependencies / "changed.js").write_text("changed", encoding="utf-8") elif role == "browser_runtime": @@ -965,6 +971,67 @@ def test_playwright_snapshot_aggregate_binds_every_toolchain_role( assert changed_aggregate != aggregate +def test_playwright_snapshot_aggregate_binds_external_entrypoint_role( + tmp_path: Path, +) -> None: + """An entrypoint outside dependencies has one independent exact snapshot.""" + launcher = tmp_path / "node" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + launcher.chmod(0o755) + source_entrypoint = _fake_playwright(tmp_path) + manifest = _toolchain_manifest(tmp_path / "toolchain", launcher, source_entrypoint) + external_entrypoint = tmp_path / "external-cli.py" + external_entrypoint.write_bytes(source_entrypoint.read_bytes()) + payload = json.loads(manifest.read_text(encoding="utf-8")) + payload["roles"]["entrypoint"] = str(external_entrypoint.resolve()) + manifest.write_text(json.dumps(payload), encoding="utf-8") + roles = _toolchain_manifest_roles(manifest) + reporter = tmp_path / "reporter.cjs" + reporter.write_text("reporter", encoding="utf-8") + dependency_destination = tmp_path / "phase" / "node_modules" + accepted = _toolchain_manifest_identity(manifest) + + proofs = _playwright_snapshot_binding_proofs( + reporter, + roles, + roles.launcher, + dependency_destination, + roles.native_bindings, + ) + identity, aggregate = _playwright_snapshot_aggregate_identity( + proofs, + reporter, + roles, + roles.launcher, + dependency_destination, + roles.native_bindings, + ) + entrypoint_proofs = [proof for proof in proofs if proof.source == external_entrypoint] + assert len(entrypoint_proofs) == 1 + assert entrypoint_proofs[0].destination == external_entrypoint + assert external_entrypoint in roles.readable_roots + assert identity == accepted + + external_entrypoint.write_text("changed entrypoint", encoding="utf-8") + changed = _playwright_snapshot_binding_proofs( + reporter, + roles, + roles.launcher, + dependency_destination, + roles.native_bindings, + ) + changed_identity, changed_aggregate = _playwright_snapshot_aggregate_identity( + changed, + reporter, + roles, + roles.launcher, + dependency_destination, + roles.native_bindings, + ) + assert changed_identity != accepted + assert changed_aggregate != aggregate + + @pytest.mark.parametrize("swap", ["launcher", "dependency"]) def test_playwright_rejects_swap_between_accepted_identity_and_snapshot( tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch, From 831619f9bd62dc1058dbabcd3a88df5f28f12a09 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 01:43:13 -0700 Subject: [PATCH 171/233] fix(sync): enforce privileged playwright aggregate --- pdd/sync_core/runner.py | 41 ++- pdd/sync_core/supervisor.py | 340 +++++++++++++++++++++- tests/test_sync_core_runner_playwright.py | 113 +++++-- tests/test_sync_core_supervisor.py | 194 ++++++++++++ 4 files changed, 637 insertions(+), 51 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 9c96c996a9..066d52695a 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -51,6 +51,7 @@ ) from .supervisor import ( ImmutableBindingProof, + PlaywrightSnapshotAggregate, SnapshotBindingProof, SupervisorLimits, _vitest_descriptor_attestation, @@ -3359,7 +3360,8 @@ def _playwright_snapshot_aggregate_identity( launcher_destination: Path, dependency_destination: Path, native_bindings: tuple[tuple[Path, Path], ...], -) -> tuple[str, str]: + result_fd: int = 198, +) -> tuple[str, PlaywrightSnapshotAggregate]: """Bind exact staged snapshots to the previously measured toolchain roles.""" expected = ( ("reporter", reporter, reporter), @@ -3404,11 +3406,19 @@ def _playwright_snapshot_aggregate_identity( aggregate = { "schema": _PLAYWRIGHT_SNAPSHOT_AGGREGATE_SCHEMA, "toolchain_identity": toolchain_identity, + "observation": { + "role": "reporter", "transport": "anonymous-pipe-v1", + "result_fd": result_fd, + }, "members": aggregate_members, } - return toolchain_identity, hashlib.sha256( - json.dumps(aggregate, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() + attestation = json.dumps(aggregate, sort_keys=True, separators=(",", ":")) + return toolchain_identity, PlaywrightSnapshotAggregate( + attestation=attestation, + digest=hashlib.sha256(attestation.encode()).hexdigest(), + accepted_toolchain_identity=toolchain_identity, + result_fd=result_fd, + ) def _directory_identity(path: Path) -> str: @@ -5420,17 +5430,6 @@ def _run_playwright_in_tree( controllers = temporary / f"controller-{os.urandom(16).hex()}" controllers.mkdir(mode=0o700) reporter = controllers / "reporter.cjs" - result_directory = temporary / f"channel-{os.urandom(16).hex()}" - result_directory.mkdir(mode=0o700) - result_fifo = result_directory / "result.fifo" - os.mkfifo(result_fifo, mode=0o600) - read_fd = os.open(result_fifo, os.O_RDONLY | os.O_NONBLOCK) - drain_finished = threading.Event() - drained: dict[str, object] = {} - drain_thread = threading.Thread( - target=_drain_result_pipe, args=(read_fd, drain_finished, drained), daemon=True - ) - drain_thread.start() result_fd = 198 reporter.write_text(_playwright_reporter_source(result_fd), encoding="utf-8") commit = expected_commit or subprocess.run( @@ -5484,6 +5483,14 @@ def _run_playwright_in_tree( digest = hashlib.sha256( json.dumps(command, separators=(",", ":")).encode() ).hexdigest() + read_fd, write_fd = os.pipe() + os.set_blocking(read_fd, False) + drain_finished = threading.Event() + drained: dict[str, object] = {} + drain_thread = threading.Thread( + target=_drain_result_pipe, args=(read_fd, drain_finished, drained), daemon=True + ) + drain_thread.start() result, surviving = run_supervised( command, cwd=root, @@ -5501,10 +5508,12 @@ def _run_playwright_in_tree( *native_bindings, (roles.dependencies, dependency_destination), ), snapshot_binding_proofs=snapshot_binding_proofs, + playwright_snapshot_aggregate=_snapshot_aggregate, limits=PLAYWRIGHT_SUPERVISOR_LIMITS, - result_fifo=result_fifo, + result_write_fd=write_fd, result_fd=result_fd, ) + os.close(write_fd) drain_finished.set() drain_thread.join(timeout=2) try: diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 3068f58c27..6892f29062 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -37,6 +37,8 @@ _VITEST_ATTESTATION_SCHEMA = "pdd-vitest-toolchain-attestation-v1" _BINDING_RECORD_SCHEMA = "pdd-immutable-binding-record-v1" _SNAPSHOT_BINDING_SCHEMA = "pdd-snapshot-binding-v1" +_PLAYWRIGHT_AGGREGATE_SCHEMA = "pdd-playwright-snapshot-aggregate-v1" +_PLAYWRIGHT_AGGREGATE_RECORD_SCHEMA = "pdd-playwright-snapshot-aggregate-record-v1" _CANDIDATE_IDENTITY_SCHEMA = "pdd-candidate-identity-v1" _VITEST_MEMBER_ROLES = frozenset({ "launcher", "entrypoint", "dependencies", "native_runtime", "lockfile", @@ -121,6 +123,16 @@ class SnapshotBindingProof: attestation: str +@dataclass(frozen=True) +class PlaywrightSnapshotAggregate: + """Canonical Playwright-only snapshot and observation authority.""" + + attestation: str + digest: str + accepted_toolchain_identity: str + result_fd: int + + @dataclass(frozen=True) # pylint: disable-next=too-many-instance-attributes class _ValidatedBindingProof: @@ -507,6 +519,99 @@ def _validate_snapshot_binding_proof(proof: SnapshotBindingProof) -> None: raise RuntimeError("protected sandbox snapshot binding proof is malformed") from exc +def _validate_playwright_snapshot_aggregate( + aggregate: PlaywrightSnapshotAggregate, +) -> dict[str, object]: + """Validate Playwright-only aggregate authority before privileged handoff.""" + # pylint: disable=unidiomatic-typecheck,too-many-locals + try: + if type(aggregate) is not PlaywrightSnapshotAggregate: + raise ValueError("invalid aggregate type") + valid_attestation = ( + type(aggregate.attestation) is str + and len(aggregate.attestation.encode()) <= _MAX_BINDING_ATTESTATION_BYTES + ) + valid_identities = ( + type(aggregate.digest) is str + and re.fullmatch(r"[0-9a-f]{64}", aggregate.digest) is not None + and type(aggregate.accepted_toolchain_identity) is str + and re.fullmatch( + r"[0-9a-f]{64}", aggregate.accepted_toolchain_identity + ) is not None + ) + valid_descriptor = ( + type(aggregate.result_fd) is int and 3 <= aggregate.result_fd <= 255 + ) + if not (valid_attestation and valid_identities and valid_descriptor): + raise ValueError("invalid aggregate scalar") + payload = json.loads(aggregate.attestation) + valid_payload = type(payload) is dict and set(payload) == { + "schema", "toolchain_identity", "observation", "members", + } + valid_authority = valid_payload and ( + payload["schema"] == _PLAYWRIGHT_AGGREGATE_SCHEMA + and payload["toolchain_identity"] == aggregate.accepted_toolchain_identity + ) + valid_encoding = ( + aggregate.attestation == _canonical_json(payload) + and hashlib.sha256(aggregate.attestation.encode()).hexdigest() + == aggregate.digest + ) + if not (valid_authority and valid_encoding): + raise ValueError("invalid aggregate identity") + observation = payload["observation"] + if ( + type(observation) is not dict + or observation != { + "role": "reporter", "transport": "anonymous-pipe-v1", + "result_fd": aggregate.result_fd, + } + ): + raise ValueError("invalid observation authority") + members = payload["members"] + if type(members) is not list or not members: + raise ValueError("invalid aggregate members") + labels = [] + attestations = [] + for member in members: + if type(member) is not dict or set(member) != {"role", "attestation"}: + raise ValueError("invalid aggregate member") + role = member["role"] + attestation = member["attestation"] + native_match = ( + re.fullmatch(r"native_runtime/(0|[1-9][0-9]*)", role) + if type(role) is str else None + ) + allowed_role = role in { + "reporter", "launcher", "browser_runtime", "lockfile", + "dependencies", "entrypoint", + } if type(role) is str else False + if ( + type(role) is not str + or not (allowed_role or native_match is not None) + or type(attestation) is not str + ): + raise ValueError("invalid aggregate member authority") + labels.append(role) + attestations.append(attestation) + required = {"reporter", "launcher", "browser_runtime", "lockfile", "dependencies"} + native_labels = sorted( + int(label.split("/", 1)[1]) for label in labels + if label.startswith("native_runtime/") + ) + if ( + len(labels) != len(set(labels)) + or not required <= set(labels) + or native_labels != list(range(len(native_labels))) + or not native_labels + or len(attestations) != len(set(attestations)) + ): + raise ValueError("incomplete aggregate roles") + return payload + except (AttributeError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise RuntimeError("protected Playwright snapshot aggregate is malformed") from exc + + _SNAPSHOT_STAGING_SOURCE = r''' def _snapshot_failure(): raise RuntimeError("protected sandbox snapshot attestation failed at staging") @@ -602,6 +707,41 @@ def _stage_snapshot(encoded,source,target): if digest.hexdigest()!=member["digest"]: _snapshot_failure() except OSError: _snapshot_failure() finally: os.close(root_fd) + +def _verify_staged_snapshot(encoded,target): + try: record=json.loads(encoded); payload=json.loads(record["attestation"]); members=payload["members"] + except (KeyError,TypeError,ValueError): _snapshot_failure() + actual=[] + for path in (pathlib.Path(target),*pathlib.Path(target).rglob("*")): + actual.append("." if path==pathlib.Path(target) else path.relative_to(target).as_posix()) + if sorted(actual)!=[member["path"] for member in members]: _snapshot_failure() + root_fd=os.open(target,os.O_RDONLY|os.O_CLOEXEC|getattr(os,"O_NOFOLLOW",0)|(os.O_DIRECTORY if members[0]["kind"]=="directory" else 0)) + try: + for member in members: + relative=member["path"]; kind=member["kind"]; descriptor=None + if relative==".": metadata=os.fstat(root_fd); descriptor=os.dup(root_fd) + elif kind=="symlink": + descriptor,name=_snapshot_parent_fd(root_fd,relative) + try: metadata=os.stat(name,dir_fd=descriptor,follow_symlinks=False); target_text=os.readlink(name,dir_fd=descriptor) + finally: os.close(descriptor); descriptor=None + else: + descriptor=_snapshot_member_fd(root_fd,relative,directory=kind=="directory"); metadata=os.fstat(descriptor) + if stat.S_IMODE(metadata.st_mode)!=member["mode"]: _snapshot_failure() + if kind=="directory": + if not stat.S_ISDIR(metadata.st_mode): _snapshot_failure() + elif kind=="symlink": + if not stat.S_ISLNK(metadata.st_mode) or target_text!=member["target"]: _snapshot_failure() + else: + if not stat.S_ISREG(metadata.st_mode): _snapshot_failure() + digest=hashlib.sha256() + while True: + chunk=os.read(descriptor,1048576) + if not chunk: break + digest.update(chunk) + if digest.hexdigest()!=member["digest"]: _snapshot_failure() + if descriptor is not None: os.close(descriptor) + except OSError: _snapshot_failure() + finally: os.close(root_fd) '''.strip() @@ -1203,6 +1343,7 @@ def _staged_bwrap( writable_roots: tuple[Path, ...], writable_specs: list[tuple[str, int, str]], immutable_binding_proofs: tuple[str, ...], snapshot_binding_proofs: tuple[str, ...], + playwright_aggregate_record: str | None, candidate_identity: str, unit_name: str, control_directory: Path, limits: SupervisorLimits, candidate_timeout: float, tools: _TrustedTools, @@ -1233,7 +1374,7 @@ def _staged_bwrap( if len(fifo_source_indices) > 1: raise RuntimeError("protected sandbox has ambiguous observation staging") helper = "\n".join(( - "import hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,time", + "import hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,threading,time", "if len(sys.argv)!=13: raise RuntimeError('invalid protected helper protocol')", "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", "candidate_identity=sys.argv[4]; proof_records=json.loads(sys.argv[5])", @@ -1243,6 +1384,7 @@ def _staged_bwrap( "argv=json.loads(sys.argv[9]); paths=json.loads(sys.argv[10])", "fifo_indices=json.loads(sys.argv[11])", "limits=json.loads(sys.argv[12])", + "playwright_record=''; anonymous_observation=False", "tool_manifest=json.loads(" + repr(tool_manifest) + ")", "def verify_tool(name):", " expected=tool_manifest[name]; path=pathlib.Path(expected['path'])", @@ -1270,12 +1412,52 @@ def _staged_bwrap( "for tool_name in tool_manifest: verify_tool(tool_name)", "targets=[control/'binds'/str(index) for index in range(len(paths))]", "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None", + "observation_read=None; observation_write=None; observation_thread=None", + "observation_chunks=[]; observation_size=0; observation_overflow=False", "scope_cgroup=None; monitor_cgroup=None; candidate_cgroup=None", _PIDFD_PROTOCOL_SOURCE.strip(), _IMMUTABLE_STAGING_SOURCE, _SNAPSHOT_STAGING_SOURCE, "candidate_uid,candidate_gid=" "_validated_candidate_identity(candidate_identity,argv)", + "def validated_playwright_record():", + " if type(anonymous_observation) is not bool: _snapshot_failure()", + " if not anonymous_observation:", + " if playwright_record!='': _snapshot_failure()", + " return None", + " try: record=json.loads(playwright_record); aggregate=json.loads(record['aggregate_attestation'])", + " except (KeyError,TypeError,ValueError): _snapshot_failure()", + " if type(record) is not dict or set(record)!={'schema','aggregate_attestation','expected_digest','accepted_toolchain_identity','result_fd','members'} or record['schema']!='pdd-playwright-snapshot-aggregate-record-v1' or playwright_record!=_snapshot_canonical_json(record): _snapshot_failure()", + " if type(aggregate) is not dict or set(aggregate)!={'schema','toolchain_identity','observation','members'} or aggregate['schema']!='pdd-playwright-snapshot-aggregate-v1' or record['aggregate_attestation']!=_snapshot_canonical_json(aggregate): _snapshot_failure()", + " if type(record['expected_digest']) is not str or len(record['expected_digest'])!=64 or hashlib.sha256(record['aggregate_attestation'].encode()).hexdigest()!=record['expected_digest']: _snapshot_failure()", + " if type(record['accepted_toolchain_identity']) is not str or aggregate['toolchain_identity']!=record['accepted_toolchain_identity']: _snapshot_failure()", + " if aggregate['observation']!={'role':'reporter','transport':'anonymous-pipe-v1','result_fd':record['result_fd']} or type(record['result_fd']) is not int or not 3<=record['result_fd']<=255: _snapshot_failure()", + " members=record['members']", + " if type(members) is not list or type(aggregate['members']) is not list or len(members)!=len(aggregate['members']): _snapshot_failure()", + " labels=[]; logical=[]", + " for member,authority in zip(members,aggregate['members']):", + " if type(member) is not dict or set(member)!={'role','source_index','destination','attestation'} or type(authority) is not dict or set(authority)!={'role','attestation'}: _snapshot_failure()", + " role=member['role']; index=member['source_index']; destination=member['destination']; attestation=member['attestation']", + " if authority!={'role':role,'attestation':attestation} or type(role) is not str or type(index) is not int or not 0<=index=len(paths) or source_index in proof_by_index or source_index in snapshot_by_index: " "_immutable_failure()", " if schema=='pdd-immutable-binding-record-v1': proof_by_index[source_index]=encoded", " elif schema=='pdd-snapshot-binding-record-v1': snapshot_by_index[source_index]=encoded", " else: _immutable_failure()", + " playwright=validated_playwright_record()", " if type(fifo_indices) is not list or len(fifo_indices)>1 or " "any(type(value) is not int or value<0 or value>=len(paths) " "for value in fifo_indices): " @@ -1431,6 +1619,18 @@ def _staged_bwrap( " for token,index,relative in writable_specs: " "path_map[token]=str(writable_paths[index]/relative)", " argv=[path_map.get(value,value) for value in argv]", + " verify_playwright_aggregate(playwright,mapped=True)", + " if anonymous_observation:", + " observation_read,observation_write=os.pipe()", + " def drain_observation():", + " global observation_size,observation_overflow", + " while True:", + " chunk=os.read(observation_read,1048576)", + " if not chunk: break", + " observation_size+=len(chunk)", + " if observation_size>limits['observation']: observation_overflow=True", + " elif not observation_overflow: observation_chunks.append(chunk)", + " observation_thread=threading.Thread(target=drain_observation,daemon=True); observation_thread.start()", " configure_candidate_leaf()", " subprocess.run([mount,'--bind',str(candidate_cgroup),str(cgroup_target)]," "check=True,timeout=limits['trusted_timeout'])", @@ -1450,17 +1650,33 @@ def _staged_bwrap( " if pid == 0:", " os.close(release_write)", " try:", + " if anonymous_observation:", + " os.close(observation_read); os.dup2(observation_write,3) if observation_write!=3 else None", + " os.close(observation_write) if observation_write!=3 else None", " if os.read(release_read,1)!=b'1': os._exit(125)", " os.close(release_read)", " os.execvpe(argv[0],argv,os.environ)", " except OSError: os._exit(125)", " os.close(release_read)", + " if anonymous_observation: os.close(observation_write)", " (candidate_cgroup/'cgroup.procs').write_text(str(pid),encoding='ascii')", " members=(candidate_cgroup/'cgroup.procs').read_text(encoding='ascii').split()", " if str(pid) not in members: raise RuntimeError('candidate cgroup placement failed')", " os.write(release_write,b'1'); os.close(release_write)", " result,timed_out=_supervise_candidate(pid,limits['timeout'])", " kill_candidate_leaf()", + " if anonymous_observation:", + " observation_thread.join(timeout=limits['trusted_timeout'])", + " os.close(observation_read)", + " if observation_thread.is_alive() or observation_overflow: raise RuntimeError('protected observation relay failed')", + " verify_playwright_aggregate(playwright,mapped=True)", + " if anonymous_observation:", + " observation_path=control/'observation.bin'", + " observation_fd=os.open(observation_path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,'O_NOFOLLOW',0),0o444)", + " try:", + " for chunk in observation_chunks: os.write(observation_fd,chunk)", + " os.fsync(observation_fd)", + " finally: os.close(observation_fd)", " record={'version':1,'state':'terminal','returncode':result," "'timed_out':timed_out}", " candidate=control/'candidate.tmp'", @@ -1517,13 +1733,16 @@ def _staged_bwrap( str(tools.helper_python), "-I", "-S", "-c", helper, str(control_directory), str(tools.mount), str(tools.umount), candidate_identity, - json.dumps(list((*immutable_binding_proofs, *snapshot_binding_proofs))), + json.dumps(list((*immutable_binding_proofs, *snapshot_binding_proofs, *( + (playwright_aggregate_record,) if playwright_aggregate_record else () + )))), json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), json.dumps(path_tokens), json.dumps(argv), json.dumps([str(path) for path in sources]), json.dumps(fifo_source_indices), json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, "writable": limits.max_writable_bytes, + "observation": 16 * 1024 * 1024, "staging": max( 1024 * 1024, sum(path.stat().st_size for path in sources if path.is_file()) @@ -1550,6 +1769,20 @@ def _framework_observation_command( return [str(_SUPERVISOR_EXECUTABLE), "-c", script, str(source_path), str(result_fd), *command] + +def _anonymous_framework_observation_command( + command: list[str], result_fd: int, +) -> list[str]: + """Move the single helper-created candidate pipe from fd 3 to its reporter fd.""" + script = ( + "import os,sys;" + "source=3;target=int(sys.argv[1]);" + "os.dup2(source,target);" + "os.close(source) if source!=target else None;" + "os.execvpe(sys.argv[2],sys.argv[2:],os.environ)" + ) + return [str(_SUPERVISOR_EXECUTABLE), "-c", script, str(result_fd), *command] + def _supervised_descendants(token: str) -> set[int]: """Find descendants carrying the unforgeable per-run environment marker.""" found: set[int] = set() @@ -1627,6 +1860,35 @@ def _load_candidate_record(path: Path) -> _CandidateRecord: return _CandidateRecord(returncode, timed_out) +def _load_root_observation(path: Path, maximum: int) -> bytes: + """Read one bounded root-owned helper artifact through a no-follow descriptor.""" + descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) + try: + before = os.fstat(descriptor) + if ( + not stat.S_ISREG(before.st_mode) + or before.st_uid != 0 + or before.st_mode & 0o222 + or before.st_size > maximum + ): + raise RuntimeError("protected observation artifact is invalid") + chunks = [] + size = 0 + while chunk := os.read(descriptor, 1024 * 1024): + size += len(chunk) + if size > maximum: + raise RuntimeError("protected observation artifact exceeded limit") + chunks.append(chunk) + 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 RuntimeError("protected observation artifact changed") + return b"".join(chunks) + finally: + os.close(descriptor) + + def _live_processes(pids: dict[int, str | None]) -> set[int]: """Return only observations whose stable process identity still matches.""" live = set() @@ -1920,8 +2182,10 @@ def _sandbox_command( readable_bindings: tuple[tuple[Path, Path], ...] = (), immutable_binding_proofs: tuple[ImmutableBindingProof, ...] = (), snapshot_binding_proofs: tuple[SnapshotBindingProof, ...] = (), + playwright_snapshot_aggregate: PlaywrightSnapshotAggregate | None = None, writable_bindings: tuple[tuple[Path, Path], ...] = (), result_fifo: Path | None = None, + result_write_fd: int | None = None, result_fd: int = 198, unit_name: str | None = None, control_directory: Path | None = None, @@ -1980,6 +2244,8 @@ def _sandbox_command( "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp", "--dir", "/sys", "--dir", "/sys/fs", "--dir", "/sys/fs/cgroup"] + if result_write_fd is not None: + argv[8:8] = ["--preserve-fds", "1"] sources: list[Path] = [] path_tokens: list[str] = [] writable_specs: list[tuple[str, int, str]] = [] @@ -1989,6 +2255,14 @@ def _sandbox_command( proofs: dict[tuple[Path, Path, Path], _ValidatedBindingProof] = {} raw_proofs: dict[tuple[Path, Path, Path], ImmutableBindingProof] = {} snapshots: dict[tuple[Path, Path], SnapshotBindingProof] = {} + aggregate_payload = ( + _validate_playwright_snapshot_aggregate(playwright_snapshot_aggregate) + if playwright_snapshot_aggregate is not None else None + ) + if (aggregate_payload is None) != (result_write_fd is None): + raise RuntimeError("Playwright aggregate and anonymous observation must pair") + if result_fifo is not None and result_write_fd is not None: + raise RuntimeError("framework observation transports conflict") for proof in snapshot_binding_proofs: _validate_snapshot_binding_proof(proof) key = (proof.source.resolve(strict=True), proof.destination) @@ -2029,6 +2303,7 @@ def _sandbox_command( consumed_proofs: set[tuple[Path, Path, Path]] = set() accepted_records: list[str] = [] accepted_snapshots: list[str] = [] + accepted_snapshot_details: dict[str, tuple[int, Path]] = {} def copied_binding_proof( source: Path, destination: Path, @@ -2159,6 +2434,9 @@ def bind( "source_index": source_index, "attestation": snapshot.attestation, })) + accepted_snapshot_details[snapshot.attestation] = ( + source_index, destination + ) mounted[destination] = ( option, resolved_source, category, source_index ) @@ -2224,16 +2502,46 @@ def bind( sandboxed = _framework_observation_command( sandboxed, result_fd, _FRAMEWORK_OBSERVATION_PATH ) + elif result_write_fd is not None: + if playwright_snapshot_aggregate.result_fd != result_fd: + raise RuntimeError("Playwright observation descriptor mismatch") + sandboxed = _anonymous_framework_observation_command( + sandboxed, result_fd + ) argv.extend(("--", *drop, *sandboxed)) if consumed_proofs != proofs.keys(): raise RuntimeError("protected sandbox has unused immutable binding proof") if len(accepted_snapshots) != len(snapshots): raise RuntimeError("protected sandbox has unused snapshot binding proof") + aggregate_record = None + if aggregate_payload is not None: + protocol_members = [] + for member in aggregate_payload["members"]: + detail = accepted_snapshot_details.get(member["attestation"]) + if detail is None: + raise RuntimeError("Playwright aggregate snapshot is not mounted") + source_index, destination = detail + protocol_members.append({ + "role": member["role"], "source_index": source_index, + "destination": str(destination), + "attestation": member["attestation"], + }) + aggregate_record = _canonical_json({ + "schema": _PLAYWRIGHT_AGGREGATE_RECORD_SCHEMA, + "aggregate_attestation": playwright_snapshot_aggregate.attestation, + "expected_digest": playwright_snapshot_aggregate.digest, + "accepted_toolchain_identity": ( + playwright_snapshot_aggregate.accepted_toolchain_identity + ), + "result_fd": playwright_snapshot_aggregate.result_fd, + "members": protocol_members, + }) return _staged_bwrap( argv, sources, path_tokens, writable_roots=storage_roots, writable_specs=writable_specs, immutable_binding_proofs=tuple(accepted_records), snapshot_binding_proofs=tuple(accepted_snapshots), + playwright_aggregate_record=aggregate_record, candidate_identity=candidate_identity, unit_name=unit_name or _scope_unit_name(), control_directory=control_directory or ( @@ -2252,9 +2560,11 @@ def run_supervised( readable_bindings: tuple[tuple[Path, Path], ...] = (), immutable_binding_proofs: tuple[ImmutableBindingProof, ...] = (), snapshot_binding_proofs: tuple[SnapshotBindingProof, ...] = (), + playwright_snapshot_aggregate: PlaywrightSnapshotAggregate | None = None, writable_bindings: tuple[tuple[Path, Path], ...] = (), temp_directory: Path | None = None, result_fifo: Path | None = None, + result_write_fd: int | None = None, result_fd: int = 198, ) -> tuple[subprocess.CompletedProcess[str], bool]: """Run after proving one delegated candidate leaf, then remove its scope.""" @@ -2281,6 +2591,19 @@ def run_supervised( tracked: dict[int, str | None] = {} phase = "construction" + if result_write_fd is not None: + try: + endpoint = os.fstat(result_write_fd) + except OSError as exc: + return subprocess.CompletedProcess( + command, 125, "", f"protected supervisor phase=construction: {exc}" + ), False + if not stat.S_ISFIFO(endpoint.st_mode) or os.get_inheritable(result_write_fd): + return subprocess.CompletedProcess( + command, 125, "", + "protected supervisor phase=construction: invalid anonymous observation endpoint", + ), False + def add_diagnostic(value: str) -> None: data = value.encode("utf-8", errors="replace") remaining = max(0, limits.max_output_bytes - len(diagnostics)) @@ -2341,8 +2664,10 @@ def record_events() -> None: readable_bindings=readable_bindings, immutable_binding_proofs=immutable_binding_proofs, snapshot_binding_proofs=snapshot_binding_proofs, + playwright_snapshot_aggregate=playwright_snapshot_aggregate, writable_bindings=writable_bindings, - result_fifo=result_fifo, result_fd=result_fd, + result_fifo=result_fifo, result_write_fd=result_write_fd, + result_fd=result_fd, candidate_timeout=timeout, unit_name=unit_name, control_directory=control, ) @@ -2460,6 +2785,13 @@ def record_events() -> None: raise RuntimeError("candidate result changed during handoff") candidate_returncode = result_record.returncode candidate_timed_out = result_record.timed_out + if result_write_fd is not None: + observation = _load_root_observation( + control / "observation.bin", 16 * 1024 * 1024 + ) + offset = 0 + while offset < len(observation): + offset += os.write(result_write_fd, observation[offset:]) fail_for_limit() record_events() if ( diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index cbb55e6c22..6d70a4afeb 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -4,6 +4,7 @@ import os import re import shutil +import stat import subprocess import sys import tempfile @@ -79,7 +80,7 @@ def _simulate_framework_observation_for_synthetic_playwright( The production supervisor intentionally does not emulate the observation descriptor on macOS. These synthetic runner tests therefore write the - checker-owned FIFO from the trusted test process, while real execution is + checker-owned pipe from the trusted test process, while real execution is reserved for the Linux/bwrap contract tests. """ if sys.platform.startswith("linux"): @@ -107,29 +108,26 @@ def supervised(command, **kwargs): synthetic_command = [*command] synthetic_command[1] = str(entrypoint) result_fd = kwargs["result_fd"] - writer = os.open(kwargs["result_fifo"], os.O_WRONLY) + writer = kwargs["result_write_fd"] try: - try: - saved_fd = os.dup(result_fd) - except OSError: - saved_fd = None - os.dup2(writer, result_fd) - try: - result = subprocess.run( - synthetic_command, cwd=kwargs["cwd"], env=kwargs["env"], text=True, - capture_output=True, timeout=kwargs["timeout"], pass_fds=(result_fd,), - check=False, - ) - except subprocess.TimeoutExpired as exc: - result = subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "") - finally: - if saved_fd is None: - os.close(result_fd) - else: - os.dup2(saved_fd, result_fd) - os.close(saved_fd) + saved_fd = os.dup(result_fd) + except OSError: + saved_fd = None + os.dup2(writer, result_fd) + try: + result = subprocess.run( + synthetic_command, cwd=kwargs["cwd"], env=kwargs["env"], text=True, + capture_output=True, timeout=kwargs["timeout"], pass_fds=(result_fd,), + check=False, + ) + except subprocess.TimeoutExpired as exc: + result = subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "") finally: - os.close(writer) + if saved_fd is None: + os.close(result_fd) + else: + os.dup2(saved_fd, result_fd) + os.close(saved_fd) return result, False monkeypatch.setattr(runner_module, "run_supervised", supervised) @@ -137,14 +135,17 @@ def supervised(command, **kwargs): def _write_framework_observation(kwargs: dict, payload: dict) -> None: """Model the checker-owned reporter transport in supervisor fakes.""" - fifo = kwargs["result_fifo"] - writer = os.open(fifo, os.O_WRONLY) + writer = kwargs.get("result_write_fd") + close_writer = writer is None + if writer is None: + writer = os.open(kwargs["result_fifo"], os.O_WRONLY) try: os.write(writer, json.dumps({ "pdd_playwright_reporter": 1, **payload, }).encode()) finally: - os.close(writer) + if close_writer: + os.close(writer) def _reporter_callback_receipt(tmp_path: Path, callbacks: str) -> dict: @@ -879,6 +880,52 @@ def supervised(command, **_kwargs): assert all("pdd-snapshot-binding-v1" in proof.attestation for proof in snapshot_proofs[0]) +def test_playwright_anonymous_observation_rejects_path_and_fd_injection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A same-UID process cannot forge PASS through the removed FIFO pathname.""" + root, commit = _repository(tmp_path) + attempts = 0 + + def supervised(command, **kwargs): + nonlocal attempts + attempts += 1 + assert "result_fifo" not in kwargs + writer = kwargs["result_write_fd"] + assert os.get_inheritable(writer) is False + aggregate = json.loads(kwargs["playwright_snapshot_aggregate"].attestation) + reporter = next( + json.loads(member["attestation"])["source"] + for member in aggregate["members"] if member["role"] == "reporter" + ) + temporary = Path(reporter).parent.parent + assert not tuple(temporary.rglob("result.fifo")) + stale = temporary / "attacker-channel/result.fifo" + stale.parent.mkdir() + stale.write_text(json.dumps({ + "pdd_playwright_reporter": 1, + "tests": [{"identity": IDENTITY, "status": "passed"}], + }), encoding="utf-8") + injected = subprocess.run( + [ + sys.executable, "-c", + "import os,sys;os.write(int(sys.argv[1]),b'forged')", str(writer), + ], + close_fds=True, capture_output=True, text=True, check=False, + ) + assert injected.returncode != 0 + _write_framework_observation(kwargs, { + "tests": [{"identity": IDENTITY, "status": "skipped"}], + }) + return subprocess.CompletedProcess(command, 0, "", ""), False + + monkeypatch.setattr(runner_module, "run_supervised", supervised) + _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) + + assert attempts == 3 + assert executions[0].outcome is EvidenceOutcome.SKIP + + @pytest.mark.parametrize( "role", [ "launcher", "entrypoint", "dependencies", "browser_runtime", "lockfile", @@ -937,7 +984,7 @@ def test_playwright_snapshot_aggregate_binds_every_toolchain_role( roles.native_bindings, ) assert reporter_identity == accepted - assert reporter_aggregate != aggregate + assert reporter_aggregate.digest != aggregate.digest if role == "launcher": roles.launcher.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") @@ -968,7 +1015,7 @@ def test_playwright_snapshot_aggregate_binds_every_toolchain_role( roles.native_bindings, ) assert changed_identity != accepted - assert changed_aggregate != aggregate + assert changed_aggregate.digest != aggregate.digest def test_playwright_snapshot_aggregate_binds_external_entrypoint_role( @@ -1029,7 +1076,7 @@ def test_playwright_snapshot_aggregate_binds_external_entrypoint_role( roles.native_bindings, ) assert changed_identity != accepted - assert changed_aggregate != aggregate + assert changed_aggregate.digest != aggregate.digest @pytest.mark.parametrize("swap", ["launcher", "dependency"]) @@ -1438,7 +1485,7 @@ def test_playwright_checker_temp_roots_cannot_alias_sandbox_tmp( root, commit = _repository(tmp_path) phase_roots: list[Path] = [] scratch_roots: list[Path] = [] - fifo_roots: list[Path] = [] + result_writer_authorities: list[tuple[bool, bool]] = [] readable_roots: list[tuple[Path, ...]] = [] readable_bindings: list[tuple[tuple[Path, Path], ...]] = [] tmp_sources: list[Path] = [] @@ -1449,7 +1496,10 @@ def supervised(command, *, cwd, writable_roots, writable_bindings, **kwargs): scratch_roots.append(writable_roots[0]) readable_roots.append(kwargs["readable_roots"]) readable_bindings.append(kwargs["readable_bindings"]) - fifo_roots.append(Path(kwargs["result_fifo"]).parent) + writer = kwargs["result_write_fd"] + result_writer_authorities.append(( + stat.S_ISFIFO(os.fstat(writer).st_mode), os.get_inheritable(writer) + )) source, destination = writable_bindings[0] tmp_sources.append(source) tmp_destinations.append(destination) @@ -1468,7 +1518,8 @@ def supervised(command, *, cwd, writable_roots, writable_bindings, **kwargs): assert all(len(roots) == 4 for roots in readable_roots) assert all(len(bindings) == 2 for bindings in readable_bindings) sandbox_tmp = Path("/tmp").resolve() - for path in (*phase_roots, *scratch_roots, *fifo_roots): + assert result_writer_authorities == [(True, False)] * len(phase_roots) + for path in (*phase_roots, *scratch_roots): assert not path.resolve().is_relative_to(sandbox_tmp) for roots in readable_roots: for path in roots: diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 84d462cdbf..ca31772533 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -136,6 +136,99 @@ def test_helper_snapshot_rejects_attested_file_swap(tmp_path: Path) -> None: ) +def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The privileged record names every typed snapshot and final destination.""" + from pdd.sync_core.runner import ( # pylint: disable=import-outside-toplevel + PlaywrightToolchainRoles, + _playwright_snapshot_aggregate_identity, + _playwright_snapshot_binding_proofs, + ) + + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: () + ) + toolchain = tmp_path / "toolchain" + dependencies = toolchain / "node_modules" + entrypoint = dependencies / "@playwright/test/cli.js" + entrypoint.parent.mkdir(parents=True) + entrypoint.write_text("cli", encoding="utf-8") + launcher = toolchain / "node" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + launcher.chmod(0o755) + browser = toolchain / "browsers" + browser.mkdir() + lockfile = toolchain / "package-lock.json" + lockfile.write_text("{}", encoding="utf-8") + native = toolchain / "native.so" + native.write_bytes(b"native") + reporter = toolchain / "reporter.cjs" + reporter.write_text("reporter", encoding="utf-8") + roles = PlaywrightToolchainRoles( + launcher, entrypoint, dependencies, browser, (native,), lockfile + ) + destination = tmp_path / "phase/node_modules" + proofs = _playwright_snapshot_binding_proofs( + reporter, roles, launcher, destination, roles.native_bindings + ) + identity, aggregate = _playwright_snapshot_aggregate_identity( + proofs, reporter, roles, launcher, destination, roles.native_bindings + ) + assert aggregate.accepted_toolchain_identity == identity + scratch = tmp_path / "scratch" + scratch.mkdir() + read_fd, write_fd = os.pipe() + try: + argv, plan = _sandbox_command( + [str(launcher), str(destination / "@playwright/test/cli.js")], + (scratch,), + readable_roots=(reporter, *roles.readable_roots), + readable_bindings=(*roles.native_bindings, (dependencies, destination)), + snapshot_binding_proofs=proofs, + playwright_snapshot_aggregate=aggregate, + result_write_fd=write_fd, + result_fd=198, + ) + finally: + os.close(read_fd) + os.close(write_fd) + + records = [json.loads(value) for value in json.loads(argv[-8])] + aggregate_record = next( + record for record in records + if record["schema"] == "pdd-playwright-snapshot-aggregate-record-v1" + ) + bwrap = json.loads(argv[-4]) + assert aggregate_record["accepted_toolchain_identity"] == identity + assert aggregate_record["expected_digest"] == aggregate.digest + assert {member["role"] for member in aggregate_record["members"]} >= { + "reporter", "launcher", "dependencies", "browser_runtime", "lockfile", + "native_runtime/0", + } + for member in aggregate_record["members"]: + assert ["--ro-bind", bwrap[bwrap.index(member["destination"]) - 1], + member["destination"]] in [ + bwrap[index:index + 3] for index in range(len(bwrap) - 2) + ] + assert bwrap[:10].count("--preserve-fds") == 1 + assert plan.helper_source.count( + "verify_playwright_aggregate(playwright,mapped=True)" + ) == 2 + compile(plan.helper_source, "", "exec") + + def _descriptor_runtime_proof( copied: Path, protected: Path, *, destination: Path | None = None, native_mode: int = 0o644, @@ -187,6 +280,43 @@ def _descriptor_runtime_proof( return proof +def _descriptor_runtime_alias_proofs( + copied: tuple[Path, Path], protected: Path, +) -> tuple[ImmutableBindingProof, ImmutableBindingProof]: + """Create two descriptor members naming one canonical native authority.""" + from pdd.sync_core.runner import ( # pylint: disable=import-outside-toplevel + VitestToolchainDescriptor, + VitestToolchainMember, + _vitest_descriptor_attestation, + _vitest_immutable_binding_proofs, + _vitest_member_payload, + _vitest_members_identity, + ) + + digest = hashlib.sha256(protected.read_bytes()).hexdigest() + members = tuple(sorted(( + VitestToolchainMember("dependencies", PurePosixPath("."), "directory", 0o755), + VitestToolchainMember("entrypoint", PurePosixPath("."), "file", 0o644, digest), + VitestToolchainMember("launcher", PurePosixPath("."), "file", 0o644, digest), + VitestToolchainMember("lockfile", PurePosixPath("."), "file", 0o644, digest), + VitestToolchainMember("native_runtime", PurePosixPath("0"), "file", 0o644, digest), + VitestToolchainMember("native_runtime", PurePosixPath("1"), "file", 0o644, digest), + ), key=lambda item: (item.role, item.relative_path.as_posix()))) + _attestation, identity = _vitest_descriptor_attestation( + tuple(_vitest_member_payload(member) for member in members), + (protected, protected), + linux_wasm_trap_handler_disabled=True, + ) + descriptor = VitestToolchainDescriptor( + protected.parent / "vitest-toolchain.json", protected, protected, + protected.parent, (protected, protected), protected, identity, + _vitest_members_identity(tuple( + member for member in members if member.role == "dependencies" + )), members, + ) + return _vitest_immutable_binding_proofs(copied, descriptor) + + def _proof_with_native_member_field(proof, field: str, value): """Return a proof whose canonical descriptor member has one altered field.""" payload = json.loads(proof.descriptor_attestation) @@ -800,6 +930,70 @@ def test_linux_sandbox_coalesces_descriptor_proven_loader_aliases( ) +@pytest.mark.parametrize( + "mutation", + [ + "attestation", "identity", "protected_source", "destination", "role", + "path", "digest", "mode", "copied_contents", "unproved_binding", + ], +) +def test_linux_sandbox_rejects_nonidentical_loader_alias_authority( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str, +) -> None: + """Alias coalescing accepts only two proofs of one exact native authority.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + protected = tmp_path / "ld-linux-x86-64.so.2" + protected.write_bytes(b"native-loader") + copied = (tmp_path / "copy-a", tmp_path / "copy-b") + for path in copied: + path.write_bytes(protected.read_bytes()) + proofs = list(_descriptor_runtime_alias_proofs(copied, protected)) + bindings = [(copied[0], protected), (copied[1], protected)] + if mutation == "attestation": + proofs[1] = replace(proofs[1], descriptor_attestation="{}") + elif mutation == "identity": + proofs[1] = replace(proofs[1], descriptor_identity="a" * 64) + elif mutation == "protected_source": + other = tmp_path / "other-loader" + other.write_bytes(protected.read_bytes()) + proofs[1] = replace(proofs[1], protected_source=other) + elif mutation == "destination": + proofs[1] = replace(proofs[1], destination=Path("/opt/other-loader")) + elif mutation == "role": + proofs[1] = replace(proofs[1], member_role="launcher") + elif mutation == "path": + proofs[1] = replace(proofs[1], member_path="2") + elif mutation == "digest": + proofs[1] = _proof_with_native_member_field(proofs[1], "digest", "a" * 64) + elif mutation == "mode": + proofs[1] = _proof_with_native_member_field(proofs[1], "mode", 0o600) + elif mutation == "copied_contents": + copied[1].write_bytes(b"different-copy") + else: + ordinary = tmp_path / "ordinary" + ordinary.write_bytes(protected.read_bytes()) + bindings.append((ordinary, protected)) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (protected,) + ) + + with pytest.raises(RuntimeError, match="immutable binding proof|conflicting bindings"): + _sandbox_command( + ["/bin/true"], (tmp_path,), readable_bindings=tuple(bindings), + immutable_binding_proofs=tuple(proofs), + ) + + @pytest.mark.parametrize("mutation", ["copied", "protected", "identity"]) def test_linux_sandbox_rejects_tampered_descriptor_proof( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str, From 7e3bf404ed07fd202853c132463bfb73bbb97de7 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 02:01:12 -0700 Subject: [PATCH 172/233] fix(sync): bind privileged observation authority --- pdd/sync_core/supervisor.py | 152 +++++++++++++++++++++++++---- tests/test_sync_core_supervisor.py | 106 ++++++++++++++++++++ 2 files changed, 241 insertions(+), 17 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 6892f29062..385e5c5fed 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -210,6 +210,15 @@ class _CandidateRecord: timed_out: bool +@dataclass(frozen=True) +class _RootObservationResult: + """Nonce-bound observation metadata released from the root authority.""" + + candidate: _CandidateRecord + observation_digest: str + observation_size: int + + def _canonical_json(payload: object) -> str: """Return the one accepted compact, key-sorted JSON spelling.""" return json.dumps(payload, sort_keys=True, separators=(",", ":")) @@ -1347,6 +1356,7 @@ def _staged_bwrap( candidate_identity: str, unit_name: str, control_directory: Path, limits: SupervisorLimits, candidate_timeout: float, tools: _TrustedTools, + observation_nonce: str | None, ) -> tuple[list[str], _ScopePlan]: """Build one scope-held helper that releases Bubblewrap after verification.""" # pylint: disable=too-many-locals @@ -1359,6 +1369,7 @@ def _staged_bwrap( ) }) staging_root = control_directory / "binds" + authority_root = control_directory / "authority" source_targets = tuple( control_directory / "binds" / str(index) for index in range(len(sources)) ) @@ -1383,7 +1394,8 @@ def _staged_bwrap( "path_tokens=json.loads(sys.argv[8]); " "argv=json.loads(sys.argv[9]); paths=json.loads(sys.argv[10])", "fifo_indices=json.loads(sys.argv[11])", - "limits=json.loads(sys.argv[12])", + "limits=json.loads(sys.argv[12]); observation_nonce=limits.get('observation_nonce')", + "authority=control/'authority'", "playwright_record=''; anonymous_observation=False", "tool_manifest=json.loads(" + repr(tool_manifest) + ")", "def verify_tool(name):", @@ -1545,6 +1557,21 @@ def _staged_bwrap( " if staging_mount is None or '-' not in staging_mount or " "staging_mount[staging_mount.index('-')+1]!='tmpfs': " "raise RuntimeError('protected staging tmpfs mount probe failed')", + " subprocess.run([mount,'-t','tmpfs','-o'," + "'size=17825792,mode=0700,nosuid,nodev','tmpfs',str(authority)]," + "check=True,timeout=limits['trusted_timeout'])", + " staged.append(authority)", + " authority_lines=pathlib.Path('/proc/self/mountinfo').read_text(" + "encoding='utf-8').splitlines()", + " authority_fields=[line.split() for line in authority_lines]", + " authority_mount=next((fields for fields in authority_fields if len(fields)>6 and " + "pathlib.Path(fields[4])==authority),None)", + " if authority_mount is None or '-' not in authority_mount or " + "authority_mount[authority_mount.index('-')+1]!='tmpfs': " + "raise RuntimeError('protected observation authority mount probe failed')", + " authority_metadata=authority.lstat()", + " if not stat.S_ISDIR(authority_metadata.st_mode) or authority_metadata.st_uid!=0 or stat.S_IMODE(authority_metadata.st_mode)!=0o700: " + "raise RuntimeError('protected observation authority ownership probe failed')", " if type(proof_records) is not list or " "any(type(value) is not str for value in proof_records): " "raise RuntimeError('invalid immutable binding proof protocol')", @@ -1671,11 +1698,12 @@ def _staged_bwrap( " if observation_thread.is_alive() or observation_overflow: raise RuntimeError('protected observation relay failed')", " verify_playwright_aggregate(playwright,mapped=True)", " if anonymous_observation:", - " observation_path=control/'observation.bin'", + " observation_path=authority/'observation.bin'", " observation_fd=os.open(observation_path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,'O_NOFOLLOW',0),0o444)", " try:", " for chunk in observation_chunks: os.write(observation_fd,chunk)", " os.fsync(observation_fd)", + " os.fchmod(observation_fd,0o444)", " finally: os.close(observation_fd)", " record={'version':1,'state':'terminal','returncode':result," "'timed_out':timed_out}", @@ -1684,9 +1712,18 @@ def _staged_bwrap( " os.replace(candidate,control/'candidate.json')", " if sum(validate_tree(path) for path in writable_paths) > " "limits['writable']: raise RuntimeError('final writable quota exceeded')", - " temporary=control/'result.tmp'", - " temporary.write_text(json.dumps(record),encoding='ascii')", - " os.replace(temporary,control/'result.json')", + " if anonymous_observation:", + " if type(observation_nonce) is not str or len(observation_nonce)!=64 or any(value not in '0123456789abcdef' for value in observation_nonce): raise RuntimeError('invalid protected observation nonce')", + " record=record|{'observation_nonce':observation_nonce,'observation_sha256':hashlib.sha256(b''.join(observation_chunks)).hexdigest(),'observation_size':observation_size}", + " temporary=authority/'result.tmp'", + " temporary.write_text(json.dumps(record),encoding='ascii')", + " os.chmod(temporary,0o444); os.replace(temporary,authority/'result.json'); os.chmod(authority,0o711)", + " authority_metadata=authority.lstat()", + " if authority_metadata.st_uid!=0 or stat.S_IMODE(authority_metadata.st_mode)!=0o711: raise RuntimeError('protected observation authority release probe failed')", + " else:", + " temporary=control/'result.tmp'", + " temporary.write_text(json.dumps(record),encoding='ascii')", + " os.replace(temporary,control/'result.json')", " wait_for('finish')", "finally:", " if pid is not None and result is None:", @@ -1719,7 +1756,7 @@ def _staged_bwrap( )) plan = _ScopePlan( unit_name, control_directory, helper, tuple(argv), tuple(sources), - (staging_root, *source_targets, control_directory / "binds" / "writable", + (authority_root, staging_root, *source_targets, control_directory / "binds" / "writable", control_directory / "binds" / "cgroup"), tools, immutable_binding_proofs, ) @@ -1749,7 +1786,8 @@ def _staged_bwrap( + 1024 * 1024, ), "timeout": candidate_timeout, - "trusted_timeout": _TRUSTED_COMMAND_SECONDS}), + "trusted_timeout": _TRUSTED_COMMAND_SECONDS, + "observation_nonce": observation_nonce}), ] return command, plan @@ -1837,7 +1875,20 @@ def _load_candidate_record(path: Path) -> _CandidateRecord: payload = json.loads(encoded) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise RuntimeError("protected candidate record is invalid") from exc + if not isinstance(payload, dict) or set(payload) != { + "version", "state", "returncode", "timed_out", + }: + raise RuntimeError("protected candidate record is invalid") + return _load_candidate_record_payload(payload) + + +def _load_candidate_record_payload(payload: object) -> _CandidateRecord: + """Validate the terminal fields shared by candidate and authority records.""" expected_keys = {"version", "state", "returncode", "timed_out"} + if isinstance(payload, dict) and set(payload) != expected_keys: + payload = { + key: payload[key] for key in expected_keys + } if not isinstance(payload, dict) or set(payload) != expected_keys: raise RuntimeError("protected candidate record is invalid") returncode = payload["returncode"] @@ -1860,8 +1911,8 @@ def _load_candidate_record(path: Path) -> _CandidateRecord: return _CandidateRecord(returncode, timed_out) -def _load_root_observation(path: Path, maximum: int) -> bytes: - """Read one bounded root-owned helper artifact through a no-follow descriptor.""" +def _read_root_artifact(path: Path, maximum: int) -> bytes: + """Read one bounded immutable root-owned authority artifact.""" descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) try: before = os.fstat(descriptor) @@ -1889,6 +1940,49 @@ def _load_root_observation(path: Path, maximum: int) -> bytes: os.close(descriptor) +def _load_root_observation_result( + path: Path, nonce: str, maximum: int, +) -> _RootObservationResult: + """Read the exact nonce-bound observation metadata from root authority.""" + # Exact built-in types keep the trusted result record unambiguous. + # pylint: disable=unidiomatic-typecheck + try: + payload = json.loads(_read_root_artifact(path, 4096)) + except (UnicodeError, json.JSONDecodeError) as exc: + raise RuntimeError("protected observation result is invalid") from exc + expected_keys = { + "version", "state", "returncode", "timed_out", "observation_nonce", + "observation_sha256", "observation_size", + } + if not isinstance(payload, dict) or set(payload) != expected_keys: + raise RuntimeError("protected observation result is invalid") + candidate = _load_candidate_record_payload(payload) + digest = payload["observation_sha256"] + size = payload["observation_size"] + if ( + payload["observation_nonce"] != nonce + or type(digest) is not str + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + or type(size) is not int + or not 0 <= size <= maximum + ): + raise RuntimeError("protected observation result is invalid") + return _RootObservationResult(candidate, digest, size) + + +def _load_root_observation( + path: Path, maximum: int, expected_digest: str, expected_size: int, +) -> bytes: + """Read a root authority artifact only when its result binding matches.""" + observation = _read_root_artifact(path, maximum) + if ( + len(observation) != expected_size + or hashlib.sha256(observation).hexdigest() != expected_digest + ): + raise RuntimeError("protected observation artifact does not match result") + return observation + + def _live_processes(pids: dict[int, str | None]) -> set[int]: """Return only observations whose stable process identity still matches.""" live = set() @@ -2132,9 +2226,10 @@ def absent() -> bool: def _prepare_staging(plan: _ScopePlan) -> None: - """Create only the mountpoint for the helper-owned staging tmpfs.""" + """Create only root-helper mountpoints inside the caller control directory.""" binds = plan.control_directory / "binds" binds.mkdir(mode=0o700) + (plan.control_directory / "authority").mkdir(mode=0o700) def _mounted_paths() -> set[Path]: @@ -2187,6 +2282,7 @@ def _sandbox_command( result_fifo: Path | None = None, result_write_fd: int | None = None, result_fd: int = 198, + observation_nonce: str | None = None, unit_name: str | None = None, control_directory: Path | None = None, ) -> tuple[list[str], _ScopePlan]: @@ -2261,6 +2357,11 @@ def _sandbox_command( ) if (aggregate_payload is None) != (result_write_fd is None): raise RuntimeError("Playwright aggregate and anonymous observation must pair") + if aggregate_payload is not None and ( + type(observation_nonce) is not str + or re.fullmatch(r"[0-9a-f]{64}", observation_nonce) is None + ): + raise RuntimeError("Playwright observation requires a fresh parent nonce") if result_fifo is not None and result_write_fd is not None: raise RuntimeError("framework observation transports conflict") for proof in snapshot_binding_proofs: @@ -2548,6 +2649,7 @@ def bind( Path(tempfile.gettempdir()) / f"pdd-scope-{uuid.uuid4().hex}" ), limits=limits, candidate_timeout=candidate_timeout, tools=tools, + observation_nonce=observation_nonce, ) raise RuntimeError("unsupported sandbox platform or mechanism") @@ -2570,6 +2672,7 @@ def run_supervised( """Run after proving one delegated candidate leaf, then remove its scope.""" # pylint: disable=consider-using-with,too-many-locals,too-many-branches,too-many-statements token = uuid.uuid4().hex + observation_nonce = os.urandom(32).hex() if result_write_fd is not None else None unit_name = _scope_unit_name() stdout_buffer = bytearray() stderr_buffer = bytearray() @@ -2657,6 +2760,8 @@ def record_events() -> None: try: with tempfile.TemporaryDirectory(prefix="pdd-scope-") as control_value: control = Path(control_value) + authority = control / "authority" + result_path = control / "result.json" try: argv, plan = _sandbox_command( command, writable_roots, cwd=cwd, @@ -2667,7 +2772,7 @@ def record_events() -> None: playwright_snapshot_aggregate=playwright_snapshot_aggregate, writable_bindings=writable_bindings, result_fifo=result_fifo, result_write_fd=result_write_fd, - result_fd=result_fd, + result_fd=result_fd, observation_nonce=observation_nonce, candidate_timeout=timeout, unit_name=unit_name, control_directory=control, ) @@ -2761,11 +2866,15 @@ def record_events() -> None: control / "candidate.json" ) phase = "trusted-postprocessing" + result_path = ( + authority / "result.json" + if result_write_fd is not None else control / "result.json" + ) postprocess_deadline = min( helper_deadline, time.monotonic() + _TRUSTED_POSTPROCESS_SECONDS, ) - while not (control / "result.json").exists(): + while not result_path.exists(): if process.poll() is not None: break if time.monotonic() >= postprocess_deadline: @@ -2778,16 +2887,25 @@ def record_events() -> None: if fail_for_limit(): break time.sleep(.01) - if (control / "result.json").exists(): + if candidate_record is not None and result_path.exists(): phase = "result-handoff" - result_record = _load_candidate_record(control / "result.json") + if result_write_fd is not None: + assert observation_nonce is not None + root_result = _load_root_observation_result( + result_path, observation_nonce, 16 * 1024 * 1024, + ) + result_record = root_result.candidate + else: + result_record = _load_candidate_record(result_path) if result_record != candidate_record: raise RuntimeError("candidate result changed during handoff") candidate_returncode = result_record.returncode candidate_timed_out = result_record.timed_out if result_write_fd is not None: observation = _load_root_observation( - control / "observation.bin", 16 * 1024 * 1024 + authority / "observation.bin", 16 * 1024 * 1024, + root_result.observation_digest, + root_result.observation_size, ) offset = 0 while offset < len(observation): @@ -2804,7 +2922,7 @@ def record_events() -> None: "scope produced no protected candidate record\n" ) elif ( - not (control / "result.json").exists() + not result_path.exists() and not failed_closed ): failed_closed = True @@ -2815,7 +2933,7 @@ def record_events() -> None: if ( candidate_returncode == 124 and not candidate_timed_out - and (control / "result.json").exists() + and result_path.exists() and not failed_closed ): failed_closed = True diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index ca31772533..8893fbf01b 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -200,6 +200,7 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( playwright_snapshot_aggregate=aggregate, result_write_fd=write_fd, result_fd=198, + observation_nonce="a" * 64, ) finally: os.close(read_fd) @@ -226,9 +227,82 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( assert plan.helper_source.count( "verify_playwright_aggregate(playwright,mapped=True)" ) == 2 + assert plan.control_directory / "authority" in plan.staging_targets + assert "str(authority)" in plan.helper_source + assert "os.chmod(authority,0o711)" in plan.helper_source + assert "control/'observation.bin'" not in plan.helper_source compile(plan.helper_source, "", "exec") +def test_root_authority_rejects_saved_pass_observation_for_current_skip( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A prior root PASS artifact cannot satisfy a current skipped result record.""" + skipped = b'{"tests":[{"status":"skipped"}]}' + saved_pass = b'{"tests":[{"status":"passed"}]}' + nonce = "a" * 64 + result = json.dumps({ + "version": 1, "state": "terminal", "returncode": 0, + "timed_out": False, "observation_nonce": nonce, + "observation_sha256": hashlib.sha256(skipped).hexdigest(), + "observation_size": len(skipped), + }).encode("ascii") + monkeypatch.setattr( + supervisor, "_read_root_artifact", + lambda path, _maximum: result if path.name == "result.json" else saved_pass, + ) + + metadata = supervisor._load_root_observation_result( + Path("authority/result.json"), nonce, 1024, + ) + with pytest.raises(RuntimeError, match="does not match result"): + supervisor._load_root_observation( + Path("authority/observation.bin"), 1024, + metadata.observation_digest, metadata.observation_size, + ) + + +@pytest.mark.parametrize("mutation", ["nonce", "digest", "size"]) +def test_root_authority_observation_binding_mismatches_fail_closed( + monkeypatch: pytest.MonkeyPatch, mutation: str, +) -> None: + """The parent requires the fresh nonce and exact artifact identity.""" + observation = b'{"tests":[]}' + nonce = "b" * 64 + result = { + "version": 1, "state": "terminal", "returncode": 0, + "timed_out": False, "observation_nonce": nonce, + "observation_sha256": hashlib.sha256(observation).hexdigest(), + "observation_size": len(observation), + } + if mutation == "nonce": + result["observation_nonce"] = "c" * 64 + elif mutation == "digest": + result["observation_sha256"] = "0" * 64 + else: + result["observation_size"] = len(observation) + 1 + encoded = json.dumps(result).encode("ascii") + monkeypatch.setattr( + supervisor, "_read_root_artifact", + lambda path, _maximum: encoded if path.name == "result.json" else observation, + ) + + if mutation == "nonce": + with pytest.raises(RuntimeError, match="observation result is invalid"): + supervisor._load_root_observation_result( + Path("authority/result.json"), nonce, 1024, + ) + return + metadata = supervisor._load_root_observation_result( + Path("authority/result.json"), nonce, 1024, + ) + with pytest.raises(RuntimeError, match="does not match result"): + supervisor._load_root_observation( + Path("authority/observation.bin"), 1024, + metadata.observation_digest, metadata.observation_size, + ) + + def _descriptor_runtime_proof( copied: Path, protected: Path, *, destination: Path | None = None, native_mode: int = 0o644, @@ -1650,6 +1724,38 @@ def _terminal_helper( """ +def test_authority_directory_replacement_is_detected_before_relay( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A same-UID replacement attempt leaves no trusted result to relay.""" + monkeypatch.setattr(supervisor, "_TRUSTED_POSTPROCESS_SECONDS", .05) + helper = """import json,pathlib,sys,time +control=pathlib.Path(sys.argv[1]) +(control/'ready').write_text('ready') +while not (control/'start').exists(): time.sleep(.001) +record={'version':1,'state':'terminal','returncode':0,'timed_out':False} +(control/'candidate.json').write_text(json.dumps(record)) +authority=control/'authority'; authority.mkdir() +authority.rename(control/'replayed-authority'); authority.mkdir() +while not (control/'finish').exists(): time.sleep(.001) +""" + _mock_scope_run(tmp_path, monkeypatch, helper) + read_fd, write_fd = os.pipe() + try: + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + result_write_fd=write_fd, + ) + finally: + os.close(read_fd) + os.close(write_fd) + + assert result.returncode == 125 + assert "trusted postprocessing" in result.stderr + assert surviving is False + + def test_scope_setup_deadline_is_not_candidate_timeout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: From a4a307dde46f6c232abc45b625e139c43535c99e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 04:12:26 -0700 Subject: [PATCH 173/233] fix: harden Playwright descriptor supervision --- .github/workflows/unit-tests.yml | 12 +- pdd/sync_core/lifecycle.py | 40 +- pdd/sync_core/runner.py | 62 ++- pdd/sync_core/supervisor.py | 463 ++++++++++++++------ tests/test_sync_core_lifecycle_scenarios.py | 21 + tests/test_sync_core_runner_playwright.py | 123 +++++- tests/test_sync_core_supervisor.py | 288 +++++++++++- 7 files changed, 832 insertions(+), 177 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d57809c26f..c98c3be2ef 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -406,13 +406,15 @@ jobs: assert all(Path(value).is_file() for value in roles["native_runtime"]) PY - - name: Run real protected Playwright source protocol + - name: Run real protected Playwright and authenticated supervisor protocols env: PDD_RUN_REAL_PLAYWRIGHT: '1' - run: > - pytest -q - tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir - --timeout=90 + run: | + pytest -q \ + tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir \ + tests/test_sync_core_supervisor.py::test_real_linux_authenticated_termination_and_cleanup \ + tests/test_sync_core_supervisor.py::test_simultaneous_high_volume_stdio_has_one_aggregate_bound \ + --timeout=90 - name: Validate architecture vs prompt includes (fixture smoke) run: pdd checkup --validate-arch-includes --project-root tests/fixtures/arch_include_validate_ok diff --git a/pdd/sync_core/lifecycle.py b/pdd/sync_core/lifecycle.py index f89eacb83d..ba3c3055b0 100644 --- a/pdd/sync_core/lifecycle.py +++ b/pdd/sync_core/lifecycle.py @@ -80,7 +80,7 @@ def raise_walk_error(error): )) _CANDIDATE_TRANSACTION_SOURCE = "\n".join(( - "import hashlib,json,os,pathlib,signal,stat,subprocess,sys,time,venv", + "import hashlib,json,os,pathlib,selectors,signal,stat,subprocess,sys,time,venv", _VENV_TREE_VALIDATOR_SOURCE.strip(), "if len(sys.argv) != 6: raise RuntimeError('invalid lifecycle transaction argv')", "root=pathlib.Path(sys.argv[1])", @@ -104,12 +104,38 @@ def raise_walk_error(error): " if remaining <= 0: raise RuntimeError('lifecycle child deadline expired')", " child=subprocess.Popen(argv,stdout=subprocess.PIPE,stderr=subprocess.PIPE," "text=False,start_new_session=True)", - " try:", - " stdout,stderr=child.communicate(timeout=remaining)", + " streams={child.stdout:bytearray(),child.stderr:bytearray()}", + " selector=selectors.DefaultSelector()", + " for stream in streams:", + " os.set_blocking(stream.fileno(),False);selector.register(stream,selectors.EVENT_READ)", + " failure=None", + " while selector.get_map():", + " remaining=deadline-time.monotonic()", + " if remaining<=0: failure='lifecycle child deadline expired';break", + " for key,_mask in selector.select(min(remaining,.05)):", + " stream=key.fileobj", + " try: chunk=os.read(stream.fileno(),65536)", + " except BlockingIOError: continue", + " if not chunk: selector.unregister(stream);stream.close();continue", + " target=streams[stream]", + " if len(target)+len(chunk)>output_limit:", + " failure='lifecycle child output exceeded limit';break", + " target.extend(chunk)", + " if failure: break", + " if failure:", + " try: os.killpg(child.pid,signal.SIGKILL)", + " except ProcessLookupError: pass", + " child.wait()", + " for stream in streams: stream.close()", + " selector.close()", + " raise RuntimeError(failure)", + " selector.close()", + " try: child.wait(timeout=max(0,deadline-time.monotonic()))", " except subprocess.TimeoutExpired:", - " os.killpg(child.pid,signal.SIGKILL)", - " child.communicate()", - " raise RuntimeError('lifecycle child deadline expired') from None", + " try: os.killpg(child.pid,signal.SIGKILL)", + " except ProcessLookupError: pass", + " child.wait();raise RuntimeError('lifecycle child deadline expired') from None", + " stdout=bytes(streams[child.stdout]);stderr=bytes(streams[child.stderr])", " try:", " os.killpg(child.pid,0)", " except ProcessLookupError:", @@ -117,8 +143,6 @@ def raise_walk_error(error): " else:", " os.killpg(child.pid,signal.SIGKILL)", " raise RuntimeError('lifecycle child left surviving descendants')", - " if len(stdout)>output_limit or len(stderr)>output_limit: " - "raise RuntimeError('lifecycle child output exceeded limit')", " return child.returncode,stdout,stderr", "def closure():", " rows=[]", diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index bfedc0e100..cceaef4969 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -55,6 +55,7 @@ PlaywrightSnapshotAggregate, SnapshotBindingProof, SupervisorLimits, + TerminationKind, _vitest_descriptor_attestation, released_runtime_closure_paths, run_supervised, @@ -3194,7 +3195,7 @@ def capture(path: Path, relative: PurePosixPath) -> None: mode = stat.S_IMODE(metadata.st_mode) member: dict[str, object] = { "path": relative.as_posix(), "mode": mode, - "digest": None, "target": None, + "digest": None, "size": None, "target": None, } if stat.S_ISDIR(metadata.st_mode): member["kind"] = "directory" @@ -3203,6 +3204,7 @@ def capture(path: Path, relative: PurePosixPath) -> None: capture(child, relative / child.name) elif stat.S_ISREG(metadata.st_mode): member["kind"] = "file" + member["size"] = metadata.st_size descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) try: digest = hashlib.sha256() @@ -3407,12 +3409,26 @@ def _playwright_snapshot_aggregate_identity( role_members["lockfile"], entrypoint_relative, ) + canonical_reporter = _playwright_reporter_source(result_fd).encode("utf-8") + reporter_digest = hashlib.sha256(canonical_reporter).hexdigest() + reporter_members = role_members["reporter"] + if reporter_members != [{ + "path": ".", "mode": stat.S_IMODE(reporter.stat().st_mode), + "digest": reporter_digest, "size": len(canonical_reporter), + "target": None, "kind": "file", + }]: + raise ValueError("Playwright reporter snapshot is not canonical") + checker_identity = hashlib.sha256(json.dumps({ + "reporter_sha256": reporter_digest, + "toolchain_identity": toolchain_identity, + }, sort_keys=True, separators=(",", ":")).encode()).hexdigest() aggregate = { "schema": _PLAYWRIGHT_SNAPSHOT_AGGREGATE_SCHEMA, "toolchain_identity": toolchain_identity, + "checker_identity": checker_identity, "observation": { "role": "reporter", "transport": "anonymous-pipe-v1", - "result_fd": result_fd, + "result_fd": result_fd, "reporter_sha256": reporter_digest, }, "members": aggregate_members, } @@ -3420,7 +3436,7 @@ def _playwright_snapshot_aggregate_identity( return toolchain_identity, PlaywrightSnapshotAggregate( attestation=attestation, digest=hashlib.sha256(attestation.encode()).hexdigest(), - accepted_toolchain_identity=toolchain_identity, + accepted_toolchain_identity=checker_identity, result_fd=result_fd, ) @@ -5043,6 +5059,31 @@ def _playwright_missing_result_detail( return f"{detail}: {diagnostic}" if diagnostic else detail +def _playwright_infrastructure_termination( + result: subprocess.CompletedProcess[str], collection: bool, +) -> tuple[EvidenceOutcome, str] | None: + """Admit reporter interpretation only after authenticated ordinary exit.""" + termination = getattr(result, "termination", None) + kind = getattr(termination, "kind", None) + phase = "collection" if collection else "execution" + if kind is TerminationKind.EXIT: + exit_code = getattr(termination, "exit_code", None) + if type(exit_code) is int and exit_code == result.returncode: # pylint: disable=unidiomatic-typecheck + return None + return EvidenceOutcome.ERROR, f"phase={phase}; Playwright trusted exit evidence is invalid" + if kind is TerminationKind.TIMEOUT: + return EvidenceOutcome.TIMEOUT, f"phase={phase}; Playwright supervisor timed out and descendants were reaped" + if kind is TerminationKind.RESOURCE_LIMIT: + resource = getattr(termination, "resource_limit", None) or "unknown" + return EvidenceOutcome.ERROR, f"phase={phase}; Playwright supervisor resource limit: {resource}" + if kind is TerminationKind.SIGNAL: + number = getattr(termination, "signal_number", None) + return EvidenceOutcome.ERROR, f"phase={phase}; Playwright terminated by trusted signal {number}" + if kind is TerminationKind.SANDBOX_ERROR: + return EvidenceOutcome.ERROR, f"phase={phase}; Playwright sandbox infrastructure failed" + return EvidenceOutcome.ERROR, f"phase={phase}; Playwright trusted termination evidence is missing" + + def _playwright_runtime_prefix( prefix: tuple[str, ...], _launcher: Path, ) -> tuple[str, ...]: @@ -5585,19 +5626,18 @@ def _run_playwright_in_tree( return RunnerExecution( "playwright", EvidenceOutcome.ERROR, digest, str(exc) ), () - if result.returncode == 124: - return RunnerExecution( - "playwright", EvidenceOutcome.TIMEOUT, digest, - _playwright_phase_detail( - "Playwright supervisor timed out and descendants were reaped", - result, collection, - ), - ), () if surviving: return RunnerExecution( "playwright", EvidenceOutcome.ERROR, digest, "Playwright left surviving descendants after execution", ), () + infrastructure = _playwright_infrastructure_termination(result, collection) + if infrastructure is not None: + outcome, detail = infrastructure + return RunnerExecution( + "playwright", outcome, digest, + _playwright_phase_detail(detail, result, collection), + ), () denied_write = result.returncode != 0 and any( marker in result.stderr for marker in ("Operation not permitted", "Permission denied") diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 6f5f6c1c3e..71c07bbb49 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -9,6 +9,7 @@ import math import os import re +import select import shutil import signal import stat @@ -44,6 +45,12 @@ _CANDIDATE_IDENTITY_SCHEMA = "pdd-candidate-identity-v1" _DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES = 4096 _DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES = 48 * 1024 * 1024 +_SNAPSHOT_STAGING_HEADROOM_BYTES = 1024 * 1024 +_SNAPSHOT_MEMBER_METADATA_BYTES = 4096 +_MAX_SNAPSHOT_MEMBER_BYTES = 2 * 1024 * 1024 * 1024 +_MAX_STAGING_BYTES = 4 * 1024 * 1024 * 1024 +_TERMINATION_HEADER_BYTES = 256 +_TERMINATION_HEADER_PREFIX = b"PDD-TERMINATION-V1 " _VITEST_MEMBER_ROLES = frozenset({ "launcher", "entrypoint", "dependencies", "native_runtime", "lockfile", }) @@ -155,6 +162,16 @@ def _supervised_result( ) +def _sandbox_error( + command: list[str], detail: str, +) -> tuple[SupervisedCompletedProcess, bool]: + """Return one typed infrastructure failure for trusted supervisor faults.""" + return _supervised_result( + command, 125, "", detail, + SupervisorTermination(TerminationKind.SANDBOX_ERROR, exit_code=125), + ), False + + def _termination_evidence( returncode: int, *, @@ -343,6 +360,96 @@ def _write_descriptor_frame(stream, payload: dict[str, object], maximum: int) -> stream.flush() +def _descriptor_poll(descriptor: int, event: int, deadline: float) -> None: + """Wait boundedly for one descriptor event.""" + poller = select.poll() + poller.register(descriptor, event | select.POLLERR | select.POLLHUP) + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError("protected descriptor transport timed out") + events = poller.poll(max(1, math.ceil(remaining * 1000))) + if not events: + continue + mask = events[0][1] + if mask & event: + return + raise RuntimeError("protected descriptor transport closed") + + +def _read_descriptor_bytes(descriptor: int, size: int, deadline: float) -> bytes: + """Read an exact bounded byte count without an indefinite blocking call.""" + chunks: list[bytes] = [] + remaining = size + while remaining: + _descriptor_poll(descriptor, select.POLLIN, deadline) + chunk = os.read(descriptor, remaining) + if not chunk: + raise RuntimeError("protected descriptor transport closed") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _read_descriptor_frame_fd( + descriptor: int, maximum: int, deadline: float, +) -> dict[str, object]: + """Read one canonical frame directly from a non-buffered descriptor.""" + header = _read_descriptor_bytes(descriptor, 4, deadline) + size = int.from_bytes(header, "big") + if not 0 < size <= maximum: + raise RuntimeError("protected descriptor frame has invalid size") + encoded = _read_descriptor_bytes(descriptor, size, deadline) + try: + payload = json.loads(encoded) + except (UnicodeError, json.JSONDecodeError) as exc: + raise RuntimeError("protected descriptor frame is invalid") from exc + if type(payload) is not dict or _canonical_json(payload).encode() != encoded: # pylint: disable=unidiomatic-typecheck + raise RuntimeError("protected descriptor frame is not canonical") + return payload + + +def _write_descriptor_bytes(descriptor: int, data: bytes, deadline: float) -> None: + """Write all bytes with polling and a monotonic deadline.""" + blocking = os.get_blocking(descriptor) + os.set_blocking(descriptor, False) + try: + offset = 0 + while offset < len(data): + _descriptor_poll(descriptor, select.POLLOUT, deadline) + try: + written = os.write(descriptor, data[offset:offset + 65536]) + except BlockingIOError: + continue + if written <= 0: + raise RuntimeError("protected descriptor short write") + offset += written + finally: + os.set_blocking(descriptor, blocking) + + +def _write_descriptor_frame_fd( + descriptor: int, payload: dict[str, object], maximum: int, deadline: float, +) -> None: + """Write one canonical frame without trusting buffered stream progress.""" + _write_descriptor_bytes(descriptor, _descriptor_frame(payload, maximum), deadline) + + +def _expect_descriptor_eof(descriptor: int, deadline: float) -> None: + """Require terminal EOF and reject every trailing protocol byte.""" + poller = select.poll() + poller.register(descriptor, select.POLLIN | select.POLLHUP | select.POLLERR) + while time.monotonic() < deadline: + events = poller.poll(max(1, math.ceil((deadline - time.monotonic()) * 1000))) + if not events: + continue + value = os.read(descriptor, 1) + if value: + raise RuntimeError("protected descriptor transport has trailing data") + return + raise RuntimeError("protected descriptor terminal EOF timed out") + + def _descriptor_result( payload: object, nonce: str, aggregate_digest: str, maximum: int, ) -> _DescriptorResult: @@ -674,7 +781,7 @@ def _validate_snapshot_binding_proof(proof: SnapshotBindingProof) -> None: members = payload["members"] paths = [] for member in members: - if type(member) is not dict or set(member) != {"path", "kind", "mode", "digest", "target"}: + if type(member) is not dict or set(member) != {"path", "kind", "mode", "digest", "size", "target"}: raise ValueError("invalid snapshot member") path = member["path"] if type(path) is not str or not path or len(path) > 4096: @@ -684,6 +791,12 @@ def _validate_snapshot_binding_proof(proof: SnapshotBindingProof) -> None: raise ValueError("invalid snapshot member path") if member["kind"] not in {"file", "directory", "symlink"} or type(member["mode"]) is not int: raise ValueError("invalid snapshot member type") + size = member["size"] + if ( + member["kind"] == "file" + and (type(size) is not int or not 0 <= size <= _MAX_SNAPSHOT_MEMBER_BYTES) + ) or (member["kind"] != "file" and size is not None): + raise ValueError("invalid snapshot member size") paths.append(path) if paths != sorted(paths) or paths[0] != "." or len(paths) != len(set(paths)): raise ValueError("non-canonical snapshot members") @@ -718,11 +831,11 @@ def _validate_playwright_snapshot_aggregate( raise ValueError("invalid aggregate scalar") payload = json.loads(aggregate.attestation) valid_payload = type(payload) is dict and set(payload) == { - "schema", "toolchain_identity", "observation", "members", + "schema", "toolchain_identity", "checker_identity", "observation", "members", } valid_authority = valid_payload and ( payload["schema"] == _PLAYWRIGHT_AGGREGATE_SCHEMA - and payload["toolchain_identity"] == aggregate.accepted_toolchain_identity + and payload["checker_identity"] == aggregate.accepted_toolchain_identity ) valid_encoding = ( aggregate.attestation == _canonical_json(payload) @@ -734,12 +847,25 @@ def _validate_playwright_snapshot_aggregate( observation = payload["observation"] if ( type(observation) is not dict - or observation != { - "role": "reporter", "transport": "anonymous-pipe-v1", - "result_fd": aggregate.result_fd, + or set(observation) != { + "role", "transport", "result_fd", "reporter_sha256", } ): raise ValueError("invalid observation authority") + if ( + observation["role"] != "reporter" + or observation["transport"] != "anonymous-pipe-v1" + or observation["result_fd"] != aggregate.result_fd + or type(observation["reporter_sha256"]) is not str + or re.fullmatch(r"[0-9a-f]{64}", observation["reporter_sha256"]) is None + ): + raise ValueError("invalid observation authority") + expected_checker = hashlib.sha256(_canonical_json({ + "reporter_sha256": observation["reporter_sha256"], + "toolchain_identity": payload["toolchain_identity"], + }).encode()).hexdigest() + if expected_checker != payload["checker_identity"]: + raise ValueError("invalid checker identity") members = payload["members"] if type(members) is not list or not members: raise ValueError("invalid aggregate members") @@ -784,6 +910,34 @@ def _validate_playwright_snapshot_aggregate( raise RuntimeError("protected Playwright snapshot aggregate is malformed") from exc +def _snapshot_staging_bytes( + _sources: list[Path], snapshot_records: list[str], +) -> int: + """Return one bounded tmpfs quota covering every attested regular member.""" + total = _SNAPSHOT_STAGING_HEADROOM_BYTES + snapshot_indices: set[int] = set() + try: + for encoded in snapshot_records: + record = json.loads(encoded) + payload = json.loads(record["attestation"]) + index = record["source_index"] + if type(index) is not int or index in snapshot_indices: # pylint: disable=unidiomatic-typecheck + raise ValueError("invalid snapshot source index") + snapshot_indices.add(index) + members = payload["members"] + total += len(members) * _SNAPSHOT_MEMBER_METADATA_BYTES + total += sum( + member["size"] for member in members if member["kind"] == "file" + ) + except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise RuntimeError("protected snapshot staging quota is invalid") from exc + page = os.sysconf("SC_PAGE_SIZE") + required = ((total + page - 1) // page) * page + if not 0 < required <= _MAX_STAGING_BYTES: + raise RuntimeError("protected snapshot staging quota exceeds maximum") + return required + + _SNAPSHOT_STAGING_SOURCE = r''' def _snapshot_failure(): raise RuntimeError("protected sandbox snapshot attestation failed at staging") @@ -833,13 +987,13 @@ def _stage_snapshot(encoded,source,target): if payload["source"]!=str(source) or type(payload["members"]) is not list or not payload["members"]: _snapshot_failure() members=payload["members"]; names=[] for member in members: - if type(member) is not dict or set(member)!={"path","kind","mode","digest","target"}: _snapshot_failure() + if type(member) is not dict or set(member)!={"path","kind","mode","digest","size","target"}: _snapshot_failure() relative=member["path"]; kind=member["kind"] parsed=pathlib.PurePosixPath(relative) if type(relative) is str else None if not relative or parsed.is_absolute() or ".." in parsed.parts or parsed.as_posix()!=relative or kind not in {"file","directory","symlink"} or type(member["mode"]) is not int: _snapshot_failure() - if kind=="file" and (type(member["digest"]) is not str or member["target"] is not None): _snapshot_failure() - if kind=="directory" and (member["digest"] is not None or member["target"] is not None): _snapshot_failure() - if kind=="symlink" and (member["digest"] is not None or type(member["target"]) is not str or not member["target"] or not _snapshot_link_is_contained(relative,member["target"])): _snapshot_failure() + if kind=="file" and (type(member["digest"]) is not str or type(member["size"]) is not int or not 0<=member["size"]<=2147483648 or member["target"] is not None): _snapshot_failure() + if kind=="directory" and (member["digest"] is not None or member["size"] is not None or member["target"] is not None): _snapshot_failure() + if kind=="symlink" and (member["digest"] is not None or member["size"] is not None or type(member["target"]) is not str or not member["target"] or not _snapshot_link_is_contained(relative,member["target"])): _snapshot_failure() names.append(relative) if names!=sorted(names) or names[0]!="." or len(names)!=len(set(names)): _snapshot_failure() root_fd=os.open(source,os.O_RDONLY|os.O_CLOEXEC|getattr(os,"O_NOFOLLOW",0)|(os.O_DIRECTORY if members[0]["kind"]=="directory" else 0)) @@ -858,7 +1012,8 @@ def _stage_snapshot(encoded,source,target): destination=pathlib.Path(target) if relative=="." else pathlib.Path(target)/relative if kind=="directory": if not stat.S_ISDIR(metadata.st_mode): _snapshot_failure() - if relative!=".": destination.mkdir(mode=member["mode"]) + if relative==".": os.chmod(destination,member["mode"],follow_symlinks=False) + else: destination.mkdir(mode=member["mode"]) if descriptor is not None: os.close(descriptor) elif kind=="symlink": if not stat.S_ISLNK(metadata.st_mode) or target_text!=member["target"]: _snapshot_failure() @@ -876,7 +1031,7 @@ def _stage_snapshot(encoded,source,target): os.fchmod(destination_fd,member["mode"]) finally: os.close(destination_fd) finally: os.close(source_fd) - if digest.hexdigest()!=member["digest"]: _snapshot_failure() + if digest.hexdigest()!=member["digest"] or metadata.st_size!=member["size"]: _snapshot_failure() except OSError: _snapshot_failure() finally: os.close(root_fd) @@ -910,7 +1065,7 @@ def _verify_staged_snapshot(encoded,target): chunk=os.read(descriptor,1048576) if not chunk: break digest.update(chunk) - if digest.hexdigest()!=member["digest"]: _snapshot_failure() + if digest.hexdigest()!=member["digest"] or metadata.st_size!=member["size"]: _snapshot_failure() if descriptor is not None: os.close(descriptor) except OSError: _snapshot_failure() finally: os.close(root_fd) @@ -940,10 +1095,9 @@ def _validated_candidate_identity(encoded,argv): _immutable_failure() if type(argv) is not list or any(type(value) is not str for value in argv): _immutable_failure() - try: separator=argv.index("--") - except ValueError: _immutable_failure() expected=["--reuid",str(uid),"--regid",str(gid),"--clear-groups","--"] - if argv[separator+2:separator+8]!=expected: + matches=[index for index in range(len(argv)-5) if argv[index:index+6]==expected] + if len(matches)!=1: _immutable_failure() return uid,gid @@ -1510,6 +1664,32 @@ def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: str(limits.max_cpu_seconds), str(limits.max_writable_bytes), "256", *command] +_INNER_STATUS_SUPERVISOR_SOURCE = "\n".join(( + "import json,os,signal,sys", + "fd=int(sys.argv[1]);token=sys.argv[2];command=sys.argv[3:]", + "if not command or len(token)!=32 or any(c not in '0123456789abcdef' for c in token): raise RuntimeError('invalid nested termination protocol')", + "os.set_inheritable(fd,False)", + "pid=os.fork()", + "if pid==0:", + " try: os.setsid(); os.execv(command[0],command)", + " except OSError: os._exit(127)", + "pid,status=os.waitpid(pid,os.WUNTRACED)", + "if os.WIFSTOPPED(status):", + " result=-os.WSTOPSIG(status);os.killpg(pid,signal.SIGKILL);os.waitpid(pid,0)", + "elif os.WIFSIGNALED(status): result=-os.WTERMSIG(status)", + "elif os.WIFEXITED(status): result=os.WEXITSTATUS(status)", + "else: raise RuntimeError('invalid nested wait status')", + "payload=json.dumps({'returncode':result,'token':token},sort_keys=True,separators=(',',':')).encode('ascii')", + f"record={_TERMINATION_HEADER_PREFIX!r}+payload+b'\\n'", + f"if len(record)>{_TERMINATION_HEADER_BYTES}: raise RuntimeError('nested termination record exceeded limit')", + f"record=record.ljust({_TERMINATION_HEADER_BYTES},b' ')", + "remaining=memoryview(record)", + "while remaining: remaining=remaining[os.write(fd,remaining):]", + "os.close(fd)", + "raise SystemExit(result if result>=0 else 128-result)", +)) + + def _staged_bwrap( argv: list[str], sources: list[Path], path_tokens: list[str], *, writable_roots: tuple[Path, ...], writable_specs: list[tuple[str, int, str]], @@ -1520,6 +1700,7 @@ def _staged_bwrap( unit_name: str, control_directory: Path, limits: SupervisorLimits, candidate_timeout: float, tools: _TrustedTools, observation_nonce: str | None, + staging_bytes: int, ) -> tuple[list[str], _ScopePlan]: """Build one scope-held helper that releases Bubblewrap after verification.""" # pylint: disable=too-many-locals @@ -1558,21 +1739,32 @@ def _staged_bwrap( "path_tokens=json.loads(sys.argv[8]); " "argv=json.loads(sys.argv[9]); paths=json.loads(sys.argv[10])", "fifo_indices=json.loads(sys.argv[11])", - "limits=json.loads(sys.argv[12]); observation_nonce=limits.get('observation_nonce'); descriptor_protocol=limits.get('descriptor_protocol')", + "limits=json.loads(sys.argv[12]); observation_nonce=limits.get('observation_nonce'); descriptor_protocol=limits.get('descriptor_protocol'); termination_token=limits.get('termination_token')", "if type(descriptor_protocol) is not bool: raise RuntimeError('invalid descriptor transport mode')", + "if type(termination_token) is not str or len(termination_token)!=32 or any(value not in '0123456789abcdef' for value in termination_token): raise RuntimeError('invalid nested termination token')", "if descriptor_protocol and (type(observation_nonce) is not str or len(observation_nonce)!=64 or any(value not in '0123456789abcdef' for value in observation_nonce)): raise RuntimeError('invalid descriptor protocol nonce')", "protocol_in=sys.stdin.buffer; protocol_out=sys.stdout.buffer", "def protocol_send(payload,maximum):", " encoded=json.dumps(payload,sort_keys=True,separators=(',',':')).encode('utf-8')", " if len(encoded)>maximum: raise RuntimeError('protected descriptor frame exceeded limit')", " protocol_out.write(len(encoded).to_bytes(4,'big')+encoded); protocol_out.flush()", - "def protocol_receive(maximum):", - " header=protocol_in.read(4)", + "def protocol_read(size,deadline):", + " chunks=[]", + " while size:", + " remaining=deadline-time.monotonic()", + " if remaining<=0: raise RuntimeError('protected descriptor control timed out')", + " ready,_,_=select.select((protocol_in,),(),(),remaining)", + " if not ready: raise RuntimeError('protected descriptor control timed out')", + " chunk=os.read(protocol_in.fileno(),size)", + " if not chunk: raise RuntimeError('protected descriptor parent disappeared')", + " chunks.append(chunk); size-=len(chunk)", + " return b''.join(chunks)", + "def protocol_receive(maximum,deadline):", + " header=protocol_read(4,deadline)", " if len(header)!=4: raise RuntimeError('protected descriptor parent disappeared')", " size=int.from_bytes(header,'big')", " if not 0limits['output']: candidate_output_overflow=True", - " elif not candidate_output_overflow: chunks.append(chunk)", + " with candidate_output_lock:", + " candidate_output_size+=len(chunk)", + " if candidate_output_size>limits['output']: candidate_output_overflow=True", + " elif not candidate_output_overflow: chunks.append(chunk)", " os.close(fd)", " candidate_output_threads=[threading.Thread(target=drain_candidate,args=(candidate_stdout_read,candidate_stdout),daemon=True),threading.Thread(target=drain_candidate,args=(candidate_stderr_read,candidate_stderr),daemon=True)]", " [thread.start() for thread in candidate_output_threads]", @@ -1878,6 +2084,7 @@ def _staged_bwrap( " if pid == 0:", " os.close(release_write)", " try:", + " os.close(status_read)", " if anonymous_observation:", " os.close(observation_read); os.dup2(observation_write,3) if observation_write!=3 else None", " os.close(observation_write) if observation_write!=3 else None", @@ -1888,9 +2095,13 @@ def _staged_bwrap( " os.close(candidate_stdout_write) if candidate_stdout_write not in {1,2,3} else None; os.close(candidate_stderr_write) if candidate_stderr_write not in {1,2,3} else None", " if os.read(release_read,1)!=b'1': os._exit(125)", " os.close(release_read)", + " status_fd=4 if anonymous_observation else 3", + " os.dup2(status_write,status_fd) if status_write!=status_fd else None", + " os.close(status_write) if status_write!=status_fd else None", " os.execvpe(argv[0],argv,os.environ)", " except OSError: os._exit(125)", " os.close(release_read)", + " os.close(status_write)", " if anonymous_observation: os.close(observation_write)", " if descriptor_protocol: os.close(candidate_stdout_write); os.close(candidate_stderr_write)", " (candidate_cgroup/'cgroup.procs').write_text(str(pid),encoding='ascii')", @@ -1906,6 +2117,26 @@ def _staged_bwrap( " parent_watch=threading.Thread(target=watch_parent,daemon=True); parent_watch.start()", " os.write(release_write,b'1'); os.close(release_write)", " result,timed_out=_supervise_candidate(pid,limits['timeout'])", + " def nested_status(deadline):", + " record=b''", + " while len(record)<256:", + " remaining=deadline-time.monotonic()", + " if remaining<=0: raise RuntimeError('nested termination status timed out')", + " ready,_,_=select.select((status_read,),(),(),remaining)", + " if not ready: raise RuntimeError('nested termination status timed out')", + " try: chunk=os.read(status_read,256-len(record))", + " except BlockingIOError: continue", + " if not chunk: break", + " record+=chunk", + " os.close(status_read)", + " prefix=b'PDD-TERMINATION-V1 '", + " if len(record)!=256 or not record.startswith(prefix): raise RuntimeError('nested termination status is invalid')", + " try: payload=json.loads(record[len(prefix):].rstrip(b' \\n').decode('ascii'))", + " except (UnicodeError,ValueError): raise RuntimeError('nested termination status is invalid') from None", + " if type(payload) is not dict or set(payload)!={'returncode','token'} or payload['token']!=termination_token or type(payload['returncode']) is not int or type(payload['returncode']) is bool or not -255<=payload['returncode']<=255: raise RuntimeError('nested termination status is invalid')", + " return payload['returncode']", + " if timed_out: os.close(status_read)", + " else: result=nested_status(time.monotonic()+limits['trusted_timeout'])", " parent_watch_done.set()", " if parent_watch is not None: parent_watch.join(timeout=limits['trusted_timeout'])", " kill_candidate_leaf()", @@ -1921,7 +2152,7 @@ def _staged_bwrap( " if sum(validate_tree(path) for path in writable_paths) > limits['writable']: raise RuntimeError('final writable quota exceeded')", " payload={'kind':'result','nonce':observation_nonce,'aggregate_digest':playwright['expected_digest'],'candidate':{'version':1,'state':'terminal','returncode':result,'timed_out':timed_out},'stdout':base64.b64encode(b''.join(candidate_stdout)).decode('ascii'),'stderr':base64.b64encode(b''.join(candidate_stderr)).decode('ascii'),'observation':base64.b64encode(b''.join(observation_chunks)).decode('ascii'),'observation_sha256':hashlib.sha256(b''.join(observation_chunks)).hexdigest(),'observation_size':observation_size}", " protocol_send(payload,limits['protocol'])", - " acknowledgement=protocol_receive(4096)", + " acknowledgement=protocol_receive(4096,time.monotonic()+limits['trusted_timeout'])", " expected_ack={'kind':'ack','nonce':observation_nonce,'digest':hashlib.sha256(json.dumps(payload,sort_keys=True,separators=(',',':')).encode('utf-8')).hexdigest()}", " if acknowledgement!=expected_ack: raise RuntimeError('protected descriptor acknowledgement is invalid')", " elif anonymous_observation:", @@ -2008,14 +2239,11 @@ def _staged_bwrap( json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, "writable": limits.max_writable_bytes, "observation": 16 * 1024 * 1024, - "staging": max( - 1024 * 1024, - sum(path.stat().st_size for path in sources if path.is_file()) - + 1024 * 1024, - ), + "staging": staging_bytes, "timeout": candidate_timeout, "trusted_timeout": _TRUSTED_COMMAND_SECONDS, "observation_nonce": observation_nonce, + "termination_token": os.urandom(16).hex(), "descriptor_protocol": descriptor_protocol, "protocol": _DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES, "output": limits.max_output_bytes}), @@ -2116,11 +2344,7 @@ def _load_candidate_record(path: Path) -> _CandidateRecord: def _load_candidate_record_payload(payload: object) -> _CandidateRecord: """Validate the terminal fields shared by candidate and authority records.""" expected_keys = {"version", "state", "returncode", "timed_out"} - if isinstance(payload, dict) and set(payload) != expected_keys: - payload = { - key: payload[key] for key in expected_keys - } - if not isinstance(payload, dict) or set(payload) != expected_keys: + if type(payload) is not dict or set(payload) != expected_keys: # pylint: disable=unidiomatic-typecheck raise RuntimeError("protected candidate record is invalid") returncode = payload["returncode"] timed_out = payload["timed_out"] @@ -2187,7 +2411,10 @@ def _load_root_observation_result( } if not isinstance(payload, dict) or set(payload) != expected_keys: raise RuntimeError("protected observation result is invalid") - candidate = _load_candidate_record_payload(payload) + candidate = _load_candidate_record_payload({ + key: payload[key] + for key in ("version", "state", "returncode", "timed_out") + }) digest = payload["observation_sha256"] size = payload["observation_size"] if ( @@ -2571,8 +2798,7 @@ def _sandbox_command( "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp", "--dir", "/sys", "--dir", "/sys/fs", "--dir", "/sys/fs/cgroup"] - if result_write_fd is not None: - argv[8:8] = ["--preserve-fds", "1"] + argv[8:8] = ["--preserve-fds", "2" if result_write_fd is not None else "1"] sources: list[Path] = [] path_tokens: list[str] = [] writable_specs: list[tuple[str, int, str]] = [] @@ -2840,7 +3066,12 @@ def bind( sandboxed = _anonymous_framework_observation_command( sandboxed, result_fd ) - argv.extend(("--", *drop, *sandboxed)) + status_fd = 4 if result_write_fd is not None else 3 + argv.extend(( + "--", str(tools.helper_python), "-I", "-S", "-c", + _INNER_STATUS_SUPERVISOR_SOURCE, str(status_fd), + "@PDD-TERMINATION-TOKEN@", *drop, *sandboxed, + )) if consumed_proofs != proofs.keys(): raise RuntimeError("protected sandbox has unused immutable binding proof") if len(accepted_snapshots) != len(snapshots): @@ -2881,18 +3112,16 @@ def bind( ), limits=limits, candidate_timeout=candidate_timeout, tools=tools, observation_nonce=observation_nonce, + staging_bytes=_snapshot_staging_bytes(sources, accepted_snapshots), ) raise RuntimeError("unsupported sandbox platform or mechanism") -def _write_all_descriptor_bytes(descriptor: int, data: bytes) -> None: +def _write_all_descriptor_bytes( + descriptor: int, data: bytes, deadline: float, +) -> None: """Forward one validated observation without accepting partial writes.""" - offset = 0 - while offset < len(data): - written = os.write(descriptor, data[offset:]) - if written <= 0: - raise RuntimeError("protected observation descriptor short write") - offset += written + _write_descriptor_bytes(descriptor, data, deadline) def _run_playwright_descriptor_supervised( @@ -2913,12 +3142,7 @@ def _run_playwright_descriptor_supervised( nonce = os.urandom(32).hex() diagnostics = bytearray() helper_stderr = bytearray() - frames: list[dict[str, object]] = [] - frame_error: list[RuntimeError] = [] - frame_lock = threading.Lock() - frame_event = threading.Event() process: subprocess.Popen[bytes] | None = None - reader: threading.Thread | None = None stderr_reader: threading.Thread | None = None plan: _ScopePlan | None = None cgroup: Path | None = None @@ -2936,36 +3160,11 @@ def add_diagnostic(value: str) -> None: remaining = max(0, limits.max_output_bytes - len(diagnostics)) diagnostics.extend(value.encode("utf-8", errors="replace")[:remaining]) - def read_frames(stream) -> None: - try: - while True: - frame = _read_descriptor_frame(stream, _DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES) - with frame_lock: - frames.append(frame) - frame_event.set() - except RuntimeError as exc: - with frame_lock: - frame_error.append(exc) - frame_event.set() - def read_stderr(stream) -> None: while chunk := stream.read(65536): remaining = max(0, limits.max_output_bytes - len(helper_stderr)) helper_stderr.extend(chunk[:remaining]) - def next_frame(deadline: float) -> dict[str, object]: - while time.monotonic() < deadline: - with frame_lock: - if frames: - return frames.pop(0) - if frame_error: - raise frame_error[0] - if process is None or process.poll() is not None: - raise RuntimeError("protected descriptor helper exited") - frame_event.wait(.01) - frame_event.clear() - raise RuntimeError("protected descriptor transport timed out") - try: endpoint = os.fstat(result_write_fd) if not stat.S_ISFIFO(endpoint.st_mode) or os.get_inheritable(result_write_fd): @@ -2991,22 +3190,25 @@ def next_frame(deadline: float) -> dict[str, object]: start_new_session=True, ) assert process.stdin is not None and process.stdout is not None and process.stderr is not None - reader = threading.Thread(target=read_frames, args=(process.stdout,), daemon=True) stderr_reader = threading.Thread(target=read_stderr, args=(process.stderr,), daemon=True) - reader.start() stderr_reader.start() phase = "scope-setup" - ready = next_frame(time.monotonic() + _TRUSTED_SETUP_SECONDS) + ready = _read_descriptor_frame_fd( + process.stdout.fileno(), _DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, + time.monotonic() + _TRUSTED_SETUP_SECONDS, + ) if ready != {"kind": "ready", "nonce": nonce}: raise RuntimeError("protected descriptor ready frame is invalid") cgroup, memory_before, pids_before = _probe_scope(plan, limits) - _write_descriptor_frame( - process.stdin, {"kind": "start", "nonce": nonce}, + _write_descriptor_frame_fd( + process.stdin.fileno(), {"kind": "start", "nonce": nonce}, _DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, + time.monotonic() + _TRUSTED_COMMAND_SECONDS, ) phase = "candidate-execution" - payload = next_frame( - time.monotonic() + timeout + _TRUSTED_POSTPROCESS_SECONDS + payload = _read_descriptor_frame_fd( + process.stdout.fileno(), _DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES, + time.monotonic() + timeout + _TRUSTED_POSTPROCESS_SECONDS, ) phase = "result-handoff" result = _descriptor_result( @@ -3019,9 +3221,12 @@ def next_frame(deadline: float) -> dict[str, object]: candidate_stderr = result.stderr if candidate_returncode == 124 and not timed_out: raise RuntimeError("candidate used reserved exit status 124") - _write_all_descriptor_bytes(result_write_fd, result.observation) - _write_descriptor_frame( - process.stdin, + handoff_deadline = time.monotonic() + _TRUSTED_POSTPROCESS_SECONDS + _write_all_descriptor_bytes( + result_write_fd, result.observation, handoff_deadline + ) + _write_descriptor_frame_fd( + process.stdin.fileno(), { "kind": "ack", "nonce": nonce, "digest": hashlib.sha256( @@ -3029,9 +3234,13 @@ def next_frame(deadline: float) -> dict[str, object]: ).hexdigest(), }, _DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, + handoff_deadline, ) process.stdin.close() process.wait(timeout=_TRUSTED_POSTPROCESS_SECONDS) + _expect_descriptor_eof( + process.stdout.fileno(), time.monotonic() + _TRUSTED_COMMAND_SECONDS + ) expected_helper_exit = ( candidate_returncode if candidate_returncode >= 0 else 128 - candidate_returncode @@ -3074,7 +3283,7 @@ def next_frame(deadline: float) -> dict[str, object]: except RuntimeError as exc: failed_closed = True add_diagnostic(f"protected supervisor phase=mount-cleanup: {exc}\n") - for thread in (reader, stderr_reader): + for thread in (stderr_reader,): if thread is not None: thread.join(timeout=1) if thread.is_alive(): @@ -3087,10 +3296,13 @@ def next_frame(deadline: float) -> dict[str, object]: (candidate_stderr + bytes(helper_stderr) + bytes(diagnostics))[:limits.max_output_bytes].decode( "utf-8", errors="replace" ), - _termination_evidence( - 125 if failed_closed else (124 if timed_out else candidate_returncode), - timed_out=timed_out, timeout_seconds=timeout, - resource_limit=resource_limit, + ( + SupervisorTermination(TerminationKind.SANDBOX_ERROR, exit_code=125) + if failed_closed else _termination_evidence( + 124 if timed_out else candidate_returncode, + timed_out=timed_out, timeout_seconds=timeout, + resource_limit=resource_limit, + ) ), ), False @@ -3114,10 +3326,10 @@ def run_supervised( # pylint: disable=consider-using-with,too-many-locals,too-many-branches,too-many-statements,too-many-return-statements if playwright_snapshot_aggregate is not None: if result_write_fd is None or result_fifo is not None: - return _supervised_result( - command, 125, "", "protected supervisor phase=construction: invalid Playwright descriptor endpoint", - _termination_evidence(125, timed_out=False, timeout_seconds=timeout, resource_limit=None), - ), False + return _sandbox_error( + command, + "protected supervisor phase=construction: invalid Playwright descriptor endpoint", + ) return _run_playwright_descriptor_supervised( command, cwd=cwd, timeout=timeout, env=env, writable_roots=writable_roots, limits=limits, readable_roots=readable_roots, @@ -3156,14 +3368,14 @@ def run_supervised( try: endpoint = os.fstat(result_write_fd) except OSError as exc: - return subprocess.CompletedProcess( - command, 125, "", f"protected supervisor phase=construction: {exc}" - ), False + return _sandbox_error( + command, f"protected supervisor phase=construction: {exc}" + ) if not stat.S_ISFIFO(endpoint.st_mode) or os.get_inheritable(result_write_fd): - return subprocess.CompletedProcess( - command, 125, "", + return _sandbox_error( + command, "protected supervisor phase=construction: invalid anonymous observation endpoint", - ), False + ) def add_diagnostic(value: str) -> None: data = value.encode("utf-8", errors="replace") @@ -3239,9 +3451,9 @@ def record_events() -> None: ) _prepare_staging(plan) except (OSError, RuntimeError) as exc: - return subprocess.CompletedProcess( - command, 125, "", f"protected supervisor phase=construction: {exc}" - ), False + return _sandbox_error( + command, f"protected supervisor phase=construction: {exc}" + ) sandbox_environment = env | { "PATH": _TRUSTED_ROOT_PATH, "PYTHONDONTWRITEBYTECODE": "1", @@ -3263,14 +3475,14 @@ def record_events() -> None: try: _cleanup_staging(plan) except RuntimeError as cleanup_exc: - return subprocess.CompletedProcess( - command, 125, "", + return _sandbox_error( + command, "protected supervisor phase=launch: " f"{exc}; cleanup failed: {cleanup_exc}", - ), False - return subprocess.CompletedProcess( - command, 125, "", f"protected supervisor phase=launch: {exc}" - ), False + ) + return _sandbox_error( + command, f"protected supervisor phase=launch: {exc}" + ) assert process.stdout is not None and process.stderr is not None output_threads = [ @@ -3368,9 +3580,13 @@ def record_events() -> None: root_result.observation_digest, root_result.observation_size, ) - offset = 0 - while offset < len(observation): - offset += os.write(result_write_fd, observation[offset:]) + _write_all_descriptor_bytes( + result_write_fd, observation, + min( + postprocess_deadline, + time.monotonic() + _TRUSTED_COMMAND_SECONDS, + ), + ) fail_for_limit() record_events() if ( @@ -3521,8 +3737,11 @@ def record_events() -> None: returncode, stdout_bytes.decode("utf-8", errors="replace"), stderr_bytes.decode("utf-8", errors="replace"), - _termination_evidence( - returncode, timed_out=candidate_timed_out, timeout_seconds=timeout, - resource_limit=resource_limit, + ( + SupervisorTermination(TerminationKind.SANDBOX_ERROR, exit_code=125) + if failed_closed else _termination_evidence( + returncode, timed_out=candidate_timed_out, timeout_seconds=timeout, + resource_limit=resource_limit, + ) ), ), surviving diff --git a/tests/test_sync_core_lifecycle_scenarios.py b/tests/test_sync_core_lifecycle_scenarios.py index cffa29b453..32ab32083c 100644 --- a/tests/test_sync_core_lifecycle_scenarios.py +++ b/tests/test_sync_core_lifecycle_scenarios.py @@ -275,6 +275,27 @@ def test_candidate_transaction_fails_on_proof_or_closure_mutation( assert receipt is None +def test_candidate_transaction_terminates_concurrent_output_at_bound( + tmp_path: Path, +) -> None: + """The production wrapper drains both streams and kills before buffering past 1 MiB.""" + completed, receipt = _run_candidate_transaction_wrapper( + tmp_path, + tmp_path / "pdd_cli-1.0.0-py3-none-any.whl", + cli_source=( + "import os\n" + "if __name__ == '__main__':\n" + " payload = b'x' * (1024 * 1024 + 65536)\n" + " os.write(1, payload)\n" + " os.write(2, payload)\n" + ), + ) + + assert completed.returncode != 0 + assert receipt is None + assert "lifecycle child output exceeded limit" in completed.stderr + + def test_lifecycle_command_maps_inputs_read_only_and_environment_immutable( tmp_path, monkeypatch ) -> None: diff --git a/tests/test_sync_core_runner_playwright.py b/tests/test_sync_core_runner_playwright.py index 6d70a4afeb..fe337a3403 100644 --- a/tests/test_sync_core_runner_playwright.py +++ b/tests/test_sync_core_runner_playwright.py @@ -14,6 +14,11 @@ import pytest import pdd.sync_core.runner as runner_module +from pdd.sync_core.supervisor import ( + SupervisedCompletedProcess, + SupervisorTermination, + TerminationKind, +) from pdd.sync_core import ( AttestationIssue, @@ -40,6 +45,7 @@ _playwright_snapshot_aggregate_identity, _playwright_snapshot_binding_proofs, _playwright_host_temp_parent, + _playwright_infrastructure_termination, _toolchain_manifest_roles, _toolchain_manifest_identity, _playwright_toolchain_identity, @@ -72,6 +78,24 @@ ) +def _trusted_completed( + command, returncode: int, stdout: str = "", stderr: str = "", *, + kind: TerminationKind = TerminationKind.EXIT, + resource_limit: str | None = None, +) -> SupervisedCompletedProcess: + """Construct production-shaped trusted termination evidence for adapter fakes.""" + termination = SupervisorTermination( + kind, + exit_code=returncode if kind in {TerminationKind.EXIT, TerminationKind.SANDBOX_ERROR} else None, + signal_number=-returncode if kind is TerminationKind.SIGNAL else None, + timeout_seconds=1 if kind is TerminationKind.TIMEOUT else None, + resource_limit=resource_limit, + ) + return SupervisedCompletedProcess( + command, returncode, stdout, stderr, termination=termination + ) + + @pytest.fixture(autouse=True) def _simulate_framework_observation_for_synthetic_playwright( monkeypatch: pytest.MonkeyPatch, @@ -121,13 +145,20 @@ def supervised(command, **kwargs): check=False, ) except subprocess.TimeoutExpired as exc: - result = subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "") + result = _trusted_completed( + command, 124, exc.stdout or "", exc.stderr or "", + kind=TerminationKind.TIMEOUT, + ) finally: if saved_fd is None: os.close(result_fd) else: os.dup2(saved_fd, result_fd) os.close(saved_fd) + if not isinstance(result, SupervisedCompletedProcess): + result = _trusted_completed( + command, result.returncode, result.stdout, result.stderr + ) return result, False monkeypatch.setattr(runner_module, "run_supervised", supervised) @@ -394,6 +425,15 @@ def test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir( manifest = Path(os.environ["PDD_REAL_PLAYWRIGHT_TOOLCHAIN_MANIFEST"]) roles = json.loads(manifest.read_text(encoding="utf-8"))["roles"] + for role in ("dependencies", "browser_runtime"): + role_root = Path(roles[role]) + assert stat.S_IMODE(role_root.stat().st_mode) == 0o755 + dependencies = Path(roles["dependencies"]) + recursive_bytes = sum( + member.stat().st_size for member in dependencies.rglob("*") + if member.is_file() and not member.is_symlink() + ) + assert recursive_bytes > 1024 * 1024 root = tmp_path / "real-playwright-repo" root.mkdir() _git(root, "init", "-q") @@ -855,7 +895,7 @@ def supervised(command, **_kwargs): _write_framework_observation(_kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) - return subprocess.CompletedProcess(command, 0, "forged stdout is ignored", ""), False + return _trusted_completed(command, 0, "forged stdout is ignored", ""), False monkeypatch.setattr(runner_module, "run_supervised", supervised) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) @@ -917,7 +957,7 @@ def supervised(command, **kwargs): _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "skipped"}], }) - return subprocess.CompletedProcess(command, 0, "", ""), False + return _trusted_completed(command, 0), False monkeypatch.setattr(runner_module, "run_supervised", supervised) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) @@ -943,7 +983,7 @@ def test_playwright_snapshot_aggregate_binds_every_toolchain_role( manifest = _toolchain_manifest(tmp_path / "toolchain", launcher, entrypoint) roles = _toolchain_manifest_roles(manifest) reporter = tmp_path / "reporter.cjs" - reporter.write_text("reporter", encoding="utf-8") + reporter.write_text(_playwright_reporter_source(198), encoding="utf-8") dependency_destination = tmp_path / "phase" / "node_modules" runtime_prefix = _playwright_runtime_prefix( (str(roles.launcher), str(roles.entrypoint)), roles.launcher @@ -975,16 +1015,16 @@ def test_playwright_snapshot_aggregate_binds_every_toolchain_role( dependency_destination, roles.native_bindings, ) - reporter_identity, reporter_aggregate = _playwright_snapshot_aggregate_identity( - reporter_proofs, - reporter, - roles, - Path(runtime_prefix[0]), - dependency_destination, - roles.native_bindings, - ) - assert reporter_identity == accepted - assert reporter_aggregate.digest != aggregate.digest + with pytest.raises(ValueError, match="reporter snapshot is not canonical"): + _playwright_snapshot_aggregate_identity( + reporter_proofs, + reporter, + roles, + Path(runtime_prefix[0]), + dependency_destination, + roles.native_bindings, + ) + reporter.write_text(_playwright_reporter_source(198), encoding="utf-8") if role == "launcher": roles.launcher.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") @@ -1034,7 +1074,7 @@ def test_playwright_snapshot_aggregate_binds_external_entrypoint_role( manifest.write_text(json.dumps(payload), encoding="utf-8") roles = _toolchain_manifest_roles(manifest) reporter = tmp_path / "reporter.cjs" - reporter.write_text("reporter", encoding="utf-8") + reporter.write_text(_playwright_reporter_source(198), encoding="utf-8") dependency_destination = tmp_path / "phase" / "node_modules" accepted = _toolchain_manifest_identity(manifest) @@ -1154,7 +1194,7 @@ def supervised(command, **kwargs): _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) - return subprocess.CompletedProcess(command, 0, "", ""), False + return _trusted_completed(command, 0), False monkeypatch.setattr(runner_module, "run_supervised", supervised) execution, _identities = runner_module._run_playwright_in_tree( @@ -1506,7 +1546,7 @@ def supervised(command, *, cwd, writable_roots, writable_bindings, **kwargs): _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) - return subprocess.CompletedProcess(command, 0, "", ""), False + return _trusted_completed(command, 0), False monkeypatch.setattr(runner_module, "run_supervised", supervised) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) @@ -1646,7 +1686,7 @@ def run_supervised(command, **kwargs): _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) - return subprocess.CompletedProcess(command, 0, "", ""), False + return _trusted_completed(command, 0), False monkeypatch.setattr(runner_module, "run_supervised", run_supervised) try: @@ -1688,7 +1728,7 @@ def run_supervised(command, **kwargs): _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) - return subprocess.CompletedProcess(command, 0, "", ""), False + return _trusted_completed(command, 0), False monkeypatch.setattr( runner_module.PlaywrightToolchainRoles, @@ -1817,7 +1857,7 @@ def supervised(command, **kwargs): _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) - return subprocess.CompletedProcess(command, 0, "", ""), False + return _trusted_completed(command, 0), False monkeypatch.setattr( runner_module.tempfile, "TemporaryDirectory", CleanupFailureTemporaryDirectory @@ -3337,7 +3377,7 @@ def supervised(command, *, cwd, writable_roots, **_kwargs): assert cwd not in writable_roots assert cwd / ".git" not in writable_roots _write_framework_observation(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) - return subprocess.CompletedProcess(command, 0, "", ""), False + return _trusted_completed(command, 0), False monkeypatch.setattr(runner_module, "run_supervised", supervised) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) @@ -3377,7 +3417,7 @@ def supervised(command, *, cwd, **_kwargs): (cwd / "candidate-cache").mkdir() (cwd / "candidate-cache/state").write_text("candidate", encoding="utf-8") _write_framework_observation(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) - return subprocess.CompletedProcess(command, 0, "", ""), False + return _trusted_completed(command, 0), False monkeypatch.setattr(runner_module, "run_supervised", supervised) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) @@ -3554,6 +3594,41 @@ def test_playwright_missing_observation_has_bounded_diagnostics() -> None: assert len(detail) < 600 +@pytest.mark.parametrize( + ("kind", "returncode", "outcome"), + [ + (TerminationKind.TIMEOUT, 124, EvidenceOutcome.TIMEOUT), + (TerminationKind.RESOURCE_LIMIT, 125, EvidenceOutcome.ERROR), + (TerminationKind.SIGNAL, -9, EvidenceOutcome.ERROR), + (TerminationKind.SANDBOX_ERROR, 125, EvidenceOutcome.ERROR), + ], +) +def test_playwright_rejects_non_exit_termination_before_reporter( + kind: TerminationKind, returncode: int, outcome: EvidenceOutcome, +) -> None: + """Only authenticated ordinary EXIT evidence may reach reporter status parsing.""" + result = _trusted_completed( + ["playwright"], returncode, kind=kind, + resource_limit="memory" if kind is TerminationKind.RESOURCE_LIMIT else None, + ) + + observed = _playwright_infrastructure_termination(result, False) + + assert observed is not None + assert observed[0] is outcome + + +def test_playwright_requires_typed_termination_evidence() -> None: + result = subprocess.CompletedProcess(["playwright"], 0, "", "") + + observed = _playwright_infrastructure_termination(result, False) + + assert observed == ( + EvidenceOutcome.ERROR, + "phase=execution; Playwright trusted termination evidence is missing", + ) + + def test_playwright_timeout_preserves_phase_reporter_and_cgroup_diagnostics( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -3569,7 +3644,7 @@ def supervised(command, **kwargs): }], }) stderr = "cgroup pids.events max delta=7\n" + ("x" * 5000) - return subprocess.CompletedProcess( + return _trusted_completed( command, 0 if collection else 1, "", stderr ), False @@ -3598,7 +3673,7 @@ def supervised(command, **kwargs): _write_framework_observation(kwargs, { "tests": [{"identity": IDENTITY, "status": "passed"}], }) - return subprocess.CompletedProcess(command, 0, "", ""), False + return _trusted_completed(command, 0), False monkeypatch.setattr(runner_module, "run_supervised", supervised) _envelope, executions = _run(root, commit, commit, _fake_playwright(tmp_path)) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 0f42294019..16daeb7932 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -138,12 +138,88 @@ def test_helper_snapshot_rejects_attested_file_swap(tmp_path: Path) -> None: ) +def test_snapshot_staging_applies_attested_directory_root_mode( + tmp_path: Path, +) -> None: + """The production helper makes a normal 0755 snapshot root traversable.""" + from pdd.sync_core.runner import _snapshot_binding_proof # pylint: disable=import-outside-toplevel + + source = tmp_path / "source" + source.mkdir(mode=0o755) + (source / "nested").mkdir(mode=0o755) + (source / "nested/member").write_bytes(b"measured") + proof = _snapshot_binding_proof(source, Path("/opt/toolchain")) + target = tmp_path / "snapshot" + target.mkdir(mode=0o700) + namespace = { + "hashlib": hashlib, "json": json, "os": os, + "pathlib": __import__("pathlib"), "stat": stat, + } + exec(supervisor._SNAPSHOT_STAGING_SOURCE, namespace) # pylint: disable=exec-used + record = json.dumps({ + "schema": "pdd-snapshot-binding-record-v1", "source_index": 0, + "attestation": proof.attestation, + }, sort_keys=True, separators=(",", ":")) + + namespace["_stage_snapshot"](record, source, target) + namespace["_verify_staged_snapshot"](record, target) + + assert stat.S_IMODE(target.stat().st_mode) == 0o755 + assert (target / "nested/member").read_bytes() == b"measured" + + +def test_snapshot_staging_quota_counts_recursive_attested_files( + tmp_path: Path, +) -> None: + """Nested regular bytes, metadata, and headroom all contribute to tmpfs size.""" + from pdd.sync_core.runner import _snapshot_binding_proof # pylint: disable=import-outside-toplevel + + source = tmp_path / "source" + nested = source / "deep" + nested.mkdir(parents=True) + (nested / "large.bin").write_bytes(b"x" * (2 * 1024 * 1024)) + proof = _snapshot_binding_proof(source, Path("/opt/toolchain")) + record = json.dumps({ + "schema": "pdd-snapshot-binding-record-v1", "source_index": 0, + "attestation": proof.attestation, + }, sort_keys=True, separators=(",", ":")) + + required = supervisor._snapshot_staging_bytes([source], [record]) + + assert required > 3 * 1024 * 1024 + assert required <= supervisor._MAX_STAGING_BYTES + + +def test_snapshot_staging_quota_rejects_explicit_global_maximum() -> None: + """Attested members cannot request an unbounded privileged tmpfs.""" + members = [ + {"path": ".", "kind": "directory", "mode": 0o755, + "digest": None, "size": None, "target": None}, + ] + [ + {"path": f"large-{index}", "kind": "file", "mode": 0o644, + "digest": "a" * 64, "size": 2 * 1024 * 1024 * 1024, + "target": None} + for index in range(2) + ] + record = json.dumps({ + "schema": "pdd-snapshot-binding-record-v1", "source_index": 0, + "attestation": json.dumps({ + "schema": "pdd-snapshot-binding-v1", "source": "/source", + "destination": "/target", "members": members, + }), + }) + + with pytest.raises(RuntimeError, match="exceeds maximum"): + supervisor._snapshot_staging_bytes([Path("/source")], [record]) + + def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """The privileged record names every typed snapshot and final destination.""" from pdd.sync_core.runner import ( # pylint: disable=import-outside-toplevel PlaywrightToolchainRoles, + _playwright_reporter_source, _playwright_snapshot_aggregate_identity, _playwright_snapshot_binding_proofs, ) @@ -177,7 +253,7 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( native = toolchain / "native.so" native.write_bytes(b"native") reporter = toolchain / "reporter.cjs" - reporter.write_text("reporter", encoding="utf-8") + reporter.write_text(_playwright_reporter_source(198), encoding="utf-8") roles = PlaywrightToolchainRoles( launcher, entrypoint, dependencies, browser, (native,), lockfile ) @@ -188,7 +264,9 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( identity, aggregate = _playwright_snapshot_aggregate_identity( proofs, reporter, roles, launcher, destination, roles.native_bindings ) - assert aggregate.accepted_toolchain_identity == identity + aggregate_payload = json.loads(aggregate.attestation) + assert aggregate_payload["toolchain_identity"] == identity + assert aggregate.accepted_toolchain_identity == aggregate_payload["checker_identity"] scratch = tmp_path / "scratch" scratch.mkdir() read_fd, write_fd = os.pipe() @@ -214,7 +292,7 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( if record["schema"] == "pdd-playwright-snapshot-aggregate-record-v1" ) bwrap = json.loads(argv[-4]) - assert aggregate_record["accepted_toolchain_identity"] == identity + assert aggregate_record["accepted_toolchain_identity"] == aggregate.accepted_toolchain_identity assert aggregate_record["expected_digest"] == aggregate.digest assert {member["role"] for member in aggregate_record["members"]} >= { "reporter", "launcher", "dependencies", "browser_runtime", "lockfile", @@ -1612,7 +1690,8 @@ def test_linux_sandbox_uses_portable_framework_observation_fifo( assert argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] assert "-C" not in argv[:6] bwrap = json.loads(argv[-4]) - assert "--preserve-fds" not in bwrap + preserve_index = bwrap.index("--preserve-fds") + assert bwrap[preserve_index + 1] == "1" observation_path = "/run/pdd-framework-observation" observation_index = bwrap.index(observation_path) assert bwrap[observation_index - 2] == "--bind" @@ -1627,7 +1706,10 @@ def test_linux_sandbox_uses_portable_framework_observation_fifo( separator = bwrap.index("--") candidate_argv = bwrap[separator + 1:] assert str(fifo) not in candidate_argv - wrapper = candidate_argv[candidate_argv.index("-c") + 1] + wrapper = next( + value for value in candidate_argv + if "os.dup2(source,target)" in value + ) assert "os.dup2(source,target)" in wrapper assert "os.open(path" in wrapper assert "result_fifo" not in plan.helper_source @@ -1770,6 +1852,7 @@ def test_scope_setup_deadline_is_not_candidate_timeout( ) assert result.returncode == 125 + assert result.termination.kind is supervisor.TerminationKind.SANDBOX_ERROR assert "phase=scope-setup" in result.stderr assert cleanup == ["scope", "mounts"] assert surviving is False @@ -1875,6 +1958,7 @@ def fail_cleanup(*_args): ) assert result.returncode == 125 + assert result.termination.kind is supervisor.TerminationKind.SANDBOX_ERROR assert "scope-cleanup" in result.stderr @@ -2341,7 +2425,12 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( assert "replace_host" not in helper assert "_publish_writable_files" not in helper assert helper.index("candidate.json") < helper.index("result.tmp") - assert helper.index("mount_lines=") < helper.index("ready") + assert helper.index("mount_lines=") < helper.index( + "protocol_send({'kind':'ready'" + ) + assert helper.index("mount_lines=") < helper.index( + "(control/'ready').write_text" + ) assert "@PDD-CGROUP@" in plan.bwrap_argv cgroup_source = plan.bwrap_argv.index("@PDD-CGROUP@") assert plan.bwrap_argv[cgroup_source - 1] == "--ro-bind" @@ -3019,6 +3108,21 @@ def test_macos_fails_closed_without_kernel_lifetime_containment( assert surviving is False +def test_playwright_construction_failure_is_typed_sandbox_error(tmp_path: Path) -> None: + """Malformed descriptor construction never becomes candidate EXIT evidence.""" + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, env={}, + writable_roots=(tmp_path,), + playwright_snapshot_aggregate=supervisor.PlaywrightSnapshotAggregate( + "{}", "a" * 64, "b" * 64, 198, + ), + ) + assert result.returncode == 125 + assert result.termination.kind is supervisor.TerminationKind.SANDBOX_ERROR + assert "phase=construction" in result.stderr + assert surviving is False + + def test_file_size_limit_uses_writable_budget() -> None: limits = SupervisorLimits(max_output_bytes=1234, max_writable_bytes=987654) command = _limited_command(["/bin/true"], limits) @@ -3039,6 +3143,71 @@ def test_binary_output_has_aggregate_limit_and_deterministic_decode(tmp_path: Pa assert "\ufffd" in result.stdout +@pytest.mark.real +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not shutil.which("bwrap"), + reason="requires hosted Linux privileged supervision", +) +def test_real_linux_authenticated_termination_and_cleanup(tmp_path: Path) -> None: + """The production chain authenticates exit, signal, timeout, and pids evidence.""" + cases = ( + ([sys.executable, "-c", "raise SystemExit(17)"], 5, + supervisor.TerminationKind.EXIT, 17), + ([sys.executable, "-c", + "import os,signal;os.kill(os.getpid(),signal.SIGTERM)"], 5, + supervisor.TerminationKind.SIGNAL, -signal.SIGTERM), + ([sys.executable, "-c", "import time;time.sleep(5)"], 1, + supervisor.TerminationKind.TIMEOUT, 124), + ) + for command, timeout, kind, returncode in cases: + result, surviving = run_supervised( + command, cwd=tmp_path, timeout=timeout, env=dict(os.environ), + writable_roots=(tmp_path,), + ) + assert isinstance(result, supervisor.SupervisedCompletedProcess) + assert result.termination.kind is kind + assert result.returncode == returncode + assert surviving is False + + fork_program = ( + "import os,time\n" + "for _ in range(32):\n" + " try: pid=os.fork()\n" + " except OSError: break\n" + " if pid==0: time.sleep(2);os._exit(0)\n" + "time.sleep(.1)\n" + ) + result, surviving = run_supervised( + [sys.executable, "-c", fork_program], cwd=tmp_path, timeout=5, + env=dict(os.environ), writable_roots=(tmp_path,), + limits=replace(SupervisorLimits(), max_processes=8), + ) + assert result.termination.kind is supervisor.TerminationKind.RESOURCE_LIMIT + assert result.termination.resource_limit == "pids" + assert surviving is False + + +@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") +def test_simultaneous_high_volume_stdio_has_one_aggregate_bound(tmp_path: Path) -> None: + """Concurrent stdout/stderr drains share one synchronized byte budget.""" + program = ( + "import os,threading\n" + "def emit(fd):\n" + " [os.write(fd,b'x'*65536) for _ in range(6)]\n" + "threads=[threading.Thread(target=emit,args=(fd,)) for fd in (1,2)]\n" + "[thread.start() for thread in threads]\n" + "[thread.join() for thread in threads]\n" + ) + result, surviving = run_supervised( + [sys.executable, "-c", program], cwd=tmp_path, timeout=10, + env=dict(os.environ), writable_roots=(tmp_path,), + limits=replace(SupervisorLimits(), max_output_bytes=1024 * 1024), + ) + assert result.returncode == 0 + assert len(result.stdout.encode()) + len(result.stderr.encode()) == 12 * 65536 + assert surviving is False + + def test_live_processes_rejects_reused_pid_identity(monkeypatch) -> None: monkeypatch.setattr("pdd.sync_core.supervisor._process_identity", lambda _pid: "new") monkeypatch.setattr(os, "kill", lambda *_args: None) @@ -3057,6 +3226,110 @@ def test_descriptor_transport_rejects_partial_malformed_and_oversized_frames( supervisor._read_descriptor_frame(io.BytesIO(frame), 16) +def test_descriptor_transport_rejects_duplicate_and_trailing_terminal_frames() -> None: + """The finite-state transport stores one frame and requires terminal EOF.""" + read_fd, write_fd = os.pipe() + try: + os.write(write_fd, supervisor._descriptor_frame( + {"kind": "ready", "nonce": "a" * 64}, 4096, + )) + os.write(write_fd, supervisor._descriptor_frame( + {"kind": "ready", "nonce": "a" * 64}, 4096, + )) + os.close(write_fd) + write_fd = -1 + first = supervisor._read_descriptor_frame_fd( + read_fd, 4096, time.monotonic() + 1, + ) + duplicate = supervisor._read_descriptor_frame_fd( + read_fd, 4096, time.monotonic() + 1, + ) + assert first == duplicate + with pytest.raises(RuntimeError, match="descriptor result"): + supervisor._descriptor_result(duplicate, "a" * 64, "b" * 64, 1024) + finally: + os.close(read_fd) + if write_fd >= 0: + os.close(write_fd) + + read_fd, write_fd = os.pipe() + try: + os.write(write_fd, b"trailing") + os.close(write_fd) + write_fd = -1 + with pytest.raises(RuntimeError, match="trailing data"): + supervisor._expect_descriptor_eof(read_fd, time.monotonic() + 1) + finally: + os.close(read_fd) + if write_fd >= 0: + os.close(write_fd) + + +def test_descriptor_control_read_has_monotonic_deadline() -> None: + """A parent/helper stall cannot leave descriptor control blocked forever.""" + read_fd, write_fd = os.pipe() + try: + started = time.monotonic() + with pytest.raises(RuntimeError, match="timed out"): + supervisor._read_descriptor_frame_fd( + read_fd, 4096, time.monotonic() + 0.02, + ) + assert time.monotonic() - started < 0.5 + finally: + os.close(read_fd) + os.close(write_fd) + + +@pytest.mark.parametrize("payload", [ + None, [], {}, {"version": 1}, + {"version": 1, "state": "terminal", "returncode": 0}, + {"version": 1, "state": "terminal", "returncode": 0, + "timed_out": False, "extra": True}, + {"version": 1, "state": "terminal", "returncode": True, + "timed_out": False}, +]) +def test_candidate_record_parser_fails_closed_for_every_malformed_shape( + payload: object, +) -> None: + """Nested candidate status has one exact key and type grammar.""" + with pytest.raises(RuntimeError, match="candidate record"): + supervisor._load_candidate_record_payload(payload) + + +@pytest.mark.parametrize( + ("program", "expected"), + [ + ("raise SystemExit(17)", 17), + ("import os,signal;os.kill(os.getpid(),signal.SIGTERM)", -signal.SIGTERM), + ], +) +def test_inner_status_supervisor_authenticates_nested_returncode( + program: str, expected: int, +) -> None: + """The exact production inner supervisor reports exit and signal status.""" + token = "a" * 32 + read_fd, write_fd = os.pipe() + try: + completed = subprocess.run( + [sys.executable, "-I", "-S", "-c", + supervisor._INNER_STATUS_SUPERVISOR_SOURCE, str(write_fd), token, + sys.executable, "-c", program], + pass_fds=(write_fd,), check=False, timeout=5, + ) + os.close(write_fd) + write_fd = -1 + record = os.read(read_fd, supervisor._TERMINATION_HEADER_BYTES) + finally: + os.close(read_fd) + if write_fd >= 0: + os.close(write_fd) + assert len(record) == supervisor._TERMINATION_HEADER_BYTES + assert record.startswith(supervisor._TERMINATION_HEADER_PREFIX) + payload = json.loads(record[len(supervisor._TERMINATION_HEADER_PREFIX):].rstrip()) + assert payload == {"returncode": expected, "token": token} + assert completed.returncode == (expected if expected >= 0 else 128 - expected) + + def test_descriptor_result_rejects_replayed_nonce_and_aggregate() -> None: """A result from another helper lifecycle cannot be replayed as a PASS.""" observation = b'{"tests":[]}' @@ -3081,7 +3354,7 @@ def test_playwright_descriptor_helper_denies_candidate_control_descriptors() -> assert "os.dup2(candidate_stdout_write,1)" in source assert "os.dup2(candidate_stderr_write,2)" in source assert "POLLHUP|select.POLLERR" in source - assert "protocol_receive(4096)" in source + assert "protocol_receive(4096,time.monotonic()+limits['trusted_timeout'])" in source def test_playwright_descriptor_transport_timeout_fails_closed( @@ -3130,6 +3403,7 @@ def sandbox(_command, _roots, **_kwargs): os.close(read_fd) os.close(write_fd) assert result.returncode == 125 + assert result.termination.kind is supervisor.TerminationKind.SANDBOX_ERROR assert "descriptor transport timed out" in result.stderr assert surviving is False From dc976394e8e6c41b7b3c4549145b12bdebf6ea73 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 04:35:04 -0700 Subject: [PATCH 174/233] fix(sync): account immutable staging bytes --- .github/workflows/unit-tests.yml | 1 + pdd/sync_core/supervisor.py | 83 ++++++-- tests/test_sync_core_supervisor.py | 312 ++++++++++++++++++++++++++++- 3 files changed, 377 insertions(+), 19 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index c98c3be2ef..a2f4bab912 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -413,6 +413,7 @@ jobs: pytest -q \ tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir \ tests/test_sync_core_supervisor.py::test_real_linux_authenticated_termination_and_cleanup \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain \ tests/test_sync_core_supervisor.py::test_simultaneous_high_volume_stdio_has_one_aggregate_bound \ --timeout=90 diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 71c07bbb49..b20a0caf42 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -47,6 +47,7 @@ _DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES = 48 * 1024 * 1024 _SNAPSHOT_STAGING_HEADROOM_BYTES = 1024 * 1024 _SNAPSHOT_MEMBER_METADATA_BYTES = 4096 +_IMMUTABLE_STAGING_METADATA_BYTES = 4096 _MAX_SNAPSHOT_MEMBER_BYTES = 2 * 1024 * 1024 * 1024 _MAX_STAGING_BYTES = 4 * 1024 * 1024 * 1024 _TERMINATION_HEADER_BYTES = 256 @@ -242,6 +243,7 @@ class _ValidatedBindingProof: collision_category: str member_digest: str member_mode: int + member_size: int @dataclass(frozen=True) @@ -647,21 +649,21 @@ def _vitest_descriptor_attestation( return encoded, identity -def _regular_file_matches(path: Path, digest: str, mode: int) -> bool: - """Match a no-follow regular file to one canonical descriptor member.""" +def _validated_regular_file_size(path: Path, digest: str, mode: int) -> int | None: + """Return a no-follow validated file size, or ``None`` on identity failure.""" try: descriptor = os.open( path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | os.O_CLOEXEC ) except OSError: - return False + return None try: before = os.fstat(descriptor) if ( not stat.S_ISREG(before.st_mode) or stat.S_IMODE(before.st_mode) != mode ): - return False + return None actual = hashlib.sha256() while chunk := os.read(descriptor, 1024 * 1024): actual.update(chunk) @@ -673,9 +675,11 @@ def _regular_file_matches(path: Path, digest: str, mode: int) -> bool: after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns, ) - return stable and actual.hexdigest() == digest + if not stable or actual.hexdigest() != digest: + return None + return before.st_size except OSError: - return False + return None finally: os.close(descriptor) @@ -684,7 +688,7 @@ def _validate_immutable_binding_proof( proof: ImmutableBindingProof, ) -> _ValidatedBindingProof: """Validate exact proof authority, topology, member, and live identities.""" - # pylint: disable=unidiomatic-typecheck + # pylint: disable=too-many-locals,unidiomatic-typecheck try: if type(proof) is not ImmutableBindingProof: raise ValueError("invalid proof type") @@ -740,16 +744,20 @@ def _validate_immutable_binding_proof( raise ValueError("proof source is not a regular file") digest = member["digest"] mode = member["mode"] - if not ( - _regular_file_matches(copied, digest, mode) - and _regular_file_matches(protected, digest, mode) + copied_size = _validated_regular_file_size(copied, digest, mode) + protected_size = _validated_regular_file_size(protected, digest, mode) + if ( + copied_size is None + or protected_size is None + or copied_size != protected_size + or copied_size > _MAX_STAGING_BYTES ): raise ValueError("proof member identity mismatch") return _ValidatedBindingProof( copied, protected, destination, proof.descriptor_attestation, descriptor_identity, proof.member_role, proof.member_path, proof.collision_category, - digest, mode, + digest, mode, copied_size, ) except (AttributeError, json.JSONDecodeError, OSError, TypeError, ValueError) as exc: raise RuntimeError("protected sandbox immutable binding proof is malformed") from exc @@ -911,26 +919,61 @@ def _validate_playwright_snapshot_aggregate( def _snapshot_staging_bytes( - _sources: list[Path], snapshot_records: list[str], + sources: list[Path], snapshot_records: list[str], + immutable_records: tuple[tuple[int, _ValidatedBindingProof], ...] = (), ) -> int: - """Return one bounded tmpfs quota covering every attested regular member.""" + """Return one bounded tmpfs quota for all validated copied sources.""" + # pylint: disable=too-many-branches,too-many-locals + # Exact built-in types keep a privileged accounting record unambiguous. + # pylint: disable=too-many-boolean-expressions,unidiomatic-typecheck total = _SNAPSHOT_STAGING_HEADROOM_BYTES snapshot_indices: set[int] = set() + immutable_indices: set[int] = set() try: for encoded in snapshot_records: record = json.loads(encoded) payload = json.loads(record["attestation"]) index = record["source_index"] - if type(index) is not int or index in snapshot_indices: # pylint: disable=unidiomatic-typecheck + if ( # pylint: disable=unidiomatic-typecheck + type(index) is not int + or not 0 <= index < len(sources) + or index in snapshot_indices + ): raise ValueError("invalid snapshot source index") snapshot_indices.add(index) members = payload["members"] + if type(members) is not list: + raise ValueError("invalid snapshot members") total += len(members) * _SNAPSHOT_MEMBER_METADATA_BYTES - total += sum( - member["size"] for member in members if member["kind"] == "file" - ) + for member in members: + if type(member) is not dict or member.get("kind") not in { + "file", "directory", "symlink" + }: + raise ValueError("invalid snapshot member") + size = member.get("size") + if member["kind"] == "file": + if type(size) is not int or not 0 <= size <= _MAX_SNAPSHOT_MEMBER_BYTES: + raise ValueError("invalid snapshot member size") + total += size + elif size is not None: + raise ValueError("invalid snapshot member size") + for index, proof in immutable_records: + if ( + type(index) is not int + or not 0 <= index < len(sources) + or index in immutable_indices + or type(proof) is not _ValidatedBindingProof + or type(proof.member_size) is not int + or not 0 <= proof.member_size <= _MAX_STAGING_BYTES + ): + raise ValueError("invalid immutable staging record") + immutable_indices.add(index) + if index not in snapshot_indices: + total += _IMMUTABLE_STAGING_METADATA_BYTES + proof.member_size except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc: raise RuntimeError("protected snapshot staging quota is invalid") from exc + if total > _MAX_STAGING_BYTES: + raise RuntimeError("protected snapshot staging quota exceeds maximum") page = os.sysconf("SC_PAGE_SIZE") required = ((total + page - 1) // page) * page if not 0 < required <= _MAX_STAGING_BYTES: @@ -2860,6 +2903,7 @@ def _sandbox_command( proofs[key] = validated consumed_proofs: set[tuple[Path, Path, Path]] = set() accepted_records: list[str] = [] + accepted_immutable_records: list[tuple[int, _ValidatedBindingProof]] = [] accepted_snapshots: list[str] = [] accepted_snapshot_details: dict[str, tuple[int, Path]] = {} @@ -2942,6 +2986,7 @@ def bind( "protected sandbox immutable binding proof has no source" ) consumed_proofs.add(key) + accepted_immutable_records.append((previous[3], proof)) accepted_records.append(_canonical_json({ "schema": _BINDING_RECORD_SCHEMA, "source_index": previous[3], @@ -3112,7 +3157,9 @@ def bind( ), limits=limits, candidate_timeout=candidate_timeout, tools=tools, observation_nonce=observation_nonce, - staging_bytes=_snapshot_staging_bytes(sources, accepted_snapshots), + staging_bytes=_snapshot_staging_bytes( + sources, accepted_snapshots, tuple(accepted_immutable_records) + ), ) raise RuntimeError("unsupported sandbox platform or mechanism") diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 16daeb7932..0395498eb5 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -12,6 +12,7 @@ import stat import subprocess import sys +import tempfile import time from dataclasses import replace from pathlib import Path @@ -213,6 +214,129 @@ def test_snapshot_staging_quota_rejects_explicit_global_maximum() -> None: supervisor._snapshot_staging_bytes([Path("/source")], [record]) +def test_snapshot_staging_quota_counts_immutable_records_without_snapshots( + tmp_path: Path, +) -> None: + """Copied immutable runtime files reserve tmpfs before the helper starts.""" + protected = [] + records = [] + for index in range(2): + source = tmp_path / f"native-{index}" + source.write_bytes(bytes([index]) * (640 * 1024)) + source.chmod(0o644) + copied = tmp_path / f"copied-{index}" + copied.write_bytes(source.read_bytes()) + copied.chmod(0o644) + protected.append(copied) + records.append((index, supervisor._validate_immutable_binding_proof( + _descriptor_runtime_proof(copied, source) + ))) + + required = supervisor._snapshot_staging_bytes(protected, [], tuple(records)) + + minimum = ( + supervisor._SNAPSHOT_STAGING_HEADROOM_BYTES + + 2 * supervisor._IMMUTABLE_STAGING_METADATA_BYTES + + 2 * 640 * 1024 + ) + assert required >= minimum + assert required > 1024 * 1024 + + +def test_snapshot_staging_quota_deduplicates_snapshot_covered_immutable_source( + tmp_path: Path, +) -> None: + """A source represented by a snapshot record consumes its bytes only once.""" + from pdd.sync_core.runner import _snapshot_binding_proof # pylint: disable=import-outside-toplevel + + source = tmp_path / "native" + source.write_bytes(b"snapshot-bytes") + source.chmod(0o644) + copied = tmp_path / "copied" + copied.write_bytes(source.read_bytes()) + copied.chmod(0o644) + immutable = supervisor._validate_immutable_binding_proof( + _descriptor_runtime_proof(copied, source) + ) + snapshot = _snapshot_binding_proof(source, Path("/opt/native")) + snapshot_record = json.dumps({ + "schema": "pdd-snapshot-binding-record-v1", "source_index": 0, + "attestation": snapshot.attestation, + }, sort_keys=True, separators=(",", ":")) + + counted = supervisor._snapshot_staging_bytes( + [copied], [snapshot_record], ((0, replace( + immutable, member_size=supervisor._MAX_STAGING_BYTES + )),), + ) + snapshot_only = supervisor._snapshot_staging_bytes( + [copied], [snapshot_record], + ) + + assert counted == snapshot_only + + +@pytest.mark.parametrize( + "records,match", + [ + (((0, object()),), "invalid"), + (((0, None),), "invalid"), + ], +) +def test_snapshot_staging_quota_rejects_malformed_immutable_records( + records: tuple[tuple[int, object], ...], match: str, +) -> None: + """Only validated regular immutable records can affect a privileged quota.""" + with pytest.raises(RuntimeError, match=match): + supervisor._snapshot_staging_bytes([Path("/source")], [], records) # type: ignore[arg-type] + + +def test_snapshot_staging_quota_rejects_duplicate_and_over_cap_immutable_records( + tmp_path: Path, +) -> None: + """Duplicate accounting and any global-cap overflow fail closed.""" + source = tmp_path / "native" + source.write_bytes(b"trusted") + source.chmod(0o644) + copied = tmp_path / "copied" + copied.write_bytes(source.read_bytes()) + copied.chmod(0o644) + record = supervisor._validate_immutable_binding_proof( + _descriptor_runtime_proof(copied, source) + ) + with pytest.raises(RuntimeError, match="invalid"): + supervisor._snapshot_staging_bytes( + [copied], [], ((0, record), (0, record)) + ) + with pytest.raises(RuntimeError, match="exceeds maximum"): + supervisor._snapshot_staging_bytes( + [copied], [], ((0, replace( + record, member_size=supervisor._MAX_STAGING_BYTES + )),), + ) + + +def test_immutable_quota_uses_validated_size_and_rejects_mutated_identity( + tmp_path: Path, +) -> None: + """Quota sizing never trusts a mutable post-validation stat result.""" + source = tmp_path / "native" + source.write_bytes(b"trusted-size") + source.chmod(0o644) + copied = tmp_path / "copied" + copied.write_bytes(source.read_bytes()) + copied.chmod(0o644) + proof = _descriptor_runtime_proof(copied, source) + record = supervisor._validate_immutable_binding_proof(proof) + required = supervisor._snapshot_staging_bytes([copied], [], ((0, record),)) + + copied.write_bytes(b"changed-size-and-identity") + + assert supervisor._snapshot_staging_bytes([copied], [], ((0, record),)) == required + with pytest.raises(RuntimeError, match="immutable binding proof"): + supervisor._validate_immutable_binding_proof(proof) + + def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -415,7 +539,9 @@ def _descriptor_runtime_proof( ), key=lambda item: (item.role, item.relative_path.as_posix()))) identity = hashlib.sha256(json.dumps({ "members": _vitest_members_identity(members), - "launch_policy": {"linux_wasm_trap_handler_disabled": True}, + "launch_policy": { + "linux_wasm_trap_handler_disabled": sys.platform.startswith("linux"), + }, }, sort_keys=True, separators=(",", ":")).encode()).hexdigest() descriptor = VitestToolchainDescriptor( protected.parent / "vitest-toolchain.json", @@ -3187,6 +3313,190 @@ def test_real_linux_authenticated_termination_and_cleanup(tmp_path: Path) -> Non assert surviving is False +@pytest.mark.real +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not shutil.which("bwrap"), + reason="requires hosted Linux privileged descriptor supervision", +) +def test_real_linux_playwright_descriptor_exact_chain(tmp_path: Path) -> None: + """Exercise real descriptor parent-loss, frame, relay, and FD-denial paths.""" + from pdd.sync_core.runner import ( # pylint: disable=import-outside-toplevel + PlaywrightToolchainRoles, + _playwright_reporter_source, + _playwright_snapshot_aggregate_identity, + _playwright_snapshot_binding_proofs, + ) + + if subprocess.run(["sudo", "-n", "true"], check=False).returncode: + pytest.fail("hosted descriptor lane requires passwordless sudo") + + toolchain = tmp_path / "descriptor-toolchain" + dependencies = toolchain / "node_modules" + entrypoint = dependencies / "@playwright/test/cli.js" + entrypoint.parent.mkdir(parents=True) + entrypoint.write_text("cli", encoding="utf-8") + launcher = toolchain / "node" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + launcher.chmod(0o755) + browser = toolchain / "browser" + browser.mkdir() + lockfile = toolchain / "package-lock.json" + lockfile.write_text("{}", encoding="utf-8") + native = toolchain / "native.so" + native.write_bytes(b"native") + reporter = toolchain / "reporter.cjs" + reporter.write_text(_playwright_reporter_source(198), encoding="utf-8") + roles = PlaywrightToolchainRoles( + launcher, entrypoint, dependencies, browser, (native,), lockfile + ) + destination = tmp_path / "phase" / "node_modules" + proofs = _playwright_snapshot_binding_proofs( + reporter, roles, launcher, destination, roles.native_bindings + ) + _identity, aggregate = _playwright_snapshot_aggregate_identity( + proofs, reporter, roles, launcher, destination, roles.native_bindings + ) + + inventory_program = """import json,os,subprocess,sys +def inventory(): + return sorted(int(value) for value in os.listdir('/proc/self/fd') if value.isdecimal()) +child = 'import json,os;print(json.dumps(sorted(int(value) for value in os.listdir(\\"/proc/self/fd\\") if value.isdecimal())))' +payload = {name: json.loads(subprocess.check_output([sys.executable, '-c', child])) + for name in ('worker', 'browser', 'descendant')} +payload['coordinator'] = inventory() +os.write(198, json.dumps(payload, sort_keys=True).encode('ascii')) +""" + sleeping_program = "import time; time.sleep(30)" + overflow_program = "import os; os.write(198, b'x' * (17 * 1024 * 1024))" + + def launch(program: str): + control = Path(tempfile.mkdtemp(prefix="pdd-descriptor-", dir=tmp_path)) + scratch = control / "scratch" + scratch.mkdir() + read_fd, write_fd = os.pipe() + try: + argv, plan = _sandbox_command( + [sys.executable, "-c", program], (scratch,), cwd=scratch, + readable_roots=(reporter, *roles.readable_roots), + readable_bindings=(*roles.native_bindings, (dependencies, destination)), + snapshot_binding_proofs=proofs, + playwright_snapshot_aggregate=aggregate, + result_write_fd=write_fd, result_fd=198, + observation_nonce="a" * 64, candidate_timeout=10, + control_directory=control, + ) + finally: + os.close(read_fd) + os.close(write_fd) + supervisor._prepare_staging(plan) + process = subprocess.Popen( + argv, cwd=Path("/"), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=supervisor._privileged_helper_environment(), + start_new_session=True, + ) + assert process.stdin is not None and process.stdout is not None + ready = supervisor._read_descriptor_frame_fd( + process.stdout.fileno(), supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, + time.monotonic() + 30, + ) + assert ready == {"kind": "ready", "nonce": "a" * 64} + return control, plan, process + + def close_case(control, plan, process) -> None: + try: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + supervisor._stop_scope(plan.unit_name, plan.tools) + finally: + supervisor._cleanup_staging(plan) + process.wait(timeout=10) + assert plan.unit_name not in subprocess.run( + ["sudo", "-n", "systemctl", "list-units", "--all", "--no-legend"], + capture_output=True, text=True, check=True, + ).stdout + assert not any(path in supervisor._mounted_paths() for path in plan.staging_targets) + shutil.rmtree(control, ignore_errors=True) + + control, plan, process = launch(inventory_program) + try: + supervisor._write_descriptor_frame_fd( + process.stdin.fileno(), {"kind": "start", "nonce": "a" * 64}, + supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, time.monotonic() + 5, + ) + result = supervisor._read_descriptor_frame_fd( + process.stdout.fileno(), supervisor._DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES, + time.monotonic() + 15, + ) + parsed = supervisor._descriptor_result(result, "a" * 64, aggregate.digest, 1024) + inventory = json.loads(parsed.observation) + assert all(set(values) <= {0, 1, 2, 198} for values in inventory.values()) + supervisor._write_descriptor_frame_fd( + process.stdin.fileno(), { + "kind": "ack", "nonce": "a" * 64, + "digest": hashlib.sha256( + supervisor._canonical_json(result).encode("utf-8") + ).hexdigest(), + }, supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, + time.monotonic() + 5, + ) + process.stdin.close() + assert process.wait(timeout=10) == 0 + supervisor._expect_descriptor_eof(process.stdout.fileno(), time.monotonic() + 5) + finally: + close_case(control, plan, process) + + for program, frames in ( + (sleeping_program, ()), + (inventory_program, ( + {"kind": "start", "nonce": "a" * 64}, + {"kind": "ack", "nonce": "a" * 64, "digest": "0" * 64}, + )), + (overflow_program, ({"kind": "start", "nonce": "a" * 64},)), + ): + control, plan, process = launch(program) + try: + for frame in frames: + supervisor._write_descriptor_frame_fd( + process.stdin.fileno(), frame, + supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, + time.monotonic() + 5, + ) + process.stdin.close() + assert process.wait(timeout=15) != 0 + finally: + close_case(control, plan, process) + + control, plan, process = launch(sleeping_program) + try: + supervisor._write_descriptor_frame_fd( + process.stdin.fileno(), {"kind": "start", "nonce": "a" * 64}, + supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, time.monotonic() + 5, + ) + time.sleep(.1) + process.stdin.close() + assert process.wait(timeout=15) != 0 + finally: + close_case(control, plan, process) + + control, plan, process = launch(inventory_program) + try: + supervisor._write_descriptor_frame_fd( + process.stdin.fileno(), {"kind": "start", "nonce": "a" * 64}, + supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, time.monotonic() + 5, + ) + supervisor._read_descriptor_frame_fd( + process.stdout.fileno(), supervisor._DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES, + time.monotonic() + 15, + ) + process.stdin.close() + assert process.wait(timeout=15) != 0 + finally: + close_case(control, plan, process) + + @pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") def test_simultaneous_high_volume_stdio_has_one_aggregate_bound(tmp_path: Path) -> None: """Concurrent stdout/stderr drains share one synchronized byte budget.""" From 185f2b0374c4ea57562644013f7c52a757858603 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 05:37:17 -0700 Subject: [PATCH 175/233] fix(sync): harden descriptor environment protocol --- .github/workflows/unit-tests.yml | 18 +- pdd/sync_core/supervisor.py | 183 +++++++++-- tests/test_sync_core_supervisor.py | 467 +++++++++++++++++++++++------ tests/test_unit_tests_workflow.py | 48 +++ 4 files changed, 602 insertions(+), 114 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a2f4bab912..540a3f6459 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -167,6 +167,9 @@ jobs: sudo apt-get update sudo apt-get install --yes bubblewrap command -v bwrap + command -v systemd-run + command -v unshare + sudo -n true bwrap --version private_root="$(mktemp -d)" chmod 700 "$private_root" @@ -413,7 +416,20 @@ jobs: pytest -q \ tests/test_sync_core_runner_playwright.py::test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir \ tests/test_sync_core_supervisor.py::test_real_linux_authenticated_termination_and_cleanup \ - tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain \ + tests/test_sync_core_supervisor.py::test_real_linux_adapter_environment_handoff[pytest] \ + tests/test_sync_core_supervisor.py::test_real_linux_adapter_environment_handoff[vitest] \ + tests/test_sync_core_supervisor.py::test_real_linux_adapter_environment_handoff[playwright] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[normal-hierarchy-environment] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[parent-exit-before-start] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[parent-exit-during-execution] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[parent-exit-after-result] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[stalled-result-reader] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[missing-ack] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[duplicate-ack] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[trailing-frame] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[trailing-raw] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[reordered-extra] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[stalled-observation-reader] \ tests/test_sync_core_supervisor.py::test_simultaneous_high_volume_stdio_has_one_aggregate_bound \ --timeout=90 diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index b20a0caf42..794d98b620 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -45,6 +45,10 @@ _CANDIDATE_IDENTITY_SCHEMA = "pdd-candidate-identity-v1" _DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES = 4096 _DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES = 48 * 1024 * 1024 +_MAX_CANDIDATE_ENVIRONMENT_BYTES = 128 * 1024 +_MAX_CANDIDATE_ENVIRONMENT_ENTRIES = 128 +_MAX_CANDIDATE_ENVIRONMENT_KEY_BYTES = 128 +_MAX_CANDIDATE_ENVIRONMENT_VALUE_BYTES = 32 * 1024 _SNAPSHOT_STAGING_HEADROOM_BYTES = 1024 * 1024 _SNAPSHOT_MEMBER_METADATA_BYTES = 4096 _IMMUTABLE_STAGING_METADATA_BYTES = 4096 @@ -55,6 +59,11 @@ _VITEST_MEMBER_ROLES = frozenset({ "launcher", "entrypoint", "dependencies", "native_runtime", "lockfile", }) +_BLOCKED_CANDIDATE_ENV_MARKERS = ( + "API_KEY", "ATTESTATION", "CERTIFICATE", "CREDENTIAL", "PASSWORD", + "RELEASED_CHECKER", "SECRET", "SIGNER", "SIGNING_KEY", "TOKEN", +) +_CANDIDATE_ENV_KEY = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") _PIDFD_PROTOCOL_SOURCE = """ def _supervise_candidate(pid, timeout): @@ -1691,20 +1700,112 @@ def _validate_limits(limits: SupervisorLimits) -> None: # pylint: enable=unidiomatic-typecheck -def _limited_command(command: list[str], limits: SupervisorLimits) -> list[str]: +def _parse_candidate_environment_record(encoded: str) -> dict[str, str]: + """Parse one canonical bounded credential-free candidate environment.""" + # Exact built-in types make duplicate and authority checks unambiguous. + # pylint: disable=unidiomatic-typecheck,too-many-boolean-expressions + try: + if ( + type(encoded) is not str + or not encoded + or len(encoded.encode("utf-8")) > _MAX_CANDIDATE_ENVIRONMENT_BYTES + ): + raise ValueError("invalid environment encoding") + payload = json.loads(encoded) + if ( + type(payload) is not list + or len(payload) > _MAX_CANDIDATE_ENVIRONMENT_ENTRIES + or encoded != _canonical_json(payload) + ): + raise ValueError("invalid environment shape") + environment: dict[str, str] = {} + for item in payload: + if type(item) is not list or len(item) != 2: + raise ValueError("invalid environment entry") + key, value = item + upper = key.upper() if type(key) is str else "" + if ( + type(key) is not str + or type(value) is not str + or _CANDIDATE_ENV_KEY.fullmatch(key) is None + or len(key.encode("utf-8")) > _MAX_CANDIDATE_ENVIRONMENT_KEY_BYTES + or len(value.encode("utf-8")) > _MAX_CANDIDATE_ENVIRONMENT_VALUE_BYTES + or "\0" in value + or key in environment + or ( + key != "PDD_SUPERVISION_TOKEN" + and any(marker in upper for marker in _BLOCKED_CANDIDATE_ENV_MARKERS) + ) + ): + raise ValueError("invalid environment entry") + environment[key] = value + if list(environment) != sorted(environment): + raise ValueError("non-canonical environment order") + return environment + except (AttributeError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise RuntimeError("protected candidate environment is invalid") from exc + + +def _candidate_environment_record( + environment: dict[str, str], *, temp_directory: Path, + supervision_token: str, +) -> str: + """Build the exact environment installed only by the unprivileged wrapper.""" + # pylint: disable=unidiomatic-typecheck + if type(environment) is not dict or any( + type(key) is not str or type(value) is not str + for key, value in environment.items() + ): + raise RuntimeError("protected candidate environment is invalid") + if ( + type(temp_directory) is not type(Path()) + or type(supervision_token) is not str + or re.fullmatch(r"[0-9a-f]{32}", supervision_token) is None + ): + raise RuntimeError("protected candidate environment is invalid") + candidate = dict(environment) + candidate.update({ + "PATH": _TRUSTED_ROOT_PATH, + "PDD_SUPERVISION_TOKEN": supervision_token, + "PYTHONDONTWRITEBYTECODE": "1", + "TEMP": str(temp_directory), + "TMP": str(temp_directory), + "TMPDIR": str(temp_directory), + }) + library_path = _sandbox_library_path(environment) + if library_path: + candidate["LD_LIBRARY_PATH"] = library_path + encoded = _canonical_json([[key, candidate[key]] for key in sorted(candidate)]) + _parse_candidate_environment_record(encoded) + return encoded + + +def _limited_command( + command: list[str], limits: SupervisorLimits, + candidate_environment: str = "[]", +) -> list[str]: """Apply non-raiseable POSIX limits after the namespace uid drop.""" _validate_limits(limits) + _parse_candidate_environment_record(candidate_environment) script = ( - "import os,resource,sys;" + "import json,os,re,resource,sys;" "v=[int(x) for x in sys.argv[1:5]];" "resource.setrlimit(resource.RLIMIT_AS,(v[0],v[0]));" "resource.setrlimit(resource.RLIMIT_CPU,(v[1],v[1]));" "resource.setrlimit(resource.RLIMIT_FSIZE,(v[2],v[2]));" "resource.setrlimit(resource.RLIMIT_NOFILE,(v[3],v[3]));" - "os.execvpe(sys.argv[5],sys.argv[5:],os.environ)" + "encoded=sys.argv[5];payload=json.loads(encoded);" + "valid=isinstance(payload,list) and len(payload)<=128 and len(encoded.encode())<=131072 and encoded==json.dumps(payload,sort_keys=True,separators=(',',':'));" + "keys=[];environment={};" + "valid=valid and all(isinstance(x,list) and len(x)==2 and isinstance(x[0],str) and isinstance(x[1],str) and re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',x[0]) and len(x[0].encode())<=128 and len(x[1].encode())<=32768 and '\\0' not in x[1] and not (x[0]!='PDD_SUPERVISION_TOKEN' and any(m in x[0].upper() for m in ('API_KEY','ATTESTATION','CERTIFICATE','CREDENTIAL','PASSWORD','RELEASED_CHECKER','SECRET','SIGNER','SIGNING_KEY','TOKEN'))) and not (x[0] in environment or environment.setdefault(x[0],x[1]) is None) for x in payload);" + "valid=valid and list(environment)==sorted(environment);" + "valid or (_ for _ in ()).throw(RuntimeError('invalid candidate environment'));" + "os.environ.clear();os.environ.update(environment);" + "os.execvpe(sys.argv[6],sys.argv[6:],os.environ)" ) return [str(_SUPERVISOR_EXECUTABLE), "-c", script, str(limits.max_virtual_memory_bytes), - str(limits.max_cpu_seconds), str(limits.max_writable_bytes), "256", *command] + str(limits.max_cpu_seconds), str(limits.max_writable_bytes), "256", + candidate_environment, *command] _INNER_STATUS_SUPERVISOR_SOURCE = "\n".join(( @@ -1786,11 +1887,25 @@ def _staged_bwrap( "if type(descriptor_protocol) is not bool: raise RuntimeError('invalid descriptor transport mode')", "if type(termination_token) is not str or len(termination_token)!=32 or any(value not in '0123456789abcdef' for value in termination_token): raise RuntimeError('invalid nested termination token')", "if descriptor_protocol and (type(observation_nonce) is not str or len(observation_nonce)!=64 or any(value not in '0123456789abcdef' for value in observation_nonce)): raise RuntimeError('invalid descriptor protocol nonce')", - "protocol_in=sys.stdin.buffer; protocol_out=sys.stdout.buffer", - "def protocol_send(payload,maximum):", + "protocol_in=sys.stdin.buffer; protocol_in_fd=sys.stdin.fileno(); protocol_out_fd=sys.stdout.fileno()", + "def protocol_send(payload,maximum,deadline):", " encoded=json.dumps(payload,sort_keys=True,separators=(',',':')).encode('utf-8')", " if len(encoded)>maximum: raise RuntimeError('protected descriptor frame exceeded limit')", - " protocol_out.write(len(encoded).to_bytes(4,'big')+encoded); protocol_out.flush()", + " frame=memoryview(len(encoded).to_bytes(4,'big')+encoded); blocking=os.get_blocking(protocol_out_fd); os.set_blocking(protocol_out_fd,False)", + " try:", + " poller=select.poll(); poller.register(protocol_out_fd,select.POLLOUT|select.POLLERR|select.POLLHUP)", + " while frame:", + " remaining=deadline-time.monotonic()", + " if remaining<=0: raise RuntimeError('protected descriptor write timed out')", + " events=poller.poll(max(1,math.ceil(remaining*1000)))", + " if not events: raise RuntimeError('protected descriptor write timed out')", + " if any(mask&(select.POLLERR|select.POLLHUP) for _fd,mask in events): raise RuntimeError('protected descriptor parent disappeared')", + " if not any(mask&select.POLLOUT for _fd,mask in events): raise RuntimeError('protected descriptor write failed')", + " try: written=os.write(protocol_out_fd,frame)", + " except BlockingIOError: continue", + " if written<=0: raise RuntimeError('protected descriptor short write')", + " frame=frame[written:]", + " finally: os.set_blocking(protocol_out_fd,blocking)", "def protocol_read(size,deadline):", " chunks=[]", " while size:", @@ -1798,7 +1913,7 @@ def _staged_bwrap( " if remaining<=0: raise RuntimeError('protected descriptor control timed out')", " ready,_,_=select.select((protocol_in,),(),(),remaining)", " if not ready: raise RuntimeError('protected descriptor control timed out')", - " chunk=os.read(protocol_in.fileno(),size)", + " chunk=os.read(protocol_in_fd,size)", " if not chunk: raise RuntimeError('protected descriptor parent disappeared')", " chunks.append(chunk); size-=len(chunk)", " return b''.join(chunks)", @@ -1812,6 +1927,12 @@ def _staged_bwrap( " except (UnicodeError,ValueError): raise RuntimeError('protected descriptor frame is invalid') from None", " if type(payload) is not dict or json.dumps(payload,sort_keys=True,separators=(',',':')).encode('utf-8')!=encoded: raise RuntimeError('protected descriptor frame is not canonical')", " return payload", + "def protocol_expect_eof(deadline):", + " remaining=deadline-time.monotonic()", + " if remaining<=0: raise RuntimeError('protected descriptor terminal EOF timed out')", + " ready,_,_=select.select((protocol_in_fd,),(),(),remaining)", + " if not ready: raise RuntimeError('protected descriptor terminal EOF timed out')", + " if os.read(protocol_in_fd,1)!=b'': raise RuntimeError('protected descriptor control has trailing data')", "authority=control/'authority'", "playwright_record=''; anonymous_observation=False", "tool_manifest=json.loads(" + repr(tool_manifest) + ")", @@ -2099,7 +2220,7 @@ def _staged_bwrap( " staged.append(cgroup_target)", " argv=[str(cgroup_target) if value == '@PDD-CGROUP@' else value for value in argv]", " if descriptor_protocol:", - " protocol_send({'kind':'ready','nonce':observation_nonce},4096)", + " protocol_send({'kind':'ready','nonce':observation_nonce},4096,time.monotonic()+limits['trusted_timeout'])", " start=protocol_receive(4096,time.monotonic()+limits['trusted_timeout'])", " if start!={'kind':'start','nonce':observation_nonce}: raise RuntimeError('protected descriptor start is invalid')", " else:", @@ -2180,8 +2301,6 @@ def _staged_bwrap( " return payload['returncode']", " if timed_out: os.close(status_read)", " else: result=nested_status(time.monotonic()+limits['trusted_timeout'])", - " parent_watch_done.set()", - " if parent_watch is not None: parent_watch.join(timeout=limits['trusted_timeout'])", " kill_candidate_leaf()", " if descriptor_protocol:", " [thread.join(timeout=limits['trusted_timeout']) for thread in candidate_output_threads]", @@ -2194,10 +2313,13 @@ def _staged_bwrap( " if descriptor_protocol:", " if sum(validate_tree(path) for path in writable_paths) > limits['writable']: raise RuntimeError('final writable quota exceeded')", " payload={'kind':'result','nonce':observation_nonce,'aggregate_digest':playwright['expected_digest'],'candidate':{'version':1,'state':'terminal','returncode':result,'timed_out':timed_out},'stdout':base64.b64encode(b''.join(candidate_stdout)).decode('ascii'),'stderr':base64.b64encode(b''.join(candidate_stderr)).decode('ascii'),'observation':base64.b64encode(b''.join(observation_chunks)).decode('ascii'),'observation_sha256':hashlib.sha256(b''.join(observation_chunks)).hexdigest(),'observation_size':observation_size}", - " protocol_send(payload,limits['protocol'])", + " protocol_send(payload,limits['protocol'],time.monotonic()+limits['trusted_timeout'])", " acknowledgement=protocol_receive(4096,time.monotonic()+limits['trusted_timeout'])", " expected_ack={'kind':'ack','nonce':observation_nonce,'digest':hashlib.sha256(json.dumps(payload,sort_keys=True,separators=(',',':')).encode('utf-8')).hexdigest()}", " if acknowledgement!=expected_ack: raise RuntimeError('protected descriptor acknowledgement is invalid')", + " protocol_expect_eof(time.monotonic()+limits['trusted_timeout'])", + " parent_watch_done.set(); parent_watch.join(timeout=limits['trusted_timeout'])", + " if parent_watch.is_alive(): raise RuntimeError('protected descriptor parent watcher did not stop')", " elif anonymous_observation:", " observation_path=authority/'observation.bin'", " observation_fd=os.open(observation_path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,'O_NOFOLLOW',0),0o444)", @@ -2783,6 +2905,10 @@ def _sandbox_command( result_fifo: Path | None = None, result_write_fd: int | None = None, result_fd: int = 198, + candidate_environment: str = "[]", + candidate_environment_values: dict[str, str] | None = None, + candidate_temp_directory: Path | None = None, + supervision_token: str | None = None, observation_nonce: str | None = None, unit_name: str | None = None, control_directory: Path | None = None, @@ -3100,7 +3226,15 @@ def bind( "--regid", str(candidate_gid), "--clear-groups", "--"] ) - sandboxed = _limited_command(command, limits) + if candidate_environment_values is not None: + if candidate_temp_directory is None or supervision_token is None: + raise RuntimeError("protected candidate environment is invalid") + candidate_environment = _candidate_environment_record( + candidate_environment_values, + temp_directory=candidate_temp_directory, + supervision_token=supervision_token, + ) + sandboxed = _limited_command(command, limits, candidate_environment) if result_fifo is not None: sandboxed = _framework_observation_command( sandboxed, result_fd, _FRAMEWORK_OBSERVATION_PATH @@ -3185,7 +3319,7 @@ def _run_playwright_descriptor_supervised( """Run aggregate Playwright evidence over the helper's inherited descriptors only.""" # pylint: disable=too-many-locals,too-many-arguments,too-many-branches # pylint: disable=too-many-statements,consider-using-with - del env, temp_directory + supervision_token = uuid.uuid4().hex nonce = os.urandom(32).hex() diagnostics = bytearray() helper_stderr = bytearray() @@ -3227,6 +3361,11 @@ def read_stderr(stream) -> None: writable_bindings=writable_bindings, result_write_fd=result_write_fd, result_fd=result_fd, observation_nonce=nonce, candidate_timeout=timeout, unit_name=_scope_unit_name(), control_directory=control, + candidate_environment_values=env, + candidate_temp_directory=( + temp_directory or writable_roots[0].resolve() + ), + supervision_token=supervision_token, ) _prepare_staging(plan) _revalidate_trusted_tools(plan.tools) @@ -3495,23 +3634,17 @@ def record_events() -> None: result_fd=result_fd, observation_nonce=observation_nonce, candidate_timeout=timeout, unit_name=unit_name, control_directory=control, + candidate_environment_values=env, + candidate_temp_directory=( + temp_directory or writable_roots[0].resolve() + ), + supervision_token=token, ) _prepare_staging(plan) except (OSError, RuntimeError) as exc: return _sandbox_error( command, f"protected supervisor phase=construction: {exc}" ) - sandbox_environment = env | { - "PATH": _TRUSTED_ROOT_PATH, - "PYTHONDONTWRITEBYTECODE": "1", - "PDD_SUPERVISION_TOKEN": token, - "TMPDIR": str(temp_directory or writable_roots[0].resolve()), - "TEMP": str(temp_directory or writable_roots[0].resolve()), - "TMP": str(temp_directory or writable_roots[0].resolve()), - } - library_path = _sandbox_library_path(env) - if library_path: - sandbox_environment["LD_LIBRARY_PATH"] = library_path try: _revalidate_trusted_tools(plan.tools) process = subprocess.Popen( diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 0395498eb5..cfff34bcfe 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -79,6 +79,76 @@ def test_privileged_helper_environment_excludes_hostile_python_startup() -> None } +def test_candidate_environment_record_is_canonical_and_complete(tmp_path: Path) -> None: + """The handoff preserves required values and injects trusted runtime values.""" + environment = { + "HOME": str(tmp_path / "home"), + "NODE_ENV": "test", + "NODE_PATH": "/opt/node_modules", + "PLAYWRIGHT_BROWSERS_PATH": "/opt/browsers", + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTHONNOUSERSITE": "1", + "XDG_CACHE_HOME": str(tmp_path / "cache"), + } + encoded = supervisor._candidate_environment_record( + environment, temp_directory=Path("/tmp"), supervision_token="a" * 32 + ) + + parsed = supervisor._parse_candidate_environment_record(encoded) + + assert parsed == environment | { + "LD_LIBRARY_PATH": supervisor._sandbox_library_path(environment), + "PATH": supervisor._TRUSTED_ROOT_PATH, + "PDD_SUPERVISION_TOKEN": "a" * 32, + "PYTHONDONTWRITEBYTECODE": "1", + "TEMP": "/tmp", "TMP": "/tmp", "TMPDIR": "/tmp", + } + assert list(parsed) == sorted(parsed) + + +@pytest.mark.parametrize( + "payload", + [ + {}, + [["A"]], + [["A", "1"], ["A", "2"]], + [["1BAD", "value"]], + [["GITHUB_TOKEN", "secret"]], + [["A", "x" * (32 * 1024 + 1)]], + [[f"K{index}", "v"] for index in range(129)], + ], + ids=("shape", "entry-shape", "duplicate", "key", "credential", "value-size", "count"), +) +def test_candidate_environment_parser_rejects_invalid_records(payload) -> None: + """Malformed, duplicate, credential-bearing, and oversized records fail closed.""" + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")) + with pytest.raises(RuntimeError, match="candidate environment"): + supervisor._parse_candidate_environment_record(encoded) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="requires Linux rlimits") +def test_limited_wrapper_installs_exact_candidate_environment(tmp_path: Path) -> None: + """The post-drop wrapper clears ambient values before candidate execution.""" + record = supervisor._candidate_environment_record( + {"HOME": str(tmp_path), "NODE_ENV": "test"}, + temp_directory=tmp_path, + supervision_token="b" * 32, + ) + command = supervisor._limited_command( + [sys.executable, "-c", "import json,os;print(json.dumps(dict(os.environ),sort_keys=True))"], + replace(SupervisorLimits(), max_virtual_memory_bytes=512 * 1024 * 1024), + record, + ) + + completed = subprocess.run( + command, capture_output=True, text=True, check=False, timeout=10, + env={"HOST_SECRET": "must-not-survive"}, + ) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == supervisor._parse_candidate_environment_record(record) + + @pytest.mark.parametrize("swap", ["content", "mode", "rename", "symlink"]) def test_privileged_helper_rejects_executable_swaps( tmp_path: Path, swap: str, @@ -3269,6 +3339,57 @@ def test_binary_output_has_aggregate_limit_and_deterministic_decode(tmp_path: Pa assert "\ufffd" in result.stdout +@pytest.mark.real +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not shutil.which("bwrap"), + reason="requires hosted Linux privileged supervision", +) +@pytest.mark.parametrize("adapter", ("pytest", "vitest", "playwright")) +def test_real_linux_adapter_environment_handoff( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, adapter: str, +) -> None: + """Every adapter receives only its exact post-drop sanitized environment.""" + token = "d" * 32 + monkeypatch.setattr( + supervisor.uuid, "uuid4", lambda: SimpleNamespace(hex=token) + ) + home = tmp_path / "home" + environments = { + "pytest": { + "HOME": str(home), "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTHONNOUSERSITE": "1", + }, + "vitest": { + "HOME": str(home), "NODE_ENV": "test", + "XDG_CACHE_HOME": str(home / "cache"), + "XDG_CONFIG_HOME": str(home / "config"), + }, + "playwright": { + "HOME": str(home), "NODE_ENV": "test", + "NODE_PATH": "/opt/pdd/node_modules", + "PLAYWRIGHT_BROWSERS_PATH": "/opt/pdd/browsers", + "XDG_CACHE_HOME": str(home / "cache"), + "XDG_CONFIG_HOME": str(home / "config"), + }, + } + environment = environments[adapter] + expected = supervisor._parse_candidate_environment_record( + supervisor._candidate_environment_record( + environment, temp_directory=tmp_path, supervision_token=token + ) + ) + + result, surviving = run_supervised( + [sys.executable, "-c", "import json,os;print(json.dumps(dict(os.environ),sort_keys=True))"], + cwd=tmp_path, timeout=10, env=environment, writable_roots=(tmp_path,), + temp_directory=tmp_path, + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == expected + assert surviving is False + + @pytest.mark.real @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap"), @@ -3318,7 +3439,22 @@ def test_real_linux_authenticated_termination_and_cleanup(tmp_path: Path) -> Non not sys.platform.startswith("linux") or not shutil.which("bwrap"), reason="requires hosted Linux privileged descriptor supervision", ) -def test_real_linux_playwright_descriptor_exact_chain(tmp_path: Path) -> None: +@pytest.mark.parametrize("case", ( + "normal-hierarchy-environment", + "parent-exit-before-start", + "parent-exit-during-execution", + "parent-exit-after-result", + "stalled-result-reader", + "missing-ack", + "duplicate-ack", + "trailing-frame", + "trailing-raw", + "reordered-extra", + "stalled-observation-reader", +)) +def test_real_linux_playwright_descriptor_exact_chain( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str, +) -> None: """Exercise real descriptor parent-loss, frame, relay, and FD-denial paths.""" from pdd.sync_core.runner import ( # pylint: disable=import-outside-toplevel PlaywrightToolchainRoles, @@ -3357,17 +3493,40 @@ def test_real_linux_playwright_descriptor_exact_chain(tmp_path: Path) -> None: proofs, reporter, roles, launcher, destination, roles.native_bindings ) - inventory_program = """import json,os,subprocess,sys + inventory_program = """import fcntl,json,os def inventory(): - return sorted(int(value) for value in os.listdir('/proc/self/fd') if value.isdecimal()) -child = 'import json,os;print(json.dumps(sorted(int(value) for value in os.listdir(\\"/proc/self/fd\\") if value.isdecimal())))' -payload = {name: json.loads(subprocess.check_output([sys.executable, '-c', child])) - for name in ('worker', 'browser', 'descendant')} -payload['coordinator'] = inventory() -os.write(198, json.dumps(payload, sort_keys=True).encode('ascii')) + opened=[] + for fd in range(256): + try: fcntl.fcntl(fd,fcntl.F_GETFD) + except OSError: continue + opened.append(fd) + return opened +def emit(role): + denied=all(fd not in inventory() for fd in (3,4,5,6,7,8)) + payload={'role':role,'fds':inventory(),'denied':denied} + if role=='coordinator': payload['environment']=dict(os.environ) + os.write(198,(json.dumps(payload,sort_keys=True)+'\\n').encode('ascii')) +def descend(roles): + emit(roles[0]) + if len(roles)==1: return + pid=os.fork() + if pid==0: + descend(roles[1:]); os._exit(0) + waited,status=os.waitpid(pid,0) + if waited!=pid or status!=0: raise SystemExit(125) +descend(('coordinator','worker','browser','descendant')) """ sleeping_program = "import time; time.sleep(30)" - overflow_program = "import os; os.write(198, b'x' * (17 * 1024 * 1024))" + candidate_environment = { + "HOME": "/tmp/pdd-home", + "NODE_ENV": "test", + "NODE_PATH": str(destination), + "PLAYWRIGHT_BROWSERS_PATH": str(browser), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTHONNOUSERSITE": "1", + "XDG_CACHE_HOME": "/tmp/pdd-home/cache", + "XDG_CONFIG_HOME": "/tmp/pdd-home/config", + } def launch(program: str): control = Path(tempfile.mkdtemp(prefix="pdd-descriptor-", dir=tmp_path)) @@ -3384,6 +3543,9 @@ def launch(program: str): result_write_fd=write_fd, result_fd=198, observation_nonce="a" * 64, candidate_timeout=10, control_directory=control, + candidate_environment_values=candidate_environment, + candidate_temp_directory=Path("/tmp"), + supervision_token="c" * 32, ) finally: os.close(read_fd) @@ -3402,99 +3564,222 @@ def launch(program: str): assert ready == {"kind": "ready", "nonce": "a" * 64} return control, plan, process - def close_case(control, plan, process) -> None: - try: - if process.poll() is None: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - try: - supervisor._stop_scope(plan.unit_name, plan.tools) - finally: - supervisor._cleanup_staging(plan) - process.wait(timeout=10) - assert plan.unit_name not in subprocess.run( - ["sudo", "-n", "systemctl", "list-units", "--all", "--no-legend"], - capture_output=True, text=True, check=True, - ).stdout - assert not any(path in supervisor._mounted_paths() for path in plan.staging_targets) - shutil.rmtree(control, ignore_errors=True) - - control, plan, process = launch(inventory_program) - try: + def send(frame, process) -> None: supervisor._write_descriptor_frame_fd( - process.stdin.fileno(), {"kind": "start", "nonce": "a" * 64}, + process.stdin.fileno(), frame, supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, time.monotonic() + 5, ) - result = supervisor._read_descriptor_frame_fd( + + def start(process) -> None: + send({"kind": "start", "nonce": "a" * 64}, process) + + def read_result(process): + return supervisor._read_descriptor_frame_fd( process.stdout.fileno(), supervisor._DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES, time.monotonic() + 15, ) - parsed = supervisor._descriptor_result(result, "a" * 64, aggregate.digest, 1024) - inventory = json.loads(parsed.observation) - assert all(set(values) <= {0, 1, 2, 198} for values in inventory.values()) - supervisor._write_descriptor_frame_fd( - process.stdin.fileno(), { - "kind": "ack", "nonce": "a" * 64, - "digest": hashlib.sha256( - supervisor._canonical_json(result).encode("utf-8") - ).hexdigest(), - }, supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, - time.monotonic() + 5, + + def ack(result, process) -> dict[str, str]: + frame = { + "kind": "ack", "nonce": "a" * 64, + "digest": hashlib.sha256( + supervisor._canonical_json(result).encode("utf-8") + ).hexdigest(), + } + send(frame, process) + return frame + + def leak_state(unit: str, targets: tuple[Path, ...], pid: int) -> list[str]: + leaks = [] + units = subprocess.run( + ["sudo", "-n", "systemctl", "list-units", unit, "--all", "--no-legend"], + capture_output=True, text=True, check=True, + ).stdout.strip() + if units: + leaks.append("scope") + groups = subprocess.run( + ["sudo", "-n", "find", "/sys/fs/cgroup", "-type", "d", "-name", unit[:-6]], + capture_output=True, text=True, check=True, + ).stdout.strip() + if groups: + leaks.append("cgroup") + if any(path in supervisor._mounted_paths() for path in targets): + leaks.append("mount") + try: + os.kill(pid, 0) + except ProcessLookupError: + pass + else: + leaks.append("process") + if supervisor._supervised_descendants("c" * 32): + leaks.append("candidate-process") + return leaks + + def await_automatic_cleanup(unit: str, targets: tuple[Path, ...], pid: int) -> None: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if not leak_state(unit, targets, pid): + return + time.sleep(.05) + assert not leak_state(unit, targets, pid) + + def emergency_cleanup(unit: str, targets: tuple[Path, ...], pid: int) -> None: + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + pass + subprocess.run( + ["sudo", "-n", "systemctl", "stop", unit], check=False, + capture_output=True, text=True, ) - process.stdin.close() - assert process.wait(timeout=10) == 0 - supervisor._expect_descriptor_eof(process.stdout.fileno(), time.monotonic() + 5) - finally: - close_case(control, plan, process) + for target in reversed(targets): + subprocess.run( + ["sudo", "-n", "umount", str(target)], check=False, + capture_output=True, text=True, + ) - for program, frames in ( - (sleeping_program, ()), - (inventory_program, ( - {"kind": "start", "nonce": "a" * 64}, - {"kind": "ack", "nonce": "a" * 64, "digest": "0" * 64}, - )), - (overflow_program, ({"kind": "start", "nonce": "a" * 64},)), - ): - control, plan, process = launch(program) + def assert_case_cleanup(control, plan, process, should_succeed: bool) -> None: try: - for frame in frames: - supervisor._write_descriptor_frame_fd( - process.stdin.fileno(), frame, - supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, - time.monotonic() + 5, - ) - process.stdin.close() - assert process.wait(timeout=15) != 0 + returncode = process.wait(timeout=20) + assert (returncode == 0) is should_succeed + await_automatic_cleanup(plan.unit_name, plan.staging_targets, process.pid) + except BaseException: + emergency_cleanup(plan.unit_name, plan.staging_targets, process.pid) + raise finally: - close_case(control, plan, process) + shutil.rmtree(control, ignore_errors=True) - control, plan, process = launch(sleeping_program) - try: - supervisor._write_descriptor_frame_fd( - process.stdin.fileno(), {"kind": "start", "nonce": "a" * 64}, - supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, time.monotonic() + 5, - ) - time.sleep(.1) - process.stdin.close() - assert process.wait(timeout=15) != 0 - finally: - close_case(control, plan, process) + if case.startswith("parent-exit-"): + report = tmp_path / f"{case}.json" + coordinator = os.fork() + if coordinator == 0: + program = ( + sleeping_program + if case == "parent-exit-during-execution" + else inventory_program + ) + control, plan, process = launch(program) + report.write_text(json.dumps({ + "unit": plan.unit_name, + "targets": [str(path) for path in plan.staging_targets], + "pid": process.pid, + "control": str(control), + }), encoding="utf-8") + if case != "parent-exit-before-start": + start(process) + if case == "parent-exit-during-execution": + time.sleep(.2) + elif case == "parent-exit-after-result": + read_result(process) + os._exit(0) + waited, status = os.waitpid(coordinator, 0) + assert waited == coordinator and status == 0 + details = json.loads(report.read_text(encoding="utf-8")) + unit = details["unit"] + targets = tuple(Path(path) for path in details["targets"]) + helper_pid = details["pid"] + try: + await_automatic_cleanup(unit, targets, helper_pid) + except BaseException: + emergency_cleanup(unit, targets, helper_pid) + raise + finally: + shutil.rmtree(details["control"], ignore_errors=True) + return - control, plan, process = launch(inventory_program) - try: - supervisor._write_descriptor_frame_fd( - process.stdin.fileno(), {"kind": "start", "nonce": "a" * 64}, - supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, time.monotonic() + 5, + if case == "stalled-observation-reader": + unit = "pdd-validator-" + "e" * 32 + ".scope" + token = "c" * 32 + monkeypatch.setattr(supervisor, "_scope_unit_name", lambda: unit) + monkeypatch.setattr( + supervisor.uuid, "uuid4", lambda: SimpleNamespace(hex=token) ) - supervisor._read_descriptor_frame_fd( - process.stdout.fileno(), supervisor._DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES, - time.monotonic() + 15, + mounts_before = supervisor._mounted_paths() + read_fd, write_fd = os.pipe() + program = ( + "import os;data=b'x'*262144;offset=0\n" + "while offset assert "os.dup2(candidate_stdout_write,1)" in source assert "os.dup2(candidate_stderr_write,2)" in source assert "POLLHUP|select.POLLERR" in source + assert "os.set_blocking(protocol_out_fd,False)" in source + assert "protected descriptor write timed out" in source + assert "protocol_expect_eof(time.monotonic()+limits['trusted_timeout'])" in source + assert source.index("protocol_send(payload,limits['protocol']") < source.index( + "parent_watch_done.set()" + ) assert "protocol_receive(4096,time.monotonic()+limits['trusted_timeout'])" in source diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py index 479fa3f63c..86a58a9a05 100644 --- a/tests/test_unit_tests_workflow.py +++ b/tests/test_unit_tests_workflow.py @@ -16,6 +16,34 @@ REQUIRED_TIMEOUT_MINUTES = math.ceil( FULL_JOB_SECONDS * (1 + HEADROOM_FRACTION) / 60 ) +HOSTED_SUPERVISOR_NODE = "tests/test_sync_core_supervisor.py::" +REQUIRED_HOSTED_NODES = ( + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_adapter_environment_handoff[pytest]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_adapter_environment_handoff[vitest]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_adapter_environment_handoff[playwright]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[normal-hierarchy-environment]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[parent-exit-before-start]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[parent-exit-during-execution]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[parent-exit-after-result]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[stalled-result-reader]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[missing-ack]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[duplicate-ack]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[trailing-frame]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[trailing-raw]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[reordered-extra]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[stalled-observation-reader]", +) def test_unit_tests_timeout_covers_documented_full_job_budget() -> None: @@ -35,3 +63,23 @@ def test_unit_tests_timeout_covers_documented_full_job_budget() -> None: assert "~30 minutes" in workflow_text assert "46m37s" in workflow_text assert "50% headroom" in workflow_text + + +def test_unit_tests_requires_complete_privileged_descriptor_matrix() -> None: + """Every real-chain case and prerequisite must be explicit in the hosted lane.""" + workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") + + for node in REQUIRED_HOSTED_NODES: + assert workflow_text.count(node) == 1, node + assert ( + "tests/test_sync_core_supervisor.py::" + "test_real_linux_playwright_descriptor_exact_chain \\\n" + ) not in workflow_text + for prerequisite in ( + "sudo apt-get install --yes bubblewrap", + "command -v bwrap", + "command -v systemd-run", + "command -v unshare", + "sudo -n true", + ): + assert prerequisite in workflow_text From 2719540d30bce6c9a6cb5c3469e1dad945c0b5b0 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 06:08:26 -0700 Subject: [PATCH 176/233] test(sync): verify hosted descriptor cleanup --- tests/test_sync_core_supervisor.py | 225 +++++++++++++++++++++++------ tests/test_unit_tests_workflow.py | 162 ++++++++++++++++++--- 2 files changed, 321 insertions(+), 66 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index cfff34bcfe..74febaef57 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -3451,6 +3451,18 @@ def test_real_linux_authenticated_termination_and_cleanup(tmp_path: Path) -> Non "trailing-raw", "reordered-extra", "stalled-observation-reader", +), ids=( + "normal-hierarchy-environment", + "parent-exit-before-start", + "parent-exit-during-execution", + "parent-exit-after-result", + "stalled-result-reader", + "missing-ack", + "duplicate-ack", + "trailing-frame", + "trailing-raw", + "reordered-extra", + "stalled-observation-reader", )) def test_real_linux_playwright_descriptor_exact_chain( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str, @@ -3516,7 +3528,19 @@ def descend(roles): if waited!=pid or status!=0: raise SystemExit(125) descend(('coordinator','worker','browser','descendant')) """ - sleeping_program = "import time; time.sleep(30)" + live_hierarchy_program = """import os,time +def descend(roles): + if len(roles) > 1: + pid=os.fork() + if pid == 0: + descend(roles[1:]) + os._exit(0) + time.sleep(30) + if len(roles) > 1: + waited,status=os.waitpid(pid,0) + if waited != pid or status != 0: raise SystemExit(125) +descend(('coordinator','worker','browser','descendant')) +""" candidate_environment = { "HOME": "/tmp/pdd-home", "NODE_ENV": "test", @@ -3589,62 +3613,173 @@ def ack(result, process) -> dict[str, str]: send(frame, process) return frame - def leak_state(unit: str, targets: tuple[Path, ...], pid: int) -> list[str]: - leaks = [] - units = subprocess.run( - ["sudo", "-n", "systemctl", "list-units", unit, "--all", "--no-legend"], - capture_output=True, text=True, check=True, - ).stdout.strip() - if units: - leaks.append("scope") - groups = subprocess.run( - ["sudo", "-n", "find", "/sys/fs/cgroup", "-type", "d", "-name", unit[:-6]], + def scope_control_group(unit: str) -> Path: + """Read the systemd-reported full scope path while it is still live.""" + completed = subprocess.run( + ["sudo", "-n", "systemctl", "show", unit, + "--property=ControlGroup", "--value"], capture_output=True, text=True, check=True, - ).stdout.strip() - if groups: - leaks.append("cgroup") - if any(path in supervisor._mounted_paths() for path in targets): - leaks.append("mount") + ) + relative = completed.stdout.strip() + assert relative.startswith("/") and relative.endswith("/" + unit), relative + cgroup = Path("/sys/fs/cgroup") / relative.lstrip("/") + assert cgroup.is_dir(), cgroup + return cgroup + + def cgroup_processes(cgroup: Path) -> set[int]: + """Use cgroup-v2 membership as the trusted host-side process inventory.""" + processes = set() + for members in cgroup.rglob("cgroup.procs"): + processes.update(int(value) for value in members.read_text( + encoding="ascii" + ).split()) + return processes + + def process_mounts(pid: int) -> set[Path]: + """Read mountinfo directly; descriptor-table scans are intentionally excluded.""" + mountinfo = Path(f"/proc/{pid}/mountinfo") + try: + lines = mountinfo.read_text(encoding="utf-8").splitlines() + except OSError: + return set() + return { + Path(fields[4].replace("\\040", " ").replace("\\134", "\\")) + for line in lines if len(fields := line.split()) > 4 + } + + def namespace_holders(namespace: str, inode: int) -> set[int]: + """Find live holders by namespace links, without transient /proc fd reads.""" + holders = set() + for entry in Path("/proc").iterdir(): + if not entry.name.isdecimal(): + continue + try: + namespace_path = entry / "ns" / "mnt" + if ( + os.readlink(namespace_path) == namespace + and namespace_path.stat().st_ino == inode + ): + holders.add(int(entry.name)) + except OSError: + continue + return holders + + def mounted_holders(targets: tuple[Path, ...]) -> set[tuple[int, Path]]: + """Find every process namespace that still exposes a relevant mountpoint.""" + holders = set() + for entry in Path("/proc").iterdir(): + if not entry.name.isdecimal(): + continue + for target in set(targets) & process_mounts(int(entry.name)): + holders.add((int(entry.name), target)) + return holders + + def capture_live_state(plan) -> dict[str, object]: + """Capture exact systemd, cgroup, namespace, and PID evidence before loss.""" + cgroup = scope_control_group(plan.unit_name) + targets = tuple(plan.staging_targets) + deadline = time.monotonic() + 10 + holder = None + while time.monotonic() < deadline: + for pid in cgroup_processes(cgroup): + if set(targets) & process_mounts(pid): + holder = pid + break + if holder is not None: + break + time.sleep(.05) + assert holder is not None, "private helper mount namespace was not observable" + namespace_path = Path(f"/proc/{holder}/ns/mnt") + namespace = os.readlink(namespace_path) + namespace_inode = namespace_path.stat().st_ino + recorded = cgroup_processes(cgroup) + assert recorded, "live scope had no trusted process membership" + assert holder in recorded, "private mount namespace holder left scope early" + return { + "unit": plan.unit_name, + "cgroup": str(cgroup), + "targets": [str(path) for path in targets], + "helper_pid": holder, + "mount_namespace": namespace, + "mount_namespace_inode": namespace_inode, + "recorded_pids": sorted(recorded), + } + + def await_live_hierarchy(cgroup: Path) -> set[int]: + """Observe all four candidate roles from the exact trusted candidate leaf.""" + candidate = cgroup / "candidate" + deadline = time.monotonic() + 10 + previous: set[int] = set() + while time.monotonic() < deadline: + current = cgroup_processes(candidate) if candidate.is_dir() else set() + if len(current) >= 4 and current == previous: + return current + previous = current + time.sleep(.05) + raise AssertionError( + "coordinator/worker/browser/descendant hierarchy was not live: " + f"{sorted(previous)}" + ) + + def pid_absent(pid: int) -> bool: + """Require the recorded PID to be both unprobeable and absent from procfs.""" try: os.kill(pid, 0) except ProcessLookupError: - pass - else: - leaks.append("process") - if supervisor._supervised_descendants("c" * 32): - leaks.append("candidate-process") + return not Path(f"/proc/{pid}").exists() + return False + + def leak_state(details: dict[str, object]) -> list[str]: + leaks = [] + cgroup = Path(str(details["cgroup"])) + targets = tuple(Path(path) for path in details["targets"]) + if cgroup.exists(): + leaks.append(f"cgroup={cgroup}") + live_pids = [pid for pid in details["recorded_pids"] if not pid_absent(pid)] + if live_pids: + leaks.append(f"recorded-pids={live_pids}") + holders = namespace_holders( + str(details["mount_namespace"]), int(details["mount_namespace_inode"]) + ) + if holders: + leaks.append(f"mount-namespace={sorted(holders)}") + mounts = mounted_holders(targets) + if mounts: + leaks.append(f"mounts={sorted((pid, str(path)) for pid, path in mounts)}") return leaks - def await_automatic_cleanup(unit: str, targets: tuple[Path, ...], pid: int) -> None: + def await_automatic_cleanup(details: dict[str, object]) -> None: deadline = time.monotonic() + 15 while time.monotonic() < deadline: - if not leak_state(unit, targets, pid): + if not leak_state(details): return time.sleep(.05) - assert not leak_state(unit, targets, pid) + assert not leak_state(details), ( + "automatic cleanup deadline expired: " + "; ".join(leak_state(details)) + ) - def emergency_cleanup(unit: str, targets: tuple[Path, ...], pid: int) -> None: + def emergency_cleanup(details: dict[str, object], pid: int) -> None: try: os.killpg(pid, signal.SIGKILL) except ProcessLookupError: pass subprocess.run( - ["sudo", "-n", "systemctl", "stop", unit], check=False, + ["sudo", "-n", "systemctl", "stop", str(details["unit"])], check=False, capture_output=True, text=True, ) - for target in reversed(targets): + for target in reversed(tuple(Path(path) for path in details["targets"])): subprocess.run( ["sudo", "-n", "umount", str(target)], check=False, capture_output=True, text=True, ) - def assert_case_cleanup(control, plan, process, should_succeed: bool) -> None: + def assert_case_cleanup(control, details, process, should_succeed: bool) -> None: try: returncode = process.wait(timeout=20) assert (returncode == 0) is should_succeed - await_automatic_cleanup(plan.unit_name, plan.staging_targets, process.pid) + await_automatic_cleanup(details) except BaseException: - emergency_cleanup(plan.unit_name, plan.staging_targets, process.pid) + emergency_cleanup(details, process.pid) raise finally: shutil.rmtree(control, ignore_errors=True) @@ -3654,34 +3789,32 @@ def assert_case_cleanup(control, plan, process, should_succeed: bool) -> None: coordinator = os.fork() if coordinator == 0: program = ( - sleeping_program + live_hierarchy_program if case == "parent-exit-during-execution" else inventory_program ) control, plan, process = launch(program) - report.write_text(json.dumps({ - "unit": plan.unit_name, - "targets": [str(path) for path in plan.staging_targets], - "pid": process.pid, - "control": str(control), - }), encoding="utf-8") + details = capture_live_state(plan) if case != "parent-exit-before-start": start(process) if case == "parent-exit-during-execution": - time.sleep(.2) + hierarchy = await_live_hierarchy(Path(str(details["cgroup"]))) + recorded = cgroup_processes(Path(str(details["cgroup"]))) + assert hierarchy <= recorded + details["recorded_pids"] = sorted(recorded) elif case == "parent-exit-after-result": read_result(process) + details["pid"] = process.pid + details["control"] = str(control) + report.write_text(json.dumps(details), encoding="utf-8") os._exit(0) waited, status = os.waitpid(coordinator, 0) assert waited == coordinator and status == 0 details = json.loads(report.read_text(encoding="utf-8")) - unit = details["unit"] - targets = tuple(Path(path) for path in details["targets"]) - helper_pid = details["pid"] try: - await_automatic_cleanup(unit, targets, helper_pid) + await_automatic_cleanup(details) except BaseException: - emergency_cleanup(unit, targets, helper_pid) + emergency_cleanup(details, details["pid"]) raise finally: shutil.rmtree(details["control"], ignore_errors=True) @@ -3720,13 +3853,13 @@ def assert_case_cleanup(control, plan, process, should_succeed: bool) -> None: assert "timed out" in result.stderr assert surviving is False assert supervisor._mounted_paths() == mounts_before - await_automatic_cleanup(unit, (), 2**30) return program = inventory_program if case == "stalled-result-reader": program = "import os;os.write(1,b'x'*1048576);os.write(198,b'{}')" control, plan, process = launch(program) + details = capture_live_state(plan) should_succeed = case == "normal-hierarchy-environment" try: if case == "reordered-extra": @@ -3775,10 +3908,10 @@ def assert_case_cleanup(control, plan, process, should_succeed: bool) -> None: ack(result, process) os.write(process.stdin.fileno(), b"x") process.stdin.close() - assert_case_cleanup(control, plan, process, should_succeed) + assert_case_cleanup(control, details, process, should_succeed) except BaseException: if process.poll() is None: - emergency_cleanup(plan.unit_name, plan.staging_targets, process.pid) + emergency_cleanup(details, process.pid) raise diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py index 86a58a9a05..7b6d909cec 100644 --- a/tests/test_unit_tests_workflow.py +++ b/tests/test_unit_tests_workflow.py @@ -3,8 +3,12 @@ from __future__ import annotations import math +import re +import shlex +from collections.abc import Callable from pathlib import Path +import pytest import yaml @@ -16,8 +20,14 @@ REQUIRED_TIMEOUT_MINUTES = math.ceil( FULL_JOB_SECONDS * (1 + HEADROOM_FRACTION) / 60 ) +LINUX_JOB_ID = "unit-tests" +PROVISION_STEP_NAME = "Provision and verify protected Linux sandbox" +HOSTED_STEP_NAME = "Run real protected Playwright and authenticated supervisor protocols" HOSTED_SUPERVISOR_NODE = "tests/test_sync_core_supervisor.py::" -REQUIRED_HOSTED_NODES = ( +REQUIRED_HOSTED_NODES = frozenset(( + "tests/test_sync_core_runner_playwright.py::" + "test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_authenticated_termination_and_cleanup", f"{HOSTED_SUPERVISOR_NODE}test_real_linux_adapter_environment_handoff[pytest]", f"{HOSTED_SUPERVISOR_NODE}test_real_linux_adapter_environment_handoff[vitest]", f"{HOSTED_SUPERVISOR_NODE}test_real_linux_adapter_environment_handoff[playwright]", @@ -43,14 +53,92 @@ "[reordered-extra]", f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" "[stalled-observation-reader]", -) + "tests/test_sync_core_supervisor.py::" + "test_simultaneous_high_volume_stdio_has_one_aggregate_bound", +)) + + +def _workflow() -> dict: + """Load the workflow with YAML semantics, never comment-sensitive text matching.""" + loaded = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) + assert isinstance(loaded, dict) + return loaded + + +def _named_step(job: dict, name: str) -> dict: + """Return exactly one active, stable-named workflow step.""" + steps = job.get("steps") + assert isinstance(steps, list) + matches = [step for step in steps if isinstance(step, dict) and step.get("name") == name] + assert len(matches) == 1, name + return matches[0] + + +def _shell_commands(command: object) -> tuple[tuple[str, ...], ...]: + """Parse active POSIX shell commands, excluding comments and quoted text.""" + assert isinstance(command, str) + commands = [] + continuation = "" + heredoc_end = None + for line in command.splitlines(): + if heredoc_end is not None: + if line == heredoc_end: + heredoc_end = None + continue + current = continuation + line + if current.rstrip().endswith("\\"): + continuation = current.rstrip()[:-1] + " " + continue + continuation = "" + tokens = tuple(shlex.split(current, comments=True, posix=True)) + if not tokens: + continue + commands.append(tokens) + marker = re.search(r"<<-?\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)", current) + if marker: + heredoc_end = marker.group(1) + assert heredoc_end is None + assert not continuation + return tuple(commands) + + +def _assert_hosted_linux_contract(workflow: dict) -> None: + """Check the exact active hosted Linux command and prerequisites.""" + jobs = workflow.get("jobs") + assert isinstance(jobs, dict) + job = jobs.get(LINUX_JOB_ID) + assert isinstance(job, dict) + assert job.get("runs-on") == "ubuntu-latest" + + provision = _named_step(job, PROVISION_STEP_NAME) + provision_commands = _shell_commands(provision.get("run")) + for command in ( + ("sudo", "apt-get", "install", "--yes", "bubblewrap"), + ("command", "-v", "bwrap"), + ("command", "-v", "systemd-run"), + ("command", "-v", "unshare"), + ("sudo", "-n", "true"), + ): + assert command in provision_commands, command + + hosted = _named_step(job, HOSTED_STEP_NAME) + hosted_commands = _shell_commands(hosted.get("run")) + pytest_commands = [ + command for command in hosted_commands if command and command[0] == "pytest" + ] + assert len(pytest_commands) == 1 + active_nodes = tuple( + token for token in pytest_commands[0] + if token.startswith("tests/") and "::" in token + ) + assert len(active_nodes) == len(set(active_nodes)), active_nodes + assert frozenset(active_nodes) == REQUIRED_HOSTED_NODES def test_unit_tests_timeout_covers_documented_full_job_budget() -> None: """The broad-suite margin must account for required prior job work too.""" workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") - workflow = yaml.safe_load(workflow_text) - timeout_minutes = workflow["jobs"]["unit-tests"]["timeout-minutes"] + timeout_minutes = _workflow()["jobs"][LINUX_JOB_ID]["timeout-minutes"] assert isinstance(timeout_minutes, int) assert timeout_minutes > 0 @@ -66,20 +154,54 @@ def test_unit_tests_timeout_covers_documented_full_job_budget() -> None: def test_unit_tests_requires_complete_privileged_descriptor_matrix() -> None: - """Every real-chain case and prerequisite must be explicit in the hosted lane.""" - workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") + """The active hosted Linux lane has one exact frozen pytest node set.""" + _assert_hosted_linux_contract(_workflow()) - for node in REQUIRED_HOSTED_NODES: - assert workflow_text.count(node) == 1, node - assert ( - "tests/test_sync_core_supervisor.py::" - "test_real_linux_playwright_descriptor_exact_chain \\\n" - ) not in workflow_text - for prerequisite in ( - "sudo apt-get install --yes bubblewrap", - "command -v bwrap", - "command -v systemd-run", - "command -v unshare", - "sudo -n true", - ): - assert prerequisite in workflow_text + +def _append_hosted_node(command: str, node: str) -> str: + """Append one active node before the pytest timeout argument.""" + return command.replace("--timeout=90", f"{node} \\\n --timeout=90") + + +@pytest.mark.parametrize( + "mutate", + ( + lambda command: command.replace( + "tests/test_sync_core_supervisor.py::" + "test_real_linux_playwright_descriptor_exact_chain[parent-exit-before-start]", + "# tests/test_sync_core_supervisor.py::" + "test_real_linux_playwright_descriptor_exact_chain[parent-exit-before-start]", + ), + lambda command: _append_hosted_node( + command, + "tests/test_sync_core_supervisor.py::" + "test_real_linux_playwright_descriptor_exact_chain[missing-ack]", + ), + lambda command: _append_hosted_node( + command, "tests/test_sync_core_supervisor.py::unexpected_hosted_case", + ), + ), + ids=("commented", "duplicated", "unexpected"), +) +def test_unit_tests_hosted_contract_rejects_node_mutations( + mutate: Callable[[str], str], +) -> None: + """Comments, duplicate nodes, and additions cannot weaken the hosted lane.""" + workflow = _workflow() + job = workflow["jobs"][LINUX_JOB_ID] + hosted = _named_step(job, HOSTED_STEP_NAME) + hosted["run"] = mutate(hosted["run"]) + + with pytest.raises(AssertionError): + _assert_hosted_linux_contract(workflow) + + +def test_unit_tests_hosted_contract_rejects_commented_prerequisite() -> None: + """A provisioning comment cannot satisfy the active Linux prerequisite contract.""" + workflow = _workflow() + job = workflow["jobs"][LINUX_JOB_ID] + provision = _named_step(job, PROVISION_STEP_NAME) + provision["run"] = provision["run"].replace("sudo -n true", "# sudo -n true") + + with pytest.raises(AssertionError): + _assert_hosted_linux_contract(workflow) From 75277602437b43696094dbce04601cd6f23b3373 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 06:58:34 -0700 Subject: [PATCH 177/233] test(sync): close hosted cleanup review gaps --- .github/workflows/unit-tests.yml | 1 - tests/test_sync_core_supervisor.py | 701 ++++++++++++++++++++++------- tests/test_unit_tests_workflow.py | 178 ++++++-- 3 files changed, 674 insertions(+), 206 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 540a3f6459..fa76cebb67 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -26,7 +26,6 @@ on: jobs: unit-tests: - if: github.event.pull_request.draft != true name: Run Unit Tests runs-on: ubuntu-latest # 16m37s setup/protected/browser + ~30 minutes broad suite = 46m37s; diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 74febaef57..aa8f4cf568 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -3434,6 +3434,273 @@ def test_real_linux_authenticated_termination_and_cleanup(tmp_path: Path) -> Non assert surviving is False +_ROOT_PROC_SCANNER_SOURCE = r""" +import json,os,pathlib,sys + +payload=json.loads(sys.argv[1]) +proc=pathlib.Path('/proc') +self_pid=os.getpid() + +def fail(message): + raise RuntimeError(message) + +def vanished_or_fail(pid_dir, operation, error): + if isinstance(error, FileNotFoundError): + try: + pid_dir.stat() + except FileNotFoundError: + raise ProcessLookupError from error + except OSError as verify_error: + fail(f'verify process ENOENT: pid={pid_dir.name}: ' + f'{type(verify_error).__name__}: {verify_error}') + fail(f'{operation}: pid={pid_dir.name}: {type(error).__name__}: {error}') + +def read_text(path,pid_dir,operation,encoding='ascii'): + try: + return path.read_text(encoding=encoding) + except OSError as error: + vanished_or_fail(pid_dir,operation,error) + +def identity(pid_dir): + raw=read_text(pid_dir/'stat',pid_dir,'read stat') + closing=raw.rfind(')') + if closing<2: + fail(f'parse stat: pid={pid_dir.name}: missing comm terminator') + fields=raw[closing+2:].split() + if (len(fields)<=19 or len(fields[0])!=1 or + not fields[1].isdigit() or not fields[19].isdigit()): + fail(f'parse stat: pid={pid_dir.name}: invalid fields') + try: + uid=pid_dir.stat().st_uid + except OSError as error: + vanished_or_fail(pid_dir,'stat process directory',error) + return {'pid':int(pid_dir.name),'ppid':int(fields[1]),'state':fields[0], + 'start_time':fields[19],'uid':uid} + +def namespace(pid_dir): + path=pid_dir/'ns'/'mnt' + try: + link=os.readlink(path) + inode=path.stat().st_ino + except OSError as error: + vanished_or_fail(pid_dir,'read mount namespace',error) + if (not link.startswith('mnt:[') or not link.endswith(']') or inode<=0 or + not link[5:-1].isdigit() or int(link[5:-1])!=inode): + fail(f'parse mount namespace: pid={pid_dir.name}: {link!r}/{inode!r}') + return link,inode + +def cmdline(pid_dir): + try: + raw=(pid_dir/'cmdline').read_bytes() + except OSError as error: + vanished_or_fail(pid_dir,'read cmdline',error) + try: + return [value.decode('utf-8') for value in raw.split(b'\0') if value] + except UnicodeError as error: + fail(f'parse cmdline: pid={pid_dir.name}: {error}') + +def fd_links(pid_dir): + directory=pid_dir/'fd' + try: + entries=list(directory.iterdir()) + except OSError as error: + vanished_or_fail(pid_dir,'list descriptors',error) + links=[] + for entry in entries: + try: + links.append((int(entry.name),os.readlink(entry),entry.stat().st_ino)) + except FileNotFoundError as error: + try: + entry.lstat() + except FileNotFoundError: + continue + except OSError as verify_error: + fail(f'verify descriptor ENOENT: pid={pid_dir.name} fd={entry.name}: ' + f'{type(verify_error).__name__}: {verify_error}') + fail(f'read descriptor: pid={pid_dir.name} fd={entry.name}: ' + f'ENOENT while descriptor still exists: {error}') + except (OSError,ValueError) as error: + fail(f'read descriptor: pid={pid_dir.name} fd={entry.name}: ' + f'{type(error).__name__}: {error}') + return links + +def unescape(value): + return value.replace('\\040',' ').replace('\\011','\t').replace('\\134','\\') + +def mounts(pid_dir): + records=[] + for line in read_text(pid_dir/'mountinfo',pid_dir,'read mountinfo','utf-8').splitlines(): + fields=line.split() + if len(fields)<10 or '-' not in fields or not fields[0].isdigit(): + fail(f'parse mountinfo: pid={pid_dir.name}: {line!r}') + records.append({'mount_id':int(fields[0]),'mount_point':unescape(fields[4])}) + return records + +def cgroup_pids(path): + if not path: + return False,set() + root=pathlib.Path(path) + try: + root.lstat() + except FileNotFoundError: + return False,set() + except OSError as error: + fail(f'stat cgroup: {root}: {type(error).__name__}: {error}') + for attempt in range(2): + found=set() + try: + files=list(root.rglob('cgroup.procs')) + if not files: + fail(f'cgroup has no membership files: {root}') + for membership in files: + values=membership.read_text(encoding='ascii').split() + if any(not value.isdigit() for value in values): + fail(f'parse cgroup membership: {membership}') + found.update(int(value) for value in values) + return True,found + except FileNotFoundError as error: + try: + root.lstat() + except FileNotFoundError: + return False,set() + except OSError as verify_error: + fail(f'verify cgroup ENOENT: {root}: ' + f'{type(verify_error).__name__}: {verify_error}') + if attempt==0: + continue + fail(f'read cgroup membership raced twice: {root}: {error}') + except OSError as error: + fail(f'read cgroup membership: {root}: {type(error).__name__}: {error}') + fail(f'unreachable cgroup scan state: {root}') + +try: + cgroup_exists,members=cgroup_pids(payload.get('cgroup','')) + watched=set(payload.get('watch_pids',[])) + if any(type(pid) is not int or pid<=0 for pid in watched): + fail('invalid watched PID payload') + expected_namespace=payload.get('namespace') + targets=set(payload.get('targets',[])) + prefix=payload.get('target_prefix','') + identities=[]; cgroup_members=[]; watched_processes=[] + current_holders=[]; fd_holders=[]; mount_holders=[] + for pid_dir in sorted( + (path for path in proc.iterdir() if path.name.isdigit()), + key=lambda path:int(path.name), + ): + if int(pid_dir.name)==self_pid: + continue + try: + record=identity(pid_dir) + identities.append(record.copy()) + if record['state']=='Z': + if record['pid'] in members: + cgroup_members.append(record.copy()) + if record['pid'] in watched: + watched_processes.append(record.copy()) + continue + ns_link,ns_inode=namespace(pid_dir) + descriptors=fd_links(pid_dir) + mount_records=mounts(pid_dir) + verified=identity(pid_dir) + if (verified['pid'],verified['start_time']) != ( + record['pid'],record['start_time']): + identities.pop() + continue + record.update({'ppid':verified['ppid'],'state':verified['state'], + 'uid':verified['uid']}) + record.update({'namespace':ns_link,'namespace_inode':ns_inode}) + identities[-1]=record.copy() + if record['pid'] in members or record['pid'] in watched: + record['cmdline']=cmdline(pid_dir) + if record['pid'] in members: + cgroup_members.append(record.copy()) + if record['pid'] in watched: + watched_processes.append(record.copy()) + if expected_namespace is not None and ( + ns_link==expected_namespace['link'] and + ns_inode==expected_namespace['inode'] + ): + current_holders.append(record|{'holder_kind':'current'}) + if expected_namespace is not None: + for fd,link,inode in descriptors: + if (link==expected_namespace['link'] and + inode==expected_namespace['inode']): + fd_holders.append(record|{'holder_kind':'fd','fd':fd}) + for mount_record in mount_records: + point=mount_record['mount_point'] + if point in targets or (prefix and point.startswith(prefix)): + mount_holders.append(record|mount_record|{'holder_kind':'mount'}) + except ProcessLookupError: + continue + final_cgroup_exists,final_members=cgroup_pids(payload.get('cgroup','')) + cgroup_exists=final_cgroup_exists + cgroup_members=[ + record for record in cgroup_members if record['pid'] in final_members + ] + output={ + 'scanner_pid':self_pid,'cgroup_exists':cgroup_exists, + 'identities':identities,'cgroup_members':cgroup_members, + 'watched':watched_processes,'current_holders':current_holders, + 'fd_holders':fd_holders,'mount_holders':mount_holders, + } + print(json.dumps(output,sort_keys=True,separators=(',',':'))) +except Exception as error: + print(f'root proc scanner failed: {type(error).__name__}: {error}',file=sys.stderr) + raise SystemExit(2) +""" + + +def _root_proc_scan( + *, cgroup: Path | None = None, namespace: dict[str, object] | None = None, + targets: tuple[Path, ...] = (), target_prefix: Path | None = None, + watch_pids: tuple[int, ...] = (), +) -> dict[str, object]: + """Run one bounded root scanner that fails closed on ambiguous procfs evidence.""" + payload = { + "cgroup": str(cgroup) if cgroup is not None else "", + "namespace": namespace, + "targets": [str(path) for path in targets], + "target_prefix": str(target_prefix) if target_prefix is not None else "", + "watch_pids": list(watch_pids), + } + scanner_python = supervisor._trusted_tools().helper_python + try: + completed = subprocess.run( + ["sudo", "-n", str(scanner_python), "-I", "-S", "-c", + _ROOT_PROC_SCANNER_SOURCE, json.dumps(payload)], + capture_output=True, text=True, check=False, timeout=10, + ) + except subprocess.TimeoutExpired as exc: + raise AssertionError("root proc scanner exceeded its 10 second bound") from exc + assert completed.returncode == 0, completed.stderr + try: + result = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise AssertionError( + f"root proc scanner returned invalid JSON: {completed.stdout!r}" + ) from exc + assert isinstance(result, dict), result + return result + + +def _process_key(record: dict[str, object]) -> tuple[int, str]: + """Return the PID-reuse-safe identity used by cleanup assertions.""" + return int(record["pid"]), str(record["start_time"]) + + +def test_root_proc_scanner_source_compiles() -> None: + """The hosted privileged scanner must remain valid isolated Python.""" + compile(_ROOT_PROC_SCANNER_SOURCE, "", "exec") + + +def test_cleanup_process_identity_rejects_pid_reuse() -> None: + """A reused PID with a different kernel start time is not the recorded process.""" + original = {"pid": 123, "start_time": "100"} + reused = {"pid": 123, "start_time": "101"} + + assert _process_key(original) != _process_key(reused) + + @pytest.mark.real @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap"), @@ -3528,18 +3795,21 @@ def descend(roles): if waited!=pid or status!=0: raise SystemExit(125) descend(('coordinator','worker','browser','descendant')) """ - live_hierarchy_program = """import os,time -def descend(roles): - if len(roles) > 1: - pid=os.fork() - if pid == 0: - descend(roles[1:]) - os._exit(0) - time.sleep(30) - if len(roles) > 1: - waited,status=os.waitpid(pid,0) - if waited != pid or status != 0: raise SystemExit(125) -descend(('coordinator','worker','browser','descendant')) + live_hierarchy_program = """import os,sys,time +roles=('coordinator','worker','browser','descendant') +source=sys.argv[1] +role=sys.argv[2] +index=roles.index(role) +child=None +if index+1 dict[str, str]: return frame def scope_control_group(unit: str) -> Path: - """Read the systemd-reported full scope path while it is still live.""" - completed = subprocess.run( - ["sudo", "-n", "systemctl", "show", unit, - "--property=ControlGroup", "--value"], - capture_output=True, text=True, check=True, - ) - relative = completed.stdout.strip() - assert relative.startswith("/") and relative.endswith("/" + unit), relative - cgroup = Path("/sys/fs/cgroup") / relative.lstrip("/") - assert cgroup.is_dir(), cgroup - return cgroup - - def cgroup_processes(cgroup: Path) -> set[int]: - """Use cgroup-v2 membership as the trusted host-side process inventory.""" - processes = set() - for members in cgroup.rglob("cgroup.procs"): - processes.update(int(value) for value in members.read_text( - encoding="ascii" - ).split()) - return processes - - def process_mounts(pid: int) -> set[Path]: - """Read mountinfo directly; descriptor-table scans are intentionally excluded.""" - mountinfo = Path(f"/proc/{pid}/mountinfo") - try: - lines = mountinfo.read_text(encoding="utf-8").splitlines() - except OSError: - return set() - return { - Path(fields[4].replace("\\040", " ").replace("\\134", "\\")) - for line in lines if len(fields := line.split()) > 4 - } - - def namespace_holders(namespace: str, inode: int) -> set[int]: - """Find live holders by namespace links, without transient /proc fd reads.""" - holders = set() - for entry in Path("/proc").iterdir(): - if not entry.name.isdecimal(): - continue + """Read the exact full systemd scope cgroup under a monotonic bound.""" + deadline = time.monotonic() + 10 + last_error = "scope was not observable" + while time.monotonic() < deadline: try: - namespace_path = entry / "ns" / "mnt" - if ( - os.readlink(namespace_path) == namespace - and namespace_path.stat().st_ino == inode - ): - holders.add(int(entry.name)) - except OSError: - continue - return holders - - def mounted_holders(targets: tuple[Path, ...]) -> set[tuple[int, Path]]: - """Find every process namespace that still exposes a relevant mountpoint.""" - holders = set() - for entry in Path("/proc").iterdir(): - if not entry.name.isdecimal(): + completed = subprocess.run( + ["sudo", "-n", "systemctl", "show", unit, + "--property=ControlGroup", "--value"], + capture_output=True, text=True, check=False, timeout=5, + ) + except subprocess.TimeoutExpired: + last_error = "systemctl ControlGroup probe timed out" continue - for target in set(targets) & process_mounts(int(entry.name)): - holders.add((int(entry.name), target)) - return holders - - def capture_live_state(plan) -> dict[str, object]: - """Capture exact systemd, cgroup, namespace, and PID evidence before loss.""" - cgroup = scope_control_group(plan.unit_name) - targets = tuple(plan.staging_targets) + relative = completed.stdout.strip() + if ( + completed.returncode == 0 + and relative.startswith("/") + and relative.endswith("/" + unit) + ): + cgroup = Path("/sys/fs/cgroup") / relative.lstrip("/") + if cgroup.is_dir(): + return cgroup + last_error = completed.stderr.strip() or relative or last_error + time.sleep(.05) + raise AssertionError(f"exact ControlGroup unavailable for {unit}: {last_error}") + + def capture_live_state( + unit: str, targets: tuple[Path, ...], *, + target_prefix: Path | None = None, watch_pids: tuple[int, ...] = (), + ) -> dict[str, object]: + """Capture exact helper namespace, mounts, and PID/start-time identities.""" + cgroup = scope_control_group(unit) deadline = time.monotonic() + 10 - holder = None + last_scan = None while time.monotonic() < deadline: - for pid in cgroup_processes(cgroup): - if set(targets) & process_mounts(pid): - holder = pid - break - if holder is not None: - break + scan = _root_proc_scan( + cgroup=cgroup, targets=targets, target_prefix=target_prefix, + watch_pids=watch_pids, + ) + last_scan = scan + members = scan["cgroup_members"] + member_keys = {_process_key(record) for record in members} + root_mount_holders = [ + record for record in scan["mount_holders"] + if _process_key(record) in member_keys and int(record["uid"]) == 0 + ] + if members and root_mount_holders: + holder = root_mount_holders[0] + observed_targets = set(targets) + observed_targets.update( + Path(record["mount_point"]) + for record in scan["mount_holders"] + ) + namespace = { + "link": holder["namespace"], + "inode": holder["namespace_inode"], + } + tracked = { + _process_key(record): record + for record in (*members, *scan["watched"]) + } + return { + "unit": unit, + "cgroup": str(cgroup), + "targets": [str(path) for path in sorted(observed_targets)], + "namespace": namespace, + "namespace_holder": holder, + "recorded_identities": list(tracked.values()), + } time.sleep(.05) - assert holder is not None, "private helper mount namespace was not observable" - namespace_path = Path(f"/proc/{holder}/ns/mnt") - namespace = os.readlink(namespace_path) - namespace_inode = namespace_path.stat().st_ino - recorded = cgroup_processes(cgroup) - assert recorded, "live scope had no trusted process membership" - assert holder in recorded, "private mount namespace holder left scope early" - return { - "unit": plan.unit_name, - "cgroup": str(cgroup), - "targets": [str(path) for path in targets], - "helper_pid": holder, - "mount_namespace": namespace, - "mount_namespace_inode": namespace_inode, - "recorded_pids": sorted(recorded), - } + raise AssertionError( + "privileged helper namespace was not observable: " + + json.dumps(last_scan, sort_keys=True) + ) - def await_live_hierarchy(cgroup: Path) -> set[int]: - """Observe all four candidate roles from the exact trusted candidate leaf.""" - candidate = cgroup / "candidate" + def await_live_role_identities(details: dict[str, object]) -> None: + """Bind all four blocked roles to exact procfs identities and ancestry.""" + expected_roles = ("coordinator", "worker", "browser", "descendant") + candidate = Path(str(details["cgroup"])) / "candidate" deadline = time.monotonic() + 10 - previous: set[int] = set() + last_records = [] while time.monotonic() < deadline: - current = cgroup_processes(candidate) if candidate.is_dir() else set() - if len(current) >= 4 and current == previous: - return current - previous = current + scan = _root_proc_scan(cgroup=candidate) + records = [] + for record in scan["cgroup_members"]: + command = record.get("cmdline", []) + if ( + len(command) >= 3 + and command[-3] == live_hierarchy_program + and command[-2] == live_hierarchy_program + and command[-1] in expected_roles + ): + records.append(record) + last_records = records + by_role = { + record["cmdline"][-1]: record | {"role": record["cmdline"][-1]} + for record in records + } + if len(records) == 4 and set(by_role) == set(expected_roles): + for parent_role, child_role in zip(expected_roles, expected_roles[1:]): + assert int(by_role[child_role]["ppid"]) == int( + by_role[parent_role]["pid"] + ), (parent_role, child_role, by_role) + details["role_identities"] = [by_role[role] for role in expected_roles] + tracked = { + _process_key(record): record + for record in details["recorded_identities"] + } + tracked.update({_process_key(record): record for record in records}) + details["recorded_identities"] = list(tracked.values()) + return time.sleep(.05) raise AssertionError( - "coordinator/worker/browser/descendant hierarchy was not live: " - f"{sorted(previous)}" + "exact blocked four-role hierarchy was not observable: " + + json.dumps(last_records, sort_keys=True) ) - def pid_absent(pid: int) -> bool: - """Require the recorded PID to be both unprobeable and absent from procfs.""" - try: - os.kill(pid, 0) - except ProcessLookupError: - return not Path(f"/proc/{pid}").exists() - return False - def leak_state(details: dict[str, object]) -> list[str]: - leaks = [] - cgroup = Path(str(details["cgroup"])) - targets = tuple(Path(path) for path in details["targets"]) - if cgroup.exists(): - leaks.append(f"cgroup={cgroup}") - live_pids = [pid for pid in details["recorded_pids"] if not pid_absent(pid)] - if live_pids: - leaks.append(f"recorded-pids={live_pids}") - holders = namespace_holders( - str(details["mount_namespace"]), int(details["mount_namespace_inode"]) + """Return only exact identity, namespace, mount, and cgroup survivors.""" + scan = _root_proc_scan( + cgroup=Path(str(details["cgroup"])), + namespace=details["namespace"], + targets=tuple(Path(path) for path in details["targets"]), ) - if holders: - leaks.append(f"mount-namespace={sorted(holders)}") - mounts = mounted_holders(targets) - if mounts: - leaks.append(f"mounts={sorted((pid, str(path)) for pid, path in mounts)}") + leaks = [] + if scan["cgroup_exists"]: + leaks.append(f"cgroup={details['cgroup']}") + current = {_process_key(record) for record in scan["identities"]} + survivors = [ + record for record in details["recorded_identities"] + if _process_key(record) in current + ] + if survivors: + leaks.append("identities=" + json.dumps(survivors, sort_keys=True)) + if scan["current_holders"]: + leaks.append( + "current-namespace-holders=" + + json.dumps(scan["current_holders"], sort_keys=True) + ) + if scan["fd_holders"]: + leaks.append( + "fd-namespace-holders=" + + json.dumps(scan["fd_holders"], sort_keys=True) + ) + if scan["mount_holders"]: + leaks.append("mounts=" + json.dumps(scan["mount_holders"], sort_keys=True)) return leaks def await_automatic_cleanup(details: dict[str, object]) -> None: @@ -3758,11 +4042,28 @@ def await_automatic_cleanup(details: dict[str, object]) -> None: "automatic cleanup deadline expired: " + "; ".join(leak_state(details)) ) - def emergency_cleanup(details: dict[str, object], pid: int) -> None: - try: - os.killpg(pid, signal.SIGKILL) - except ProcessLookupError: - pass + def emergency_cleanup( + details: dict[str, object], pid: int | None = None, *, + process_group: bool = True, + ) -> None: + if pid is not None: + expected = next( + (record for record in details["recorded_identities"] + if int(record["pid"]) == pid), + None, + ) + scan = _root_proc_scan(watch_pids=(pid,)) + if expected is not None and any( + _process_key(record) == _process_key(expected) + for record in scan["watched"] + ): + try: + if process_group: + os.killpg(pid, signal.SIGKILL) + else: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass subprocess.run( ["sudo", "-n", "systemctl", "stop", str(details["unit"])], check=False, capture_output=True, text=True, @@ -3793,15 +4094,19 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None if case == "parent-exit-during-execution" else inventory_program ) - control, plan, process = launch(program) - details = capture_live_state(plan) + arguments = ( + (live_hierarchy_program, "coordinator") + if case == "parent-exit-during-execution" + else () + ) + control, plan, process = launch(program, arguments) + details = capture_live_state( + plan.unit_name, plan.staging_targets, watch_pids=(process.pid,) + ) if case != "parent-exit-before-start": start(process) if case == "parent-exit-during-execution": - hierarchy = await_live_hierarchy(Path(str(details["cgroup"]))) - recorded = cgroup_processes(Path(str(details["cgroup"]))) - assert hierarchy <= recorded - details["recorded_pids"] = sorted(recorded) + await_live_role_identities(details) elif case == "parent-exit-after-result": read_result(process) details["pid"] = process.pid @@ -3827,39 +4132,113 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None monkeypatch.setattr( supervisor.uuid, "uuid4", lambda: SimpleNamespace(hex=token) ) - mounts_before = supervisor._mounted_paths() + monkeypatch.setattr(supervisor.tempfile, "tempdir", str(tmp_path)) read_fd, write_fd = os.pipe() + report = tmp_path / "stalled-observation-reader.json" program = ( "import os;data=b'x'*262144;offset=0\n" "while offset= 0: + os.close(write_fd) + try: + reap_deadline = time.monotonic() + 5 + while time.monotonic() < reap_deadline: + waited, _status = os.waitpid(coordinator, os.WNOHANG) + if waited == coordinator: + break + time.sleep(.05) + else: + current = _root_proc_scan(watch_pids=(coordinator,)) + if any( + _process_key(record) == _process_key(coordinator_identity) + for record in current["watched"] + ): + os.kill(coordinator, signal.SIGKILL) + os.waitpid(coordinator, 0) + except ChildProcessError: + pass return program = inventory_program if case == "stalled-result-reader": program = "import os;os.write(1,b'x'*1048576);os.write(198,b'{}')" control, plan, process = launch(program) - details = capture_live_state(plan) + details = capture_live_state( + plan.unit_name, plan.staging_targets, watch_pids=(process.pid,) + ) should_succeed = case == "normal-hierarchy-environment" try: if case == "reordered-extra": diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py index 7b6d909cec..641102cfd0 100644 --- a/tests/test_unit_tests_workflow.py +++ b/tests/test_unit_tests_workflow.py @@ -24,7 +24,7 @@ PROVISION_STEP_NAME = "Provision and verify protected Linux sandbox" HOSTED_STEP_NAME = "Run real protected Playwright and authenticated supervisor protocols" HOSTED_SUPERVISOR_NODE = "tests/test_sync_core_supervisor.py::" -REQUIRED_HOSTED_NODES = frozenset(( +REQUIRED_HOSTED_NODES = ( "tests/test_sync_core_runner_playwright.py::" "test_real_playwright_1_55_config_suffixes_collect_and_use_config_dir", f"{HOSTED_SUPERVISOR_NODE}test_real_linux_authenticated_termination_and_cleanup", @@ -55,7 +55,20 @@ "[stalled-observation-reader]", "tests/test_sync_core_supervisor.py::" "test_simultaneous_high_volume_stdio_has_one_aggregate_bound", -)) +) +EXPECTED_PROVISION_COMMANDS = ( + ("set", "-euo", "pipefail"), + ("sudo", "apt-get", "update"), + ("sudo", "apt-get", "install", "--yes", "bubblewrap"), + ("command", "-v", "bwrap"), + ("command", "-v", "systemd-run"), + ("command", "-v", "unshare"), + ("sudo", "-n", "true"), + ("bwrap", "--version"), +) +EXPECTED_HOSTED_COMMAND = ( + "pytest", "-q", *REQUIRED_HOSTED_NODES, "--timeout=90", +) def _workflow() -> dict: @@ -75,7 +88,7 @@ def _named_step(job: dict, name: str) -> dict: def _shell_commands(command: object) -> tuple[tuple[str, ...], ...]: - """Parse active POSIX shell commands, excluding comments and quoted text.""" + """Parse top-level shell lines while excluding comments and heredoc bodies.""" assert isinstance(command, str) commands = [] continuation = "" @@ -102,6 +115,12 @@ def _shell_commands(command: object) -> tuple[tuple[str, ...], ...]: return tuple(commands) +def _assert_enabled(subject: dict) -> None: + """Require unconditional execution with ordinary failure propagation.""" + assert "if" not in subject + assert "continue-on-error" not in subject + + def _assert_hosted_linux_contract(workflow: dict) -> None: """Check the exact active hosted Linux command and prerequisites.""" jobs = workflow.get("jobs") @@ -109,30 +128,55 @@ def _assert_hosted_linux_contract(workflow: dict) -> None: job = jobs.get(LINUX_JOB_ID) assert isinstance(job, dict) assert job.get("runs-on") == "ubuntu-latest" + _assert_enabled(job) + steps = job.get("steps") + assert isinstance(steps, list) provision = _named_step(job, PROVISION_STEP_NAME) + hosted = _named_step(job, HOSTED_STEP_NAME) + assert steps.index(provision) < steps.index(hosted) + _assert_enabled(provision) + _assert_enabled(hosted) + provision_commands = _shell_commands(provision.get("run")) - for command in ( - ("sudo", "apt-get", "install", "--yes", "bubblewrap"), - ("command", "-v", "bwrap"), - ("command", "-v", "systemd-run"), - ("command", "-v", "unshare"), - ("sudo", "-n", "true"), - ): - assert command in provision_commands, command + assert provision_commands[:len(EXPECTED_PROVISION_COMMANDS)] == ( + EXPECTED_PROVISION_COMMANDS + ) - hosted = _named_step(job, HOSTED_STEP_NAME) hosted_commands = _shell_commands(hosted.get("run")) - pytest_commands = [ - command for command in hosted_commands if command and command[0] == "pytest" - ] - assert len(pytest_commands) == 1 - active_nodes = tuple( - token for token in pytest_commands[0] - if token.startswith("tests/") and "::" in token - ) - assert len(active_nodes) == len(set(active_nodes)), active_nodes - assert frozenset(active_nodes) == REQUIRED_HOSTED_NODES + assert hosted_commands == (EXPECTED_HOSTED_COMMAND,) + pytest_command = hosted_commands[0] + selectors = [] + for argument in pytest_command[1:]: + if argument in {"-q", "--timeout=90"}: + continue + assert not argument.startswith("-"), argument + selectors.append(argument) + assert tuple(selectors) == REQUIRED_HOSTED_NODES + assert len(selectors) == len(set(selectors)) + + +def _hosted_command(workflow: dict) -> tuple[dict, str]: + job = workflow["jobs"][LINUX_JOB_ID] + hosted = _named_step(job, HOSTED_STEP_NAME) + return hosted, hosted["run"] + + +def _append_hosted_argument(command: str, argument: str) -> str: + """Append one active argument before the frozen pytest timeout argument.""" + return command.replace("--timeout=90", f"{argument} \\\n --timeout=90") + + +def _remove_hosted_node_line(command: str) -> str: + """Physically remove one complete required selector line.""" + node = REQUIRED_HOSTED_NODES[6] + lines = command.splitlines(keepends=True) + matches = [index for index, line in enumerate(lines) if node in line] + assert len(matches) == 1 + del lines[matches[0]] + mutated = "".join(lines) + assert mutated != command and node not in mutated + return mutated def test_unit_tests_timeout_covers_documented_full_job_budget() -> None: @@ -158,39 +202,86 @@ def test_unit_tests_requires_complete_privileged_descriptor_matrix() -> None: _assert_hosted_linux_contract(_workflow()) -def _append_hosted_node(command: str, node: str) -> str: - """Append one active node before the pytest timeout argument.""" - return command.replace("--timeout=90", f"{node} \\\n --timeout=90") - - @pytest.mark.parametrize( "mutate", ( lambda command: command.replace( - "tests/test_sync_core_supervisor.py::" - "test_real_linux_playwright_descriptor_exact_chain[parent-exit-before-start]", - "# tests/test_sync_core_supervisor.py::" - "test_real_linux_playwright_descriptor_exact_chain[parent-exit-before-start]", + REQUIRED_HOSTED_NODES[6], "# " + REQUIRED_HOSTED_NODES[6] ), - lambda command: _append_hosted_node( - command, - "tests/test_sync_core_supervisor.py::" - "test_real_linux_playwright_descriptor_exact_chain[missing-ack]", + lambda command: _append_hosted_argument(command, REQUIRED_HOSTED_NODES[10]), + lambda command: _append_hosted_argument( + command, "tests/test_sync_core_supervisor.py::unexpected_hosted_case" ), - lambda command: _append_hosted_node( - command, "tests/test_sync_core_supervisor.py::unexpected_hosted_case", + _remove_hosted_node_line, + lambda command: _append_hosted_argument(command, "-k parent-exit"), + lambda command: _append_hosted_argument( + command, "tests/test_sync_core_supervisor.py" ), ), - ids=("commented", "duplicated", "unexpected"), + ids=("commented", "duplicated", "unexpected", "removed", "k", "file-selector"), ) -def test_unit_tests_hosted_contract_rejects_node_mutations( +def test_unit_tests_hosted_contract_rejects_selector_mutations( mutate: Callable[[str], str], ) -> None: - """Comments, duplicate nodes, and additions cannot weaken the hosted lane.""" + """Every selection change must invalidate the exact hosted command.""" + workflow = _workflow() + hosted, command = _hosted_command(workflow) + mutated = mutate(command) + assert mutated != command + hosted["run"] = mutated + + with pytest.raises(AssertionError): + _assert_hosted_linux_contract(workflow) + + +@pytest.mark.parametrize( + ("subject", "field", "value"), + ( + ("job", "if", False), + ("provision", "if", False), + ("hosted", "if", False), + ("job", "continue-on-error", True), + ("provision", "continue-on-error", True), + ("hosted", "continue-on-error", True), + ), +) +def test_unit_tests_hosted_contract_rejects_disabling_semantics( + subject: str, field: str, value: object, +) -> None: + """Job and critical steps must stay unconditional and failure-propagating.""" workflow = _workflow() job = workflow["jobs"][LINUX_JOB_ID] - hosted = _named_step(job, HOSTED_STEP_NAME) - hosted["run"] = mutate(hosted["run"]) + targets = { + "job": job, + "provision": _named_step(job, PROVISION_STEP_NAME), + "hosted": _named_step(job, HOSTED_STEP_NAME), + } + targets[subject][field] = value + + with pytest.raises(AssertionError): + _assert_hosted_linux_contract(workflow) + + +def test_unit_tests_hosted_contract_rejects_reordered_steps() -> None: + """Provisioning must execute before the hosted privileged test command.""" + workflow = _workflow() + steps = workflow["jobs"][LINUX_JOB_ID]["steps"] + provision = _named_step(workflow["jobs"][LINUX_JOB_ID], PROVISION_STEP_NAME) + hosted = _named_step(workflow["jobs"][LINUX_JOB_ID], HOSTED_STEP_NAME) + first, second = steps.index(provision), steps.index(hosted) + steps[first], steps[second] = steps[second], steps[first] + + with pytest.raises(AssertionError): + _assert_hosted_linux_contract(workflow) + + +def test_unit_tests_hosted_contract_rejects_dead_branch_prerequisite() -> None: + """A prerequisite hidden in a dead shell branch is not top-level active setup.""" + workflow = _workflow() + provision = _named_step(workflow["jobs"][LINUX_JOB_ID], PROVISION_STEP_NAME) + active = "sudo apt-get install --yes bubblewrap" + dead = f"if false; then\n {active}\n fi" + provision["run"] = provision["run"].replace(active, dead) with pytest.raises(AssertionError): _assert_hosted_linux_contract(workflow) @@ -199,8 +290,7 @@ def test_unit_tests_hosted_contract_rejects_node_mutations( def test_unit_tests_hosted_contract_rejects_commented_prerequisite() -> None: """A provisioning comment cannot satisfy the active Linux prerequisite contract.""" workflow = _workflow() - job = workflow["jobs"][LINUX_JOB_ID] - provision = _named_step(job, PROVISION_STEP_NAME) + provision = _named_step(workflow["jobs"][LINUX_JOB_ID], PROVISION_STEP_NAME) provision["run"] = provision["run"].replace("sudo -n true", "# sudo -n true") with pytest.raises(AssertionError): From 4aa09c6756756132f9d9743be374c952408f2f55 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 07:31:50 -0700 Subject: [PATCH 178/233] test(sync): prove descriptor blocked-state cleanup --- .github/workflows/unit-tests.yml | 1 + tests/test_sync_core_supervisor.py | 233 +++++++++++++++++++++++++---- tests/test_unit_tests_workflow.py | 33 +++- 3 files changed, 232 insertions(+), 35 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index fa76cebb67..540a3f6459 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -26,6 +26,7 @@ on: jobs: unit-tests: + if: github.event.pull_request.draft != true name: Run Unit Tests runs-on: ubuntu-latest # 16m37s setup/protected/browser + ~30 minutes broad suite = 46m37s; diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index aa8f4cf568..f0d2454ac5 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -3499,6 +3499,9 @@ def cmdline(pid_dir): except UnicodeError as error: fail(f'parse cmdline: pid={pid_dir.name}: {error}') +def wchan(pid_dir): + return read_text(pid_dir/'wchan',pid_dir,'read wchan').strip() + def fd_links(pid_dir): directory=pid_dir/'fd' try: @@ -3612,6 +3615,15 @@ def cgroup_pids(path): identities[-1]=record.copy() if record['pid'] in members or record['pid'] in watched: record['cmdline']=cmdline(pid_dir) + record['wchan']=wchan(pid_dir) + final=identity(pid_dir) + if (final['pid'],final['start_time']) != ( + record['pid'],record['start_time']): + identities.pop() + continue + record.update({'ppid':final['ppid'],'state':final['state'], + 'uid':final['uid']}) + identities[-1]=record.copy() if record['pid'] in members: cgroup_members.append(record.copy()) if record['pid'] in watched: @@ -3688,6 +3700,74 @@ def _process_key(record: dict[str, object]) -> tuple[int, str]: return int(record["pid"]), str(record["start_time"]) +_BLOCKED_WCHAN_MARKERS = ("sleep", "wait", "pause", "futex") + + +def _assert_exact_blocked_role_snapshot( + expected_roles: list[dict[str, object]], scan: dict[str, object], +) -> None: + """Require exact live role identities to remain blocked in the candidate scope.""" + expected = {str(record["role"]): record for record in expected_roles} + assert len(expected) == 4 + observed = { + _process_key(record): record + for record in scan["cgroup_members"] + } + for role, prior in expected.items(): + record = observed.get(_process_key(prior)) + assert record is not None, (role, prior, scan["cgroup_members"]) + assert int(record["ppid"]) == int(prior["ppid"]), (role, prior, record) + state = str(record["state"]) + assert state.lower() != "r" and state.lower() != "running", (role, record) + wchan = str(record.get("wchan", "")).strip() + assert wchan and wchan != "0", (role, record) + assert any(marker in wchan.lower() for marker in _BLOCKED_WCHAN_MARKERS), ( + role, record, + ) + + +def _fallback_stalled_observation_cleanup( + unit: str, coordinator: int, read_fd: int, mount_paths: tuple[Path, ...], *, + runner=subprocess.run, +) -> int: + """Close owned transport, stop the exact scope, reap child, then unmount bounds.""" + try: + os.close(read_fd) + except OSError: + pass + runner( + ["sudo", "-n", "systemctl", "stop", unit], check=False, + capture_output=True, text=True, + ) + deadline = time.monotonic() + 5 + try: + while time.monotonic() < deadline: + waited, _status = os.waitpid(coordinator, os.WNOHANG) + if waited == coordinator: + break + time.sleep(.05) + else: + os.kill(coordinator, signal.SIGKILL) + os.waitpid(coordinator, 0) + except (ChildProcessError, ProcessLookupError): + pass + for mount in reversed(mount_paths[:8]): + runner( + ["sudo", "-n", "umount", str(mount)], check=False, + capture_output=True, text=True, + ) + return -1 + + +def _run_stalled_observation_setup(scan, assert_watched, failure_cleanup) -> None: + """Keep early privileged-observation failures inside cleanup ownership.""" + try: + assert_watched(scan()) + except BaseException: + failure_cleanup() + raise + + def test_root_proc_scanner_source_compiles() -> None: """The hosted privileged scanner must remain valid isolated Python.""" compile(_ROOT_PROC_SCANNER_SOURCE, "", "exec") @@ -3701,6 +3781,80 @@ def test_cleanup_process_identity_rejects_pid_reuse() -> None: assert _process_key(original) != _process_key(reused) +@pytest.mark.parametrize("failure", ("scan", "watched")) +def test_stalled_observation_setup_failure_reaps_child_scope_and_pipe( + tmp_path: Path, failure: str, +) -> None: + """The first scan and watched assertion both retain fallback cleanup ownership.""" + read_fd, write_fd = os.pipe() + coordinator = os.fork() + if coordinator == 0: + os.close(read_fd) + os.close(write_fd) + signal.pause() + os._exit(125) + os.close(write_fd) + commands = [] + + def runner(*args, **_kwargs): + commands.append(args[0]) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + def scan(): + if failure == "scan": + raise AssertionError("injected initial scan failure") + return {"watched": []} + + def assert_watched(scan_result): + assert scan_result["watched"] == ["coordinator"] + + with pytest.raises(AssertionError): + _run_stalled_observation_setup( + scan, assert_watched, + lambda: _fallback_stalled_observation_cleanup( + "pdd-validator-test.scope", coordinator, read_fd, + (tmp_path / "first", tmp_path / "second"), runner=runner, + ), + ) + + with pytest.raises(OSError): + os.fstat(read_fd) + with pytest.raises(ChildProcessError): + os.waitpid(coordinator, os.WNOHANG) + assert commands == [ + ["sudo", "-n", "systemctl", "stop", "pdd-validator-test.scope"], + ["sudo", "-n", "umount", str(tmp_path / "second")], + ["sudo", "-n", "umount", str(tmp_path / "first")], + ] + + +def test_exact_blocked_role_snapshot_rejects_running_and_reused_identity() -> None: + """A running role or PID reuse cannot satisfy blocked-role evidence.""" + roles = [ + {"role": role, "pid": index + 10, "start_time": str(index + 100), + "ppid": 1 if index == 0 else index + 9} + for index, role in enumerate(("coordinator", "worker", "browser", "descendant")) + ] + members = [ + record | {"state": "S", "wchan": "hrtimer_nanosleep"} + for record in roles + ] + _assert_exact_blocked_role_snapshot(roles, {"cgroup_members": members}) + + with pytest.raises(AssertionError): + _assert_exact_blocked_role_snapshot( + roles, {"cgroup_members": members[:1] + [ + members[1] | {"state": "R"}, *members[2:] + ]}, + ) + with pytest.raises(AssertionError): + _assert_exact_blocked_role_snapshot( + roles, {"cgroup_members": members[:2] + [ + members[2] | {"start_time": "reused"}, members[3] + ]}, + ) + + @pytest.mark.real @pytest.mark.skipif( not sys.platform.startswith("linux") or not shutil.which("bwrap"), @@ -3960,7 +4114,7 @@ def capture_live_state( ) def await_live_role_identities(details: dict[str, object]) -> None: - """Bind all four blocked roles to exact procfs identities and ancestry.""" + """Bind all four live roles to exact procfs identities and ancestry.""" expected_roles = ("coordinator", "worker", "browser", "descendant") candidate = Path(str(details["cgroup"])) / "candidate" deadline = time.monotonic() + 10 @@ -4001,6 +4155,24 @@ def await_live_role_identities(details: dict[str, object]) -> None: + json.dumps(last_records, sort_keys=True) ) + def await_same_blocked_role_identities(details: dict[str, object]) -> None: + """Re-observe the recorded roles as blocked candidates before parent loss.""" + candidate = Path(str(details["cgroup"])) / "candidate" + deadline = time.monotonic() + 10 + last_scan = None + while time.monotonic() < deadline: + scan = _root_proc_scan(cgroup=candidate) + last_scan = scan + try: + _assert_exact_blocked_role_snapshot(details["role_identities"], scan) + return + except AssertionError: + time.sleep(.05) + raise AssertionError( + "exact role identities were not blocked before parent exit: " + + json.dumps(last_scan, sort_keys=True) + ) + def leak_state(details: dict[str, object]) -> list[str]: """Return only exact identity, namespace, mount, and cgroup survivors.""" scan = _root_proc_scan( @@ -4107,6 +4279,7 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None start(process) if case == "parent-exit-during-execution": await_live_role_identities(details) + await_same_blocked_role_identities(details) elif case == "parent-exit-after-result": read_result(process) details["pid"] = process.pid @@ -4171,11 +4344,25 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None os._exit(exit_status) os.close(write_fd) write_fd = -1 - coordinator_scan = _root_proc_scan(watch_pids=(coordinator,)) - assert len(coordinator_scan["watched"]) == 1 - coordinator_identity = coordinator_scan["watched"][0] details = None + fallback_done = False try: + def assert_initial_coordinator(scan: dict[str, object]) -> None: + assert len(scan["watched"]) == 1 + + def early_failure_cleanup() -> None: + nonlocal read_fd, fallback_done + read_fd = _fallback_stalled_observation_cleanup( + unit, coordinator, read_fd, + tuple(sorted(tmp_path.glob("pdd-scope-*"))), + ) + fallback_done = True + + _run_stalled_observation_setup( + lambda: _root_proc_scan(watch_pids=(coordinator,)), + assert_initial_coordinator, + early_failure_cleanup, + ) details = capture_live_state( unit, (), target_prefix=tmp_path / "pdd-scope-", watch_pids=(coordinator,), @@ -4195,39 +4382,23 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None assert "timed out" in outcome["stderr"] assert outcome["surviving"] is False except BaseException: - if details is not None: - emergency_cleanup(details, coordinator, process_group=False) - else: - current = _root_proc_scan(watch_pids=(coordinator,)) - if any( - _process_key(record) == _process_key(coordinator_identity) - for record in current["watched"] - ): - os.kill(coordinator, signal.SIGKILL) - subprocess.run( - ["sudo", "-n", "systemctl", "stop", unit], check=False, - capture_output=True, text=True, + if not fallback_done: + mount_paths = ( + tuple(Path(path) for path in details["targets"]) + if details is not None + else tuple(sorted(tmp_path.glob("pdd-scope-*"))) + ) + read_fd = _fallback_stalled_observation_cleanup( + unit, coordinator, read_fd, mount_paths, ) raise finally: - os.close(read_fd) + if read_fd >= 0: + os.close(read_fd) if write_fd >= 0: os.close(write_fd) try: - reap_deadline = time.monotonic() + 5 - while time.monotonic() < reap_deadline: - waited, _status = os.waitpid(coordinator, os.WNOHANG) - if waited == coordinator: - break - time.sleep(.05) - else: - current = _root_proc_scan(watch_pids=(coordinator,)) - if any( - _process_key(record) == _process_key(coordinator_identity) - for record in current["watched"] - ): - os.kill(coordinator, signal.SIGKILL) - os.waitpid(coordinator, 0) + os.waitpid(coordinator, os.WNOHANG) except ChildProcessError: pass return diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py index 641102cfd0..4f6cbf7e6f 100644 --- a/tests/test_unit_tests_workflow.py +++ b/tests/test_unit_tests_workflow.py @@ -21,6 +21,7 @@ FULL_JOB_SECONDS * (1 + HEADROOM_FRACTION) / 60 ) LINUX_JOB_ID = "unit-tests" +APPROVED_DRAFT_GUARD = "github.event.pull_request.draft != true" PROVISION_STEP_NAME = "Provision and verify protected Linux sandbox" HOSTED_STEP_NAME = "Run real protected Playwright and authenticated supervisor protocols" HOSTED_SUPERVISOR_NODE = "tests/test_sync_core_supervisor.py::" @@ -121,6 +122,12 @@ def _assert_enabled(subject: dict) -> None: assert "continue-on-error" not in subject +def _assert_approved_draft_guard(job: dict) -> None: + """Require the reviewed job-level draft guard without equivalent rewrites.""" + assert job.get("if") == APPROVED_DRAFT_GUARD + assert "continue-on-error" not in job + + def _assert_hosted_linux_contract(workflow: dict) -> None: """Check the exact active hosted Linux command and prerequisites.""" jobs = workflow.get("jobs") @@ -128,7 +135,7 @@ def _assert_hosted_linux_contract(workflow: dict) -> None: job = jobs.get(LINUX_JOB_ID) assert isinstance(job, dict) assert job.get("runs-on") == "ubuntu-latest" - _assert_enabled(job) + _assert_approved_draft_guard(job) steps = job.get("steps") assert isinstance(steps, list) @@ -234,13 +241,31 @@ def test_unit_tests_hosted_contract_rejects_selector_mutations( _assert_hosted_linux_contract(workflow) +@pytest.mark.parametrize( + "mutated_guard", + (None, False, "github.event.pull_request.draft == false"), + ids=("removed", "false", "altered"), +) +def test_unit_tests_hosted_contract_rejects_draft_guard_mutations( + mutated_guard: object, +) -> None: + """The reviewed draft guard must remain exactly attached to the unit-test job.""" + workflow = _workflow() + job = workflow["jobs"][LINUX_JOB_ID] + if mutated_guard is None: + del job["if"] + else: + job["if"] = mutated_guard + + with pytest.raises(AssertionError): + _assert_hosted_linux_contract(workflow) + + @pytest.mark.parametrize( ("subject", "field", "value"), ( - ("job", "if", False), ("provision", "if", False), ("hosted", "if", False), - ("job", "continue-on-error", True), ("provision", "continue-on-error", True), ("hosted", "continue-on-error", True), ), @@ -248,7 +273,7 @@ def test_unit_tests_hosted_contract_rejects_selector_mutations( def test_unit_tests_hosted_contract_rejects_disabling_semantics( subject: str, field: str, value: object, ) -> None: - """Job and critical steps must stay unconditional and failure-propagating.""" + """Critical steps must stay unconditional and failure-propagating.""" workflow = _workflow() job = workflow["jobs"][LINUX_JOB_ID] targets = { From 07dac9d64cfd6db98e40246f07646e87cdec53b4 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 08:17:44 -0700 Subject: [PATCH 179/233] test(sync): prove privileged fallback cleanup --- .github/workflows/unit-tests.yml | 2 + tests/test_sync_core_supervisor.py | 357 ++++++++++++++++++++++++----- tests/test_unit_tests_workflow.py | 4 + 3 files changed, 310 insertions(+), 53 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 540a3f6459..46e79ad529 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -430,6 +430,8 @@ jobs: tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[trailing-raw] \ tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[reordered-extra] \ tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[stalled-observation-reader] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[initial-scan-failure] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[initial-watched-assertion-failure] \ tests/test_sync_core_supervisor.py::test_simultaneous_high_volume_stdio_has_one_aggregate_bound \ --timeout=90 diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index f0d2454ac5..4be97ef093 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -3726,45 +3726,202 @@ def _assert_exact_blocked_role_snapshot( ) +def _cleanup_failure(primary: BaseException, cleanup: BaseException) -> None: + """Keep cleanup evidence on, but never replace, the triggering failure.""" + primary.add_note(f"stalled-observation cleanup failure: {cleanup}") + + def _fallback_stalled_observation_cleanup( - unit: str, coordinator: int, read_fd: int, mount_paths: tuple[Path, ...], *, - runner=subprocess.run, -) -> int: - """Close owned transport, stop the exact scope, reap child, then unmount bounds.""" - try: - os.close(read_fd) - except OSError: - pass - runner( - ["sudo", "-n", "systemctl", "stop", unit], check=False, - capture_output=True, text=True, + ownership: dict[str, object], owned_fds: tuple[int, ...], *, + runner=subprocess.run, scanner=_root_proc_scan, +) -> tuple[int, ...]: + """Boundedly remove every captured privileged resource after early observation loss.""" + errors = [] + deadline = time.monotonic() + 20 + unit = str(ownership["unit"]) + cgroup = Path(str(ownership["cgroup"])) + control_prefix = Path(str(ownership["control_prefix"])) + namespace = ownership["namespace"] + coordinator = ownership["coordinator"] + assert isinstance(namespace, dict) and isinstance(coordinator, dict) + expected_coordinator = _process_key(coordinator) + captured_mounts = {Path(path) for path in ownership["mount_points"]} + + def remaining() -> float: + return deadline - time.monotonic() + + def command(argv: list[str], purpose: str): + timeout = min(5, remaining()) + if timeout <= 0: + errors.append(f"cleanup deadline expired before {purpose}") + return None + try: + return runner( + argv, check=False, capture_output=True, text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired: + errors.append(f"{purpose} timed out") + except OSError as exc: + errors.append(f"{purpose} failed: {type(exc).__name__}: {exc}") + return None + + def scan_owned() -> dict[str, object] | None: + if remaining() <= 0: + errors.append("cleanup deadline expired before privileged procfs scan") + return None + try: + return scanner( + cgroup=cgroup, namespace=namespace, + targets=tuple(sorted(captured_mounts)), target_prefix=control_prefix, + watch_pids=(int(coordinator["pid"]),), + ) + except BaseException as exc: # pylint: disable=broad-exception-caught + # Scanner failure is cleanup evidence, not a reason to abandon teardown. + errors.append(f"privileged procfs scan failed: {type(exc).__name__}: {exc}") + return None + + for descriptor in owned_fds: + if descriptor < 0: + continue + try: + os.close(descriptor) + except OSError as exc: + if exc.errno != 9: + errors.append(f"close fd {descriptor} failed: {exc}") + + stopped = command(["sudo", "-n", "systemctl", "stop", unit], "stop exact scope") + if stopped is not None and stopped.returncode != 0: + errors.append(stopped.stderr.strip() or f"cannot stop exact scope {unit}") + + # Signal only a procfs-confirmed PID/start-time identity; a reused PID is untouchable. + scan = scan_owned() + coordinator_present = scan is not None and any( + _process_key(record) == expected_coordinator for record in scan["watched"] ) - deadline = time.monotonic() + 5 - try: - while time.monotonic() < deadline: - waited, _status = os.waitpid(coordinator, os.WNOHANG) - if waited == coordinator: + if coordinator_present: + try: + os.kill(int(coordinator["pid"]), signal.SIGTERM) + except ProcessLookupError: + pass + reap_deadline = min(deadline, time.monotonic() + 5) + reaped = False + while time.monotonic() < reap_deadline: + try: + waited, _status = os.waitpid(int(coordinator["pid"]), os.WNOHANG) + except ChildProcessError: + reaped = True + break + if waited == int(coordinator["pid"]): + reaped = True + break + time.sleep(.05) + if not reaped and coordinator_present: + try: + os.kill(int(coordinator["pid"]), signal.SIGKILL) + except ProcessLookupError: + pass + kill_deadline = min(deadline, time.monotonic() + 5) + while time.monotonic() < kill_deadline: + try: + waited, _status = os.waitpid(int(coordinator["pid"]), os.WNOHANG) + except ChildProcessError: + reaped = True + break + if waited == int(coordinator["pid"]): + reaped = True break time.sleep(.05) + if coordinator_present and not reaped: + errors.append("captured coordinator could not be reaped before deadline") + + # The scanner sees mounts in the privileged private namespace, including binds/*. + scan = scan_owned() + if scan is not None: + captured_mounts.update(Path(record["mount_point"]) for record in scan["mount_holders"]) + holders = [] if scan is None else [ + *scan["current_holders"], *scan["fd_holders"] + ] + namespace_holder = next( + (record for record in holders if _process_key(record) != expected_coordinator), + None, + ) or (holders[0] if holders else None) + for mount in sorted( + captured_mounts, key=lambda path: (len(path.parts), str(path)), reverse=True, + ): + if namespace_holder is None: + argv = ["sudo", "-n", "umount", str(mount)] else: - os.kill(coordinator, signal.SIGKILL) - os.waitpid(coordinator, 0) - except (ChildProcessError, ProcessLookupError): - pass - for mount in reversed(mount_paths[:8]): - runner( - ["sudo", "-n", "umount", str(mount)], check=False, - capture_output=True, text=True, + argv = [ + "sudo", "-n", "nsenter", + f"--mount=/proc/{namespace_holder['pid']}/ns/mnt", "--", + "umount", str(mount), + ] + completed = command(argv, f"unmount {mount}") + if completed is None or completed.returncode == 0: + continue + diagnostic = (completed.stderr or completed.stdout).strip().lower() + if "not mounted" not in diagnostic and "no such file" not in diagnostic: + errors.append(diagnostic or f"cannot unmount {mount}") + + verification_deadline = min(deadline, time.monotonic() + 8) + final_leaks = ["verification did not run"] + while time.monotonic() < verification_deadline: + scan = scan_owned() + inactive = command( + ["sudo", "-n", "systemctl", "is-active", "--quiet", unit], + "verify exact scope inactive", ) - return -1 + fds_open = [] + for descriptor in owned_fds: + if descriptor < 0: + continue + try: + os.fstat(descriptor) + except OSError as exc: + if exc.errno == 9: + continue + fds_open.append(f"fd={descriptor}: {exc}") + else: + fds_open.append(f"fd={descriptor}") + if scan is None or inactive is None: + break + current = {_process_key(record) for record in scan["identities"]} + coordinator_alive = expected_coordinator in current + final_leaks = [] + if inactive.returncode not in {3, 4}: + final_leaks.append(f"unit-active-returncode={inactive.returncode}") + if scan["cgroup_exists"]: + final_leaks.append(f"cgroup={cgroup}") + if coordinator_alive: + final_leaks.append(f"coordinator={expected_coordinator}") + if scan["current_holders"]: + final_leaks.append("current-namespace-holder") + if scan["fd_holders"]: + final_leaks.append("fd-namespace-holder") + if scan["mount_holders"]: + final_leaks.append("owned-nested-mount") + final_leaks.extend(fds_open) + if not final_leaks: + break + time.sleep(.05) + else: + errors.append("cleanup verification deadline expired") + if final_leaks: + errors.append("cleanup survivors: " + ", ".join(final_leaks)) + if errors: + raise AssertionError("; ".join(errors)) + return tuple(-1 for _descriptor in owned_fds) def _run_stalled_observation_setup(scan, assert_watched, failure_cleanup) -> None: """Keep early privileged-observation failures inside cleanup ownership.""" try: assert_watched(scan()) - except BaseException: - failure_cleanup() + except BaseException as primary: + try: + failure_cleanup() + except BaseException as cleanup: # pylint: disable=broad-exception-caught + _cleanup_failure(primary, cleanup) raise @@ -3782,7 +3939,7 @@ def test_cleanup_process_identity_rejects_pid_reuse() -> None: @pytest.mark.parametrize("failure", ("scan", "watched")) -def test_stalled_observation_setup_failure_reaps_child_scope_and_pipe( +def test_stalled_observation_setup_failure_preserves_primary_and_reaps_owned_state( tmp_path: Path, failure: str, ) -> None: """The first scan and watched assertion both retain fallback cleanup ownership.""" @@ -3795,25 +3952,52 @@ def test_stalled_observation_setup_failure_reaps_child_scope_and_pipe( os._exit(125) os.close(write_fd) commands = [] + calls = 0 def runner(*args, **_kwargs): commands.append(args[0]) + if args[0][2:4] == ["systemctl", "is-active"]: + return SimpleNamespace(returncode=3, stdout="", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="") + coordinator_record = {"pid": coordinator, "start_time": "unit-test"} + + def scanner(**_kwargs): + nonlocal calls + calls += 1 + if calls == 1: + return { + "watched": [coordinator_record], "mount_holders": [], + "current_holders": [], "fd_holders": [], "identities": [], + "cgroup_exists": False, + } + return { + "watched": [], "mount_holders": [], "current_holders": [], + "fd_holders": [], "identities": [], "cgroup_exists": False, + } + + ownership = { + "unit": "pdd-validator-test.scope", "cgroup": tmp_path / "cgroup", + "control_prefix": tmp_path / "pdd-scope-owned", + "namespace": {"link": "mnt:[1]", "inode": 1}, + "coordinator": coordinator_record, + "mount_points": [tmp_path / "pdd-scope-owned" / "binds" / "nested"], + } + def scan(): if failure == "scan": raise AssertionError("injected initial scan failure") return {"watched": []} def assert_watched(scan_result): - assert scan_result["watched"] == ["coordinator"] + if scan_result["watched"] != ["coordinator"]: + raise AssertionError("injected initial watched assertion failure") - with pytest.raises(AssertionError): + with pytest.raises(AssertionError, match="injected initial"): _run_stalled_observation_setup( scan, assert_watched, lambda: _fallback_stalled_observation_cleanup( - "pdd-validator-test.scope", coordinator, read_fd, - (tmp_path / "first", tmp_path / "second"), runner=runner, + ownership, (read_fd,), runner=runner, scanner=scanner, ), ) @@ -3823,8 +4007,8 @@ def assert_watched(scan_result): os.waitpid(coordinator, os.WNOHANG) assert commands == [ ["sudo", "-n", "systemctl", "stop", "pdd-validator-test.scope"], - ["sudo", "-n", "umount", str(tmp_path / "second")], - ["sudo", "-n", "umount", str(tmp_path / "first")], + ["sudo", "-n", "umount", str(tmp_path / "pdd-scope-owned" / "binds" / "nested")], + ["sudo", "-n", "systemctl", "is-active", "--quiet", "pdd-validator-test.scope"], ] @@ -3872,6 +4056,8 @@ def test_exact_blocked_role_snapshot_rejects_running_and_reused_identity() -> No "trailing-raw", "reordered-extra", "stalled-observation-reader", + "initial-scan-failure", + "initial-watched-assertion-failure", ), ids=( "normal-hierarchy-environment", "parent-exit-before-start", @@ -3884,6 +4070,8 @@ def test_exact_blocked_role_snapshot_rejects_running_and_reused_identity() -> No "trailing-raw", "reordered-extra", "stalled-observation-reader", + "initial-scan-failure", + "initial-watched-assertion-failure", )) def test_real_linux_playwright_descriptor_exact_chain( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str, @@ -4099,12 +4287,23 @@ def capture_live_state( _process_key(record): record for record in (*members, *scan["watched"]) } + coordinator_record = next( + (record for record in scan["watched"] + if int(record["pid"]) in watch_pids), + None, + ) + if coordinator_record is None: + time.sleep(.05) + continue return { "unit": unit, "cgroup": str(cgroup), "targets": [str(path) for path in sorted(observed_targets)], + "mount_points": [str(path) for path in sorted(observed_targets)], + "control_prefix": str(target_prefix) if target_prefix else "", "namespace": namespace, "namespace_holder": holder, + "coordinator": coordinator_record, "recorded_identities": list(tracked.values()), } time.sleep(.05) @@ -4179,6 +4378,10 @@ def leak_state(details: dict[str, object]) -> list[str]: cgroup=Path(str(details["cgroup"])), namespace=details["namespace"], targets=tuple(Path(path) for path in details["targets"]), + target_prefix=( + Path(str(details["control_prefix"])) + if details.get("control_prefix") else None + ), ) leaks = [] if scan["cgroup_exists"]: @@ -4298,7 +4501,10 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None shutil.rmtree(details["control"], ignore_errors=True) return - if case == "stalled-observation-reader": + if case in { + "stalled-observation-reader", "initial-scan-failure", + "initial-watched-assertion-failure", + }: unit = "pdd-validator-" + "e" * 32 + ".scope" token = "c" * 32 monkeypatch.setattr(supervisor, "_scope_unit_name", lambda: unit) @@ -4347,24 +4553,64 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None details = None fallback_done = False try: + def exact_control_prefix() -> Path: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + controls = tuple( + path for path in tmp_path.glob("pdd-scope-*") if path.is_dir() + ) + if len(controls) == 1: + return controls[0] + time.sleep(.05) + raise AssertionError("exact stalled-observation control directory unavailable") + def assert_initial_coordinator(scan: dict[str, object]) -> None: assert len(scan["watched"]) == 1 + if case == "initial-watched-assertion-failure": + raise AssertionError("injected initial watched assertion failure") + + def initial_scan() -> dict[str, object]: + scan = _root_proc_scan(watch_pids=(coordinator,)) + if case == "initial-scan-failure": + raise AssertionError("injected initial scan failure") + return scan def early_failure_cleanup() -> None: - nonlocal read_fd, fallback_done - read_fd = _fallback_stalled_observation_cleanup( - unit, coordinator, read_fd, - tuple(sorted(tmp_path.glob("pdd-scope-*"))), + nonlocal details, read_fd, write_fd, fallback_done + control_prefix = exact_control_prefix() + details = capture_live_state( + unit, (), target_prefix=control_prefix, + watch_pids=(coordinator,), ) - fallback_done = True + assert all( + Path(path).is_relative_to(control_prefix) + for path in details["mount_points"] + ) + assert any( + len(Path(path).relative_to(control_prefix).parts) > 1 + for path in details["mount_points"] + ), "hosted injection requires a real nested control mount" + try: + read_fd, write_fd = _fallback_stalled_observation_cleanup( + details, (read_fd, write_fd), + ) + finally: + fallback_done = True + + if case in {"initial-scan-failure", "initial-watched-assertion-failure"}: + with pytest.raises(AssertionError, match="injected initial"): + _run_stalled_observation_setup( + initial_scan, assert_initial_coordinator, early_failure_cleanup, + ) + assert fallback_done and details is not None + assert read_fd == -1 and write_fd == -1 + return _run_stalled_observation_setup( - lambda: _root_proc_scan(watch_pids=(coordinator,)), - assert_initial_coordinator, - early_failure_cleanup, + initial_scan, assert_initial_coordinator, early_failure_cleanup, ) details = capture_live_state( - unit, (), target_prefix=tmp_path / "pdd-scope-", + unit, (), target_prefix=exact_control_prefix(), watch_pids=(coordinator,), ) deadline = time.monotonic() + 30 @@ -4381,16 +4627,21 @@ def early_failure_cleanup() -> None: assert outcome["kind"] == supervisor.TerminationKind.SANDBOX_ERROR.value assert "timed out" in outcome["stderr"] assert outcome["surviving"] is False - except BaseException: + except BaseException as primary: if not fallback_done: - mount_paths = ( - tuple(Path(path) for path in details["targets"]) - if details is not None - else tuple(sorted(tmp_path.glob("pdd-scope-*"))) - ) - read_fd = _fallback_stalled_observation_cleanup( - unit, coordinator, read_fd, mount_paths, - ) + try: + if details is None: + details = capture_live_state( + unit, (), target_prefix=exact_control_prefix(), + watch_pids=(coordinator,), + ) + read_fd, write_fd = _fallback_stalled_observation_cleanup( + details, (read_fd, write_fd), + ) + except BaseException as cleanup: + _cleanup_failure(primary, cleanup) + finally: + fallback_done = True raise finally: if read_fd >= 0: diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py index 4f6cbf7e6f..7c5eab5481 100644 --- a/tests/test_unit_tests_workflow.py +++ b/tests/test_unit_tests_workflow.py @@ -54,6 +54,10 @@ "[reordered-extra]", f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" "[stalled-observation-reader]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[initial-scan-failure]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[initial-watched-assertion-failure]", "tests/test_sync_core_supervisor.py::" "test_simultaneous_high_volume_stdio_has_one_aggregate_bound", ) From 7b4e36a8d81321d45690303727340d813b00e4ec Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 08:55:18 -0700 Subject: [PATCH 180/233] test(sync): bind fallback cleanup ownership --- .github/workflows/unit-tests.yml | 2 + tests/test_sync_core_supervisor.py | 673 +++++++++++++++++++++++++++-- tests/test_unit_tests_workflow.py | 3 + 3 files changed, 643 insertions(+), 35 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 46e79ad529..3fec078b61 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -169,6 +169,7 @@ jobs: command -v bwrap command -v systemd-run command -v unshare + command -v nsenter sudo -n true bwrap --version private_root="$(mktemp -d)" @@ -432,6 +433,7 @@ jobs: tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[stalled-observation-reader] \ tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[initial-scan-failure] \ tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[initial-watched-assertion-failure] \ + tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[fd-only-namespace-holder-cleanup] \ tests/test_sync_core_supervisor.py::test_simultaneous_high_volume_stdio_has_one_aggregate_bound \ --timeout=90 diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 4be97ef093..1e7ed210b6 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -7,6 +7,7 @@ import inspect import json import math +import select import signal import shutil import stat @@ -3530,6 +3531,26 @@ def fd_links(pid_dir): def unescape(value): return value.replace('\\040',' ').replace('\\011','\t').replace('\\134','\\') +def canonical_path(value,label): + if type(value) is not str or not value.startswith('/'): + fail(f'{label} is not an absolute string path: {value!r}') + path=pathlib.PurePosixPath(value) + if str(path)!=value or any(part in ('.','..') for part in path.parts): + fail(f'{label} is not canonical: {value!r}') + return path + +def owned_mount(point,prefix,targets): + path=canonical_path(point,'mount point') + if str(path) in targets: + return True + if prefix is None: + return False + try: + path.relative_to(prefix) + except ValueError: + return False + return True + def mounts(pid_dir): records=[] for line in read_text(pid_dir/'mountinfo',pid_dir,'read mountinfo','utf-8').splitlines(): @@ -3582,8 +3603,17 @@ def cgroup_pids(path): if any(type(pid) is not int or pid<=0 for pid in watched): fail('invalid watched PID payload') expected_namespace=payload.get('namespace') + if expected_namespace is not None and ( + type(expected_namespace) is not dict or + set(expected_namespace)!=set(('link','inode')) or + type(expected_namespace.get('link')) is not str or + type(expected_namespace.get('inode')) is not int): + fail('invalid expected mount namespace payload') targets=set(payload.get('targets',[])) - prefix=payload.get('target_prefix','') + for value in targets: + canonical_path(value,'target') + prefix_value=payload.get('target_prefix','') + prefix=canonical_path(prefix_value,'target prefix') if prefix_value else None identities=[]; cgroup_members=[]; watched_processes=[] current_holders=[]; fd_holders=[]; mount_holders=[] for pid_dir in sorted( @@ -3637,10 +3667,19 @@ def cgroup_pids(path): for fd,link,inode in descriptors: if (link==expected_namespace['link'] and inode==expected_namespace['inode']): - fd_holders.append(record|{'holder_kind':'fd','fd':fd}) + fd_holders.append(record|{ + 'holder_kind':'fd','fd':fd, + 'fd_path':str(pid_dir/'fd'/str(fd)), + 'fd_link':link,'fd_inode':inode, + }) for mount_record in mount_records: point=mount_record['mount_point'] - if point in targets or (prefix and point.startswith(prefix)): + in_expected_namespace=( + expected_namespace is None or + (ns_link==expected_namespace['link'] and + ns_inode==expected_namespace['inode']) + ) + if in_expected_namespace and owned_mount(point,prefix,targets): mount_holders.append(record|mount_record|{'holder_kind':'mount'}) except ProcessLookupError: continue @@ -3700,6 +3739,211 @@ def _process_key(record: dict[str, object]) -> tuple[int, str]: return int(record["pid"]), str(record["start_time"]) +def _canonical_owned_mount_point( + value: str, control_prefix: Path, targets: tuple[Path, ...] = (), +) -> bool: + """Validate one canonical absolute mountpoint and test component containment.""" + if not isinstance(value, str) or not value.startswith("/"): + raise ValueError(f"mount point is not absolute: {value!r}") + point = PurePosixPath(value) + if str(point) != value or any(part in {".", ".."} for part in point.parts): + raise ValueError(f"mount point is not canonical: {value!r}") + prefix = PurePosixPath(str(control_prefix)) + if not prefix.is_absolute() or str(prefix) != str(control_prefix): + raise ValueError(f"control prefix is not canonical: {control_prefix!r}") + exact_targets = {PurePosixPath(str(target)) for target in targets} + if point in exact_targets: + return True + try: + point.relative_to(prefix) + except ValueError: + return False + return True + + +def _exact_namespace_mounts( + scan: dict[str, object], namespace: dict[str, object], control_prefix: Path, + targets: tuple[Path, ...] = (), +) -> tuple[Path, ...]: + """Return only canonical owned mounts reported from the captured namespace.""" + mounts = set() + for record in scan["mount_holders"]: + if ( + record.get("namespace") != namespace["link"] + or record.get("namespace_inode") != namespace["inode"] + ): + continue + value = str(record["mount_point"]) + if _canonical_owned_mount_point(value, control_prefix, targets): + mounts.add(Path(value)) + return tuple(sorted(mounts)) + + +def _namespace_entry_path( + holder: dict[str, object], namespace: dict[str, object], +) -> str: + """Build the exact procfs namespace entry after strict holder-schema checks.""" + pid = holder.get("pid") + start_time = holder.get("start_time") + if type(pid) is not int or pid <= 0 or not str(start_time).isdigit(): + raise ValueError("namespace holder has invalid process identity") + current_link = holder.get("namespace") + current_inode = holder.get("namespace_inode") + if ( + not isinstance(current_link, str) + or not current_link.startswith("mnt:[") or not current_link.endswith("]") + or type(current_inode) is not int or current_inode <= 0 + ): + raise ValueError("namespace holder has invalid current namespace identity") + kind = holder.get("holder_kind") + if kind == "current": + if ( + holder.get("namespace") != namespace.get("link") + or holder.get("namespace_inode") != namespace.get("inode") + ): + raise ValueError("current holder does not match captured namespace") + return f"/proc/{pid}/ns/mnt" + if kind != "fd": + raise ValueError(f"unsupported namespace holder kind: {kind!r}") + descriptor = holder.get("fd") + if type(descriptor) is not int or descriptor < 0: + raise ValueError("FD-only namespace holder has invalid descriptor") + expected_path = f"/proc/{pid}/fd/{descriptor}" + if holder.get("fd_path") != expected_path: + raise ValueError("FD-only namespace holder has wrong descriptor path") + if ( + holder.get("fd_link") != namespace["link"] + or holder.get("fd_inode") != namespace["inode"] + ): + raise ValueError("FD-only namespace holder has wrong descriptor identity") + return expected_path + + +def _holder_key(record: dict[str, object]) -> tuple[object, ...]: + """Return the complete identity of one current or FD-only namespace holder.""" + return ( + record.get("holder_kind"), *_process_key(record), record.get("fd"), + record.get("fd_path"), record.get("fd_link"), record.get("fd_inode"), + record.get("namespace"), + record.get("namespace_inode"), + ) + + +def _select_captured_namespace_holder( + scan: dict[str, object], captured: tuple[dict[str, object], ...], + namespace: dict[str, object], +) -> dict[str, object] | None: + """Select only a still-live holder with the complete previously captured identity.""" + captured_keys = {_holder_key(record) for record in captured} + candidates = [*scan["current_holders"], *scan["fd_holders"]] + for record in candidates: + if _holder_key(record) in captured_keys: + _namespace_entry_path(record, namespace) + return record + return None + + +def _exact_unit_not_found(completed: object) -> bool: + """Accept only canonical successful systemd LoadState=not-found output.""" + return ( + getattr(completed, "returncode", None) == 0 + and getattr(completed, "stdout", None) == "not-found\n" + and getattr(completed, "stderr", None) == "" + ) + + +_NSENTER_REVALIDATOR_SOURCE = r""" +import json,os,pathlib,sys + +payload=json.loads(sys.argv[1]) +holder=payload['holder']; namespace=payload['namespace'] +pid=holder.get('pid'); start_time=holder.get('start_time') +if type(pid) is not int or pid<=0 or type(start_time) is not str: + raise SystemExit('invalid holder process identity') +proc=pathlib.Path('/proc')/str(pid) + +def identity(): + raw=(proc/'stat').read_text(encoding='ascii') + closing=raw.rfind(')'); fields=raw[closing+2:].split() + if closing<2 or len(fields)<=19 or not fields[19].isdigit(): + raise RuntimeError('invalid holder stat') + return fields[19] + +def exact_namespace(path): + link=os.readlink(path); inode=path.stat().st_ino + if link!=namespace['link'] or inode!=namespace['inode']: + raise RuntimeError('holder namespace identity changed') + return str(path) + +if identity()!=start_time: + raise RuntimeError('holder PID was reused') +current_path=proc/'ns'/'mnt' +current_link=os.readlink(current_path); current_inode=current_path.stat().st_ino +if (current_link!=holder.get('namespace') or + current_inode!=holder.get('namespace_inode')): + raise RuntimeError('holder current namespace identity changed') +kind=holder.get('holder_kind') +if kind=='current': + entry=exact_namespace(current_path) +elif kind=='fd': + fd=holder.get('fd') + if type(fd) is not int or fd<0: + raise RuntimeError('invalid namespace descriptor') + entry_path=proc/'fd'/str(fd) + link=os.readlink(entry_path); inode=entry_path.stat().st_ino + if (str(entry_path)!=holder.get('fd_path') or + link!=holder.get('fd_link') or inode!=holder.get('fd_inode') or + link!=namespace['link'] or inode!=namespace['inode']): + raise RuntimeError('namespace descriptor identity changed') + entry=str(entry_path) +else: + raise RuntimeError('invalid namespace holder kind') +if identity()!=start_time: + raise RuntimeError('holder identity raced before namespace entry') +operation=payload.get('operation') +if operation=='unmount': + command=[payload['umount'],payload['mount']] +elif operation=='scan': + command=[payload['python'],'-I','-S','-c',payload['scanner'], + json.dumps(payload['scan_payload'],sort_keys=True)] +else: + raise RuntimeError('invalid namespace operation') +os.execv(payload['nsenter'],[payload['nsenter'],'--mount='+entry,'--',*command]) +""" + + +_NAMESPACE_MOUNT_SCANNER_SOURCE = r""" +import json,pathlib,sys + +payload=json.loads(sys.argv[1]) + +def canonical(value,label): + if type(value) is not str or not value.startswith('/'): + raise RuntimeError(f'{label} is not absolute: {value!r}') + path=pathlib.PurePosixPath(value) + if str(path)!=value or any(part in ('.','..') for part in path.parts): + raise RuntimeError(f'{label} is not canonical: {value!r}') + return path + +prefix=canonical(payload['prefix'],'prefix') +targets={str(canonical(value,'target')) for value in payload['targets']} +mounts=[] +for line in pathlib.Path('/proc/self/mountinfo').read_text(encoding='utf-8').splitlines(): + fields=line.split() + if len(fields)<10 or '-' not in fields or not fields[0].isdigit(): + raise RuntimeError(f'malformed mountinfo: {line!r}') + value=fields[4].replace('\\040',' ').replace('\\011','\t').replace('\\134','\\') + point=canonical(value,'mount point') + try: + point.relative_to(prefix); contained=True + except ValueError: + contained=False + if str(point) in targets or contained: + mounts.append(str(point)) +print(json.dumps(sorted(set(mounts)),separators=(',',':'))) +""" + + _BLOCKED_WCHAN_MARKERS = ("sleep", "wait", "pause", "futex") @@ -3745,7 +3989,15 @@ def _fallback_stalled_observation_cleanup( coordinator = ownership["coordinator"] assert isinstance(namespace, dict) and isinstance(coordinator, dict) expected_coordinator = _process_key(coordinator) - captured_mounts = {Path(path) for path in ownership["mount_points"]} + captured_mounts = { + Path(path) for path in ownership["mount_points"] + if _canonical_owned_mount_point(str(path), control_prefix) + } + holder_records = ownership.get("namespace_holders") + if holder_records is None: + holder_records = (ownership["namespace_holder"],) + captured_holders = tuple(holder_records) + unit_action_failures = [] def remaining() -> float: return deadline - time.monotonic() @@ -3789,9 +4041,21 @@ def scan_owned() -> dict[str, object] | None: if exc.errno != 9: errors.append(f"close fd {descriptor} failed: {exc}") - stopped = command(["sudo", "-n", "systemctl", "stop", unit], "stop exact scope") - if stopped is not None and stopped.returncode != 0: - errors.append(stopped.stderr.strip() or f"cannot stop exact scope {unit}") + for action in ( + ["kill", "--kill-whom=all", "--signal=SIGKILL", unit], + ["stop", unit], + ["reset-failed", unit], + ): + purpose = f"systemctl {action[0]} exact scope" + before = len(errors) + completed = command(["sudo", "-n", "systemctl", *action], purpose) + if len(errors) != before: + unit_action_failures.extend(errors[before:]) + del errors[before:] + elif completed is not None and completed.returncode != 0: + unit_action_failures.append( + completed.stderr.strip() or f"{purpose} returned {completed.returncode}" + ) # Signal only a procfs-confirmed PID/start-time identity; a reused PID is untouchable. scan = scan_owned() @@ -3837,24 +4101,83 @@ def scan_owned() -> dict[str, object] | None: # The scanner sees mounts in the privileged private namespace, including binds/*. scan = scan_owned() if scan is not None: - captured_mounts.update(Path(record["mount_point"]) for record in scan["mount_holders"]) - holders = [] if scan is None else [ - *scan["current_holders"], *scan["fd_holders"] - ] - namespace_holder = next( - (record for record in holders if _process_key(record) != expected_coordinator), - None, - ) or (holders[0] if holders else None) + captured_mounts.update( + _exact_namespace_mounts( + scan, namespace, control_prefix, tuple(captured_mounts) + ) + ) + holders = [] if scan is None else [*scan["current_holders"], *scan["fd_holders"]] + namespace_holder = None if scan is None else _select_captured_namespace_holder( + scan, captured_holders, namespace, + ) + if holders and namespace_holder is None: + errors.append("no live namespace holder matches the complete captured identity") + + def holder_command_payload(operation: str, mount: Path | None = None) -> str: + assert namespace_holder is not None + _namespace_entry_path(namespace_holder, namespace) + nsenter = shutil.which("nsenter") or "/usr/bin/nsenter" + umount = shutil.which("umount") or "/usr/bin/umount" + helper_python = str(supervisor._trusted_tools().helper_python) + return json.dumps({ + "holder": namespace_holder, "namespace": namespace, + "nsenter": nsenter, "umount": umount, "python": helper_python, + "operation": operation, "mount": str(mount) if mount else "", + "scanner": _NAMESPACE_MOUNT_SCANNER_SOURCE, + "scan_payload": { + "prefix": str(control_prefix), + "targets": [str(path) for path in sorted(captured_mounts)], + }, + }, sort_keys=True) + + def holder_mount_scan() -> tuple[Path, ...] | None: + if namespace_holder is None: + return () + payload = holder_command_payload("scan") + completed = command([ + "sudo", "-n", str(supervisor._trusted_tools().helper_python), + "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, payload, + ], "scan exact held mount namespace") + if completed is None or completed.returncode != 0: + if completed is not None: + errors.append( + completed.stderr.strip() or "held mount namespace scan failed" + ) + return None + try: + values = json.loads(completed.stdout) + if not isinstance(values, list) or any( + not isinstance(value, str) for value in values + ): + raise ValueError("mount scan result is not a string list") + mounts = tuple(Path(value) for value in values if + _canonical_owned_mount_point(value, control_prefix)) + except (json.JSONDecodeError, ValueError) as exc: + errors.append(f"held mount namespace scan was malformed: {exc}") + return None + return tuple(sorted(set(mounts))) + + held_mounts = holder_mount_scan() + if held_mounts is not None: + captured_mounts.update(held_mounts) + if ownership.get("require_fd_only_holder") and ( + namespace_holder is None + or namespace_holder.get("holder_kind") != "fd" + or scan is None + or scan["current_holders"] + or not held_mounts + ): + errors.append("exact FD-only namespace mount ownership was not preserved") for mount in sorted( captured_mounts, key=lambda path: (len(path.parts), str(path)), reverse=True, ): if namespace_holder is None: argv = ["sudo", "-n", "umount", str(mount)] else: + payload = holder_command_payload("unmount", mount) argv = [ - "sudo", "-n", "nsenter", - f"--mount=/proc/{namespace_holder['pid']}/ns/mnt", "--", - "umount", str(mount), + "sudo", "-n", str(supervisor._trusted_tools().helper_python), + "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, payload, ] completed = command(argv, f"unmount {mount}") if completed is None or completed.returncode == 0: @@ -3863,13 +4186,48 @@ def scan_owned() -> dict[str, object] | None: if "not mounted" not in diagnostic and "no such file" not in diagnostic: errors.append(diagnostic or f"cannot unmount {mount}") + remaining_held_mounts = holder_mount_scan() + if remaining_held_mounts: + errors.append( + "owned mounts remain in held namespace: " + + ", ".join(str(path) for path in remaining_held_mounts) + ) + + for holder in ownership.get("external_holders", ()): + expected = _process_key(holder) + holder_scan = scanner(watch_pids=(int(holder["pid"]),)) + if any(_process_key(record) == expected for record in holder_scan["watched"]): + try: + os.kill(int(holder["pid"]), signal.SIGTERM) + except ProcessLookupError: + pass + holder_deadline = min(deadline, time.monotonic() + 5) + while time.monotonic() < holder_deadline: + try: + waited, _status = os.waitpid(int(holder["pid"]), os.WNOHANG) + except ChildProcessError: + break + if waited == int(holder["pid"]): + break + time.sleep(.05) + else: + errors.append(f"external namespace holder was not reaped: {expected}") + for reaper in ownership.get("external_reapers", ()): + timeout = min(5, remaining()) + try: + reaper.wait(timeout=max(.01, timeout)) + except subprocess.TimeoutExpired: + reaper.kill() + reaper.wait(timeout=max(.01, min(5, remaining()))) + verification_deadline = min(deadline, time.monotonic() + 8) final_leaks = ["verification did not run"] while time.monotonic() < verification_deadline: scan = scan_owned() - inactive = command( - ["sudo", "-n", "systemctl", "is-active", "--quiet", unit], - "verify exact scope inactive", + load_state = command( + ["sudo", "-n", "systemctl", "show", unit, + "--property=LoadState", "--value"], + "verify exact scope absent", ) fds_open = [] for descriptor in owned_fds: @@ -3883,17 +4241,38 @@ def scan_owned() -> dict[str, object] | None: fds_open.append(f"fd={descriptor}: {exc}") else: fds_open.append(f"fd={descriptor}") - if scan is None or inactive is None: + if scan is None or load_state is None: break current = {_process_key(record) for record in scan["identities"]} coordinator_alive = expected_coordinator in current + external_alive = [ + _process_key(record) for record in ownership.get("external_holders", ()) + if _process_key(record) in current + ] final_leaks = [] - if inactive.returncode not in {3, 4}: - final_leaks.append(f"unit-active-returncode={inactive.returncode}") + if ( + load_state.returncode != 0 or load_state.stderr != "" + or load_state.stdout not in { + "not-found\n", "loaded\n", "inactive\n", "failed\n", + } + ): + final_leaks.append( + "malformed-unit-load-state=" + + repr((load_state.returncode, load_state.stdout, load_state.stderr)) + ) + errors.append(final_leaks[-1]) + break + if not _exact_unit_not_found(load_state): + final_leaks.append( + "unit-load-state=" + + repr((load_state.returncode, load_state.stdout, load_state.stderr)) + ) if scan["cgroup_exists"]: final_leaks.append(f"cgroup={cgroup}") if coordinator_alive: final_leaks.append(f"coordinator={expected_coordinator}") + if external_alive: + final_leaks.append(f"external-holders={external_alive}") if scan["current_holders"]: final_leaks.append("current-namespace-holder") if scan["fd_holders"]: @@ -3908,6 +4287,8 @@ def scan_owned() -> dict[str, object] | None: errors.append("cleanup verification deadline expired") if final_leaks: errors.append("cleanup survivors: " + ", ".join(final_leaks)) + if final_leaks and unit_action_failures: + errors.extend(unit_action_failures) if errors: raise AssertionError("; ".join(errors)) return tuple(-1 for _descriptor in owned_fds) @@ -3928,6 +4309,91 @@ def _run_stalled_observation_setup(scan, assert_watched, failure_cleanup) -> Non def test_root_proc_scanner_source_compiles() -> None: """The hosted privileged scanner must remain valid isolated Python.""" compile(_ROOT_PROC_SCANNER_SOURCE, "", "exec") + compile(_NSENTER_REVALIDATOR_SOURCE, "", "exec") + compile(_NAMESPACE_MOUNT_SCANNER_SOURCE, "", "exec") + + +@pytest.mark.parametrize( + ("returncode", "stdout", "stderr"), + ( + (0, "inactive\n", ""), (0, "failed\n", ""), (0, "loaded\n", ""), + (0, "not-found", ""), (0, "not-found\nextra\n", ""), + (1, "not-found\n", ""), (0, "not-found\n", "warning"), + ), + ids=("inactive", "failed", "loaded", "no-newline", "extra", "status", "stderr"), +) +def test_exact_unit_absence_rejects_noncanonical_load_state( + returncode: int, stdout: str, stderr: str, +) -> None: + """Only exact successful LoadState=not-found is unit-absence evidence.""" + completed = SimpleNamespace( + returncode=returncode, stdout=stdout, stderr=stderr, + ) + assert not _exact_unit_not_found(completed) + + +def test_exact_unit_absence_accepts_only_not_found() -> None: + completed = SimpleNamespace(returncode=0, stdout="not-found\n", stderr="") + assert _exact_unit_not_found(completed) + + +def test_owned_mount_containment_rejects_sibling_escape_and_malformed() -> None: + prefix = Path("/tmp/pdd-scope-abc") + assert _canonical_owned_mount_point("/tmp/pdd-scope-abc/binds/2", prefix) + assert not _canonical_owned_mount_point("/tmp/pdd-scope-abc-extra/binds/2", prefix) + for value in ( + "relative/binds/2", "/tmp/pdd-scope-abc/../escape", + "/tmp/pdd-scope-abc//binds/2", "/tmp/pdd-scope-abc/./binds/2", + ): + with pytest.raises(ValueError): + _canonical_owned_mount_point(value, prefix) + + +def test_exact_namespace_mounts_excludes_other_namespaces() -> None: + namespace = {"link": "mnt:[11]", "inode": 11} + scan = {"mount_holders": [ + {"mount_point": "/tmp/pdd-scope-abc/binds/1", + "namespace": "mnt:[11]", "namespace_inode": 11}, + {"mount_point": "/tmp/pdd-scope-abc/binds/foreign", + "namespace": "mnt:[12]", "namespace_inode": 12}, + ]} + assert _exact_namespace_mounts( + scan, namespace, Path("/tmp/pdd-scope-abc") + ) == (Path("/tmp/pdd-scope-abc/binds/1"),) + + +def test_namespace_holder_selection_rejects_wrong_kind_fd_reuse_and_race() -> None: + namespace = {"link": "mnt:[11]", "inode": 11} + captured = { + "holder_kind": "fd", "pid": 22, "start_time": "100", + "namespace": "mnt:[1]", "namespace_inode": 1, + "fd": 7, "fd_path": "/proc/22/fd/7", + "fd_link": "mnt:[11]", "fd_inode": 11, + } + current = captured | {"holder_kind": "current"} + with pytest.raises(ValueError, match="captured namespace"): + _namespace_entry_path(current, namespace) + current = captured | {"holder_kind": "mount", "namespace": "mnt:[11]", + "namespace_inode": 11} + with pytest.raises(ValueError, match="holder kind"): + _namespace_entry_path(current, namespace) + wrong_fd = captured | {"namespace": "mnt:[11]", "namespace_inode": 11, + "fd_inode": 12} + with pytest.raises(ValueError, match="descriptor identity"): + _namespace_entry_path(wrong_fd, namespace) + with pytest.raises(ValueError, match="descriptor path"): + _namespace_entry_path(captured | {"fd_path": "/proc/22/fd/8"}, namespace) + + exact = captured | {"namespace": "mnt:[11]", "namespace_inode": 11} + reused = exact | {"start_time": "101"} + raced = exact | {"fd": 8} + assert _select_captured_namespace_holder( + {"current_holders": [], "fd_holders": [reused]}, (exact,), namespace, + ) is None + assert _select_captured_namespace_holder( + {"current_holders": [], "fd_holders": [raced]}, (exact,), namespace, + ) is None + assert _namespace_entry_path(exact, namespace) == "/proc/22/fd/7" def test_cleanup_process_identity_rejects_pid_reuse() -> None: @@ -3956,8 +4422,10 @@ def test_stalled_observation_setup_failure_preserves_primary_and_reaps_owned_sta def runner(*args, **_kwargs): commands.append(args[0]) - if args[0][2:4] == ["systemctl", "is-active"]: - return SimpleNamespace(returncode=3, stdout="", stderr="") + if args[0][2:4] == ["systemctl", "stop"]: + return SimpleNamespace(returncode=5, stdout="", stderr="already removed") + if args[0][2:4] == ["systemctl", "show"]: + return SimpleNamespace(returncode=0, stdout="not-found\n", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="") coordinator_record = {"pid": coordinator, "start_time": "unit-test"} @@ -3980,6 +4448,11 @@ def scanner(**_kwargs): "unit": "pdd-validator-test.scope", "cgroup": tmp_path / "cgroup", "control_prefix": tmp_path / "pdd-scope-owned", "namespace": {"link": "mnt:[1]", "inode": 1}, + "namespace_holder": { + "holder_kind": "current", "pid": coordinator, + "start_time": "unit-test", "namespace": "mnt:[1]", + "namespace_inode": 1, + }, "coordinator": coordinator_record, "mount_points": [tmp_path / "pdd-scope-owned" / "binds" / "nested"], } @@ -4006,12 +4479,56 @@ def assert_watched(scan_result): with pytest.raises(ChildProcessError): os.waitpid(coordinator, os.WNOHANG) assert commands == [ + ["sudo", "-n", "systemctl", "kill", "--kill-whom=all", + "--signal=SIGKILL", "pdd-validator-test.scope"], ["sudo", "-n", "systemctl", "stop", "pdd-validator-test.scope"], + ["sudo", "-n", "systemctl", "reset-failed", "pdd-validator-test.scope"], ["sudo", "-n", "umount", str(tmp_path / "pdd-scope-owned" / "binds" / "nested")], - ["sudo", "-n", "systemctl", "is-active", "--quiet", "pdd-validator-test.scope"], + ["sudo", "-n", "systemctl", "show", "pdd-validator-test.scope", + "--property=LoadState", "--value"], ] +def test_stalled_cleanup_load_state_timeout_fails_closed(tmp_path: Path) -> None: + """A bounded exact-unit probe timeout can never prove cleanup success.""" + coordinator = os.fork() + if coordinator == 0: + signal.pause() + os._exit(125) + record = {"pid": coordinator, "start_time": "100"} + calls = 0 + + def scanner(**_kwargs): + nonlocal calls + calls += 1 + return { + "watched": [record] if calls == 1 else [], "mount_holders": [], + "current_holders": [], "fd_holders": [], "identities": [], + "cgroup_exists": False, + } + + def runner(argv, **_kwargs): + if argv[2:4] == ["systemctl", "show"]: + raise subprocess.TimeoutExpired(argv, 5) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + ownership = { + "unit": "pdd-validator-timeout.scope", "cgroup": tmp_path / "cgroup", + "control_prefix": tmp_path / "pdd-scope-owned", "namespace": { + "link": "mnt:[1]", "inode": 1, + }, + "namespace_holder": { + "holder_kind": "current", **record, "namespace": "mnt:[1]", + "namespace_inode": 1, + }, + "coordinator": record, "mount_points": [], + } + with pytest.raises(AssertionError, match="verify exact scope absent timed out"): + _fallback_stalled_observation_cleanup( + ownership, (), runner=runner, scanner=scanner, + ) + + def test_exact_blocked_role_snapshot_rejects_running_and_reused_identity() -> None: """A running role or PID reuse cannot satisfy blocked-role evidence.""" roles = [ @@ -4058,6 +4575,7 @@ def test_exact_blocked_role_snapshot_rejects_running_and_reused_identity() -> No "stalled-observation-reader", "initial-scan-failure", "initial-watched-assertion-failure", + "fd-only-namespace-holder-cleanup", ), ids=( "normal-hierarchy-environment", "parent-exit-before-start", @@ -4072,6 +4590,7 @@ def test_exact_blocked_role_snapshot_rejects_running_and_reused_identity() -> No "stalled-observation-reader", "initial-scan-failure", "initial-watched-assertion-failure", + "fd-only-namespace-holder-cleanup", )) def test_real_linux_playwright_descriptor_exact_chain( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str, @@ -4274,21 +4793,36 @@ def capture_live_state( ] if members and root_mount_holders: holder = root_mount_holders[0] - observed_targets = set(targets) - observed_targets.update( - Path(record["mount_point"]) - for record in scan["mount_holders"] - ) namespace = { "link": holder["namespace"], "inode": holder["namespace_inode"], } + exact_scan = _root_proc_scan( + cgroup=cgroup, namespace=namespace, targets=targets, + target_prefix=target_prefix, watch_pids=watch_pids, + ) + exact_members = exact_scan["cgroup_members"] + exact_member_keys = {_process_key(record) for record in exact_members} + namespace_holders = [ + *exact_scan["current_holders"], *exact_scan["fd_holders"] + ] + if ( + _process_key(holder) not in exact_member_keys + or not namespace_holders + ): + time.sleep(.05) + continue + observed_targets = set(targets) + if target_prefix is not None: + observed_targets.update(_exact_namespace_mounts( + exact_scan, namespace, target_prefix, targets, + )) tracked = { _process_key(record): record - for record in (*members, *scan["watched"]) + for record in (*exact_members, *exact_scan["watched"]) } coordinator_record = next( - (record for record in scan["watched"] + (record for record in exact_scan["watched"] if int(record["pid"]) in watch_pids), None, ) @@ -4303,6 +4837,7 @@ def capture_live_state( "control_prefix": str(target_prefix) if target_prefix else "", "namespace": namespace, "namespace_holder": holder, + "namespace_holders": namespace_holders, "coordinator": coordinator_record, "recorded_identities": list(tracked.values()), } @@ -4503,7 +5038,7 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None if case in { "stalled-observation-reader", "initial-scan-failure", - "initial-watched-assertion-failure", + "initial-watched-assertion-failure", "fd-only-namespace-holder-cleanup", }: unit = "pdd-validator-" + "e" * 32 + ".scope" token = "c" * 32 @@ -4552,6 +5087,7 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None write_fd = -1 details = None fallback_done = False + external_reaper = None try: def exact_control_prefix() -> Path: deadline = time.monotonic() + 10 @@ -4613,6 +5149,66 @@ def early_failure_cleanup() -> None: unit, (), target_prefix=exact_control_prefix(), watch_pids=(coordinator,), ) + if case == "fd-only-namespace-holder-cleanup": + holder_source = r""" +import json,os,signal,sys +target=int(sys.argv[1]) +descriptor=os.open(f'/proc/{target}/ns/mnt',os.O_RDONLY|os.O_CLOEXEC) +raw=open(f'/proc/{os.getpid()}/stat',encoding='ascii').read() +fields=raw[raw.rfind(')')+2:].split() +print(json.dumps({'pid':os.getpid(),'start_time':fields[19], + 'fd':descriptor}),flush=True) +signal.pause() +""" + external_reaper = subprocess.Popen( + [ + "sudo", "-n", str(supervisor._trusted_tools().helper_python), + "-I", "-S", "-c", holder_source, + str(details["namespace_holder"]["pid"]), + ], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, + ) + assert external_reaper.stdout is not None + ready, _, _ = select.select( + [external_reaper.stdout.fileno()], [], [], 5, + ) + assert ready, "external namespace-FD holder did not become ready" + holder_ready = json.loads(external_reaper.stdout.readline()) + details["external_holders"] = [holder_ready] + details["external_reapers"] = [external_reaper] + holder_deadline = time.monotonic() + 8 + holder_record = None + while time.monotonic() < holder_deadline: + holder_scan = _root_proc_scan( + namespace=details["namespace"], + targets=tuple(Path(path) for path in details["mount_points"]), + target_prefix=Path(str(details["control_prefix"])), + watch_pids=(int(holder_ready["pid"]),), + ) + holder_record = next(( + record for record in holder_scan["fd_holders"] + if int(record["pid"]) == int(holder_ready["pid"]) + and int(record["fd"]) == int(holder_ready["fd"]) + and _process_key(record) == _process_key(holder_ready) + ), None) + if holder_record is not None: + assert not any( + int(record["pid"]) == int(holder_ready["pid"]) + for record in holder_scan["current_holders"] + ) + break + time.sleep(.05) + assert holder_record is not None, "exact namespace-FD holder unavailable" + details["namespace_holders"].append(holder_record) + details["require_fd_only_holder"] = True + read_fd, write_fd = _fallback_stalled_observation_cleanup( + details, (read_fd, write_fd), + ) + fallback_done = True + assert external_reaper.poll() is not None + assert read_fd == -1 and write_fd == -1 + return deadline = time.monotonic() + 30 while time.monotonic() < deadline: waited, status = os.waitpid(coordinator, os.WNOHANG) @@ -4652,6 +5248,13 @@ def early_failure_cleanup() -> None: os.waitpid(coordinator, os.WNOHANG) except ChildProcessError: pass + if external_reaper is not None and external_reaper.poll() is None: + external_reaper.terminate() + try: + external_reaper.wait(timeout=5) + except subprocess.TimeoutExpired: + external_reaper.kill() + external_reaper.wait(timeout=5) return program = inventory_program diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py index 7c5eab5481..5accfc542a 100644 --- a/tests/test_unit_tests_workflow.py +++ b/tests/test_unit_tests_workflow.py @@ -58,6 +58,8 @@ "[initial-scan-failure]", f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" "[initial-watched-assertion-failure]", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[fd-only-namespace-holder-cleanup]", "tests/test_sync_core_supervisor.py::" "test_simultaneous_high_volume_stdio_has_one_aggregate_bound", ) @@ -68,6 +70,7 @@ ("command", "-v", "bwrap"), ("command", "-v", "systemd-run"), ("command", "-v", "unshare"), + ("command", "-v", "nsenter"), ("sudo", "-n", "true"), ("bwrap", "--version"), ) From 997fec3b43e9472031812957a6986c350efda3a0 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 09:20:02 -0700 Subject: [PATCH 181/233] test(sync): bound Playwright launch descriptor argv --- tests/test_sync_core_supervisor.py | 100 +++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 1e7ed210b6..5c557d25c0 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -509,6 +509,106 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( compile(plan.helper_source, "", "exec") +def test_playwright_launch_descriptor_bounds_privileged_argv_for_large_aggregate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Large valid Playwright authority is sent once through a bounded launch frame.""" + from pdd.sync_core.runner import ( # pylint: disable=import-outside-toplevel + PlaywrightToolchainRoles, + _playwright_snapshot_aggregate_identity, + _playwright_snapshot_binding_proofs, + _playwright_reporter_source, + ) + + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: () + ) + toolchain = tmp_path / "toolchain" + dependencies = toolchain / "node_modules" + entrypoint = dependencies / "@playwright/test/cli.js" + entrypoint.parent.mkdir(parents=True) + entrypoint.write_text("cli", encoding="utf-8") + launcher = toolchain / "node" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + launcher.chmod(0o755) + browser = toolchain / "browsers" + browser.mkdir() + lockfile = toolchain / "package-lock.json" + lockfile.write_text("{}", encoding="utf-8") + native = toolchain / "native.so" + native.write_bytes(b"native") + reporter = toolchain / "reporter.cjs" + reporter.write_text(_playwright_reporter_source(198), encoding="utf-8") + roles = PlaywrightToolchainRoles( + launcher, entrypoint, dependencies, browser, (native,), lockfile + ) + destination = tmp_path / "phase/node_modules" + proofs = _playwright_snapshot_binding_proofs( + reporter, roles, launcher, destination, roles.native_bindings + ) + reporter_proof = proofs[0] + snapshot = json.loads(reporter_proof.attestation) + snapshot["members"] = [snapshot["members"][0]] + [ + { + "path": f"payload/{index:03d}-" + "x" * 4000, + "kind": "file", "mode": 0o444, "digest": "0" * 64, + "size": 0, "target": None, + } + for index in range(40) + ] + enlarged_attestation = supervisor._canonical_json(snapshot) + assert len(enlarged_attestation.encode("utf-8")) > 131072 + enlarged_proofs = ( + replace(reporter_proof, attestation=enlarged_attestation), *proofs[1:] + ) + _identity, aggregate = _playwright_snapshot_aggregate_identity( + proofs, reporter, roles, launcher, destination, roles.native_bindings + ) + aggregate_payload = json.loads(aggregate.attestation) + aggregate_payload["members"][0]["attestation"] = enlarged_attestation + aggregate_attestation = supervisor._canonical_json(aggregate_payload) + aggregate = replace( + aggregate, attestation=aggregate_attestation, + digest=hashlib.sha256(aggregate_attestation.encode("utf-8")).hexdigest(), + ) + scratch = tmp_path / "scratch" + scratch.mkdir() + read_fd, write_fd = os.pipe() + try: + argv, plan = _sandbox_command( + [str(launcher), str(destination / "@playwright/test/cli.js")], + (scratch,), readable_roots=(reporter, *roles.readable_roots), + readable_bindings=(*roles.native_bindings, (dependencies, destination)), + snapshot_binding_proofs=enlarged_proofs, + playwright_snapshot_aggregate=aggregate, result_write_fd=write_fd, + result_fd=198, observation_nonce="a" * 64, + ) + finally: + os.close(read_fd) + os.close(write_fd) + + assert max(len(value.encode("utf-8")) for value in argv) < 131072 + assert sum(len(value.encode("utf-8")) + 1 for value in argv) < 131072 + frame = supervisor._descriptor_frame( + plan.launch_payload, supervisor._DESCRIPTOR_PROTOCOL_MAX_LAUNCH_BYTES + ) + assert len(frame) > 131072 + assert supervisor._read_descriptor_frame( + io.BytesIO(frame), supervisor._DESCRIPTOR_PROTOCOL_MAX_LAUNCH_BYTES + ) == plan.launch_payload + + def test_root_authority_rejects_saved_pass_observation_for_current_skip( monkeypatch: pytest.MonkeyPatch, ) -> None: From 8932f032564a919d0bf2e04f11c618009c1830e2 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 11:09:39 -0700 Subject: [PATCH 182/233] fix(sync): stream privileged launch authority --- .github/workflows/unit-tests.yml | 28 ++++-- pdd/sync_core/supervisor.py | 106 +++++++++++++-------- tests/test_sync_core_supervisor.py | 146 +++++++++++++++++++---------- tests/test_unit_tests_workflow.py | 8 ++ 4 files changed, 191 insertions(+), 97 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3fec078b61..5491ae582f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -188,6 +188,10 @@ jobs: ) root = Path(os.environ["PRIVATE_ROOT"]) + environment = { + "HOME": str(root / "home"), + "PYTHONNOUSERSITE": "1", + } resolved_interpreter = Path(sys.executable).resolve() print("resolved interpreter:", resolved_interpreter) print(subprocess.run( @@ -215,7 +219,7 @@ jobs: """ result, surviving = run_supervised( [sys.executable, "-c", script], - cwd=root / "scratch", timeout=10, env=dict(os.environ), + cwd=root / "scratch", timeout=10, env=environment, writable_roots=(root / "scratch",), ) assert result.returncode == 0, result.stderr @@ -236,6 +240,10 @@ jobs: from pdd.sync_core.supervisor import SupervisorLimits, run_supervised root = Path(os.environ["ROOT"]) + environment = { + "HOME": str(root / "home"), + "PYTHONNOUSERSITE": "1", + } def assert_no_leak(): units = subprocess.run( ['sudo', '-n', 'systemctl', 'list-units', @@ -274,7 +282,7 @@ jobs: ''' result, surviving = run_supervised( [sys.executable, '-c', program], cwd=root / 'scratch', timeout=30, - env=dict(os.environ), writable_roots=(root / 'scratch',), + env=environment, writable_roots=(root / 'scratch',), limits=SupervisorLimits(max_processes=8), ) assert result.returncode == 0, result.stderr @@ -282,7 +290,7 @@ jobs: assert_no_leak() oom, surviving = run_supervised( [sys.executable, '-c', 'bytearray(512 * 1024 * 1024)'], - cwd=root / 'scratch', timeout=30, env=dict(os.environ), + cwd=root / 'scratch', timeout=30, env=environment, writable_roots=(root / 'scratch',), limits=SupervisorLimits( max_memory_bytes=128 * 1024 * 1024, max_virtual_memory_bytes=2 * 1024 * 1024 * 1024, @@ -295,7 +303,7 @@ jobs: assert_no_leak() tasks, surviving = run_supervised( [sys.executable, '-c', "import subprocess,sys; children=[subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(2)']) for _ in range(16)]; [child.wait() for child in children]"], - cwd=root / 'scratch', timeout=30, env=dict(os.environ), + cwd=root / 'scratch', timeout=30, env=environment, writable_roots=(root / 'scratch',), limits=SupervisorLimits(max_processes=8), ) assert tasks.returncode != 0, tasks.stderr @@ -306,7 +314,7 @@ jobs: assert_no_leak() timed_out, surviving = run_supervised( [sys.executable, '-c', 'import time; time.sleep(30)'], - cwd=root / 'scratch', timeout=1, env=dict(os.environ), + cwd=root / 'scratch', timeout=1, env=environment, writable_roots=(root / 'scratch',), limits=SupervisorLimits(), ) assert timed_out.returncode == 124, timed_out.stderr @@ -315,7 +323,7 @@ jobs: assert_no_leak() output, surviving = run_supervised( [sys.executable, '-c', "print('x' * 200000)"], - cwd=root / 'scratch', timeout=30, env=dict(os.environ), + cwd=root / 'scratch', timeout=30, env=environment, writable_roots=(root / 'scratch',), limits=SupervisorLimits(max_output_bytes=1024), ) @@ -330,7 +338,7 @@ jobs: sparse_root = quota_case('quota-sparse') sparse, surviving = run_supervised( [sys.executable, '-c', "open('sparse','wb').truncate(8*1024*1024)"], - cwd=sparse_root, timeout=30, env=dict(os.environ), + cwd=sparse_root, timeout=30, env=environment, writable_roots=(sparse_root,), limits=quota, ) assert sparse.returncode != 0, sparse.stderr @@ -339,7 +347,7 @@ jobs: aggregate_root = quota_case('quota-aggregate') aggregate, surviving = run_supervised( [sys.executable, '-c', "[open(f'part-{i}','wb').write(b'x'*131072) for i in range(32)]"], - cwd=aggregate_root, timeout=30, env=dict(os.environ), + cwd=aggregate_root, timeout=30, env=environment, writable_roots=(aggregate_root,), limits=quota, ) assert aggregate.returncode != 0, aggregate.stderr @@ -348,7 +356,7 @@ jobs: churn_root = quota_case('quota-churn') churn, surviving = run_supervised( [sys.executable, '-c', "from pathlib import Path; files=[Path(f'churn-{i}') for i in range(16)]; [path.write_bytes(b'x'*131072) for path in files]; [path.unlink() for path in files]"], - cwd=churn_root, timeout=30, env=dict(os.environ), + cwd=churn_root, timeout=30, env=environment, writable_roots=(churn_root,), limits=quota, ) assert churn.returncode != 0, churn.stderr @@ -357,7 +365,7 @@ jobs: within_root = quota_case('quota-within') within, surviving = run_supervised( [sys.executable, '-c', "open('within','wb').write(b'x'*65536)"], - cwd=within_root, timeout=30, env=dict(os.environ), + cwd=within_root, timeout=30, env=environment, writable_roots=(within_root,), limits=quota, ) assert within.returncode == 0, within.stderr diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 794d98b620..6aea3bb12e 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -45,6 +45,7 @@ _CANDIDATE_IDENTITY_SCHEMA = "pdd-candidate-identity-v1" _DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES = 4096 _DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES = 48 * 1024 * 1024 +_DESCRIPTOR_PROTOCOL_MAX_LAUNCH_BYTES = 48 * 1024 * 1024 _MAX_CANDIDATE_ENVIRONMENT_BYTES = 128 * 1024 _MAX_CANDIDATE_ENVIRONMENT_ENTRIES = 128 _MAX_CANDIDATE_ENVIRONMENT_KEY_BYTES = 128 @@ -305,6 +306,7 @@ class _ScopePlan: staging_targets: tuple[Path, ...] tools: _TrustedTools immutable_binding_proofs: tuple[str, ...] = () + launch_payload: dict[str, object] | None = None @dataclass(frozen=True) @@ -1875,18 +1877,7 @@ def _staged_bwrap( raise RuntimeError("protected sandbox has ambiguous observation staging") helper = "\n".join(( "import base64,hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,threading,time", - "if len(sys.argv)!=13: raise RuntimeError('invalid protected helper protocol')", - "control=pathlib.Path(sys.argv[1]); mount=sys.argv[2]; umount=sys.argv[3]", - "candidate_identity=sys.argv[4]; proof_records=json.loads(sys.argv[5])", - "writable_roots=[pathlib.Path(value) for value in json.loads(sys.argv[6])]", - "writable_specs=json.loads(sys.argv[7])", - "path_tokens=json.loads(sys.argv[8]); " - "argv=json.loads(sys.argv[9]); paths=json.loads(sys.argv[10])", - "fifo_indices=json.loads(sys.argv[11])", - "limits=json.loads(sys.argv[12]); observation_nonce=limits.get('observation_nonce'); descriptor_protocol=limits.get('descriptor_protocol'); termination_token=limits.get('termination_token')", - "if type(descriptor_protocol) is not bool: raise RuntimeError('invalid descriptor transport mode')", - "if type(termination_token) is not str or len(termination_token)!=32 or any(value not in '0123456789abcdef' for value in termination_token): raise RuntimeError('invalid nested termination token')", - "if descriptor_protocol and (type(observation_nonce) is not str or len(observation_nonce)!=64 or any(value not in '0123456789abcdef' for value in observation_nonce)): raise RuntimeError('invalid descriptor protocol nonce')", + "if len(sys.argv)!=1: raise RuntimeError('invalid protected helper protocol')", "protocol_in=sys.stdin.buffer; protocol_in_fd=sys.stdin.fileno(); protocol_out_fd=sys.stdout.fileno()", "def protocol_send(payload,maximum,deadline):", " encoded=json.dumps(payload,sort_keys=True,separators=(',',':')).encode('utf-8')", @@ -1933,6 +1924,21 @@ def _staged_bwrap( " ready,_,_=select.select((protocol_in_fd,),(),(),remaining)", " if not ready: raise RuntimeError('protected descriptor terminal EOF timed out')", " if os.read(protocol_in_fd,1)!=b'': raise RuntimeError('protected descriptor control has trailing data')", + "launch=protocol_receive(" + str(_DESCRIPTOR_PROTOCOL_MAX_LAUNCH_BYTES) + ",time.monotonic()+" + str(_TRUSTED_SETUP_SECONDS) + ")", + "expected_launch={'schema','control','candidate_identity','proof_records','writable_roots','writable_specs','path_tokens','argv','paths','fifo_indices','limits'}", + "if type(launch) is not dict or set(launch)!=expected_launch or launch.get('schema')!='pdd-sandbox-launch-v1': raise RuntimeError('protected launch descriptor is invalid')", + "control_value=launch['control']; candidate_identity=launch['candidate_identity']; proof_records=launch['proof_records']; writable_root_values=launch['writable_roots']; writable_specs=launch['writable_specs']; path_tokens=launch['path_tokens']; argv=launch['argv']; paths=launch['paths']; fifo_indices=launch['fifo_indices']; limits=launch['limits']", + "if type(control_value) is not str or not control_value or type(candidate_identity) is not str or type(proof_records) is not list or type(writable_root_values) is not list or type(writable_specs) is not list or type(path_tokens) is not list or type(argv) is not list or type(paths) is not list or type(fifo_indices) is not list or type(limits) is not dict: raise RuntimeError('protected launch descriptor is invalid')", + "if any(type(value) is not str for value in proof_records+writable_root_values+path_tokens+argv+paths) or any(type(value) is not int or type(value) is bool for value in fifo_indices): raise RuntimeError('protected launch descriptor is invalid')", + "if any(type(value) is not list or len(value)!=3 or type(value[0]) is not str or type(value[1]) is not int or type(value[1]) is bool or type(value[2]) is not str for value in writable_specs): raise RuntimeError('protected launch descriptor is invalid')", + "expected_limits={'memory','pids','writable','observation','staging','timeout','trusted_timeout','observation_nonce','termination_token','descriptor_protocol','protocol','output'}", + "if set(limits)!=expected_limits or any(type(limits[key]) is not int or type(limits[key]) is bool or limits[key]<=0 for key in {'memory','pids','writable','observation','staging','trusted_timeout','protocol','output'}) or type(limits['timeout']) not in {int,float} or isinstance(limits['timeout'],bool) or not math.isfinite(limits['timeout']) or limits['timeout']<=0: raise RuntimeError('protected launch descriptor is invalid')", + "control=pathlib.Path(control_value); writable_roots=[pathlib.Path(value) for value in writable_root_values]", + "observation_nonce=limits.get('observation_nonce'); descriptor_protocol=limits.get('descriptor_protocol'); termination_token=limits.get('termination_token')", + "if type(descriptor_protocol) is not bool: raise RuntimeError('invalid descriptor transport mode')", + "if type(termination_token) is not str or len(termination_token)!=32 or any(value not in '0123456789abcdef' for value in termination_token): raise RuntimeError('invalid nested termination token')", + "if descriptor_protocol and (type(observation_nonce) is not str or len(observation_nonce)!=64 or any(value not in '0123456789abcdef' for value in observation_nonce)): raise RuntimeError('invalid descriptor protocol nonce')", + "if not descriptor_protocol: protocol_expect_eof(time.monotonic()+limits['trusted_timeout'])", "authority=control/'authority'", "playwright_record=''; anonymous_observation=False", "tool_manifest=json.loads(" + repr(tool_manifest) + ")", @@ -1960,6 +1966,7 @@ def _staged_bwrap( "subprocess.run=trusted_run", "if set(tool_manifest)!={'bwrap','mount','setpriv','sudo','systemctl','systemd-run','umount','unshare','python'}: raise RuntimeError('invalid protected executable manifest')", "for tool_name in tool_manifest: verify_tool(tool_name)", + "mount=verify_tool('mount'); umount=verify_tool('umount')", "targets=[control/'binds'/str(index) for index in range(len(paths))]", "staged=[]; result=None; timed_out=False; cleanup_error=None; pid=None", "observation_read=None; observation_write=None; observation_thread=None", @@ -2378,11 +2385,39 @@ def _staged_bwrap( "raise SystemExit(result if result is not None and result >= 0 else " "(128-result if result is not None else 125))", )) + launch_payload = { + "schema": "pdd-sandbox-launch-v1", + "control": str(control_directory), + "candidate_identity": candidate_identity, + "proof_records": list((*immutable_binding_proofs, *snapshot_binding_proofs, *( + (playwright_aggregate_record,) if playwright_aggregate_record else () + ))), + "writable_roots": [str(path) for path in writable_roots], + "writable_specs": [list(spec) for spec in writable_specs], + "path_tokens": list(path_tokens), + "argv": list(argv), + "paths": [str(path) for path in sources], + "fifo_indices": list(fifo_source_indices), + "limits": { + "memory": limits.max_memory_bytes, "pids": limits.max_processes, + "writable": limits.max_writable_bytes, + "observation": 16 * 1024 * 1024, + "staging": staging_bytes, + "timeout": candidate_timeout, + "trusted_timeout": _TRUSTED_COMMAND_SECONDS, + "observation_nonce": observation_nonce, + "termination_token": os.urandom(16).hex(), + "descriptor_protocol": descriptor_protocol, + "protocol": _DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES, + "output": limits.max_output_bytes, + }, + } + _descriptor_frame(launch_payload, _DESCRIPTOR_PROTOCOL_MAX_LAUNCH_BYTES) plan = _ScopePlan( unit_name, control_directory, helper, tuple(argv), tuple(sources), (authority_root, staging_root, *source_targets, control_directory / "binds" / "writable", control_directory / "binds" / "cgroup"), tools, - immutable_binding_proofs, + immutable_binding_proofs, launch_payload, ) command = [ str(tools.sudo), "-n", str(tools.systemd_run), "--scope", "--quiet", @@ -2391,27 +2426,7 @@ def _staged_bwrap( "--property=TasksMax=infinity", "--property=OOMPolicy=continue", "--property=KillMode=control-group", "--", str(tools.unshare), "--mount", "--propagation", "private", "--wd", "/", - str(tools.helper_python), "-I", "-S", "-c", helper, str(control_directory), - str(tools.mount), str(tools.umount), - candidate_identity, - json.dumps(list((*immutable_binding_proofs, *snapshot_binding_proofs, *( - (playwright_aggregate_record,) if playwright_aggregate_record else () - )))), - json.dumps([str(path) for path in writable_roots]), json.dumps(writable_specs), - json.dumps(path_tokens), json.dumps(argv), - json.dumps([str(path) for path in sources]), - json.dumps(fifo_source_indices), - json.dumps({"memory": limits.max_memory_bytes, "pids": limits.max_processes, - "writable": limits.max_writable_bytes, - "observation": 16 * 1024 * 1024, - "staging": staging_bytes, - "timeout": candidate_timeout, - "trusted_timeout": _TRUSTED_COMMAND_SECONDS, - "observation_nonce": observation_nonce, - "termination_token": os.urandom(16).hex(), - "descriptor_protocol": descriptor_protocol, - "protocol": _DESCRIPTOR_PROTOCOL_MAX_RESULT_BYTES, - "output": limits.max_output_bytes}), + str(tools.helper_python), "-I", "-S", "-c", helper, ] return command, plan @@ -3379,6 +3394,11 @@ def read_stderr(stream) -> None: stderr_reader = threading.Thread(target=read_stderr, args=(process.stderr,), daemon=True) stderr_reader.start() phase = "scope-setup" + _write_descriptor_frame_fd( + process.stdin.fileno(), plan.launch_payload or {}, + _DESCRIPTOR_PROTOCOL_MAX_LAUNCH_BYTES, + time.monotonic() + _TRUSTED_SETUP_SECONDS, + ) ready = _read_descriptor_frame_fd( process.stdout.fileno(), _DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, time.monotonic() + _TRUSTED_SETUP_SECONDS, @@ -3648,7 +3668,8 @@ def record_events() -> None: try: _revalidate_trusted_tools(plan.tools) process = subprocess.Popen( - argv, cwd=Path("/"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + argv, cwd=Path("/"), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=_privileged_helper_environment(), start_new_session=True, ) except OSError as exc: @@ -3664,7 +3685,7 @@ def record_events() -> None: command, f"protected supervisor phase=launch: {exc}" ) - assert process.stdout is not None and process.stderr is not None + assert process.stdin is not None and process.stdout is not None and process.stderr is not None output_threads = [ threading.Thread( target=drain_output, args=(process.stdout, stdout_buffer), daemon=True @@ -3676,9 +3697,18 @@ def record_events() -> None: for output_thread in output_threads: output_thread.start() - setup_deadline = time.monotonic() + _TRUSTED_SETUP_SECONDS - phase = "scope-setup" try: + phase = "launch-handoff" + if plan.launch_payload is None: + raise RuntimeError("protected launch descriptor is unavailable") + _write_descriptor_frame_fd( + process.stdin.fileno(), plan.launch_payload, + _DESCRIPTOR_PROTOCOL_MAX_LAUNCH_BYTES, + time.monotonic() + _TRUSTED_SETUP_SECONDS, + ) + process.stdin.close() + setup_deadline = time.monotonic() + _TRUSTED_SETUP_SECONDS + phase = "scope-setup" while not (control / "ready").exists(): if process.poll() is not None: raise RuntimeError("protected scope exited before verification") diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 5c557d25c0..f5940cf701 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -466,7 +466,7 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( scratch.mkdir() read_fd, write_fd = os.pipe() try: - argv, plan = _sandbox_command( + _argv, plan = _sandbox_command( [str(launcher), str(destination / "@playwright/test/cli.js")], (scratch,), readable_roots=(reporter, *roles.readable_roots), @@ -481,12 +481,13 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( os.close(read_fd) os.close(write_fd) - records = [json.loads(value) for value in json.loads(argv[-8])] + assert plan.launch_payload is not None + records = [json.loads(value) for value in plan.launch_payload["proof_records"]] aggregate_record = next( record for record in records if record["schema"] == "pdd-playwright-snapshot-aggregate-record-v1" ) - bwrap = json.loads(argv[-4]) + bwrap = list(plan.bwrap_argv) assert aggregate_record["accepted_toolchain_identity"] == aggregate.accepted_toolchain_identity assert aggregate_record["expected_digest"] == aggregate.digest assert {member["role"] for member in aggregate_record["members"]} >= { @@ -509,6 +510,7 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( compile(plan.helper_source, "", "exec") +# pylint: disable=too-many-locals,too-many-statements,protected-access def test_playwright_launch_descriptor_bounds_privileged_argv_for_large_aggregate( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1078,7 +1080,7 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( argv, plan = _sandbox_command(["/bin/true"], (tmp_path,)) assert plan.unit_name.startswith("pdd-validator-") assert argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] - bwrap = json.loads(argv[-4]) + bwrap = plan.bwrap_argv assert {"--unshare-pid", "--unshare-net", "--unshare-cgroup"} <= set(bwrap) assert "--unshare-user" not in bwrap separator = bwrap.index("--") @@ -1086,7 +1088,8 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( assert bwrap[bwrap.index("--reuid") + 1] == "1234" assert bwrap[bwrap.index("--regid") + 1] == "2345" assert bwrap.index("--proc") < separator - assert bwrap[bwrap.index("--ro-bind") + 1] in json.loads(argv[-5]) + assert plan.launch_payload is not None + assert bwrap[bwrap.index("--ro-bind") + 1] in plan.launch_payload["path_tokens"] def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( @@ -1107,18 +1110,19 @@ def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( copied.write_bytes(b"descriptor-bound") manifest_destination = Path("/opt/node/lib/libnode.so") - argv, _profile = _sandbox_command( + _argv, plan = _sandbox_command( ["/bin/true"], (tmp_path,), readable_bindings=((copied, manifest_destination),), ) - bwrap = json.loads(argv[-4]) - sources = json.loads(argv[-3]) + bwrap = plan.bwrap_argv + sources = [str(path) for path in plan.sources] destination_index = bwrap.index(str(manifest_destination)) assert bwrap[destination_index - 2] == "--ro-bind" placeholder = bwrap[destination_index - 1] - tokens = json.loads(argv[-5]) + assert plan.launch_payload is not None + tokens = plan.launch_payload["path_tokens"] assert sources[tokens.index(placeholder)] == str(copied.resolve()) @@ -1139,20 +1143,21 @@ def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( scratch = tmp_path / "scratch" scratch.mkdir() - argv, _profile = _sandbox_command( + _argv, plan = _sandbox_command( ["/bin/true"], (scratch,), writable_bindings=((scratch, Path("/tmp")),), ) - bwrap = json.loads(argv[-4]) - sources = json.loads(argv[-3]) + bwrap = plan.bwrap_argv + sources = [str(path) for path in plan.sources] destination_index = len(bwrap) - 1 - bwrap[::-1].index("/tmp") assert bwrap[destination_index - 2] == "--bind" assert destination_index < bwrap.index("--ro-bind") placeholder = bwrap[destination_index - 1] - writable_roots = json.loads(argv[-7]) - writable_specs = json.loads(argv[-6]) + assert plan.launch_payload is not None + writable_roots = plan.launch_payload["writable_roots"] + writable_specs = plan.launch_payload["writable_specs"] token, root_index, relative = next( spec for spec in writable_specs if spec[0] == placeholder ) @@ -1179,14 +1184,14 @@ def test_linux_sandbox_deduplicates_identical_read_only_bindings( native = tmp_path / "native.so" native.write_bytes(b"native") - argv, _profile = _sandbox_command( + _argv, plan = _sandbox_command( ["/bin/true"], (tmp_path,), readable_roots=(native, native), readable_bindings=((native, native),), ) - bwrap = json.loads(argv[-4]) + bwrap = plan.bwrap_argv assert bwrap.count(str(native)) == 1 @@ -1245,12 +1250,12 @@ def test_linux_sandbox_mounts_nested_declared_toolchain_after_phase_root( lambda *_args: (phase_root,), ) - argv, _plan = _sandbox_command( + _argv, plan = _sandbox_command( ["/bin/true"], (tmp_path,), cwd=phase_root, readable_bindings=((dependency_source, dependency_destination),), ) - bwrap = json.loads(argv[-4]) + bwrap = plan.bwrap_argv def mount_index(destination: Path) -> int: return next( @@ -1287,18 +1292,19 @@ def test_linux_sandbox_allows_descriptor_proven_copied_loader_at_inferred_runtim assert json.loads(proof.descriptor_attestation)["adapter"] == "vitest" - argv, _profile = _sandbox_command( + _argv, plan = _sandbox_command( ["/bin/true"], (tmp_path,), readable_bindings=((copied_loader, host_loader),), immutable_binding_proofs=(proof,), ) - bwrap = json.loads(argv[-4]) - sources = json.loads(argv[-3]) + bwrap = plan.bwrap_argv + sources = [str(path) for path in plan.sources] destination_index = bwrap.index(str(host_loader)) assert bwrap.count(str(host_loader)) == 1 - assert sources[json.loads(argv[-5]).index(bwrap[destination_index - 1])] == str( + assert plan.launch_payload is not None + assert sources[plan.launch_payload["path_tokens"].index(bwrap[destination_index - 1])] == str( copied_loader.resolve() ) @@ -1365,18 +1371,19 @@ def test_linux_sandbox_coalesces_descriptor_proven_loader_aliases( "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) ) - argv, _profile = _sandbox_command( + _argv, plan = _sandbox_command( ["/bin/true"], (tmp_path,), readable_bindings=tuple((copied, host_loader) for copied in copied_loaders), immutable_binding_proofs=proofs, ) - bwrap = json.loads(argv[-4]) - sources = json.loads(argv[-3]) + bwrap = plan.bwrap_argv + sources = [str(path) for path in plan.sources] destination_index = bwrap.index(str(host_loader)) assert bwrap.count(str(host_loader)) == 1 - assert sources[json.loads(argv[-5]).index(bwrap[destination_index - 1])] == str( + assert plan.launch_payload is not None + assert sources[plan.launch_payload["path_tokens"].index(bwrap[destination_index - 1])] == str( copied_loaders[0].resolve() ) @@ -1562,7 +1569,7 @@ def test_linux_sandbox_helper_stages_descriptor_mode_for_candidate( protected.chmod(mode) copied.chmod(mode) proof = _descriptor_runtime_proof(copied, protected, native_mode=mode) - argv, plan = _sandbox_command( + _argv, plan = _sandbox_command( ["/bin/true"], (tmp_path,), readable_bindings=((copied, protected),), @@ -1580,8 +1587,9 @@ def test_linux_sandbox_helper_stages_descriptor_mode_for_candidate( monkeypatch.setenv("SUDO_GID", str(candidate_gid)) monkeypatch.setattr(os, "geteuid", lambda: 0) monkeypatch.setattr(os, "getegid", lambda: 0) + assert plan.launch_payload is not None validated_uid, validated_gid = namespace["_validated_candidate_identity"]( - argv[-9], json.loads(argv[-4]) + plan.launch_payload["candidate_identity"], list(plan.bwrap_argv) ) assert namespace["_stage_immutable_snapshot"]( @@ -1595,7 +1603,7 @@ def test_linux_sandbox_helper_stages_descriptor_mode_for_candidate( assert target.read_bytes() == payload if executable: assert subprocess.run([target], check=False).returncode == 0 - bwrap = json.loads(argv[-4]) + bwrap = plan.bwrap_argv destination_index = bwrap.index(str(protected)) assert bwrap[destination_index - 2] == "--ro-bind" assert "mode=0700,nosuid,nodev" in plan.helper_source @@ -1622,13 +1630,13 @@ def test_linux_sandbox_helper_rejects_forged_candidate_identity( """Candidate ownership is bound to root invocation and the exact UID drop.""" protected, copied = _mock_runtime_collision(tmp_path, monkeypatch) proof = _descriptor_runtime_proof(copied, protected) - command, _plan = _sandbox_command( + _command, plan = _sandbox_command( ["/bin/true"], (tmp_path,), readable_bindings=((copied, protected),), immutable_binding_proofs=(proof,), ) - bwrap = json.loads(command[-4]) + bwrap = list(plan.bwrap_argv) if argv_mutation: bwrap[bwrap.index("--reuid") + 1] = "9999" namespace = {} @@ -1979,24 +1987,25 @@ def test_linux_sandbox_uses_portable_framework_observation_fifo( scratch.mkdir(mode=0o700) fifo = channel / "checker.fifo" os.mkfifo(fifo) - argv, plan = _sandbox_command( + _argv, plan = _sandbox_command( ["/bin/true"], (scratch,), result_fifo=fifo, result_fd=198 ) assert plan.unit_name.startswith("pdd-validator-") - assert argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] - assert "-C" not in argv[:6] - bwrap = json.loads(argv[-4]) + assert _argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] + assert "-C" not in _argv[:6] + bwrap = list(plan.bwrap_argv) preserve_index = bwrap.index("--preserve-fds") assert bwrap[preserve_index + 1] == "1" observation_path = "/run/pdd-framework-observation" observation_index = bwrap.index(observation_path) assert bwrap[observation_index - 2] == "--bind" observation_token = bwrap[observation_index - 1] - tokens = json.loads(argv[-5]) - sources = json.loads(argv[-3]) + assert plan.launch_payload is not None + tokens = plan.launch_payload["path_tokens"] + sources = [str(path) for path in plan.sources] assert sources[tokens.index(observation_token)] == str(fifo.resolve()) - assert json.loads(argv[-2]) == [tokens.index(observation_token)] + assert plan.launch_payload["fifo_indices"] == [tokens.index(observation_token)] assert "stat.S_ISFIFO(metadata.st_mode)" in plan.helper_source assert "os.mkfifo(target,mode=0o600)" in plan.helper_source assert str(channel) not in bwrap @@ -2030,12 +2039,13 @@ def test_linux_sandbox_does_not_authorize_declared_fifo_staging( candidate_fifo = tmp_path / "candidate.fifo" os.mkfifo(candidate_fifo) - argv, _plan = _sandbox_command( + _argv, plan = _sandbox_command( ["/bin/true"], (scratch,), readable_bindings=((candidate_fifo, Path("/run/candidate.fifo")),), ) - assert json.loads(argv[-2]) == [] + assert plan.launch_payload is not None + assert plan.launch_payload["fifo_indices"] == [] def _mock_scope_run( @@ -2053,6 +2063,7 @@ def sandbox(_command, _writable_roots, **kwargs): plan = SimpleNamespace( unit_name="pdd-validator-00000000000000000000000000000000.scope", tools=SimpleNamespace(), + launch_payload={}, ) return [sys.executable, "-c", helper, str(kwargs["control_directory"])], plan @@ -2105,6 +2116,31 @@ def _terminal_helper( """ +def test_launch_descriptor_write_failure_stops_scope_and_cleans_staging( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed initial handoff remains inside the protected cleanup lifecycle.""" + cleanup = _mock_scope_run( + tmp_path, monkeypatch, "import time; time.sleep(30)" + ) + monkeypatch.setattr( + supervisor, "_write_descriptor_frame_fd", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("launch write failed") + ), + ) + + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, + env={}, writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert surviving is False + assert "phase=launch-handoff: launch write failed" in result.stderr + assert cleanup == ["scope", "mounts"] + + def test_authority_directory_replacement_is_detected_before_relay( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2490,8 +2526,8 @@ def test_sandbox_directory_bind_provides_parent_for_nested_file( "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () ) - argv, _profile = _sandbox_command([str(interpreter)], (tmp_path,), cwd=tmp_path) - bwrap = json.loads(argv[-4]) + _argv, plan = _sandbox_command([str(interpreter)], (tmp_path,), cwd=tmp_path) + bwrap = plan.bwrap_argv directory_targets = { bwrap[index + 1] for index, value in enumerate(bwrap[:-1]) if value == "--dir" @@ -2540,17 +2576,18 @@ def test_sandbox_binds_resolved_runtime_sources_at_original_destinations( ), ) - argv, _profile = _sandbox_command( + _argv, plan = _sandbox_command( [str(candidate), "-c", "pass"], (workdir,), cwd=workdir ) - bwrap = json.loads(argv[-4]) - sources = json.loads(argv[-3]) + bwrap = plan.bwrap_argv + sources = [str(path) for path in plan.sources] def bind_source(destination: Path) -> str: index = bwrap.index(str(destination)) assert bwrap[index - 2] == "--ro-bind" placeholder = bwrap[index - 1] - tokens = json.loads(argv[-5]) + assert plan.launch_payload is not None + tokens = plan.launch_payload["path_tokens"] return sources[tokens.index(placeholder)] assert str(executable_destination.parent) in { @@ -2708,7 +2745,8 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( assert "candidate-start.json" not in helper assert supervisor._PIDFD_PROTOCOL_SOURCE.strip() in helper assert "timeout=limits['trusted_timeout']" in helper - assert json.loads(argv[-1])["timeout"] == 17 + assert plan.launch_payload is not None + assert plan.launch_payload["limits"]["timeout"] == 17 assert "result.json" in helper and "finish" in helper assert "candidate cgroup remained populated" in helper assert "candidate_cgroup.rmdir()" in helper @@ -3369,6 +3407,7 @@ def sandbox(_command, _writable_roots, **kwargs): plan = SimpleNamespace( unit_name="pdd-validator-00000000000000000000000000000000.scope", tools=SimpleNamespace(), + launch_payload={}, ) return [sys.executable, "-c", helper, str(kwargs["control_directory"])], plan @@ -4812,6 +4851,12 @@ def launch(program: str, arguments: tuple[str, ...] = ()): start_new_session=True, ) assert process.stdin is not None and process.stdout is not None + assert plan.launch_payload is not None + supervisor._write_descriptor_frame_fd( + process.stdin.fileno(), plan.launch_payload, + supervisor._DESCRIPTOR_PROTOCOL_MAX_LAUNCH_BYTES, + time.monotonic() + 30, + ) ready = supervisor._read_descriptor_frame_fd( process.stdout.fileno(), supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, time.monotonic() + 30, @@ -5605,8 +5650,10 @@ def test_playwright_descriptor_transport_timeout_fails_closed( (cgroup / "pids.events").write_text("max 0\n", encoding="ascii") helper = ( "import json,sys,time;" - "payload=json.dumps({'kind':'ready','nonce':'aa'*32},sort_keys=True,separators=(',',':')).encode();" - "sys.stdout.buffer.write(len(payload).to_bytes(4,'big')+payload);sys.stdout.buffer.flush();" + "payload=json.dumps({'kind':'ready','nonce':'aa'*32},sort_keys=True," + "separators=(',',':')).encode();" + "sys.stdout.buffer.write(len(payload).to_bytes(4,'big')+payload);" + "sys.stdout.buffer.flush();" "sys.stdin.buffer.read(4096);time.sleep(30)" ) @@ -5614,6 +5661,7 @@ def sandbox(_command, _roots, **_kwargs): return [sys.executable, "-c", helper], SimpleNamespace( unit_name="pdd-validator-00000000000000000000000000000000.scope", tools=SimpleNamespace(), + launch_payload={}, ) monkeypatch.setattr(supervisor, "_sandbox_command", sandbox) diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py index 5accfc542a..59f2fecce8 100644 --- a/tests/test_unit_tests_workflow.py +++ b/tests/test_unit_tests_workflow.py @@ -216,6 +216,14 @@ def test_unit_tests_requires_complete_privileged_descriptor_matrix() -> None: _assert_hosted_linux_contract(_workflow()) +def test_unit_tests_protected_smokes_use_credential_free_environment() -> None: + """Hosted protected setup never forwards the runner's ambient credentials.""" + workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") + + assert "env=dict(os.environ)" not in workflow_text + assert workflow_text.count("env=environment") == 10 + + @pytest.mark.parametrize( "mutate", ( From 5ae63f9bb3f31696f6d91c625a1362dc9d59b71a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 11:24:51 -0700 Subject: [PATCH 183/233] test(sync): enforce launch frame lifecycle --- tests/test_sync_core_supervisor.py | 87 ++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index f5940cf701..b186b25f89 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -2060,12 +2060,15 @@ def _mock_scope_run( cleanup: list[str] = [] def sandbox(_command, _writable_roots, **kwargs): + control = str(kwargs["control_directory"]) plan = SimpleNamespace( unit_name="pdd-validator-00000000000000000000000000000000.scope", tools=SimpleNamespace(), - launch_payload={}, + launch_payload={ + "schema": "pdd-sandbox-launch-v1", "control": control, + }, ) - return [sys.executable, "-c", helper, str(kwargs["control_directory"])], plan + return [sys.executable, "-c", _launch_aware_mock_helper(helper)], plan monkeypatch.setattr(supervisor, "_sandbox_command", sandbox) monkeypatch.setattr(supervisor, "_prepare_staging", lambda _plan: None) @@ -2086,6 +2089,25 @@ def sandbox(_command, _writable_roots, **kwargs): return cleanup +def _launch_aware_mock_helper(helper: str) -> str: + """Require launch-frame/EOF ordering before running a file-control fixture.""" + return f"""import json,sys +stream=sys.stdin.buffer +header=stream.read(4) +if len(header)!=4: raise RuntimeError('mock launch header is partial') +size=int.from_bytes(header,'big') +if not 0 None: + """The file-control lifecycle starts only after one canonical launch and EOF.""" + cleanup = _mock_scope_run( + tmp_path, monkeypatch, _terminal_helper(0, False, output="launch-ok") + ) + + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, + env={}, writable_roots=(tmp_path,), + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "launch-ok" + assert surviving is False + assert cleanup == ["scope", "mounts"] + + +@pytest.mark.parametrize("case", ("partial", "oversized", "trailing", "duplicate")) +def test_invalid_launch_descriptor_stops_scope_and_cleans_staging( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str, +) -> None: + """Malformed initial frames never reach ready and retain exact cleanup.""" + cleanup = _mock_scope_run( + tmp_path, monkeypatch, _terminal_helper(0, False) + ) + + def malformed_write(descriptor, payload, maximum, _deadline): + valid = supervisor._descriptor_frame(payload, maximum) + if case == "partial": + data = b"\x00\x00\x00\x08{}" + elif case == "oversized": + data = (maximum + 1).to_bytes(4, "big") + elif case == "trailing": + data = valid + b"x" + else: + data = valid + valid + os.write(descriptor, data) + + monkeypatch.setattr( + supervisor, "_write_descriptor_frame_fd", malformed_write + ) + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, + env={}, writable_roots=(tmp_path,), + ) + + assert result.returncode == 125 + assert surviving is False + assert "exited before verification" in result.stderr + assert cleanup == ["scope", "mounts"] + + def test_authority_directory_replacement_is_detected_before_relay( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -3404,12 +3480,15 @@ def test_candidate_deadline_stops_before_trusted_postprocessing( (cgroup / "pids.events").write_text("max 0\n", encoding="ascii") def sandbox(_command, _writable_roots, **kwargs): + control = str(kwargs["control_directory"]) plan = SimpleNamespace( unit_name="pdd-validator-00000000000000000000000000000000.scope", tools=SimpleNamespace(), - launch_payload={}, + launch_payload={ + "schema": "pdd-sandbox-launch-v1", "control": control, + }, ) - return [sys.executable, "-c", helper, str(kwargs["control_directory"])], plan + return [sys.executable, "-c", _launch_aware_mock_helper(helper)], plan monkeypatch.setattr(supervisor, "_sandbox_command", sandbox) monkeypatch.setattr(supervisor, "_prepare_staging", lambda _plan: None) From 3fc97087a94e80075066c34fc5804a6dfd784e4d Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 11:37:50 -0700 Subject: [PATCH 184/233] test(sync): reproduce hosted Linux runtime failures --- tests/test_sync_core_supervisor.py | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index b186b25f89..76ec74a0d5 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -240,6 +240,33 @@ def test_snapshot_staging_applies_attested_directory_root_mode( assert (target / "nested/member").read_bytes() == b"measured" +def test_snapshot_staging_copies_attested_root_file(tmp_path: Path) -> None: + """A file-root snapshot duplicates its verified source descriptor before copy.""" + from pdd.sync_core.runner import _snapshot_binding_proof # pylint: disable=import-outside-toplevel + + source = tmp_path / "reporter.cjs" + source.write_bytes(b"module.exports = class Reporter {};\n") + source.chmod(0o444) + proof = _snapshot_binding_proof(source, Path("/opt/reporter.cjs")) + target = tmp_path / "snapshot" + target.touch(mode=0o600) + namespace = { + "hashlib": hashlib, "json": json, "os": os, + "pathlib": __import__("pathlib"), "stat": stat, + } + exec(supervisor._SNAPSHOT_STAGING_SOURCE, namespace) # pylint: disable=exec-used + record = json.dumps({ + "schema": "pdd-snapshot-binding-record-v1", "source_index": 0, + "attestation": proof.attestation, + }, sort_keys=True, separators=(",", ":")) + + namespace["_stage_snapshot"](record, source, target) + namespace["_verify_staged_snapshot"](record, target) + + assert target.read_bytes() == source.read_bytes() + assert stat.S_IMODE(target.stat().st_mode) == 0o444 + + def test_snapshot_staging_quota_counts_recursive_attested_files( tmp_path: Path, ) -> None: @@ -1092,6 +1119,34 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( assert bwrap[bwrap.index("--ro-bind") + 1] in plan.launch_payload["path_tokens"] +def test_linux_sandbox_uses_upstream_bwrap_inherited_descriptor_contract( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ubuntu Bubblewrap receives no unsupported descriptor-preservation option.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: () + ) + + _argv, plan = _sandbox_command(["/bin/true"], (tmp_path,)) + + assert "--preserve-fds" not in plan.bwrap_argv + separator = plan.bwrap_argv.index("--") + candidate = plan.bwrap_argv[separator + 1:] + assert supervisor._INNER_STATUS_SUPERVISOR_SOURCE in candidate + assert candidate[candidate.index(supervisor._INNER_STATUS_SUPERVISOR_SOURCE) + 1] == "3" + + def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From ad6363b28f583d6f9817913fb257c87017126b79 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 11:39:03 -0700 Subject: [PATCH 185/233] fix(sync): restore hosted Linux sandbox compatibility --- pdd/sync_core/supervisor.py | 3 +-- tests/test_sync_core_supervisor.py | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 6aea3bb12e..69ee82d3c1 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1054,7 +1054,7 @@ def _stage_snapshot(encoded,source,target): try: for member in members: relative=member["path"]; kind=member["kind"]; descriptor=None - if relative==".": metadata=os.fstat(root_fd) + if relative==".": metadata=os.fstat(root_fd); descriptor=os.dup(root_fd) elif kind=="symlink": descriptor,name=_snapshot_parent_fd(root_fd,relative) try: metadata=os.stat(name,dir_fd=descriptor,follow_symlinks=False); target_text=os.readlink(name,dir_fd=descriptor) @@ -2982,7 +2982,6 @@ def _sandbox_command( "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp", "--dir", "/sys", "--dir", "/sys/fs", "--dir", "/sys/fs/cgroup"] - argv[8:8] = ["--preserve-fds", "2" if result_write_fd is not None else "1"] sources: list[Path] = [] path_tokens: list[str] = [] writable_specs: list[tuple[str, int, str]] = [] diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 76ec74a0d5..87244958ce 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -526,7 +526,7 @@ def test_linux_playwright_aggregate_binds_root_snapshot_mount_graph( member["destination"]] in [ bwrap[index:index + 3] for index in range(len(bwrap) - 2) ] - assert bwrap[:10].count("--preserve-fds") == 1 + assert "--preserve-fds" not in bwrap assert plan.helper_source.count( "verify_playwright_aggregate(playwright,mapped=True)" ) == 2 @@ -2050,8 +2050,7 @@ def test_linux_sandbox_uses_portable_framework_observation_fifo( assert _argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] assert "-C" not in _argv[:6] bwrap = list(plan.bwrap_argv) - preserve_index = bwrap.index("--preserve-fds") - assert bwrap[preserve_index + 1] == "1" + assert "--preserve-fds" not in bwrap observation_path = "/run/pdd-framework-observation" observation_index = bwrap.index(observation_path) assert bwrap[observation_index - 2] == "--bind" From cf0174323dcce0f2aaf1a2b81379ec53164119c5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 12:05:38 -0700 Subject: [PATCH 186/233] fix(sync): bind protected helper runtime --- pdd/sync_core/supervisor.py | 12 ++++--- tests/test_sync_core_supervisor.py | 56 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 69ee82d3c1..ef73987a49 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -3207,10 +3207,14 @@ def bind( argv.extend(deferred_readable_mounts) # The helper replaces this placeholder with only its systemd scope. argv.extend(("--ro-bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) - # ``setpriv`` executes after the namespace root is installed, so bind - # it and its ELF closure directly even when PATH resolution differs. - if tools.setpriv: - for item in (tools.setpriv, *_linked_libraries(tools.setpriv)): + # ``setpriv`` and the root helper interpreter execute after the + # namespace root is installed. Bind each exact invoked spelling with + # only its ELF closure and selected native Python stdlib root. + for executable in (tools.setpriv, tools.helper_python): + for item in ( + executable, *_linked_libraries(executable), + *_native_python_runtime_roots(executable), + ): bind("--ro-bind", item.resolve(), item, category="trusted_runtime") for item in readable_roots: bind("--ro-bind", item.resolve(), category="readable_root") diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 87244958ce..6edbb77fd4 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1147,6 +1147,62 @@ def test_linux_sandbox_uses_upstream_bwrap_inherited_descriptor_contract( assert candidate[candidate.index(supervisor._INNER_STATUS_SUPERVISOR_SOURCE) + 1] == "3" +def test_linux_sandbox_binds_inner_helper_python_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The Bubblewrap-invoked helper retains its exact native Python runtime.""" + helper_prefix = tmp_path.with_name(f"{tmp_path.name}-helper-runtime") + helper_python = helper_prefix / "bin" / "python3.12" + helper_python.parent.mkdir(parents=True) + helper_python.write_bytes(b"helper-python") + helper_python.chmod(0o755) + helper_stdlib = helper_prefix / "lib" / "python3.12" + helper_stdlib.mkdir(parents=True) + (helper_stdlib / "linecache.py").write_text("cache = {}\n", encoding="utf-8") + helper_loader = helper_prefix / "lib" / "ld-linux-x86-64.so.2" + helper_loader.write_bytes(b"loader") + + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 1234) + monkeypatch.setattr(os, "getgid", lambda: 2345) + _mock_linux_tools(tmp_path, monkeypatch) + metadata = helper_python.stat() + helper_identity = supervisor._ExecutableIdentity( + helper_python, + (metadata.st_dev, metadata.st_ino, stat.S_IMODE(metadata.st_mode), + 0, metadata.st_size, metadata.st_mtime_ns), + hashlib.sha256(helper_python.read_bytes()).hexdigest(), + ) + monkeypatch.setattr(supervisor, "_trusted_helper_python", lambda: helper_identity) + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr(supervisor, "_runtime_roots", lambda *_args: ()) + monkeypatch.setattr( + supervisor, "_linked_libraries", + lambda path: (helper_loader,) if path == helper_python else (), + ) + + _argv, plan = _sandbox_command(["/bin/true"], (tmp_path,)) + bwrap = plan.bwrap_argv + + def bind_source(destination: Path) -> str: + index = next( + index for index, value in enumerate(bwrap[2:], 2) + if value == str(destination) and bwrap[index - 2] == "--ro-bind" + ) + assert plan.launch_payload is not None + tokens = plan.launch_payload["path_tokens"] + return str(plan.sources[tokens.index(bwrap[index - 1])]) + + separator = bwrap.index("--") + assert bwrap[separator + 1] == str(helper_python) + assert bind_source(helper_python) == str(helper_python) + assert bind_source(helper_loader) == str(helper_loader) + assert bind_source(helper_stdlib) == str(helper_stdlib) + + def test_linux_sandbox_maps_copied_runtime_to_manifest_destination( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From f5e0b9b57eef7bd07b2a5da033ec980913764794 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 12:24:07 -0700 Subject: [PATCH 187/233] fix(sync): isolate candidate cgroup lifecycle --- pdd/sync_core/supervisor.py | 38 ++++----- tests/test_sync_core_supervisor.py | 125 +++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 27 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index ef73987a49..96b8ea6481 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1811,13 +1811,13 @@ def _limited_command( _INNER_STATUS_SUPERVISOR_SOURCE = "\n".join(( - "import json,os,signal,sys", - "fd=int(sys.argv[1]);token=sys.argv[2];command=sys.argv[3:]", - "if not command or len(token)!=32 or any(c not in '0123456789abcdef' for c in token): raise RuntimeError('invalid nested termination protocol')", + "import json,os,pathlib,signal,sys", + "fd=int(sys.argv[1]);token=sys.argv[2];cgroup=pathlib.Path(sys.argv[3]);command=sys.argv[4:]", + "if not command or not cgroup.is_absolute() or '..' in cgroup.parts or len(token)!=32 or any(c not in '0123456789abcdef' for c in token): raise RuntimeError('invalid nested termination protocol')", "os.set_inheritable(fd,False)", "pid=os.fork()", "if pid==0:", - " try: os.setsid(); os.execv(command[0],command)", + " try: (cgroup/'cgroup.procs').write_text(str(os.getpid()),encoding='ascii'); os.setsid(); os.execv(command[0],command)", " except OSError: os._exit(127)", "pid,status=os.waitpid(pid,os.WUNTRACED)", "if os.WIFSTOPPED(status):", @@ -2275,9 +2275,6 @@ def _staged_bwrap( " os.close(status_write)", " if anonymous_observation: os.close(observation_write)", " if descriptor_protocol: os.close(candidate_stdout_write); os.close(candidate_stderr_write)", - " (candidate_cgroup/'cgroup.procs').write_text(str(pid),encoding='ascii')", - " members=(candidate_cgroup/'cgroup.procs').read_text(encoding='ascii').split()", - " if str(pid) not in members: raise RuntimeError('candidate cgroup placement failed')", " parent_watch_done=threading.Event(); parent_watch=None", " if descriptor_protocol:", " def watch_parent():", @@ -3205,8 +3202,9 @@ def bind( # Nested declared toolchain mounts must be installed after broader # inferred roots or Bubblewrap would hide them with the later root bind. argv.extend(deferred_readable_mounts) - # The helper replaces this placeholder with only its systemd scope. - argv.extend(("--ro-bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) + # The root status supervisor moves only its forked child into this leaf + # before ``setpriv`` drops the candidate's authority to alter cgroups. + argv.extend(("--bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) # ``setpriv`` and the root helper interpreter execute after the # namespace root is installed. Bind each exact invoked spelling with # only its ELF closure and selected native Python stdlib root. @@ -3267,7 +3265,7 @@ def bind( argv.extend(( "--", str(tools.helper_python), "-I", "-S", "-c", _INNER_STATUS_SUPERVISOR_SOURCE, str(status_fd), - "@PDD-TERMINATION-TOKEN@", *drop, *sandboxed, + "@PDD-TERMINATION-TOKEN@", "/sys/fs/cgroup", *drop, *sandboxed, )) if consumed_proofs != proofs.keys(): raise RuntimeError("protected sandbox has unused immutable binding proof") @@ -3430,6 +3428,17 @@ def read_stderr(stream) -> None: candidate_stderr = result.stderr if candidate_returncode == 124 and not timed_out: raise RuntimeError("candidate used reserved exit status 124") + # The helper holds the candidate leaf until this acknowledgement. + # Capture authoritative kernel deltas before permitting that cleanup. + memory_after = _cgroup_events(cgroup, "memory.events") + pids_after = _cgroup_events(cgroup, "pids.events") + if max( + memory_after["oom"] - memory_before["oom"], + memory_after["oom_kill"] - memory_before["oom_kill"], + ) > 0: + resource_limit = "memory" + elif pids_after["max"] - pids_before["max"] > 0: + resource_limit = "pids" handoff_deadline = time.monotonic() + _TRUSTED_POSTPROCESS_SECONDS _write_all_descriptor_bytes( result_write_fd, result.observation, handoff_deadline @@ -3456,15 +3465,6 @@ def read_stderr(stream) -> None: ) if process.returncode != expected_helper_exit: raise RuntimeError("protected descriptor helper exit status mismatch") - memory_after = _cgroup_events(cgroup, "memory.events") - pids_after = _cgroup_events(cgroup, "pids.events") - if max( - memory_after["oom"] - memory_before["oom"], - memory_after["oom_kill"] - memory_before["oom_kill"], - ) > 0: - resource_limit = "memory" - elif pids_after["max"] - pids_before["max"] > 0: - resource_limit = "pids" except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc: failed_closed = True add_diagnostic(f"protected supervisor phase={phase}: {exc}\n") diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 6edbb77fd4..e9f4b7d589 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -2916,10 +2916,7 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( assert helper.index("start") < helper.index("os.fork()") assert "release_read,release_write=os.pipe()" in helper assert "os.read(release_read,1)" in helper - assert "(candidate_cgroup/'cgroup.procs').write_text(str(pid)" in helper - assert helper.index("(candidate_cgroup/'cgroup.procs').write_text(str(pid)") < helper.index( - "os.write(release_write,b'1')" - ) + assert "(candidate_cgroup/'cgroup.procs').write_text(str(pid)" not in helper assert helper.index("os.read(release_read,1)") < helper.index( "os.execvpe(argv[0],argv,os.environ)" ) @@ -2952,10 +2949,14 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( assert helper.index("mount_lines=") < helper.index( "(control/'ready').write_text" ) - assert "@PDD-CGROUP@" in plan.bwrap_argv + assert plan.bwrap_argv.count("@PDD-CGROUP@") == 1 cgroup_source = plan.bwrap_argv.index("@PDD-CGROUP@") - assert plan.bwrap_argv[cgroup_source - 1] == "--ro-bind" + assert plan.bwrap_argv[cgroup_source - 1] == "--bind" assert plan.bwrap_argv[cgroup_source + 1] == "/sys/fs/cgroup" + status_supervisor = plan.bwrap_argv.index( + supervisor._INNER_STATUS_SUPERVISOR_SOURCE + ) + assert plan.bwrap_argv[status_supervisor + 3] == "/sys/fs/cgroup" def _generated_pidfd_protocol(namespace: dict[str, object] | None = None): @@ -5770,16 +5771,19 @@ def test_candidate_record_parser_fails_closed_for_every_malformed_shape( ], ) def test_inner_status_supervisor_authenticates_nested_returncode( - program: str, expected: int, + tmp_path: Path, program: str, expected: int, ) -> None: """The exact production inner supervisor reports exit and signal status.""" token = "a" * 32 + cgroup = tmp_path / "candidate-cgroup" + cgroup.mkdir() + (cgroup / "cgroup.procs").touch() read_fd, write_fd = os.pipe() try: completed = subprocess.run( [sys.executable, "-I", "-S", "-c", supervisor._INNER_STATUS_SUPERVISOR_SOURCE, str(write_fd), token, - sys.executable, "-c", program], + str(cgroup), sys.executable, "-c", program], pass_fds=(write_fd,), check=False, timeout=5, ) os.close(write_fd) @@ -5796,6 +5800,44 @@ def test_inner_status_supervisor_authenticates_nested_returncode( assert completed.returncode == (expected if expected >= 0 else 128 - expected) +def test_inner_status_supervisor_moves_only_candidate_to_cgroup( + tmp_path: Path, +) -> None: + """Only the forked untrusted child consumes the candidate pids budget.""" + cgroup = tmp_path / "candidate-cgroup" + cgroup.mkdir() + membership = cgroup / "cgroup.procs" + membership.touch() + candidate_pid = tmp_path / "candidate-pid" + token = "a" * 32 + read_fd, write_fd = os.pipe() + program = ( + "from pathlib import Path;import os,sys;" + "Path(sys.argv[1]).write_text(str(os.getpid()),encoding='ascii')" + ) + try: + completed = subprocess.run( + [ + sys.executable, "-I", "-S", "-c", + supervisor._INNER_STATUS_SUPERVISOR_SOURCE, str(write_fd), token, + str(cgroup), sys.executable, "-c", program, str(candidate_pid), + ], + pass_fds=(write_fd,), check=False, timeout=5, + ) + os.close(write_fd) + write_fd = -1 + record = os.read(read_fd, supervisor._TERMINATION_HEADER_BYTES) + finally: + os.close(read_fd) + if write_fd >= 0: + os.close(write_fd) + + payload = json.loads(record[len(supervisor._TERMINATION_HEADER_PREFIX):].rstrip()) + assert completed.returncode == 0 + assert payload == {"returncode": 0, "token": token} + assert membership.read_text(encoding="ascii") == candidate_pid.read_text(encoding="ascii") + + def test_descriptor_result_rejects_replayed_nonce_and_aggregate() -> None: """A result from another helper lifecycle cannot be replayed as a PASS.""" observation = b'{"tests":[]}' @@ -5883,6 +5925,73 @@ def sandbox(_command, _roots, **_kwargs): assert surviving is False +def test_playwright_descriptor_records_events_before_helper_cleanup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Authenticated event deltas remain readable until result handoff is acknowledged.""" + cgroup = tmp_path / "cgroup" + cgroup.mkdir() + memory_events = cgroup / "memory.events" + pids_events = cgroup / "pids.events" + memory_events.write_text("oom 0\noom_kill 0\n", encoding="ascii") + pids_events.write_text("max 0\n", encoding="ascii") + helper = """ +import hashlib,json,os,sys +def receive(): + size=int.from_bytes(sys.stdin.buffer.read(4),'big') + return json.loads(sys.stdin.buffer.read(size)) +def send(value): + payload=json.dumps(value,sort_keys=True,separators=(',',':')).encode() + sys.stdout.buffer.write(len(payload).to_bytes(4,'big')+payload);sys.stdout.buffer.flush() +receive() +nonce='aa'*32 +send({'kind':'ready','nonce':nonce}) +receive() +observation=b'' +send({'kind':'result','nonce':nonce,'aggregate_digest':'b'*64, + 'candidate':{'version':1,'state':'terminal','returncode':0,'timed_out':False}, + 'stdout':'','stderr':'','observation':'', + 'observation_sha256':hashlib.sha256(observation).hexdigest(), + 'observation_size':0}) +receive() +os.unlink(sys.argv[1]);os.unlink(sys.argv[2]) +""" + + def sandbox(_command, _roots, **_kwargs): + return [sys.executable, "-c", helper, str(memory_events), str(pids_events)], SimpleNamespace( + unit_name="pdd-validator-00000000000000000000000000000000.scope", + tools=SimpleNamespace(), launch_payload={}, + ) + + monkeypatch.setattr(supervisor, "_sandbox_command", sandbox) + monkeypatch.setattr(supervisor, "_prepare_staging", lambda _plan: None) + monkeypatch.setattr( + supervisor, "_probe_scope", + lambda _plan, _limits: (cgroup, {"oom": 0, "oom_kill": 0}, {"max": 0}), + ) + monkeypatch.setattr(supervisor, "_stop_scope", lambda *_args: None) + monkeypatch.setattr(supervisor, "_cleanup_staging", lambda _plan: None) + monkeypatch.setattr(supervisor.os, "urandom", lambda size: b"\xaa" * size) + read_fd, write_fd = os.pipe() + try: + result, surviving = supervisor._run_playwright_descriptor_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, env={}, + writable_roots=(tmp_path,), limits=SupervisorLimits(), readable_roots=(), + readable_bindings=(), immutable_binding_proofs=(), snapshot_binding_proofs=(), + playwright_snapshot_aggregate=supervisor.PlaywrightSnapshotAggregate( + "{}", "b" * 64, "identity", 198, + ), writable_bindings=(), temp_directory=None, + result_write_fd=write_fd, result_fd=198, + ) + finally: + os.close(read_fd) + os.close(write_fd) + + assert result.returncode == 0, result.stderr + assert result.termination.kind is supervisor.TerminationKind.EXIT + assert surviving is False + + @pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") def test_writable_churn_cannot_escape_supervisor_cleanup(tmp_path: Path) -> None: program = ( From 14648b07fd0071fce72510958798906bcfa46742 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 13:15:37 -0700 Subject: [PATCH 188/233] test(sync): expose missing cgroup termination telemetry --- tests/test_sync_core_runner_vitest.py | 51 ++++++++++++++++++++++++++- tests/test_sync_core_supervisor.py | 33 +++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_runner_vitest.py b/tests/test_sync_core_runner_vitest.py index df87ebe922..f52ba4d60d 100644 --- a/tests/test_sync_core_runner_vitest.py +++ b/tests/test_sync_core_runner_vitest.py @@ -2,6 +2,7 @@ import json import os +import signal import shutil import subprocess import sys @@ -40,7 +41,13 @@ vitest_validator_config_digest, ) from pdd.sync_core.evidence_store import attestation_payload, decode_attestation -from pdd.sync_core.supervisor import SupervisorLimits +from pdd.sync_core.supervisor import ( + CgroupResourceTelemetry, + SupervisedCompletedProcess, + SupervisorLimits, + SupervisorTermination, + TerminationKind, +) def test_framework_observation_fifo_eof_waits_for_late_writer( @@ -1896,6 +1903,48 @@ def test_vitest_result_fifo_without_writer_is_distinct_collection_error( assert executions[0].detail == "Vitest reporter produced no result" +def test_vitest_sigabrt_reports_only_trusted_zero_cgroup_deltas( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A generic abort stays fail-closed and is not mislabeled as a limit breach.""" + root, _commit = _repository(tmp_path) + result = SupervisedCompletedProcess( + ["vitest"], + -signal.SIGABRT, + "candidate stdout must not be exposed", + "candidate stderr must not be exposed", + termination=SupervisorTermination( + TerminationKind.SIGNAL, + signal_number=signal.SIGABRT, + resource_telemetry=CgroupResourceTelemetry(0, 0, 0), + ), + ) + monkeypatch.setattr( + "pdd.sync_core.runner.run_supervised", + lambda *_args, **_kwargs: (result, False), + ) + + execution, identities = _run_vitest( + root, + (PurePosixPath("tests/widget.test.ts"),), + 30, + _runner_config(tmp_path, _fake_vitest(tmp_path)), + ) + + assert execution.outcome is EvidenceOutcome.ERROR + assert execution.detail == ( + "Vitest infrastructure termination: reporter=missing; kind=signal; " + "signal=SIGABRT; signal_number=6; cgroup_memory_oom_delta=0; " + "cgroup_memory_oom_kill_delta=0; cgroup_pids_max_delta=0; " + "diagnostic_sha256=a56506d06ba82ba55af2e5593dab5a2044555b5f75d8389f" + "c90dd9679fe43f20" + ) + assert "candidate stdout" not in execution.detail + assert "candidate stderr" not in execution.detail + assert identities == () + + @pytest.mark.parametrize( ("returncode", "outcome"), [(126, EvidenceOutcome.ERROR), (127, EvidenceOutcome.ERROR), (1, EvidenceOutcome.ERROR)], diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index e9f4b7d589..8846067dbc 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -3307,6 +3307,39 @@ def test_cgroup_event_counters_require_authoritative_keys( supervisor._cgroup_events(tmp_path, filename) +def test_signal_termination_retains_zero_cgroup_event_deltas() -> None: + """A signal stays a signal while proving configured cgroup limits did not fire.""" + telemetry = supervisor.CgroupResourceTelemetry( + memory_oom_delta=0, + memory_oom_kill_delta=0, + pids_max_delta=0, + ) + + termination = supervisor._termination_evidence( + -signal.SIGABRT, + timed_out=False, + timeout_seconds=30, + resource_limit=None, + resource_telemetry=telemetry, + ) + + assert termination.kind is supervisor.TerminationKind.SIGNAL + assert termination.signal_number == signal.SIGABRT + assert termination.resource_limit is None + assert termination.resource_telemetry == telemetry + + +def test_cgroup_resource_telemetry_rejects_counter_regression() -> None: + """Kernel counters must be monotonic before becoming trusted diagnostics.""" + with pytest.raises(RuntimeError, match="counter regressed"): + supervisor._cgroup_resource_telemetry( + {"oom": 2, "oom_kill": 1}, + {"oom": 1, "oom_kill": 1}, + {"max": 0}, + {"max": 0}, + ) + + def test_scope_cleanup_targets_only_validated_unique_unit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: From 2f3eb2b613d96074372778d1dd0b3462bc92756f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 13:18:36 -0700 Subject: [PATCH 189/233] test(sync): reproduce sibling cgroup namespace migration --- tests/test_sync_core_supervisor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 8846067dbc..5094384db4 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1108,7 +1108,8 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( assert plan.unit_name.startswith("pdd-validator-") assert argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] bwrap = plan.bwrap_argv - assert {"--unshare-pid", "--unshare-net", "--unshare-cgroup"} <= set(bwrap) + assert {"--unshare-pid", "--unshare-net"} <= set(bwrap) + assert "--unshare-cgroup" not in bwrap assert "--unshare-user" not in bwrap separator = bwrap.index("--") assert bwrap.index("--bind") < separator < bwrap.index("--reuid") From 82c1671d94176f3cd5204f7ab9345b301abe2a95 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 13:29:19 -0700 Subject: [PATCH 190/233] fix(sync): preserve cgroup termination telemetry --- pdd/sync_core/runner.py | 9 ++++ pdd/sync_core/supervisor.py | 92 +++++++++++++++++++++++++++++++------ 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index cceaef4969..7f6bf7bc04 100644 --- a/pdd/sync_core/runner.py +++ b/pdd/sync_core/runner.py @@ -55,6 +55,7 @@ PlaywrightSnapshotAggregate, SnapshotBindingProof, SupervisorLimits, + SupervisorTermination, TerminationKind, _vitest_descriptor_attestation, released_runtime_closure_paths, @@ -4476,6 +4477,14 @@ def _vitest_infrastructure_termination( ) elif kind == "resource-limit": fields.append(f"resource_limit={getattr(termination, 'resource_limit', None) or 'unknown'}") + if isinstance(termination, SupervisorTermination): + telemetry = termination.resource_telemetry + if telemetry is not None: + fields.extend(( + f"cgroup_memory_oom_delta={telemetry.memory_oom_delta}", + f"cgroup_memory_oom_kill_delta={telemetry.memory_oom_kill_delta}", + f"cgroup_pids_max_delta={telemetry.pids_max_delta}", + )) diagnostic = result.stderr or result.stdout if diagnostic: fields.append("diagnostic_sha256=" + hashlib.sha256( diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 96b8ea6481..d8c903e657 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -131,6 +131,15 @@ class TerminationKind(str, Enum): SANDBOX_ERROR = "sandbox-error" +@dataclass(frozen=True) +class CgroupResourceTelemetry: + """Trusted deltas from the candidate leaf's kernel event counters.""" + + memory_oom_delta: int + memory_oom_kill_delta: int + pids_max_delta: int + + @dataclass(frozen=True) class SupervisorTermination: """Typed termination evidence retained outside candidate-controlled output.""" @@ -140,6 +149,7 @@ class SupervisorTermination: signal_number: int | None = None timeout_seconds: int | None = None resource_limit: str | None = None + resource_telemetry: CgroupResourceTelemetry | None = None class SupervisedCompletedProcess(subprocess.CompletedProcess[str]): # pylint: disable=too-few-public-methods @@ -189,19 +199,32 @@ def _termination_evidence( timed_out: bool, timeout_seconds: int, resource_limit: str | None, + resource_telemetry: CgroupResourceTelemetry | None = None, ) -> SupervisorTermination: """Classify only termination causes observed by the trusted supervisor.""" if resource_limit is not None: return SupervisorTermination( - TerminationKind.RESOURCE_LIMIT, resource_limit=resource_limit + TerminationKind.RESOURCE_LIMIT, + resource_limit=resource_limit, + resource_telemetry=resource_telemetry, ) if timed_out: return SupervisorTermination( - TerminationKind.TIMEOUT, timeout_seconds=timeout_seconds + TerminationKind.TIMEOUT, + timeout_seconds=timeout_seconds, + resource_telemetry=resource_telemetry, ) if returncode < 0: - return SupervisorTermination(TerminationKind.SIGNAL, signal_number=-returncode) - return SupervisorTermination(TerminationKind.EXIT, exit_code=returncode) + return SupervisorTermination( + TerminationKind.SIGNAL, + signal_number=-returncode, + resource_telemetry=resource_telemetry, + ) + return SupervisorTermination( + TerminationKind.EXIT, + exit_code=returncode, + resource_telemetry=resource_telemetry, + ) @dataclass(frozen=True) @@ -2752,6 +2775,32 @@ def _cgroup_events(cgroup: Path, filename: str) -> dict[str, int]: return events +def _cgroup_resource_telemetry( + memory_before: dict[str, int], + memory_after: dict[str, int], + pids_before: dict[str, int], + pids_after: dict[str, int], +) -> CgroupResourceTelemetry: + """Return monotonic kernel-event deltas for one proven candidate leaf.""" + values = { + "memory.events oom": memory_after["oom"] - memory_before["oom"], + "memory.events oom_kill": ( + memory_after["oom_kill"] - memory_before["oom_kill"] + ), + "pids.events max": pids_after["max"] - pids_before["max"], + } + regressed = [name for name, value in values.items() if value < 0] + if regressed: + raise RuntimeError( + "protected cgroup event counter regressed: " + ",".join(regressed) + ) + return CgroupResourceTelemetry( + memory_oom_delta=values["memory.events oom"], + memory_oom_kill_delta=values["memory.events oom_kill"], + pids_max_delta=values["pids.events max"], + ) + + def _probe_scope( plan: _ScopePlan, limits: SupervisorLimits, ) -> tuple[Path, dict[str, int], dict[str, int]]: @@ -2975,8 +3024,12 @@ def _sandbox_command( storage_roots = _writable_storage_roots(writable_sources) if _writable_size(storage_roots) > limits.max_writable_bytes: raise RuntimeError("initial writable quota exceeded") + # Keep the host cgroup namespace while exposing only the candidate leaf + # below. A namespace rooted at the monitor cannot migrate the child into + # its sibling candidate leaf; the read-only candidate uid still cannot + # change membership or limits after the root supervisor performs it. argv = [str(tools.bwrap), "--unshare-ipc", "--unshare-pid", "--unshare-net", - "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", + "--unshare-uts", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp", "--dir", "/sys", "--dir", "/sys/fs", "--dir", "/sys/fs/cgroup"] sources: list[Path] = [] @@ -3349,6 +3402,7 @@ def _run_playwright_descriptor_supervised( candidate_returncode = 125 failed_closed = False resource_limit: str | None = None + resource_telemetry: CgroupResourceTelemetry | None = None phase = "construction" candidate_stdout = b"" candidate_stderr = b"" @@ -3430,14 +3484,18 @@ def read_stderr(stream) -> None: raise RuntimeError("candidate used reserved exit status 124") # The helper holds the candidate leaf until this acknowledgement. # Capture authoritative kernel deltas before permitting that cleanup. - memory_after = _cgroup_events(cgroup, "memory.events") - pids_after = _cgroup_events(cgroup, "pids.events") + resource_telemetry = _cgroup_resource_telemetry( + memory_before, + _cgroup_events(cgroup, "memory.events"), + pids_before, + _cgroup_events(cgroup, "pids.events"), + ) if max( - memory_after["oom"] - memory_before["oom"], - memory_after["oom_kill"] - memory_before["oom_kill"], + resource_telemetry.memory_oom_delta, + resource_telemetry.memory_oom_kill_delta, ) > 0: resource_limit = "memory" - elif pids_after["max"] - pids_before["max"] > 0: + elif resource_telemetry.pids_max_delta > 0: resource_limit = "pids" handoff_deadline = time.monotonic() + _TRUSTED_POSTPROCESS_SECONDS _write_all_descriptor_bytes( @@ -3511,6 +3569,7 @@ def read_stderr(stream) -> None: 124 if timed_out else candidate_returncode, timed_out=timed_out, timeout_seconds=timeout, resource_limit=resource_limit, + resource_telemetry=resource_telemetry, ) ), ), False @@ -3565,6 +3624,7 @@ def run_supervised( candidate_returncode: int | None = None candidate_record: _CandidateRecord | None = None resource_limit: str | None = None + resource_telemetry: CgroupResourceTelemetry | None = None process: subprocess.Popen[bytes] | None = None plan: _ScopePlan | None = None cgroup: Path | None = None @@ -3622,16 +3682,19 @@ def fail_for_limit() -> bool: return True def record_events() -> None: - nonlocal resource_limit + nonlocal resource_limit, resource_telemetry if cgroup is None: return memory_after = _cgroup_events(cgroup, "memory.events") pids_after = _cgroup_events(cgroup, "pids.events") + resource_telemetry = _cgroup_resource_telemetry( + memory_before, memory_after, pids_before, pids_after, + ) oom_delta = max( - memory_after["oom"] - memory_before["oom"], - memory_after["oom_kill"] - memory_before["oom_kill"], + resource_telemetry.memory_oom_delta, + resource_telemetry.memory_oom_kill_delta, ) - pids_delta = pids_after["max"] - pids_before["max"] + pids_delta = resource_telemetry.pids_max_delta if oom_delta > 0: resource_limit = "memory" add_diagnostic(f"cgroup memory.events oom delta={oom_delta}\n") @@ -3955,6 +4018,7 @@ def record_events() -> None: if failed_closed else _termination_evidence( returncode, timed_out=candidate_timed_out, timeout_seconds=timeout, resource_limit=resource_limit, + resource_telemetry=resource_telemetry, ) ), ), surviving From 18bb6c4b0260e0601a65fb565eab8b89d87db74d Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 14:01:56 -0700 Subject: [PATCH 191/233] test(sync): reproduce hosted helper fork hazards --- tests/test_sync_core_supervisor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 5094384db4..8fef591188 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -98,6 +98,7 @@ def test_candidate_environment_record_is_canonical_and_complete(tmp_path: Path) parsed = supervisor._parse_candidate_environment_record(encoded) assert parsed == environment | { + "LANG": "C", "LC_ALL": "C", "LD_LIBRARY_PATH": supervisor._sandbox_library_path(environment), "PATH": supervisor._TRUSTED_ROOT_PATH, "PDD_SUPERVISION_TOKEN": "a" * 32, @@ -2916,6 +2917,9 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( assert "ready" in helper and "start" in helper assert helper.index("start") < helper.index("os.fork()") assert "release_read,release_write=os.pipe()" in helper + fork_offset = helper.index("pid=os.fork()") + assert fork_offset < helper.index("observation_thread.start()") + assert fork_offset < helper.index("[thread.start() for thread in candidate_output_threads]") assert "os.read(release_read,1)" in helper assert "(candidate_cgroup/'cgroup.procs').write_text(str(pid)" not in helper assert helper.index("os.read(release_read,1)") < helper.index( From 48c7318d1f9dfc25a458676f5a2a04f8b6d68ac7 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 14:04:17 -0700 Subject: [PATCH 192/233] fix(sync): launch sandbox drains after trusted fork --- pdd/sync_core/supervisor.py | 7 ++++-- tests/test_sync_core_supervisor.py | 34 ++++++++++++++++++------------ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index d8c903e657..9117be060b 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1790,6 +1790,8 @@ def _candidate_environment_record( raise RuntimeError("protected candidate environment is invalid") candidate = dict(environment) candidate.update({ + "LANG": "C", + "LC_ALL": "C", "PATH": _TRUSTED_ROOT_PATH, "PDD_SUPERVISION_TOKEN": supervision_token, "PYTHONDONTWRITEBYTECODE": "1", @@ -2237,7 +2239,7 @@ def _staged_bwrap( " observation_size+=len(chunk)", " if observation_size>limits['observation']: observation_overflow=True", " elif not observation_overflow: observation_chunks.append(chunk)", - " observation_thread=threading.Thread(target=drain_observation,daemon=True); observation_thread.start()", + " observation_thread=threading.Thread(target=drain_observation,daemon=True)", " configure_candidate_leaf()", " subprocess.run([mount,'--bind',str(candidate_cgroup),str(cgroup_target)]," "check=True,timeout=limits['trusted_timeout'])", @@ -2273,7 +2275,6 @@ def _staged_bwrap( " elif not candidate_output_overflow: chunks.append(chunk)", " os.close(fd)", " candidate_output_threads=[threading.Thread(target=drain_candidate,args=(candidate_stdout_read,candidate_stdout),daemon=True),threading.Thread(target=drain_candidate,args=(candidate_stderr_read,candidate_stderr),daemon=True)]", - " [thread.start() for thread in candidate_output_threads]", " pid=os.fork()", " if pid == 0:", " os.close(release_write)", @@ -2298,6 +2299,8 @@ def _staged_bwrap( " os.close(status_write)", " if anonymous_observation: os.close(observation_write)", " if descriptor_protocol: os.close(candidate_stdout_write); os.close(candidate_stderr_write)", + " if anonymous_observation: observation_thread.start()", + " if descriptor_protocol: [thread.start() for thread in candidate_output_threads]", " parent_watch_done=threading.Event(); parent_watch=None", " if descriptor_protocol:", " def watch_parent():", diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 8fef591188..1eb7f0bae2 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -37,6 +37,11 @@ ) +def _credential_free_environment(home: Path) -> dict[str, str]: + """Return a fixed candidate environment independent of hosted CI secrets.""" + return {"HOME": str(home), "NODE_ENV": "test"} + + def _mock_linux_tools( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> dict[str, str]: @@ -2796,7 +2801,7 @@ def test_sandboxed_python_minimal_smoke(tmp_path: Path) -> None: [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=10, - env=dict(os.environ), + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), ) @@ -3280,7 +3285,7 @@ def test_exact_supervisor_candidate_leaf_preserves_monitor_and_events( ) result, surviving = run_supervised( commands[case], cwd=tmp_path, timeout=.1 if case == "timeout" else 10, - env=dict(os.environ), writable_roots=(tmp_path,), limits=limits, + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), limits=limits, ) assert not surviving @@ -3410,7 +3415,7 @@ def test_sandboxed_python_imports_standard_library_after_command_construction( [sys.executable, "-c", "import math; print(math.pi)"], cwd=tmp_path, timeout=10, - env=dict(os.environ), + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), ) @@ -3478,7 +3483,7 @@ def test_detached_session_descendant_is_terminated(tmp_path: Path) -> None: [sys.executable, "-c", parent, child, str(marker)], cwd=tmp_path, timeout=10, - env=dict(os.environ), + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), ) assert result.returncode == 0 @@ -3505,7 +3510,7 @@ def test_detached_descendant_cannot_escape_by_removing_marker(tmp_path: Path) -> ) result, surviving = run_supervised( [sys.executable, "-c", parent, child, str(marker)], cwd=tmp_path, - timeout=10, env=dict(os.environ), writable_roots=(tmp_path,), + timeout=10, env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), ) assert result.returncode == 0 assert surviving is False @@ -3523,7 +3528,7 @@ def test_candidate_cannot_read_absolute_host_sentinel(tmp_path: Path) -> None: "import pathlib,sys; pathlib.Path(sys.argv[1]).read_text()", str(sentinel), ], - cwd=tmp_path, timeout=10, env=dict(os.environ), writable_roots=(tmp_path,), + cwd=tmp_path, timeout=10, env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), ) assert result.returncode != 0 assert surviving is False @@ -3548,7 +3553,7 @@ def test_immediate_detached_child_cannot_forge_checker_result_channel( ) completed, _surviving = run_supervised( [sys.executable, "-c", parent, child, str(result_channel)], - cwd=scratch, timeout=10, env=dict(os.environ), + cwd=scratch, timeout=10, env=_credential_free_environment(tmp_path), writable_roots=(scratch,), ) assert completed.returncode == 0 @@ -3560,7 +3565,7 @@ def test_immediate_detached_child_cannot_forge_checker_result_channel( def test_output_is_bounded(tmp_path: Path) -> None: result, _surviving = run_supervised( [sys.executable, "-c", "print('x' * 20000000)"], cwd=tmp_path, - timeout=10, env=dict(os.environ), writable_roots=(tmp_path,), + timeout=10, env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), ) assert len(result.stdout.encode()) <= 16 * 1024 * 1024 assert result.returncode != 0 @@ -3587,7 +3592,7 @@ def test_memory_disk_and_process_limits_fail_closed(tmp_path: Path, program: str ) result, _surviving = run_supervised( [sys.executable, "-c", program], cwd=tmp_path, timeout=10, - env=dict(os.environ), writable_roots=(tmp_path,), limits=limits, + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), limits=limits, ) assert result.returncode != 0 @@ -3699,7 +3704,7 @@ def test_binary_output_has_aggregate_limit_and_deterministic_decode(tmp_path: Pa limits = SupervisorLimits(max_output_bytes=1024) result, _surviving = run_supervised( [sys.executable, "-c", "import os; os.write(1, b'\\xff' * 800); os.write(2, b'x' * 800)"], - cwd=tmp_path, timeout=10, env=dict(os.environ), + cwd=tmp_path, timeout=10, env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), limits=limits, ) assert result.returncode == 125 @@ -3776,7 +3781,8 @@ def test_real_linux_authenticated_termination_and_cleanup(tmp_path: Path) -> Non ) for command, timeout, kind, returncode in cases: result, surviving = run_supervised( - command, cwd=tmp_path, timeout=timeout, env=dict(os.environ), + command, cwd=tmp_path, timeout=timeout, + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), ) assert isinstance(result, supervisor.SupervisedCompletedProcess) @@ -3794,7 +3800,7 @@ def test_real_linux_authenticated_termination_and_cleanup(tmp_path: Path) -> Non ) result, surviving = run_supervised( [sys.executable, "-c", fork_program], cwd=tmp_path, timeout=5, - env=dict(os.environ), writable_roots=(tmp_path,), + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), limits=replace(SupervisorLimits(), max_processes=8), ) assert result.termination.kind is supervisor.TerminationKind.RESOURCE_LIMIT @@ -5705,7 +5711,7 @@ def test_simultaneous_high_volume_stdio_has_one_aggregate_bound(tmp_path: Path) ) result, surviving = run_supervised( [sys.executable, "-c", program], cwd=tmp_path, timeout=10, - env=dict(os.environ), writable_roots=(tmp_path,), + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), limits=replace(SupervisorLimits(), max_output_bytes=1024 * 1024), ) assert result.returncode == 0 @@ -6042,7 +6048,7 @@ def test_writable_churn_cannot_escape_supervisor_cleanup(tmp_path: Path) -> None ) result, surviving = run_supervised( [sys.executable, "-c", program], cwd=tmp_path, timeout=5, - env=dict(os.environ), writable_roots=(tmp_path,), + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), ) assert result.returncode == 0 assert surviving is False From e5424588492bdd0cab906dca20e00bf993aad6a0 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 14:42:11 -0700 Subject: [PATCH 193/233] fix: deduplicate workflow ownership rule --- .pdd/sync-ownership.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.pdd/sync-ownership.json b/.pdd/sync-ownership.json index b9efb2e884..49e82e8d6d 100644 --- a/.pdd/sync-ownership.json +++ b/.pdd/sync-ownership.json @@ -13449,13 +13449,6 @@ "pattern": "tests/test_unfinished_prompt.py", "role": "human-maintained" }, - { - "inventory": "HUMAN_OWNED", - "owner": "pdd-maintainers", - "pattern": "tests/test_unit_tests_workflow.py", - "preauthorize_absent": true, - "role": "human-maintained" - }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", From 698908127eb0e3b62a62dfb8ab05e8fe6a74bb34 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 14:51:06 -0700 Subject: [PATCH 194/233] fix(sync): inherit hosted workflow preauthorization --- .pdd/sync-ownership.json | 14 +++++++------- tests/test_sync_core_pdd_rollout_policy.py | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.pdd/sync-ownership.json b/.pdd/sync-ownership.json index 49e82e8d6d..b9152c160a 100644 --- a/.pdd/sync-ownership.json +++ b/.pdd/sync-ownership.json @@ -13304,13 +13304,6 @@ "preauthorize_absent": true, "role": "human-maintained" }, - { - "inventory": "HUMAN_OWNED", - "owner": "pdd-maintainers", - "pattern": "tests/test_unit_tests_workflow.py", - "preauthorize_absent": true, - "role": "human-maintained" - }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", @@ -13449,6 +13442,13 @@ "pattern": "tests/test_unfinished_prompt.py", "role": "human-maintained" }, + { + "inventory": "HUMAN_OWNED", + "owner": "pdd-maintainers", + "pattern": "tests/test_unit_tests_workflow.py", + "preauthorize_absent": true, + "role": "human-maintained" + }, { "inventory": "HUMAN_OWNED", "owner": "pdd-maintainers", diff --git a/tests/test_sync_core_pdd_rollout_policy.py b/tests/test_sync_core_pdd_rollout_policy.py index c222f68462..7743fa8fc8 100644 --- a/tests/test_sync_core_pdd_rollout_policy.py +++ b/tests/test_sync_core_pdd_rollout_policy.py @@ -62,7 +62,6 @@ "tests/test_sync_core_runner_jest.py", "tests/test_sync_core_runner_vitest.py", "tests/test_sync_core_runner_playwright.py", - "tests/test_unit_tests_workflow.py", "tests/test_cloud_global_dry_run.py", "tests/test_continuous_sync_path_policy.py", "pdd/sync_core/human_attestation.py", From 58beab3886afac88de99c05a5c36cbb39b427acf Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 14:54:49 -0700 Subject: [PATCH 195/233] fix(sync): arm candidate cgroup after namespace entry --- pdd/sync_core/supervisor.py | 234 ++++++++++++++++++++++++----- tests/test_sync_core_supervisor.py | 227 +++++++++++++++++++++++++--- 2 files changed, 403 insertions(+), 58 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 9117be060b..9aef50cf56 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -57,6 +57,9 @@ _MAX_STAGING_BYTES = 4 * 1024 * 1024 * 1024 _TERMINATION_HEADER_BYTES = 256 _TERMINATION_HEADER_PREFIX = b"PDD-TERMINATION-V1 " +_CGROUP_HANDSHAKE_BYTES = 128 +_CGROUP_READY_PREFIX = b"PDD-CGROUP-READY-V1 " +_CGROUP_START_PREFIX = b"PDD-CGROUP-START-V1 " _VITEST_MEMBER_ROLES = frozenset({ "launcher", "entrypoint", "dependencies", "native_runtime", "lockfile", }) @@ -1836,10 +1839,31 @@ def _limited_command( _INNER_STATUS_SUPERVISOR_SOURCE = "\n".join(( - "import json,os,pathlib,signal,sys", - "fd=int(sys.argv[1]);token=sys.argv[2];cgroup=pathlib.Path(sys.argv[3]);command=sys.argv[4:]", - "if not command or not cgroup.is_absolute() or '..' in cgroup.parts or len(token)!=32 or any(c not in '0123456789abcdef' for c in token): raise RuntimeError('invalid nested termination protocol')", - "os.set_inheritable(fd,False)", + "import json,os,pathlib,select,signal,sys,time", + "fd=int(sys.argv[1]);token=sys.argv[2];cgroup=pathlib.Path(sys.argv[3]);ready_fd=int(sys.argv[4]);start_fd=int(sys.argv[5]);timeout=float(sys.argv[6]);command=sys.argv[7:]", + "if not command or not cgroup.is_absolute() or '..' in cgroup.parts or len(token)!=32 or any(c not in '0123456789abcdef' for c in token) or len({fd,ready_fd,start_fd})!=3 or any(value<3 or value>255 for value in (fd,ready_fd,start_fd)) or not 0 1: raise RuntimeError("protected sandbox has ambiguous observation staging") helper = "\n".join(( - "import base64,hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,threading,time", + "import base64,errno,hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,threading,time", "if len(sys.argv)!=1: raise RuntimeError('invalid protected helper protocol')", "protocol_in=sys.stdin.buffer; protocol_in_fd=sys.stdin.fileno(); protocol_out_fd=sys.stdout.fileno()", "def protocol_send(payload,maximum,deadline):", @@ -1997,8 +2021,8 @@ def _staged_bwrap( "observation_read=None; observation_write=None; observation_thread=None", "observation_chunks=[]; observation_size=0; observation_overflow=False", "candidate_stdout_read=None; candidate_stdout_write=None; candidate_stderr_read=None; candidate_stderr_write=None; candidate_stdout=[]; candidate_stderr=[]; candidate_output_size=0; candidate_output_overflow=False; candidate_output_threads=[]; candidate_output_lock=threading.Lock()", - "status_read=None; status_write=None", - "scope_cgroup=None; monitor_cgroup=None; candidate_cgroup=None", + "status_read=None; status_write=None; inner_ready_read=None; inner_ready_write=None; inner_start_read=None; inner_start_write=None", + "scope_cgroup=None; monitor_cgroup=None; sandbox_cgroup=None; trusted_cgroup=None; candidate_cgroup=None", _PIDFD_PROTOCOL_SOURCE.strip(), _IMMUTABLE_STAGING_SOURCE, _SNAPSHOT_STAGING_SOURCE, @@ -2070,21 +2094,35 @@ def _staged_bwrap( "raise RuntimeError(f'invalid cgroup event file: {path.name}')", " values[fields[0]]=fields[1]", " return values", - "def wait_candidate_empty():", + "def cgroup_members(cgroup):", + " values=(cgroup/'cgroup.procs').read_text(encoding='ascii').split()", + " if len(values)!=len(set(values)) or any(not value.isdecimal() for value in values): raise RuntimeError('invalid cgroup membership')", + " return values", + "def wait_leaf_empty(cgroup,label):", " deadline=time.monotonic()+limits['trusted_timeout']", " while time.monotonic()limits['observation']: observation_overflow=True", " elif not observation_overflow: observation_chunks.append(chunk)", " observation_thread=threading.Thread(target=drain_observation,daemon=True)", - " configure_candidate_leaf()", - " subprocess.run([mount,'--bind',str(candidate_cgroup),str(cgroup_target)]," + " configure_cgroup_topology()", + " subprocess.run([mount,'--bind',str(sandbox_cgroup),str(cgroup_target)]," "check=True,timeout=limits['trusted_timeout'])", " staged.append(cgroup_target)", " subprocess.run([umount,str(cgroup_target)],check=True," "timeout=limits['trusted_timeout'])", " staged.pop()", - " subprocess.run([mount,'--bind',str(candidate_cgroup),str(cgroup_target)]," + " subprocess.run([mount,'--bind',str(sandbox_cgroup),str(cgroup_target)]," "check=True,timeout=limits['trusted_timeout'])", " staged.append(cgroup_target)", " argv=[str(cgroup_target) if value == '@PDD-CGROUP@' else value for value in argv]", - " if descriptor_protocol:", - " protocol_send({'kind':'ready','nonce':observation_nonce},4096,time.monotonic()+limits['trusted_timeout'])", - " start=protocol_receive(4096,time.monotonic()+limits['trusted_timeout'])", - " if start!={'kind':'start','nonce':observation_nonce}: raise RuntimeError('protected descriptor start is invalid')", - " else:", - " (control/'ready').write_text('ready',encoding='ascii')", - " wait_for('start')", " release_read,release_write=os.pipe()", " status_read,status_write=os.pipe()", + " inner_ready_read,inner_ready_write=os.pipe()", + " inner_start_read,inner_start_write=os.pipe()", " os.set_blocking(status_read,False)", " verify_tool('bwrap'); verify_tool('setpriv')", " if descriptor_protocol:", @@ -2280,6 +2369,7 @@ def _staged_bwrap( " os.close(release_write)", " try:", " os.close(status_read)", + " os.close(inner_ready_read);os.close(inner_start_write)", " if anonymous_observation:", " os.close(observation_read); os.dup2(observation_write,3) if observation_write!=3 else None", " os.close(observation_write) if observation_write!=3 else None", @@ -2291,16 +2381,25 @@ def _staged_bwrap( " if os.read(release_read,1)!=b'1': os._exit(125)", " os.close(release_read)", " status_fd=4 if anonymous_observation else 3", - " os.dup2(status_write,status_fd) if status_write!=status_fd else None", - " os.close(status_write) if status_write!=status_fd else None", + " ready_fd=status_fd+1;start_fd=status_fd+2", + " mappings=((status_write,status_fd),(inner_ready_write,ready_fd),(inner_start_read,start_fd))", + " for source,target in mappings:", + " if source!=target: os.dup2(source,target)", + " for source,_target in mappings:", + " if source not in {status_fd,ready_fd,start_fd}: os.close(source)", " os.execvpe(argv[0],argv,os.environ)", " except OSError: os._exit(125)", " os.close(release_read)", " os.close(status_write)", + " os.close(inner_ready_write);inner_ready_write=None", + " os.close(inner_start_read);inner_start_read=None", " if anonymous_observation: os.close(observation_write)", " if descriptor_protocol: os.close(candidate_stdout_write); os.close(candidate_stderr_write)", " if anonymous_observation: observation_thread.start()", " if descriptor_protocol: [thread.start() for thread in candidate_output_threads]", + " (sandbox_cgroup/'cgroup.procs').write_text(str(pid),encoding='ascii')", + " sandbox_members=(sandbox_cgroup/'cgroup.procs').read_text(encoding='ascii').split()", + " if str(pid) not in sandbox_members: raise RuntimeError('sandbox cgroup placement failed')", " parent_watch_done=threading.Event(); parent_watch=None", " if descriptor_protocol:", " def watch_parent():", @@ -2310,6 +2409,17 @@ def _staged_bwrap( " kill_candidate_leaf(); return", " parent_watch=threading.Thread(target=watch_parent,daemon=True); parent_watch.start()", " os.write(release_write,b'1'); os.close(release_write)", + " nested_cgroup_ready()", + " evacuate_sandbox()", + " arm_candidate_leaf()", + " if descriptor_protocol:", + " protocol_send({'kind':'ready','nonce':observation_nonce},4096,time.monotonic()+limits['trusted_timeout'])", + " start=protocol_receive(4096,time.monotonic()+limits['trusted_timeout'])", + " if start!={'kind':'start','nonce':observation_nonce}: raise RuntimeError('protected descriptor start is invalid')", + " else:", + " (control/'ready').write_text('ready',encoding='ascii')", + " wait_for('start')", + " signal_inner_start()", " result,timed_out=_supervise_candidate(pid,limits['timeout'])", " def nested_status(deadline):", " record=b''", @@ -2380,6 +2490,10 @@ def _staged_bwrap( " os.replace(temporary,control/'result.json')", " wait_for('finish')", "finally:", + " for descriptor in (inner_ready_read,inner_start_write):", + " if descriptor is not None:", + " try: os.close(descriptor)", + " except OSError: pass", " if pid is not None and result is None:", " try: os.kill(pid,9)", " except ProcessLookupError: pass", @@ -2392,9 +2506,21 @@ def _staged_bwrap( " except subprocess.TimeoutExpired:", " cleanup_error='umount timed out'; continue", " if completed.returncode != 0: cleanup_error=completed.stderr or 'umount failed'", + " if sandbox_cgroup is not None and sandbox_cgroup.exists():", + " try: kill_sandbox_tree()", + " except (OSError,RuntimeError) as error: cleanup_error=str(error)", " if candidate_cgroup is not None and candidate_cgroup.exists():", " try: kill_candidate_leaf(); candidate_cgroup.rmdir()", " except (OSError,RuntimeError) as error: cleanup_error=str(error)", + " if trusted_cgroup is not None and trusted_cgroup.exists():", + " try: kill_trusted_leaf(); trusted_cgroup.rmdir()", + " except (OSError,RuntimeError) as error: cleanup_error=str(error)", + " if sandbox_cgroup is not None and sandbox_cgroup.exists():", + " try:", + " if candidate_cgroup.exists() or trusted_cgroup.exists() or cgroup_members(sandbox_cgroup): raise RuntimeError('sandbox cgroup cleanup precondition failed')", + " (sandbox_cgroup/'cgroup.subtree_control').write_text(" + "'-memory -pids',encoding='ascii'); sandbox_cgroup.rmdir()", + " except (OSError,RuntimeError) as error: cleanup_error=str(error)", " if scope_cgroup is not None:", " try:", " (scope_cgroup/'cgroup.subtree_control').write_text(" @@ -2823,12 +2949,16 @@ def _probe_scope( if differences: raise RuntimeError("protected scope properties unverified: " + ", ".join(differences)) parent = _scope_cgroup(properties) - cgroup = parent / "candidate" monitor = parent / "monitor" + sandbox = parent / "sandbox" + trusted = sandbox / "trusted" + cgroup = sandbox / "candidate" try: parent_resolved = parent.resolve(strict=True) metadata = cgroup.lstat() monitor_metadata = monitor.lstat() + sandbox_metadata = sandbox.lstat() + trusted_metadata = trusted.lstat() if ( not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode) @@ -2836,12 +2966,22 @@ def _probe_scope( or not stat.S_ISDIR(monitor_metadata.st_mode) or stat.S_ISLNK(monitor_metadata.st_mode) or stat.S_IMODE(monitor_metadata.st_mode) != 0o755 - or cgroup.resolve(strict=True).parent != parent_resolved + or not stat.S_ISDIR(sandbox_metadata.st_mode) + or stat.S_ISLNK(sandbox_metadata.st_mode) + or stat.S_IMODE(sandbox_metadata.st_mode) != 0o755 + or not stat.S_ISDIR(trusted_metadata.st_mode) + or stat.S_ISLNK(trusted_metadata.st_mode) + or stat.S_IMODE(trusted_metadata.st_mode) != 0o755 or monitor.resolve(strict=True).parent != parent_resolved + or sandbox.resolve(strict=True).parent != parent_resolved + or trusted.resolve(strict=True).parent != sandbox.resolve(strict=True) + or cgroup.resolve(strict=True).parent != sandbox.resolve(strict=True) ): raise RuntimeError("protected scope candidate leaf topology is invalid") parent_members = (parent / "cgroup.procs").read_text(encoding="ascii").split() monitor_members = (monitor / "cgroup.procs").read_text(encoding="ascii").split() + sandbox_members = (sandbox / "cgroup.procs").read_text(encoding="ascii").split() + trusted_members = (trusted / "cgroup.procs").read_text(encoding="ascii").split() candidate_members = (cgroup / "cgroup.procs").read_text(encoding="ascii").split() except OSError as exc: raise RuntimeError("protected scope candidate leaf is unavailable") from exc @@ -2849,6 +2989,9 @@ def _probe_scope( parent_members or len(monitor_members) != 1 or not monitor_members[0].isdecimal() + or sandbox_members + or not trusted_members + or any(not member.isdecimal() for member in trusted_members) or candidate_members ): raise RuntimeError("protected scope candidate leaf membership is invalid") @@ -2865,6 +3008,15 @@ def _probe_scope( raise RuntimeError( f"protected scope kernel limit mismatch: {filename}={actual}" ) + for filename in ("memory.max", "memory.swap.max", "pids.max"): + try: + actual = (trusted / filename).read_text(encoding="ascii").strip() + except OSError as exc: + raise RuntimeError(f"protected scope cannot read trusted {filename}") from exc + if actual != "max": + raise RuntimeError( + f"protected scope trusted limit mismatch: {filename}={actual}" + ) return cgroup, _cgroup_events(cgroup, "memory.events"), _cgroup_events(cgroup, "pids.events") @@ -3027,12 +3179,10 @@ def _sandbox_command( storage_roots = _writable_storage_roots(writable_sources) if _writable_size(storage_roots) > limits.max_writable_bytes: raise RuntimeError("initial writable quota exceeded") - # Keep the host cgroup namespace while exposing only the candidate leaf - # below. A namespace rooted at the monitor cannot migrate the child into - # its sibling candidate leaf; the read-only candidate uid still cannot - # change membership or limits after the root supervisor performs it. + # Bubblewrap unshares only after the outer helper places it in sandbox; + # the inner helper then sees trusted and candidate below that namespace root. argv = [str(tools.bwrap), "--unshare-ipc", "--unshare-pid", "--unshare-net", - "--unshare-uts", "--die-with-parent", "--new-session", + "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp", "--dir", "/sys", "--dir", "/sys/fs", "--dir", "/sys/fs/cgroup"] sources: list[Path] = [] @@ -3258,8 +3408,8 @@ def bind( # Nested declared toolchain mounts must be installed after broader # inferred roots or Bubblewrap would hide them with the later root bind. argv.extend(deferred_readable_mounts) - # The root status supervisor moves only its forked child into this leaf - # before ``setpriv`` drops the candidate's authority to alter cgroups. + # Bubblewrap enters this subtree before unsharing its cgroup namespace. + # Its root status supervisor can then reach only the candidate descendant. argv.extend(("--bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) # ``setpriv`` and the root helper interpreter execute after the # namespace root is installed. Bind each exact invoked spelling with @@ -3318,10 +3468,14 @@ def bind( sandboxed, result_fd ) status_fd = 4 if result_write_fd is not None else 3 + ready_fd = status_fd + 1 + start_fd = status_fd + 2 argv.extend(( "--", str(tools.helper_python), "-I", "-S", "-c", _INNER_STATUS_SUPERVISOR_SOURCE, str(status_fd), - "@PDD-TERMINATION-TOKEN@", "/sys/fs/cgroup", *drop, *sandboxed, + "@PDD-TERMINATION-TOKEN@", "/sys/fs/cgroup/candidate", + str(ready_fd), str(start_fd), str(_TRUSTED_COMMAND_SECONDS), + *drop, *sandboxed, )) if consumed_proofs != proofs.keys(): raise RuntimeError("protected sandbox has unused immutable binding proof") diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 1eb7f0bae2..517f9179da 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1114,8 +1114,7 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( assert plan.unit_name.startswith("pdd-validator-") assert argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] bwrap = plan.bwrap_argv - assert {"--unshare-pid", "--unshare-net"} <= set(bwrap) - assert "--unshare-cgroup" not in bwrap + assert {"--unshare-pid", "--unshare-net", "--unshare-cgroup"} <= set(bwrap) assert "--unshare-user" not in bwrap separator = bwrap.index("--") assert bwrap.index("--bind") < separator < bwrap.index("--reuid") @@ -2885,7 +2884,7 @@ def test_linux_sandbox_binds_probe_identity_to_systemd_scope_execution( assert plan.bwrap_argv[0] == tools["bwrap"] -def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( +def test_linux_sandbox_stages_candidate_in_namespace_visible_leaf_before_exec( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """The privileged helper must move the blocked child before untrusted exec.""" @@ -2908,16 +2907,31 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( compile(helper, "", "exec") assert "monitor_cgroup=scope_cgroup/'monitor'" in helper - assert "candidate_cgroup=scope_cgroup/'candidate'" in helper + assert "sandbox_cgroup=scope_cgroup/'sandbox'" in helper + assert "trusted_cgroup=sandbox_cgroup/'trusted'" in helper + assert "candidate_cgroup=sandbox_cgroup/'candidate'" in helper assert "monitor_cgroup.mkdir(mode=0o755)" in helper + assert "sandbox_cgroup.mkdir(mode=0o755)" in helper + assert "trusted_cgroup.mkdir(mode=0o755)" in helper assert "candidate_cgroup.mkdir(mode=0o755)" in helper assert "monitor_cgroup.chmod(0o755)" in helper assert "candidate_cgroup.chmod(0o755)" in helper assert "(monitor_cgroup/'cgroup.procs').write_text(str(os.getpid())" in helper + assert helper.index( + "(monitor_cgroup/'cgroup.procs').write_text(str(os.getpid())" + ) < helper.index( + "(scope_cgroup/'cgroup.subtree_control').write_text(" + ) + assert helper.index("\n configure_cgroup_topology()\n") < helper.index("pid=os.fork()") + assert helper.index("pid=os.fork()") < helper.index("\n arm_candidate_leaf()\n") assert "(candidate_cgroup/'memory.max').write_text(str(limits['memory'])" in helper assert "(candidate_cgroup/'memory.swap.max').write_text('0'" in helper assert "(candidate_cgroup/'memory.oom.group').write_text('1'" in helper assert "(candidate_cgroup/'pids.max').write_text(str(limits['pids'])" in helper + assert "(sandbox_cgroup/'memory.max')" not in helper + assert "(sandbox_cgroup/'memory.swap.max')" not in helper + assert "(sandbox_cgroup/'memory.oom.group')" not in helper + assert "(sandbox_cgroup/'pids.max')" not in helper assert "(candidate_cgroup/'cgroup.kill').write_text('1'" in helper assert "ready" in helper and "start" in helper assert helper.index("start") < helper.index("os.fork()") @@ -2926,7 +2940,25 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( assert fork_offset < helper.index("observation_thread.start()") assert fork_offset < helper.index("[thread.start() for thread in candidate_output_threads]") assert "os.read(release_read,1)" in helper - assert "(candidate_cgroup/'cgroup.procs').write_text(str(pid)" not in helper + assert "(sandbox_cgroup/'cgroup.procs').write_text(str(pid)" in helper + assert helper.index("(sandbox_cgroup/'cgroup.procs').write_text(str(pid)") < helper.index( + "os.write(release_write,b'1')" + ) + assert helper.index("os.write(release_write,b'1')") < helper.index( + "\n nested_cgroup_ready()\n" + ) + assert helper.index("\n nested_cgroup_ready()\n") < helper.index( + "\n evacuate_sandbox()\n" + ) + assert helper.index("\n evacuate_sandbox()\n") < helper.index( + "\n arm_candidate_leaf()\n" + ) + assert helper.index("\n arm_candidate_leaf()\n") < helper.index( + "\n signal_inner_start()\n" + ) + assert helper.index("\n arm_candidate_leaf()\n") < helper.index( + "protocol_send({'kind':'ready'" + ) < helper.index("\n signal_inner_start()\n") assert helper.index("os.read(release_read,1)") < helper.index( "os.execvpe(argv[0],argv,os.environ)" ) @@ -2941,9 +2973,19 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( assert plan.launch_payload is not None assert plan.launch_payload["limits"]["timeout"] == 17 assert "result.json" in helper and "finish" in helper - assert "candidate cgroup remained populated" in helper + assert "raise RuntimeError(f'{label} cgroup remained populated')" in helper + assert supervisor._INNER_STATUS_SUPERVISOR_SOURCE.index( + "if read_bounded(start_fd" + ) < supervisor._INNER_STATUS_SUPERVISOR_SOURCE.index("pid=os.fork()") assert "candidate_cgroup.rmdir()" in helper + assert "trusted_cgroup.rmdir()" in helper + assert "sandbox_cgroup.rmdir()" in helper assert "monitor_cgroup.rmdir()" in helper + assert helper.index("'-memory -pids',encoding='ascii'); sandbox_cgroup.rmdir()") > helper.index( + "candidate_cgroup.rmdir()" + ) + assert "str(sandbox_cgroup),str(cgroup_target)" in helper + assert "str(candidate_cgroup),str(cgroup_target)" not in helper assert "-t','tmpfs" in helper assert "/proc/self/mountinfo" in helper assert "writable tmpfs mount probe failed" in helper @@ -2966,7 +3008,10 @@ def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( status_supervisor = plan.bwrap_argv.index( supervisor._INNER_STATUS_SUPERVISOR_SOURCE ) - assert plan.bwrap_argv[status_supervisor + 3] == "/sys/fs/cgroup" + assert plan.bwrap_argv[status_supervisor + 3] == "/sys/fs/cgroup/candidate" + assert plan.bwrap_argv[status_supervisor + 4:status_supervisor + 7] == ( + "4", "5", str(supervisor._TRUSTED_COMMAND_SECONDS), + ) def _generated_pidfd_protocol(namespace: dict[str, object] | None = None): @@ -3190,7 +3235,13 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( tools = supervisor._trusted_tools() cgroup = tmp_path / "scope-cgroup" cgroup.mkdir() - candidate = cgroup / "candidate" + sandbox = cgroup / "sandbox" + sandbox.mkdir() + sandbox.chmod(0o755) + trusted = sandbox / "trusted" + trusted.mkdir() + trusted.chmod(0o755) + candidate = sandbox / "candidate" candidate.mkdir() candidate.chmod(0o755) monitor = cgroup / "monitor" @@ -3198,6 +3249,8 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( monitor.chmod(0o755) (cgroup / "cgroup.procs").write_text("", encoding="ascii") (monitor / "cgroup.procs").write_text("123\n", encoding="ascii") + (sandbox / "cgroup.procs").write_text("", encoding="ascii") + (trusted / "cgroup.procs").write_text("456\n", encoding="ascii") (candidate / "cgroup.procs").write_text("", encoding="ascii") values = { "memory.max": "2147483648", "memory.swap.max": "0", @@ -3207,6 +3260,8 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( } for name, value in values.items(): (candidate / name).write_text(value, encoding="ascii") + for name in ("memory.max", "memory.swap.max", "pids.max"): + (trusted / name).write_text("max", encoding="ascii") properties = { "LoadState": "loaded", "ActiveState": "active", "ControlGroup": "/system.slice/example.scope", @@ -3227,6 +3282,10 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( assert actual_cgroup == candidate assert memory["oom_kill"] == 2 assert pids["max"] == 4 + (sandbox / "cgroup.procs").write_text("789\n", encoding="ascii") + with pytest.raises(RuntimeError, match="membership is invalid"): + supervisor._probe_scope(plan, SupervisorLimits()) + (sandbox / "cgroup.procs").write_text("", encoding="ascii") properties["KillMode"] = "process" with pytest.raises(RuntimeError, match="properties unverified"): supervisor._probe_scope(plan, SupervisorLimits()) @@ -3300,6 +3359,35 @@ def test_exact_supervisor_candidate_leaf_preserves_monitor_and_events( assert result.returncode == 124, result.stderr +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not shutil.which("bwrap"), + reason="requires Linux delegated cgroup-v2 and bubblewrap", +) +def test_real_cgroup_namespace_arms_only_after_internal_process_evacuation( + tmp_path: Path, +) -> None: + """A real delegated tree accepts controllers after trusted tasks move below it.""" + result, surviving = run_supervised( + [sys.executable, "-c", "print(open('/proc/self/cgroup').read().strip())"], + cwd=tmp_path, timeout=10, env=_credential_free_environment(tmp_path), + writable_roots=(tmp_path,), + ) + unavailable = ( + "requires privileged bind staging", + "has no cgroup-v2 membership", + "delegated cgroup controllers unavailable", + "protected cgroup.kill unavailable", + ) + if result.termination.kind is supervisor.TerminationKind.SANDBOX_ERROR and any( + marker in result.stderr for marker in unavailable + ): + pytest.skip(f"delegated cgroup-v2 unavailable: {result.stderr.strip()}") + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "0::/candidate" + assert surviving is False + + @pytest.mark.parametrize( ("filename", "content", "missing"), [ @@ -5229,7 +5317,7 @@ def capture_live_state( def await_live_role_identities(details: dict[str, object]) -> None: """Bind all four live roles to exact procfs identities and ancestry.""" expected_roles = ("coordinator", "worker", "browser", "descendant") - candidate = Path(str(details["cgroup"])) / "candidate" + candidate = Path(str(details["cgroup"])) / "sandbox" / "candidate" deadline = time.monotonic() + 10 last_records = [] while time.monotonic() < deadline: @@ -5270,7 +5358,7 @@ def await_live_role_identities(details: dict[str, object]) -> None: def await_same_blocked_role_identities(details: dict[str, object]) -> None: """Re-observe the recorded roles as blocked candidates before parent loss.""" - candidate = Path(str(details["cgroup"])) / "candidate" + candidate = Path(str(details["cgroup"])) / "sandbox" / "candidate" deadline = time.monotonic() + 10 last_scan = None while time.monotonic() < deadline: @@ -5807,6 +5895,13 @@ def test_candidate_record_parser_fails_closed_for_every_malformed_shape( supervisor._load_candidate_record_payload(payload) +def _cgroup_handshake_record(prefix: bytes, state: str, token: str) -> bytes: + payload = json.dumps( + {"state": state, "token": token}, sort_keys=True, separators=(",", ":"), + ).encode("ascii") + return (prefix + payload + b"\n").ljust(supervisor._CGROUP_HANDSHAKE_BYTES, b" ") + + @pytest.mark.parametrize( ("program", "expected"), [ @@ -5823,20 +5918,38 @@ def test_inner_status_supervisor_authenticates_nested_returncode( cgroup.mkdir() (cgroup / "cgroup.procs").touch() read_fd, write_fd = os.pipe() + ready_read, ready_write = os.pipe() + start_read, start_write = os.pipe() try: + os.write( + start_write, + _cgroup_handshake_record(supervisor._CGROUP_START_PREFIX, "start", token), + ) + os.close(start_write) + start_write = -1 completed = subprocess.run( [sys.executable, "-I", "-S", "-c", supervisor._INNER_STATUS_SUPERVISOR_SOURCE, str(write_fd), token, - str(cgroup), sys.executable, "-c", program], - pass_fds=(write_fd,), check=False, timeout=5, + str(cgroup), str(ready_write), str(start_read), "5", + sys.executable, "-c", program], + pass_fds=(write_fd, ready_write, start_read), check=False, timeout=5, ) os.close(write_fd) write_fd = -1 + os.close(ready_write) + ready_write = -1 + ready = os.read(ready_read, supervisor._CGROUP_HANDSHAKE_BYTES) record = os.read(read_fd, supervisor._TERMINATION_HEADER_BYTES) finally: os.close(read_fd) - if write_fd >= 0: - os.close(write_fd) + os.close(ready_read) + os.close(start_read) + for descriptor in (write_fd, ready_write, start_write): + if descriptor >= 0: + os.close(descriptor) + assert ready == _cgroup_handshake_record( + supervisor._CGROUP_READY_PREFIX, "ready", token, + ) assert len(record) == supervisor._TERMINATION_HEADER_BYTES assert record.startswith(supervisor._TERMINATION_HEADER_PREFIX) payload = json.loads(record[len(supervisor._TERMINATION_HEADER_PREFIX):].rstrip()) @@ -5855,33 +5968,111 @@ def test_inner_status_supervisor_moves_only_candidate_to_cgroup( candidate_pid = tmp_path / "candidate-pid" token = "a" * 32 read_fd, write_fd = os.pipe() + ready_read, ready_write = os.pipe() + start_read, start_write = os.pipe() program = ( "from pathlib import Path;import os,sys;" "Path(sys.argv[1]).write_text(str(os.getpid()),encoding='ascii')" ) try: + os.write( + start_write, + _cgroup_handshake_record(supervisor._CGROUP_START_PREFIX, "start", token), + ) + os.close(start_write) + start_write = -1 completed = subprocess.run( [ sys.executable, "-I", "-S", "-c", supervisor._INNER_STATUS_SUPERVISOR_SOURCE, str(write_fd), token, - str(cgroup), sys.executable, "-c", program, str(candidate_pid), + str(cgroup), str(ready_write), str(start_read), "5", + sys.executable, "-c", program, str(candidate_pid), ], - pass_fds=(write_fd,), check=False, timeout=5, + pass_fds=(write_fd, ready_write, start_read), check=False, timeout=5, ) os.close(write_fd) write_fd = -1 + os.close(ready_write) + ready_write = -1 + ready = os.read(ready_read, supervisor._CGROUP_HANDSHAKE_BYTES) record = os.read(read_fd, supervisor._TERMINATION_HEADER_BYTES) finally: os.close(read_fd) - if write_fd >= 0: - os.close(write_fd) + os.close(ready_read) + os.close(start_read) + for descriptor in (write_fd, ready_write, start_write): + if descriptor >= 0: + os.close(descriptor) + assert ready == _cgroup_handshake_record( + supervisor._CGROUP_READY_PREFIX, "ready", token, + ) payload = json.loads(record[len(supervisor._TERMINATION_HEADER_PREFIX):].rstrip()) assert completed.returncode == 0 assert payload == {"returncode": 0, "token": token} assert membership.read_text(encoding="ascii") == candidate_pid.read_text(encoding="ascii") +@pytest.mark.parametrize("mode", ["forged", "timeout"]) +def test_inner_status_supervisor_requires_bounded_authenticated_start_before_fork( + tmp_path: Path, mode: str, +) -> None: + """The trusted inner helper cannot fork until its outer helper authenticates.""" + token = "a" * 32 + cgroup = tmp_path / "candidate-cgroup" + cgroup.mkdir() + (cgroup / "cgroup.procs").touch() + marker = tmp_path / "forked" + status_read, status_write = os.pipe() + ready_read, ready_write = os.pipe() + start_read, start_write = os.pipe() + try: + if mode == "forged": + os.write( + start_write, + _cgroup_handshake_record( + supervisor._CGROUP_START_PREFIX, "start", "b" * 32, + ), + ) + os.close(start_write) + start_write = -1 + completed = subprocess.run( + [ + sys.executable, "-I", "-S", "-c", + supervisor._INNER_STATUS_SUPERVISOR_SOURCE, + str(status_write), token, str(cgroup), str(ready_write), + str(start_read), "0.02" if mode == "timeout" else "5", + sys.executable, "-c", + "from pathlib import Path;import sys;Path(sys.argv[1]).touch()", + str(marker), + ], + pass_fds=(status_write, ready_write, start_read), + capture_output=True, check=False, timeout=5, + ) + os.close(status_write) + status_write = -1 + os.close(ready_write) + ready_write = -1 + ready = os.read(ready_read, supervisor._CGROUP_HANDSHAKE_BYTES) + termination = os.read(status_read, supervisor._TERMINATION_HEADER_BYTES) + finally: + for descriptor in ( + status_read, status_write, ready_read, ready_write, + start_read, start_write, + ): + if descriptor >= 0: + os.close(descriptor) + + assert completed.returncode != 0 + assert ready == _cgroup_handshake_record( + supervisor._CGROUP_READY_PREFIX, "ready", token, + ) + assert termination == b"" + assert not marker.exists() + expected = "start is invalid" if mode == "forged" else "start timed out" + assert expected in completed.stderr.decode() + + def test_descriptor_result_rejects_replayed_nonce_and_aggregate() -> None: """A result from another helper lifecycle cannot be replayed as a PASS.""" observation = b'{"tests":[]}' From 391a9e856b392828b3f37904243a2c23bfc5e441 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 15:18:24 -0700 Subject: [PATCH 196/233] Revert "fix(sync): arm candidate cgroup after namespace entry" This reverts commit 58beab3886afac88de99c05a5c36cbb39b427acf. --- pdd/sync_core/supervisor.py | 234 +++++------------------------ tests/test_sync_core_supervisor.py | 227 +++------------------------- 2 files changed, 58 insertions(+), 403 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 9aef50cf56..9117be060b 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -57,9 +57,6 @@ _MAX_STAGING_BYTES = 4 * 1024 * 1024 * 1024 _TERMINATION_HEADER_BYTES = 256 _TERMINATION_HEADER_PREFIX = b"PDD-TERMINATION-V1 " -_CGROUP_HANDSHAKE_BYTES = 128 -_CGROUP_READY_PREFIX = b"PDD-CGROUP-READY-V1 " -_CGROUP_START_PREFIX = b"PDD-CGROUP-START-V1 " _VITEST_MEMBER_ROLES = frozenset({ "launcher", "entrypoint", "dependencies", "native_runtime", "lockfile", }) @@ -1839,31 +1836,10 @@ def _limited_command( _INNER_STATUS_SUPERVISOR_SOURCE = "\n".join(( - "import json,os,pathlib,select,signal,sys,time", - "fd=int(sys.argv[1]);token=sys.argv[2];cgroup=pathlib.Path(sys.argv[3]);ready_fd=int(sys.argv[4]);start_fd=int(sys.argv[5]);timeout=float(sys.argv[6]);command=sys.argv[7:]", - "if not command or not cgroup.is_absolute() or '..' in cgroup.parts or len(token)!=32 or any(c not in '0123456789abcdef' for c in token) or len({fd,ready_fd,start_fd})!=3 or any(value<3 or value>255 for value in (fd,ready_fd,start_fd)) or not 0 1: raise RuntimeError("protected sandbox has ambiguous observation staging") helper = "\n".join(( - "import base64,errno,hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,threading,time", + "import base64,hashlib,json,math,os,pathlib,select,shutil,stat,subprocess,sys,threading,time", "if len(sys.argv)!=1: raise RuntimeError('invalid protected helper protocol')", "protocol_in=sys.stdin.buffer; protocol_in_fd=sys.stdin.fileno(); protocol_out_fd=sys.stdout.fileno()", "def protocol_send(payload,maximum,deadline):", @@ -2021,8 +1997,8 @@ def _staged_bwrap( "observation_read=None; observation_write=None; observation_thread=None", "observation_chunks=[]; observation_size=0; observation_overflow=False", "candidate_stdout_read=None; candidate_stdout_write=None; candidate_stderr_read=None; candidate_stderr_write=None; candidate_stdout=[]; candidate_stderr=[]; candidate_output_size=0; candidate_output_overflow=False; candidate_output_threads=[]; candidate_output_lock=threading.Lock()", - "status_read=None; status_write=None; inner_ready_read=None; inner_ready_write=None; inner_start_read=None; inner_start_write=None", - "scope_cgroup=None; monitor_cgroup=None; sandbox_cgroup=None; trusted_cgroup=None; candidate_cgroup=None", + "status_read=None; status_write=None", + "scope_cgroup=None; monitor_cgroup=None; candidate_cgroup=None", _PIDFD_PROTOCOL_SOURCE.strip(), _IMMUTABLE_STAGING_SOURCE, _SNAPSHOT_STAGING_SOURCE, @@ -2094,35 +2070,21 @@ def _staged_bwrap( "raise RuntimeError(f'invalid cgroup event file: {path.name}')", " values[fields[0]]=fields[1]", " return values", - "def cgroup_members(cgroup):", - " values=(cgroup/'cgroup.procs').read_text(encoding='ascii').split()", - " if len(values)!=len(set(values)) or any(not value.isdecimal() for value in values): raise RuntimeError('invalid cgroup membership')", - " return values", - "def wait_leaf_empty(cgroup,label):", + "def wait_candidate_empty():", " deadline=time.monotonic()+limits['trusted_timeout']", " while time.monotonic()limits['observation']: observation_overflow=True", " elif not observation_overflow: observation_chunks.append(chunk)", " observation_thread=threading.Thread(target=drain_observation,daemon=True)", - " configure_cgroup_topology()", - " subprocess.run([mount,'--bind',str(sandbox_cgroup),str(cgroup_target)]," + " configure_candidate_leaf()", + " subprocess.run([mount,'--bind',str(candidate_cgroup),str(cgroup_target)]," "check=True,timeout=limits['trusted_timeout'])", " staged.append(cgroup_target)", " subprocess.run([umount,str(cgroup_target)],check=True," "timeout=limits['trusted_timeout'])", " staged.pop()", - " subprocess.run([mount,'--bind',str(sandbox_cgroup),str(cgroup_target)]," + " subprocess.run([mount,'--bind',str(candidate_cgroup),str(cgroup_target)]," "check=True,timeout=limits['trusted_timeout'])", " staged.append(cgroup_target)", " argv=[str(cgroup_target) if value == '@PDD-CGROUP@' else value for value in argv]", + " if descriptor_protocol:", + " protocol_send({'kind':'ready','nonce':observation_nonce},4096,time.monotonic()+limits['trusted_timeout'])", + " start=protocol_receive(4096,time.monotonic()+limits['trusted_timeout'])", + " if start!={'kind':'start','nonce':observation_nonce}: raise RuntimeError('protected descriptor start is invalid')", + " else:", + " (control/'ready').write_text('ready',encoding='ascii')", + " wait_for('start')", " release_read,release_write=os.pipe()", " status_read,status_write=os.pipe()", - " inner_ready_read,inner_ready_write=os.pipe()", - " inner_start_read,inner_start_write=os.pipe()", " os.set_blocking(status_read,False)", " verify_tool('bwrap'); verify_tool('setpriv')", " if descriptor_protocol:", @@ -2369,7 +2280,6 @@ def _staged_bwrap( " os.close(release_write)", " try:", " os.close(status_read)", - " os.close(inner_ready_read);os.close(inner_start_write)", " if anonymous_observation:", " os.close(observation_read); os.dup2(observation_write,3) if observation_write!=3 else None", " os.close(observation_write) if observation_write!=3 else None", @@ -2381,25 +2291,16 @@ def _staged_bwrap( " if os.read(release_read,1)!=b'1': os._exit(125)", " os.close(release_read)", " status_fd=4 if anonymous_observation else 3", - " ready_fd=status_fd+1;start_fd=status_fd+2", - " mappings=((status_write,status_fd),(inner_ready_write,ready_fd),(inner_start_read,start_fd))", - " for source,target in mappings:", - " if source!=target: os.dup2(source,target)", - " for source,_target in mappings:", - " if source not in {status_fd,ready_fd,start_fd}: os.close(source)", + " os.dup2(status_write,status_fd) if status_write!=status_fd else None", + " os.close(status_write) if status_write!=status_fd else None", " os.execvpe(argv[0],argv,os.environ)", " except OSError: os._exit(125)", " os.close(release_read)", " os.close(status_write)", - " os.close(inner_ready_write);inner_ready_write=None", - " os.close(inner_start_read);inner_start_read=None", " if anonymous_observation: os.close(observation_write)", " if descriptor_protocol: os.close(candidate_stdout_write); os.close(candidate_stderr_write)", " if anonymous_observation: observation_thread.start()", " if descriptor_protocol: [thread.start() for thread in candidate_output_threads]", - " (sandbox_cgroup/'cgroup.procs').write_text(str(pid),encoding='ascii')", - " sandbox_members=(sandbox_cgroup/'cgroup.procs').read_text(encoding='ascii').split()", - " if str(pid) not in sandbox_members: raise RuntimeError('sandbox cgroup placement failed')", " parent_watch_done=threading.Event(); parent_watch=None", " if descriptor_protocol:", " def watch_parent():", @@ -2409,17 +2310,6 @@ def _staged_bwrap( " kill_candidate_leaf(); return", " parent_watch=threading.Thread(target=watch_parent,daemon=True); parent_watch.start()", " os.write(release_write,b'1'); os.close(release_write)", - " nested_cgroup_ready()", - " evacuate_sandbox()", - " arm_candidate_leaf()", - " if descriptor_protocol:", - " protocol_send({'kind':'ready','nonce':observation_nonce},4096,time.monotonic()+limits['trusted_timeout'])", - " start=protocol_receive(4096,time.monotonic()+limits['trusted_timeout'])", - " if start!={'kind':'start','nonce':observation_nonce}: raise RuntimeError('protected descriptor start is invalid')", - " else:", - " (control/'ready').write_text('ready',encoding='ascii')", - " wait_for('start')", - " signal_inner_start()", " result,timed_out=_supervise_candidate(pid,limits['timeout'])", " def nested_status(deadline):", " record=b''", @@ -2490,10 +2380,6 @@ def _staged_bwrap( " os.replace(temporary,control/'result.json')", " wait_for('finish')", "finally:", - " for descriptor in (inner_ready_read,inner_start_write):", - " if descriptor is not None:", - " try: os.close(descriptor)", - " except OSError: pass", " if pid is not None and result is None:", " try: os.kill(pid,9)", " except ProcessLookupError: pass", @@ -2506,21 +2392,9 @@ def _staged_bwrap( " except subprocess.TimeoutExpired:", " cleanup_error='umount timed out'; continue", " if completed.returncode != 0: cleanup_error=completed.stderr or 'umount failed'", - " if sandbox_cgroup is not None and sandbox_cgroup.exists():", - " try: kill_sandbox_tree()", - " except (OSError,RuntimeError) as error: cleanup_error=str(error)", " if candidate_cgroup is not None and candidate_cgroup.exists():", " try: kill_candidate_leaf(); candidate_cgroup.rmdir()", " except (OSError,RuntimeError) as error: cleanup_error=str(error)", - " if trusted_cgroup is not None and trusted_cgroup.exists():", - " try: kill_trusted_leaf(); trusted_cgroup.rmdir()", - " except (OSError,RuntimeError) as error: cleanup_error=str(error)", - " if sandbox_cgroup is not None and sandbox_cgroup.exists():", - " try:", - " if candidate_cgroup.exists() or trusted_cgroup.exists() or cgroup_members(sandbox_cgroup): raise RuntimeError('sandbox cgroup cleanup precondition failed')", - " (sandbox_cgroup/'cgroup.subtree_control').write_text(" - "'-memory -pids',encoding='ascii'); sandbox_cgroup.rmdir()", - " except (OSError,RuntimeError) as error: cleanup_error=str(error)", " if scope_cgroup is not None:", " try:", " (scope_cgroup/'cgroup.subtree_control').write_text(" @@ -2949,16 +2823,12 @@ def _probe_scope( if differences: raise RuntimeError("protected scope properties unverified: " + ", ".join(differences)) parent = _scope_cgroup(properties) + cgroup = parent / "candidate" monitor = parent / "monitor" - sandbox = parent / "sandbox" - trusted = sandbox / "trusted" - cgroup = sandbox / "candidate" try: parent_resolved = parent.resolve(strict=True) metadata = cgroup.lstat() monitor_metadata = monitor.lstat() - sandbox_metadata = sandbox.lstat() - trusted_metadata = trusted.lstat() if ( not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode) @@ -2966,22 +2836,12 @@ def _probe_scope( or not stat.S_ISDIR(monitor_metadata.st_mode) or stat.S_ISLNK(monitor_metadata.st_mode) or stat.S_IMODE(monitor_metadata.st_mode) != 0o755 - or not stat.S_ISDIR(sandbox_metadata.st_mode) - or stat.S_ISLNK(sandbox_metadata.st_mode) - or stat.S_IMODE(sandbox_metadata.st_mode) != 0o755 - or not stat.S_ISDIR(trusted_metadata.st_mode) - or stat.S_ISLNK(trusted_metadata.st_mode) - or stat.S_IMODE(trusted_metadata.st_mode) != 0o755 + or cgroup.resolve(strict=True).parent != parent_resolved or monitor.resolve(strict=True).parent != parent_resolved - or sandbox.resolve(strict=True).parent != parent_resolved - or trusted.resolve(strict=True).parent != sandbox.resolve(strict=True) - or cgroup.resolve(strict=True).parent != sandbox.resolve(strict=True) ): raise RuntimeError("protected scope candidate leaf topology is invalid") parent_members = (parent / "cgroup.procs").read_text(encoding="ascii").split() monitor_members = (monitor / "cgroup.procs").read_text(encoding="ascii").split() - sandbox_members = (sandbox / "cgroup.procs").read_text(encoding="ascii").split() - trusted_members = (trusted / "cgroup.procs").read_text(encoding="ascii").split() candidate_members = (cgroup / "cgroup.procs").read_text(encoding="ascii").split() except OSError as exc: raise RuntimeError("protected scope candidate leaf is unavailable") from exc @@ -2989,9 +2849,6 @@ def _probe_scope( parent_members or len(monitor_members) != 1 or not monitor_members[0].isdecimal() - or sandbox_members - or not trusted_members - or any(not member.isdecimal() for member in trusted_members) or candidate_members ): raise RuntimeError("protected scope candidate leaf membership is invalid") @@ -3008,15 +2865,6 @@ def _probe_scope( raise RuntimeError( f"protected scope kernel limit mismatch: {filename}={actual}" ) - for filename in ("memory.max", "memory.swap.max", "pids.max"): - try: - actual = (trusted / filename).read_text(encoding="ascii").strip() - except OSError as exc: - raise RuntimeError(f"protected scope cannot read trusted {filename}") from exc - if actual != "max": - raise RuntimeError( - f"protected scope trusted limit mismatch: {filename}={actual}" - ) return cgroup, _cgroup_events(cgroup, "memory.events"), _cgroup_events(cgroup, "pids.events") @@ -3179,10 +3027,12 @@ def _sandbox_command( storage_roots = _writable_storage_roots(writable_sources) if _writable_size(storage_roots) > limits.max_writable_bytes: raise RuntimeError("initial writable quota exceeded") - # Bubblewrap unshares only after the outer helper places it in sandbox; - # the inner helper then sees trusted and candidate below that namespace root. + # Keep the host cgroup namespace while exposing only the candidate leaf + # below. A namespace rooted at the monitor cannot migrate the child into + # its sibling candidate leaf; the read-only candidate uid still cannot + # change membership or limits after the root supervisor performs it. argv = [str(tools.bwrap), "--unshare-ipc", "--unshare-pid", "--unshare-net", - "--unshare-uts", "--unshare-cgroup", "--die-with-parent", "--new-session", + "--unshare-uts", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp", "--dir", "/sys", "--dir", "/sys/fs", "--dir", "/sys/fs/cgroup"] sources: list[Path] = [] @@ -3408,8 +3258,8 @@ def bind( # Nested declared toolchain mounts must be installed after broader # inferred roots or Bubblewrap would hide them with the later root bind. argv.extend(deferred_readable_mounts) - # Bubblewrap enters this subtree before unsharing its cgroup namespace. - # Its root status supervisor can then reach only the candidate descendant. + # The root status supervisor moves only its forked child into this leaf + # before ``setpriv`` drops the candidate's authority to alter cgroups. argv.extend(("--bind", "@PDD-CGROUP@", "/sys/fs/cgroup")) # ``setpriv`` and the root helper interpreter execute after the # namespace root is installed. Bind each exact invoked spelling with @@ -3468,14 +3318,10 @@ def bind( sandboxed, result_fd ) status_fd = 4 if result_write_fd is not None else 3 - ready_fd = status_fd + 1 - start_fd = status_fd + 2 argv.extend(( "--", str(tools.helper_python), "-I", "-S", "-c", _INNER_STATUS_SUPERVISOR_SOURCE, str(status_fd), - "@PDD-TERMINATION-TOKEN@", "/sys/fs/cgroup/candidate", - str(ready_fd), str(start_fd), str(_TRUSTED_COMMAND_SECONDS), - *drop, *sandboxed, + "@PDD-TERMINATION-TOKEN@", "/sys/fs/cgroup", *drop, *sandboxed, )) if consumed_proofs != proofs.keys(): raise RuntimeError("protected sandbox has unused immutable binding proof") diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 517f9179da..1eb7f0bae2 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1114,7 +1114,8 @@ def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( assert plan.unit_name.startswith("pdd-validator-") assert argv[:3] == [str(plan.tools.sudo), "-n", str(plan.tools.systemd_run)] bwrap = plan.bwrap_argv - assert {"--unshare-pid", "--unshare-net", "--unshare-cgroup"} <= set(bwrap) + assert {"--unshare-pid", "--unshare-net"} <= set(bwrap) + assert "--unshare-cgroup" not in bwrap assert "--unshare-user" not in bwrap separator = bwrap.index("--") assert bwrap.index("--bind") < separator < bwrap.index("--reuid") @@ -2884,7 +2885,7 @@ def test_linux_sandbox_binds_probe_identity_to_systemd_scope_execution( assert plan.bwrap_argv[0] == tools["bwrap"] -def test_linux_sandbox_stages_candidate_in_namespace_visible_leaf_before_exec( +def test_linux_sandbox_stages_candidate_in_limited_leaf_before_exec( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """The privileged helper must move the blocked child before untrusted exec.""" @@ -2907,31 +2908,16 @@ def test_linux_sandbox_stages_candidate_in_namespace_visible_leaf_before_exec( compile(helper, "", "exec") assert "monitor_cgroup=scope_cgroup/'monitor'" in helper - assert "sandbox_cgroup=scope_cgroup/'sandbox'" in helper - assert "trusted_cgroup=sandbox_cgroup/'trusted'" in helper - assert "candidate_cgroup=sandbox_cgroup/'candidate'" in helper + assert "candidate_cgroup=scope_cgroup/'candidate'" in helper assert "monitor_cgroup.mkdir(mode=0o755)" in helper - assert "sandbox_cgroup.mkdir(mode=0o755)" in helper - assert "trusted_cgroup.mkdir(mode=0o755)" in helper assert "candidate_cgroup.mkdir(mode=0o755)" in helper assert "monitor_cgroup.chmod(0o755)" in helper assert "candidate_cgroup.chmod(0o755)" in helper assert "(monitor_cgroup/'cgroup.procs').write_text(str(os.getpid())" in helper - assert helper.index( - "(monitor_cgroup/'cgroup.procs').write_text(str(os.getpid())" - ) < helper.index( - "(scope_cgroup/'cgroup.subtree_control').write_text(" - ) - assert helper.index("\n configure_cgroup_topology()\n") < helper.index("pid=os.fork()") - assert helper.index("pid=os.fork()") < helper.index("\n arm_candidate_leaf()\n") assert "(candidate_cgroup/'memory.max').write_text(str(limits['memory'])" in helper assert "(candidate_cgroup/'memory.swap.max').write_text('0'" in helper assert "(candidate_cgroup/'memory.oom.group').write_text('1'" in helper assert "(candidate_cgroup/'pids.max').write_text(str(limits['pids'])" in helper - assert "(sandbox_cgroup/'memory.max')" not in helper - assert "(sandbox_cgroup/'memory.swap.max')" not in helper - assert "(sandbox_cgroup/'memory.oom.group')" not in helper - assert "(sandbox_cgroup/'pids.max')" not in helper assert "(candidate_cgroup/'cgroup.kill').write_text('1'" in helper assert "ready" in helper and "start" in helper assert helper.index("start") < helper.index("os.fork()") @@ -2940,25 +2926,7 @@ def test_linux_sandbox_stages_candidate_in_namespace_visible_leaf_before_exec( assert fork_offset < helper.index("observation_thread.start()") assert fork_offset < helper.index("[thread.start() for thread in candidate_output_threads]") assert "os.read(release_read,1)" in helper - assert "(sandbox_cgroup/'cgroup.procs').write_text(str(pid)" in helper - assert helper.index("(sandbox_cgroup/'cgroup.procs').write_text(str(pid)") < helper.index( - "os.write(release_write,b'1')" - ) - assert helper.index("os.write(release_write,b'1')") < helper.index( - "\n nested_cgroup_ready()\n" - ) - assert helper.index("\n nested_cgroup_ready()\n") < helper.index( - "\n evacuate_sandbox()\n" - ) - assert helper.index("\n evacuate_sandbox()\n") < helper.index( - "\n arm_candidate_leaf()\n" - ) - assert helper.index("\n arm_candidate_leaf()\n") < helper.index( - "\n signal_inner_start()\n" - ) - assert helper.index("\n arm_candidate_leaf()\n") < helper.index( - "protocol_send({'kind':'ready'" - ) < helper.index("\n signal_inner_start()\n") + assert "(candidate_cgroup/'cgroup.procs').write_text(str(pid)" not in helper assert helper.index("os.read(release_read,1)") < helper.index( "os.execvpe(argv[0],argv,os.environ)" ) @@ -2973,19 +2941,9 @@ def test_linux_sandbox_stages_candidate_in_namespace_visible_leaf_before_exec( assert plan.launch_payload is not None assert plan.launch_payload["limits"]["timeout"] == 17 assert "result.json" in helper and "finish" in helper - assert "raise RuntimeError(f'{label} cgroup remained populated')" in helper - assert supervisor._INNER_STATUS_SUPERVISOR_SOURCE.index( - "if read_bounded(start_fd" - ) < supervisor._INNER_STATUS_SUPERVISOR_SOURCE.index("pid=os.fork()") + assert "candidate cgroup remained populated" in helper assert "candidate_cgroup.rmdir()" in helper - assert "trusted_cgroup.rmdir()" in helper - assert "sandbox_cgroup.rmdir()" in helper assert "monitor_cgroup.rmdir()" in helper - assert helper.index("'-memory -pids',encoding='ascii'); sandbox_cgroup.rmdir()") > helper.index( - "candidate_cgroup.rmdir()" - ) - assert "str(sandbox_cgroup),str(cgroup_target)" in helper - assert "str(candidate_cgroup),str(cgroup_target)" not in helper assert "-t','tmpfs" in helper assert "/proc/self/mountinfo" in helper assert "writable tmpfs mount probe failed" in helper @@ -3008,10 +2966,7 @@ def test_linux_sandbox_stages_candidate_in_namespace_visible_leaf_before_exec( status_supervisor = plan.bwrap_argv.index( supervisor._INNER_STATUS_SUPERVISOR_SOURCE ) - assert plan.bwrap_argv[status_supervisor + 3] == "/sys/fs/cgroup/candidate" - assert plan.bwrap_argv[status_supervisor + 4:status_supervisor + 7] == ( - "4", "5", str(supervisor._TRUSTED_COMMAND_SECONDS), - ) + assert plan.bwrap_argv[status_supervisor + 3] == "/sys/fs/cgroup" def _generated_pidfd_protocol(namespace: dict[str, object] | None = None): @@ -3235,13 +3190,7 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( tools = supervisor._trusted_tools() cgroup = tmp_path / "scope-cgroup" cgroup.mkdir() - sandbox = cgroup / "sandbox" - sandbox.mkdir() - sandbox.chmod(0o755) - trusted = sandbox / "trusted" - trusted.mkdir() - trusted.chmod(0o755) - candidate = sandbox / "candidate" + candidate = cgroup / "candidate" candidate.mkdir() candidate.chmod(0o755) monitor = cgroup / "monitor" @@ -3249,8 +3198,6 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( monitor.chmod(0o755) (cgroup / "cgroup.procs").write_text("", encoding="ascii") (monitor / "cgroup.procs").write_text("123\n", encoding="ascii") - (sandbox / "cgroup.procs").write_text("", encoding="ascii") - (trusted / "cgroup.procs").write_text("456\n", encoding="ascii") (candidate / "cgroup.procs").write_text("", encoding="ascii") values = { "memory.max": "2147483648", "memory.swap.max": "0", @@ -3260,8 +3207,6 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( } for name, value in values.items(): (candidate / name).write_text(value, encoding="ascii") - for name in ("memory.max", "memory.swap.max", "pids.max"): - (trusted / name).write_text("max", encoding="ascii") properties = { "LoadState": "loaded", "ActiveState": "active", "ControlGroup": "/system.slice/example.scope", @@ -3282,10 +3227,6 @@ def test_scope_probe_requires_systemd_and_kernel_limits_before_release( assert actual_cgroup == candidate assert memory["oom_kill"] == 2 assert pids["max"] == 4 - (sandbox / "cgroup.procs").write_text("789\n", encoding="ascii") - with pytest.raises(RuntimeError, match="membership is invalid"): - supervisor._probe_scope(plan, SupervisorLimits()) - (sandbox / "cgroup.procs").write_text("", encoding="ascii") properties["KillMode"] = "process" with pytest.raises(RuntimeError, match="properties unverified"): supervisor._probe_scope(plan, SupervisorLimits()) @@ -3359,35 +3300,6 @@ def test_exact_supervisor_candidate_leaf_preserves_monitor_and_events( assert result.returncode == 124, result.stderr -@pytest.mark.skipif( - not sys.platform.startswith("linux") or not shutil.which("bwrap"), - reason="requires Linux delegated cgroup-v2 and bubblewrap", -) -def test_real_cgroup_namespace_arms_only_after_internal_process_evacuation( - tmp_path: Path, -) -> None: - """A real delegated tree accepts controllers after trusted tasks move below it.""" - result, surviving = run_supervised( - [sys.executable, "-c", "print(open('/proc/self/cgroup').read().strip())"], - cwd=tmp_path, timeout=10, env=_credential_free_environment(tmp_path), - writable_roots=(tmp_path,), - ) - unavailable = ( - "requires privileged bind staging", - "has no cgroup-v2 membership", - "delegated cgroup controllers unavailable", - "protected cgroup.kill unavailable", - ) - if result.termination.kind is supervisor.TerminationKind.SANDBOX_ERROR and any( - marker in result.stderr for marker in unavailable - ): - pytest.skip(f"delegated cgroup-v2 unavailable: {result.stderr.strip()}") - - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "0::/candidate" - assert surviving is False - - @pytest.mark.parametrize( ("filename", "content", "missing"), [ @@ -5317,7 +5229,7 @@ def capture_live_state( def await_live_role_identities(details: dict[str, object]) -> None: """Bind all four live roles to exact procfs identities and ancestry.""" expected_roles = ("coordinator", "worker", "browser", "descendant") - candidate = Path(str(details["cgroup"])) / "sandbox" / "candidate" + candidate = Path(str(details["cgroup"])) / "candidate" deadline = time.monotonic() + 10 last_records = [] while time.monotonic() < deadline: @@ -5358,7 +5270,7 @@ def await_live_role_identities(details: dict[str, object]) -> None: def await_same_blocked_role_identities(details: dict[str, object]) -> None: """Re-observe the recorded roles as blocked candidates before parent loss.""" - candidate = Path(str(details["cgroup"])) / "sandbox" / "candidate" + candidate = Path(str(details["cgroup"])) / "candidate" deadline = time.monotonic() + 10 last_scan = None while time.monotonic() < deadline: @@ -5895,13 +5807,6 @@ def test_candidate_record_parser_fails_closed_for_every_malformed_shape( supervisor._load_candidate_record_payload(payload) -def _cgroup_handshake_record(prefix: bytes, state: str, token: str) -> bytes: - payload = json.dumps( - {"state": state, "token": token}, sort_keys=True, separators=(",", ":"), - ).encode("ascii") - return (prefix + payload + b"\n").ljust(supervisor._CGROUP_HANDSHAKE_BYTES, b" ") - - @pytest.mark.parametrize( ("program", "expected"), [ @@ -5918,38 +5823,20 @@ def test_inner_status_supervisor_authenticates_nested_returncode( cgroup.mkdir() (cgroup / "cgroup.procs").touch() read_fd, write_fd = os.pipe() - ready_read, ready_write = os.pipe() - start_read, start_write = os.pipe() try: - os.write( - start_write, - _cgroup_handshake_record(supervisor._CGROUP_START_PREFIX, "start", token), - ) - os.close(start_write) - start_write = -1 completed = subprocess.run( [sys.executable, "-I", "-S", "-c", supervisor._INNER_STATUS_SUPERVISOR_SOURCE, str(write_fd), token, - str(cgroup), str(ready_write), str(start_read), "5", - sys.executable, "-c", program], - pass_fds=(write_fd, ready_write, start_read), check=False, timeout=5, + str(cgroup), sys.executable, "-c", program], + pass_fds=(write_fd,), check=False, timeout=5, ) os.close(write_fd) write_fd = -1 - os.close(ready_write) - ready_write = -1 - ready = os.read(ready_read, supervisor._CGROUP_HANDSHAKE_BYTES) record = os.read(read_fd, supervisor._TERMINATION_HEADER_BYTES) finally: os.close(read_fd) - os.close(ready_read) - os.close(start_read) - for descriptor in (write_fd, ready_write, start_write): - if descriptor >= 0: - os.close(descriptor) - assert ready == _cgroup_handshake_record( - supervisor._CGROUP_READY_PREFIX, "ready", token, - ) + if write_fd >= 0: + os.close(write_fd) assert len(record) == supervisor._TERMINATION_HEADER_BYTES assert record.startswith(supervisor._TERMINATION_HEADER_PREFIX) payload = json.loads(record[len(supervisor._TERMINATION_HEADER_PREFIX):].rstrip()) @@ -5968,111 +5855,33 @@ def test_inner_status_supervisor_moves_only_candidate_to_cgroup( candidate_pid = tmp_path / "candidate-pid" token = "a" * 32 read_fd, write_fd = os.pipe() - ready_read, ready_write = os.pipe() - start_read, start_write = os.pipe() program = ( "from pathlib import Path;import os,sys;" "Path(sys.argv[1]).write_text(str(os.getpid()),encoding='ascii')" ) try: - os.write( - start_write, - _cgroup_handshake_record(supervisor._CGROUP_START_PREFIX, "start", token), - ) - os.close(start_write) - start_write = -1 completed = subprocess.run( [ sys.executable, "-I", "-S", "-c", supervisor._INNER_STATUS_SUPERVISOR_SOURCE, str(write_fd), token, - str(cgroup), str(ready_write), str(start_read), "5", - sys.executable, "-c", program, str(candidate_pid), + str(cgroup), sys.executable, "-c", program, str(candidate_pid), ], - pass_fds=(write_fd, ready_write, start_read), check=False, timeout=5, + pass_fds=(write_fd,), check=False, timeout=5, ) os.close(write_fd) write_fd = -1 - os.close(ready_write) - ready_write = -1 - ready = os.read(ready_read, supervisor._CGROUP_HANDSHAKE_BYTES) record = os.read(read_fd, supervisor._TERMINATION_HEADER_BYTES) finally: os.close(read_fd) - os.close(ready_read) - os.close(start_read) - for descriptor in (write_fd, ready_write, start_write): - if descriptor >= 0: - os.close(descriptor) + if write_fd >= 0: + os.close(write_fd) - assert ready == _cgroup_handshake_record( - supervisor._CGROUP_READY_PREFIX, "ready", token, - ) payload = json.loads(record[len(supervisor._TERMINATION_HEADER_PREFIX):].rstrip()) assert completed.returncode == 0 assert payload == {"returncode": 0, "token": token} assert membership.read_text(encoding="ascii") == candidate_pid.read_text(encoding="ascii") -@pytest.mark.parametrize("mode", ["forged", "timeout"]) -def test_inner_status_supervisor_requires_bounded_authenticated_start_before_fork( - tmp_path: Path, mode: str, -) -> None: - """The trusted inner helper cannot fork until its outer helper authenticates.""" - token = "a" * 32 - cgroup = tmp_path / "candidate-cgroup" - cgroup.mkdir() - (cgroup / "cgroup.procs").touch() - marker = tmp_path / "forked" - status_read, status_write = os.pipe() - ready_read, ready_write = os.pipe() - start_read, start_write = os.pipe() - try: - if mode == "forged": - os.write( - start_write, - _cgroup_handshake_record( - supervisor._CGROUP_START_PREFIX, "start", "b" * 32, - ), - ) - os.close(start_write) - start_write = -1 - completed = subprocess.run( - [ - sys.executable, "-I", "-S", "-c", - supervisor._INNER_STATUS_SUPERVISOR_SOURCE, - str(status_write), token, str(cgroup), str(ready_write), - str(start_read), "0.02" if mode == "timeout" else "5", - sys.executable, "-c", - "from pathlib import Path;import sys;Path(sys.argv[1]).touch()", - str(marker), - ], - pass_fds=(status_write, ready_write, start_read), - capture_output=True, check=False, timeout=5, - ) - os.close(status_write) - status_write = -1 - os.close(ready_write) - ready_write = -1 - ready = os.read(ready_read, supervisor._CGROUP_HANDSHAKE_BYTES) - termination = os.read(status_read, supervisor._TERMINATION_HEADER_BYTES) - finally: - for descriptor in ( - status_read, status_write, ready_read, ready_write, - start_read, start_write, - ): - if descriptor >= 0: - os.close(descriptor) - - assert completed.returncode != 0 - assert ready == _cgroup_handshake_record( - supervisor._CGROUP_READY_PREFIX, "ready", token, - ) - assert termination == b"" - assert not marker.exists() - expected = "start is invalid" if mode == "forged" else "start timed out" - assert expected in completed.stderr.decode() - - def test_descriptor_result_rejects_replayed_nonce_and_aggregate() -> None: """A result from another helper lifecycle cannot be replayed as a PASS.""" observation = b'{"tests":[]}' From 82778c87284bd742246973777a354f6d36ef9629 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 15:03:56 -0700 Subject: [PATCH 197/233] test(sync): reproduce authority token aliasing --- tests/test_sync_core_supervisor.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 1eb7f0bae2..e96cb8feaa 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -113,6 +113,18 @@ def test_candidate_environment_record_is_canonical_and_complete(tmp_path: Path) assert list(parsed) == sorted(parsed) +def test_supervision_token_generation_is_independent_of_staging_identifiers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A deterministic authority token cannot collapse bind-staging identities.""" + monkeypatch.setattr( + supervisor.uuid, "uuid4", lambda: SimpleNamespace(hex="d" * 32) + ) + monkeypatch.setattr(supervisor.os, "urandom", lambda size: b"\xaa" * size) + + assert supervisor._fresh_supervision_token() == "aa" * 16 + + @pytest.mark.parametrize( "payload", [ From d18819dc158ebbc4e5b46a718b018105cedc7e0f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 15:18:08 -0700 Subject: [PATCH 198/233] fix(sync): separate supervision authority tokens --- pdd/sync_core/supervisor.py | 9 +++++++-- tests/test_sync_core_supervisor.py | 8 ++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pdd/sync_core/supervisor.py b/pdd/sync_core/supervisor.py index 9117be060b..b3cb46e2ce 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1331,6 +1331,11 @@ def _scope_unit_name() -> str: return f"pdd-validator-{uuid.uuid4().hex}.scope" +def _fresh_supervision_token() -> str: + """Return authority bytes independently of staging and unit identifiers.""" + return os.urandom(16).hex() + + def _validated_scope_unit(unit_name: str) -> str: """Reject any unit spelling that could target another systemd object.""" if _SCOPE_PATTERN.fullmatch(unit_name) is None: @@ -3391,7 +3396,7 @@ def _run_playwright_descriptor_supervised( """Run aggregate Playwright evidence over the helper's inherited descriptors only.""" # pylint: disable=too-many-locals,too-many-arguments,too-many-branches # pylint: disable=too-many-statements,consider-using-with - supervision_token = uuid.uuid4().hex + supervision_token = _fresh_supervision_token() nonce = os.urandom(32).hex() diagnostics = bytearray() helper_stderr = bytearray() @@ -3611,7 +3616,7 @@ def run_supervised( writable_bindings=writable_bindings, temp_directory=temp_directory, result_write_fd=result_write_fd, result_fd=result_fd, ) - token = uuid.uuid4().hex + token = _fresh_supervision_token() observation_nonce = os.urandom(32).hex() if result_write_fd is not None else None unit_name = _scope_unit_name() stdout_buffer = bytearray() diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index e96cb8feaa..f68a19eec0 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -3735,9 +3735,7 @@ def test_real_linux_adapter_environment_handoff( ) -> None: """Every adapter receives only its exact post-drop sanitized environment.""" token = "d" * 32 - monkeypatch.setattr( - supervisor.uuid, "uuid4", lambda: SimpleNamespace(hex=token) - ) + monkeypatch.setattr(supervisor, "_fresh_supervision_token", lambda: token) home = tmp_path / "home" environments = { "pytest": { @@ -5434,9 +5432,7 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None unit = "pdd-validator-" + "e" * 32 + ".scope" token = "c" * 32 monkeypatch.setattr(supervisor, "_scope_unit_name", lambda: unit) - monkeypatch.setattr( - supervisor.uuid, "uuid4", lambda: SimpleNamespace(hex=token) - ) + monkeypatch.setattr(supervisor, "_fresh_supervision_token", lambda: token) monkeypatch.setattr(supervisor.tempfile, "tempdir", str(tmp_path)) read_fd, write_fd = os.pipe() report = tmp_path / "stalled-observation-reader.json" From 3eb253a739170b8b6250b4b3402bf180d8347dc2 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 15:38:55 -0700 Subject: [PATCH 199/233] test(sync): reproduce kernel-sized descriptor stall --- tests/test_sync_core_supervisor.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index f68a19eec0..aa236f7ca6 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -5799,6 +5799,19 @@ def test_descriptor_control_read_has_monotonic_deadline() -> None: os.close(write_fd) +def test_descriptor_stall_fixture_saturates_the_exact_pipe() -> None: + """The hosted cleanup fixture cannot depend on a kernel pipe-size default.""" + read_fd, write_fd = os.pipe() + try: + _saturate_descriptor_pipe(write_fd) + os.set_blocking(write_fd, False) + with pytest.raises(BlockingIOError): + os.write(write_fd, b"x") + finally: + os.close(read_fd) + os.close(write_fd) + + @pytest.mark.parametrize("payload", [ None, [], {}, {"version": 1}, {"version": 1, "state": "terminal", "returncode": 0}, From 9abf98d5acd85d1fc5d6b0b7292093b2ba20cc8f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 15:39:37 -0700 Subject: [PATCH 200/233] fix(sync): saturate stalled descriptor fixture --- tests/test_sync_core_supervisor.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index aa236f7ca6..7c64e82dbc 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4358,6 +4358,22 @@ def _cleanup_failure(primary: BaseException, cleanup: BaseException) -> None: primary.add_note(f"stalled-observation cleanup failure: {cleanup}") +def _saturate_descriptor_pipe(write_fd: int) -> None: + """Fill one undrained pipe without assuming its kernel-selected capacity.""" + blocking = os.get_blocking(write_fd) + chunk = b"x" * 65536 + os.set_blocking(write_fd, False) + try: + for _attempt in range(4096): + try: + os.write(write_fd, chunk) + except BlockingIOError: + return + raise AssertionError("descriptor pipe did not reach capacity") + finally: + os.set_blocking(write_fd, blocking) + + def _fallback_stalled_observation_cleanup( ownership: dict[str, object], owned_fds: tuple[int, ...], *, runner=subprocess.run, scanner=_root_proc_scan, @@ -5435,6 +5451,7 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None monkeypatch.setattr(supervisor, "_fresh_supervision_token", lambda: token) monkeypatch.setattr(supervisor.tempfile, "tempdir", str(tmp_path)) read_fd, write_fd = os.pipe() + _saturate_descriptor_pipe(write_fd) report = tmp_path / "stalled-observation-reader.json" program = ( "import os;data=b'x'*262144;offset=0\n" From 79391abd8933158a46c8539ebd881e83be5b2cf2 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 16:10:50 -0700 Subject: [PATCH 201/233] test(sync): reproduce descriptor observer race --- tests/test_sync_core_supervisor.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 7c64e82dbc..2fcafb67d1 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -14,6 +14,7 @@ import subprocess import sys import tempfile +import threading import time from dataclasses import replace from pathlib import Path @@ -5829,6 +5830,33 @@ def test_descriptor_stall_fixture_saturates_the_exact_pipe() -> None: os.close(write_fd) +def test_descriptor_observer_barrier_precedes_the_real_handoff() -> None: + """Observation begins at the exact production write without replacing it.""" + ready_read, ready_write = os.pipe() + release_read, release_write = os.pipe() + called = threading.Event() + + def handoff(_descriptor: int, _data: bytes, _deadline: float) -> None: + called.set() + + worker = threading.Thread( + target=_write_after_observer_barrier, + args=(handoff, 9, b"payload", time.monotonic() + 1, + ready_write, release_read), + ) + try: + worker.start() + readable, _, _ = select.select([ready_read], [], [], 1) + assert readable and os.read(ready_read, 1) == b"R" + assert not called.is_set() + os.write(release_write, b"G") + worker.join(timeout=1) + assert not worker.is_alive() and called.is_set() + finally: + for descriptor in (ready_read, ready_write, release_read, release_write): + os.close(descriptor) + + @pytest.mark.parametrize("payload", [ None, [], {}, {"version": 1}, {"version": 1, "state": "terminal", "returncode": 0}, From 1108a585fddcdd9715d233c72058bfb6f4f69dc2 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 16:11:40 -0700 Subject: [PATCH 202/233] fix(sync): synchronize descriptor lifecycle observation --- tests/test_sync_core_supervisor.py | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 2fcafb67d1..168470aaba 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4375,6 +4375,18 @@ def _saturate_descriptor_pipe(write_fd: int) -> None: os.set_blocking(write_fd, blocking) +def _write_after_observer_barrier( + writer, descriptor: int, data: bytes, deadline: float, + ready_fd: int, release_fd: int, +) -> None: + """Signal the exact handoff point, then preserve the real bounded writer.""" + if os.write(ready_fd, b"R") != 1: + raise RuntimeError("descriptor observer readiness was not delivered") + if os.read(release_fd, 1) != b"G": + raise RuntimeError("descriptor observer release was not authenticated") + writer(descriptor, data, deadline) + + def _fallback_stalled_observation_cleanup( ownership: dict[str, object], owned_fds: tuple[int, ...], *, runner=subprocess.run, scanner=_root_proc_scan, @@ -5453,6 +5465,21 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None monkeypatch.setattr(supervisor.tempfile, "tempdir", str(tmp_path)) read_fd, write_fd = os.pipe() _saturate_descriptor_pipe(write_fd) + handoff_ready_read, handoff_ready_write = os.pipe() + handoff_release_read, handoff_release_write = os.pipe() + bounded_handoff = supervisor._write_all_descriptor_bytes + + def observed_handoff( + descriptor: int, data: bytes, deadline: float, + ) -> None: + _write_after_observer_barrier( + bounded_handoff, descriptor, data, deadline, + handoff_ready_write, handoff_release_read, + ) + + monkeypatch.setattr( + supervisor, "_write_all_descriptor_bytes", observed_handoff, + ) report = tmp_path / "stalled-observation-reader.json" program = ( "import os;data=b'x'*262144;offset=0\n" @@ -5461,6 +5488,8 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None coordinator = os.fork() if coordinator == 0: os.close(read_fd) + os.close(handoff_ready_read) + os.close(handoff_release_write) exit_status = 0 try: result, surviving = run_supervised( @@ -5487,9 +5516,18 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None }), encoding="utf-8") finally: os.close(write_fd) + os.close(handoff_ready_write) + os.close(handoff_release_read) os._exit(exit_status) os.close(write_fd) write_fd = -1 + os.close(handoff_ready_write) + handoff_ready_write = -1 + os.close(handoff_release_read) + handoff_release_read = -1 + ready, _, _ = select.select([handoff_ready_read], [], [], 10) + assert ready, "descriptor handoff did not become observable" + assert os.read(handoff_ready_read, 1) == b"R" details = None fallback_done = False external_reaper = None @@ -5615,6 +5653,9 @@ def early_failure_cleanup() -> None: assert read_fd == -1 and write_fd == -1 return deadline = time.monotonic() + 30 + assert os.write(handoff_release_write, b"G") == 1 + os.close(handoff_release_write) + handoff_release_write = -1 while time.monotonic() < deadline: waited, status = os.waitpid(coordinator, os.WNOHANG) if waited == coordinator: @@ -5660,6 +5701,12 @@ def early_failure_cleanup() -> None: except subprocess.TimeoutExpired: external_reaper.kill() external_reaper.wait(timeout=5) + for descriptor in ( + handoff_ready_read, handoff_ready_write, + handoff_release_read, handoff_release_write, + ): + if descriptor >= 0: + os.close(descriptor) return program = inventory_program From 3b014c75e05296df299ae003344a2a9f10c7ee18 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 16:37:36 -0700 Subject: [PATCH 203/233] Revert "fix(sync): synchronize descriptor lifecycle observation" This reverts commit 1108a585fddcdd9715d233c72058bfb6f4f69dc2. --- tests/test_sync_core_supervisor.py | 47 ------------------------------ 1 file changed, 47 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 168470aaba..2fcafb67d1 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4375,18 +4375,6 @@ def _saturate_descriptor_pipe(write_fd: int) -> None: os.set_blocking(write_fd, blocking) -def _write_after_observer_barrier( - writer, descriptor: int, data: bytes, deadline: float, - ready_fd: int, release_fd: int, -) -> None: - """Signal the exact handoff point, then preserve the real bounded writer.""" - if os.write(ready_fd, b"R") != 1: - raise RuntimeError("descriptor observer readiness was not delivered") - if os.read(release_fd, 1) != b"G": - raise RuntimeError("descriptor observer release was not authenticated") - writer(descriptor, data, deadline) - - def _fallback_stalled_observation_cleanup( ownership: dict[str, object], owned_fds: tuple[int, ...], *, runner=subprocess.run, scanner=_root_proc_scan, @@ -5465,21 +5453,6 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None monkeypatch.setattr(supervisor.tempfile, "tempdir", str(tmp_path)) read_fd, write_fd = os.pipe() _saturate_descriptor_pipe(write_fd) - handoff_ready_read, handoff_ready_write = os.pipe() - handoff_release_read, handoff_release_write = os.pipe() - bounded_handoff = supervisor._write_all_descriptor_bytes - - def observed_handoff( - descriptor: int, data: bytes, deadline: float, - ) -> None: - _write_after_observer_barrier( - bounded_handoff, descriptor, data, deadline, - handoff_ready_write, handoff_release_read, - ) - - monkeypatch.setattr( - supervisor, "_write_all_descriptor_bytes", observed_handoff, - ) report = tmp_path / "stalled-observation-reader.json" program = ( "import os;data=b'x'*262144;offset=0\n" @@ -5488,8 +5461,6 @@ def observed_handoff( coordinator = os.fork() if coordinator == 0: os.close(read_fd) - os.close(handoff_ready_read) - os.close(handoff_release_write) exit_status = 0 try: result, surviving = run_supervised( @@ -5516,18 +5487,9 @@ def observed_handoff( }), encoding="utf-8") finally: os.close(write_fd) - os.close(handoff_ready_write) - os.close(handoff_release_read) os._exit(exit_status) os.close(write_fd) write_fd = -1 - os.close(handoff_ready_write) - handoff_ready_write = -1 - os.close(handoff_release_read) - handoff_release_read = -1 - ready, _, _ = select.select([handoff_ready_read], [], [], 10) - assert ready, "descriptor handoff did not become observable" - assert os.read(handoff_ready_read, 1) == b"R" details = None fallback_done = False external_reaper = None @@ -5653,9 +5615,6 @@ def early_failure_cleanup() -> None: assert read_fd == -1 and write_fd == -1 return deadline = time.monotonic() + 30 - assert os.write(handoff_release_write, b"G") == 1 - os.close(handoff_release_write) - handoff_release_write = -1 while time.monotonic() < deadline: waited, status = os.waitpid(coordinator, os.WNOHANG) if waited == coordinator: @@ -5701,12 +5660,6 @@ def early_failure_cleanup() -> None: except subprocess.TimeoutExpired: external_reaper.kill() external_reaper.wait(timeout=5) - for descriptor in ( - handoff_ready_read, handoff_ready_write, - handoff_release_read, handoff_release_write, - ): - if descriptor >= 0: - os.close(descriptor) return program = inventory_program From 73b2b37a3096b348a64f46077e26d54b6be215e8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 16:37:36 -0700 Subject: [PATCH 204/233] Revert "test(sync): reproduce descriptor observer race" This reverts commit 79391abd8933158a46c8539ebd881e83be5b2cf2. --- tests/test_sync_core_supervisor.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 2fcafb67d1..7c64e82dbc 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -14,7 +14,6 @@ import subprocess import sys import tempfile -import threading import time from dataclasses import replace from pathlib import Path @@ -5830,33 +5829,6 @@ def test_descriptor_stall_fixture_saturates_the_exact_pipe() -> None: os.close(write_fd) -def test_descriptor_observer_barrier_precedes_the_real_handoff() -> None: - """Observation begins at the exact production write without replacing it.""" - ready_read, ready_write = os.pipe() - release_read, release_write = os.pipe() - called = threading.Event() - - def handoff(_descriptor: int, _data: bytes, _deadline: float) -> None: - called.set() - - worker = threading.Thread( - target=_write_after_observer_barrier, - args=(handoff, 9, b"payload", time.monotonic() + 1, - ready_write, release_read), - ) - try: - worker.start() - readable, _, _ = select.select([ready_read], [], [], 1) - assert readable and os.read(ready_read, 1) == b"R" - assert not called.is_set() - os.write(release_write, b"G") - worker.join(timeout=1) - assert not worker.is_alive() and called.is_set() - finally: - for descriptor in (ready_read, ready_write, release_read, release_write): - os.close(descriptor) - - @pytest.mark.parametrize("payload", [ None, [], {}, {"version": 1}, {"version": 1, "state": "terminal", "returncode": 0}, From 1a8629d665191e6d6bdf80c0e493144fcd2585ac Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 16:38:11 -0700 Subject: [PATCH 205/233] test(sync): reproduce exhaustive initial lifecycle scan --- tests/test_sync_core_supervisor.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 7c64e82dbc..288bfc3263 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4712,6 +4712,30 @@ def test_root_proc_scanner_source_compiles() -> None: compile(_NAMESPACE_MOUNT_SCANNER_SOURCE, "", "exec") +def test_root_proc_scanner_forwards_scope_only_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Initial lifecycle capture can avoid an exhaustive host procfs walk.""" + captured = {} + + def scanner_run(argv, **_kwargs): + captured.update(json.loads(argv[-1])) + return SimpleNamespace(returncode=0, stdout="{}", stderr="") + + monkeypatch.setattr( + supervisor, "_trusted_tools", + lambda: SimpleNamespace(helper_python=Path("/trusted/python")), + ) + monkeypatch.setattr(subprocess, "run", scanner_run) + + _root_proc_scan( + cgroup=Path("/sys/fs/cgroup/pdd.scope"), watch_pids=(123,), + scope_only=True, + ) + + assert captured["scope_only"] is True + + @pytest.mark.parametrize( ("returncode", "stdout", "stderr"), ( From 5f455e880f2a7280620e7169fcec0ee1fc20e70d Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 16:41:29 -0700 Subject: [PATCH 206/233] fix(sync): bound initial lifecycle observation to scope --- tests/test_sync_core_supervisor.py | 49 +++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 288bfc3263..7ad3f59530 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -15,7 +15,7 @@ import sys import tempfile import time -from dataclasses import replace +from dataclasses import dataclass, replace from pathlib import Path from pathlib import PurePosixPath from types import SimpleNamespace @@ -3985,6 +3985,9 @@ def cgroup_pids(path): watched=set(payload.get('watch_pids',[])) if any(type(pid) is not int or pid<=0 for pid in watched): fail('invalid watched PID payload') + scope_only=payload.get('scope_only') + if type(scope_only) is not bool: + fail('invalid scope-only payload') expected_namespace=payload.get('namespace') if expected_namespace is not None and ( type(expected_namespace) is not dict or @@ -3999,10 +4002,14 @@ def cgroup_pids(path): prefix=canonical_path(prefix_value,'target prefix') if prefix_value else None identities=[]; cgroup_members=[]; watched_processes=[] current_holders=[]; fd_holders=[]; mount_holders=[] - for pid_dir in sorted( - (path for path in proc.iterdir() if path.name.isdigit()), - key=lambda path:int(path.name), - ): + if scope_only: + pid_directories=(proc/str(pid) for pid in sorted(members|watched)) + else: + pid_directories=sorted( + (path for path in proc.iterdir() if path.name.isdigit()), + key=lambda path:int(path.name), + ) + for pid_dir in pid_directories: if int(pid_dir.name)==self_pid: continue try: @@ -4084,10 +4091,18 @@ def cgroup_pids(path): """ +@dataclass(frozen=True) +class _RootProcSelection: + """Bound the privileged procfs scan to selected process identities.""" + + watch_pids: tuple[int, ...] = () + scope_only: bool = False + + def _root_proc_scan( *, cgroup: Path | None = None, namespace: dict[str, object] | None = None, targets: tuple[Path, ...] = (), target_prefix: Path | None = None, - watch_pids: tuple[int, ...] = (), + selection: _RootProcSelection = _RootProcSelection(), ) -> dict[str, object]: """Run one bounded root scanner that fails closed on ambiguous procfs evidence.""" payload = { @@ -4095,7 +4110,8 @@ def _root_proc_scan( "namespace": namespace, "targets": [str(path) for path in targets], "target_prefix": str(target_prefix) if target_prefix is not None else "", - "watch_pids": list(watch_pids), + "watch_pids": list(selection.watch_pids), + "scope_only": selection.scope_only, } scanner_python = supervisor._trusted_tools().helper_python try: @@ -4729,8 +4745,8 @@ def scanner_run(argv, **_kwargs): monkeypatch.setattr(subprocess, "run", scanner_run) _root_proc_scan( - cgroup=Path("/sys/fs/cgroup/pdd.scope"), watch_pids=(123,), - scope_only=True, + cgroup=Path("/sys/fs/cgroup/pdd.scope"), + selection=_RootProcSelection((123,), scope_only=True), ) assert captured["scope_only"] is True @@ -5211,7 +5227,7 @@ def capture_live_state( while time.monotonic() < deadline: scan = _root_proc_scan( cgroup=cgroup, targets=targets, target_prefix=target_prefix, - watch_pids=watch_pids, + selection=_RootProcSelection(watch_pids, scope_only=True), ) last_scan = scan members = scan["cgroup_members"] @@ -5228,7 +5244,8 @@ def capture_live_state( } exact_scan = _root_proc_scan( cgroup=cgroup, namespace=namespace, targets=targets, - target_prefix=target_prefix, watch_pids=watch_pids, + target_prefix=target_prefix, + selection=_RootProcSelection(watch_pids, scope_only=True), ) exact_members = exact_scan["cgroup_members"] exact_member_keys = {_process_key(record) for record in exact_members} @@ -5391,7 +5408,7 @@ def emergency_cleanup( if int(record["pid"]) == pid), None, ) - scan = _root_proc_scan(watch_pids=(pid,)) + scan = _root_proc_scan(selection=_RootProcSelection((pid,))) if expected is not None and any( _process_key(record) == _process_key(expected) for record in scan["watched"] @@ -5534,7 +5551,11 @@ def assert_initial_coordinator(scan: dict[str, object]) -> None: raise AssertionError("injected initial watched assertion failure") def initial_scan() -> dict[str, object]: - scan = _root_proc_scan(watch_pids=(coordinator,)) + scan = _root_proc_scan( + selection=_RootProcSelection( + (coordinator,), scope_only=True, + ), + ) if case == "initial-scan-failure": raise AssertionError("injected initial scan failure") return scan @@ -5612,7 +5633,7 @@ def early_failure_cleanup() -> None: namespace=details["namespace"], targets=tuple(Path(path) for path in details["mount_points"]), target_prefix=Path(str(details["control_prefix"])), - watch_pids=(int(holder_ready["pid"]),), + selection=_RootProcSelection((int(holder_ready["pid"]),)), ) holder_record = next(( record for record in holder_scan["fd_holders"] From ca60bd43f1ff837765830b541dc31a0ddf35eb76 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 16:47:09 -0700 Subject: [PATCH 207/233] test(sync): reject stale fallback scanner arguments --- tests/test_sync_core_supervisor.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 7ad3f59530..b73cf8289b 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4861,6 +4861,8 @@ def test_stalled_observation_setup_failure_preserves_primary_and_reaps_owned_sta def runner(*args, **_kwargs): commands.append(args[0]) + if args[0][2:4] == ["systemctl", "kill"]: + os.kill(coordinator, signal.SIGKILL) if args[0][2:4] == ["systemctl", "stop"]: return SimpleNamespace(returncode=5, stdout="", stderr="already removed") if args[0][2:4] == ["systemctl", "show"]: @@ -4868,10 +4870,16 @@ def runner(*args, **_kwargs): return SimpleNamespace(returncode=0, stdout="", stderr="") coordinator_record = {"pid": coordinator, "start_time": "unit-test"} + selections = [] - def scanner(**_kwargs): + def scanner( + *, cgroup=None, namespace=None, targets=(), target_prefix=None, + selection=_RootProcSelection(), + ): + del cgroup, namespace, targets, target_prefix nonlocal calls calls += 1 + selections.append(selection) if calls == 1: return { "watched": [coordinator_record], "mount_holders": [], @@ -4917,6 +4925,7 @@ def assert_watched(scan_result): os.fstat(read_fd) with pytest.raises(ChildProcessError): os.waitpid(coordinator, os.WNOHANG) + assert selections and selections[0] == _RootProcSelection((coordinator,)) assert commands == [ ["sudo", "-n", "systemctl", "kill", "--kill-whom=all", "--signal=SIGKILL", "pdd-validator-test.scope"], From 33e9c76e5a995990ea45c8c15cc42c933acc37e8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 16:49:54 -0700 Subject: [PATCH 208/233] fix(sync): preserve bounded fallback scan contract --- tests/test_sync_core_supervisor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index b73cf8289b..9d4baf5d30 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4440,7 +4440,7 @@ def scan_owned() -> dict[str, object] | None: return scanner( cgroup=cgroup, namespace=namespace, targets=tuple(sorted(captured_mounts)), target_prefix=control_prefix, - watch_pids=(int(coordinator["pid"]),), + selection=_RootProcSelection((int(coordinator["pid"]),)), ) except BaseException as exc: # pylint: disable=broad-exception-caught # Scanner failure is cleanup evidence, not a reason to abandon teardown. @@ -4610,7 +4610,9 @@ def holder_mount_scan() -> tuple[Path, ...] | None: for holder in ownership.get("external_holders", ()): expected = _process_key(holder) - holder_scan = scanner(watch_pids=(int(holder["pid"]),)) + holder_scan = scanner( + selection=_RootProcSelection((int(holder["pid"]),)), + ) if any(_process_key(record) == expected for record in holder_scan["watched"]): try: os.kill(int(holder["pid"]), signal.SIGTERM) From 27a3cb593da639c325875ebef2bca4360c0f858d Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 17:13:06 -0700 Subject: [PATCH 209/233] test(sync): reproduce unheld descriptor lifecycle --- tests/test_sync_core_supervisor.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 9d4baf5d30..0a36d2b1d5 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -14,6 +14,7 @@ import subprocess import sys import tempfile +import threading import time from dataclasses import dataclass, replace from pathlib import Path @@ -5885,6 +5886,35 @@ def test_descriptor_stall_fixture_saturates_the_exact_pipe() -> None: os.close(write_fd) +def test_validated_descriptor_result_gate_precedes_external_handoff() -> None: + """The helper-owned scope remains held before the bounded observer write.""" + ready_read, ready_write = os.pipe() + release_read, release_write = os.pipe() + returned = [] + worker = threading.Thread( + target=lambda: returned.append( + _hold_validated_descriptor_result( + object(), ready_write, release_read, + ) + ), + ) + try: + worker.start() + readable, _, _ = select.select([ready_read], [], [], 1) + assert readable and os.read(ready_read, 1) == b"R" + assert not returned + assert os.write(release_write, b"G") == 1 + worker.join(timeout=1) + assert not worker.is_alive() and len(returned) == 1 + source = inspect.getsource(supervisor._run_playwright_descriptor_supervised) + assert source.index("_descriptor_result(") < source.index( + "_write_all_descriptor_bytes(" + ) + finally: + for descriptor in (ready_read, ready_write, release_read, release_write): + os.close(descriptor) + + @pytest.mark.parametrize("payload", [ None, [], {}, {"version": 1}, {"version": 1, "state": "terminal", "returncode": 0}, From fcc185d90b50ff55de30b8d86dac82e7aa911f1e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 17:17:29 -0700 Subject: [PATCH 210/233] fix(sync): hold authenticated descriptor lifecycle --- tests/test_sync_core_supervisor.py | 113 +++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 14 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 0a36d2b1d5..1aa0bb133d 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4253,7 +4253,7 @@ def _exact_unit_not_found(completed: object) -> bool: _NSENTER_REVALIDATOR_SOURCE = r""" -import json,os,pathlib,sys +import json,os,pathlib,signal,sys payload=json.loads(sys.argv[1]) holder=payload['holder']; namespace=payload['namespace'] @@ -4301,6 +4301,9 @@ def exact_namespace(path): if identity()!=start_time: raise RuntimeError('holder identity raced before namespace entry') operation=payload.get('operation') +if operation=='terminate': + os.kill(pid,signal.SIGTERM) + raise SystemExit(0) if operation=='unmount': command=[payload['umount'],payload['mount']] elif operation=='scan': @@ -4391,6 +4394,17 @@ def _saturate_descriptor_pipe(write_fd: int) -> None: os.set_blocking(write_fd, blocking) +def _hold_validated_descriptor_result( + result: object, ready_fd: int, release_fd: int, +) -> object: + """Hold the authenticated helper result before its external handoff.""" + if os.write(ready_fd, b"R") != 1: + raise RuntimeError("validated descriptor result was not announced") + if os.read(release_fd, 1) != b"G": + raise RuntimeError("validated descriptor result was not released") + return result + + def _fallback_stalled_observation_cleanup( ownership: dict[str, object], owned_fds: tuple[int, ...], *, runner=subprocess.run, scanner=_root_proc_scan, @@ -4615,10 +4629,19 @@ def holder_mount_scan() -> tuple[Path, ...] | None: selection=_RootProcSelection((int(holder["pid"]),)), ) if any(_process_key(record) == expected for record in holder_scan["watched"]): - try: - os.kill(int(holder["pid"]), signal.SIGTERM) - except ProcessLookupError: - pass + completed = command([ + "sudo", "-n", str(supervisor._trusted_tools().helper_python), + "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, + json.dumps({ + "holder": holder, "namespace": namespace, + "operation": "terminate", + }, sort_keys=True), + ], "terminate exact external namespace holder") + if completed is not None and completed.returncode != 0: + errors.append( + completed.stderr.strip() + or "external namespace holder termination failed" + ) holder_deadline = min(deadline, time.monotonic() + 5) while time.monotonic() < holder_deadline: try: @@ -5503,21 +5526,36 @@ def assert_case_cleanup(control, details, process, should_succeed: bool) -> None monkeypatch.setattr(supervisor, "_scope_unit_name", lambda: unit) monkeypatch.setattr(supervisor, "_fresh_supervision_token", lambda: token) monkeypatch.setattr(supervisor.tempfile, "tempdir", str(tmp_path)) + scratch = tmp_path / "stalled-candidate-scratch" + scratch.mkdir() read_fd, write_fd = os.pipe() _saturate_descriptor_pipe(write_fd) + lifecycle_ready_read, lifecycle_ready_write = os.pipe() + lifecycle_release_read, lifecycle_release_write = os.pipe() report = tmp_path / "stalled-observation-reader.json" program = ( "import os;data=b'x'*262144;offset=0\n" "while offset None }), encoding="utf-8") finally: os.close(write_fd) + os.close(lifecycle_ready_write) + os.close(lifecycle_release_read) os._exit(exit_status) os.close(write_fd) write_fd = -1 + os.close(lifecycle_ready_write) + lifecycle_ready_write = -1 + os.close(lifecycle_release_read) + lifecycle_release_read = -1 details = None fallback_done = False external_reaper = None try: + ready, _, _ = select.select([lifecycle_ready_read], [], [], 10) + assert ready, "validated descriptor result was not observable" + assert os.read(lifecycle_ready_read, 1) == b"R" + def exact_control_prefix() -> Path: deadline = time.monotonic() + 10 while time.monotonic() < deadline: @@ -5574,6 +5622,7 @@ def initial_scan() -> dict[str, object]: def early_failure_cleanup() -> None: nonlocal details, read_fd, write_fd, fallback_done + nonlocal lifecycle_ready_read, lifecycle_release_write control_prefix = exact_control_prefix() details = capture_live_state( unit, (), target_prefix=control_prefix, @@ -5588,9 +5637,15 @@ def early_failure_cleanup() -> None: for path in details["mount_points"] ), "hosted injection requires a real nested control mount" try: - read_fd, write_fd = _fallback_stalled_observation_cleanup( - details, (read_fd, write_fd), + owned_fds = ( + read_fd, write_fd, lifecycle_ready_read, + lifecycle_release_write, ) + read_fd = write_fd = -1 + lifecycle_ready_read = lifecycle_release_write = -1 + assert _fallback_stalled_observation_cleanup( + details, owned_fds, + ) == (-1,) * len(owned_fds) finally: fallback_done = True @@ -5601,6 +5656,8 @@ def early_failure_cleanup() -> None: ) assert fallback_done and details is not None assert read_fd == -1 and write_fd == -1 + assert lifecycle_ready_read == -1 + assert lifecycle_release_write == -1 return _run_stalled_observation_setup( @@ -5663,13 +5720,26 @@ def early_failure_cleanup() -> None: assert holder_record is not None, "exact namespace-FD holder unavailable" details["namespace_holders"].append(holder_record) details["require_fd_only_holder"] = True - read_fd, write_fd = _fallback_stalled_observation_cleanup( - details, (read_fd, write_fd), + owned_fds = ( + read_fd, write_fd, lifecycle_ready_read, + lifecycle_release_write, ) - fallback_done = True + read_fd = write_fd = -1 + lifecycle_ready_read = lifecycle_release_write = -1 + try: + assert _fallback_stalled_observation_cleanup( + details, owned_fds, + ) == (-1,) * len(owned_fds) + finally: + fallback_done = True assert external_reaper.poll() is not None assert read_fd == -1 and write_fd == -1 + assert lifecycle_ready_read == -1 + assert lifecycle_release_write == -1 return + assert os.write(lifecycle_release_write, b"G") == 1 + os.close(lifecycle_release_write) + lifecycle_release_write = -1 deadline = time.monotonic() + 30 while time.monotonic() < deadline: waited, status = os.waitpid(coordinator, os.WNOHANG) @@ -5679,6 +5749,9 @@ def early_failure_cleanup() -> None: time.sleep(.05) else: raise AssertionError("stalled observation coordinator did not exit") + assert os.read(lifecycle_ready_read, 1) == b"" + os.close(lifecycle_ready_read) + lifecycle_ready_read = -1 await_automatic_cleanup(details) outcome = json.loads(report.read_text(encoding="utf-8")) assert outcome["kind"] == supervisor.TerminationKind.SANDBOX_ERROR.value @@ -5692,9 +5765,15 @@ def early_failure_cleanup() -> None: unit, (), target_prefix=exact_control_prefix(), watch_pids=(coordinator,), ) - read_fd, write_fd = _fallback_stalled_observation_cleanup( - details, (read_fd, write_fd), + owned_fds = ( + read_fd, write_fd, lifecycle_ready_read, + lifecycle_release_write, ) + read_fd = write_fd = -1 + lifecycle_ready_read = lifecycle_release_write = -1 + assert _fallback_stalled_observation_cleanup( + details, owned_fds, + ) == (-1,) * len(owned_fds) except BaseException as cleanup: _cleanup_failure(primary, cleanup) finally: @@ -5705,6 +5784,12 @@ def early_failure_cleanup() -> None: os.close(read_fd) if write_fd >= 0: os.close(write_fd) + for descriptor in ( + lifecycle_ready_read, lifecycle_ready_write, + lifecycle_release_read, lifecycle_release_write, + ): + if descriptor >= 0: + os.close(descriptor) try: os.waitpid(coordinator, os.WNOHANG) except ChildProcessError: From 63085063eefa6f269014bc663bdfae9d94da8ba0 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 17:22:30 -0700 Subject: [PATCH 211/233] test(sync): reproduce sparse holder termination race --- tests/test_sync_core_supervisor.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 1aa0bb133d..f8713a5ed8 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4861,6 +4861,33 @@ def test_namespace_holder_selection_rejects_wrong_kind_fd_reuse_and_race() -> No assert _namespace_entry_path(exact, namespace) == "/proc/22/fd/7" +def test_external_holder_termination_requires_complete_pidfd_identity() -> None: + """Termination binds the complete captured holder to a pidfd, never a PID.""" + namespace = {"link": "mnt:[11]", "inode": 11} + holder = { + "holder_kind": "fd", "pid": 22, "start_time": "100", + "namespace": "mnt:[1]", "namespace_inode": 1, + "fd": 7, "fd_path": "/proc/22/fd/7", + "fd_link": "mnt:[11]", "fd_inode": 11, + } + + payload = json.loads(_external_holder_termination_payload(holder, namespace)) + + assert payload == { + "holder": holder, "namespace": namespace, "operation": "terminate", + } + with pytest.raises(ValueError, match="descriptor"): + _external_holder_termination_payload( + {key: value for key, value in holder.items() if key != "fd_path"}, + namespace, + ) + assert "os.kill(" not in _NSENTER_REVALIDATOR_SOURCE + assert _NSENTER_REVALIDATOR_SOURCE.index("os.pidfd_open") < ( + _NSENTER_REVALIDATOR_SOURCE.index("identity()!=start_time") + ) + assert "signal.pidfd_send_signal" in _NSENTER_REVALIDATOR_SOURCE + + def test_cleanup_process_identity_rejects_pid_reuse() -> None: """A reused PID with a different kernel start time is not the recorded process.""" original = {"pid": 123, "start_time": "100"} From 57a28ff70a080478b0fae43aefe1bc8ebab72a10 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 17:24:51 -0700 Subject: [PATCH 212/233] fix(sync): bind external cleanup to pidfd identity --- tests/test_sync_core_supervisor.py | 90 ++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index f8713a5ed8..38b5c581a5 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4219,6 +4219,16 @@ def _namespace_entry_path( return expected_path +def _external_holder_termination_payload( + holder: dict[str, object], namespace: dict[str, object], +) -> str: + """Serialize only a complete captured holder for pidfd termination.""" + _namespace_entry_path(holder, namespace) + return json.dumps({ + "holder": holder, "namespace": namespace, "operation": "terminate", + }, sort_keys=True) + + def _holder_key(record: dict[str, object]) -> tuple[object, ...]: """Return the complete identity of one current or FD-only namespace holder.""" return ( @@ -4260,6 +4270,10 @@ def _exact_unit_not_found(completed: object) -> bool: pid=holder.get('pid'); start_time=holder.get('start_time') if type(pid) is not int or pid<=0 or type(start_time) is not str: raise SystemExit('invalid holder process identity') +try: + pidfd=os.pidfd_open(pid,0) +except ProcessLookupError: + raise SystemExit(0) proc=pathlib.Path('/proc')/str(pid) def identity(): @@ -4302,7 +4316,11 @@ def exact_namespace(path): raise RuntimeError('holder identity raced before namespace entry') operation=payload.get('operation') if operation=='terminate': - os.kill(pid,signal.SIGTERM) + try: + signal.pidfd_send_signal(pidfd,signal.SIGTERM,None,0) + except ProcessLookupError: + pass + os.close(pidfd) raise SystemExit(0) if operation=='unmount': command=[payload['umount'],payload['mount']] @@ -4632,10 +4650,7 @@ def holder_mount_scan() -> tuple[Path, ...] | None: completed = command([ "sudo", "-n", str(supervisor._trusted_tools().helper_python), "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, - json.dumps({ - "holder": holder, "namespace": namespace, - "operation": "terminate", - }, sort_keys=True), + _external_holder_termination_payload(holder, namespace), ], "terminate exact external namespace holder") if completed is not None and completed.returncode != 0: errors.append( @@ -4888,6 +4903,69 @@ def test_external_holder_termination_requires_complete_pidfd_identity() -> None: assert "signal.pidfd_send_signal" in _NSENTER_REVALIDATOR_SOURCE +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not hasattr(os, "pidfd_open") + or not hasattr(signal, "pidfd_send_signal"), + reason="requires Linux pidfd signaling", +) +def test_external_holder_pidfd_rejects_reuse_then_terminates_and_reaps() -> None: + """A stale identity is untouched; the exact pidfd identity is reaped.""" + + def fork_holder() -> int: + child = os.fork() + if child == 0: + signal.pause() + os._exit(125) + return child + + def current_holder(pid: int) -> tuple[dict[str, object], dict[str, object]]: + raw = Path(f"/proc/{pid}/stat").read_text(encoding="ascii") + fields = raw[raw.rfind(")") + 2:].split() + namespace_path = Path(f"/proc/{pid}/ns/mnt") + link = os.readlink(namespace_path) + inode = namespace_path.stat().st_ino + namespace = {"link": link, "inode": inode} + return ({ + "holder_kind": "current", "pid": pid, "start_time": fields[19], + "namespace": link, "namespace_inode": inode, + }, namespace) + + def invoke(holder: dict[str, object], namespace: dict[str, object]): + return subprocess.run( + [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, + _external_holder_termination_payload(holder, namespace)], + capture_output=True, text=True, check=False, timeout=5, + ) + + stale_pid = fork_holder() + exact_pid = fork_holder() + try: + stale, stale_namespace = current_holder(stale_pid) + stale["start_time"] = str(int(stale["start_time"]) + 1) + rejected = invoke(stale, stale_namespace) + assert rejected.returncode != 0 + assert os.waitpid(stale_pid, os.WNOHANG) == (0, 0) + + exact, exact_namespace = current_holder(exact_pid) + terminated = invoke(exact, exact_namespace) + assert terminated.returncode == 0, terminated.stderr + waited, status = os.waitpid(exact_pid, 0) + assert waited == exact_pid and os.waitstatus_to_exitcode(status) == -signal.SIGTERM + exact_pid = -1 + finally: + for pid in (stale_pid, exact_pid): + if pid <= 0: + continue + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + + def test_cleanup_process_identity_rejects_pid_reuse() -> None: """A reused PID with a different kernel start time is not the recorded process.""" original = {"pid": 123, "start_time": "100"} @@ -5720,7 +5798,6 @@ def early_failure_cleanup() -> None: ) assert ready, "external namespace-FD holder did not become ready" holder_ready = json.loads(external_reaper.stdout.readline()) - details["external_holders"] = [holder_ready] details["external_reapers"] = [external_reaper] holder_deadline = time.monotonic() + 8 holder_record = None @@ -5746,6 +5823,7 @@ def early_failure_cleanup() -> None: time.sleep(.05) assert holder_record is not None, "exact namespace-FD holder unavailable" details["namespace_holders"].append(holder_record) + details["external_holders"] = [holder_record] details["require_fd_only_holder"] = True owned_fds = ( read_fd, write_fd, lifecycle_ready_read, From 188c8339fc2d28f5c223dc89f77f3a6a660e060a Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 17:51:56 -0700 Subject: [PATCH 213/233] fix(sync): enter held namespace root for cleanup --- tests/test_sync_core_supervisor.py | 211 +++++++++++++++++++++++++++-- 1 file changed, 196 insertions(+), 15 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 38b5c581a5..6230beb0db 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4219,6 +4219,29 @@ def _namespace_entry_path( return expected_path +def _namespace_root_entry_path(holder: dict[str, object]) -> str: + """Return the PID-reuse-safe root entry paired with a namespace holder.""" + pid = holder.get("pid") + if type(pid) is not int or pid <= 0: + raise ValueError("namespace holder has invalid process identity") + if holder.get("holder_kind") == "current": + return f"/proc/{pid}/root" + descriptor = holder.get("root_fd") + if type(descriptor) is not int or descriptor < 0: + raise ValueError("FD-only namespace holder has invalid root descriptor") + expected_path = f"/proc/{pid}/fd/{descriptor}" + if holder.get("root_path") != expected_path: + raise ValueError("FD-only namespace holder has wrong root descriptor path") + if ( + type(holder.get("root_device")) is not int + or type(holder.get("root_inode")) is not int + or int(holder["root_device"]) < 0 + or int(holder["root_inode"]) <= 0 + ): + raise ValueError("FD-only namespace holder has invalid root descriptor identity") + return expected_path + + def _external_holder_termination_payload( holder: dict[str, object], namespace: dict[str, object], ) -> str: @@ -4229,6 +4252,48 @@ def _external_holder_termination_payload( }, sort_keys=True) +@dataclass(frozen=True) +class _NamespaceHolderCommandContext: + """Trusted command and ownership values for one held-namespace operation.""" + + control_prefix: Path + captured_mounts: set[Path] + nsenter: str + umount: str + helper_python: str + + +def _namespace_holder_command_payload( + holder: dict[str, object], namespace: dict[str, object], operation: str, *, + mount: Path | None = None, context: _NamespaceHolderCommandContext, +) -> str: + """Serialize one exact namespace scan or unmount without empty mount arguments.""" + _namespace_entry_path(holder, namespace) + _namespace_root_entry_path(holder) + if operation == "unmount": + if mount is None or not _canonical_owned_mount_point( + str(mount), context.control_prefix, tuple(context.captured_mounts), + ): + raise ValueError("namespace unmount point is not owned") + elif operation != "scan" or mount is not None: + raise ValueError("invalid namespace holder operation") + payload = { + "holder": holder, "namespace": namespace, + "nsenter": context.nsenter, "umount": context.umount, + "python": context.helper_python, + "operation": operation, + } + if operation == "unmount": + payload["mount"] = str(mount) + else: + payload["scanner"] = _NAMESPACE_MOUNT_SCANNER_SOURCE + payload["scan_payload"] = { + "prefix": str(context.control_prefix), + "targets": [str(path) for path in sorted(context.captured_mounts)], + } + return json.dumps(payload, sort_keys=True) + + def _holder_key(record: dict[str, object]) -> tuple[object, ...]: """Return the complete identity of one current or FD-only namespace holder.""" return ( @@ -4245,11 +4310,17 @@ def _select_captured_namespace_holder( ) -> dict[str, object] | None: """Select only a still-live holder with the complete previously captured identity.""" captured_keys = {_holder_key(record) for record in captured} + captured_by_key = {_holder_key(record): record for record in captured} candidates = [*scan["current_holders"], *scan["fd_holders"]] for record in candidates: if _holder_key(record) in captured_keys: _namespace_entry_path(record, namespace) - return record + captured_record = captured_by_key[_holder_key(record)] + return record | { + field: captured_record[field] + for field in ("root_fd", "root_path", "root_device", "root_inode") + if field in captured_record + } return None @@ -4322,14 +4393,34 @@ def exact_namespace(path): pass os.close(pidfd) raise SystemExit(0) +if kind=='current': + root_entry=str(proc/'root') +else: + root_fd=holder.get('root_fd') + if type(root_fd) is not int or root_fd<0: + raise RuntimeError('invalid root descriptor') + root_path=proc/'fd'/str(root_fd) + root_metadata=root_path.stat() + if (str(root_path)!=holder.get('root_path') or + root_metadata.st_dev!=holder.get('root_device') or + root_metadata.st_ino!=holder.get('root_inode')): + raise RuntimeError('root descriptor identity changed') + root_entry=str(root_path) if operation=='unmount': - command=[payload['umount'],payload['mount']] + mount=payload.get('mount') + if type(mount) is not str or not mount.startswith('/'): + raise RuntimeError('invalid namespace unmount point') + mount_path=pathlib.PurePosixPath(mount) + if str(mount_path)!=mount or any(part in ('.','..') for part in mount_path.parts): + raise RuntimeError('invalid namespace unmount point') + command=[payload['umount'],mount] elif operation=='scan': command=[payload['python'],'-I','-S','-c',payload['scanner'], json.dumps(payload['scan_payload'],sort_keys=True)] else: raise RuntimeError('invalid namespace operation') -os.execv(payload['nsenter'],[payload['nsenter'],'--mount='+entry,'--',*command]) +os.execv(payload['nsenter'],[payload['nsenter'],'--mount='+entry, + '--root='+root_entry,'--',*command]) """ @@ -4563,20 +4654,15 @@ def scan_owned() -> dict[str, object] | None: def holder_command_payload(operation: str, mount: Path | None = None) -> str: assert namespace_holder is not None - _namespace_entry_path(namespace_holder, namespace) nsenter = shutil.which("nsenter") or "/usr/bin/nsenter" umount = shutil.which("umount") or "/usr/bin/umount" helper_python = str(supervisor._trusted_tools().helper_python) - return json.dumps({ - "holder": namespace_holder, "namespace": namespace, - "nsenter": nsenter, "umount": umount, "python": helper_python, - "operation": operation, "mount": str(mount) if mount else "", - "scanner": _NAMESPACE_MOUNT_SCANNER_SOURCE, - "scan_payload": { - "prefix": str(control_prefix), - "targets": [str(path) for path in sorted(captured_mounts)], - }, - }, sort_keys=True) + return _namespace_holder_command_payload( + namespace_holder, namespace, operation, mount=mount, + context=_NamespaceHolderCommandContext( + control_prefix, captured_mounts, nsenter, umount, helper_python, + ), + ) def holder_mount_scan() -> tuple[Path, ...] | None: if namespace_holder is None: @@ -4865,6 +4951,10 @@ def test_namespace_holder_selection_rejects_wrong_kind_fd_reuse_and_race() -> No _namespace_entry_path(captured | {"fd_path": "/proc/22/fd/8"}, namespace) exact = captured | {"namespace": "mnt:[11]", "namespace_inode": 11} + exact |= { + "root_fd": 8, "root_path": "/proc/22/fd/8", + "root_device": 10, "root_inode": 20, + } reused = exact | {"start_time": "101"} raced = exact | {"fd": 8} assert _select_captured_namespace_holder( @@ -4874,6 +4964,85 @@ def test_namespace_holder_selection_rejects_wrong_kind_fd_reuse_and_race() -> No {"current_holders": [], "fd_holders": [raced]}, (exact,), namespace, ) is None assert _namespace_entry_path(exact, namespace) == "/proc/22/fd/7" + assert _namespace_root_entry_path(exact) == "/proc/22/fd/8" + with pytest.raises(ValueError, match="root descriptor path"): + _namespace_root_entry_path(exact | {"root_path": "/proc/22/fd/9"}) + + +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not hasattr(os, "pidfd_open"), + reason="requires Linux pidfd namespace revalidation", +) +def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( + tmp_path: Path, +) -> None: + """The held-root protocol sends one nonempty mount argument into nsenter.""" + raw = Path("/proc/self/stat").read_text(encoding="ascii") + fields = raw[raw.rfind(")") + 2:].split() + namespace_path = Path("/proc/self/ns/mnt") + namespace = { + "link": os.readlink(namespace_path), "inode": namespace_path.stat().st_ino, + } + namespace_descriptor = os.open(namespace_path, os.O_RDONLY | os.O_CLOEXEC) + root_descriptor = os.open( + "/proc/self/root", getattr(os, "O_PATH") | os.O_CLOEXEC, + ) + try: + root_metadata = os.fstat(root_descriptor) + holder = { + "holder_kind": "fd", "pid": os.getpid(), "start_time": fields[19], + "namespace": namespace["link"], "namespace_inode": namespace["inode"], + "fd": namespace_descriptor, + "fd_path": f"/proc/{os.getpid()}/fd/{namespace_descriptor}", + "fd_link": os.readlink(f"/proc/self/fd/{namespace_descriptor}"), + "fd_inode": os.fstat(namespace_descriptor).st_ino, + "root_fd": root_descriptor, + "root_path": f"/proc/{os.getpid()}/fd/{root_descriptor}", + "root_device": root_metadata.st_dev, "root_inode": root_metadata.st_ino, + } + control = tmp_path / "pdd-scope-owned" + mount = control / "binds" / "writable" + capture = tmp_path / "capture-nsenter-argv" + capture.write_text( + "#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps(sys.argv))\n", + encoding="utf-8", + ) + capture.chmod(0o755) + + payload = json.loads(_namespace_holder_command_payload( + holder, namespace, "unmount", mount=mount, + context=_NamespaceHolderCommandContext( + control, {mount}, str(capture), "/bin/umount", sys.executable, + ), + )) + + assert payload == { + "holder": holder, "namespace": namespace, "nsenter": str(capture), + "umount": "/bin/umount", "python": sys.executable, + "operation": "unmount", "mount": str(mount), + } + completed = subprocess.run( + [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, + json.dumps(payload, sort_keys=True)], + capture_output=True, text=True, check=False, timeout=5, + ) + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == [ + str(capture), f"--mount=/proc/{os.getpid()}/fd/{namespace_descriptor}", + f"--root=/proc/{os.getpid()}/fd/{root_descriptor}", + "--", "/bin/umount", str(mount), + ] + payload["mount"] = "" + rejected = subprocess.run( + [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, + json.dumps(payload, sort_keys=True)], + capture_output=True, text=True, check=False, timeout=5, + ) + assert rejected.returncode != 0 + assert "invalid namespace unmount point" in rejected.stderr + finally: + os.close(root_descriptor) + os.close(namespace_descriptor) def test_external_holder_termination_requires_complete_pidfd_identity() -> None: @@ -5777,10 +5946,14 @@ def early_failure_cleanup() -> None: import json,os,signal,sys target=int(sys.argv[1]) descriptor=os.open(f'/proc/{target}/ns/mnt',os.O_RDONLY|os.O_CLOEXEC) +root_descriptor=os.open(f'/proc/{target}/root',os.O_PATH|os.O_CLOEXEC) +root_metadata=os.fstat(root_descriptor) raw=open(f'/proc/{os.getpid()}/stat',encoding='ascii').read() fields=raw[raw.rfind(')')+2:].split() print(json.dumps({'pid':os.getpid(),'start_time':fields[19], - 'fd':descriptor}),flush=True) + 'fd':descriptor,'root_fd':root_descriptor, + 'root_device':root_metadata.st_dev, + 'root_inode':root_metadata.st_ino}),flush=True) signal.pause() """ external_reaper = subprocess.Popen( @@ -5822,6 +5995,14 @@ def early_failure_cleanup() -> None: break time.sleep(.05) assert holder_record is not None, "exact namespace-FD holder unavailable" + holder_record |= { + "root_fd": holder_ready["root_fd"], + "root_path": ( + f"/proc/{holder_ready['pid']}/fd/{holder_ready['root_fd']}" + ), + "root_device": holder_ready["root_device"], + "root_inode": holder_ready["root_inode"], + } details["namespace_holders"].append(holder_record) details["external_holders"] = [holder_record] details["require_fd_only_holder"] = True From 1d4e4b8918df6a247bc2f54b92b4f47aeb228e42 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 18:09:16 -0700 Subject: [PATCH 214/233] fix(sync): pin fd holder root identity --- tests/test_sync_core_supervisor.py | 223 ++++++++++++++++++++++++----- 1 file changed, 185 insertions(+), 38 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 6230beb0db..bdcda1ec39 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -3912,6 +3912,13 @@ def fd_links(pid_dir): f'{type(error).__name__}: {error}') return links +def fd_mnt_id(pid_dir,fd): + lines=read_text(pid_dir/'fdinfo'/str(fd),pid_dir,'read descriptor mount ID').splitlines() + values=[line[7:] for line in lines if line.startswith('mnt_id:')] + if len(values)!=1 or not values[0].isdigit() or int(values[0])<=0: + fail(f'parse descriptor mount ID: pid={pid_dir.name} fd={fd}') + return int(values[0]) + def unescape(value): return value.replace('\\040',' ').replace('\\011','\t').replace('\\134','\\') @@ -3996,6 +4003,23 @@ def cgroup_pids(path): type(expected_namespace.get('link')) is not str or type(expected_namespace.get('inode')) is not int): fail('invalid expected mount namespace payload') + root_holders=payload.get('root_holders') + if type(root_holders) is not list: + fail('invalid root holder payload') + root_by_key={} + for holder in root_holders: + if type(holder) is not dict: + fail('invalid root holder payload') + pid=holder.get('pid'); start_time=holder.get('start_time') + descriptor=holder.get('fd'); root_fd=holder.get('root_fd') + if (type(pid) is not int or pid<=0 or type(start_time) is not str or + not start_time.isdigit() or type(descriptor) is not int or + descriptor<0 or type(root_fd) is not int or root_fd<0): + fail('invalid root holder payload') + key=(pid,start_time,descriptor) + if key in root_by_key: + fail('duplicate root holder payload') + root_by_key[key]=root_fd targets=set(payload.get('targets',[])) for value in targets: canonical_path(value,'target') @@ -4058,11 +4082,23 @@ def cgroup_pids(path): for fd,link,inode in descriptors: if (link==expected_namespace['link'] and inode==expected_namespace['inode']): - fd_holders.append(record|{ + fd_holder=record|{ 'holder_kind':'fd','fd':fd, 'fd_path':str(pid_dir/'fd'/str(fd)), 'fd_link':link,'fd_inode':inode, - }) + } + root_fd=root_by_key.get((record['pid'],record['start_time'],fd)) + if root_fd is not None: + root_path=pid_dir/'fd'/str(root_fd) + root_metadata=root_path.stat() + fd_holder=fd_holder|{ + 'root_fd':root_fd,'root_path':str(root_path), + 'root_device':root_metadata.st_dev, + 'root_inode':root_metadata.st_ino, + 'root_mode':root_metadata.st_mode, + 'root_mnt_id':fd_mnt_id(pid_dir,root_fd), + } + fd_holders.append(fd_holder) for mount_record in mount_records: point=mount_record['mount_point'] in_expected_namespace=( @@ -4098,6 +4134,7 @@ class _RootProcSelection: watch_pids: tuple[int, ...] = () scope_only: bool = False + root_holders: tuple[dict[str, object], ...] = () def _root_proc_scan( @@ -4113,6 +4150,7 @@ def _root_proc_scan( "target_prefix": str(target_prefix) if target_prefix is not None else "", "watch_pids": list(selection.watch_pids), "scope_only": selection.scope_only, + "root_holders": list(selection.root_holders), } scanner_python = supervisor._trusted_tools().helper_python try: @@ -4235,8 +4273,12 @@ def _namespace_root_entry_path(holder: dict[str, object]) -> str: if ( type(holder.get("root_device")) is not int or type(holder.get("root_inode")) is not int + or type(holder.get("root_mode")) is not int + or type(holder.get("root_mnt_id")) is not int or int(holder["root_device"]) < 0 or int(holder["root_inode"]) <= 0 + or int(holder["root_mode"]) <= 0 + or int(holder["root_mnt_id"]) <= 0 ): raise ValueError("FD-only namespace holder has invalid root descriptor identity") return expected_path @@ -4299,6 +4341,9 @@ def _holder_key(record: dict[str, object]) -> tuple[object, ...]: return ( record.get("holder_kind"), *_process_key(record), record.get("fd"), record.get("fd_path"), record.get("fd_link"), record.get("fd_inode"), + record.get("root_fd"), record.get("root_path"), + record.get("root_device"), record.get("root_inode"), + record.get("root_mode"), record.get("root_mnt_id"), record.get("namespace"), record.get("namespace_inode"), ) @@ -4310,17 +4355,11 @@ def _select_captured_namespace_holder( ) -> dict[str, object] | None: """Select only a still-live holder with the complete previously captured identity.""" captured_keys = {_holder_key(record) for record in captured} - captured_by_key = {_holder_key(record): record for record in captured} candidates = [*scan["current_holders"], *scan["fd_holders"]] for record in candidates: if _holder_key(record) in captured_keys: _namespace_entry_path(record, namespace) - captured_record = captured_by_key[_holder_key(record)] - return record | { - field: captured_record[field] - for field in ("root_fd", "root_path", "root_device", "root_inode") - if field in captured_record - } + return record return None @@ -4362,6 +4401,14 @@ def exact_namespace(path): if identity()!=start_time: raise RuntimeError('holder PID was reused') +operation=payload.get('operation') +if operation=='terminate': + try: + signal.pidfd_send_signal(pidfd,signal.SIGTERM,None,0) + except ProcessLookupError: + pass + os.close(pidfd) + raise SystemExit(0) current_path=proc/'ns'/'mnt' current_link=os.readlink(current_path); current_inode=current_path.stat().st_ino if (current_link!=holder.get('namespace') or @@ -4383,16 +4430,6 @@ def exact_namespace(path): entry=str(entry_path) else: raise RuntimeError('invalid namespace holder kind') -if identity()!=start_time: - raise RuntimeError('holder identity raced before namespace entry') -operation=payload.get('operation') -if operation=='terminate': - try: - signal.pidfd_send_signal(pidfd,signal.SIGTERM,None,0) - except ProcessLookupError: - pass - os.close(pidfd) - raise SystemExit(0) if kind=='current': root_entry=str(proc/'root') else: @@ -4400,12 +4437,27 @@ def exact_namespace(path): if type(root_fd) is not int or root_fd<0: raise RuntimeError('invalid root descriptor') root_path=proc/'fd'/str(root_fd) - root_metadata=root_path.stat() - if (str(root_path)!=holder.get('root_path') or - root_metadata.st_dev!=holder.get('root_device') or - root_metadata.st_ino!=holder.get('root_inode')): + if str(root_path)!=holder.get('root_path'): raise RuntimeError('root descriptor identity changed') root_entry=str(root_path) +namespace_fd=os.open(entry,os.O_RDONLY|os.O_CLOEXEC) +exact_namespace(pathlib.Path('/proc/self/fd')/str(namespace_fd)) +root_fd=os.open(root_entry,getattr(os,'O_PATH',0)|os.O_CLOEXEC) +root_metadata=os.fstat(root_fd) +root_info=(pathlib.Path('/proc/self/fdinfo')/str(root_fd)).read_text(encoding='ascii') +root_mnt_ids=[line[7:] for line in root_info.splitlines() if line.startswith('mnt_id:')] +if (len(root_mnt_ids)!=1 or not root_mnt_ids[0].isdigit() or + int(root_mnt_ids[0])<=0): + raise RuntimeError('invalid root descriptor mount ID') +if kind=='fd' and (root_metadata.st_dev!=holder.get('root_device') or + root_metadata.st_ino!=holder.get('root_inode') or + root_metadata.st_mode!=holder.get('root_mode') or + int(root_mnt_ids[0])!=holder.get('root_mnt_id')): + raise RuntimeError('root descriptor identity changed') +if identity()!=start_time: + raise RuntimeError('holder identity raced after descriptor pinning') +os.set_inheritable(namespace_fd,True); os.set_inheritable(root_fd,True) +os.close(pidfd) if operation=='unmount': mount=payload.get('mount') if type(mount) is not str or not mount.startswith('/'): @@ -4419,8 +4471,8 @@ def exact_namespace(path): json.dumps(payload['scan_payload'],sort_keys=True)] else: raise RuntimeError('invalid namespace operation') -os.execv(payload['nsenter'],[payload['nsenter'],'--mount='+entry, - '--root='+root_entry,'--',*command]) +os.execv(payload['nsenter'],[payload['nsenter'],'--mount=/proc/self/fd/'+str(namespace_fd), + '--root=/proc/self/fd/'+str(root_fd),'--',*command]) """ @@ -4564,7 +4616,16 @@ def scan_owned() -> dict[str, object] | None: return scanner( cgroup=cgroup, namespace=namespace, targets=tuple(sorted(captured_mounts)), target_prefix=control_prefix, - selection=_RootProcSelection((int(coordinator["pid"]),)), + selection=_RootProcSelection( + (int(coordinator["pid"]),), root_holders=tuple( + holder for holder in captured_holders + if holder.get("holder_kind") == "fd" + and all(field in holder for field in ( + "root_fd", "root_path", "root_device", "root_inode", + "root_mode", "root_mnt_id", + )) + ), + ), ) except BaseException as exc: # pylint: disable=broad-exception-caught # Scanner failure is cleanup evidence, not a reason to abandon teardown. @@ -4953,16 +5014,25 @@ def test_namespace_holder_selection_rejects_wrong_kind_fd_reuse_and_race() -> No exact = captured | {"namespace": "mnt:[11]", "namespace_inode": 11} exact |= { "root_fd": 8, "root_path": "/proc/22/fd/8", - "root_device": 10, "root_inode": 20, + "root_device": 10, "root_inode": 20, "root_mode": 0o40755, + "root_mnt_id": 30, } reused = exact | {"start_time": "101"} raced = exact | {"fd": 8} + substituted_root = exact | {"root_mnt_id": 31} assert _select_captured_namespace_holder( {"current_holders": [], "fd_holders": [reused]}, (exact,), namespace, ) is None assert _select_captured_namespace_holder( {"current_holders": [], "fd_holders": [raced]}, (exact,), namespace, ) is None + assert _select_captured_namespace_holder( + {"current_holders": [], "fd_holders": [substituted_root]}, + (exact,), namespace, + ) is None + assert _select_captured_namespace_holder( + {"current_holders": [], "fd_holders": [exact]}, (exact,), namespace, + ) == exact assert _namespace_entry_path(exact, namespace) == "/proc/22/fd/7" assert _namespace_root_entry_path(exact) == "/proc/22/fd/8" with pytest.raises(ValueError, match="root descriptor path"): @@ -4989,6 +5059,13 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( ) try: root_metadata = os.fstat(root_descriptor) + root_mount_ids = [ + line[7:] for line in Path(f"/proc/self/fdinfo/{root_descriptor}").read_text( + encoding="ascii", + ).splitlines() if line.startswith("mnt_id:") + ] + assert len(root_mount_ids) == 1 and root_mount_ids[0].isdigit() + root_mnt_id = int(root_mount_ids[0]) holder = { "holder_kind": "fd", "pid": os.getpid(), "start_time": fields[19], "namespace": namespace["link"], "namespace_inode": namespace["inode"], @@ -4999,6 +5076,7 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( "root_fd": root_descriptor, "root_path": f"/proc/{os.getpid()}/fd/{root_descriptor}", "root_device": root_metadata.st_dev, "root_inode": root_metadata.st_ino, + "root_mode": root_metadata.st_mode, "root_mnt_id": root_mnt_id, } control = tmp_path / "pdd-scope-owned" mount = control / "binds" / "writable" @@ -5027,11 +5105,11 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( capture_output=True, text=True, check=False, timeout=5, ) assert completed.returncode == 0, completed.stderr - assert json.loads(completed.stdout) == [ - str(capture), f"--mount=/proc/{os.getpid()}/fd/{namespace_descriptor}", - f"--root=/proc/{os.getpid()}/fd/{root_descriptor}", - "--", "/bin/umount", str(mount), - ] + argv = json.loads(completed.stdout) + assert argv[0] == str(capture) + assert argv[1].startswith("--mount=/proc/self/fd/") + assert argv[2].startswith("--root=/proc/self/fd/") + assert argv[3:] == ["--", "/bin/umount", str(mount)] payload["mount"] = "" rejected = subprocess.run( [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, @@ -5040,6 +5118,35 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( ) assert rejected.returncode != 0 assert "invalid namespace unmount point" in rejected.stderr + host_root = os.open("/", getattr(os, "O_PATH") | os.O_CLOEXEC) + try: + host_metadata = os.fstat(host_root) + assert (host_metadata.st_dev, host_metadata.st_ino) == ( + root_metadata.st_dev, root_metadata.st_ino, + ) + substituted = holder | { + "root_fd": host_root, + "root_path": f"/proc/{os.getpid()}/fd/{host_root}", + "root_device": host_metadata.st_dev, + "root_inode": host_metadata.st_ino, + "root_mode": host_metadata.st_mode, + "root_mnt_id": root_mnt_id + 1, + } + substitution = json.loads(_namespace_holder_command_payload( + substituted, namespace, "unmount", mount=mount, + context=_NamespaceHolderCommandContext( + control, {mount}, str(capture), "/bin/umount", sys.executable, + ), + )) + rejected = subprocess.run( + [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, + json.dumps(substitution, sort_keys=True)], + capture_output=True, text=True, check=False, timeout=5, + ) + assert rejected.returncode != 0 + assert "root descriptor identity changed" in rejected.stderr + finally: + os.close(host_root) finally: os.close(root_descriptor) os.close(namespace_descriptor) @@ -5943,24 +6050,62 @@ def early_failure_cleanup() -> None: ) if case == "fd-only-namespace-holder-cleanup": holder_source = r""" -import json,os,signal,sys -target=int(sys.argv[1]) -descriptor=os.open(f'/proc/{target}/ns/mnt',os.O_RDONLY|os.O_CLOEXEC) -root_descriptor=os.open(f'/proc/{target}/root',os.O_PATH|os.O_CLOEXEC) +import json,os,pathlib,signal,sys +target=json.loads(sys.argv[1]) +pid=target.get('pid'); start_time=target.get('start_time') +namespace=target.get('namespace') +if (type(pid) is not int or pid<=0 or type(start_time) is not str or + not start_time.isdigit() or type(namespace) is not dict or + set(namespace)!={'link','inode'} or type(namespace['link']) is not str or + type(namespace['inode']) is not int or namespace['inode']<=0): + raise SystemExit('invalid target identity') +try: target_pidfd=os.pidfd_open(pid,0) +except ProcessLookupError: raise SystemExit('target exited') +proc=pathlib.Path('/proc')/str(pid) +def identity(): + raw=(proc/'stat').read_text(encoding='ascii') + closing=raw.rfind(')'); fields=raw[closing+2:].split() + if closing<2 or len(fields)<=19 or not fields[19].isdigit(): raise RuntimeError('invalid target stat') + return fields[19] +def validate_target(): + namespace_path=proc/'ns'/'mnt' + if (identity()!=start_time or os.readlink(namespace_path)!=namespace['link'] or + namespace_path.stat().st_ino!=namespace['inode']): + raise RuntimeError('target identity changed during holder capture') +def mnt_id(fd): + values=[line[7:] for line in pathlib.Path('/proc/self/fdinfo',str(fd)).read_text(encoding='ascii').splitlines() if line.startswith('mnt_id:')] + if len(values)!=1 or not values[0].isdigit() or int(values[0])<=0: + raise RuntimeError('invalid root descriptor mount ID') + return int(values[0]) +validate_target() +descriptor=os.open(proc/'ns'/'mnt',os.O_RDONLY|os.O_CLOEXEC) +root_descriptor=os.open(proc/'root',os.O_PATH|os.O_CLOEXEC) root_metadata=os.fstat(root_descriptor) +if (os.readlink(f'/proc/self/fd/{descriptor}')!=namespace['link'] or + os.fstat(descriptor).st_ino!=namespace['inode']): + raise RuntimeError('captured namespace descriptor changed') +root_mount_id=mnt_id(root_descriptor) +validate_target() raw=open(f'/proc/{os.getpid()}/stat',encoding='ascii').read() fields=raw[raw.rfind(')')+2:].split() print(json.dumps({'pid':os.getpid(),'start_time':fields[19], 'fd':descriptor,'root_fd':root_descriptor, 'root_device':root_metadata.st_dev, - 'root_inode':root_metadata.st_ino}),flush=True) + 'root_inode':root_metadata.st_ino, + 'root_mode':root_metadata.st_mode, + 'root_mnt_id':root_mount_id}),flush=True) +os.close(target_pidfd) signal.pause() """ external_reaper = subprocess.Popen( [ "sudo", "-n", str(supervisor._trusted_tools().helper_python), "-I", "-S", "-c", holder_source, - str(details["namespace_holder"]["pid"]), + json.dumps({ + "pid": details["namespace_holder"]["pid"], + "start_time": details["namespace_holder"]["start_time"], + "namespace": details["namespace"], + }, sort_keys=True), ], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -6002,6 +6147,8 @@ def early_failure_cleanup() -> None: ), "root_device": holder_ready["root_device"], "root_inode": holder_ready["root_inode"], + "root_mode": holder_ready["root_mode"], + "root_mnt_id": holder_ready["root_mnt_id"], } details["namespace_holders"].append(holder_record) details["external_holders"] = [holder_record] From b072736c1e7f69802317560dc176177476257d55 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 18:15:41 -0700 Subject: [PATCH 215/233] fix(sync): normalize fdinfo mount IDs --- tests/test_sync_core_supervisor.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index bdcda1ec39..c9bfd7a9d4 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -3914,7 +3914,7 @@ def fd_links(pid_dir): def fd_mnt_id(pid_dir,fd): lines=read_text(pid_dir/'fdinfo'/str(fd),pid_dir,'read descriptor mount ID').splitlines() - values=[line[7:] for line in lines if line.startswith('mnt_id:')] + values=[line.partition(':')[2].strip() for line in lines if line.startswith('mnt_id:')] if len(values)!=1 or not values[0].isdigit() or int(values[0])<=0: fail(f'parse descriptor mount ID: pid={pid_dir.name} fd={fd}') return int(values[0]) @@ -4445,7 +4445,7 @@ def exact_namespace(path): root_fd=os.open(root_entry,getattr(os,'O_PATH',0)|os.O_CLOEXEC) root_metadata=os.fstat(root_fd) root_info=(pathlib.Path('/proc/self/fdinfo')/str(root_fd)).read_text(encoding='ascii') -root_mnt_ids=[line[7:] for line in root_info.splitlines() if line.startswith('mnt_id:')] +root_mnt_ids=[line.partition(':')[2].strip() for line in root_info.splitlines() if line.startswith('mnt_id:')] if (len(root_mnt_ids)!=1 or not root_mnt_ids[0].isdigit() or int(root_mnt_ids[0])<=0): raise RuntimeError('invalid root descriptor mount ID') @@ -4916,6 +4916,24 @@ def test_root_proc_scanner_source_compiles() -> None: compile(_NAMESPACE_MOUNT_SCANNER_SOURCE, "", "exec") +def test_fdinfo_mount_id_parser_normalizes_whitespace_and_rejects_ambiguity() -> None: + """All fdinfo mount-ID protocols accept whitespace but require one positive value.""" + def parse(lines: list[str]) -> int: + values = [ + line.partition(":")[2].strip() + for line in lines if line.startswith("mnt_id:") + ] + if len(values) != 1 or not values[0].isdigit() or int(values[0]) <= 0: + raise ValueError("invalid mount ID") + return int(values[0]) + + assert parse(["flags:\t0100000", "mnt_id:\t42"]) == 42 + assert parse(["mnt_id: 42 "]) == 42 + for lines in ([], ["mnt_id:"], ["mnt_id: 0"], ["mnt_id: 4", "mnt_id: 5"]): + with pytest.raises(ValueError, match="invalid mount ID"): + parse(lines) + + def test_root_proc_scanner_forwards_scope_only_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -5060,7 +5078,7 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( try: root_metadata = os.fstat(root_descriptor) root_mount_ids = [ - line[7:] for line in Path(f"/proc/self/fdinfo/{root_descriptor}").read_text( + line.partition(":")[2].strip() for line in Path(f"/proc/self/fdinfo/{root_descriptor}").read_text( encoding="ascii", ).splitlines() if line.startswith("mnt_id:") ] @@ -6073,7 +6091,7 @@ def validate_target(): namespace_path.stat().st_ino!=namespace['inode']): raise RuntimeError('target identity changed during holder capture') def mnt_id(fd): - values=[line[7:] for line in pathlib.Path('/proc/self/fdinfo',str(fd)).read_text(encoding='ascii').splitlines() if line.startswith('mnt_id:')] + values=[line.partition(':')[2].strip() for line in pathlib.Path('/proc/self/fdinfo',str(fd)).read_text(encoding='ascii').splitlines() if line.startswith('mnt_id:')] if len(values)!=1 or not values[0].isdigit() or int(values[0])<=0: raise RuntimeError('invalid root descriptor mount ID') return int(values[0]) From 34d5054ddb11688205924cb928234e404d6702ec Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 18:19:05 -0700 Subject: [PATCH 216/233] test(sync): require positive root mount ID --- tests/test_sync_core_supervisor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index c9bfd7a9d4..7a60fb1dad 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -5082,7 +5082,7 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( encoding="ascii", ).splitlines() if line.startswith("mnt_id:") ] - assert len(root_mount_ids) == 1 and root_mount_ids[0].isdigit() + assert len(root_mount_ids) == 1 and root_mount_ids[0].isdigit() and int(root_mount_ids[0]) > 0 root_mnt_id = int(root_mount_ids[0]) holder = { "holder_kind": "fd", "pid": os.getpid(), "start_time": fields[19], From e4f20a6d186e2e635e755787915cc7952d9dc6f5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 18:48:50 -0700 Subject: [PATCH 217/233] test(sync): diagnose held namespace cleanup --- tests/test_sync_core_supervisor.py | 483 +++++++++++++++++++++++++++-- 1 file changed, 454 insertions(+), 29 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 7a60fb1dad..555dd8ed69 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4319,6 +4319,16 @@ def _namespace_holder_command_payload( raise ValueError("namespace unmount point is not owned") elif operation != "scan" or mount is not None: raise ValueError("invalid namespace holder operation") + if operation == "scan" and ( + len(context.captured_mounts) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS + or any( + not _canonical_owned_mount_point( + str(target), context.control_prefix, tuple(context.captured_mounts), + ) + for target in context.captured_mounts + ) + ): + raise ValueError("namespace diagnostic targets are not bounded owned mounts") payload = { "holder": holder, "namespace": namespace, "nsenter": context.nsenter, "umount": context.umount, @@ -4330,6 +4340,7 @@ def _namespace_holder_command_payload( else: payload["scanner"] = _NAMESPACE_MOUNT_SCANNER_SOURCE payload["scan_payload"] = { + "operation": "scan", "prefix": str(context.control_prefix), "targets": [str(path) for path in sorted(context.captured_mounts)], } @@ -4477,37 +4488,369 @@ def exact_namespace(path): _NAMESPACE_MOUNT_SCANNER_SOURCE = r""" -import json,pathlib,sys +import json,os,pathlib,sys +MAX_TARGETS=24 +MAX_EXACT_MOUNTS=24 +MAX_RELATED_MOUNTS=8 +MAX_TEXT=128 payload=json.loads(sys.argv[1]) +def raw_text(value,label): + if (type(value) is not str or not value or + any(ord(character)<32 or ord(character)==127 for character in value)): + raise RuntimeError(f'invalid {label}') + return value + +def text(value,label): + value=raw_text(value,label) + if len(value)>MAX_TEXT: + raise RuntimeError(f'oversized {label}') + return value + +def limited(value,label): + value=raw_text(value,label) + return value[:MAX_TEXT],len(value)>MAX_TEXT + def canonical(value,label): - if type(value) is not str or not value.startswith('/'): - raise RuntimeError(f'{label} is not absolute: {value!r}') + value=text(value,label) + if not value.startswith('/'): + raise RuntimeError(f'{label} is not absolute') path=pathlib.PurePosixPath(value) if str(path)!=value or any(part in ('.','..') for part in path.parts): - raise RuntimeError(f'{label} is not canonical: {value!r}') + raise RuntimeError(f'{label} is not canonical') return path -prefix=canonical(payload['prefix'],'prefix') -targets={str(canonical(value,'target')) for value in payload['targets']} -mounts=[] -for line in pathlib.Path('/proc/self/mountinfo').read_text(encoding='utf-8').splitlines(): - fields=line.split() - if len(fields)<10 or '-' not in fields or not fields[0].isdigit(): - raise RuntimeError(f'malformed mountinfo: {line!r}') - value=fields[4].replace('\\040',' ').replace('\\011','\t').replace('\\134','\\') - point=canonical(value,'mount point') +def unescape(value,label): + output=[]; index=0 + while index4095: + raise RuntimeError('invalid metadata errno') from error + return {'status':'error','errno':error.errno} + return {'status':'ok','device':result.st_dev,'inode':result.st_ino,'mode':result.st_mode} + +def path_record(path): + lstat=metadata(path,False); followed=metadata(path,True) + return {'path':str(path),'exists':followed['status']=='ok','lstat':lstat,'stat':followed} + +def cwd_record(): + try: + return {'status':'ok','path':str(canonical(os.getcwd(),'cwd'))} + except OSError as error: + if type(error.errno) is not int or error.errno<=0 or error.errno>4095: + raise RuntimeError('invalid cwd errno') from error + return {'status':'error','errno':error.errno} + +def mount_record(line): + fields=line.split(); separator=fields.index('-') if '-' in fields else -1 + if (separator<6 or len(fields)!=separator+4 or not fields[0].isdigit() or + not fields[1].isdigit() or fields[2].count(':')!=1): + raise RuntimeError('malformed mountinfo') + major,minor=fields[2].split(':') + if not major.isdigit() or not minor.isdigit(): + raise RuntimeError('malformed mountinfo device') + truncated=[] + filesystem,filesystem_truncated=limited(fields[separator+1],'filesystem') + source,source_truncated=limited(unescape(fields[separator+2],'source'),'source') + mount_options,mount_options_truncated=limited(fields[5],'mount options') + super_options,super_options_truncated=limited(fields[separator+3],'super options') + for label,was_truncated in ( + ('filesystem',filesystem_truncated),('source',source_truncated), + ('options.mount',mount_options_truncated),('options.super',super_options_truncated)): + if was_truncated: truncated.append(label) + truncated.sort() + return { + 'mount_id':int(fields[0]),'parent_id':int(fields[1]), + 'root':str(canonical(unescape(fields[3],'mount root'),'mount root')), + 'mount_point':str(canonical(unescape(fields[4],'mount point'),'mount point')), + 'major_minor':fields[2], 'filesystem':filesystem,'source':source, + 'options':{'mount':mount_options,'super':super_options},'truncated':truncated, + } + +def contained(point): try: - point.relative_to(prefix); contained=True + pathlib.PurePosixPath(point).relative_to(prefix); return True except ValueError: - contained=False - if str(point) in targets or contained: - mounts.append(str(point)) -print(json.dumps(sorted(set(mounts)),separators=(',',':'))) + return False + +def common_parts(left,right): + count=0 + for left_part,right_part in zip(left.parts,right.parts): + if left_part!=right_part: break + count+=1 + return count + +if set(payload)!={'operation','prefix','targets'} or payload['operation']!='scan': + raise RuntimeError('invalid diagnostic scan payload') +prefix=canonical(payload['prefix'],'prefix') +if type(payload['targets']) is not list or len(payload['targets'])>MAX_TARGETS: + raise RuntimeError('invalid diagnostic targets') +target_paths=[canonical(value,'target') for value in payload['targets']] +if [str(path) for path in target_paths]!=sorted({str(path) for path in target_paths}): + raise RuntimeError('diagnostic targets are not unique and sorted') +targets={str(path) for path in target_paths} +namespace_path=pathlib.Path('/proc/self/ns/mnt') +namespace_link=text(os.readlink(namespace_path),'mount namespace link') +namespace_inode=namespace_path.stat().st_ino +if not namespace_link.startswith('mnt:[') or not namespace_link.endswith(']') or namespace_inode<=0: + raise RuntimeError('invalid current mount namespace') +root_path=pathlib.Path('/proc/self/root') +root_link=str(canonical(os.readlink(root_path),'root link')) +root_fd=os.open(root_path,getattr(os,'O_PATH',0)|os.O_CLOEXEC) +try: + root_metadata=os.fstat(root_fd) + root_info=(pathlib.Path('/proc/self/fdinfo')/str(root_fd)).read_text(encoding='ascii') +finally: + os.close(root_fd) +root_mnt_ids=[line.partition(':')[2].strip() for line in root_info.splitlines() + if line.startswith('mnt_id:')] +if len(root_mnt_ids)!=1 or not root_mnt_ids[0].isdigit() or int(root_mnt_ids[0])<=0: + raise RuntimeError('invalid current root mount ID') +records=[] +for line in pathlib.Path('/proc/self/mountinfo').read_text(encoding='utf-8').splitlines(): + records.append(mount_record(line)) +exact=[record for record in records if record['mount_point'] in targets or contained(record['mount_point'])] +if len(exact)>MAX_EXACT_MOUNTS: + raise RuntimeError('too many exact diagnostic mounts') +related=[] +if not exact: + probes=(prefix,*target_paths) + def distance(record): + point=pathlib.PurePosixPath(record['mount_point']) + shared=max(common_parts(point,probe) for probe in probes) + return (len(point.parts)+min(len(probe.parts) for probe in probes)-2*shared, + -shared,record['mount_id']) + related=sorted(records,key=distance)[:MAX_RELATED_MOUNTS] +output={ + 'schema':'pdd-held-namespace-diagnostic-v1','operation':'scan', + 'prefix':str(prefix),'targets':[str(path) for path in target_paths], + 'mount_namespace':{'link':namespace_link,'inode':namespace_inode}, + 'root':{'link':root_link,'device':root_metadata.st_dev,'inode':root_metadata.st_ino, + 'mode':root_metadata.st_mode,'mount_id':int(root_mnt_ids[0])}, + 'cwd':cwd_record(), + 'paths':{'prefix':path_record(prefix),'targets':[path_record(path) for path in target_paths]}, + 'mountinfo':{'exact':exact,'related':related}, +} +print(json.dumps(output,sort_keys=True,separators=(',',':'))) """ +_HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES = 48 * 1024 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS = 24 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS = 24 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_RELATED_MOUNTS = 8 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_TEXT = 512 + + +def _parse_held_namespace_diagnostic( + output: str, *, namespace: dict[str, object], control_prefix: Path, + targets: tuple[Path, ...], +) -> tuple[dict[str, object], tuple[Path, ...]]: + """Validate one bounded scanner envelope and return its exact owned mounts.""" + def reject(message: str) -> None: + raise ValueError(f"held namespace diagnostic {message}") + + def bounded_text(value: object, label: str) -> str: + if ( + type(value) is not str or not value or + len(value) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_TEXT or + any(ord(character) < 32 or ord(character) == 127 for character in value) + ): + reject(f"has invalid {label}") + return value + + def canonical_path(value: object, label: str) -> str: + text = bounded_text(value, label) + if not text.startswith("/"): + reject(f"has non-absolute {label}") + path = PurePosixPath(text) + if str(path) != text or any(part in {".", ".."} for part in path.parts): + reject(f"has non-canonical {label}") + return text + + def positive_int(value: object, label: str, *, zero: bool = False) -> int: + if type(value) is not int or value < (0 if zero else 1): + reject(f"has invalid {label}") + return value + + def metadata(value: object, label: str) -> None: + if not isinstance(value, dict): + reject(f"has invalid {label}") + if value.get("status") == "ok": + if set(value) != {"status", "device", "inode", "mode"}: + reject(f"has invalid {label}") + positive_int(value["device"], f"{label} device", zero=True) + positive_int(value["inode"], f"{label} inode") + positive_int(value["mode"], f"{label} mode", zero=True) + elif value.get("status") == "error": + if set(value) != {"status", "errno"}: + reject(f"has invalid {label}") + errno = positive_int(value["errno"], f"{label} errno") + if errno > 4095: + reject(f"has invalid {label} errno") + else: + reject(f"has invalid {label}") + + def path_record(value: object, expected: str, label: str) -> None: + if not isinstance(value, dict) or set(value) != { + "path", "exists", "lstat", "stat", + }: + reject(f"has invalid {label}") + if canonical_path(value["path"], f"{label} path") != expected: + reject(f"has mismatched {label} path") + if type(value["exists"]) is not bool: + reject(f"has invalid {label} existence") + metadata(value["lstat"], f"{label} lstat") + metadata(value["stat"], f"{label} stat") + if value["exists"] != (value["stat"].get("status") == "ok"): + reject(f"has inconsistent {label} existence") + + def mount_record(value: object, label: str) -> str: + if not isinstance(value, dict) or set(value) != { + "mount_id", "parent_id", "root", "mount_point", "major_minor", + "filesystem", "source", "options", "truncated", + }: + reject(f"has invalid {label}") + positive_int(value["mount_id"], f"{label} mount ID") + positive_int(value["parent_id"], f"{label} parent ID", zero=True) + canonical_path(value["root"], f"{label} root") + point = canonical_path(value["mount_point"], f"{label} mount point") + major_minor = bounded_text(value["major_minor"], f"{label} major minor") + parts = major_minor.split(":") + if len(parts) != 2 or not all(part.isdigit() for part in parts): + reject(f"has invalid {label} major minor") + bounded_text(value["filesystem"], f"{label} filesystem") + bounded_text(value["source"], f"{label} source") + options = value["options"] + if not isinstance(options, dict) or set(options) != {"mount", "super"}: + reject(f"has invalid {label} options") + bounded_text(options["mount"], f"{label} mount options") + bounded_text(options["super"], f"{label} super options") + truncated = value["truncated"] + permitted = {"filesystem", "source", "options.mount", "options.super"} + if ( + type(truncated) is not list or len(truncated) > len(permitted) or + any(type(item) is not str or item not in permitted for item in truncated) or + truncated != sorted(set(truncated)) + ): + reject(f"has invalid {label} truncation") + return point + + if ( + type(output) is not str or + len(output.encode("utf-8")) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES + ): + reject("exceeds the output cap") + try: + diagnostic = json.loads(output) + except json.JSONDecodeError as exc: + raise ValueError("held namespace diagnostic is not JSON") from exc + if not isinstance(diagnostic, dict) or set(diagnostic) != { + "schema", "operation", "prefix", "targets", "mount_namespace", "root", + "cwd", "paths", "mountinfo", + }: + reject("has invalid shape") + if diagnostic["schema"] != "pdd-held-namespace-diagnostic-v1": + reject("has invalid schema") + if diagnostic["operation"] != "scan": + reject("has invalid operation") + expected_prefix = str(control_prefix) + expected_targets = [str(path) for path in sorted(targets)] + if canonical_path(diagnostic["prefix"], "prefix") != expected_prefix: + reject("has mismatched prefix") + reported_targets = diagnostic["targets"] + if ( + type(reported_targets) is not list or + len(reported_targets) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS or + [canonical_path(value, "target") for value in reported_targets] != expected_targets + ): + reject("has mismatched targets") + reported_namespace = diagnostic["mount_namespace"] + if ( + not isinstance(reported_namespace, dict) or + set(reported_namespace) != {"link", "inode"} + ): + reject("has invalid mount namespace") + link = bounded_text(reported_namespace["link"], "mount namespace link") + if not link.startswith("mnt:[") or not link.endswith("]") or not link[5:-1].isdigit(): + reject("has invalid mount namespace link") + if ( + link != namespace.get("link") or + positive_int(reported_namespace["inode"], "mount namespace inode") != namespace.get("inode") + ): + reject("has mismatched mount namespace") + root = diagnostic["root"] + if not isinstance(root, dict) or set(root) != { + "link", "device", "inode", "mode", "mount_id", + }: + reject("has invalid root") + canonical_path(root["link"], "root link") + positive_int(root["device"], "root device", zero=True) + positive_int(root["inode"], "root inode") + positive_int(root["mode"], "root mode", zero=True) + positive_int(root["mount_id"], "root mount ID") + cwd = diagnostic["cwd"] + if not isinstance(cwd, dict): + reject("has invalid cwd") + if cwd.get("status") == "ok": + if set(cwd) != {"status", "path"}: + reject("has invalid cwd") + canonical_path(cwd["path"], "cwd path") + elif cwd.get("status") == "error": + if set(cwd) != {"status", "errno"}: + reject("has invalid cwd") + errno = positive_int(cwd["errno"], "cwd errno") + if errno > 4095: + reject("has invalid cwd errno") + else: + reject("has invalid cwd") + paths = diagnostic["paths"] + if not isinstance(paths, dict) or set(paths) != {"prefix", "targets"}: + reject("has invalid paths") + path_record(paths["prefix"], expected_prefix, "prefix") + if type(paths["targets"]) is not list or len(paths["targets"]) != len(expected_targets): + reject("has invalid target paths") + for value, expected in zip(paths["targets"], expected_targets): + path_record(value, expected, "target") + mountinfo = diagnostic["mountinfo"] + if not isinstance(mountinfo, dict) or set(mountinfo) != {"exact", "related"}: + reject("has invalid mountinfo") + exact = mountinfo["exact"] + related = mountinfo["related"] + if ( + type(exact) is not list or type(related) is not list or + len(exact) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS or + len(related) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_RELATED_MOUNTS or + (exact and related) or (not exact and not related) + ): + reject("has invalid mountinfo bounds") + exact_points = [mount_record(value, "exact mount") for value in exact] + if len({entry["mount_id"] for entry in exact}) != len(exact) or any( + not _canonical_owned_mount_point(point, control_prefix, targets) + for point in exact_points + ): + reject("has invalid exact mount ownership") + related_points = [mount_record(value, "related mount") for value in related] + if len({entry["mount_id"] for entry in related}) != len(related) or any( + _canonical_owned_mount_point(point, control_prefix, targets) + for point in related_points + ): + reject("has invalid related mounts") + return diagnostic, tuple(Path(point) for point in sorted(exact_points)) + + _BLOCKED_WCHAN_MARKERS = ("sleep", "wait", "pause", "futex") @@ -4572,6 +4915,7 @@ def _fallback_stalled_observation_cleanup( ) -> tuple[int, ...]: """Boundedly remove every captured privileged resource after early observation loss.""" errors = [] + held_namespace_diagnostics = [] deadline = time.monotonic() + 20 unit = str(ownership["unit"]) cgroup = Path(str(ownership["cgroup"])) @@ -4728,6 +5072,7 @@ def holder_command_payload(operation: str, mount: Path | None = None) -> str: def holder_mount_scan() -> tuple[Path, ...] | None: if namespace_holder is None: return () + scan_targets = tuple(sorted(captured_mounts)) payload = holder_command_payload("scan") completed = command([ "sudo", "-n", str(supervisor._trusted_tools().helper_python), @@ -4736,21 +5081,23 @@ def holder_mount_scan() -> tuple[Path, ...] | None: if completed is None or completed.returncode != 0: if completed is not None: errors.append( - completed.stderr.strip() or "held mount namespace scan failed" + "held mount namespace scan failed: returncode=" + + repr(completed.returncode) ) return None try: - values = json.loads(completed.stdout) - if not isinstance(values, list) or any( - not isinstance(value, str) for value in values - ): - raise ValueError("mount scan result is not a string list") - mounts = tuple(Path(value) for value in values if - _canonical_owned_mount_point(value, control_prefix)) + diagnostic, mounts = _parse_held_namespace_diagnostic( + completed.stdout, namespace=namespace, + control_prefix=control_prefix, targets=scan_targets, + ) except (json.JSONDecodeError, ValueError) as exc: errors.append(f"held mount namespace scan was malformed: {exc}") return None - return tuple(sorted(set(mounts))) + held_namespace_diagnostics.append( + "held mount namespace diagnostic=" + + json.dumps(diagnostic, sort_keys=True, separators=(",", ":")) + ) + return mounts held_mounts = holder_mount_scan() if held_mounts is not None: @@ -4778,8 +5125,7 @@ def holder_mount_scan() -> tuple[Path, ...] | None: if completed is None or completed.returncode == 0: continue diagnostic = (completed.stderr or completed.stdout).strip().lower() - if "not mounted" not in diagnostic and "no such file" not in diagnostic: - errors.append(diagnostic or f"cannot unmount {mount}") + errors.append(diagnostic[:512] or f"cannot unmount {mount}") remaining_held_mounts = holder_mount_scan() if remaining_held_mounts: @@ -4893,6 +5239,7 @@ def holder_mount_scan() -> tuple[Path, ...] | None: if final_leaks and unit_action_failures: errors.extend(unit_action_failures) if errors: + errors.extend(held_namespace_diagnostics) raise AssertionError("; ".join(errors)) return tuple(-1 for _descriptor in owned_fds) @@ -4916,6 +5263,84 @@ def test_root_proc_scanner_source_compiles() -> None: compile(_NAMESPACE_MOUNT_SCANNER_SOURCE, "", "exec") +def test_held_namespace_diagnostic_parser_is_strict_bounded_and_actionable() -> None: + """The held-namespace envelope is portable, bounded, and fails closed.""" + prefix = Path("/tmp/pdd-scope-owned") + target = prefix / "binds" / "writable" + + def metadata() -> dict[str, object]: + return {"status": "ok", "device": 1, "inode": 2, "mode": 0o40700} + + def path_record(path: Path) -> dict[str, object]: + return { + "path": str(path), "exists": True, + "lstat": metadata(), "stat": metadata(), + } + + def mount(point: Path, mount_id: int) -> dict[str, object]: + return { + "mount_id": mount_id, "parent_id": 1, "root": "/", + "mount_point": str(point), "major_minor": "0:42", + "filesystem": "tmpfs", "source": "tmpfs", + "options": {"mount": "rw", "super": "rw"}, "truncated": [], + } + + diagnostic = { + "schema": "pdd-held-namespace-diagnostic-v1", "operation": "scan", + "prefix": str(prefix), "targets": [str(target)], + "mount_namespace": {"link": "mnt:[11]", "inode": 11}, + "root": { + "link": "/", "device": 1, "inode": 2, "mode": 0o40755, + "mount_id": 42, + }, + "cwd": {"status": "ok", "path": "/"}, "paths": { + "prefix": path_record(prefix), "targets": [path_record(target)], + }, + "mountinfo": {"exact": [mount(target, 43)], "related": []}, + } + raw = json.dumps(diagnostic, sort_keys=True, separators=(",", ":")) + parsed, mounts = _parse_held_namespace_diagnostic( + raw, namespace={"link": "mnt:[11]", "inode": 11}, + control_prefix=prefix, targets=(target,), + ) + assert parsed == diagnostic + assert mounts == (target,) + + no_exact = json.loads(raw) + no_exact["mountinfo"] = {"exact": [], "related": [mount(Path("/"), 44)]} + _parsed, mounts = _parse_held_namespace_diagnostic( + json.dumps(no_exact), namespace={"link": "mnt:[11]", "inode": 11}, + control_prefix=prefix, targets=(target,), + ) + assert mounts == () + + malformed = [] + malformed.append("not-json") + malformed.append(json.dumps(diagnostic | {"unexpected": True})) + wrong_namespace = json.loads(raw) + wrong_namespace["mount_namespace"]["inode"] = 12 + malformed.append(json.dumps(wrong_namespace)) + mixed_mounts = json.loads(raw) + mixed_mounts["mountinfo"]["related"] = [mount(Path("/"), 44)] + malformed.append(json.dumps(mixed_mounts)) + missing_related = json.loads(raw) + missing_related["mountinfo"]["exact"] = [] + malformed.append(json.dumps(missing_related)) + too_many_exact = json.loads(raw) + too_many_exact["mountinfo"]["exact"] = [ + mount(target / str(index), index + 100) + for index in range(_HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS + 1) + ] + malformed.append(json.dumps(too_many_exact)) + malformed.append("x" * (_HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES + 1)) + for value in malformed: + with pytest.raises(ValueError, match="held namespace diagnostic"): + _parse_held_namespace_diagnostic( + value, namespace={"link": "mnt:[11]", "inode": 11}, + control_prefix=prefix, targets=(target,), + ) + + def test_fdinfo_mount_id_parser_normalizes_whitespace_and_rejects_ambiguity() -> None: """All fdinfo mount-ID protocols accept whitespace but require one positive value.""" def parse(lines: list[str]) -> int: From 2b07834f4fe67b5a624cb7191982a4f47e190fe5 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 19:11:42 -0700 Subject: [PATCH 218/233] test(sync): correct held namespace diagnostics --- tests/test_sync_core_supervisor.py | 671 ++++++++++++++++++++++++++--- 1 file changed, 601 insertions(+), 70 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 555dd8ed69..b3d0b28cb7 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4319,16 +4319,6 @@ def _namespace_holder_command_payload( raise ValueError("namespace unmount point is not owned") elif operation != "scan" or mount is not None: raise ValueError("invalid namespace holder operation") - if operation == "scan" and ( - len(context.captured_mounts) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS - or any( - not _canonical_owned_mount_point( - str(target), context.control_prefix, tuple(context.captured_mounts), - ) - for target in context.captured_mounts - ) - ): - raise ValueError("namespace diagnostic targets are not bounded owned mounts") payload = { "holder": holder, "namespace": namespace, "nsenter": context.nsenter, "umount": context.umount, @@ -4338,11 +4328,17 @@ def _namespace_holder_command_payload( if operation == "unmount": payload["mount"] = str(mount) else: + root_fields = ("root_device", "root_inode", "root_mode", "root_mnt_id") + expected_root = None if not all(field in holder for field in root_fields) else { + "device": holder["root_device"], "inode": holder["root_inode"], + "mode": holder["root_mode"], "mount_id": holder["root_mnt_id"], + } payload["scanner"] = _NAMESPACE_MOUNT_SCANNER_SOURCE payload["scan_payload"] = { "operation": "scan", "prefix": str(context.control_prefix), "targets": [str(path) for path in sorted(context.captured_mounts)], + "expected_root": expected_root, } return json.dumps(payload, sort_keys=True) @@ -4490,27 +4486,31 @@ def exact_namespace(path): _NAMESPACE_MOUNT_SCANNER_SOURCE = r""" import json,os,pathlib,sys -MAX_TARGETS=24 -MAX_EXACT_MOUNTS=24 +# Hosted inventory has reached roughly 130 mountpoints; retain all up to 512. +MAX_INVENTORY=512 +MAX_TARGET_SAMPLES=16 +MAX_EXACT_SAMPLES=16 MAX_RELATED_MOUNTS=8 -MAX_TEXT=128 +MAX_PATH_TEXT=1024 +MAX_DETAIL_TEXT=128 payload=json.loads(sys.argv[1]) def raw_text(value,label): if (type(value) is not str or not value or - any(ord(character)<32 or ord(character)==127 for character in value)): + any((ord(character)<32 and character not in '\t\n') or ord(character)==127 + for character in value)): raise RuntimeError(f'invalid {label}') return value def text(value,label): value=raw_text(value,label) - if len(value)>MAX_TEXT: + if len(value)>MAX_PATH_TEXT: raise RuntimeError(f'oversized {label}') return value def limited(value,label): value=raw_text(value,label) - return value[:MAX_TEXT],len(value)>MAX_TEXT + return value[:MAX_DETAIL_TEXT],len(value)>MAX_DETAIL_TEXT def canonical(value,label): value=text(value,label) @@ -4527,7 +4527,7 @@ def unescape(value,label): if value[index]!='\\': output.append(value[index]); index+=1; continue escaped=value[index+1:index+4] - if len(escaped)!=3 or any(character not in '01234567' for character in escaped): + if escaped not in {'040','011','012','134'}: raise RuntimeError(f'invalid {label} escape') output.append(chr(int(escaped,8))); index+=4 return raw_text(''.join(output),label) @@ -4592,15 +4592,32 @@ def common_parts(left,right): count+=1 return count -if set(payload)!={'operation','prefix','targets'} or payload['operation']!='scan': +def valid_optional_fields(fields): + for field in fields: + if '\\' in field: + raise RuntimeError('escaped mountinfo optional field') + if field=='unbindable': + continue + tag,separator,value=field.partition(':') + if (separator!=':' or tag not in {'shared','master','propagate_from'} or + not value.isdigit() or int(value)<=0): + raise RuntimeError('invalid mountinfo optional field') + +if set(payload)!={'operation','prefix','targets','expected_root'} or payload['operation']!='scan': raise RuntimeError('invalid diagnostic scan payload') prefix=canonical(payload['prefix'],'prefix') -if type(payload['targets']) is not list or len(payload['targets'])>MAX_TARGETS: +if type(payload['targets']) is not list or len(payload['targets'])>MAX_INVENTORY: raise RuntimeError('invalid diagnostic targets') target_paths=[canonical(value,'target') for value in payload['targets']] if [str(path) for path in target_paths]!=sorted({str(path) for path in target_paths}): raise RuntimeError('diagnostic targets are not unique and sorted') targets={str(path) for path in target_paths} +expected_root=payload['expected_root'] +if expected_root is not None: + if set(expected_root)!={'device','inode','mode','mount_id'} or any( + type(expected_root[field]) is not int or expected_root[field] <= 0 + for field in expected_root): + raise RuntimeError('invalid expected root identity') namespace_path=pathlib.Path('/proc/self/ns/mnt') namespace_link=text(os.readlink(namespace_path),'mount namespace link') namespace_inode=namespace_path.stat().st_ino @@ -4620,10 +4637,16 @@ def common_parts(left,right): raise RuntimeError('invalid current root mount ID') records=[] for line in pathlib.Path('/proc/self/mountinfo').read_text(encoding='utf-8').splitlines(): + fields=line.split(); separator=fields.index('-') if '-' in fields else -1 + if separator<6: + raise RuntimeError('malformed mountinfo') + valid_optional_fields(fields[6:separator]) records.append(mount_record(line)) exact=[record for record in records if record['mount_point'] in targets or contained(record['mount_point'])] -if len(exact)>MAX_EXACT_MOUNTS: - raise RuntimeError('too many exact diagnostic mounts') +mounts=sorted({record['mount_point'] for record in exact}) +if len(mounts)>MAX_INVENTORY: + raise RuntimeError('too many held mount inventory entries') +exact_samples=sorted(exact,key=lambda record:(record['mount_point'],record['mount_id']))[:MAX_EXACT_SAMPLES] related=[] if not exact: probes=(prefix,*target_paths) @@ -4637,24 +4660,33 @@ def distance(record): 'schema':'pdd-held-namespace-diagnostic-v1','operation':'scan', 'prefix':str(prefix),'targets':[str(path) for path in target_paths], 'mount_namespace':{'link':namespace_link,'inode':namespace_inode}, - 'root':{'link':root_link,'device':root_metadata.st_dev,'inode':root_metadata.st_ino, - 'mode':root_metadata.st_mode,'mount_id':int(root_mnt_ids[0])}, + 'root':{'link':root_link,'expected':expected_root, + 'actual':{'device':root_metadata.st_dev,'inode':root_metadata.st_ino, + 'mode':root_metadata.st_mode,'mount_id':int(root_mnt_ids[0])}, + 'matches':None if expected_root is None else expected_root=={ + 'device':root_metadata.st_dev,'inode':root_metadata.st_ino, + 'mode':root_metadata.st_mode,'mount_id':int(root_mnt_ids[0])}}, 'cwd':cwd_record(), - 'paths':{'prefix':path_record(prefix),'targets':[path_record(path) for path in target_paths]}, - 'mountinfo':{'exact':exact,'related':related}, + 'paths':{'prefix':path_record(prefix),'target_count':len(target_paths), + 'targets':[path_record(path) for path in target_paths[:MAX_TARGET_SAMPLES]]}, + 'mountinfo':{'mounts':mounts,'exact_count':len(exact), + 'samples':exact_samples,'related':related}, } print(json.dumps(output,sort_keys=True,separators=(',',':'))) """ -_HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES = 48 * 1024 -_HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS = 24 -_HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS = 24 +# Hosted cleanup has observed roughly 130 mountpoints. The complete inventory +# is authoritative for teardown; only its detailed records are sampled. +_HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES = 2 * 1024 * 1024 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS = 512 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS = 16 _HELD_NAMESPACE_DIAGNOSTIC_MAX_RELATED_MOUNTS = 8 -_HELD_NAMESPACE_DIAGNOSTIC_MAX_TEXT = 512 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_TEXT = 1024 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_DETAIL_TEXT = 128 -def _parse_held_namespace_diagnostic( +def _parse_held_namespace_diagnostic_legacy( output: str, *, namespace: dict[str, object], control_prefix: Path, targets: tuple[Path, ...], ) -> tuple[dict[str, object], tuple[Path, ...]]: @@ -4851,6 +4883,238 @@ def mount_record(value: object, label: str) -> str: return diagnostic, tuple(Path(point) for point in sorted(exact_points)) +def _parse_held_namespace_diagnostic( + output: str, *, namespace: dict[str, object], holder: dict[str, object], + control_prefix: Path, targets: tuple[Path, ...], +) -> tuple[dict[str, object], tuple[Path, ...]]: + """Fail closed unless a complete inventory and bounded detail samples agree.""" + def reject(message: str) -> None: + raise ValueError(f"held namespace diagnostic {message}") + + def text(value: object, label: str, *, detail: bool = False) -> str: + maximum = ( + _HELD_NAMESPACE_DIAGNOSTIC_MAX_DETAIL_TEXT + if detail else _HELD_NAMESPACE_DIAGNOSTIC_MAX_TEXT + ) + if ( + type(value) is not str or not value or len(value) > maximum + or any( + (ord(character) < 32 and character not in "\t\n") + or ord(character) == 127 for character in value + ) + ): + reject(f"has invalid {label}") + return value + + def path(value: object, label: str) -> str: + value = text(value, label) + candidate = PurePosixPath(value) + if not value.startswith("/") or str(candidate) != value or any( + part in {".", ".."} for part in candidate.parts + ): + reject(f"has non-canonical {label}") + return value + + def integer(value: object, label: str, *, zero: bool = False) -> int: + if type(value) is not int or value < (0 if zero else 1): + reject(f"has invalid {label}") + return value + + def identity(value: object, label: str) -> dict[str, int]: + if not isinstance(value, dict) or set(value) != {"device", "inode", "mode", "mount_id"}: + reject(f"has invalid {label}") + return { + "device": integer(value["device"], f"{label} device", zero=True), + "inode": integer(value["inode"], f"{label} inode"), + "mode": integer(value["mode"], f"{label} mode", zero=True), + "mount_id": integer(value["mount_id"], f"{label} mount ID"), + } + + def metadata(value: object, label: str) -> None: + if not isinstance(value, dict): + reject(f"has invalid {label}") + if value.get("status") == "ok": + if set(value) != {"status", "device", "inode", "mode"}: + reject(f"has invalid {label}") + integer(value["device"], f"{label} device", zero=True) + integer(value["inode"], f"{label} inode") + integer(value["mode"], f"{label} mode", zero=True) + return + if value.get("status") == "error" and set(value) == {"status", "errno"}: + if integer(value["errno"], f"{label} errno") <= 4095: + return + reject(f"has invalid {label}") + + def path_record(value: object, expected: str, label: str) -> None: + if not isinstance(value, dict) or set(value) != {"path", "exists", "lstat", "stat"}: + reject(f"has invalid {label}") + if path(value["path"], f"{label} path") != expected or type(value["exists"]) is not bool: + reject(f"has mismatched {label}") + metadata(value["lstat"], f"{label} lstat") + metadata(value["stat"], f"{label} stat") + if value["exists"] != (value["stat"].get("status") == "ok"): + reject(f"has inconsistent {label} existence") + + def mount(value: object, label: str) -> tuple[str, int]: + if not isinstance(value, dict) or set(value) != { + "mount_id", "parent_id", "root", "mount_point", "major_minor", + "filesystem", "source", "options", "truncated", + }: + reject(f"has invalid {label}") + mount_id = integer(value["mount_id"], f"{label} mount ID") + integer(value["parent_id"], f"{label} parent ID", zero=True) + path(value["root"], f"{label} root") + point = path(value["mount_point"], f"{label} mount point") + major_minor = text(value["major_minor"], f"{label} major minor", detail=True) + if len(major_minor.split(":")) != 2 or not all( + part.isdigit() for part in major_minor.split(":") + ): + reject(f"has invalid {label} major minor") + text(value["filesystem"], f"{label} filesystem", detail=True) + text(value["source"], f"{label} source", detail=True) + options = value["options"] + if not isinstance(options, dict) or set(options) != {"mount", "super"}: + reject(f"has invalid {label} options") + text(options["mount"], f"{label} mount options", detail=True) + text(options["super"], f"{label} super options", detail=True) + permitted = {"filesystem", "source", "options.mount", "options.super"} + truncated = value["truncated"] + if ( + type(truncated) is not list or truncated != sorted(set(truncated)) + or any(type(item) is not str or item not in permitted for item in truncated) + ): + reject(f"has invalid {label} truncation") + return point, mount_id + + if type(output) is not str or len(output.encode("utf-8")) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES: + reject("exceeds the output cap") + try: + diagnostic = json.loads(output) + except json.JSONDecodeError as exc: + raise ValueError("held namespace diagnostic is not JSON") from exc + if not isinstance(diagnostic, dict) or set(diagnostic) != { + "schema", "operation", "prefix", "targets", "mount_namespace", "root", + "cwd", "paths", "mountinfo", + }: + reject("has invalid shape") + if diagnostic["schema"] != "pdd-held-namespace-diagnostic-v1" or diagnostic["operation"] != "scan": + reject("has invalid operation") + expected_targets = [str(target) for target in sorted(targets)] + if path(diagnostic["prefix"], "prefix") != str(control_prefix): + reject("has mismatched prefix") + reported_targets = diagnostic["targets"] + if ( + type(reported_targets) is not list or len(reported_targets) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS + or [path(value, "target") for value in reported_targets] != expected_targets + ): + reject("has mismatched targets") + reported_namespace = diagnostic["mount_namespace"] + if not isinstance(reported_namespace, dict) or set(reported_namespace) != {"link", "inode"}: + reject("has invalid mount namespace") + link = text(reported_namespace["link"], "mount namespace link") + if ( + not link.startswith("mnt:[") or not link.endswith("]") or not link[5:-1].isdigit() + or link != namespace.get("link") + or integer(reported_namespace["inode"], "mount namespace inode") != namespace.get("inode") + ): + reject("has mismatched mount namespace") + expected_root = None + root_fields = ("root_device", "root_inode", "root_mode", "root_mnt_id") + if all(field in holder for field in root_fields): + expected_root = { + "device": holder["root_device"], "inode": holder["root_inode"], + "mode": holder["root_mode"], "mount_id": holder["root_mnt_id"], + } + expected_root = identity(expected_root, "captured root") + root = diagnostic["root"] + if not isinstance(root, dict) or set(root) != {"link", "expected", "actual", "matches"}: + reject("has invalid root") + path(root["link"], "root link") + actual_root = identity(root["actual"], "actual root") + if root["expected"] is None: + if expected_root is not None or root["matches"] is not None: + reject("has mismatched expected root") + else: + if identity(root["expected"], "expected root") != expected_root: + reject("has mismatched expected root") + if type(root["matches"]) is not bool or root["matches"] != (actual_root == expected_root): + reject("has inconsistent root match") + cwd = diagnostic["cwd"] + if not isinstance(cwd, dict): + reject("has invalid cwd") + if cwd.get("status") == "ok" and set(cwd) == {"status", "path"}: + path(cwd["path"], "cwd path") + elif cwd.get("status") == "error" and set(cwd) == {"status", "errno"}: + if integer(cwd["errno"], "cwd errno") > 4095: + reject("has invalid cwd errno") + else: + reject("has invalid cwd") + paths = diagnostic["paths"] + if not isinstance(paths, dict) or set(paths) != {"prefix", "target_count", "targets"}: + reject("has invalid paths") + path_record(paths["prefix"], str(control_prefix), "prefix") + if integer(paths["target_count"], "target count", zero=True) != len(expected_targets): + reject("has mismatched target count") + samples = paths["targets"] + if type(samples) is not list or len(samples) > 16: + reject("has invalid target samples") + for record, expected in zip(samples, expected_targets[:16]): + path_record(record, expected, "target") + if len(samples) != min(16, len(expected_targets)): + reject("has incomplete target samples") + mountinfo = diagnostic["mountinfo"] + if not isinstance(mountinfo, dict) or set(mountinfo) != {"mounts", "exact_count", "samples", "related"}: + reject("has invalid mountinfo") + mounts = mountinfo["mounts"] + if type(mounts) is not list or len(mounts) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS: + reject("has invalid mount inventory") + mount_points = [path(value, "mount inventory") for value in mounts] + if ( + mount_points != sorted(set(mount_points)) + or any(not _canonical_owned_mount_point(value, control_prefix, targets) for value in mount_points) + ): + reject("has invalid mount inventory") + exact_count = integer(mountinfo["exact_count"], "exact count", zero=True) + samples = mountinfo["samples"] + related = mountinfo["related"] + if type(samples) is not list or len(samples) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS: + reject("has invalid mount samples") + sample_keys = [mount(value, "mount sample") for value in samples] + if sample_keys != sorted(sample_keys) or any(point not in mount_points for point, _mount_id in sample_keys): + reject("has unordered mount samples") + if exact_count < len(sample_keys) or (mounts and len(samples) != min(16, exact_count)): + reject("has incomplete mount samples") + if type(related) is not list or len(related) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_RELATED_MOUNTS: + reject("has invalid related mounts") + related_keys = [mount(value, "related mount") for value in related] + if mounts: + if related: + reject("has unrelated mount samples") + elif not related_keys or any(_canonical_owned_mount_point(point, control_prefix, targets) for point, _mount_id in related_keys): + reject("has invalid related mounts") + return diagnostic, tuple(Path(value) for value in mount_points) + + +def _sanitized_held_namespace_scan_stderr(stderr: object) -> str: + """Return a short stable child-error tail without paths, payloads, or tracebacks.""" + if not isinstance(stderr, str): + return "redacted" + known = ( + "invalid diagnostic scan payload", "invalid diagnostic targets", + "invalid expected root identity", "malformed mountinfo", + "invalid mountinfo optional field", "escaped mountinfo optional field", + "invalid current mount namespace", "invalid current root mount ID", + "too many held mount inventory entries", "oversized", + ) + tail = [] + for line in stderr.splitlines()[-8:]: + for marker in known: + if marker in line: + tail.append(marker) + break + return ",".join(tail)[-256:] or "redacted" + + _BLOCKED_WCHAN_MARKERS = ("sleep", "wait", "pause", "futex") @@ -5073,26 +5337,33 @@ def holder_mount_scan() -> tuple[Path, ...] | None: if namespace_holder is None: return () scan_targets = tuple(sorted(captured_mounts)) - payload = holder_command_payload("scan") - completed = command([ - "sudo", "-n", str(supervisor._trusted_tools().helper_python), - "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, payload, - ], "scan exact held mount namespace") + try: + payload = holder_command_payload("scan") + argv = [ + "sudo", "-n", str(supervisor._trusted_tools().helper_python), + "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, payload, + ] + except (OSError, RuntimeError, TypeError, ValueError) as exc: + errors.append("held mount namespace scan construction failed: " + type(exc).__name__) + return None + completed = command(argv, "scan exact held mount namespace") if completed is None or completed.returncode != 0: if completed is not None: errors.append( - "held mount namespace scan failed: returncode=" - + repr(completed.returncode) + "held mount namespace scan failed: category=child-nonzero stderr_tail=" + + _sanitized_held_namespace_scan_stderr(completed.stderr) ) return None try: diagnostic, mounts = _parse_held_namespace_diagnostic( - completed.stdout, namespace=namespace, + completed.stdout, namespace=namespace, holder=namespace_holder, control_prefix=control_prefix, targets=scan_targets, ) - except (json.JSONDecodeError, ValueError) as exc: - errors.append(f"held mount namespace scan was malformed: {exc}") + except (TypeError, ValueError) as exc: + errors.append("held mount namespace scan was malformed: " + str(exc)[:256]) return None + if diagnostic["root"]["matches"] is False: + errors.append("held mount namespace root identity mismatched") held_namespace_diagnostics.append( "held mount namespace diagnostic=" + json.dumps(diagnostic, sort_keys=True, separators=(",", ":")) @@ -5110,22 +5381,30 @@ def holder_mount_scan() -> tuple[Path, ...] | None: or not held_mounts ): errors.append("exact FD-only namespace mount ownership was not preserved") + deferred_absent_unmounts = [] for mount in sorted( captured_mounts, key=lambda path: (len(path.parts), str(path)), reverse=True, ): if namespace_holder is None: argv = ["sudo", "-n", "umount", str(mount)] else: - payload = holder_command_payload("unmount", mount) - argv = [ - "sudo", "-n", str(supervisor._trusted_tools().helper_python), - "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, payload, - ] + try: + payload = holder_command_payload("unmount", mount) + argv = [ + "sudo", "-n", str(supervisor._trusted_tools().helper_python), + "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, payload, + ] + except (OSError, RuntimeError, TypeError, ValueError) as exc: + errors.append("namespace unmount construction failed: " + type(exc).__name__) + continue completed = command(argv, f"unmount {mount}") if completed is None or completed.returncode == 0: continue diagnostic = (completed.stderr or completed.stdout).strip().lower() - errors.append(diagnostic[:512] or f"cannot unmount {mount}") + if "not mounted" in diagnostic or "no such file" in diagnostic: + deferred_absent_unmounts.append((mount, diagnostic[:512])) + else: + errors.append(diagnostic[:512] or f"cannot unmount {mount}") remaining_held_mounts = holder_mount_scan() if remaining_held_mounts: @@ -5133,6 +5412,15 @@ def holder_mount_scan() -> tuple[Path, ...] | None: "owned mounts remain in held namespace: " + ", ".join(str(path) for path in remaining_held_mounts) ) + if deferred_absent_unmounts: + proven_absent = remaining_held_mounts is not None and all( + mount not in remaining_held_mounts for mount, _diagnostic in deferred_absent_unmounts + ) + if not proven_absent: + errors.extend( + diagnostic or f"cannot unmount {mount}" + for mount, diagnostic in deferred_absent_unmounts + ) for holder in ownership.get("external_holders", ()): expected = _process_key(holder) @@ -5140,11 +5428,17 @@ def holder_mount_scan() -> tuple[Path, ...] | None: selection=_RootProcSelection((int(holder["pid"]),)), ) if any(_process_key(record) == expected for record in holder_scan["watched"]): - completed = command([ - "sudo", "-n", str(supervisor._trusted_tools().helper_python), - "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, - _external_holder_termination_payload(holder, namespace), - ], "terminate exact external namespace holder") + try: + argv = [ + "sudo", "-n", str(supervisor._trusted_tools().helper_python), + "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, + _external_holder_termination_payload(holder, namespace), + ] + except (OSError, RuntimeError, TypeError, ValueError) as exc: + errors.append("external holder termination construction failed: " + type(exc).__name__) + completed = None + else: + completed = command(argv, "terminate exact external namespace holder") if completed is not None and completed.returncode != 0: errors.append( completed.stderr.strip() @@ -5263,10 +5557,63 @@ def test_root_proc_scanner_source_compiles() -> None: compile(_NAMESPACE_MOUNT_SCANNER_SOURCE, "", "exec") +def _valid_mountinfo_optional_fields(fields: tuple[str, ...]) -> bool: + """Mirror the scanner's documented Linux mountinfo optional-field grammar.""" + for field in fields: + if "\\" in field or field == "unbindable": + if field != "unbindable": + return False + continue + tag, separator, value = field.partition(":") + if ( + separator != ":" or tag not in {"shared", "master", "propagate_from"} + or not value.isdigit() or int(value) <= 0 + ): + return False + return True + + +def _unescape_documented_mountinfo_field(value: str) -> str: + """Mirror the scanner's limited Linux mountinfo escape alphabet.""" + result = [] + index = 0 + escapes = {"040": " ", "011": "\t", "012": "\n", "134": "\\"} + while index < len(value): + if value[index] != "\\": + result.append(value[index]) + index += 1 + continue + escaped = value[index + 1:index + 4] + if escaped not in escapes: + raise ValueError("invalid mountinfo escape") + result.append(escapes[escaped]) + index += 4 + return "".join(result) + + +def test_mountinfo_optional_field_grammar_rejects_undefined_escapes_and_tags() -> None: + """Only Linux's documented propagation optional fields are diagnostic input.""" + assert _valid_mountinfo_optional_fields( + ("shared:10", "master:11", "propagate_from:12", "unbindable") + ) + for fields in (("shared:0",), ("private",), ("unknown:1",), ("shared:1\\040",)): + assert not _valid_mountinfo_optional_fields(fields) + assert _unescape_documented_mountinfo_field(r"a\040b\011c\012d\134e") == "a b\tc\nd\\e" + with pytest.raises(ValueError, match="invalid mountinfo escape"): + _unescape_documented_mountinfo_field(r"a\041b") + + def test_held_namespace_diagnostic_parser_is_strict_bounded_and_actionable() -> None: """The held-namespace envelope is portable, bounded, and fails closed.""" prefix = Path("/tmp/pdd-scope-owned") - target = prefix / "binds" / "writable" + targets = tuple(sorted( + prefix / "binds" / f"{index:03d}-{'nested-' * 20}mount" + for index in range(130) + )) + holder = { + "root_device": 1, "root_inode": 2, "root_mode": 0o40755, + "root_mnt_id": 42, + } def metadata() -> dict[str, object]: return {"status": "ok", "device": 1, "inode": 2, "mode": 0o40700} @@ -5287,30 +5634,50 @@ def mount(point: Path, mount_id: int) -> dict[str, object]: diagnostic = { "schema": "pdd-held-namespace-diagnostic-v1", "operation": "scan", - "prefix": str(prefix), "targets": [str(target)], + "prefix": str(prefix), "targets": [str(target) for target in targets], "mount_namespace": {"link": "mnt:[11]", "inode": 11}, "root": { - "link": "/", "device": 1, "inode": 2, "mode": 0o40755, - "mount_id": 42, + "link": "/", "expected": { + "device": 1, "inode": 2, "mode": 0o40755, "mount_id": 42, + }, "actual": { + "device": 1, "inode": 2, "mode": 0o40755, "mount_id": 42, + }, "matches": True, }, "cwd": {"status": "ok", "path": "/"}, "paths": { - "prefix": path_record(prefix), "targets": [path_record(target)], + "prefix": path_record(prefix), "target_count": len(targets), + "targets": [path_record(target) for target in targets[:16]], + }, + "mountinfo": { + "mounts": [str(target) for target in targets], "exact_count": len(targets), + "samples": [mount(target, index + 43) for index, target in enumerate(targets[:16])], + "related": [], }, - "mountinfo": {"exact": [mount(target, 43)], "related": []}, } raw = json.dumps(diagnostic, sort_keys=True, separators=(",", ":")) parsed, mounts = _parse_held_namespace_diagnostic( raw, namespace={"link": "mnt:[11]", "inode": 11}, - control_prefix=prefix, targets=(target,), + holder=holder, control_prefix=prefix, targets=targets, ) assert parsed == diagnostic - assert mounts == (target,) + assert mounts == targets + + root_mismatch = json.loads(raw) + root_mismatch["root"]["actual"]["inode"] = 3 + root_mismatch["root"]["matches"] = False + parsed, mounts = _parse_held_namespace_diagnostic( + json.dumps(root_mismatch), namespace={"link": "mnt:[11]", "inode": 11}, + holder=holder, control_prefix=prefix, targets=targets, + ) + assert parsed["root"]["matches"] is False and mounts == targets no_exact = json.loads(raw) - no_exact["mountinfo"] = {"exact": [], "related": [mount(Path("/"), 44)]} + no_exact["mountinfo"] = { + "mounts": [], "exact_count": 0, "samples": [], + "related": [mount(Path("/"), 44)], + } _parsed, mounts = _parse_held_namespace_diagnostic( json.dumps(no_exact), namespace={"link": "mnt:[11]", "inode": 11}, - control_prefix=prefix, targets=(target,), + holder=holder, control_prefix=prefix, targets=targets, ) assert mounts == () @@ -5321,23 +5688,187 @@ def mount(point: Path, mount_id: int) -> dict[str, object]: wrong_namespace["mount_namespace"]["inode"] = 12 malformed.append(json.dumps(wrong_namespace)) mixed_mounts = json.loads(raw) - mixed_mounts["mountinfo"]["related"] = [mount(Path("/"), 44)] + mixed_mounts["mountinfo"]["related"] = [mount(Path("/"), 999)] malformed.append(json.dumps(mixed_mounts)) missing_related = json.loads(raw) - missing_related["mountinfo"]["exact"] = [] + missing_related["mountinfo"] = { + "mounts": [], "exact_count": 0, "samples": [], "related": [], + } malformed.append(json.dumps(missing_related)) - too_many_exact = json.loads(raw) - too_many_exact["mountinfo"]["exact"] = [ - mount(target / str(index), index + 100) - for index in range(_HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS + 1) - ] - malformed.append(json.dumps(too_many_exact)) + unordered_samples = json.loads(raw) + unordered_samples["mountinfo"]["samples"] = list(reversed( + unordered_samples["mountinfo"]["samples"] + )) + malformed.append(json.dumps(unordered_samples)) malformed.append("x" * (_HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES + 1)) for value in malformed: with pytest.raises(ValueError, match="held namespace diagnostic"): _parse_held_namespace_diagnostic( value, namespace={"link": "mnt:[11]", "inode": 11}, - control_prefix=prefix, targets=(target,), + holder=holder, control_prefix=prefix, targets=targets, + ) + + +@pytest.mark.parametrize("failure", ("construction", "nonzero", "malformed")) +def test_held_namespace_scan_failures_continue_cleanup_and_final_verification( + tmp_path: Path, failure: str, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Failed diagnostic scans retain unmount and final-verification ownership.""" + prefix = tmp_path / "pdd-scope-owned" + mount = prefix / "binds" / "writable" + namespace = {"link": "mnt:[11]", "inode": 11} + holder = { + "holder_kind": "current", "pid": 22, "start_time": "100", + "namespace": "mnt:[11]", "namespace_inode": 11, + } + ownership = { + "unit": "pdd-validator-test.scope", "cgroup": tmp_path / "cgroup", + "control_prefix": prefix, "namespace": namespace, + "namespace_holder": holder, "coordinator": {"pid": 1, "start_time": "1"}, + "mount_points": [mount], + } + commands = [] + scans = 0 + monkeypatch.setattr( + supervisor, "_trusted_tools", lambda: SimpleNamespace(helper_python=Path("/trusted/python")), + ) + if failure == "construction": + original_payload = _namespace_holder_command_payload + + def reject_scan_payload(*args, **kwargs): + if args[2] == "scan": + raise ValueError("injected diagnostic construction failure") + return original_payload(*args, **kwargs) + + monkeypatch.setattr(sys.modules[__name__], "_namespace_holder_command_payload", reject_scan_payload) + + def scanner(**_kwargs): + nonlocal scans + scans += 1 + return { + "watched": [], "identities": [], "cgroup_exists": False, + "mount_holders": [], "fd_holders": [], + "current_holders": [holder] if scans <= 2 else [], + } + + def runner(argv, **_kwargs): + commands.append(argv) + if "systemctl" in argv and "show" in argv: + return SimpleNamespace(returncode=0, stdout="not-found\n", stderr="") + if _NSENTER_REVALIDATOR_SOURCE in argv: + payload = json.loads(argv[-1]) + if payload["operation"] == "scan": + if failure == "nonzero": + return SimpleNamespace( + returncode=2, stdout="", + stderr="Traceback payload=/secret\nRuntimeError: malformed mountinfo\n", + ) + return SimpleNamespace(returncode=0, stdout="[]", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + with pytest.raises(AssertionError) as raised: + _fallback_stalled_observation_cleanup( + ownership, (), runner=runner, scanner=scanner, + ) + message = str(raised.value) + operations = [ + json.loads(argv[-1])["operation"] + for argv in commands if _NSENTER_REVALIDATOR_SOURCE in argv + ] + assert "unmount" in operations + assert any("show" in argv for argv in commands) + if failure == "nonzero": + assert "category=child-nonzero stderr_tail=malformed mountinfo" in message + assert "Traceback" not in message and "/secret" not in message + elif failure == "malformed": + assert "held mount namespace scan was malformed" in message + else: + assert "held mount namespace scan construction failed" in message + + +@pytest.mark.parametrize( + ("stderr", "succeeds"), + (("not mounted", True), ("no such file", True), ("no mount point specified", False)), +) +def test_proven_absent_unmount_failures_are_deferred_only_after_later_scan( + tmp_path: Path, stderr: str, succeeds: bool, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only later namespace absence proof can discharge known stale-unmount output.""" + prefix = tmp_path / "pdd-scope-owned" + target = prefix / "binds" / "writable" + namespace = {"link": "mnt:[11]", "inode": 11} + holder = { + "holder_kind": "current", "pid": 22, "start_time": "100", + "namespace": "mnt:[11]", "namespace_inode": 11, + } + ownership = { + "unit": "pdd-validator-test.scope", "cgroup": tmp_path / "cgroup", + "control_prefix": prefix, "namespace": namespace, + "namespace_holder": holder, "coordinator": {"pid": 1, "start_time": "1"}, + "mount_points": [target], + } + monkeypatch.setattr( + supervisor, "_trusted_tools", lambda: SimpleNamespace(helper_python=Path("/trusted/python")), + ) + + def metadata() -> dict[str, object]: + return {"status": "ok", "device": 1, "inode": 2, "mode": 0o40700} + + def diagnostic(mounts: list[Path]) -> str: + record = { + "mount_id": 43, "parent_id": 1, "root": "/", "mount_point": str(target), + "major_minor": "0:42", "filesystem": "tmpfs", "source": "tmpfs", + "options": {"mount": "rw", "super": "rw"}, "truncated": [], + } + return json.dumps({ + "schema": "pdd-held-namespace-diagnostic-v1", "operation": "scan", + "prefix": str(prefix), "targets": [str(target)], + "mount_namespace": namespace, + "root": {"link": "/", "expected": None, "actual": { + "device": 1, "inode": 2, "mode": 0o40755, "mount_id": 42, + }, "matches": None}, + "cwd": {"status": "ok", "path": "/"}, "paths": { + "prefix": {"path": str(prefix), "exists": True, "lstat": metadata(), "stat": metadata()}, + "target_count": 1, + "targets": [{"path": str(target), "exists": True, "lstat": metadata(), "stat": metadata()}], + }, + "mountinfo": { + "mounts": [str(path) for path in mounts], "exact_count": len(mounts), + "samples": [record] if mounts else [], + "related": [] if mounts else [{**record, "mount_id": 44, "mount_point": "/"}], + }, + }, sort_keys=True) + + scan_outputs = [diagnostic([target]), diagnostic([])] + scans = 0 + + def scanner(**_kwargs): + nonlocal scans + scans += 1 + return { + "watched": [], "identities": [], "cgroup_exists": False, + "mount_holders": [], "fd_holders": [], + "current_holders": [holder] if scans <= 2 else [], + } + + def runner(argv, **_kwargs): + if "systemctl" in argv and "show" in argv: + return SimpleNamespace(returncode=0, stdout="not-found\n", stderr="") + if _NSENTER_REVALIDATOR_SOURCE in argv: + payload = json.loads(argv[-1]) + if payload["operation"] == "scan": + return SimpleNamespace(returncode=0, stdout=scan_outputs.pop(0), stderr="") + return SimpleNamespace(returncode=1, stdout="", stderr=stderr) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + if succeeds: + assert _fallback_stalled_observation_cleanup( + ownership, (), runner=runner, scanner=scanner, + ) == () + else: + with pytest.raises(AssertionError, match="no mount point specified"): + _fallback_stalled_observation_cleanup( + ownership, (), runner=runner, scanner=scanner, ) From 4c62e17cdecfb5f9c255f208961fef85f392749e Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 19:25:14 -0700 Subject: [PATCH 219/233] test(sync): require sealed diagnostic transport --- tests/test_sync_core_supervisor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index b3d0b28cb7..9f66f71c4c 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -5557,6 +5557,14 @@ def test_root_proc_scanner_source_compiles() -> None: compile(_NAMESPACE_MOUNT_SCANNER_SOURCE, "", "exec") +def test_held_namespace_scan_uses_authenticated_sealed_fd_transport() -> None: + """The complete scan inventory is stdin-fed and never becomes an argv item.""" + # The transport helper is intentionally exercised separately from nsenter so + # this assertion remains deterministic at the declared cardinality bound. + assert _HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES > 128 * 1024 + assert _held_namespace_scan_stdin is not None + + def _valid_mountinfo_optional_fields(fields: tuple[str, ...]) -> bool: """Mirror the scanner's documented Linux mountinfo optional-field grammar.""" for field in fields: From 48ceab521c0a3799b1e6d1717cfc93da712735b6 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 19:36:52 -0700 Subject: [PATCH 220/233] test(sync): model sealed held namespace scan frame --- tests/test_sync_core_supervisor.py | 511 ++++++++++++++++++++++++----- 1 file changed, 425 insertions(+), 86 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 9f66f71c4c..f96f0b656f 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4305,6 +4305,53 @@ class _NamespaceHolderCommandContext: helper_python: str +_HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS = 512 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_PATH_BYTES = 1024 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_DETAIL_BYTES = 128 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS = 16 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_RELATED_MOUNTS = 8 +_HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGET_SAMPLES = 16 +# A non-ASCII UTF-8 byte can expand to three JSON bytes (a four-byte scalar +# becomes two ``\\uXXXX`` escapes). The scan contains one complete inventory; +# the response contains complete target and mount inventories. +_HELD_NAMESPACE_JSON_ESCAPED_BYTES_PER_UTF8_BYTE = 3 +_HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES = ( + _HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS + * _HELD_NAMESPACE_DIAGNOSTIC_MAX_PATH_BYTES + * _HELD_NAMESPACE_JSON_ESCAPED_BYTES_PER_UTF8_BYTE + + 64 * 1024 +) +_HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES = ( + 2 * _HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS + * _HELD_NAMESPACE_DIAGNOSTIC_MAX_PATH_BYTES + * _HELD_NAMESPACE_JSON_ESCAPED_BYTES_PER_UTF8_BYTE + + 256 * 1024 +) +# Kept solely for the retained v1 parser fixture below. +_HELD_NAMESPACE_DIAGNOSTIC_MAX_TEXT = _HELD_NAMESPACE_DIAGNOSTIC_MAX_PATH_BYTES +_HELD_NAMESPACE_DIAGNOSTIC_MAX_DETAIL_TEXT = _HELD_NAMESPACE_DIAGNOSTIC_MAX_DETAIL_BYTES + + +def _held_namespace_scan_stdin( + holder: dict[str, object], context: _NamespaceHolderCommandContext, +) -> bytes: + """Serialize the complete inventory for the bounded authenticated stdin leg.""" + root_fields = ("root_device", "root_inode", "root_mode", "root_mnt_id") + expected_root = None if not all(field in holder for field in root_fields) else { + "device": holder["root_device"], "inode": holder["root_inode"], + "mode": holder["root_mode"], "mount_id": holder["root_mnt_id"], + } + payload = { + "operation": "scan", "prefix": str(context.control_prefix), + "targets": [str(path) for path in sorted(context.captured_mounts)], + "expected_root": expected_root, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + if len(encoded) > _HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES: + raise ValueError("namespace diagnostic scan payload exceeds byte cap") + return encoded + + def _namespace_holder_command_payload( holder: dict[str, object], namespace: dict[str, object], operation: str, *, mount: Path | None = None, context: _NamespaceHolderCommandContext, @@ -4328,19 +4375,11 @@ def _namespace_holder_command_payload( if operation == "unmount": payload["mount"] = str(mount) else: - root_fields = ("root_device", "root_inode", "root_mode", "root_mnt_id") - expected_root = None if not all(field in holder for field in root_fields) else { - "device": holder["root_device"], "inode": holder["root_inode"], - "mode": holder["root_mode"], "mount_id": holder["root_mnt_id"], - } + scan_stdin = _held_namespace_scan_stdin(holder, context) payload["scanner"] = _NAMESPACE_MOUNT_SCANNER_SOURCE - payload["scan_payload"] = { - "operation": "scan", - "prefix": str(context.control_prefix), - "targets": [str(path) for path in sorted(context.captured_mounts)], - "expected_root": expected_root, - } - return json.dumps(payload, sort_keys=True) + payload["scan_bytes"] = len(scan_stdin) + payload["scan_sha256"] = hashlib.sha256(scan_stdin).hexdigest() + return json.dumps(payload, sort_keys=True, separators=(",", ":")) def _holder_key(record: dict[str, object]) -> tuple[object, ...]: @@ -4380,9 +4419,18 @@ def _exact_unit_not_found(completed: object) -> bool: _NSENTER_REVALIDATOR_SOURCE = r""" -import json,os,pathlib,signal,sys +import fcntl,hashlib,json,os,pathlib,signal,sys -payload=json.loads(sys.argv[1]) +if len(sys.argv)!=2 or len(sys.argv[1].encode('utf-8'))>131072: + raise SystemExit('diagnostic transport invariant') +try: + payload=json.loads(sys.argv[1]) +except json.JSONDecodeError as error: + raise SystemExit('diagnostic transport invariant') from error +if not isinstance(payload,dict): + raise SystemExit('diagnostic transport invariant') +if sys.argv[1] != json.dumps(payload,sort_keys=True,separators=(',',':')): + raise SystemExit('diagnostic transport invariant') holder=payload['holder']; namespace=payload['namespace'] pid=holder.get('pid'); start_time=holder.get('start_time') if type(pid) is not int or pid<=0 or type(start_time) is not str: @@ -4416,6 +4464,61 @@ def exact_namespace(path): pass os.close(pidfd) raise SystemExit(0) + +def transport_failure(category): + raise RuntimeError('diagnostic transport '+category) + +def read_scan_stdin(expected_bytes,expected_digest): + if (type(expected_bytes) is not int or expected_bytes<0 or + expected_bytes>1638400 or type(expected_digest) is not str or + len(expected_digest)!=64 or any(char not in '0123456789abcdef' for char in expected_digest)): + transport_failure('invariant') + chunks=[]; total=0 + while True: + chunk=sys.stdin.buffer.read(min(65536,1638401-total)) + if not chunk: break + total+=len(chunk) + if total>1638400: transport_failure('oversized') + chunks.append(chunk) + raw=b''.join(chunks) + if len(raw)!=expected_bytes or hashlib.sha256(raw).hexdigest()!=expected_digest: + transport_failure('authentication') + return raw + +def sealed_memfd(raw): + required=('F_ADD_SEALS','F_GET_SEALS','F_SEAL_WRITE','F_SEAL_GROW','F_SEAL_SHRINK','F_SEAL_SEAL') + if not hasattr(os,'memfd_create') or not hasattr(os,'MFD_ALLOW_SEALING') or any(not hasattr(fcntl,name) for name in required): + transport_failure('unavailable') + try: + fd=os.memfd_create('pdd-held-namespace-scan',os.MFD_CLOEXEC|os.MFD_ALLOW_SEALING) + offset=0 + while offset2 and descriptor not in allowed: + os.set_inheritable(descriptor,False) + for descriptor in allowed: + os.set_inheritable(descriptor,True) + except OSError: + transport_failure('invariant') current_path=proc/'ns'/'mnt' current_link=os.readlink(current_path); current_inode=current_path.stat().st_ino if (current_link!=holder.get('namespace') or @@ -4463,7 +4566,10 @@ def exact_namespace(path): raise RuntimeError('root descriptor identity changed') if identity()!=start_time: raise RuntimeError('holder identity raced after descriptor pinning') -os.set_inheritable(namespace_fd,True); os.set_inheritable(root_fd,True) +scan_fd=None +if operation=='scan': + scan_fd=sealed_memfd(read_scan_stdin(payload.get('scan_bytes'),payload.get('scan_sha256'))) +inherit_only(namespace_fd,root_fd,*(() if scan_fd is None else (scan_fd,))) os.close(pidfd) if operation=='unmount': mount=payload.get('mount') @@ -4474,8 +4580,10 @@ def exact_namespace(path): raise RuntimeError('invalid namespace unmount point') command=[payload['umount'],mount] elif operation=='scan': + if scan_fd is None: + transport_failure('invariant') command=[payload['python'],'-I','-S','-c',payload['scanner'], - json.dumps(payload['scan_payload'],sort_keys=True)] + '/proc/self/fd/'+str(scan_fd),str(payload['scan_bytes']),payload['scan_sha256']] else: raise RuntimeError('invalid namespace operation') os.execv(payload['nsenter'],[payload['nsenter'],'--mount=/proc/self/fd/'+str(namespace_fd), @@ -4484,16 +4592,44 @@ def exact_namespace(path): _NAMESPACE_MOUNT_SCANNER_SOURCE = r""" -import json,os,pathlib,sys +import hashlib,json,os,pathlib,sys # Hosted inventory has reached roughly 130 mountpoints; retain all up to 512. MAX_INVENTORY=512 MAX_TARGET_SAMPLES=16 MAX_EXACT_SAMPLES=16 MAX_RELATED_MOUNTS=8 -MAX_PATH_TEXT=1024 -MAX_DETAIL_TEXT=128 -payload=json.loads(sys.argv[1]) +MAX_PATH_BYTES=1024 +MAX_DETAIL_BYTES=128 +MAX_INPUT_BYTES=1638400 +MAX_OUTPUT_BYTES=3407872 +if len(sys.argv)!=4 or not sys.argv[1].startswith('/proc/self/fd/'): + raise RuntimeError('invalid diagnostic transport reference') +try: + expected_bytes=int(sys.argv[2]); expected_digest=sys.argv[3] +except ValueError as error: + raise RuntimeError('invalid diagnostic transport reference') from error +if (expected_bytes<0 or expected_bytes>MAX_INPUT_BYTES or len(expected_digest)!=64 or + any(character not in '0123456789abcdef' for character in expected_digest)): + raise RuntimeError('invalid diagnostic transport reference') +with open(sys.argv[1],'rb',buffering=0) as transport: + chunks=[]; total=0 + while True: + chunk=transport.read(min(65536,MAX_INPUT_BYTES+1-total)) + if not chunk: break + total+=len(chunk) + if total>MAX_INPUT_BYTES: raise RuntimeError('oversized diagnostic scan payload') + chunks.append(chunk) + if transport.read(1)!=b'': raise RuntimeError('diagnostic scan payload has trailing bytes') +raw_payload=b''.join(chunks) +if len(raw_payload)!=expected_bytes or hashlib.sha256(raw_payload).hexdigest()!=expected_digest: + raise RuntimeError('invalid diagnostic scan payload') +try: + payload=json.loads(raw_payload.decode('utf-8')) +except (UnicodeDecodeError,json.JSONDecodeError) as error: + raise RuntimeError('invalid diagnostic scan payload') from error +if raw_payload!=json.dumps(payload,sort_keys=True,separators=(',',':')).encode('utf-8'): + raise RuntimeError('diagnostic scan payload has trailing bytes') def raw_text(value,label): if (type(value) is not str or not value or @@ -4504,13 +4640,14 @@ def raw_text(value,label): def text(value,label): value=raw_text(value,label) - if len(value)>MAX_PATH_TEXT: + if len(value.encode('utf-8'))>MAX_PATH_BYTES: raise RuntimeError(f'oversized {label}') return value def limited(value,label): value=raw_text(value,label) - return value[:MAX_DETAIL_TEXT],len(value)>MAX_DETAIL_TEXT + encoded=value.encode('utf-8') + return encoded[:MAX_DETAIL_BYTES].decode('utf-8','ignore'),len(encoded)>MAX_DETAIL_BYTES def canonical(value,label): value=text(value,label) @@ -4594,13 +4731,13 @@ def common_parts(left,right): def valid_optional_fields(fields): for field in fields: - if '\\' in field: - raise RuntimeError('escaped mountinfo optional field') - if field=='unbindable': - continue + field=unescape(field,'mountinfo optional field') tag,separator,value=field.partition(':') - if (separator!=':' or tag not in {'shared','master','propagate_from'} or - not value.isdigit() or int(value)<=0): + if (not tag or not tag.replace('_','a').replace('-','a').isalnum() or + not tag[0].isalpha() or any(ord(character)<32 or ord(character)==127 for character in field) or + (separator and (not value or ':' in value))): + raise RuntimeError('invalid mountinfo optional field') + if tag in {'shared','master','propagate_from'} and (separator!=':' or not value.isdigit() or int(value)<=0): raise RuntimeError('invalid mountinfo optional field') if set(payload)!={'operation','prefix','targets','expected_root'} or payload['operation']!='scan': @@ -4672,20 +4809,14 @@ def distance(record): 'mountinfo':{'mounts':mounts,'exact_count':len(exact), 'samples':exact_samples,'related':related}, } -print(json.dumps(output,sort_keys=True,separators=(',',':'))) +encoded=json.dumps(output,sort_keys=True,separators=(',',':')).encode('utf-8') +if len(encoded)+1>MAX_OUTPUT_BYTES: + raise RuntimeError('oversized held namespace diagnostic output') +sys.stdout.buffer.write(encoded) +sys.stdout.buffer.write(b'\n') """ -# Hosted cleanup has observed roughly 130 mountpoints. The complete inventory -# is authoritative for teardown; only its detailed records are sampled. -_HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES = 2 * 1024 * 1024 -_HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS = 512 -_HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS = 16 -_HELD_NAMESPACE_DIAGNOSTIC_MAX_RELATED_MOUNTS = 8 -_HELD_NAMESPACE_DIAGNOSTIC_MAX_TEXT = 1024 -_HELD_NAMESPACE_DIAGNOSTIC_MAX_DETAIL_TEXT = 128 - - def _parse_held_namespace_diagnostic_legacy( output: str, *, namespace: dict[str, object], control_prefix: Path, targets: tuple[Path, ...], @@ -4893,11 +5024,11 @@ def reject(message: str) -> None: def text(value: object, label: str, *, detail: bool = False) -> str: maximum = ( - _HELD_NAMESPACE_DIAGNOSTIC_MAX_DETAIL_TEXT - if detail else _HELD_NAMESPACE_DIAGNOSTIC_MAX_TEXT + _HELD_NAMESPACE_DIAGNOSTIC_MAX_DETAIL_BYTES + if detail else _HELD_NAMESPACE_DIAGNOSTIC_MAX_PATH_BYTES ) if ( - type(value) is not str or not value or len(value) > maximum + type(value) is not str or not value or len(value.encode("utf-8")) > maximum or any( (ord(character) < 32 and character not in "\t\n") or ord(character) == 127 for character in value @@ -5082,7 +5213,14 @@ def mount(value: object, label: str) -> tuple[str, int]: sample_keys = [mount(value, "mount sample") for value in samples] if sample_keys != sorted(sample_keys) or any(point not in mount_points for point, _mount_id in sample_keys): reject("has unordered mount samples") - if exact_count < len(sample_keys) or (mounts and len(samples) != min(16, exact_count)): + if ( + bool(mounts) != bool(exact_count) + or exact_count < len(mount_points) + or exact_count < len(sample_keys) + or len(sample_keys) != len(set(sample_keys)) + or len({mount_id for _point, mount_id in sample_keys}) != len(sample_keys) + or (mounts and len(samples) != min(16, exact_count)) + ): reject("has incomplete mount samples") if type(related) is not list or len(related) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_RELATED_MOUNTS: reject("has invalid related mounts") @@ -5105,6 +5243,8 @@ def _sanitized_held_namespace_scan_stderr(stderr: object) -> str: "invalid mountinfo optional field", "escaped mountinfo optional field", "invalid current mount namespace", "invalid current root mount ID", "too many held mount inventory entries", "oversized", + "diagnostic transport unavailable", "diagnostic transport invariant", + "diagnostic transport authentication", ) tail = [] for line in stderr.splitlines()[-8:]: @@ -5115,6 +5255,23 @@ def _sanitized_held_namespace_scan_stderr(stderr: object) -> str: return ",".join(tail)[-256:] or "redacted" +def _deferred_absent_is_proven( + deferred: list[tuple[Path, str]], + remaining_scan: tuple[tuple[Path, ...], bool] | None, + final_authoritative_mounts: tuple[Path, ...] | None, +) -> bool: + """Accept stale-unmount output only with root-valid or final procfs absence.""" + root_valid_absence = ( + remaining_scan is not None and remaining_scan[1] and all( + mount not in remaining_scan[0] for mount, _diagnostic in deferred + ) + ) + procfs_absence = final_authoritative_mounts is not None and all( + mount not in final_authoritative_mounts for mount, _diagnostic in deferred + ) + return root_valid_absence or procfs_absence + + _BLOCKED_WCHAN_MARKERS = ("sleep", "wait", "pause", "futex") @@ -5201,14 +5358,15 @@ def _fallback_stalled_observation_cleanup( def remaining() -> float: return deadline - time.monotonic() - def command(argv: list[str], purpose: str): + def command(argv: list[str], purpose: str, stdin: bytes | None = None): timeout = min(5, remaining()) if timeout <= 0: errors.append(f"cleanup deadline expired before {purpose}") return None try: return runner( - argv, check=False, capture_output=True, text=True, timeout=timeout, + argv, check=False, capture_output=True, text=stdin is None, + input=stdin, timeout=timeout, ) except subprocess.TimeoutExpired: errors.append(f"{purpose} timed out") @@ -5321,24 +5479,29 @@ def scan_owned() -> dict[str, object] | None: if holders and namespace_holder is None: errors.append("no live namespace holder matches the complete captured identity") + def holder_context() -> _NamespaceHolderCommandContext: + assert namespace_holder is not None + return _NamespaceHolderCommandContext( + control_prefix, captured_mounts, + shutil.which("nsenter") or "/usr/bin/nsenter", + shutil.which("umount") or "/usr/bin/umount", + str(supervisor._trusted_tools().helper_python), + ) + def holder_command_payload(operation: str, mount: Path | None = None) -> str: assert namespace_holder is not None - nsenter = shutil.which("nsenter") or "/usr/bin/nsenter" - umount = shutil.which("umount") or "/usr/bin/umount" - helper_python = str(supervisor._trusted_tools().helper_python) return _namespace_holder_command_payload( namespace_holder, namespace, operation, mount=mount, - context=_NamespaceHolderCommandContext( - control_prefix, captured_mounts, nsenter, umount, helper_python, - ), + context=holder_context(), ) - def holder_mount_scan() -> tuple[Path, ...] | None: + def holder_mount_scan() -> tuple[tuple[Path, ...], bool] | None: if namespace_holder is None: - return () + return (), False scan_targets = tuple(sorted(captured_mounts)) try: payload = holder_command_payload("scan") + scan_stdin = _held_namespace_scan_stdin(namespace_holder, holder_context()) argv = [ "sudo", "-n", str(supervisor._trusted_tools().helper_python), "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, payload, @@ -5346,17 +5509,25 @@ def holder_mount_scan() -> tuple[Path, ...] | None: except (OSError, RuntimeError, TypeError, ValueError) as exc: errors.append("held mount namespace scan construction failed: " + type(exc).__name__) return None - completed = command(argv, "scan exact held mount namespace") + completed = command(argv, "scan exact held mount namespace", scan_stdin) if completed is None or completed.returncode != 0: if completed is not None: + stderr = ( + completed.stderr.decode("utf-8", "replace") + if isinstance(completed.stderr, bytes) else completed.stderr + ) errors.append( "held mount namespace scan failed: category=child-nonzero stderr_tail=" - + _sanitized_held_namespace_scan_stderr(completed.stderr) + + _sanitized_held_namespace_scan_stderr(stderr) ) return None try: + output = ( + completed.stdout.decode("utf-8") + if isinstance(completed.stdout, bytes) else completed.stdout + ) diagnostic, mounts = _parse_held_namespace_diagnostic( - completed.stdout, namespace=namespace, holder=namespace_holder, + output, namespace=namespace, holder=namespace_holder, control_prefix=control_prefix, targets=scan_targets, ) except (TypeError, ValueError) as exc: @@ -5368,9 +5539,10 @@ def holder_mount_scan() -> tuple[Path, ...] | None: "held mount namespace diagnostic=" + json.dumps(diagnostic, sort_keys=True, separators=(",", ":")) ) - return mounts + return mounts, diagnostic["root"]["matches"] is True - held_mounts = holder_mount_scan() + held_scan = holder_mount_scan() + held_mounts = None if held_scan is None else held_scan[0] if held_mounts is not None: captured_mounts.update(held_mounts) if ownership.get("require_fd_only_holder") and ( @@ -5406,22 +5578,13 @@ def holder_mount_scan() -> tuple[Path, ...] | None: else: errors.append(diagnostic[:512] or f"cannot unmount {mount}") - remaining_held_mounts = holder_mount_scan() + remaining_scan = holder_mount_scan() + remaining_held_mounts = None if remaining_scan is None else remaining_scan[0] if remaining_held_mounts: errors.append( "owned mounts remain in held namespace: " + ", ".join(str(path) for path in remaining_held_mounts) ) - if deferred_absent_unmounts: - proven_absent = remaining_held_mounts is not None and all( - mount not in remaining_held_mounts for mount, _diagnostic in deferred_absent_unmounts - ) - if not proven_absent: - errors.extend( - diagnostic or f"cannot unmount {mount}" - for mount, diagnostic in deferred_absent_unmounts - ) - for holder in ownership.get("external_holders", ()): expected = _process_key(holder) holder_scan = scanner( @@ -5465,6 +5628,7 @@ def holder_mount_scan() -> tuple[Path, ...] | None: verification_deadline = min(deadline, time.monotonic() + 8) final_leaks = ["verification did not run"] + final_authoritative_mounts: tuple[Path, ...] | None = None while time.monotonic() < verification_deadline: scan = scan_owned() load_state = command( @@ -5486,6 +5650,9 @@ def holder_mount_scan() -> tuple[Path, ...] | None: fds_open.append(f"fd={descriptor}") if scan is None or load_state is None: break + final_authoritative_mounts = _exact_namespace_mounts( + scan, namespace, control_prefix, tuple(captured_mounts) + ) current = {_process_key(record) for record in scan["identities"]} coordinator_alive = expected_coordinator in current external_alive = [ @@ -5532,6 +5699,14 @@ def holder_mount_scan() -> tuple[Path, ...] | None: errors.append("cleanup survivors: " + ", ".join(final_leaks)) if final_leaks and unit_action_failures: errors.extend(unit_action_failures) + if deferred_absent_unmounts: + if not _deferred_absent_is_proven( + deferred_absent_unmounts, remaining_scan, final_authoritative_mounts, + ): + errors.extend( + diagnostic or f"cannot unmount {mount}" + for mount, diagnostic in deferred_absent_unmounts + ) if errors: errors.extend(held_namespace_diagnostics) raise AssertionError("; ".join(errors)) @@ -5559,23 +5734,157 @@ def test_root_proc_scanner_source_compiles() -> None: def test_held_namespace_scan_uses_authenticated_sealed_fd_transport() -> None: """The complete scan inventory is stdin-fed and never becomes an argv item.""" - # The transport helper is intentionally exercised separately from nsenter so - # this assertion remains deterministic at the declared cardinality bound. - assert _HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES > 128 * 1024 - assert _held_namespace_scan_stdin is not None + prefix = Path("/p") + targets = { + prefix / ("😀" * 253 + f"{index:03d}" + "x" * 6) + for index in range(_HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS) + } + assert all(len(str(target).encode("utf-8")) == 1024 for target in targets) + context = _NamespaceHolderCommandContext( + prefix, targets, "/usr/bin/nsenter", "/usr/bin/umount", sys.executable, + ) + holder = { + "holder_kind": "current", "pid": 1, "start_time": "1", + "namespace": "mnt:[1]", "namespace_inode": 1, + } + + scan_stdin = _held_namespace_scan_stdin(holder, context) + control = _namespace_holder_command_payload( + holder, {"link": "mnt:[1]", "inode": 1}, "scan", context=context, + ) + + assert len(scan_stdin) > 128 * 1024 + assert len(scan_stdin) <= _HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES + assert _HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES >= ( + 2 * _HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS + * _HELD_NAMESPACE_DIAGNOSTIC_MAX_PATH_BYTES + * _HELD_NAMESPACE_JSON_ESCAPED_BYTES_PER_UTF8_BYTE + ) + assert all(str(target).encode("utf-8") not in control.encode("utf-8") for target in targets) + assert len(control.encode("utf-8")) < 128 * 1024 + control_record = json.loads(control) + assert control_record["scan_bytes"] == len(scan_stdin) + assert control_record["scan_sha256"] == hashlib.sha256(scan_stdin).hexdigest() + assert "scan_payload" not in control_record + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="requires Linux memfd seals") +def test_held_namespace_scan_memfd_is_sealed_and_only_transport_fds_inherit( + tmp_path: Path, +) -> None: + """The revalidator preserves a sealed, non-argv max-cardinality inventory.""" + prefix = Path("/p") + targets = { + prefix / ("😀" * 253 + f"{index:03d}" + "x" * 6) + for index in range(_HELD_NAMESPACE_DIAGNOSTIC_MAX_TARGETS) + } + namespace_path = Path("/proc/self/ns/mnt") + namespace = {"link": os.readlink(namespace_path), "inode": namespace_path.stat().st_ino} + stat_fields = Path("/proc/self/stat").read_text(encoding="ascii").rsplit(")", 1)[1].split() + holder = { + "holder_kind": "current", "pid": os.getpid(), "start_time": stat_fields[19], + "namespace": namespace["link"], "namespace_inode": namespace["inode"], + } + capture = tmp_path / "capture-nsenter.py" + capture.write_text( + """import fcntl,hashlib,json,os,sys +argv=sys.argv +scan_ref=next(value for value in argv if value.startswith('/proc/self/fd/')) +scan_fd=int(scan_ref.rsplit('/',1)[1]) +seals=fcntl.fcntl(scan_fd,fcntl.F_GET_SEALS) +try: + os.write(scan_fd,b'x'); mutation='accepted' +except OSError: mutation='rejected' +inheritable=[] +for entry in os.listdir('/proc/self/fd'): + try: + descriptor=int(entry) + if descriptor>2 and os.get_inheritable(descriptor): inheritable.append(descriptor) + except OSError: pass +os.lseek(scan_fd,0,os.SEEK_SET) +data=os.read(scan_fd,2*1024*1024) +print(json.dumps({'argv':argv,'scan_fd':scan_fd,'seals':seals,'mutation':mutation, + 'inheritable':sorted(inheritable),'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest()})) +""", + encoding="utf-8", + ) + capture.chmod(0o755) + context = _NamespaceHolderCommandContext( + prefix, targets, str(capture), "/usr/bin/umount", sys.executable, + ) + scan_stdin = _held_namespace_scan_stdin(holder, context) + control = _namespace_holder_command_payload(holder, namespace, "scan", context=context) + + completed = subprocess.run( + [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, control], + input=scan_stdin, capture_output=True, check=False, timeout=10, + ) + + assert completed.returncode == 0, completed.stderr.decode("utf-8", "replace") + captured = json.loads(completed.stdout) + import fcntl # pylint: disable=import-outside-toplevel + required = ( + getattr(fcntl, "F_SEAL_WRITE") | getattr(fcntl, "F_SEAL_GROW") + | getattr(fcntl, "F_SEAL_SHRINK") | getattr(fcntl, "F_SEAL_SEAL") + ) + assert captured["seals"] & required == required + assert captured["mutation"] == "rejected" + assert captured["bytes"] == len(scan_stdin) + assert captured["sha256"] == hashlib.sha256(scan_stdin).hexdigest() + inherited = { + int(value.rsplit("/", 1)[1]) + for value in captured["argv"] + if "/proc/self/fd/" in value + } + assert set(captured["inheritable"]) == inherited + assert all(str(target) not in captured["argv"] for target in targets) + + +@pytest.mark.skipif( + not sys.platform.startswith("linux"), reason="requires Linux memfd transport", +) +@pytest.mark.parametrize( + "raw", (b"[]", b'{"operation":"scan"}\n', + b"x" * (_HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES + 1)), +) +def test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_frames( + raw: bytes, +) -> None: + """The inner scanner reads a bounded exact frame instead of argv text.""" + descriptor = getattr(os, "memfd_create")( + "pdd-scanner-frame", getattr(os, "MFD_CLOEXEC"), + ) + try: + os.write(descriptor, raw) + os.lseek(descriptor, 0, os.SEEK_SET) + completed = subprocess.run( + [ + sys.executable, "-I", "-S", "-c", _NAMESPACE_MOUNT_SCANNER_SOURCE, + f"/proc/self/fd/{descriptor}", str(len(raw)), + hashlib.sha256(raw).hexdigest(), + ], + pass_fds=(descriptor,), capture_output=True, check=False, timeout=10, + ) + assert completed.returncode != 0 + assert ( + b"diagnostic scan payload" in completed.stderr + or b"oversized" in completed.stderr + ) + finally: + os.close(descriptor) def _valid_mountinfo_optional_fields(fields: tuple[str, ...]) -> bool: - """Mirror the scanner's documented Linux mountinfo optional-field grammar.""" + """Mirror the forward-compatible Linux mountinfo optional-field grammar.""" for field in fields: - if "\\" in field or field == "unbindable": - if field != "unbindable": - return False - continue tag, separator, value = field.partition(":") if ( - separator != ":" or tag not in {"shared", "master", "propagate_from"} - or not value.isdigit() or int(value) <= 0 + not tag or not tag.replace("_", "a").replace("-", "a").isalnum() + or not tag[0].isalpha() or (separator and (not value or ":" in value)) + ): + return False + if tag in {"shared", "master", "propagate_from"} and ( + separator != ":" or not value.isdigit() or int(value) <= 0 ): return False return True @@ -5599,12 +5908,13 @@ def _unescape_documented_mountinfo_field(value: str) -> str: return "".join(result) -def test_mountinfo_optional_field_grammar_rejects_undefined_escapes_and_tags() -> None: - """Only Linux's documented propagation optional fields are diagnostic input.""" +def test_mountinfo_optional_field_grammar_accepts_unknown_fields_and_rejects_malformed() -> None: + """Unknown well-formed tags remain forward compatible while known tags are strict.""" assert _valid_mountinfo_optional_fields( ("shared:10", "master:11", "propagate_from:12", "unbindable") ) - for fields in (("shared:0",), ("private",), ("unknown:1",), ("shared:1\\040",)): + assert _valid_mountinfo_optional_fields(("private", "vendor-tag:opaque", "unbindable")) + for fields in (("shared:0",), ("1private",), ("unknown:",), ("shared:1:2",)): assert not _valid_mountinfo_optional_fields(fields) assert _unescape_documented_mountinfo_field(r"a\040b\011c\012d\134e") == "a b\tc\nd\\e" with pytest.raises(ValueError, match="invalid mountinfo escape"): @@ -5708,6 +6018,14 @@ def mount(point: Path, mount_id: int) -> dict[str, object]: unordered_samples["mountinfo"]["samples"] )) malformed.append(json.dumps(unordered_samples)) + inconsistent_count = json.loads(raw) + inconsistent_count["mountinfo"]["exact_count"] = 0 + malformed.append(json.dumps(inconsistent_count)) + duplicate_sample_id = json.loads(raw) + duplicate_sample_id["mountinfo"]["samples"][1]["mount_id"] = ( + duplicate_sample_id["mountinfo"]["samples"][0]["mount_id"] + ) + malformed.append(json.dumps(duplicate_sample_id)) malformed.append("x" * (_HELD_NAMESPACE_DIAGNOSTIC_MAX_BYTES + 1)) for value in malformed: with pytest.raises(ValueError, match="held namespace diagnostic"): @@ -5717,7 +6035,7 @@ def mount(point: Path, mount_id: int) -> dict[str, object]: ) -@pytest.mark.parametrize("failure", ("construction", "nonzero", "malformed")) +@pytest.mark.parametrize("failure", ("construction", "nonzero", "malformed", "transport")) def test_held_namespace_scan_failures_continue_cleanup_and_final_verification( tmp_path: Path, failure: str, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -5735,6 +6053,9 @@ def test_held_namespace_scan_failures_continue_cleanup_and_final_verification( "namespace_holder": holder, "coordinator": {"pid": 1, "start_time": "1"}, "mount_points": [mount], } + external = holder | {"pid": 33, "start_time": "101"} + if failure == "transport": + ownership["external_holders"] = [external] commands = [] scans = 0 monkeypatch.setattr( @@ -5750,11 +6071,13 @@ def reject_scan_payload(*args, **kwargs): monkeypatch.setattr(sys.modules[__name__], "_namespace_holder_command_payload", reject_scan_payload) - def scanner(**_kwargs): + def scanner(**kwargs): nonlocal scans scans += 1 + selection = kwargs.get("selection") return { - "watched": [], "identities": [], "cgroup_exists": False, + "watched": [external] if selection and selection.watch_pids == (33,) else [], + "identities": [], "cgroup_exists": False, "mount_holders": [], "fd_holders": [], "current_holders": [holder] if scans <= 2 else [], } @@ -5771,6 +6094,10 @@ def runner(argv, **_kwargs): returncode=2, stdout="", stderr="Traceback payload=/secret\nRuntimeError: malformed mountinfo\n", ) + if failure == "transport": + return SimpleNamespace( + returncode=2, stdout=b"", stderr=b"RuntimeError: diagnostic transport unavailable\n", + ) return SimpleNamespace(returncode=0, stdout="[]", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="") @@ -5790,6 +6117,9 @@ def runner(argv, **_kwargs): assert "Traceback" not in message and "/secret" not in message elif failure == "malformed": assert "held mount namespace scan was malformed" in message + elif failure == "transport": + assert "category=child-nonzero stderr_tail=diagnostic transport unavailable" in message + assert "terminate" in operations else: assert "held mount namespace scan construction failed" in message @@ -5880,6 +6210,15 @@ def runner(argv, **_kwargs): ) +def test_root_mismatched_scan_is_never_deferred_absence_proof() -> None: + """A scan from a mismatched root cannot discharge an otherwise stale unmount.""" + deferred = [(Path("/p/binds/mount"), "not mounted")] + + assert not _deferred_absent_is_proven(deferred, ((), False), None) + assert _deferred_absent_is_proven(deferred, ((), True), None) + assert _deferred_absent_is_proven(deferred, ((), False), ()) + + def test_fdinfo_mount_id_parser_normalizes_whitespace_and_rejects_ambiguity() -> None: """All fdinfo mount-ID protocols accept whitespace but require one positive value.""" def parse(lines: list[str]) -> int: From 385ab8872411e740480bb164fb2b840b91f2624c Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 20:08:54 -0700 Subject: [PATCH 221/233] test(sync): fix sealed transport fixture execution --- tests/test_sync_core_supervisor.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index f96f0b656f..7ec1b12864 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -5787,6 +5787,7 @@ def test_held_namespace_scan_memfd_is_sealed_and_only_transport_fds_inherit( } capture = tmp_path / "capture-nsenter.py" capture.write_text( + f"#!{sys.executable} -I\n" """import fcntl,hashlib,json,os,sys argv=sys.argv scan_ref=next(value for value in argv if value.startswith('/proc/self/fd/')) @@ -5874,6 +5875,35 @@ def test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_fr os.close(descriptor) +@pytest.mark.skipif( + not sys.platform.startswith("linux"), reason="requires Linux memfd transport", +) +def test_namespace_scanner_rejects_truncated_canonical_frame() -> None: + """A shorter FD than its authenticated canonical frame metadata fails closed.""" + canonical = json.dumps( + {"expected_root": None, "operation": "scan", "prefix": "/p", "targets": []}, + sort_keys=True, separators=(",", ":"), + ).encode("utf-8") + descriptor = getattr(os, "memfd_create")( + "pdd-truncated-scanner-frame", getattr(os, "MFD_CLOEXEC"), + ) + try: + os.write(descriptor, canonical[:-1]) + os.lseek(descriptor, 0, os.SEEK_SET) + completed = subprocess.run( + [ + sys.executable, "-I", "-S", "-c", _NAMESPACE_MOUNT_SCANNER_SOURCE, + f"/proc/self/fd/{descriptor}", str(len(canonical)), + hashlib.sha256(canonical).hexdigest(), + ], + pass_fds=(descriptor,), capture_output=True, check=False, timeout=10, + ) + assert completed.returncode != 0 + assert b"invalid diagnostic scan payload" in completed.stderr + finally: + os.close(descriptor) + + def _valid_mountinfo_optional_fields(fields: tuple[str, ...]) -> bool: """Mirror the forward-compatible Linux mountinfo optional-field grammar.""" for field in fields: From b33732b7e9240f870a2a27f08adf4163520a0c38 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 21:31:00 -0700 Subject: [PATCH 222/233] test(sync): bound held namespace Linux smoke --- .github/workflows/unit-tests.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 5491ae582f..f146a17b53 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -385,6 +385,15 @@ jobs: tests/test_sync_core_trust.py::test_remote_signer_timeout_reaps_env_cleared_detached_descendant --timeout=60 + - name: Verify held-namespace transport and FD-only cleanup smoke + run: > + timeout --signal=TERM --kill-after=10s 290s + pytest -vv -s + tests/test_sync_core_supervisor.py::test_held_namespace_scan_memfd_is_sealed_and_only_transport_fds_inherit + tests/test_sync_core_supervisor.py::test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_frames + 'tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[fd-only-namespace-holder-cleanup]' + --timeout=60 + - name: Run focused protected-runner tests run: > pytest -q From 628c79fee498fa550f327160713e7cde0e37d57b Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 21:39:15 -0700 Subject: [PATCH 223/233] test(sync): harden held namespace smoke contract --- .github/workflows/unit-tests.yml | 1 + tests/test_sync_core_supervisor.py | 1 + tests/test_unit_tests_workflow.py | 31 ++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index f146a17b53..e09699e12d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -391,6 +391,7 @@ jobs: pytest -vv -s tests/test_sync_core_supervisor.py::test_held_namespace_scan_memfd_is_sealed_and_only_transport_fds_inherit tests/test_sync_core_supervisor.py::test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_frames + tests/test_sync_core_supervisor.py::test_namespace_scanner_rejects_truncated_canonical_frame 'tests/test_sync_core_supervisor.py::test_real_linux_playwright_descriptor_exact_chain[fd-only-namespace-holder-cleanup]' --timeout=60 diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 7ec1b12864..4c2d49eaa7 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -5847,6 +5847,7 @@ def test_held_namespace_scan_memfd_is_sealed_and_only_transport_fds_inherit( @pytest.mark.parametrize( "raw", (b"[]", b'{"operation":"scan"}\n', b"x" * (_HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES + 1)), + ids=("malformed", "trailing", "oversized"), ) def test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_frames( raw: bytes, diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py index 59f2fecce8..636a5d8abb 100644 --- a/tests/test_unit_tests_workflow.py +++ b/tests/test_unit_tests_workflow.py @@ -24,6 +24,8 @@ APPROVED_DRAFT_GUARD = "github.event.pull_request.draft != true" PROVISION_STEP_NAME = "Provision and verify protected Linux sandbox" HOSTED_STEP_NAME = "Run real protected Playwright and authenticated supervisor protocols" +HELD_NAMESPACE_SMOKE_STEP_NAME = "Verify held-namespace transport and FD-only cleanup smoke" +FOCUSED_STEP_NAME = "Run focused protected-runner tests" HOSTED_SUPERVISOR_NODE = "tests/test_sync_core_supervisor.py::" REQUIRED_HOSTED_NODES = ( "tests/test_sync_core_runner_playwright.py::" @@ -77,6 +79,20 @@ EXPECTED_HOSTED_COMMAND = ( "pytest", "-q", *REQUIRED_HOSTED_NODES, "--timeout=90", ) +REQUIRED_HELD_NAMESPACE_SMOKE_NODES = ( + f"{HOSTED_SUPERVISOR_NODE}" + "test_held_namespace_scan_memfd_is_sealed_and_only_transport_fds_inherit", + f"{HOSTED_SUPERVISOR_NODE}" + "test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_frames", + f"{HOSTED_SUPERVISOR_NODE}test_namespace_scanner_rejects_truncated_canonical_frame", + f"{HOSTED_SUPERVISOR_NODE}test_real_linux_playwright_descriptor_exact_chain" + "[fd-only-namespace-holder-cleanup]", +) +EXPECTED_HELD_NAMESPACE_SMOKE_COMMAND = ( + "timeout", "--signal=TERM", "--kill-after=10s", "290s", + "pytest", "-vv", "-s", *REQUIRED_HELD_NAMESPACE_SMOKE_NODES, + "--timeout=60", +) def _workflow() -> dict: @@ -216,6 +232,21 @@ def test_unit_tests_requires_complete_privileged_descriptor_matrix() -> None: _assert_hosted_linux_contract(_workflow()) +def test_unit_tests_held_namespace_smoke_is_bounded_and_precedes_focused_suite() -> None: + """The Linux transport smoke is exact, fail-fast, and runs before the broad lane.""" + workflow = _workflow() + job = workflow["jobs"][LINUX_JOB_ID] + steps = job["steps"] + smoke = _named_step(job, HELD_NAMESPACE_SMOKE_STEP_NAME) + focused = _named_step(job, FOCUSED_STEP_NAME) + + _assert_enabled(smoke) + assert steps.index(smoke) < steps.index(focused) + assert _shell_commands(smoke.get("run")) == ( + EXPECTED_HELD_NAMESPACE_SMOKE_COMMAND, + ) + + def test_unit_tests_protected_smokes_use_credential_free_environment() -> None: """Hosted protected setup never forwards the runner's ambient credentials.""" workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") From 2e85b67f13a9c4f0abbab30b9120d7d9bc249c76 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 21:56:34 -0700 Subject: [PATCH 224/233] test(sync): tolerate transient fd directory closure --- tests/test_sync_core_supervisor.py | 40 +++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 4c2d49eaa7..90ebb244c5 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4419,7 +4419,7 @@ def _exact_unit_not_found(completed: object) -> bool: _NSENTER_REVALIDATOR_SOURCE = r""" -import fcntl,hashlib,json,os,pathlib,signal,sys +import errno,fcntl,hashlib,json,os,pathlib,signal,sys if len(sys.argv)!=2 or len(sys.argv[1].encode('utf-8'))>131072: raise SystemExit('diagnostic transport invariant') @@ -4514,7 +4514,12 @@ def inherit_only(*descriptors): opened=[int(entry.name) for entry in pathlib.Path('/proc/self/fd').iterdir()] for descriptor in opened: if descriptor>2 and descriptor not in allowed: - os.set_inheritable(descriptor,False) + try: + os.set_inheritable(descriptor,False) + except OSError as error: + if error.errno==errno.EBADF: + continue + raise for descriptor in allowed: os.set_inheritable(descriptor,True) except OSError: @@ -5732,6 +5737,21 @@ def test_root_proc_scanner_source_compiles() -> None: compile(_NAMESPACE_MOUNT_SCANNER_SOURCE, "", "exec") +def test_nsenter_revalidator_ignores_only_closed_disallowed_fds() -> None: + """The fd-directory iterator's transient FD cannot weaken transport inheritance.""" + assert "import errno,fcntl" in _NSENTER_REVALIDATOR_SOURCE + assert """for descriptor in opened: + if descriptor>2 and descriptor not in allowed: + try: + os.set_inheritable(descriptor,False) + except OSError as error: + if error.errno==errno.EBADF: + continue + raise + for descriptor in allowed: + os.set_inheritable(descriptor,True)""" in _NSENTER_REVALIDATOR_SOURCE + + def test_held_namespace_scan_uses_authenticated_sealed_fd_transport() -> None: """The complete scan inventory is stdin-fed and never becomes an argv item.""" prefix = Path("/p") @@ -5845,12 +5865,17 @@ def test_held_namespace_scan_memfd_is_sealed_and_only_transport_fds_inherit( not sys.platform.startswith("linux"), reason="requires Linux memfd transport", ) @pytest.mark.parametrize( - "raw", (b"[]", b'{"operation":"scan"}\n', - b"x" * (_HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES + 1)), + ("raw", "expected_category"), + ( + (b"[]", b"invalid diagnostic scan payload"), + (b'{"operation":"scan"}\n', b"diagnostic scan payload has trailing bytes"), + (b"x" * (_HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES + 1), + b"invalid diagnostic transport reference"), + ), ids=("malformed", "trailing", "oversized"), ) def test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_frames( - raw: bytes, + raw: bytes, expected_category: bytes, ) -> None: """The inner scanner reads a bounded exact frame instead of argv text.""" descriptor = getattr(os, "memfd_create")( @@ -5868,10 +5893,7 @@ def test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_fr pass_fds=(descriptor,), capture_output=True, check=False, timeout=10, ) assert completed.returncode != 0 - assert ( - b"diagnostic scan payload" in completed.stderr - or b"oversized" in completed.stderr - ) + assert expected_category in completed.stderr finally: os.close(descriptor) From aba6cd0d73d295cba8903d39d8856d87a1b3b252 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 22:19:40 -0700 Subject: [PATCH 225/233] test(sync): classify held namespace scan failures --- tests/test_sync_core_supervisor.py | 132 ++++++++++++++++++++++++----- 1 file changed, 111 insertions(+), 21 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 90ebb244c5..79bdbdedaf 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -5238,26 +5238,97 @@ def mount(value: object, label: str) -> tuple[str, int]: return diagnostic, tuple(Path(value) for value in mount_points) +_HELD_NAMESPACE_SCAN_STATIC_STDERR_CATEGORIES = ( + # _NSENTER_REVALIDATOR_SOURCE static child failures. + "diagnostic transport invariant", "diagnostic transport oversized", + "diagnostic transport authentication", "diagnostic transport unavailable", + "invalid holder process identity", "invalid holder stat", + "holder namespace identity changed", "holder PID was reused", + "holder current namespace identity changed", "invalid namespace descriptor", + "namespace descriptor identity changed", "invalid namespace holder kind", + "invalid root descriptor", "root descriptor identity changed", + "invalid root descriptor mount ID", + "holder identity raced after descriptor pinning", + "invalid namespace unmount point", "invalid namespace operation", + # _NAMESPACE_MOUNT_SCANNER_SOURCE static child failures. + "invalid diagnostic transport reference", "oversized diagnostic scan payload", + "diagnostic scan payload has trailing bytes", "invalid diagnostic scan payload", + "invalid metadata errno", "invalid cwd errno", "malformed mountinfo", + "malformed mountinfo device", "invalid mountinfo optional field", + "invalid diagnostic targets", "diagnostic targets are not unique and sorted", + "invalid expected root identity", "invalid current mount namespace", + "invalid current root mount ID", "too many held mount inventory entries", + "oversized held namespace diagnostic output", +) +_HELD_NAMESPACE_SCAN_NSENTER_STDERR_CATEGORIES = ( + ("nsenter: reassociate to namespace", "nsenter-reassociate-failed"), + ("nsenter: failed to execute", "nsenter-exec-failed"), +) + + def _sanitized_held_namespace_scan_stderr(stderr: object) -> str: - """Return a short stable child-error tail without paths, payloads, or tracebacks.""" + """Return bounded static child classifications without child-controlled detail.""" if not isinstance(stderr, str): return "redacted" - known = ( - "invalid diagnostic scan payload", "invalid diagnostic targets", - "invalid expected root identity", "malformed mountinfo", - "invalid mountinfo optional field", "escaped mountinfo optional field", - "invalid current mount namespace", "invalid current root mount ID", - "too many held mount inventory entries", "oversized", - "diagnostic transport unavailable", "diagnostic transport invariant", - "diagnostic transport authentication", - ) - tail = [] + categories = [] for line in stderr.splitlines()[-8:]: - for marker in known: - if marker in line: - tail.append(marker) - break - return ",".join(tail)[-256:] or "redacted" + category = next( + ( + marker + for marker in sorted( + _HELD_NAMESPACE_SCAN_STATIC_STDERR_CATEGORIES, key=len, reverse=True, + ) + if marker in line + ), + None, + ) + if category is None: + category = next( + (safe for marker, safe in _HELD_NAMESPACE_SCAN_NSENTER_STDERR_CATEGORIES if marker in line), + None, + ) + if category is None or category in categories: + continue + candidate = ",".join((*categories, category)) + if len(candidate.encode("ascii")) <= 256: + categories.append(category) + return ",".join(categories) or "redacted" + + +def test_held_namespace_scan_stderr_sanitizer_classifies_only_static_failures() -> None: + """Known child literals and nsenter failures retain bounded safe categories.""" + stderr = ( + "Traceback (most recent call last): /private/token=secret\n" + "RuntimeError: invalid root descriptor mount ID payload=/secret\n" + "RuntimeError: holder identity raced after descriptor pinning payload=/secret\n" + "nsenter: reassociate to namespace '/proc/self/fd/73' failed: Operation not permitted\n" + ) + + classified = _sanitized_held_namespace_scan_stderr(stderr) + + assert classified == ( + "invalid root descriptor mount ID,holder identity raced after descriptor pinning," + "nsenter-reassociate-failed" + ) + assert len(classified.encode("ascii")) <= 256 + assert "Traceback" not in classified + assert "/private" not in classified + assert "secret" not in classified + + +def test_held_namespace_scan_stderr_sanitizer_redacts_dynamic_child_content() -> None: + """Unrecognized exception text cannot relay paths, payloads, or credentials.""" + stderr = ( + "Traceback (most recent call last):\n" + "RuntimeError: unexpected failure path=/private/secret payload=token-value\n" + ) + + classified = _sanitized_held_namespace_scan_stderr(stderr) + + assert classified == "redacted" + assert len(classified.encode("ascii")) <= 256 + assert "secret" not in classified + assert "token-value" not in classified def _deferred_absent_is_proven( @@ -5852,12 +5923,31 @@ def test_held_namespace_scan_memfd_is_sealed_and_only_transport_fds_inherit( assert captured["mutation"] == "rejected" assert captured["bytes"] == len(scan_stdin) assert captured["sha256"] == hashlib.sha256(scan_stdin).hexdigest() - inherited = { - int(value.rsplit("/", 1)[1]) - for value in captured["argv"] - if "/proc/self/fd/" in value + def canonical_fd_reference(value: str, prefix: str) -> int | None: + """Return one exact canonical FD argument, never a source-code substring.""" + if not value.startswith(prefix): + return None + descriptor = value.removeprefix(prefix) + if not descriptor.isascii() or not descriptor.isdecimal(): + return None + number = int(descriptor) + return number if number > 2 and descriptor == str(number) else None + + transport_arguments = { + prefix: [ + descriptor for value in captured["argv"] + if (descriptor := canonical_fd_reference(value, prefix)) is not None + ] + for prefix in ("--mount=/proc/self/fd/", "--root=/proc/self/fd/", "/proc/self/fd/") + } + assert _NSENTER_REVALIDATOR_SOURCE in captured["argv"] + assert _NAMESPACE_MOUNT_SCANNER_SOURCE in captured["argv"] + assert all(len(descriptors) == 1 for descriptors in transport_arguments.values()) + expected_transport_descriptors = { + descriptors[0] for descriptors in transport_arguments.values() } - assert set(captured["inheritable"]) == inherited + assert len(expected_transport_descriptors) == 3 + assert set(captured["inheritable"]) == expected_transport_descriptors assert all(str(target) not in captured["argv"] for target in targets) From 4f4e71c3d91a4fc77357dd17191976d3e865d5f8 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 22:29:24 -0700 Subject: [PATCH 226/233] test(sync): tighten held namespace failure checks --- tests/test_sync_core_supervisor.py | 180 ++++++++++++++++++++--------- 1 file changed, 128 insertions(+), 52 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 79bdbdedaf..2f36dfb61f 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -5261,55 +5261,56 @@ def mount(value: object, label: str) -> tuple[str, int]: "oversized held namespace diagnostic output", ) _HELD_NAMESPACE_SCAN_NSENTER_STDERR_CATEGORIES = ( - ("nsenter: reassociate to namespace", "nsenter-reassociate-failed"), - ("nsenter: failed to execute", "nsenter-exec-failed"), + ("nsenter: reassociate to namespace ", "nsenter-reassociate-failed"), + ("nsenter: failed to execute ", "nsenter-exec-failed"), ) +_HELD_NAMESPACE_SCAN_RUNTIME_ERROR_PREFIX = "RuntimeError: " + + +def _held_namespace_scan_stderr_category(line: str) -> str | None: + """Classify only complete static Python failures or anchored nsenter failures.""" + if line in _HELD_NAMESPACE_SCAN_STATIC_STDERR_CATEGORIES: + return line + if line.startswith(_HELD_NAMESPACE_SCAN_RUNTIME_ERROR_PREFIX): + message = line.removeprefix(_HELD_NAMESPACE_SCAN_RUNTIME_ERROR_PREFIX) + return ( + message + if message in _HELD_NAMESPACE_SCAN_STATIC_STDERR_CATEGORIES else None + ) + for prefix, category in _HELD_NAMESPACE_SCAN_NSENTER_STDERR_CATEGORIES: + if line.startswith(prefix): + return category + return None def _sanitized_held_namespace_scan_stderr(stderr: object) -> str: """Return bounded static child classifications without child-controlled detail.""" if not isinstance(stderr, str): return "redacted" - categories = [] - for line in stderr.splitlines()[-8:]: - category = next( - ( - marker - for marker in sorted( - _HELD_NAMESPACE_SCAN_STATIC_STDERR_CATEGORIES, key=len, reverse=True, - ) - if marker in line - ), - None, - ) - if category is None: - category = next( - (safe for marker, safe in _HELD_NAMESPACE_SCAN_NSENTER_STDERR_CATEGORIES if marker in line), - None, - ) - if category is None or category in categories: + newest_categories = [] + size = 0 + for line in reversed(stderr.splitlines()[-8:]): + category = _held_namespace_scan_stderr_category(line) + if category is None or category in newest_categories: continue - candidate = ",".join((*categories, category)) - if len(candidate.encode("ascii")) <= 256: - categories.append(category) - return ",".join(categories) or "redacted" + category_size = len(category.encode("ascii")) + bool(newest_categories) + if size + category_size <= 256: + newest_categories.append(category) + size += category_size + return ",".join(reversed(newest_categories)) or "redacted" def test_held_namespace_scan_stderr_sanitizer_classifies_only_static_failures() -> None: - """Known child literals and nsenter failures retain bounded safe categories.""" + """Exact RuntimeError and bare SystemExit forms retain only static categories.""" stderr = ( "Traceback (most recent call last): /private/token=secret\n" - "RuntimeError: invalid root descriptor mount ID payload=/secret\n" - "RuntimeError: holder identity raced after descriptor pinning payload=/secret\n" - "nsenter: reassociate to namespace '/proc/self/fd/73' failed: Operation not permitted\n" + "RuntimeError: invalid root descriptor mount ID\n" + "invalid holder process identity\n" ) classified = _sanitized_held_namespace_scan_stderr(stderr) - assert classified == ( - "invalid root descriptor mount ID,holder identity raced after descriptor pinning," - "nsenter-reassociate-failed" - ) + assert classified == "invalid root descriptor mount ID,invalid holder process identity" assert len(classified.encode("ascii")) <= 256 assert "Traceback" not in classified assert "/private" not in classified @@ -5331,6 +5332,81 @@ def test_held_namespace_scan_stderr_sanitizer_redacts_dynamic_child_content() -> assert "token-value" not in classified +def test_held_namespace_scan_stderr_sanitizer_rejects_allowlist_spoofing() -> None: + """Allowlisted substrings embedded in arbitrary child lines never classify.""" + stderr = ( + "attacker says RuntimeError: malformed mountinfo\n" + "SystemExit: malformed mountinfo\n" + "RuntimeError: invalid root descriptor mount ID payload=/private/secret\n" + "prefix nsenter: failed to execute /private/payload\n" + ) + + assert _sanitized_held_namespace_scan_stderr(stderr) == "redacted" + + +def test_held_namespace_scan_stderr_sanitizer_deduplicates_exact_collisions() -> None: + """Exact matching distinguishes overlapping literals and keeps each category once.""" + stderr = ( + "RuntimeError: invalid root descriptor\n" + "RuntimeError: invalid root descriptor mount ID\n" + "RuntimeError: invalid root descriptor mount ID\n" + ) + + assert _sanitized_held_namespace_scan_stderr(stderr) == ( + "invalid root descriptor,invalid root descriptor mount ID" + ) + + +def test_held_namespace_scan_stderr_sanitizer_prioritizes_terminal_categories() -> None: + """Newest categories survive when all eight safe terminal lines exceed the cap.""" + categories = ( + "holder current namespace identity changed", + "namespace descriptor identity changed", + "holder identity raced after descriptor pinning", + "diagnostic targets are not unique and sorted", + "diagnostic scan payload has trailing bytes", + "too many held mount inventory entries", + "oversized held namespace diagnostic output", + "invalid cwd errno", + ) + stderr = "".join(f"RuntimeError: {category}\n" for category in categories) + + classified = _sanitized_held_namespace_scan_stderr(stderr) + + assert classified == ",".join(categories[2:]) + assert categories[-1] in classified + assert categories[0] not in classified + assert len(classified.encode("ascii")) <= 256 + + +@pytest.mark.parametrize( + ("stderr", "expected"), + ( + ( + "nsenter: reassociate to namespace '/proc/self/fd/73' failed: " + "Operation not permitted path=/private/secret", + "nsenter-reassociate-failed", + ), + ( + "nsenter: failed to execute /private/secret/payload: No such file", + "nsenter-exec-failed", + ), + ), + ids=("reassociate", "execute"), +) +def test_held_namespace_scan_stderr_sanitizer_maps_anchored_nsenter_failures( + stderr: str, expected: str, +) -> None: + """Nsenter reports only one static class and never its dynamic path or payload.""" + classified = _sanitized_held_namespace_scan_stderr(stderr) + + assert classified == expected + assert len(classified.encode("ascii")) <= 256 + assert "/private" not in classified + assert "secret" not in classified + assert "payload" not in classified + + def _deferred_absent_is_proven( deferred: list[tuple[Path, str]], remaining_scan: tuple[tuple[Path, ...], bool] | None, @@ -5923,31 +5999,31 @@ def test_held_namespace_scan_memfd_is_sealed_and_only_transport_fds_inherit( assert captured["mutation"] == "rejected" assert captured["bytes"] == len(scan_stdin) assert captured["sha256"] == hashlib.sha256(scan_stdin).hexdigest() - def canonical_fd_reference(value: str, prefix: str) -> int | None: - """Return one exact canonical FD argument, never a source-code substring.""" - if not value.startswith(prefix): - return None + def canonical_fd_reference(value: str, prefix: str) -> int: + """Parse one fixed-role canonical FD argument.""" + assert value.startswith(prefix) descriptor = value.removeprefix(prefix) - if not descriptor.isascii() or not descriptor.isdecimal(): - return None + assert descriptor.isascii() and descriptor.isdecimal() number = int(descriptor) - return number if number > 2 and descriptor == str(number) else None - - transport_arguments = { - prefix: [ - descriptor for value in captured["argv"] - if (descriptor := canonical_fd_reference(value, prefix)) is not None - ] - for prefix in ("--mount=/proc/self/fd/", "--root=/proc/self/fd/", "/proc/self/fd/") - } - assert _NSENTER_REVALIDATOR_SOURCE in captured["argv"] - assert _NAMESPACE_MOUNT_SCANNER_SOURCE in captured["argv"] - assert all(len(descriptors) == 1 for descriptors in transport_arguments.values()) + assert number > 2 and descriptor == str(number) + return number + + argv = captured["argv"] + assert len(argv) == 12 + assert argv[0] == str(capture) + namespace_descriptor = canonical_fd_reference(argv[1], "--mount=/proc/self/fd/") + root_descriptor = canonical_fd_reference(argv[2], "--root=/proc/self/fd/") + assert argv[3:9] == [ + "--", sys.executable, "-I", "-S", "-c", _NAMESPACE_MOUNT_SCANNER_SOURCE, + ] + scan_descriptor = canonical_fd_reference(argv[9], "/proc/self/fd/") + assert argv[10:] == [str(len(scan_stdin)), hashlib.sha256(scan_stdin).hexdigest()] + assert captured["scan_fd"] == scan_descriptor expected_transport_descriptors = { - descriptors[0] for descriptors in transport_arguments.values() + namespace_descriptor, root_descriptor, scan_descriptor, } assert len(expected_transport_descriptors) == 3 - assert set(captured["inheritable"]) == expected_transport_descriptors + assert captured["inheritable"] == sorted(expected_transport_descriptors) assert all(str(target) not in captured["argv"] for target in targets) From 6464313c94ef05c9b0a6f9e7685e959ac459246b Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 22:44:14 -0700 Subject: [PATCH 227/233] fix(sync): classify held namespace child failures --- tests/test_sync_core_supervisor.py | 300 ++++++++++++++++++++++------- 1 file changed, 229 insertions(+), 71 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 2f36dfb61f..c504d48384 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4421,6 +4421,19 @@ def _exact_unit_not_found(completed: object) -> bool: _NSENTER_REVALIDATOR_SOURCE = r""" import errno,fcntl,hashlib,json,os,pathlib,signal,sys +COMPONENT='revalidator' +PHASE='control-parsing' +EXCEPTION_TOKENS=((json.JSONDecodeError,'jsondecodeerror'),(OSError,'oserror'), + (ValueError,'valueerror'),(TypeError,'typeerror'), + (KeyError,'keyerror'),(RuntimeError,'runtimeerror'), + (Exception,'exception'),(BaseException,'baseexception')) +def uncaught(exception_type,_exception,_traceback): + token=next(token for expected,token in EXCEPTION_TOKENS + if issubclass(exception_type,expected)) + try: os.write(2,('pdd-held-namespace-failure:'+COMPONENT+':'+PHASE+':'+token+'\n').encode('ascii')) + except OSError: pass +sys.excepthook=uncaught + if len(sys.argv)!=2 or len(sys.argv[1].encode('utf-8'))>131072: raise SystemExit('diagnostic transport invariant') try: @@ -4431,14 +4444,17 @@ def _exact_unit_not_found(completed: object) -> bool: raise SystemExit('diagnostic transport invariant') if sys.argv[1] != json.dumps(payload,sort_keys=True,separators=(',',':')): raise SystemExit('diagnostic transport invariant') +PHASE='holder-validation' holder=payload['holder']; namespace=payload['namespace'] pid=holder.get('pid'); start_time=holder.get('start_time') if type(pid) is not int or pid<=0 or type(start_time) is not str: raise SystemExit('invalid holder process identity') +PHASE='pidfd' try: pidfd=os.pidfd_open(pid,0) except ProcessLookupError: raise SystemExit(0) +PHASE='namespace-pin' proc=pathlib.Path('/proc')/str(pid) def identity(): @@ -4545,6 +4561,7 @@ def inherit_only(*descriptors): entry=str(entry_path) else: raise RuntimeError('invalid namespace holder kind') +PHASE='root-pin' if kind=='current': root_entry=str(proc/'root') else: @@ -4573,8 +4590,11 @@ def inherit_only(*descriptors): raise RuntimeError('holder identity raced after descriptor pinning') scan_fd=None if operation=='scan': + PHASE='scan-transport' scan_fd=sealed_memfd(read_scan_stdin(payload.get('scan_bytes'),payload.get('scan_sha256'))) +PHASE='fd-inheritance' inherit_only(namespace_fd,root_fd,*(() if scan_fd is None else (scan_fd,))) +PHASE='exec' os.close(pidfd) if operation=='unmount': mount=payload.get('mount') @@ -4599,6 +4619,19 @@ def inherit_only(*descriptors): _NAMESPACE_MOUNT_SCANNER_SOURCE = r""" import hashlib,json,os,pathlib,sys +COMPONENT='scanner' +PHASE='transport' +EXCEPTION_TOKENS=((json.JSONDecodeError,'jsondecodeerror'),(OSError,'oserror'), + (ValueError,'valueerror'),(TypeError,'typeerror'), + (KeyError,'keyerror'),(RuntimeError,'runtimeerror'), + (Exception,'exception'),(BaseException,'baseexception')) +def uncaught(exception_type,_exception,_traceback): + token=next(token for expected,token in EXCEPTION_TOKENS + if issubclass(exception_type,expected)) + try: os.write(2,('pdd-held-namespace-failure:'+COMPONENT+':'+PHASE+':'+token+'\n').encode('ascii')) + except OSError: pass +sys.excepthook=uncaught + # Hosted inventory has reached roughly 130 mountpoints; retain all up to 512. MAX_INVENTORY=512 MAX_TARGET_SAMPLES=16 @@ -4629,6 +4662,7 @@ def inherit_only(*descriptors): raw_payload=b''.join(chunks) if len(raw_payload)!=expected_bytes or hashlib.sha256(raw_payload).hexdigest()!=expected_digest: raise RuntimeError('invalid diagnostic scan payload') +PHASE='payload' try: payload=json.loads(raw_payload.decode('utf-8')) except (UnicodeDecodeError,json.JSONDecodeError) as error: @@ -4747,6 +4781,7 @@ def valid_optional_fields(fields): if set(payload)!={'operation','prefix','targets','expected_root'} or payload['operation']!='scan': raise RuntimeError('invalid diagnostic scan payload') +PHASE='paths' prefix=canonical(payload['prefix'],'prefix') if type(payload['targets']) is not list or len(payload['targets'])>MAX_INVENTORY: raise RuntimeError('invalid diagnostic targets') @@ -4760,11 +4795,13 @@ def valid_optional_fields(fields): type(expected_root[field]) is not int or expected_root[field] <= 0 for field in expected_root): raise RuntimeError('invalid expected root identity') +PHASE='namespace' namespace_path=pathlib.Path('/proc/self/ns/mnt') namespace_link=text(os.readlink(namespace_path),'mount namespace link') namespace_inode=namespace_path.stat().st_ino if not namespace_link.startswith('mnt:[') or not namespace_link.endswith(']') or namespace_inode<=0: raise RuntimeError('invalid current mount namespace') +PHASE='root' root_path=pathlib.Path('/proc/self/root') root_link=str(canonical(os.readlink(root_path),'root link')) root_fd=os.open(root_path,getattr(os,'O_PATH',0)|os.O_CLOEXEC) @@ -4777,6 +4814,7 @@ def valid_optional_fields(fields): if line.startswith('mnt_id:')] if len(root_mnt_ids)!=1 or not root_mnt_ids[0].isdigit() or int(root_mnt_ids[0])<=0: raise RuntimeError('invalid current root mount ID') +PHASE='mountinfo' records=[] for line in pathlib.Path('/proc/self/mountinfo').read_text(encoding='utf-8').splitlines(): fields=line.split(); separator=fields.index('-') if '-' in fields else -1 @@ -4798,6 +4836,11 @@ def distance(record): return (len(point.parts)+min(len(probe.parts) for probe in probes)-2*shared, -shared,record['mount_id']) related=sorted(records,key=distance)[:MAX_RELATED_MOUNTS] +PHASE='paths' +cwd=cwd_record() +paths={'prefix':path_record(prefix),'target_count':len(target_paths), + 'targets':[path_record(path) for path in target_paths[:MAX_TARGET_SAMPLES]]} +PHASE='output' output={ 'schema':'pdd-held-namespace-diagnostic-v1','operation':'scan', 'prefix':str(prefix),'targets':[str(path) for path in target_paths], @@ -4808,9 +4851,8 @@ def distance(record): 'matches':None if expected_root is None else expected_root=={ 'device':root_metadata.st_dev,'inode':root_metadata.st_ino, 'mode':root_metadata.st_mode,'mount_id':int(root_mnt_ids[0])}}, - 'cwd':cwd_record(), - 'paths':{'prefix':path_record(prefix),'target_count':len(target_paths), - 'targets':[path_record(path) for path in target_paths[:MAX_TARGET_SAMPLES]]}, + 'cwd':cwd, + 'paths':paths, 'mountinfo':{'mounts':mounts,'exact_count':len(exact), 'samples':exact_samples,'related':related}, } @@ -5238,48 +5280,42 @@ def mount(value: object, label: str) -> tuple[str, int]: return diagnostic, tuple(Path(value) for value in mount_points) -_HELD_NAMESPACE_SCAN_STATIC_STDERR_CATEGORIES = ( - # _NSENTER_REVALIDATOR_SOURCE static child failures. - "diagnostic transport invariant", "diagnostic transport oversized", - "diagnostic transport authentication", "diagnostic transport unavailable", - "invalid holder process identity", "invalid holder stat", - "holder namespace identity changed", "holder PID was reused", - "holder current namespace identity changed", "invalid namespace descriptor", - "namespace descriptor identity changed", "invalid namespace holder kind", - "invalid root descriptor", "root descriptor identity changed", - "invalid root descriptor mount ID", - "holder identity raced after descriptor pinning", - "invalid namespace unmount point", "invalid namespace operation", - # _NAMESPACE_MOUNT_SCANNER_SOURCE static child failures. - "invalid diagnostic transport reference", "oversized diagnostic scan payload", - "diagnostic scan payload has trailing bytes", "invalid diagnostic scan payload", - "invalid metadata errno", "invalid cwd errno", "malformed mountinfo", - "malformed mountinfo device", "invalid mountinfo optional field", - "invalid diagnostic targets", "diagnostic targets are not unique and sorted", - "invalid expected root identity", "invalid current mount namespace", - "invalid current root mount ID", "too many held mount inventory entries", - "oversized held namespace diagnostic output", -) +_HELD_NAMESPACE_SCAN_FAILURE_MARKER_PREFIX = "pdd-held-namespace-failure" +_HELD_NAMESPACE_SCAN_FAILURE_COMPONENT_PHASES = { + "revalidator": frozenset({ + "control-parsing", "holder-validation", "pidfd", "namespace-pin", + "root-pin", "scan-transport", "fd-inheritance", "exec", + }), + "scanner": frozenset({ + "transport", "payload", "namespace", "root", "mountinfo", "paths", "output", + }), +} +_HELD_NAMESPACE_SCAN_FAILURE_EXCEPTION_CLASSES = frozenset({ + "jsondecodeerror", "oserror", "valueerror", "typeerror", "keyerror", + "runtimeerror", "exception", "baseexception", +}) _HELD_NAMESPACE_SCAN_NSENTER_STDERR_CATEGORIES = ( ("nsenter: reassociate to namespace ", "nsenter-reassociate-failed"), ("nsenter: failed to execute ", "nsenter-exec-failed"), ) -_HELD_NAMESPACE_SCAN_RUNTIME_ERROR_PREFIX = "RuntimeError: " def _held_namespace_scan_stderr_category(line: str) -> str | None: - """Classify only complete static Python failures or anchored nsenter failures.""" - if line in _HELD_NAMESPACE_SCAN_STATIC_STDERR_CATEGORIES: + """Classify exact child markers and anchored static nsenter failures.""" + marker = line.split(":") + if ( + len(marker) == 4 + and marker[0] == _HELD_NAMESPACE_SCAN_FAILURE_MARKER_PREFIX + and marker[1] in _HELD_NAMESPACE_SCAN_FAILURE_COMPONENT_PHASES + and marker[2] in _HELD_NAMESPACE_SCAN_FAILURE_COMPONENT_PHASES[marker[1]] + and marker[3] in _HELD_NAMESPACE_SCAN_FAILURE_EXCEPTION_CLASSES + ): return line - if line.startswith(_HELD_NAMESPACE_SCAN_RUNTIME_ERROR_PREFIX): - message = line.removeprefix(_HELD_NAMESPACE_SCAN_RUNTIME_ERROR_PREFIX) - return ( - message - if message in _HELD_NAMESPACE_SCAN_STATIC_STDERR_CATEGORIES else None - ) for prefix, category in _HELD_NAMESPACE_SCAN_NSENTER_STDERR_CATEGORIES: if line.startswith(prefix): return category + if line.startswith("nsenter:"): + return "nsenter-unclassified" return None @@ -5300,17 +5336,62 @@ def _sanitized_held_namespace_scan_stderr(stderr: object) -> str: return ",".join(reversed(newest_categories)) or "redacted" -def test_held_namespace_scan_stderr_sanitizer_classifies_only_static_failures() -> None: - """Exact RuntimeError and bare SystemExit forms retain only static categories.""" +def _held_namespace_scan_child_returncode_category(returncode: object) -> str: + """Format only a bounded nonzero process return code for cleanup diagnostics.""" + if type(returncode) is int and -255 <= returncode <= 255 and returncode != 0: + return f"child-nonzero-returncode-{returncode}" + return "child-nonzero-returncode-invalid" + + +@pytest.mark.parametrize( + ("returncode", "expected"), + ( + (-255, "child-nonzero-returncode--255"), + (-9, "child-nonzero-returncode--9"), + (1, "child-nonzero-returncode-1"), + (255, "child-nonzero-returncode-255"), + (0, "child-nonzero-returncode-invalid"), + (-256, "child-nonzero-returncode-invalid"), + (256, "child-nonzero-returncode-invalid"), + (True, "child-nonzero-returncode-invalid"), + ("2", "child-nonzero-returncode-invalid"), + ), +) +def test_held_namespace_scan_child_returncode_category_is_bounded( + returncode: object, expected: str, +) -> None: + """Parent diagnostics expose only canonical bounded numeric child statuses.""" + assert _held_namespace_scan_child_returncode_category(returncode) == expected + + +def _held_namespace_scan_failure_marker( + component: str, phase: str, exception_class: str, +) -> str: + """Build one closed child marker for sanitizer contract tests.""" + return ( + f"{_HELD_NAMESPACE_SCAN_FAILURE_MARKER_PREFIX}:" + f"{component}:{phase}:{exception_class}" + ) + + +def test_held_namespace_scan_stderr_sanitizer_classifies_only_exact_markers() -> None: + """Only complete closed markers survive alongside dynamic child output.""" + revalidator_marker = _held_namespace_scan_failure_marker( + "revalidator", "root-pin", "runtimeerror", + ) + scanner_marker = _held_namespace_scan_failure_marker( + "scanner", "mountinfo", "oserror", + ) stderr = ( "Traceback (most recent call last): /private/token=secret\n" - "RuntimeError: invalid root descriptor mount ID\n" - "invalid holder process identity\n" + f"{revalidator_marker}\n" + "RuntimeError: unexpected failure payload=/private/secret\n" + f"{scanner_marker}\n" ) classified = _sanitized_held_namespace_scan_stderr(stderr) - assert classified == "invalid root descriptor mount ID,invalid holder process identity" + assert classified == f"{revalidator_marker},{scanner_marker}" assert len(classified.encode("ascii")) <= 256 assert "Traceback" not in classified assert "/private" not in classified @@ -5332,48 +5413,67 @@ def test_held_namespace_scan_stderr_sanitizer_redacts_dynamic_child_content() -> assert "token-value" not in classified -def test_held_namespace_scan_stderr_sanitizer_rejects_allowlist_spoofing() -> None: - """Allowlisted substrings embedded in arbitrary child lines never classify.""" +def test_held_namespace_scan_stderr_sanitizer_rejects_marker_spoofing() -> None: + """Marker prefixes, foreign tokens, and suffixes never classify.""" + marker = _held_namespace_scan_failure_marker( + "scanner", "payload", "runtimeerror", + ) stderr = ( - "attacker says RuntimeError: malformed mountinfo\n" - "SystemExit: malformed mountinfo\n" - "RuntimeError: invalid root descriptor mount ID payload=/private/secret\n" + f"attacker says {marker}\n" + f"{marker} payload=/private/secret\n" + "pdd-held-namespace-failure:scanner:bogus:runtimeerror\n" + "pdd-held-namespace-failure:bogus:payload:runtimeerror\n" + "pdd-held-namespace-failure:scanner:payload:bogus\n" "prefix nsenter: failed to execute /private/payload\n" ) assert _sanitized_held_namespace_scan_stderr(stderr) == "redacted" -def test_held_namespace_scan_stderr_sanitizer_deduplicates_exact_collisions() -> None: - """Exact matching distinguishes overlapping literals and keeps each category once.""" +def test_held_namespace_scan_stderr_sanitizer_accepts_only_closed_marker_tokens() -> None: + """Every component/phase/class token accepted by the sanitizer is closed.""" + for component, phases in _HELD_NAMESPACE_SCAN_FAILURE_COMPONENT_PHASES.items(): + for phase in phases: + for exception_class in _HELD_NAMESPACE_SCAN_FAILURE_EXCEPTION_CLASSES: + marker = _held_namespace_scan_failure_marker( + component, phase, exception_class, + ) + assert _held_namespace_scan_stderr_category(marker) == marker + + +def test_held_namespace_scan_stderr_sanitizer_deduplicates_exact_markers() -> None: + """Exact matching keeps each marker once without overlapping text ambiguity.""" + root_marker = _held_namespace_scan_failure_marker( + "revalidator", "root-pin", "runtimeerror", + ) + namespace_marker = _held_namespace_scan_failure_marker( + "revalidator", "namespace-pin", "runtimeerror", + ) stderr = ( - "RuntimeError: invalid root descriptor\n" - "RuntimeError: invalid root descriptor mount ID\n" - "RuntimeError: invalid root descriptor mount ID\n" + f"{root_marker}\n" + f"{namespace_marker}\n" + f"{namespace_marker}\n" ) assert _sanitized_held_namespace_scan_stderr(stderr) == ( - "invalid root descriptor,invalid root descriptor mount ID" + f"{root_marker},{namespace_marker}" ) def test_held_namespace_scan_stderr_sanitizer_prioritizes_terminal_categories() -> None: """Newest categories survive when all eight safe terminal lines exceed the cap.""" categories = ( - "holder current namespace identity changed", - "namespace descriptor identity changed", - "holder identity raced after descriptor pinning", - "diagnostic targets are not unique and sorted", - "diagnostic scan payload has trailing bytes", - "too many held mount inventory entries", - "oversized held namespace diagnostic output", - "invalid cwd errno", + *( + _held_namespace_scan_failure_marker("scanner", phase, "runtimeerror") + for phase in sorted(_HELD_NAMESPACE_SCAN_FAILURE_COMPONENT_PHASES["scanner"]) + ), + _held_namespace_scan_failure_marker("revalidator", "exec", "oserror"), ) - stderr = "".join(f"RuntimeError: {category}\n" for category in categories) + stderr = "".join(f"{category}\n" for category in categories) classified = _sanitized_held_namespace_scan_stderr(stderr) - assert classified == ",".join(categories[2:]) + assert classified == ",".join(categories[-4:]) assert categories[-1] in classified assert categories[0] not in classified assert len(classified.encode("ascii")) <= 256 @@ -5391,8 +5491,12 @@ def test_held_namespace_scan_stderr_sanitizer_prioritizes_terminal_categories() "nsenter: failed to execute /private/secret/payload: No such file", "nsenter-exec-failed", ), + ( + "nsenter: unexpected platform wording path=/private/secret", + "nsenter-unclassified", + ), ), - ids=("reassociate", "execute"), + ids=("reassociate", "execute", "unclassified"), ) def test_held_namespace_scan_stderr_sanitizer_maps_anchored_nsenter_failures( stderr: str, expected: str, @@ -5669,7 +5773,9 @@ def holder_mount_scan() -> tuple[tuple[Path, ...], bool] | None: if isinstance(completed.stderr, bytes) else completed.stderr ) errors.append( - "held mount namespace scan failed: category=child-nonzero stderr_tail=" + "held mount namespace scan failed: category=" + + _held_namespace_scan_child_returncode_category(completed.returncode) + + " stderr_tail=" + _sanitized_held_namespace_scan_stderr(stderr) ) return None @@ -5884,6 +5990,45 @@ def test_root_proc_scanner_source_compiles() -> None: compile(_NAMESPACE_MOUNT_SCANNER_SOURCE, "", "exec") +def test_held_namespace_child_sources_use_closed_marker_states() -> None: + """Both standalone child programs declare every parent-accepted marker token.""" + for source, component in ( + (_NSENTER_REVALIDATOR_SOURCE, "revalidator"), + (_NAMESPACE_MOUNT_SCANNER_SOURCE, "scanner"), + ): + assert "sys.excepthook=uncaught" in source + assert f"COMPONENT='{component}'" in source + for phase in _HELD_NAMESPACE_SCAN_FAILURE_COMPONENT_PHASES[component]: + assert f"PHASE='{phase}'" in source + for exception_class in _HELD_NAMESPACE_SCAN_FAILURE_EXCEPTION_CLASSES: + assert f"'{exception_class}'" in source + + +def test_held_namespace_child_sources_emit_exact_markers_and_preserve_system_exit() -> None: + """Uncaught children never relay traceback detail while explicit exits remain exact.""" + revalidator = subprocess.run( + [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, "{}"], + capture_output=True, text=True, check=False, timeout=5, + ) + scanner = subprocess.run( + [sys.executable, "-I", "-S", "-c", _NAMESPACE_MOUNT_SCANNER_SOURCE], + capture_output=True, text=True, check=False, timeout=5, + ) + static_exit = subprocess.run( + [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE], + capture_output=True, text=True, check=False, timeout=5, + ) + + assert revalidator.stderr == _held_namespace_scan_failure_marker( + "revalidator", "holder-validation", "keyerror", + ) + "\n" + assert scanner.stderr == _held_namespace_scan_failure_marker( + "scanner", "transport", "runtimeerror", + ) + "\n" + assert "Traceback" not in revalidator.stderr + scanner.stderr + assert static_exit.stderr == "diagnostic transport invariant\n" + + def test_nsenter_revalidator_ignores_only_closed_disallowed_fds() -> None: """The fd-directory iterator's transient FD cannot weaken transport inheritance.""" assert "import errno,fcntl" in _NSENTER_REVALIDATOR_SOURCE @@ -6033,10 +6178,10 @@ def canonical_fd_reference(value: str, prefix: str) -> int: @pytest.mark.parametrize( ("raw", "expected_category"), ( - (b"[]", b"invalid diagnostic scan payload"), - (b'{"operation":"scan"}\n', b"diagnostic scan payload has trailing bytes"), + (b"[]", b"pdd-held-namespace-failure:scanner:payload:runtimeerror"), + (b'{"operation":"scan"}\n', b"pdd-held-namespace-failure:scanner:payload:runtimeerror"), (b"x" * (_HELD_NAMESPACE_SCAN_PAYLOAD_MAX_BYTES + 1), - b"invalid diagnostic transport reference"), + b"pdd-held-namespace-failure:scanner:transport:runtimeerror"), ), ids=("malformed", "trailing", "oversized"), ) @@ -6059,7 +6204,7 @@ def test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_fr pass_fds=(descriptor,), capture_output=True, check=False, timeout=10, ) assert completed.returncode != 0 - assert expected_category in completed.stderr + assert completed.stderr == expected_category + b"\n" finally: os.close(descriptor) @@ -6088,7 +6233,9 @@ def test_namespace_scanner_rejects_truncated_canonical_frame() -> None: pass_fds=(descriptor,), capture_output=True, check=False, timeout=10, ) assert completed.returncode != 0 - assert b"invalid diagnostic scan payload" in completed.stderr + assert completed.stderr == ( + b"pdd-held-namespace-failure:scanner:transport:runtimeerror\n" + ) finally: os.close(descriptor) @@ -6311,11 +6458,16 @@ def runner(argv, **_kwargs): if failure == "nonzero": return SimpleNamespace( returncode=2, stdout="", - stderr="Traceback payload=/secret\nRuntimeError: malformed mountinfo\n", + stderr=( + "Traceback payload=/secret\n" + "pdd-held-namespace-failure:scanner:mountinfo:runtimeerror\n" + ), ) if failure == "transport": return SimpleNamespace( - returncode=2, stdout=b"", stderr=b"RuntimeError: diagnostic transport unavailable\n", + returncode=2, stdout=b"", stderr=( + b"pdd-held-namespace-failure:revalidator:scan-transport:runtimeerror\n" + ), ) return SimpleNamespace(returncode=0, stdout="[]", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="") @@ -6332,12 +6484,18 @@ def runner(argv, **_kwargs): assert "unmount" in operations assert any("show" in argv for argv in commands) if failure == "nonzero": - assert "category=child-nonzero stderr_tail=malformed mountinfo" in message + assert ( + "category=child-nonzero-returncode-2 stderr_tail=" + "pdd-held-namespace-failure:scanner:mountinfo:runtimeerror" + ) in message assert "Traceback" not in message and "/secret" not in message elif failure == "malformed": assert "held mount namespace scan was malformed" in message elif failure == "transport": - assert "category=child-nonzero stderr_tail=diagnostic transport unavailable" in message + assert ( + "category=child-nonzero-returncode-2 stderr_tail=" + "pdd-held-namespace-failure:revalidator:scan-transport:runtimeerror" + ) in message assert "terminate" in operations else: assert "held mount namespace scan construction failed" in message From 8b58c444d6f2c2cb3d2637155946f2623156f0af Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 23:01:06 -0700 Subject: [PATCH 228/233] fix(sync): use concrete scanner paths --- tests/test_sync_core_supervisor.py | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index c504d48384..e71b0311fc 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4709,6 +4709,7 @@ def unescape(value,label): return raw_text(''.join(output),label) def metadata(path,follow): + path=pathlib.Path(path) try: result=path.stat() if follow else path.lstat() except OSError as error: @@ -6240,6 +6241,94 @@ def test_namespace_scanner_rejects_truncated_canonical_frame() -> None: os.close(descriptor) +@pytest.mark.skipif( + not sys.platform.startswith("linux") + or not all(hasattr(os, name) for name in ("memfd_create", "MFD_ALLOW_SEALING")), + reason="requires Linux sealed memfd transport", +) +def test_namespace_scanner_accepts_authenticated_frame_and_records_paths( + tmp_path: Path, +) -> None: + """A sealed canonical frame records existing and absent concrete paths.""" + import fcntl # pylint: disable=import-outside-toplevel + + prefix = tmp_path / "scanner-paths" + prefix.mkdir() + existing = prefix / "existing" + existing.write_text("present", encoding="utf-8") + absent = prefix / "absent" + targets = tuple(sorted((existing, absent))) + namespace_path = Path("/proc/self/ns/mnt") + namespace = { + "link": os.readlink(namespace_path), "inode": namespace_path.stat().st_ino, + } + root_descriptor = os.open( + "/proc/self/root", getattr(os, "O_PATH") | os.O_CLOEXEC, + ) + try: + root_metadata = os.fstat(root_descriptor) + root_mount_ids = [ + line.partition(":")[2].strip() + for line in Path(f"/proc/self/fdinfo/{root_descriptor}").read_text( + encoding="ascii", + ).splitlines() + if line.startswith("mnt_id:") + ] + finally: + os.close(root_descriptor) + assert len(root_mount_ids) == 1 and root_mount_ids[0].isdigit() + expected_root = { + "device": root_metadata.st_dev, "inode": root_metadata.st_ino, + "mode": root_metadata.st_mode, "mount_id": int(root_mount_ids[0]), + } + raw = json.dumps( + { + "expected_root": expected_root, "operation": "scan", + "prefix": str(prefix), "targets": [str(path) for path in targets], + }, sort_keys=True, separators=(",", ":"), + ).encode("utf-8") + descriptor = getattr(os, "memfd_create")( + "pdd-scanner-success", + getattr(os, "MFD_CLOEXEC") | getattr(os, "MFD_ALLOW_SEALING"), + ) + try: + os.write(descriptor, raw) + fcntl.fcntl( + descriptor, getattr(fcntl, "F_ADD_SEALS"), + getattr(fcntl, "F_SEAL_WRITE") | getattr(fcntl, "F_SEAL_GROW") + | getattr(fcntl, "F_SEAL_SHRINK") | getattr(fcntl, "F_SEAL_SEAL"), + ) + os.lseek(descriptor, 0, os.SEEK_SET) + completed = subprocess.run( + [ + sys.executable, "-I", "-S", "-c", _NAMESPACE_MOUNT_SCANNER_SOURCE, + f"/proc/self/fd/{descriptor}", str(len(raw)), hashlib.sha256(raw).hexdigest(), + ], + pass_fds=(descriptor,), capture_output=True, check=False, timeout=10, + ) + finally: + os.close(descriptor) + + assert completed.returncode == 0, completed.stderr.decode("utf-8", "replace") + assert completed.stderr == b"" + diagnostic, _mounts = _parse_held_namespace_diagnostic( + completed.stdout.decode("utf-8"), namespace=namespace, + holder={ + "root_device": expected_root["device"], + "root_inode": expected_root["inode"], + "root_mode": expected_root["mode"], + "root_mnt_id": expected_root["mount_id"], + }, control_prefix=prefix, targets=targets, + ) + records = diagnostic["paths"] + assert records["prefix"]["stat"]["status"] == "ok" + target_records = {record["path"]: record for record in records["targets"]} + assert set(target_records) == {str(existing), str(absent)} + assert target_records[str(existing)]["stat"]["status"] == "ok" + assert target_records[str(absent)]["lstat"]["status"] == "error" + assert target_records[str(absent)]["stat"]["status"] == "error" + + def _valid_mountinfo_optional_fields(fields: tuple[str, ...]) -> bool: """Mirror the forward-compatible Linux mountinfo optional-field grammar.""" for field in fields: From 144b93bf6e01d81c677fa54315343efbbe5355bd Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 23:22:42 -0700 Subject: [PATCH 229/233] fix(sync): accept empty authenticated held inventory --- tests/test_sync_core_supervisor.py | 247 ++++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 2 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index e71b0311fc..fa4f826e80 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -5809,12 +5809,16 @@ def holder_mount_scan() -> tuple[tuple[Path, ...], bool] | None: or namespace_holder.get("holder_kind") != "fd" or scan is None or scan["current_holders"] - or not held_mounts + or held_scan is None + or not held_scan[1] ): errors.append("exact FD-only namespace mount ownership was not preserved") deferred_absent_unmounts = [] + unmount_mounts = ( + held_mounts if held_scan is not None and held_scan[1] else captured_mounts + ) for mount in sorted( - captured_mounts, key=lambda path: (len(path.parts), str(path)), reverse=True, + unmount_mounts, key=lambda path: (len(path.parts), str(path)), reverse=True, ): if namespace_holder is None: argv = ["sudo", "-n", "umount", str(mount)] @@ -6490,6 +6494,245 @@ def mount(point: Path, mount_id: int) -> dict[str, object]: ) +def _fallback_held_scan_diagnostic( + prefix: Path, targets: tuple[Path, ...], holder: dict[str, object], + mounts: tuple[Path, ...], *, root_matches: bool, +) -> str: + """Build one authenticated held-root inventory for fallback cleanup tests.""" + expected_root = { + "device": int(holder["root_device"]), "inode": int(holder["root_inode"]), + "mode": int(holder["root_mode"]), "mount_id": int(holder["root_mnt_id"]), + } + actual_root = dict(expected_root) + if not root_matches: + actual_root["inode"] += 1 + + def missing(path: Path) -> dict[str, object]: + error = {"status": "error", "errno": 2} + return { + "path": str(path), "exists": False, "lstat": error, "stat": error, + } + + def mount_record(path: Path, mount_id: int) -> dict[str, object]: + return { + "mount_id": mount_id, "parent_id": 1, "root": "/", + "mount_point": str(path), "major_minor": "0:42", + "filesystem": "tmpfs", "source": "tmpfs", + "options": {"mount": "rw", "super": "rw"}, "truncated": [], + } + + inventory = tuple(sorted(mounts)) + return json.dumps({ + "schema": "pdd-held-namespace-diagnostic-v1", "operation": "scan", + "prefix": str(prefix), "targets": [str(path) for path in targets], + "mount_namespace": { + "link": str(holder["namespace"]), "inode": holder["namespace_inode"], + }, + "root": { + "link": "/", "expected": expected_root, "actual": actual_root, + "matches": root_matches, + }, + "cwd": {"status": "ok", "path": "/"}, + "paths": { + "prefix": missing(prefix), "target_count": len(targets), + "targets": [missing(path) for path in targets[:16]], + }, + "mountinfo": { + "mounts": [str(path) for path in inventory], "exact_count": len(inventory), + "samples": [ + mount_record(path, 43 + index) + for index, path in enumerate(inventory[:16]) + ], + "related": [] if inventory else [mount_record(Path("/"), 99)], + }, + }, sort_keys=True) + + +def _fallback_fd_holder() -> dict[str, object]: + """Return the complete captured identity of an FD-only held namespace owner.""" + return { + "holder_kind": "fd", "pid": 22, "start_time": "100", "fd": 9, + "fd_path": "/proc/22/fd/9", "fd_link": "mnt:[11]", "fd_inode": 11, + "root_fd": 10, "root_path": "/proc/22/fd/10", "root_device": 1, + "root_inode": 2, "root_mode": 0o40755, "root_mnt_id": 42, + "namespace": "mnt:[11]", "namespace_inode": 11, + } + + +def test_fallback_fd_holder_accepts_root_valid_empty_held_inventory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """An authenticated empty FD-held inventory proves stale outer mounts absent.""" + prefix = tmp_path / "pdd-scope-owned" + stale = prefix / "binds" / "stale" + holder = _fallback_fd_holder() + ownership = { + "unit": "pdd-validator-test.scope", "cgroup": tmp_path / "cgroup", + "control_prefix": prefix, "namespace": {"link": "mnt:[11]", "inode": 11}, + "namespace_holder": holder, "coordinator": {"pid": 1, "start_time": "1"}, + "mount_points": [stale], "require_fd_only_holder": True, + } + commands = [] + scans = 0 + monkeypatch.setattr( + supervisor, "_trusted_tools", lambda: SimpleNamespace(helper_python=Path("/trusted/python")), + ) + + def scanner(**_kwargs): + nonlocal scans + scans += 1 + return { + "watched": [], "identities": [], "cgroup_exists": False, + "mount_holders": [], "current_holders": [], + "fd_holders": [holder] if scans <= 2 else [], + } + + def runner(argv, **kwargs): + commands.append(argv) + if "systemctl" in argv and "show" in argv: + return SimpleNamespace(returncode=0, stdout="not-found\n", stderr="") + if _NSENTER_REVALIDATOR_SOURCE in argv: + payload = json.loads(argv[-1]) + assert payload["operation"] == "scan" + request = json.loads(kwargs["input"]) + return SimpleNamespace( + returncode=0, + stdout=_fallback_held_scan_diagnostic( + prefix, tuple(Path(path) for path in request["targets"]), holder, (), + root_matches=True, + ), + stderr="", + ) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + assert _fallback_stalled_observation_cleanup( + ownership, (), runner=runner, scanner=scanner, + ) == () + operations = [ + json.loads(argv[-1])["operation"] + for argv in commands if _NSENTER_REVALIDATOR_SOURCE in argv + ] + assert operations == ["scan", "scan"] + + +def test_fallback_fd_holder_unmounts_only_root_valid_held_inventory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Held-root cleanup does not unmount stale paths absent from its inventory.""" + prefix = tmp_path / "pdd-scope-owned" + stale = prefix / "binds" / "stale" + held = prefix / "binds" / "held" + holder = _fallback_fd_holder() + ownership = { + "unit": "pdd-validator-test.scope", "cgroup": tmp_path / "cgroup", + "control_prefix": prefix, "namespace": {"link": "mnt:[11]", "inode": 11}, + "namespace_holder": holder, "coordinator": {"pid": 1, "start_time": "1"}, + "mount_points": [stale], "require_fd_only_holder": True, + } + unmounts = [] + scans = 0 + held_inventories = iter(((held,), ())) + monkeypatch.setattr( + supervisor, "_trusted_tools", lambda: SimpleNamespace(helper_python=Path("/trusted/python")), + ) + + def scanner(**_kwargs): + nonlocal scans + scans += 1 + return { + "watched": [], "identities": [], "cgroup_exists": False, + "mount_holders": [], "current_holders": [], + "fd_holders": [holder] if scans <= 2 else [], + } + + def runner(argv, **kwargs): + if "systemctl" in argv and "show" in argv: + return SimpleNamespace(returncode=0, stdout="not-found\n", stderr="") + if _NSENTER_REVALIDATOR_SOURCE in argv: + payload = json.loads(argv[-1]) + if payload["operation"] == "unmount": + unmounts.append(payload["mount"]) + return SimpleNamespace(returncode=0, stdout="", stderr="") + request = json.loads(kwargs["input"]) + return SimpleNamespace( + returncode=0, + stdout=_fallback_held_scan_diagnostic( + prefix, tuple(Path(path) for path in request["targets"]), holder, + next(held_inventories), root_matches=True, + ), + stderr="", + ) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + assert _fallback_stalled_observation_cleanup( + ownership, (), runner=runner, scanner=scanner, + ) == () + assert unmounts == [str(held)] + + +@pytest.mark.parametrize("mode", ("root-mismatch", "unavailable")) +def test_fallback_fd_holder_empty_untrusted_scan_keeps_stale_unmounts( + tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Root-invalid or unavailable scans cannot prove a stale mount is absent.""" + prefix = tmp_path / "pdd-scope-owned" + stale = prefix / "binds" / "stale" + holder = _fallback_fd_holder() + ownership = { + "unit": "pdd-validator-test.scope", "cgroup": tmp_path / "cgroup", + "control_prefix": prefix, "namespace": {"link": "mnt:[11]", "inode": 11}, + "namespace_holder": holder, "coordinator": {"pid": 1, "start_time": "1"}, + "mount_points": [stale], + } + unmounts = [] + scans = 0 + monkeypatch.setattr( + supervisor, "_trusted_tools", lambda: SimpleNamespace(helper_python=Path("/trusted/python")), + ) + + def scanner(**_kwargs): + nonlocal scans + scans += 1 + return { + "watched": [], "identities": [], "cgroup_exists": False, + "mount_holders": [], "current_holders": [], + "fd_holders": [holder] if scans <= 2 else [], + } + + def runner(argv, **kwargs): + if "systemctl" in argv and "show" in argv: + return SimpleNamespace(returncode=0, stdout="not-found\n", stderr="") + if _NSENTER_REVALIDATOR_SOURCE in argv: + payload = json.loads(argv[-1]) + if payload["operation"] == "unmount": + unmounts.append(payload["mount"]) + return SimpleNamespace( + returncode=1, stdout="", stderr="no mount point specified", + ) + if mode == "unavailable": + return SimpleNamespace(returncode=2, stdout="", stderr="scanner unavailable") + request = json.loads(kwargs["input"]) + return SimpleNamespace( + returncode=0, + stdout=_fallback_held_scan_diagnostic( + prefix, tuple(Path(path) for path in request["targets"]), holder, (), + root_matches=False, + ), + stderr="", + ) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + with pytest.raises(AssertionError, match="no mount point specified") as raised: + _fallback_stalled_observation_cleanup( + ownership, (), runner=runner, scanner=scanner, + ) + assert unmounts == [str(stale)] + if mode == "root-mismatch": + assert "root identity mismatched" in str(raised.value) + else: + assert "held mount namespace scan failed" in str(raised.value) + + @pytest.mark.parametrize("failure", ("construction", "nonzero", "malformed", "transport")) def test_held_namespace_scan_failures_continue_cleanup_and_final_verification( tmp_path: Path, failure: str, monkeypatch: pytest.MonkeyPatch, From 826ef3697c7c8c155ca1332d2681dc9150552f0f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 23:32:35 -0700 Subject: [PATCH 230/233] fix(sync): preserve stacked held mounts --- tests/test_sync_core_supervisor.py | 61 ++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index fa4f826e80..3764b49d79 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4824,10 +4824,11 @@ def valid_optional_fields(fields): valid_optional_fields(fields[6:separator]) records.append(mount_record(line)) exact=[record for record in records if record['mount_point'] in targets or contained(record['mount_point'])] -mounts=sorted({record['mount_point'] for record in exact}) +exact=sorted(exact,key=lambda record:(record['mount_point'],record['mount_id'])) +mounts=[record['mount_point'] for record in exact] if len(mounts)>MAX_INVENTORY: raise RuntimeError('too many held mount inventory entries') -exact_samples=sorted(exact,key=lambda record:(record['mount_point'],record['mount_id']))[:MAX_EXACT_SAMPLES] +exact_samples=exact[:MAX_EXACT_SAMPLES] related=[] if not exact: probes=(prefix,*target_paths) @@ -5249,7 +5250,7 @@ def mount(value: object, label: str) -> tuple[str, int]: reject("has invalid mount inventory") mount_points = [path(value, "mount inventory") for value in mounts] if ( - mount_points != sorted(set(mount_points)) + mount_points != sorted(mount_points) or any(not _canonical_owned_mount_point(value, control_prefix, targets) for value in mount_points) ): reject("has invalid mount inventory") @@ -5259,15 +5260,16 @@ def mount(value: object, label: str) -> tuple[str, int]: if type(samples) is not list or len(samples) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_EXACT_MOUNTS: reject("has invalid mount samples") sample_keys = [mount(value, "mount sample") for value in samples] - if sample_keys != sorted(sample_keys) or any(point not in mount_points for point, _mount_id in sample_keys): + if ( + sample_keys != sorted(sample_keys) + or [point for point, _mount_id in sample_keys] != mount_points[:len(sample_keys)] + ): reject("has unordered mount samples") if ( - bool(mounts) != bool(exact_count) - or exact_count < len(mount_points) - or exact_count < len(sample_keys) + exact_count != len(mount_points) or len(sample_keys) != len(set(sample_keys)) or len({mount_id for _point, mount_id in sample_keys}) != len(sample_keys) - or (mounts and len(samples) != min(16, exact_count)) + or len(samples) != min(16, exact_count) ): reject("has incomplete mount samples") if type(related) is not list or len(related) > _HELD_NAMESPACE_DIAGNOSTIC_MAX_RELATED_MOUNTS: @@ -6438,6 +6440,23 @@ def mount(point: Path, mount_id: int) -> dict[str, object]: assert parsed == diagnostic assert mounts == targets + stacked_inventory = (targets[0], targets[0], *targets[1:]) + stacked = json.loads(raw) + stacked["mountinfo"] = { + "mounts": [str(target) for target in stacked_inventory], + "exact_count": len(stacked_inventory), + "samples": [ + mount(target, index + 43) + for index, target in enumerate(stacked_inventory[:16]) + ], + "related": [], + } + _parsed, mounts = _parse_held_namespace_diagnostic( + json.dumps(stacked), namespace={"link": "mnt:[11]", "inode": 11}, + holder=holder, control_prefix=prefix, targets=targets, + ) + assert mounts == stacked_inventory + root_mismatch = json.loads(raw) root_mismatch["root"]["actual"]["inode"] = 3 root_mismatch["root"]["matches"] = False @@ -6477,9 +6496,18 @@ def mount(point: Path, mount_id: int) -> dict[str, object]: unordered_samples["mountinfo"]["samples"] )) malformed.append(json.dumps(unordered_samples)) - inconsistent_count = json.loads(raw) - inconsistent_count["mountinfo"]["exact_count"] = 0 - malformed.append(json.dumps(inconsistent_count)) + for count in (len(targets) - 1, len(targets) + 1): + inconsistent_count = json.loads(raw) + inconsistent_count["mountinfo"]["exact_count"] = count + malformed.append(json.dumps(inconsistent_count)) + unordered_duplicate_inventory = json.loads(json.dumps(stacked)) + unordered_duplicate_inventory["mountinfo"]["mounts"][:3] = [ + str(targets[0]), str(targets[1]), str(targets[0]), + ] + malformed.append(json.dumps(unordered_duplicate_inventory)) + mismatched_stacked_samples = json.loads(json.dumps(stacked)) + mismatched_stacked_samples["mountinfo"]["samples"][1]["mount_point"] = str(targets[1]) + malformed.append(json.dumps(mismatched_stacked_samples)) duplicate_sample_id = json.loads(raw) duplicate_sample_id["mountinfo"]["samples"][1]["mount_id"] = ( duplicate_sample_id["mountinfo"]["samples"][0]["mount_id"] @@ -6615,13 +6643,14 @@ def runner(argv, **kwargs): assert operations == ["scan", "scan"] -def test_fallback_fd_holder_unmounts_only_root_valid_held_inventory( +def test_fallback_fd_holder_unmounts_stacked_nested_held_inventory_exactly( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Held-root cleanup does not unmount stale paths absent from its inventory.""" + """Held-root cleanup preserves stacked layers and unmounts nested paths first.""" prefix = tmp_path / "pdd-scope-owned" stale = prefix / "binds" / "stale" - held = prefix / "binds" / "held" + shared = prefix / "binds" / "shared" + nested = shared / "nested" holder = _fallback_fd_holder() ownership = { "unit": "pdd-validator-test.scope", "cgroup": tmp_path / "cgroup", @@ -6631,7 +6660,7 @@ def test_fallback_fd_holder_unmounts_only_root_valid_held_inventory( } unmounts = [] scans = 0 - held_inventories = iter(((held,), ())) + held_inventories = iter(((shared, shared, nested), ())) monkeypatch.setattr( supervisor, "_trusted_tools", lambda: SimpleNamespace(helper_python=Path("/trusted/python")), ) @@ -6667,7 +6696,7 @@ def runner(argv, **kwargs): assert _fallback_stalled_observation_cleanup( ownership, (), runner=runner, scanner=scanner, ) == () - assert unmounts == [str(held)] + assert unmounts == [str(nested), str(shared), str(shared)] @pytest.mark.parametrize("mode", ("root-mismatch", "unavailable")) From 57474c7287b24213f6715942c930e772eb5d744f Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Wed, 15 Jul 2026 23:43:35 -0700 Subject: [PATCH 231/233] fix(sync): canonicalize external holder termination payload --- tests/test_sync_core_supervisor.py | 41 ++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index 3764b49d79..c43dd24a6c 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -4291,7 +4291,7 @@ def _external_holder_termination_payload( _namespace_entry_path(holder, namespace) return json.dumps({ "holder": holder, "namespace": namespace, "operation": "terminate", - }, sort_keys=True) + }, sort_keys=True, separators=(",", ":")) @dataclass(frozen=True) @@ -7160,7 +7160,7 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( } completed = subprocess.run( [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, - json.dumps(payload, sort_keys=True)], + json.dumps(payload, sort_keys=True, separators=(",", ":"))], capture_output=True, text=True, check=False, timeout=5, ) assert completed.returncode == 0, completed.stderr @@ -7172,7 +7172,7 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( payload["mount"] = "" rejected = subprocess.run( [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, - json.dumps(payload, sort_keys=True)], + json.dumps(payload, sort_keys=True, separators=(",", ":"))], capture_output=True, text=True, check=False, timeout=5, ) assert rejected.returncode != 0 @@ -7199,7 +7199,7 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( )) rejected = subprocess.run( [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, - json.dumps(substitution, sort_keys=True)], + json.dumps(substitution, sort_keys=True, separators=(",", ":"))], capture_output=True, text=True, check=False, timeout=5, ) assert rejected.returncode != 0 @@ -7212,20 +7212,41 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( def test_external_holder_termination_requires_complete_pidfd_identity() -> None: - """Termination binds the complete captured holder to a pidfd, never a PID.""" + """Termination binds canonical complete holder data to a pidfd, never a PID.""" namespace = {"link": "mnt:[11]", "inode": 11} holder = { - "holder_kind": "fd", "pid": 22, "start_time": "100", + "holder_kind": "fd", "pid": 2_147_483_647, "start_time": "100", "namespace": "mnt:[1]", "namespace_inode": 1, - "fd": 7, "fd_path": "/proc/22/fd/7", + "fd": 7, "fd_path": "/proc/2147483647/fd/7", "fd_link": "mnt:[11]", "fd_inode": 11, } - payload = json.loads(_external_holder_termination_payload(holder, namespace)) - - assert payload == { + expected = { "holder": holder, "namespace": namespace, "operation": "terminate", } + payload = _external_holder_termination_payload(holder, namespace) + + assert payload == json.dumps(expected, sort_keys=True, separators=(",", ":")) + assert payload == ( + '{"holder":{"fd":7,"fd_inode":11,"fd_link":"mnt:[11]",' + '"fd_path":"/proc/2147483647/fd/7","holder_kind":"fd",' + '"namespace":"mnt:[1]","namespace_inode":1,"pid":2147483647,' + '"start_time":"100"},"namespace":{"inode":11,"link":"mnt:[11]"},' + '"operation":"terminate"}' + ) + accepted = subprocess.run( + [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, payload], + capture_output=True, text=True, check=False, timeout=5, + ) + assert "diagnostic transport invariant" not in accepted.stderr + noncanonical = json.dumps(expected, sort_keys=True) + assert noncanonical != payload + rejected = subprocess.run( + [sys.executable, "-I", "-S", "-c", _NSENTER_REVALIDATOR_SOURCE, noncanonical], + capture_output=True, text=True, check=False, timeout=5, + ) + assert rejected.returncode != 0 + assert rejected.stderr == "diagnostic transport invariant\n" with pytest.raises(ValueError, match="descriptor"): _external_holder_termination_payload( {key: value for key, value in holder.items() if key != "fd_path"}, From decfeed163806d89a254366ebce37084275a7266 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Thu, 16 Jul 2026 00:24:27 -0700 Subject: [PATCH 232/233] test(sync): reproduce proc descriptor reuse race --- tests/test_sync_core_supervisor.py | 108 ++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index c43dd24a6c..b5e2de4fd4 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -5997,6 +5997,106 @@ def test_root_proc_scanner_source_compiles() -> None: compile(_NAMESPACE_MOUNT_SCANNER_SOURCE, "", "exec") +def _run_root_proc_scanner_descriptor_fault_fixture( + tmp_path: Path, fault: str, +) -> tuple[subprocess.CompletedProcess[str], Path, Path]: + """Run the embedded scanner against a procfs fixture with one FD read fault.""" + pid = 4242 + proc = tmp_path / "proc" + pid_dir = proc / str(pid) + fd_directory = pid_dir / "fd" + namespace_directory = pid_dir / "ns" + fd_directory.mkdir(parents=True) + namespace_directory.mkdir() + namespace_target = pid_dir / "namespace-target" + namespace_target.write_text("namespace", encoding="ascii") + namespace_path = namespace_directory / "mnt" + namespace_path.symlink_to(namespace_target) + descriptor_target = pid_dir / "descriptor-target" + descriptor_target.write_text("original", encoding="ascii") + replacement_target = pid_dir / "replacement-target" + replacement_target.write_text("replacement", encoding="ascii") + descriptor = fd_directory / "3" + descriptor.symlink_to(descriptor_target) + (pid_dir / "stat").write_text( + f"{pid} (fixture) S {' '.join(['0'] * 20)}\n", encoding="ascii", + ) + (pid_dir / "cmdline").write_bytes(b"fixture\0") + (pid_dir / "wchan").write_text("fixture\n", encoding="ascii") + (pid_dir / "mountinfo").write_text( + "42 0 0:42 / / rw - tmpfs tmpfs rw\n", encoding="ascii", + ) + probe = tmp_path / "descriptor-fault-probe" + namespace_inode = namespace_path.stat().st_ino + if fault == "enoent": + descriptor_fault = f""" + if value=={str(descriptor)!r}: + pathlib.Path(value).unlink() + pathlib.Path(value).symlink_to({str(replacement_target)!r}) + pathlib.Path({str(probe)!r}).write_text('reused',encoding='ascii') + raise FileNotFoundError(2,'descriptor changed',value) +""" + elif fault == "permission": + descriptor_fault = f""" + if value=={str(descriptor)!r}: + pathlib.Path({str(probe)!r}).write_text('denied',encoding='ascii') + raise PermissionError(13,'descriptor denied',value) +""" + else: + raise ValueError(f"unknown descriptor fixture fault: {fault}") + source = _ROOT_PROC_SCANNER_SOURCE.replace( + "proc=pathlib.Path('/proc')", f"proc=pathlib.Path({str(proc)!r})", + ).replace( + "self_pid=os.getpid()", + f"""self_pid=os.getpid() +_fixture_readlink=os.readlink +def fixture_readlink(path): + value=os.fspath(path) + if value=={str(namespace_path)!r}: + return 'mnt:[{namespace_inode}]' +{descriptor_fault} return _fixture_readlink(path) +os.readlink=fixture_readlink""", + ) + payload = { + "cgroup": "", "namespace": None, "targets": [], "target_prefix": "", + "watch_pids": [pid], "scope_only": True, "root_holders": [], + } + completed = subprocess.run( + [sys.executable, "-I", "-S", "-c", source, json.dumps(payload)], + capture_output=True, text=True, check=False, timeout=5, + ) + return completed, descriptor, probe + + +def test_root_proc_scanner_source_skips_enoent_reused_descriptor_entry( + tmp_path: Path, +) -> None: + """An ENOENT descriptor snapshot race does not make the scanner abort.""" + completed, descriptor, probe = _run_root_proc_scanner_descriptor_fault_fixture( + tmp_path, "enoent", + ) + + assert probe.read_text(encoding="ascii") == "reused" + assert descriptor.is_symlink() + assert completed.returncode == 0, completed.stderr + + +def test_root_proc_scanner_source_rejects_non_enoent_descriptor_errors( + tmp_path: Path, +) -> None: + """Descriptor errors other than an enumeration snapshot race stay fatal.""" + completed, _descriptor, probe = _run_root_proc_scanner_descriptor_fault_fixture( + tmp_path, "permission", + ) + + assert probe.read_text(encoding="ascii") == "denied" + assert completed.returncode == 2 + assert ( + "root proc scanner failed: PermissionError: " + "read descriptor: pid=4242 fd=3: PermissionError: " + ) in completed.stderr + + def test_held_namespace_child_sources_use_closed_marker_states() -> None: """Both standalone child programs declare every parent-accepted marker token.""" for source, component in ( @@ -7176,7 +7276,9 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( capture_output=True, text=True, check=False, timeout=5, ) assert rejected.returncode != 0 - assert "invalid namespace unmount point" in rejected.stderr + assert rejected.stderr == _held_namespace_scan_failure_marker( + "revalidator", "exec", "runtimeerror", + ) + "\n" host_root = os.open("/", getattr(os, "O_PATH") | os.O_CLOEXEC) try: host_metadata = os.fstat(host_root) @@ -7203,7 +7305,9 @@ def test_namespace_holder_unmount_payload_and_nsenter_argv_are_exact( capture_output=True, text=True, check=False, timeout=5, ) assert rejected.returncode != 0 - assert "root descriptor identity changed" in rejected.stderr + assert rejected.stderr == _held_namespace_scan_failure_marker( + "revalidator", "root-pin", "runtimeerror", + ) + "\n" finally: os.close(host_root) finally: From d2d85fe400fd864037ff498b03da1e798c297189 Mon Sep 17 00:00:00 2001 From: Greg Tanaka Date: Thu, 16 Jul 2026 00:25:53 -0700 Subject: [PATCH 233/233] fix(sync): tolerate reused proc descriptor entries --- tests/test_sync_core_supervisor.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/tests/test_sync_core_supervisor.py b/tests/test_sync_core_supervisor.py index b5e2de4fd4..bb36ad9cb9 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -3897,16 +3897,8 @@ def fd_links(pid_dir): for entry in entries: try: links.append((int(entry.name),os.readlink(entry),entry.stat().st_ino)) - except FileNotFoundError as error: - try: - entry.lstat() - except FileNotFoundError: - continue - except OSError as verify_error: - fail(f'verify descriptor ENOENT: pid={pid_dir.name} fd={entry.name}: ' - f'{type(verify_error).__name__}: {verify_error}') - fail(f'read descriptor: pid={pid_dir.name} fd={entry.name}: ' - f'ENOENT while descriptor still exists: {error}') + except FileNotFoundError: + continue except (OSError,ValueError) as error: fail(f'read descriptor: pid={pid_dir.name} fd={entry.name}: ' f'{type(error).__name__}: {error}') @@ -6092,7 +6084,7 @@ def test_root_proc_scanner_source_rejects_non_enoent_descriptor_errors( assert probe.read_text(encoding="ascii") == "denied" assert completed.returncode == 2 assert ( - "root proc scanner failed: PermissionError: " + "root proc scanner failed: RuntimeError: " "read descriptor: pid=4242 fd=3: PermissionError: " ) in completed.stderr