diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a12ff1cfe2..e09699e12d 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 @@ -97,6 +98,60 @@ 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 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: Configure git identity run: | git config --global user.email "ci@pdd.dev" @@ -108,8 +163,15 @@ jobs: - name: Provision and verify protected Linux sandbox run: | + set -euo pipefail sudo apt-get update sudo apt-get install --yes bubblewrap + command -v bwrap + command -v systemd-run + command -v unshare + command -v nsenter + sudo -n true + bwrap --version private_root="$(mktemp -d)" chmod 700 "$private_root" mkdir -m 700 "$private_root/scratch" @@ -126,16 +188,21 @@ 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( ["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[-2])) + 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/") @@ -152,15 +219,160 @@ 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 assert not surviving - assert (root / "scratch" / "ok").read_text() == "ok" + assert not (root / "scratch" / "ok").exists() 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"]) + environment = { + "HOME": str(root / "home"), + "PYTHONNOUSERSITE": "1", + } + 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') + 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) + 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: + pass + else: + raise SystemExit('candidate can migrate cgroups') + ''' + result, surviving = run_supervised( + [sys.executable, '-c', program], cwd=root / 'scratch', timeout=30, + env=environment, writable_roots=(root / 'scratch',), + limits=SupervisorLimits(max_processes=8), + ) + assert result.returncode == 0, result.stderr + assert not surviving + assert_no_leak() + oom, surviving = run_supervised( + [sys.executable, '-c', 'bytearray(512 * 1024 * 1024)'], + 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, + ), + ) + 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() + 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=environment, + 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() + timed_out, surviving = run_supervised( + [sys.executable, '-c', 'import time; time.sleep(30)'], + cwd=root / 'scratch', timeout=1, env=environment, + 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( + [sys.executable, '-c', "print('x' * 200000)"], + cwd=root / 'scratch', timeout=30, env=environment, + writable_roots=(root / 'scratch',), + limits=SupervisorLimits(max_output_bytes=1024), + ) + assert output.returncode == 125, output.stderr + 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=sparse_root, timeout=30, env=environment, + 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=aggregate_root, timeout=30, env=environment, + 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', "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=environment, + 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=within_root, timeout=30, env=environment, + writable_roots=(within_root,), limits=quota, + ) + assert within.returncode == 0, within.stderr + assert not surviving + assert_no_leak() + PY + - name: Verify protected pytest smoke run: > pytest -q @@ -173,6 +385,16 @@ 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_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 + - name: Run focused protected-runner tests run: > pytest -q @@ -181,8 +403,58 @@ 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", + "native_runtime", "lockfile", + } + assert set(roles) == required + 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 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 \ + tests/test_sync_core_supervisor.py::test_real_linux_authenticated_termination_and_cleanup \ + 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_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 + - name: Validate architecture vs prompt includes (fixture smoke) run: pdd checkup --validate-arch-includes --project-root tests/fixtures/arch_include_validate_ok @@ -274,7 +546,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: '' @@ -291,6 +563,69 @@ 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 + 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 + 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 @@ -302,14 +637,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_1_55_config_suffixes_collect_and_use_config_dir \ + --timeout=90 - name: Verify packaged Vitest grammars offline run: | diff --git a/docs/global_sync_resolution_plan.md b/docs/global_sync_resolution_plan.md index 9b0a7c6af9..0ff11afb45 100644 --- a/docs/global_sync_resolution_plan.md +++ b/docs/global_sync_resolution_plan.md @@ -495,9 +495,39 @@ 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 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 + 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 +1162,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 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 obligations. @@ -1583,6 +1615,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. diff --git a/pdd/commands/sync_core.py b/pdd/commands/sync_core.py index 505a04473b..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__ @@ -166,9 +168,29 @@ 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: + # 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") @@ -194,6 +216,24 @@ 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") + 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, @@ -202,6 +242,8 @@ def _runner_config_from_options( ), vitest_command=protected_vitest, vitest_toolchain_manifest=manifest_path, + playwright_command=protected_playwright, + playwright_toolchain_manifest=playwright_manifest_path, ) if protected_vitest is not None: try: @@ -213,6 +255,26 @@ 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, + 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 @@ -478,6 +540,17 @@ def baseline(ctx: click.Context, module: str, reviewed_by: str, reason: str) -> 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, @@ -487,6 +560,8 @@ def validate( jest_command: str | None, 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.""" @@ -504,6 +579,9 @@ def validate( "vitest_command": vitest_command, "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/__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/evidence_store.py b/pdd/sync_core/evidence_store.py index 8964216e11..f2ef1b9f21 100644 --- a/pdd/sync_core/evidence_store.py +++ b/pdd/sync_core/evidence_store.py @@ -83,6 +83,10 @@ def attestation_payload(envelope: AttestationEnvelope) -> dict[str, Any]: payload["binding"]["adapter_identities"] = [ list(item) for item in binding.adapter_identities ] + if binding.playwright_toolchain_identity is not None: + payload["binding"]["playwright_toolchain_identity"] = ( + binding.playwright_toolchain_identity + ) return payload @@ -127,6 +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") + 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_toolchain_identity must be a non-empty string") binding = AttestationBinding( subject, _string(binding_data, "snapshot_digest"), @@ -136,6 +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_toolchain_identity=toolchain_identity, ) results = tuple( ObligationEvidence( diff --git a/pdd/sync_core/finalize.py b/pdd/sync_core/finalize.py index fe065da16e..a5b7714489 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_toolchain_identity=envelope.binding.playwright_toolchain_identity, ), ), TRUSTED_RUNNER_VERSION, base_sha, envelope.binding.checked_sha, adapter_identities=envelope.binding.adapter_identities, + playwright_toolchain_identity=envelope.binding.playwright_toolchain_identity, ) verifier.verify_current_for_idempotency(envelope, binding, now=now) ancestry = subprocess.run( @@ -325,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/git_io.py b/pdd/sync_core/git_io.py index 2bda0a9583..25059ee1ee 100644 --- a/pdd/sync_core/git_io.py +++ b/pdd/sync_core/git_io.py @@ -44,6 +44,21 @@ 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, + text=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/lifecycle.py b/pdd/sync_core/lifecycle.py index d712ddcbe9..ba3c3055b0 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,238 @@ 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) + 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)", +)) + +_CANDIDATE_TRANSACTION_SOURCE = "\n".join(( + "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])", + "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)", + " 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:", + " 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:", + " pass", + " else:", + " os.killpg(child.pid,signal.SIGKILL)", + " raise RuntimeError('lifecycle child left surviving descendants')", + " 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)", +)) + + +@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: + 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( @@ -184,65 +417,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( - [sys.executable, "-m", "venv", str(environment)], temporary, home - ) - 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), + 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), ) - 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( @@ -307,17 +615,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") ) @@ -345,21 +645,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( @@ -369,7 +676,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/pdd/sync_core/reporting.py b/pdd/sync_core/reporting.py index 6242e95d91..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, @@ -168,6 +179,7 @@ def _evidence( profile, root=context.root, ref=context.manifest.head_ref, config=RunnerConfig( adapter_identities=binding.adapter_identities, + playwright_toolchain_identity=binding.playwright_toolchain_identity, ), ) or binding.tool_version != TRUSTED_RUNNER_VERSION @@ -179,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/pdd/sync_core/runner.py b/pdd/sync_core/runner.py index 497784749c..7f6bf7bc04 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 signal import stat import subprocess @@ -24,11 +25,14 @@ 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 +from tree_sitter import Node from .trust import ( AttestationBinding, @@ -37,18 +41,32 @@ AttestationRequest, ) from .isolation import untrusted_child_environment -from .git_io import read_git_blob, read_git_regular_blob +from .git_io import read_git_blob, read_git_mode, read_git_regular_blob from .types import ( + AssuranceLevel, EvidenceOutcome, ObligationEvidence, UnitId, VerificationObligation, VerificationProfile, ) -from .supervisor import SupervisorLimits, released_runtime_closure_paths, run_supervised +from .supervisor import ( + ImmutableBindingProof, + PlaywrightSnapshotAggregate, + SnapshotBindingProof, + SupervisorLimits, + SupervisorTermination, + TerminationKind, + _vitest_descriptor_attestation, + released_runtime_closure_paths, + run_supervised, +) TRUSTED_RUNNER_VERSION = "pdd-trusted-runner-v2" +_IN_PROCESS_FRAMEWORK_ADAPTERS = frozenset( + {"pytest", "jest", "vitest", "playwright"} +) _VITEST_SUPERVISOR_LIMITS = SupervisorLimits( max_memory_bytes=4 * 1024 * 1024 * 1024 ) @@ -170,11 +188,67 @@ 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 +PLAYWRIGHT_CONFIG_NAMES = ( + "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_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", + "native_runtime", "lockfile", +} +PLAYWRIGHT_SUPERVISOR_LIMITS = SupervisorLimits( + max_memory_bytes=2 * 1024 * 1024 * 1024, + max_virtual_memory_bytes=256 * 1024 * 1024 * 1024, +) + + +@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.browser_runtime, + self.lockfile, + *((self.entrypoint,) + if not _path_is_relative_to(self.entrypoint, self.dependencies) else ()), + ) + + @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 + ) + + +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.""" @@ -185,6 +259,9 @@ 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 + playwright_toolchain_manifest: Path | None = None + playwright_toolchain_identity: str | None = None @dataclass(frozen=True) @@ -831,6 +908,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] = [] @@ -1442,6 +1545,842 @@ def vitest_validator_config_digest( return digest.hexdigest() +_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]: + """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 + 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_RESERVED_PACKAGES: + raise ValueError("Playwright config uses a reserved package self-reference") + return found[0], content + + +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() + 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 + + +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: + 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": + 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") + 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 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") + 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 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: + references.update(_validate_playwright_config_object(path, source, config_node)) + except ValueError as exc: + raise ValueError(f"Playwright configuration is unsupported: {exc}") from exc + 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") + + +def _javascript_parser(path: PurePosixPath): + """Select the grammar matching JavaScript, JSX, TypeScript, or TSX.""" + if path.suffix == ".tsx": + return _vitest_parser("tsx") + if path.suffix in {".ts", ".cts", ".mts"}: + return _vitest_parser("typescript") + return _vitest_parser("javascript") + + +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 _validate_playwright_config_object( + path: PurePosixPath, source: bytes, config: Node +) -> set[PurePosixPath]: + """Apply the explicit root-property allowlist to a parsed config object.""" + allowed_data = { + "timeout", "fullyParallel", "forbidOnly", "outputDir", + "testDir", "testMatch", "testIgnore", "preserveOutput", "quiet", + "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: + 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 _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") + + +_PLAYWRIGHT_UNBOUND_PATH_KEYS = frozenset({ + "path", "pathTemplate", "snapshotPathTemplate", "storageState", + "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, *, 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 + 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 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, top_level=False) + + +def _playwright_support_closure( + 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) + 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} + 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, + 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() + while pending: + path, owners = pending.pop() + known_owners = visited.get(path, frozenset()) + new_owners = owners - known_owners + if not new_owners: + continue + 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()}") + 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 == ".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 + ) + 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)) + 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) + paths.update(_playwright_tree_trust_manifests(root, ref)) + 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) + 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) + 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( + 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: + raise ValueError("Playwright source is not valid JavaScript/TypeScript syntax") + local: set[str] = set() + bare: set[str] = set() + resources: set[str] = set() + snapshot = False + assertion_calls = { + "toBe", "toEqual", "toStrictEqual", "toBeTruthy", "toBeFalsy", + "toContain", "toMatch", "toHaveTitle", "toHaveURL", "toBeVisible", + "toBeHidden", "toHaveText", "toContainText", "toHaveValue", + "toHaveScreenshot", "toMatchSnapshot", "toBeEnabled", "toBeDisabled", + "toBeChecked", "toHaveAttribute", "toHaveClass", "toHaveCount", + "toHaveCSS", "toHaveJSProperty", "toHaveId", "toHaveAccessibleName", + } + test_calls = { + "use", "beforeEach", "afterEach", "beforeAll", "afterAll", "step", + "configure", + } + 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() + 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.""" + 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 + + # 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: + candidate = discovery.pop() + discovery.extend(candidate.named_children) + 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" + ): + 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) + 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: + 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() + 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", "globalThis", "global", + }: + 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") + 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") + 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 "" + 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( + "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 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": + 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": + 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 not is_bound_expect(obj): + raise ValueError( + "Playwright assertion capability is not bound to an imported expect" + ) + receiver_methods = { + "locator": locator_calls, + "frame": frame_calls, + "page": page_calls, + } + 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( + 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" + ) + 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": + 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) + 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 == "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" + ) + 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") + 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") + 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, resources + + +def playwright_validator_config_digest( + 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, code_under_test_paths + ): + digest.update( + path.as_posix().encode() + b"\0" + read_git_mode(root, ref, path).encode() + + b"\0" + content + b"\0" + ) + return digest.hexdigest() + + def _support_digest( root: Path, ref: str, profile: VerificationProfile ) -> tuple[str, tuple[PurePosixPath, ...]]: @@ -1528,6 +2467,30 @@ 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 + ) + 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, product) + 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: @@ -1567,13 +2530,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( @@ -1586,6 +2550,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", @@ -1595,7 +2560,7 @@ def runner_identity_digest( "-q", *PYTEST_PROTECTED_FLAGS, "", - "--junitxml=", + "--junitxml=", ], "pytest_collection_command": [ "", @@ -1619,9 +2584,11 @@ def runner_identity_digest( "", "--config=", "--reporter=json", - "--outputFile=", + "--outputFile=", ], "vitest_environment": {"NODE_ENV": "test"}, + "playwright_command": ["", "", "test", "", "--config=", "--reporter="], + "playwright_environment": {"NODE_ENV": "test"}, "obligations": [ { "id": item.obligation_id, @@ -1644,6 +2611,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( @@ -1810,6 +2778,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, @@ -1910,12 +2890,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( @@ -2007,6 +2986,39 @@ 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") + 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 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, + destination=protected, + descriptor_attestation=attestation, + descriptor_identity=identity, + member_role="native_runtime", + member_path=str(index), + collision_category="vitest_inferred_runtime", + )) + return tuple(proofs) + + def _assert_vitest_members( actual: tuple[VitestToolchainMember, ...], expected: tuple[VitestToolchainMember, ...], @@ -2078,6 +3090,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") } | { @@ -2131,6 +3147,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, @@ -2150,25 +3169,532 @@ def _prepare_vitest_toolchain( return phase -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() - if not path.is_absolute() and "/" not in part: - return part - resolved = (path if path.is_absolute() else root / path).resolve() - return _file_identity(resolved) if resolved.exists() else part +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 _validator_command_identity_digest(root: Path, config: RunnerConfig) -> str: - """Bind explicitly supplied validator commands to signed runner evidence.""" - payload: dict[str, object] = {} - if config.jest_command is not None: - payload["jest"] = [ - _command_part_identity(root, part) for part in config.jest_command - ] - payload["jest_toolchain"] = [ - _validator_tree_identity(path) - for path in _jest_toolchain_roots(config.jest_command) +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, "size": 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" + member["size"] = metadata.st_size + 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, + 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.""" + 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) + + +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]], + 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 | None, +) -> dict[str, object]: + """Build the relocation-stable typed identity payload from snapshot members.""" + 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}, + entrypoint_role, + {"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]], + 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 | 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, + 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], ...], + result_fd: int = 198, +) -> tuple[str, PlaywrightSnapshotAggregate]: + """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), + *( + (("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)), + ) + 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)) + ) + 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"], + 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, "reporter_sha256": reporter_digest, + }, + "members": aggregate_members, + } + attestation = json.dumps(aggregate, sort_keys=True, separators=(",", ":")) + return toolchain_identity, PlaywrightSnapshotAggregate( + attestation=attestation, + digest=hashlib.sha256(attestation.encode()).hexdigest(), + accepted_toolchain_identity=checker_identity, + result_fd=result_fd, + ) + + +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(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(relative + b"\0" + data + b"\0") + return digest.hexdigest() + + +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() + try: + 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) + 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"))) + return digest.digest() + 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 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(), relative / child.name + )) + digest.update(b"\0") + return digest.digest() + raise ValueError("Playwright toolchain special files are unsupported") + except OSError as exc: + raise ValueError("Playwright toolchain member is unreadable") from exc + + +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", "roles"}: + raise ValueError("Playwright toolchain manifest must declare typed roles") + roles = payload.get("roles") + if payload.get("version") != 3 or not isinstance(roles, dict): + raise ValueError("Playwright toolchain manifest roles schema is invalid") + 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") + captured: dict[str, list[dict[str, object]]] = {} + 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") + 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") + _manifest_path_identity(declared, set(), PurePosixPath(role)) + 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") + _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}") + ) + )) + 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: + entrypoint_relative = None + return _playwright_snapshot_toolchain_identity( + captured["launcher"], + captured["dependencies"], + captured["entrypoint"], + captured["browser_runtime"], + tuple(native_members), + captured["lockfile"], + entrypoint_relative, + ) + + +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")) + 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( + 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") + manifest_identity = _toolchain_manifest_identity(manifest) + roles = _toolchain_manifest_roles(manifest) + 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") + 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: + 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() + 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 manifest_identity + + +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() + if not path.is_absolute() and "/" not in part: + return part + resolved = (path if path.is_absolute() else root / path).resolve() + 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] = {} + if config.jest_command is not None: + payload["jest"] = [ + _command_part_identity(root, part) for part in config.jest_command + ] + payload["jest_toolchain"] = [ + _validator_tree_identity(path) + for path in _jest_toolchain_roots(config.jest_command) ] if config.vitest_command is not None: try: @@ -2179,6 +3705,17 @@ 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: + 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"] = identity return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() @@ -2227,6 +3764,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 @@ -2393,11 +3941,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 = { @@ -2630,27 +4178,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 observation descriptor.""" + return f"""const RESULT_FD = {result_fd}; +class PddFrameworkReporter {{ + 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})); } -} -module.exports = PddTrustedReporter; + 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 = PddFrameworkReporter; """ 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.""" + """Validate checker-owned framework observations and normalize non-pass states.""" 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) @@ -2659,8 +4210,8 @@ def _jest_result( for item in tests ): raise ValueError("malformed Jest reporter payload") - except (OSError, ValueError, json.JSONDecodeError, KeyError): - return EvidenceOutcome.COLLECTION_ERROR, "trusted Jest reporter produced malformed JSON", () + except (UnicodeDecodeError, ValueError, json.JSONDecodeError, KeyError): + return EvidenceOutcome.COLLECTION_ERROR, "Jest reporter produced malformed JSON", () identities = tuple(sorted(item["identity"] for item in tests)) if not identities: return ( @@ -2703,7 +4254,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(): @@ -2738,9 +4289,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), @@ -2756,10 +4317,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, @@ -2770,14 +4329,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", @@ -2797,6 +4364,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 bounded observation 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 @@ -2877,54 +4452,48 @@ def _vitest_result( def _vitest_infrastructure_termination( - result: subprocess.CompletedProcess[str], - timeout_seconds: int, + result: subprocess.CompletedProcess[str], timeout_seconds: int, ) -> tuple[EvidenceOutcome, str]: """Describe trusted no-reporter termination without trusting stderr prose.""" termination = getattr(result, "termination", None) - kind = getattr(termination, "kind", None) - kind = getattr(kind, "value", kind) + kind = getattr(getattr(termination, "kind", None), "value", None) if kind is None: - if result.returncode == 124: - kind = "timeout" - elif result.returncode < 0: - kind = "signal" - else: - kind = "exit" + kind = "timeout" if result.returncode == 124 else ( + "signal" if result.returncode < 0 else "exit" + ) fields = ["Vitest infrastructure termination: reporter=missing", f"kind={kind}"] if kind in {"exit", "sandbox-error"}: - exit_code = getattr(termination, "exit_code", None) - fields.append(f"exit_code={result.returncode if exit_code is None else exit_code}") + fields.append(f"exit_code={getattr(termination, 'exit_code', result.returncode)}") elif kind == "signal": - signal_number = getattr(termination, "signal_number", None) - if signal_number is None and result.returncode < 0: - signal_number = -result.returncode + number = getattr(termination, "signal_number", -result.returncode) try: - signal_name = signal.Signals(signal_number).name + name = signal.Signals(number).name except (TypeError, ValueError): - signal_name = "UNKNOWN" - fields.extend((f"signal={signal_name}", f"signal_number={signal_number}")) + name = "UNKNOWN" + fields.extend((f"signal={name}", f"signal_number={number}")) elif kind == "timeout": - measured_timeout = getattr(termination, "timeout_seconds", None) fields.append( - f"timeout_seconds={timeout_seconds if measured_timeout is None else measured_timeout}" + f"timeout_seconds={getattr(termination, 'timeout_seconds', timeout_seconds)}" ) elif kind == "resource-limit": - resource_limit = getattr(termination, "resource_limit", None) or "unknown" - fields.append(f"resource_limit={resource_limit}") - signal_number = getattr(termination, "signal_number", None) - if signal_number is not None: - fields.append(f"signal_number={signal_number}") + 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(diagnostic.encode("utf-8")).hexdigest() - ) - outcome = ( - EvidenceOutcome.TIMEOUT if kind == "timeout" else EvidenceOutcome.ERROR + fields.append("diagnostic_sha256=" + hashlib.sha256( + diagnostic.encode("utf-8") + ).hexdigest()) + return ( + EvidenceOutcome.TIMEOUT if kind == "timeout" else EvidenceOutcome.ERROR, + "; ".join(fields), ) - return outcome, "; ".join(fields) def _vitest_reporter_source(result_fd: int) -> str: @@ -2932,7 +4501,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(); @@ -2953,7 +4522,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 @@ -2967,6 +4536,7 @@ def _drain_result_pipe( except BlockingIOError: continue if not chunk: + finished.wait(.01) continue size += len(chunk) if size > VITEST_RESULT_MAX_BYTES: @@ -3001,7 +4571,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: @@ -3069,6 +4639,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, ) @@ -3101,17 +4672,12 @@ def _run_vitest( if result.returncode in {126, 127} and not output_data: return RunnerExecution("vitest", EvidenceOutcome.ERROR, digest, "Vitest launcher is missing or not executable"), () termination = getattr(result, "termination", None) - termination_kind = getattr(termination, "kind", None) - termination_kind = getattr(termination_kind, "value", termination_kind) + termination_kind = getattr(getattr(termination, "kind", None), "value", None) if termination_kind == "timeout" or result.returncode == 124: - outcome, detail = _vitest_infrastructure_termination( - result, timeout_seconds - ) + outcome, detail = _vitest_infrastructure_termination(result, timeout_seconds) return RunnerExecution("vitest", outcome, digest, detail), () if result.returncode and not output_data: - outcome, detail = _vitest_infrastructure_termination( - result, timeout_seconds - ) + outcome, detail = _vitest_infrastructure_termination(result, timeout_seconds) return RunnerExecution("vitest", outcome, digest, detail), () if not output_data: return RunnerExecution( @@ -3135,102 +4701,1178 @@ def _run_vitest( return RunnerExecution("vitest", outcome, digest, detail), identities -def _run_test_node( - root: Path, - node_id: str, - timeout_seconds: int, -) -> RunnerExecution: - pytest_args = [ - "-q", - *PYTEST_PROTECTED_FLAGS, - node_id, - ] - command = [ - sys.executable, - "-P", - "pdd-trusted-execution-runner", - "import-trusted-pytest", - "pytest.main", - *pytest_args, - ] - command_digest = hashlib.sha256( - json.dumps(command, separators=(",", ":")).encode() - ).hexdigest() - with tempfile.TemporaryDirectory(prefix="pdd-trusted-runner-") as directory: - temporary = Path(directory) - controllers = temporary / f"controller-{os.urandom(16).hex()}" - 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) - worker = _trusted_execution_runner( - controllers, root, [*pytest_args, f"--junitxml={junit}"] - ) - 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,), - ) - if result.returncode == 124: - return RunnerExecution( - node_id, - EvidenceOutcome.TIMEOUT, - command_digest, - "test execution timed out", - ) - if surviving: - return RunnerExecution( - node_id, EvidenceOutcome.ERROR, command_digest, - "validator left a surviving process-group descendant", - ) - output = result.stdout + "\n" + result.stderr - outcome, detail = _junit_outcome(junit, result.returncode, output, 1) - return RunnerExecution(node_id, outcome, command_digest, detail) +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 + return None -def _collect_node_ids( - root: Path, - path: PurePosixPath, - timeout_seconds: int, -) -> tuple[RunnerExecution, tuple[str, ...]]: - # pylint: disable=too-many-locals - """Collect exact pytest node IDs through the protected adapter.""" - with tempfile.TemporaryDirectory(prefix="pdd-trusted-collection-") as directory: - temporary = Path(directory) - controllers = temporary / f"controller-{os.urandom(16).hex()}" - controllers.mkdir(mode=0o700) - home = temporary / "scratch" / "home" - home.mkdir(mode=0o700, parents=True) - pytest_args = [ - "--collect-only", - "-q", - "--strict-config", - "--strict-markers", - "-p", - "no:cacheprovider", - path.as_posix(), - ] - plugin_name, _plugin_directory = _trusted_probe_plugin(controllers) - pytest_args.extend(("-p", plugin_name)) - worker = _trusted_collection_runner(controllers, root, pytest_args) - command = [sys.executable, "-P", str(worker)] - digest = hashlib.sha256( - json.dumps( - { - "argv": [sys.executable, "pdd-trusted-collection-runner"], - "pytest_args": pytest_args, - "probe_path": str(_CHECKER_PYTEST_PROBE), - "probe_sha256": _checker_probe_digest(), - }, - separators=(",", ":"), - sort_keys=True, - ).encode() - ).hexdigest() - result, surviving = _managed_subprocess( - command, cwd=controllers, timeout=timeout_seconds, - env=_pytest_environment(home), writable_roots=(home.parent,), +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" + 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 + + +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_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 "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]: + """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]: + """Return an isolated credential-free environment for Playwright.""" + environment = untrusted_child_environment( + home=home, + extra={"NODE_ENV": "test"}, + drop={"NODE_PATH", "PLAYWRIGHT_BROWSERS_PATH"}, + ) + 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 + + +def _playwright_reporter_source(result_fd: int) -> str: + """Return the checker-owned reporter for the observation descriptor.""" + return f"""const fs = require('fs'); +const path = require('path'); +const RESULT_FD = {result_fd}; +const REPORTER_ERROR = 'invalid_reporter_state'; +const REPORTER_ERROR_REASONS = new Set([ + '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_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) {{ + if (this.reporterError) return; + this.reporterError = REPORTER_ERROR_REASONS.has(reason) + ? reason : 'serialization_failure'; + this.tests = null; + }} + identity(test) {{ + 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 {{reason: 'invalid_title_path'}}; + }} + const [, titleProject, titleFile, ...titles] = titlePath; + 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 {{reason: 'invalid_project_title'}}; + }} + 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'}}; + }} + 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}}`) || basename !== titleFile + || file.includes('::') || /[\\0\\r\\n]/.test(file) || file.length > 4096) {{ + return {{reason: 'invalid_location'}}; + }} + return {{value: `${{project}}::${{file}}::${{titles.join(' > ')}}`}}; + }} + onBegin(suite) {{ + if (this.reporterError) return; + 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 = allTestsFunction.call(suite); + }} catch (_error) {{ + this.invalidate('suite_all_tests_call'); + return; + }} + if (!Array.isArray(allTests)) {{ + this.invalidate('suite_all_tests_call'); + return; + }} + try {{ + const collected = new Map(); + for (const test of allTests) {{ + 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_all_tests_call'); + }} + }} + onTestEnd(test, result) {{ + if (this.reporterError) return; + try {{ + 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)) {{ + 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('invalid_test_result'); + return; + }} + error = result.error.message.slice(0, 4096); + }} + this.tests.set(identity, {{status: result.status, error}}); + }} catch (_error) {{ + this.invalidate('invalid_test_result'); + }} + }} + 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(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}}) + ); + const receipt = JSON.stringify({{pdd_playwright_reporter: 1, tests}}); + 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('write_failure'); + this.writeErrorReceipt(); + }} + }} catch (_error) {{ + this.invalidate('serialization_failure'); + this.writeErrorReceipt(); + }} + }} +}} +module.exports = PddFrameworkReporter; +""" + + +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 observation (exit {result.returncode})" + 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, ...]: + """Return the trusted toolchain argv without checker-injected browser flags.""" + 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) > 2048: + diagnostic = diagnostic[:1021] + "......" + diagnostic[-1012:] + 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, +) -> tuple[EvidenceOutcome, str, tuple[str, ...]]: + """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") + 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") + 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_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: + raise ValueError("malformed Playwright reporter error") + return ( + EvidenceOutcome.COLLECTION_ERROR, + "Playwright reporter rejected an invalid framework observation " + f"({reason})", + (), + ) + 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: + 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 ()) + 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"]) + if not spec_file.is_absolute(): + 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"],)) + 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 all(isinstance(result, dict) for result in results) + ): + raise ValueError("malformed Playwright results") + 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": status, + }) + for child in suite.get("suites", []): + visit(child, next_parents) + 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): + raise ValueError("malformed Playwright reporter payload") + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + 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", () + 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 - {"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_timeout_detail(tests), identities + if returncode or "failed" in statuses or "interrupted" in statuses: + 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"}: + return EvidenceOutcome.SKIP, "Playwright reported skipped protected tests", identities + return EvidenceOutcome.PASS, f"{len(identities)} protected Playwright tests passed", identities + + +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 + metadata = path.lstat() + digest.update(relative.as_posix().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(): + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def _playwright_protected_worktree_identity( + 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, 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(): + 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"): + raise ValueError(f"Playwright closure member mode changed: {path}") + 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, + 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 bounded observation 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, + ), () + 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) + 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: + 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, + "playwright-untrusted", str(exc), + ), () + command_error = _playwright_command_error(root, prefix) + if command_error is not None: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, + "playwright-untrusted", command_error, + ), () + 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 + ) + 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)), () + 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" + 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" + 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, + ).stdout.strip() + 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] + canonical_entrypoint = dependency_destination / roles.entrypoint.relative_to( + roles.dependencies + ) + snapshot_binding_proofs = _playwright_snapshot_binding_proofs( + reporter, + roles, + Path(runtime_prefix[0]), + 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) + ), () + command = [ + 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}", + "--update-snapshots=none", f"--output={scratch / 'results'}", + ] + if collection: + command.append("--list") + 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, + timeout=timeout_seconds, + env=_playwright_environment( + home, + 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, (roles.dependencies, dependency_destination), + ), + snapshot_binding_proofs=snapshot_binding_proofs, + playwright_snapshot_aggregate=_snapshot_aggregate, + limits=PLAYWRIGHT_SUPERVISOR_LIMITS, + result_write_fd=write_fd, + result_fd=result_fd, + ) + os.close(write_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) + try: + _remove_playwright_node_modules_mountpoint(mountpoint_identity) + except ValueError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.ERROR, digest, str(exc) + ), () + 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") + ) + current_commit = subprocess.run( + ["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 current_closure_identity != 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", + ), () + try: + 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", + ), () + except ValueError as exc: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, str(exc) + ), () + if not output: + return RunnerExecution( + "playwright", EvidenceOutcome.COLLECTION_ERROR, digest, + _playwright_missing_result_detail(result), + ), () + outcome, detail, identities = _playwright_result( + root, output.decode("utf-8", errors="replace"), result.returncode, expected, collection + ) + return RunnerExecution( + "playwright", outcome, digest, + _playwright_phase_detail(detail, result, collection), + ), 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, + 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 + 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() + 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", + 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, + code_under_test_paths=code_under_test_paths, + ) + + +def _run_test_node( + root: Path, + node_id: str, + timeout_seconds: int, +) -> RunnerExecution: + pytest_args = [ + "-q", + *PYTEST_PROTECTED_FLAGS, + node_id, + ] + command = [ + sys.executable, + "-P", + "pdd-trusted-execution-runner", + "import-trusted-pytest", + "pytest.main", + *pytest_args, + ] + command_digest = hashlib.sha256( + json.dumps(command, separators=(",", ":")).encode() + ).hexdigest() + with tempfile.TemporaryDirectory(prefix="pdd-trusted-runner-") as directory: + temporary = Path(directory) + controllers = temporary / f"controller-{os.urandom(16).hex()}" + controllers.mkdir(mode=0o700) + home = temporary / "scratch" / "home" + home.mkdir(mode=0o700, parents=True) + 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=/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,), 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, + EvidenceOutcome.TIMEOUT, + command_digest, + "test execution timed out", + ) + if surviving: + return RunnerExecution( + node_id, EvidenceOutcome.ERROR, command_digest, + "validator left a surviving process-group descendant", + ) + output = result.stdout + "\n" + result.stderr + observation_output = drained.get("data", b"") + if "error" in drained or drained.get("overflow") or not isinstance( + observation_output, bytes + ): + return RunnerExecution( + node_id, EvidenceOutcome.COLLECTION_ERROR, command_digest, + "pytest bounded observation transport failed", + ) + outcome, detail = _junit_outcome( + observation_output, result.returncode, output, 1 + ) + return RunnerExecution(node_id, outcome, command_digest, detail) + + +def _collect_node_ids( + root: Path, + path: PurePosixPath, + timeout_seconds: int, +) -> tuple[RunnerExecution, tuple[str, ...]]: + # pylint: disable=too-many-locals + """Collect exact pytest node IDs through the protected adapter.""" + with tempfile.TemporaryDirectory(prefix="pdd-trusted-collection-") as directory: + temporary = Path(directory) + controllers = temporary / f"controller-{os.urandom(16).hex()}" + controllers.mkdir(mode=0o700) + home = temporary / "scratch" / "home" + home.mkdir(mode=0o700, parents=True) + pytest_args = [ + "--collect-only", + "-q", + "--strict-config", + "--strict-markers", + "-p", + "no:cacheprovider", + path.as_posix(), + ] + plugin_name, _plugin_directory = _trusted_probe_plugin(controllers) + pytest_args.extend(("-p", plugin_name)) + worker = _trusted_collection_runner(controllers, root, pytest_args) + command = [sys.executable, "-P", str(worker)] + digest = hashlib.sha256( + json.dumps( + { + "argv": [sys.executable, "pdd-trusted-collection-runner"], + "pytest_args": pytest_args, + "probe_path": str(_CHECKER_PYTEST_PROBE), + "probe_sha256": _checker_probe_digest(), + }, + separators=(",", ":"), + sort_keys=True, + ).encode() + ).hexdigest() + result, surviving = _managed_subprocess( + command, cwd=controllers, timeout=timeout_seconds, + env=_pytest_environment(home), writable_roots=(home.parent,), readable_roots=(root, _CHECKER_PYTEST_PROBE), ) if result.returncode == 124: @@ -3411,6 +6053,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, ...], @@ -3442,7 +6098,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, @@ -3460,6 +6116,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": @@ -3570,7 +6227,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( @@ -3690,6 +6347,23 @@ def _run_vitest_at_commit( ), () +def _collect_playwright_at_base( + 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: + 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, code_under_test_paths=code_under_test_paths, + ) + + def _protected_node_ids( root: Path, obligation: VerificationObligation, @@ -3810,6 +6484,37 @@ 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, + 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, + 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( UnitId("runner-closure", PurePosixPath("closure.prompt"), "python"), (obligation,), @@ -3873,6 +6578,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( @@ -3911,6 +6617,48 @@ 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.COLLECTION_ERROR + if command_error.startswith("Playwright launch") + else EvidenceOutcome.ERROR, + 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( @@ -3950,22 +6698,45 @@ 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, 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 obligation.validator_id in capture_errors - else run_obligation( + if ( + profile.assurance is AssuranceLevel.ISOLATED_BLACK_BOX + and obligation.validator_id in _IN_PROCESS_FRAMEWORK_ADAPTERS + ) + 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: @@ -3978,7 +6749,28 @@ 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 ( + 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) @@ -3991,6 +6783,7 @@ def run_profile( binding.base_sha, binding.head_sha, adapter_identities=config.adapter_identities, + 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/supervisor.py b/pdd/sync_core/supervisor.py index 5d13ed8fc6..b3cb46e2ce 100644 --- a/pdd/sync_core/supervisor.py +++ b/pdd/sync_core/supervisor.py @@ -1,11 +1,15 @@ """Fail-closed OS sandbox and complete process-group supervision.""" -# pylint: disable=too-many-arguments +# pylint: disable=too-many-arguments,too-many-lines,line-too-long from __future__ import annotations import hashlib +import base64 import json +import math import os +import re +import select import shutil import signal import stat @@ -18,7 +22,7 @@ from functools import lru_cache from dataclasses import dataclass from enum import Enum -from pathlib import Path +from pathlib import Path, PurePosixPath import sysconfig @@ -26,8 +30,83 @@ # 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" +_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 +_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" +_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" +_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 +_MAX_CANDIDATE_ENVIRONMENT_VALUE_BYTES = 32 * 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 _TERMINATION_HEADER_PREFIX = b"PDD-TERMINATION-V1 " +_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): + 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) @@ -37,6 +116,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 @@ -51,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.""" @@ -60,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 @@ -93,45 +183,14 @@ def _supervised_result( ) -def _termination_status_header(token: str, returncode: int) -> bytes: - """Encode one fixed-size authenticated nested-process status record.""" - payload = json.dumps( - {"returncode": returncode, "token": token}, - sort_keys=True, - separators=(",", ":"), - ).encode("ascii") - record = _TERMINATION_HEADER_PREFIX + payload + b"\n" - if len(record) > _TERMINATION_HEADER_BYTES: - raise ValueError("protected termination status record is too large") - return record.ljust(_TERMINATION_HEADER_BYTES, b" ") - - -def _authenticated_nested_returncode(header: bytes, token: str) -> int | None: - """Return only a well-formed status bearing the supervisor's secret token.""" - if len(header) != _TERMINATION_HEADER_BYTES or not header.startswith( - _TERMINATION_HEADER_PREFIX - ): - return None - try: - payload = json.loads( - header[len(_TERMINATION_HEADER_PREFIX):].rstrip(b" \n").decode("ascii") - ) - except (UnicodeError, json.JSONDecodeError): - return None - if not isinstance(payload, dict) or set(payload) != {"returncode", "token"}: - return None - returncode = payload["returncode"] - if ( - payload["token"] != token - or isinstance(returncode, bool) - or not isinstance(returncode, int) - or not ( - 0 <= returncode <= 255 - or -getattr(signal, "NSIG", 128) < returncode < 0 - ) - ): - return None - return returncode +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( @@ -140,28 +199,90 @@ 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: - signal_number = -returncode return SupervisorTermination( TerminationKind.SIGNAL, - signal_number=signal_number, + signal_number=-returncode, + resource_telemetry=resource_telemetry, ) - return SupervisorTermination(TerminationKind.EXIT, exit_code=returncode) + return SupervisorTermination( + TerminationKind.EXIT, + exit_code=returncode, + resource_telemetry=resource_telemetry, + ) + + +@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) +class SnapshotBindingProof: + """Complete no-follow tree identity for one read-only helper snapshot.""" + + source: Path + destination: Path + 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: + """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 + member_size: int @dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes class _ExecutableIdentity: - """Immutable identity for one executable admitted across the root boundary.""" + """Immutable identity for one executable crossing the root boundary.""" path: Path stat_identity: tuple[int, int, int, int, int, int] @@ -169,42 +290,1097 @@ class _ExecutableIdentity: require_root: bool = True def payload(self) -> dict[str, object]: - """Return a strict JSON representation for root-side revalidation.""" + """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, + "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: + """Exact privileged executable identities used by one protected run.""" + + bwrap: Path + mount: Path + setpriv: Path + sudo: Path + systemctl: Path + systemd_run: Path + umount: Path + unshare: Path + helper_python: Path + identities: tuple[_ExecutableIdentity, ...] + + +@dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes +class _ScopePlan: + """Immutable transient-scope construction and cleanup state.""" + + unit_name: str + control_directory: Path + helper_source: str + bwrap_argv: tuple[str, ...] + sources: tuple[Path, ...] + staging_targets: tuple[Path, ...] + tools: _TrustedTools + immutable_binding_proofs: tuple[str, ...] = () + launch_payload: dict[str, object] | None = None + + +@dataclass(frozen=True) +class _CandidateRecord: + """Validated terminal status emitted by the privileged direct-child helper.""" + + returncode: int + 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 + + +@dataclass(frozen=True) +class _DescriptorResult: + """One validated terminal Playwright result carried only by helper descriptors.""" + + candidate: _CandidateRecord + stdout: bytes + stderr: bytes + observation: bytes + + +def _descriptor_frame(payload: dict[str, object], maximum: int) -> bytes: + """Encode one canonical bounded protocol frame.""" + encoded = _canonical_json(payload).encode("utf-8") + if len(encoded) > maximum: + raise RuntimeError("protected descriptor frame exceeded limit") + return len(encoded).to_bytes(4, "big") + encoded + + +def _read_descriptor_frame(stream, maximum: int) -> dict[str, object]: + """Read exactly one canonical bounded JSON frame from a helper descriptor.""" + # Exact built-in objects prevent protocol type subclasses from carrying authority. + # pylint: disable=unidiomatic-typecheck + header = stream.read(4) + if len(header) != 4: + raise RuntimeError("protected descriptor transport closed") + size = int.from_bytes(header, "big") + if not 0 < size <= maximum: + raise RuntimeError("protected descriptor frame has invalid size") + encoded = stream.read(size) + if len(encoded) != size: + raise RuntimeError("protected descriptor frame is partial") + 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("utf-8") != encoded: + raise RuntimeError("protected descriptor frame is not canonical") + return payload + + +def _write_descriptor_frame(stream, payload: dict[str, object], maximum: int) -> None: + """Write one canonical protocol frame, failing on a short parent handoff.""" + frame = _descriptor_frame(payload, maximum) + stream.write(frame) + 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: + """Validate the complete helper result before forwarding any observation bytes.""" + # Exact built-in types and all result bindings are part of the authority grammar. + # pylint: disable=too-many-boolean-expressions,unidiomatic-typecheck + expected = { + "kind", "nonce", "aggregate_digest", "candidate", "stdout", "stderr", + "observation", "observation_sha256", "observation_size", + } + if type(payload) is not dict or set(payload) != expected: + raise RuntimeError("protected descriptor result is invalid") + if ( + payload["kind"] != "result" + or payload["nonce"] != nonce + or payload["aggregate_digest"] != aggregate_digest + or type(payload["stdout"]) is not str + or type(payload["stderr"]) is not str + or type(payload["observation"]) is not str + or type(payload["observation_sha256"]) is not str + or type(payload["observation_size"]) is not int + ): + raise RuntimeError("protected descriptor result is invalid") + try: + stdout = base64.b64decode(payload["stdout"], validate=True) + stderr = base64.b64decode(payload["stderr"], validate=True) + observation = base64.b64decode(payload["observation"], validate=True) + except (ValueError, TypeError) as exc: + raise RuntimeError("protected descriptor result is invalid") from exc + if ( + len(stdout) + len(stderr) > maximum + or len(observation) > maximum + or payload["observation_size"] != len(observation) + or hashlib.sha256(observation).hexdigest() != payload["observation_sha256"] + ): + raise RuntimeError("protected descriptor result is invalid") + return _DescriptorResult(_load_candidate_record_payload(payload["candidate"]), stdout, stderr, observation) + + +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 _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 None + try: + before = os.fstat(descriptor) + if ( + not stat.S_ISREG(before.st_mode) + or stat.S_IMODE(before.st_mode) != mode + ): + return None + actual = hashlib.sha256() + while chunk := os.read(descriptor, 1024 * 1024): + actual.update(chunk) + 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, + ) + if not stable or actual.hexdigest() != digest: + return None + return before.st_size + except OSError: + return None + finally: + os.close(descriptor) + + +def _validate_immutable_binding_proof( + proof: ImmutableBindingProof, +) -> _ValidatedBindingProof: + """Validate exact proof authority, topology, member, and live identities.""" + # pylint: disable=too-many-locals,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"] + 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, copied_size, + ) + except (AttributeError, json.JSONDecodeError, OSError, TypeError, ValueError) as exc: + 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", "size", "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") + 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") + except (AttributeError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + 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", "checker_identity", "observation", "members", } + valid_authority = valid_payload and ( + payload["schema"] == _PLAYWRIGHT_AGGREGATE_SCHEMA + and payload["checker_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 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") + 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 + + +def _snapshot_staging_bytes( + sources: list[Path], snapshot_records: list[str], + immutable_records: tuple[tuple[int, _ValidatedBindingProof], ...] = (), +) -> int: + """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 ( # 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 + 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: + 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") + +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","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 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)) + 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) + 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==".": 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() + 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"] or metadata.st_size!=member["size"]: _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"] 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) +'''.strip() + + +_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 _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 04096: + _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,uid=None,gid=None): + 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() + if uid is not None and (before.st_uid!=uid or before.st_gid!=gid): + _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,candidate_uid,candidate_gid): + 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(candidate_uid) is not int or not 0=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.fchown(target_fd,candidate_uid,candidate_gid) + 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"],member["mode"],candidate_uid,candidate_gid) + 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" + + +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: + raise RuntimeError("invalid protected scope unit name") + return unit_name + + +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 executable fd and reject mutable path ancestry.""" +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 | os.O_NOFOLLOW + resolved, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0) ) metadata = os.fstat(descriptor) - except OSError as exc: - raise RuntimeError("protected sandbox requires a trusted root-owned executable") from exc - 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 - ): - if descriptor is not None: - os.close(descriptor) - raise RuntimeError("protected sandbox requires a trusted root-owned executable") - digest = hashlib.sha256() - try: - for chunk in iter(lambda: os.read(descriptor, 1024 * 1024), b""): + 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: @@ -225,32 +1401,11 @@ def _executable_identity( ) if measured != final: raise RuntimeError("protected executable identity changed during measurement") - return _ExecutableIdentity( - resolved, measured, - digest.hexdigest(), require_root, - ) - - -def _validate_trusted_executable_chain(path: Path) -> None: - """Require every resolved ancestor to be immutable to unprivileged users.""" - 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") + return _ExecutableIdentity(resolved, measured, digest.hexdigest(), require_root) def _revalidate_executable(expected: _ExecutableIdentity) -> None: - """Fail if an admitted executable changed since its trust measurement.""" + """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: @@ -259,103 +1414,63 @@ def _revalidate_executable(expected: _ExecutableIdentity) -> None: raise RuntimeError("protected executable identity changed") -def _trusted_tool(name: str) -> _ExecutableIdentity: - """Resolve one tool once and bind its root-owned executable identity.""" - value = shutil.which(name) +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("protected sandbox requires a trusted root-owned executable") + raise RuntimeError(f"protected sandbox requires trusted {name}") return _executable_identity(Path(value)) def _trusted_helper_python() -> _ExecutableIdentity: - """Select a system Python whose ownership is independent of the candidate.""" - candidates = (Path("/usr/bin/python3"), Path("/bin/python3")) - for candidate in candidates: - if candidate.exists(): - try: - return _executable_identity(candidate) - except RuntimeError: - continue - return _trusted_tool("python3") + """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_helper_runtime_roots( - identity: _ExecutableIdentity, -) -> tuple[Path, ...]: - """Return the minimal immutable stdlib root needed for Python startup.""" - version = identity.path.name.removeprefix("python") - if ( - version.count(".") != 1 - or not all(part.isdigit() for part in version.split(".")) - ): - raise RuntimeError("protected helper Python version is not identity-bound") - try: - encodings = ( - identity.path.parent.parent / "lib" / f"python{version}" / "encodings" - ).resolve(strict=True) - metadata = encodings.lstat() - _validate_trusted_executable_chain(encodings / "__init__.py") - init_metadata = (encodings / "__init__.py").lstat() - except OSError as exc: - raise RuntimeError("protected helper Python runtime 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 helper Python runtime is not immutable") - if ( - not stat.S_ISREG(init_metadata.st_mode) - or stat.S_ISLNK(init_metadata.st_mode) - or init_metadata.st_uid != 0 - or init_metadata.st_mode & 0o022 - ): - raise RuntimeError("protected helper Python runtime is not immutable") - return (encodings,) +def _trusted_tools() -> _TrustedTools: + """Resolve the complete privileged toolchain once for probe and execution.""" + 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 _privileged_helper_environment( - _candidate_environment: dict[str, str], -) -> dict[str, str]: - """Return a constant environment that cannot load candidate Python hooks.""" - return { - "HOME": "/root", "LANG": "C", "LC_ALL": "C", - "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", - } +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 _revalidate_privileged_command(argv: list[str]) -> None: - """Revalidate every bound executable immediately before sudo execution.""" - try: - manifest = json.loads(argv[-3]) - expected_names = { - "sudo", "unshare", "python", "mount", "umount", "bwrap", "setpriv", - "sh", "xargs", "env", - } - if not isinstance(manifest, dict) or set(manifest) != expected_names: - raise ValueError("invalid executable manifest") - for payload in manifest.values(): - identity = _ExecutableIdentity( - Path(payload["path"]), - ( - payload["device"], payload["inode"], payload["mode"], - payload["uid"], payload["size"], payload["mtime_ns"], - ), - payload["sha256"], True, - ) - _revalidate_executable(identity) - except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: - raise RuntimeError("protected executable identity manifest is invalid") from exc +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, ...]: """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() @@ -543,27 +1658,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"), - "unshare": shutil.which("unshare"), - "mount": shutil.which("mount"), - "umount": shutil.which("umount"), - } - for name, value in sandbox_commands.items(): - if value: - path = Path(value).resolve() - entries[f"sandbox/{name}"] = path - native.add(path) + sandbox_commands = {} if sys.platform.startswith("linux"): - try: - helper_python = _trusted_helper_python().path - except RuntimeError: - helper_python = None - if helper_python is not None: - entries["sandbox/python-helper"] = helper_python - native.add(helper_python) + for name in ( + "bwrap", "mount", "setpriv", "sudo", "systemctl", "systemd-run", + "umount", "unshare", + ): + if shutil.which(name, path=_TRUSTED_ROOT_PATH): + sandbox_commands[name] = _trusted_executable(name).path + for name, value in sandbox_commands.items(): + 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): @@ -588,8 +1694,10 @@ def _runtime_roots(command: list[str], cwd: Path) -> tuple[Path, ...]: roots.add(executable) if not resolved_executable.is_relative_to(cwd): roots.add(resolved_executable) - for label, path in released_runtime_closure_paths(): - if label.startswith("sandbox/") or any( + for _label, path in released_runtime_closure_paths(): + if path.name in { + "bwrap", "setpriv", "sudo", "systemctl", "systemd-run", "mount", "umount", + } or any( path.is_relative_to(directory) for directory in directories ): continue @@ -611,268 +1719,774 @@ def _sandbox_library_path(environment: dict[str, str]) -> str: return os.pathsep.join(dict.fromkeys(directories)) -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]];" - "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)" - ) - return [str(_SUPERVISOR_EXECUTABLE), "-c", script, str(limits.max_memory_bytes), - str(limits.max_cpu_seconds), str(limits.max_processes), - str(limits.max_output_bytes), "256", *command] +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 _candidate_environment_launcher() -> str: - """Return the post-uid-drop launcher that preserves exact child status.""" - return "\n".join(( - "import os,sys", - "fd=os.open(sys.argv[1],os.O_RDONLY|os.O_CLOEXEC|os.O_NOFOLLOW)", - "try:", - " chunks=[]", - " while True:", - " chunk=os.read(fd,1024*1024)", - " if not chunk: break", - " chunks.append(chunk)", - "finally: os.close(fd)", - "items=b''.join(chunks).split(b'\\0')", - "boundary=next((i for i,item in enumerate(items) if b'=' not in item),None)", - "if boundary is None: raise RuntimeError('candidate command missing')", - "environment={}", - "for item in items[:boundary]:", - " key,value=item.split(b'=',1)", - " key=key.decode('utf-8');value=value.decode('utf-8')", - " if not key or key in environment: raise RuntimeError('invalid candidate environment')", - " environment[key]=value", - "command=[item.decode('utf-8') for item in items[boundary:]]", - "if not command or not command[0]: raise RuntimeError('candidate command missing')", - "os.execve(command[0],command,environment)", - )) - - -def _inner_status_supervisor() -> str: - """Return the root-side sandbox helper that observes candidate wait status.""" - return "\n".join(( - "import os,sys", - "path=sys.argv[1]", - "status_fd=os.open(path,os.O_WRONLY|os.O_CLOEXEC|os.O_NOFOLLOW)", - "os.unlink(path)", - "command=sys.argv[2:]", - "if not command: raise RuntimeError('candidate command missing')", - "pid=os.fork()", - "if pid==0:", - " try:", - " os.setsid()", - " os.execv(command[0],command)", - " except OSError: os._exit(127)", - "pid,wait_status=os.waitpid(pid,os.WUNTRACED)", - "stopped=os.WIFSTOPPED(wait_status)", - "if os.WIFSTOPPED(wait_status):", - " status=-os.WSTOPSIG(wait_status)", - " os.killpg(pid,9)", - " os.waitpid(pid,0)", - "elif os.WIFSIGNALED(wait_status): status=-os.WTERMSIG(wait_status)", - "elif os.WIFEXITED(wait_status): status=os.WEXITSTATUS(wait_status)", - "else: raise RuntimeError('unrecognized candidate status')", - "payload=f'{status}\\n'.encode('ascii')", - "try:", - " remaining=memoryview(payload)", - " while remaining: remaining=remaining[os.write(status_fd,remaining):]", - "finally: os.close(status_fd)", - "if stopped: raise SystemExit(125)", - "if status<0: raise SystemExit(128-status)", - "raise SystemExit(status)", - )) - - -def _inner_status_record_parser() -> str: - """Return helper code accepting exactly one canonical bounded status record.""" - return "\n".join(( - "inner_status=None", - "try:", - " parsed_status=int(inner_record.decode('ascii').removesuffix('\\n'))", - " if inner_record!=f'{parsed_status}\\n'.encode('ascii'):", - " raise ValueError('noncanonical inner status')", - " if not (0<=parsed_status<=255 or -signal.NSIG str: - """Return helper code that observes exits, signals, and stops without hanging.""" - return "\n".join(( - "process=subprocess.Popen(argv,env=helper_env)", - "pid,wait_status=os.waitpid(process.pid,os.WUNTRACED)", - "if os.WIFSTOPPED(wait_status):", - " status=-os.WSTOPSIG(wait_status)", - " os.kill(process.pid,signal.SIGKILL)", - " os.waitpid(process.pid,0)", - "elif os.WIFSIGNALED(wait_status): status=-os.WTERMSIG(wait_status)", - "elif os.WIFEXITED(wait_status): status=os.WEXITSTATUS(wait_status)", - "else: raise RuntimeError('unrecognized protected subprocess status')", - "process.returncode=status", - )) +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({ + "LANG": "C", + "LC_ALL": "C", + "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 _subprocess_status_handoff() -> str: - """Return helper code that safely re-emits a trusted nested status.""" - return "\n".join(( - "if status<0:", - " observed_signal=-status", - " stopping={signal.SIGSTOP,signal.SIGTSTP,signal.SIGTTIN,signal.SIGTTOU}", - " if observed_signal in stopping: raise SystemExit(125)", - " if observed_signal!=signal.SIGKILL:", - " signal.signal(observed_signal,signal.SIG_DFL)", - " os.kill(os.getpid(),observed_signal)", - " raise RuntimeError('subprocess signal handoff returned')", - "raise SystemExit(status)", - )) +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 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]));" + "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", + candidate_environment, *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)", + "pid=os.fork()", + "if pid==0:", + " 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):", + " 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], - tools: dict[str, _ExecutableIdentity], *, candidate_command: list[str], - candidate_uid: int, candidate_gid: int, termination_token: str, -) -> list[str]: - """Stage exact bind mounts wholly inside a private mount namespace.""" + 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, ...], + playwright_aggregate_record: str | None, + candidate_identity: str, + 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 + 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" + authority_root = control_directory / "authority" + descriptor_protocol = playwright_aggregate_record is not None + 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,os,pathlib,shutil,signal,stat,subprocess,sys,tempfile", - "helper_env={'HOME':'/root','LANG':'C','LC_ALL':'C'," - "'PATH':'/usr/sbin:/usr/bin:/sbin:/bin'}", - "os.environ.clear(); os.environ.update(helper_env)", - "candidate_command=json.loads(sys.argv[1]); uid=int(sys.argv[2]); gid=int(sys.argv[3])", - "termination_token=sys.argv[4]", - "if len(termination_token)!=32 or any(c not in '0123456789abcdef' " - "for c in termination_token): raise RuntimeError('invalid termination token')", - "manifest=json.loads(sys.argv[5]); argv=json.loads(sys.argv[6]); " - "paths=json.loads(sys.argv[7])", - "def verify_chain(path):", + "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):", + " encoded=json.dumps(payload,sort_keys=True,separators=(',',':')).encode('utf-8')", + " if len(encoded)>maximum: raise RuntimeError('protected descriptor frame exceeded limit')", + " 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:", + " 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_fd,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 06 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')", + " 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')", + " proof_by_index={}; snapshot_by_index={}", + " for encoded in proof_records:", + " try: preliminary=json.loads(encoded); schema=preliminary['schema']", + " except (TypeError,ValueError,KeyError): _immutable_failure()", + " if schema=='pdd-playwright-snapshot-aggregate-record-v1':", + " if playwright_record!='': _snapshot_failure()", + " playwright_record=encoded; anonymous_observation=True; continue", + " try: source_index=preliminary['source_index']", + " except (TypeError,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 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): " + "raise RuntimeError('invalid protected FIFO staging protocol')", + " for index,(source,target) in enumerate(zip(paths,targets)):", + " metadata=pathlib.Path(source).lstat()", + " 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)", + " 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'])", + " 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 index,(source,target) in enumerate(zip(paths,targets)):", + " 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:", + " 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]", + " argv=[termination_token if value=='@PDD-TERMINATION-TOKEN@' else 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)", + " 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(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()", + " os.set_blocking(status_read,False)", + " verify_tool('bwrap'); verify_tool('setpriv')", + " if descriptor_protocol:", + " candidate_stdout_read,candidate_stdout_write=os.pipe(); candidate_stderr_read,candidate_stderr_write=os.pipe()", + " def drain_candidate(fd,chunks):", + " global candidate_output_size,candidate_output_overflow", + " while True:", + " chunk=os.read(fd,1048576)", + " if not chunk: break", + " 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)]", + " pid=os.fork()", + " 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", + " if descriptor_protocol:", + " os.close(candidate_stdout_read); os.close(candidate_stderr_read)", + " null=os.open('/dev/null',os.O_RDONLY); os.dup2(null,0); os.close(null) if null!=0 else None", + " os.dup2(candidate_stdout_write,1); os.dup2(candidate_stderr_write,2)", + " 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)", + " 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():", + " poller=select.poll(); poller.register(protocol_in.fileno(),select.POLLHUP|select.POLLERR)", + " while not parent_watch_done.is_set():", + " if any(mask&(select.POLLHUP|select.POLLERR) for _fd,mask in poller.poll(100)):", + " 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)", + " 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'])", + " kill_candidate_leaf()", + " if descriptor_protocol:", + " [thread.join(timeout=limits['trusted_timeout']) for thread in candidate_output_threads]", + " if any(thread.is_alive() for thread in candidate_output_threads) or candidate_output_overflow: raise RuntimeError('protected candidate output relay failed')", + " 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 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'],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)", + " 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)", + " if not descriptor_protocol:", + " record={'version':1,'state':'terminal','returncode':result," + "'timed_out':timed_out}", + " candidate=control/'candidate.tmp'", + " 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')", + " 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 'status_read' in locals(): os.close(status_read)", + " if pid is not None and result is None:", + " try: os.kill(pid,9)", + " except ProcessLookupError: pass", + " try: os.waitpid(pid,0)", + " except ChildProcessError: pass", " for target in reversed(staged):", - " umount=verify('umount')", - " subprocess.run([umount,str(target)],check=False)", - " shutil.rmtree(base,ignore_errors=True)", - "payload=json.dumps({'returncode':status,'token':termination_token}," - "sort_keys=True,separators=(',',':')).encode('ascii')", - "record=termination_prefix+payload+b'\\n'", - "if len(record)>termination_header_bytes: raise RuntimeError('termination record too large')", - "written=os.pwrite(2,record.ljust(termination_header_bytes,b' '),0)", - "if written!=termination_header_bytes: raise RuntimeError('short termination record')", - "os.fsync(2)", - *_subprocess_status_handoff().splitlines(), + " 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 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 scope_cgroup is not None:", + " try:", + " (scope_cgroup/'cgroup.subtree_control').write_text(" + "'-memory -pids',encoding='ascii')", + " (scope_cgroup/'cgroup.procs').write_text(str(os.getpid()),encoding='ascii')", + " if monitor_cgroup is not None: monitor_cgroup.rmdir()", + " except OSError as error: cleanup_error=str(error)", + "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))", )) - manifest = json.dumps({name: identity.payload() for name, identity in tools.items()}) - return [ - str(tools["sudo"].path), "-n", "--", str(tools["unshare"].path), - "--mount", "--propagation", "private", "--wd", "/", - str(tools["python"].path), "-I", "-S", "-c", helper, - json.dumps(candidate_command), str(candidate_uid), str(candidate_gid), - termination_token, manifest, - json.dumps(argv), json.dumps([str(path) for path in sources]), + 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, launch_payload, + ) + command = [ + 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(tools.helper_python), "-I", "-S", "-c", helper, ] + return command, plan -def _private_result_command( - command: list[str], result_fifo: Path, result_fd: int, +def _framework_observation_command( + command: list[str], result_fd: int, source_path: Path, ) -> list[str]: - """Open and unlink a checker FIFO before candidate code can execute.""" + """Open the namespace-local standard-framework observation channel.""" script = ( "import os,sys;" "path=sys.argv[1];target=int(sys.argv[2]);" - "source=os.open(path,os.O_WRONLY);os.unlink(path);" + "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(result_fifo), str(result_fd), *command] + 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.""" @@ -888,10 +2502,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: @@ -903,29 +2520,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"): @@ -939,6 +2533,122 @@ def _process_identity(pid: int) -> str | None: return None +def _load_candidate_record(path: Path) -> _CandidateRecord: + """Read one strict bounded terminal record from the protected helper.""" + try: + encoded = path.read_bytes() + if len(encoded) > 4096: + raise RuntimeError("protected candidate record is invalid") + 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 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"] + 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) + and -255 <= returncode <= 255 + ) + 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 _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) + 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 _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({ + key: payload[key] + for key in ("version", "state", "returncode", "timed_out") + }) + 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() @@ -954,71 +2664,489 @@ 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"} + + +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], + 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]: + """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", "Delegate", "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 = {} + 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 + + +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]]: + # 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": "infinity", "MemorySwapMax": "infinity", + "TasksMax": "infinity", "OOMPolicy": "continue", + "KillMode": "control-group", "Delegate": "yes", + } + 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)) + 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 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 + ): + 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), + } + 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) + + 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 + 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: + 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: + if absent(): + 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 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]: + """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 as exc: + raise RuntimeError("protected mount table is unavailable") from exc + 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 + 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): + 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(), + limits: SupervisorLimits = SupervisorLimits(), + candidate_timeout: float = 300, readable_roots: tuple[Path, ...] = (), 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, - termination_token: str | None = None, -) -> tuple[list[str], Path | None]: - # pylint: disable=too-many-locals,too-many-branches + 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, +) -> 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": raise RuntimeError( "unsupported protected sandbox: macOS cannot prove process lifetime isolation" ) if sys.platform.startswith("linux"): - if os.getuid() == 0: + 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() + 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" ) - tools = { - "sudo": _trusted_tool("sudo"), - "unshare": _trusted_tool("unshare"), - "python": _trusted_helper_python(), - "mount": _trusted_tool("mount"), - "umount": _trusted_tool("umount"), - "bwrap": _trusted_tool("bwrap"), - "setpriv": _trusted_tool("setpriv"), - "sh": _trusted_tool("sh"), - "xargs": _trusted_tool("xargs"), - "env": _trusted_tool("env"), - } - if subprocess.run( - [str(tools["sudo"].path), "-n", "true"], - capture_output=True, check=False, - ).returncode != 0: + if not 0 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", "--die-with-parent", "--new-session", "--tmpfs", "/", "--proc", "/proc", "--dir", "/tmp", - "--dir", "/run"] + "--dir", "/sys", "--dir", "/sys/fs", "--dir", "/sys/fs/cgroup"] sources: list[Path] = [] + path_tokens: list[str] = [] + writable_specs: list[tuple[str, int, str]] = [] + deferred_readable_mounts: list[str] = [] destination_dirs = {Path("/tmp")} - def bind(option: str, source: Path, destination: Path | None = None) -> None: - destination = destination or source + 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] = {} + 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 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: + _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: + 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 (AttributeError, OSError, TypeError, ValueError) as exc: + raise RuntimeError( + "protected sandbox immutable binding proof is malformed" + ) from exc + 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] = [] + accepted_immutable_records: list[tuple[int, _ValidatedBindingProof]] = [] + accepted_snapshots: list[str] = [] + accepted_snapshot_details: dict[str, tuple[int, Path]] = {} + + 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: + 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, None + raise RuntimeError("writable source is outside bounded storage") + source_index = len(sources) + sources.append(source) + path_tokens.append(token) + return token, source_index + + def ensure_destination_parent(destination: Path) -> None: missing = [] parent = destination.parent while parent != Path("/") and parent not in destination_dirs: @@ -1027,217 +3155,878 @@ def bind(option: str, source: Path, destination: Path | None = None) -> None: for directory in reversed(missing): argv.extend(("--dir", str(directory))) destination_dirs.add(directory) - sources.append(source) - argv.extend((option, f"@FD:{len(sources) - 1}@", str(destination))) + + def bind( + option: str, source: Path, destination: Path | None = None, *, + category: str, defer_mount: bool = False, + ) -> None: + destination = destination or source + resolved_source = source.resolve() + previous = mounted.get(destination) + if previous is not None and previous[:2] == (option, resolved_source): + return + 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 + ): + 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_immutable_records.append((previous[3], proof)) + 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 + 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}" + ) + 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, + })) + accepted_snapshot_details[snapshot.attestation] = ( + source_index, destination + ) + mounted[destination] = ( + option, resolved_source, category, source_index + ) + ensure_destination_parent(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) + + for source, destination in writable_bindings: + bind("--bind", source.resolve(), destination, category="writable") + for source, destination in readable_bindings: + bind( + "--ro-bind", source.resolve(), destination, + category="declared_readable", defer_mount=True, + ) 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) - # ``setpriv`` executes after the namespace root is installed, so bind - # it and its ELF closure directly even when PATH resolution differs. - for tool_name in ("setpriv", "python", "sh", "xargs", "env"): - tool_path = tools[tool_name].path - for item in (tool_path, *_linked_libraries(tool_path)): - bind("--ro-bind", item.resolve(), item) - for item in _trusted_helper_runtime_roots(tools["python"]): - bind("--ro-bind", item.resolve(), item) + 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 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. + 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()) - for source, destination in readable_bindings: - bind("--ro-bind", source.resolve(), destination) + 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): + 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, "observation", len(sources) + ) + ensure_destination_parent(_FRAMEWORK_OBSERVATION_PATH) + argv.extend( + ( + "--bind", + stage_source(observation_source)[0], + str(_FRAMEWORK_OBSERVATION_PATH), + ) + ) argv.extend(("--dev", "/dev")) for item in writable_roots: - 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()) + bind("--bind", item.resolve(), category="writable") argv.extend(("--chdir", str(workdir))) - sandboxed = _limited_command(command, limits) + drop = ( + [str(tools.setpriv), "--reuid", str(candidate_uid), + "--regid", str(candidate_gid), + "--clear-groups", "--"] + ) + 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 = _private_result_command(sandboxed, result_fifo, result_fd) - drop = [ - setpriv, "--reuid", str(os.getuid()), "--regid", str(os.getgid()), - "--clear-groups", "--", str(tools["python"].path), "-I", "-S", - "-c", _candidate_environment_launcher(), "@PDD-CANDIDATE-ENV@", - ] - inner = [ - str(tools["python"].path), "-I", "-S", "-c", - _inner_status_supervisor(), "/run/pdd-termination/status", *drop, - ] - separator = len(argv) - argv.extend(("--", *inner)) - argv[separator:separator] = [ - "--bind", "@PDD-TERMINATION-DIR@", "/run/pdd-termination", - ] + 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 + ) + 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@", "/sys/fs/cgroup", *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, tools, candidate_command=sandboxed, - candidate_uid=os.getuid(), candidate_gid=os.getgid(), - termination_token=termination_token or uuid.uuid4().hex, - ), None + 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 ( + Path(tempfile.gettempdir()) / f"pdd-scope-{uuid.uuid4().hex}" + ), + limits=limits, candidate_timeout=candidate_timeout, tools=tools, + observation_nonce=observation_nonce, + staging_bytes=_snapshot_staging_bytes( + sources, accepted_snapshots, tuple(accepted_immutable_records) + ), + ) raise RuntimeError("unsupported sandbox platform or mechanism") +def _write_all_descriptor_bytes( + descriptor: int, data: bytes, deadline: float, +) -> None: + """Forward one validated observation without accepting partial writes.""" + _write_descriptor_bytes(descriptor, data, deadline) + + +def _run_playwright_descriptor_supervised( + command: list[str], *, cwd: Path, timeout: int, env: dict[str, str], + writable_roots: tuple[Path, ...], limits: SupervisorLimits, + readable_roots: tuple[Path, ...], + readable_bindings: tuple[tuple[Path, Path], ...], + immutable_binding_proofs: tuple[ImmutableBindingProof, ...], + snapshot_binding_proofs: tuple[SnapshotBindingProof, ...], + playwright_snapshot_aggregate: PlaywrightSnapshotAggregate, + writable_bindings: tuple[tuple[Path, Path], ...], + temp_directory: Path | None, result_write_fd: int, result_fd: int, +) -> tuple[SupervisedCompletedProcess, bool]: + """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 = _fresh_supervision_token() + nonce = os.urandom(32).hex() + diagnostics = bytearray() + helper_stderr = bytearray() + process: subprocess.Popen[bytes] | None = None + stderr_reader: threading.Thread | None = None + plan: _ScopePlan | None = None + cgroup: Path | None = None + memory_before: dict[str, int] = {} + pids_before: dict[str, int] = {} + timed_out = False + candidate_returncode = 125 + failed_closed = False + resource_limit: str | None = None + resource_telemetry: CgroupResourceTelemetry | None = None + phase = "construction" + candidate_stdout = b"" + candidate_stderr = b"" + + 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_stderr(stream) -> None: + while chunk := stream.read(65536): + remaining = max(0, limits.max_output_bytes - len(helper_stderr)) + helper_stderr.extend(chunk[:remaining]) + + try: + endpoint = os.fstat(result_write_fd) + if not stat.S_ISFIFO(endpoint.st_mode) or os.get_inheritable(result_write_fd): + raise RuntimeError("invalid anonymous observation endpoint") + with tempfile.TemporaryDirectory(prefix="pdd-scope-") as control_value: + control = Path(control_value) + argv, plan = _sandbox_command( + command, writable_roots, cwd=cwd, limits=limits, + readable_roots=readable_roots, 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_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) + phase = "launch" + process = subprocess.Popen( + argv, cwd=Path("/"), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=_privileged_helper_environment(), + start_new_session=True, + ) + assert process.stdin is not None and process.stdout is not None and process.stderr is not 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, + ) + 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_fd( + process.stdin.fileno(), {"kind": "start", "nonce": nonce}, + _DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, + time.monotonic() + _TRUSTED_COMMAND_SECONDS, + ) + phase = "candidate-execution" + 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( + payload, nonce, playwright_snapshot_aggregate.digest, + limits.max_output_bytes, + ) + candidate_returncode = result.candidate.returncode + timed_out = result.candidate.timed_out + candidate_stdout = result.stdout + 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. + resource_telemetry = _cgroup_resource_telemetry( + memory_before, + _cgroup_events(cgroup, "memory.events"), + pids_before, + _cgroup_events(cgroup, "pids.events"), + ) + if max( + resource_telemetry.memory_oom_delta, + resource_telemetry.memory_oom_kill_delta, + ) > 0: + resource_limit = "memory" + elif resource_telemetry.pids_max_delta > 0: + resource_limit = "pids" + 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( + _canonical_json(payload).encode("utf-8") + ).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 + ) + if process.returncode != expected_helper_exit: + raise RuntimeError("protected descriptor helper exit status mismatch") + except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc: + failed_closed = True + add_diagnostic(f"protected supervisor phase={phase}: {exc}\n") + finally: + if process is not None and process.poll() is None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + if plan is not None: + try: + _stop_scope(plan.unit_name, plan.tools) + except RuntimeError as exc: + failed_closed = True + add_diagnostic(f"protected supervisor phase=scope-cleanup: {exc}\n") + if process is not None: + 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") + if plan is not None: + try: + _cleanup_staging(plan) + except RuntimeError as exc: + failed_closed = True + add_diagnostic(f"protected supervisor phase=mount-cleanup: {exc}\n") + for thread in (stderr_reader,): + if thread is not None: + thread.join(timeout=1) + if thread.is_alive(): + failed_closed = True + add_diagnostic("protected descriptor transport did not close\n") + return _supervised_result( + command, + 125 if failed_closed else (124 if timed_out else candidate_returncode), + candidate_stdout[:limits.max_output_bytes].decode("utf-8", errors="replace"), + (candidate_stderr + bytes(helper_stderr) + bytes(diagnostics))[:limits.max_output_bytes].decode( + "utf-8", errors="replace" + ), + ( + 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, + resource_telemetry=resource_telemetry, + ) + ), + ), False + + 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], ...] = (), + 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[SupervisedCompletedProcess, bool]: - """Run sandboxed and terminate marked descendants across session changes.""" - # pylint: disable=consider-using-with,too-many-locals,too-many-branches,too-many-statements - termination_token = uuid.uuid4().hex - try: - argv, profile = _sandbox_command( - command, writable_roots, cwd=cwd, writable_files=writable_files, +) -> tuple[subprocess.CompletedProcess[str], bool]: + """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,too-many-return-statements + if playwright_snapshot_aggregate is not None: + if result_write_fd is None or result_fifo is not None: + 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, readable_bindings=readable_bindings, - result_fifo=result_fifo, result_fd=result_fd, - termination_token=termination_token, + immutable_binding_proofs=immutable_binding_proofs, + snapshot_binding_proofs=snapshot_binding_proofs, + playwright_snapshot_aggregate=playwright_snapshot_aggregate, + writable_bindings=writable_bindings, temp_directory=temp_directory, + result_write_fd=result_write_fd, result_fd=result_fd, ) - _revalidate_privileged_command(argv) - except RuntimeError as exc: - return _supervised_result( - command, - 125, - "", - str(exc), - SupervisorTermination(TerminationKind.SANDBOX_ERROR, exit_code=125), - ), False - token = uuid.uuid4().hex - stdout_file = tempfile.TemporaryFile(mode="w+b") - stderr_file = tempfile.TemporaryFile(mode="w+b") - 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()), - } - library_path = _sandbox_library_path(env) - if library_path: - sandbox_environment["LD_LIBRARY_PATH"] = library_path - process = subprocess.Popen( - argv, cwd=Path("/"), stdin=subprocess.PIPE, - stdout=stdout_file, stderr=stderr_file, - env=_privileged_helper_environment(sandbox_environment), - start_new_session=True, - ) - try: - if process.stdin is None: - raise RuntimeError("protected helper environment channel is unavailable") - process.stdin.write(json.dumps(sandbox_environment).encode("utf-8")) - process.stdin.close() - process.stdin = None - except (BrokenPipeError, OSError, RuntimeError) as exc: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait() - stdout_file.close() - stderr_file.close() - return _supervised_result( - command, - 125, - "", - f"protected helper startup failed: {exc}", - SupervisorTermination(TerminationKind.SANDBOX_ERROR, exit_code=125), - ), False - timed_out = False + 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() + stderr_buffer = bytearray() + diagnostics = bytearray() + output_lock = threading.Lock() + output_size = 0 + output_overflow = False + output_threads: list[threading.Thread] = [] + candidate_timed_out = False + failed_closed = False surviving = False + 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 + memory_before: dict[str, int] = {} + pids_before: dict[str, int] = {} tracked: dict[int, str | None] = {} - tracking_done = threading.Event() + phase = "construction" - 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)) + if result_write_fd is not None: + try: + endpoint = os.fstat(result_write_fd) + except OSError as exc: + 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 _sandbox_error( + command, + "protected supervisor phase=construction: invalid anonymous observation endpoint", + ) - tracker = threading.Thread(target=track_process_tree, daemon=True) - tracker.start() - deadline = time.monotonic() + timeout - resource_limit: str | None = None - try: - while process.poll() is None: - if time.monotonic() >= deadline: - timed_out = True - break - if _writable_size(writable_roots) > limits.max_writable_bytes: - resource_limit = "writable-bytes" - break - protected_stderr_bytes = max( - 0, stderr_file.tell() - _TERMINATION_HEADER_BYTES + 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: + with output_lock: + exceeded = output_overflow + observed = output_size + if exceeded: + return ( + "protected supervisor output quota exceeded: " + f"{observed}>{limits.max_output_bytes}" ) - if stdout_file.tell() + protected_stderr_bytes > limits.max_output_bytes: - resource_limit = "output-bytes" - break - time.sleep(0.01) + return None + + def fail_for_limit() -> bool: + nonlocal failed_closed + error = limit_error() + if error is None: + return False + failed_closed = True + add_diagnostic(f"protected supervisor phase={phase}: {error}\n") + return True + + def record_events() -> None: + 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( + resource_telemetry.memory_oom_delta, + resource_telemetry.memory_oom_kill_delta, + ) + 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") + if pids_delta > 0: + resource_limit = "pids" + add_diagnostic(f"cgroup pids.events max delta={pids_delta}\n") + + 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, + limits=limits, readable_roots=readable_roots, + 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_write_fd=result_write_fd, + 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}" + ) + try: + _revalidate_trusted_tools(plan.tools) + process = subprocess.Popen( + argv, cwd=Path("/"), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=_privileged_helper_environment(), start_new_session=True, + ) + except OSError as exc: + try: + _cleanup_staging(plan) + except RuntimeError as cleanup_exc: + return _sandbox_error( + command, + "protected supervisor phase=launch: " + f"{exc}; cleanup failed: {cleanup_exc}", + ) + return _sandbox_error( + command, f"protected supervisor phase=launch: {exc}" + ) + + 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 + ), + threading.Thread( + target=drain_output, args=(process.stderr, stderr_buffer), daemon=True + ), + ] + for output_thread in output_threads: + output_thread.start() + + 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") + 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 failed_closed: + cgroup, memory_before, pids_before = _probe_scope(plan, limits) + (control / "start").write_text("start", encoding="ascii") + phase = "candidate-execution" + 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() >= 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(): + candidate_record = _load_candidate_record( + 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 result_path.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 candidate_record is not None and result_path.exists(): + phase = "result-handoff" + 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( + authority / "observation.bin", 16 * 1024 * 1024, + root_result.observation_digest, + root_result.observation_size, + ) + _write_all_descriptor_bytes( + result_write_fd, observation, + min( + postprocess_deadline, + time.monotonic() + _TRUSTED_COMMAND_SECONDS, + ), + ) + fail_for_limit() + record_events() + if ( + candidate_record is None + and not failed_closed + ): + failed_closed = True + add_diagnostic( + "protected supervisor phase=candidate-execution: " + "scope produced no protected candidate record\n" + ) + elif ( + not result_path.exists() + and not failed_closed + ): + failed_closed = True + add_diagnostic( + "protected supervisor phase=trusted-postprocessing: " + "scope produced no validated result\n" + ) + if ( + candidate_returncode == 124 + and not candidate_timed_out + and result_path.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 failed_closed: + (control / "finish").write_text("finish", encoding="ascii") + try: + 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 ( + 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( + "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") + finally: + 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) + except ProcessLookupError: + pass + 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: + failed_closed = True + add_diagnostic(f"protected supervisor phase=scope-cleanup: {exc}\n") + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + 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: + failed_closed = True + add_diagnostic(f"protected supervisor phase=mount-cleanup: {exc}\n") finally: - tracking_done.set() - tracker.join(timeout=1) - if process.poll() is None: + 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: try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait() - observed = _supervised_descendants(token) - {process.pid} + 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) 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) - stdout_file.seek(0) - stderr_file.seek(0) - remaining = limits.max_output_bytes - stdout_bytes = stdout_file.read(remaining) - remaining -= len(stdout_bytes) - header = stderr_file.read(_TERMINATION_HEADER_BYTES) - nested_returncode = _authenticated_nested_returncode( - header, termination_token - ) - if nested_returncode is None: - stderr_bytes = (header + stderr_file.read(remaining))[:remaining] - else: - stderr_bytes = stderr_file.read(remaining) - if stdout_file.read(1) or stderr_file.read(1): - resource_limit = "output-bytes" - stdout_file.close() - stderr_file.close() - stdout = stdout_bytes.decode("utf-8", errors="replace") - stderr = stderr_bytes.decode("utf-8", errors="replace") - authenticated_status_missing = nested_returncode is None - observed_returncode = ( - 125 if authenticated_status_missing else nested_returncode + + 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 + returncode = 125 if failed_closed else ( + 124 if candidate_timed_out else candidate_returncode ) - returncode = 125 if resource_limit else (124 if timed_out else observed_returncode) - if authenticated_status_missing and not timed_out and resource_limit is None: - termination = SupervisorTermination( - TerminationKind.SANDBOX_ERROR, exit_code=125 - ) - else: - termination = _termination_evidence( - observed_returncode, - timed_out=timed_out, - timeout_seconds=timeout, - resource_limit=resource_limit, - ) + if returncode is None: + returncode = 125 return _supervised_result( - command, returncode, stdout, stderr, termination + command, + returncode, + stdout_bytes.decode("utf-8", errors="replace"), + stderr_bytes.decode("utf-8", errors="replace"), + ( + 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, + resource_telemetry=resource_telemetry, + ) + ), ), surviving diff --git a/pdd/sync_core/trust.py b/pdd/sync_core/trust.py index 78818d04d6..276f581036 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_toolchain_identity: 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_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/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 5a868ab60c..40d03ee656 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") @@ -56,6 +61,7 @@ class _ProfileInput: requirements: tuple[str, ...] obligations: tuple[VerificationObligation, ...] + assurance: AssuranceLevel = AssuranceLevel.STANDARD_FRAMEWORK @dataclass(frozen=True) @@ -190,6 +196,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, @@ -230,6 +247,7 @@ def _load_inputs( parsed = _ProfileInput( tuple(sorted(requirements)), tuple(sorted(_obligation(item) for item in obligations)), + _assurance(row), ) except (KeyError, TypeError, VerificationProfileError) as exc: invalid.append(f"{ref}: invalid profile entry: {exc}") @@ -270,6 +288,7 @@ def _profile_digest( unit_id: UnitId, requirements: tuple[str, ...], obligations: tuple[VerificationObligation, ...], + assurance: AssuranceLevel, ) -> str: payload = { "unit": { @@ -278,6 +297,7 @@ def _profile_digest( "language_id": unit_id.language_id, }, "required_requirement_ids": requirements, + "assurance": assurance.value, "obligations": [ { "obligation_id": item.obligation_id, @@ -526,7 +546,9 @@ def _expected_requirement_update( human, requirement_ids=(authorization.to_requirement_id,) ) expected = _ProfileInput( - (authorization.to_requirement_id,), tuple(sorted(obligations.values())) + (authorization.to_requirement_id,), + tuple(sorted(obligations.values())), + protected.assurance, ) if candidate != expected: return None, "requirement transition changes protected fields" @@ -789,11 +811,26 @@ 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_evidence_store.py b/tests/test_sync_core_evidence_store.py index fe71cd42a0..49353d7187 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_toolchain_identity() -> None: + envelope = _envelope() + envelope = replace( + envelope, + binding=replace( + envelope.binding, + playwright_toolchain_identity="3e5f" * 16, + ), + ) + 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_lifecycle_scenarios.py b/tests/test_sync_core_lifecycle_scenarios.py index f28d7a07cd..32ab32083c 100644 --- a/tests/test_sync_core_lifecycle_scenarios.py +++ b/tests/test_sync_core_lifecycle_scenarios.py @@ -1,12 +1,15 @@ """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 import os import json +import stat import subprocess import sys +import venv import zipfile from pathlib import Path, PurePosixPath @@ -32,6 +35,100 @@ from pdd.sync_core.identity import initialize_repository_identity +@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 + + +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", +) +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) + + def _run(root: Path, *args: str, env=None) -> subprocess.CompletedProcess[str]: return subprocess.run( args, @@ -90,6 +187,115 @@ 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 _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: + 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, completed.stdout + assert "candidate help" not in completed.stdout + assert len(receipt.dependency_digest) == 64 + assert any(row[2].endswith("/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_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: @@ -221,7 +427,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( @@ -252,13 +458,12 @@ 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 "-m" in command and "venv" in command: - (tmp_path / "candidate-venv" / ("Scripts" if os.name == "nt" else "bin")).mkdir( - parents=True - ) - return subprocess.CompletedProcess(command, 0, "ok", "") + receipt = _valid_transaction_receipt() + return subprocess.CompletedProcess( + command, 0, json.dumps(receipt, separators=(",", ":"), sort_keys=True), "" + ) monkeypatch.setattr( "pdd.sync_core.lifecycle._lifecycle_command", @@ -272,16 +477,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 + assert transaction_command[:3] == (sys.executable, "-I", "-c") + assert str(wheelhouse.resolve()) in transaction_command def test_candidate_install_proves_isolated_module_entrypoint( @@ -295,13 +504,12 @@ 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 "-m" in command and "venv" in command: - (tmp_path / "candidate-venv" / ("Scripts" if os.name == "nt" else "bin")).mkdir( - parents=True - ) - return subprocess.CompletedProcess(command, 0, "ok", "") + receipt = _valid_transaction_receipt() + return subprocess.CompletedProcess( + command, 0, json.dumps(receipt, separators=(",", ":"), sort_keys=True), "" + ) monkeypatch.setattr( "pdd.sync_core.lifecycle._lifecycle_command", @@ -314,11 +522,38 @@ 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 "[str(candidate_python),'-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 = _valid_transaction_receipt() + 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 = _valid_transaction_receipt() + 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" @@ -377,20 +612,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: diff --git a/tests/test_sync_core_pdd_rollout_policy.py b/tests/test_sync_core_pdd_rollout_policy.py index c19fa38444..7743fa8fc8 100644 --- a/tests/test_sync_core_pdd_rollout_policy.py +++ b/tests/test_sync_core_pdd_rollout_policy.py @@ -32,7 +32,7 @@ ) FOUNDATION_PROFILE = "pdd/prompts/durable_sync_runner_python.prompt" FOUNDATION_PROFILE_DIGEST = ( - "3fb63c651345467be6b2cb445b34edf979b35ffba1bb1ebb44a81f1313beb244" + "1d719c645ecc9c77014b6ba3e7531e9827185a6cbd32e85ffd45cfeb6d5df1d7" ) FOUNDATION_OBLIGATIONS = { "pytest-descriptor-store": { diff --git a/tests/test_sync_core_reporting.py b/tests/test_sync_core_reporting.py index e911f6904e..dc98d3e541 100644 --- a/tests/test_sync_core_reporting.py +++ b/tests/test_sync_core_reporting.py @@ -230,6 +230,42 @@ 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", + "-u", + "--", + ".pdd/verification-profiles.json", + "prompts/widget_python.prompt", + ) + _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 +277,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( @@ -874,6 +942,41 @@ def test_validate_command_requires_vitest_command_and_manifest_together( assert "manifest" in result.output.lower() +def test_validate_command_rejects_unpaired_playwright_command( + 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 + assert "required together" in result.output + mocked_finalize.assert_not_called() + + def test_trusted_finalizer_commits_artifact_closure_evidence_and_fingerprint( tmp_path, ) -> None: @@ -942,6 +1045,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", diff --git a/tests/test_sync_core_runner.py b/tests/test_sync_core_runner.py index 6a1a3f252c..cfe7bdc479 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.""" @@ -795,18 +874,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_checker_owned_junit_observation_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 +895,12 @@ def supervised(command, **kwargs): assert execution.outcome is EvidenceOutcome.PASS +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 + + 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_jest.py b/tests/test_sync_core_runner_jest.py index 39d007b4fc..0db0f3ed8b 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.""" +"""Contract tests for the fail-closed standard-framework 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 @@ -660,13 +672,14 @@ 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"] - output = Path(environment["PDD_TRUSTED_JEST_OUTPUT"]) - output.write_text( + 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 +697,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 +710,14 @@ 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( + 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_playwright.py b/tests/test_sync_core_runner_playwright.py new file mode 100644 index 0000000000..fe337a3403 --- /dev/null +++ b/tests/test_sync_core_runner_playwright.py @@ -0,0 +1,3876 @@ +"""Contract tests for the fail-closed trusted Playwright adapter.""" + +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import tempfile +from collections.abc import Iterator +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + +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, + AttestationSigner, + EvidenceOutcome, + RunBinding, + RunnerConfig, + UnitId, + VerificationObligation, + VerificationProfile, + run_profile, +) +from pdd.sync_core.runner import ( + PLAYWRIGHT_SUPERVISOR_LIMITS, + _directory_identity, + _local_javascript_imports, + _playwright_environment, + _playwright_command_error, + _playwright_missing_result_detail, + _playwright_reporter_source, + _playwright_reported_failure_detail, + _playwright_result, + _playwright_runtime_prefix, + _playwright_snapshot_aggregate_identity, + _playwright_snapshot_binding_proofs, + _playwright_host_temp_parent, + _playwright_infrastructure_termination, + _toolchain_manifest_roles, + _toolchain_manifest_identity, + _playwright_toolchain_identity, + playwright_validator_config_digest, +) + + +UNIT = UnitId("repository-1", PurePosixPath("prompts/widget_ts.prompt"), "typescript") +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", + "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", +) + + +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, +) -> None: + """Model the Linux-only inherited descriptor for the local fake runner. + + The production supervisor intentionally does not emulate the observation + descriptor on macOS. These synthetic runner tests therefore write the + checker-owned pipe 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() + 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 = ( + "root = pathlib.Path.cwd()" in source + or "const file = path.resolve" in source + ) + except OSError: + synthetic = False + if not synthetic: + return original(command, **kwargs) + synthetic_command = [*command] + synthetic_command[1] = str(entrypoint) + result_fd = kwargs["result_fd"] + writer = kwargs["result_write_fd"] + 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 = _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) + + +def _write_framework_observation(kwargs: dict, payload: dict) -> None: + """Model the checker-owned reporter transport in supervisor fakes.""" + 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: + if close_writer: + 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 + ).stdout.strip() + + +def _fake_playwright(tmp_path: Path) -> Path: + runner = tmp_path / "fake_playwright.py" + runner.write_text( + "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" + "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" + " 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 + + +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" + " 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 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 + + +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" + " 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 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 + + +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 _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" + browsers = directory / "browsers" + dependencies.mkdir(exist_ok=True) + 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()) + 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({ + "version": 3, + "roles": { + "launcher": str(launcher.resolve()), + "entrypoint": str(installed_entrypoint.resolve()), + "dependencies": str(dependencies.resolve()), + "browser_runtime": str(browsers.resolve()), + "native_runtime": [str(native.resolve())], + "lockfile": str(lockfile.resolve()), + }, + }), + encoding="utf-8", + ) + return manifest + + +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") + 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_config_suffixes_collect_and_use_config_dir( + 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"): + 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"] + 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") + _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 }, 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", + encoding="utf-8", + ) + commonjs = suffix == ".cjs" or ( + suffix == ".js" and js_scope == "commonjs" + ) + config = ( + "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": + 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") + 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"] + + +@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() + 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" + "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", + ) + + +@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", "reason"), + [ + (( + "import { test } from '@playwright/test';\n" + "test('valid identity', () => {});\n" + "test.skip('bad::identity', () => {});\n" + ), "invalid_project_title"), + (( + "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, reason: 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", + "reason": reason, + } +def test_playwright_binds_static_runtime_resources_and_rejects_reflection( + tmp_path: Path, +) -> None: + root, _commit = _repository(tmp_path) + 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" + "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( + "import { expect } from '@playwright/test';\n" + "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 + + +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", "tests/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="regular|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 + ) + 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 + ) + + +def test_playwright_execution_uses_process_group_supervisor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, commit = _repository(tmp_path) + calls: list[list[str]] = [] + readable_bindings = [] + snapshot_proofs = [] + scratch_bindings = [] + temp_directories = [] + phase_roots = [] + + 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"]) + _write_framework_observation(_kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + 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)) + assert executions[0].outcome is EvidenceOutcome.PASS + assert calls + assert scratch_bindings[0][0][1] == Path("/tmp") + assert scratch_bindings[0][0][0].name == "tmp" + assert scratch_bindings[0][0][0].parent.name == "scratch" + assert temp_directories[0] == Path("/tmp") + 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" + 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_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 _trusted_completed(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", + "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(_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 + ) + 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 + 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, + roles, + Path(runtime_prefix[0]), + dependency_destination, + roles.native_bindings, + ) + 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") + 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": + (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.digest != aggregate.digest + + +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(_playwright_reporter_source(198), 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.digest != aggregate.digest + + +@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: + """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 _trusted_completed(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(trusted_toolchain_dir, _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( + 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) + 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)), + 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"]) +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") + commit = _git(root, "rev-parse", "HEAD") + 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.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) + 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)), + 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.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_modules"): + playwright_validator_config_digest( + root, "HEAD", (PurePosixPath("tests/widget.spec.ts"),), products + ) + + +@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( + 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: + """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] = [] + result_writer_authorities: list[tuple[bool, bool]] = [] + 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"]) + 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) + _write_framework_observation(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return _trusted_completed(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) == 4 for roots in readable_roots) + assert all(len(bindings) == 2 for bindings in readable_bindings) + sandbox_tmp = Path("/tmp").resolve() + 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: + 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), + ( + Path(payload["roles"]["dependencies"]).resolve(), + root / "node_modules", + ), + ) + _write_framework_observation(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return _trusted_completed(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_framework_observation(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return _trusted_completed(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_framework_observation(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return _trusted_completed(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", + [ + "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 in { + EvidenceOutcome.ERROR, EvidenceOutcome.COLLECTION_ERROR, + } + + +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, + 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]) + 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, + ), + ) + + +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)], +) +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( + "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 + + +@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( + ("callbacks", "reason"), + [ + (( + "const bad = valid('bad'); bad.titlePath = () => { throw new Error('bad'); };\n" + "try { reporter.onBegin({ allTests: () => [valid(), bad] }); } catch {}\n" + "reporter.onEnd({ status: 'passed' });" + ), "title_path_call"), + (( + "try { reporter.onBegin({ allTests: () => [valid(), valid()] }); } catch {}\n" + "reporter.onEnd({ status: 'passed' });" + ), "duplicate_identity"), + (( + "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" + "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"), + (( + "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( + tmp_path: Path, callbacks: str, reason: 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", + "reason": reason, + } + 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({ status: 'passed' }); } catch {}", + ) + + assert receipt == { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "reason": "serialization_failure", + } + + +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, +) -> 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_title_path", + "tests": [{"identity": IDENTITY, "status": "collected"}], + }, + { + "pdd_playwright_reporter": True, + "reporter_error": "invalid_reporter_state", + "reason": "invalid_title_path", + }, + { + "pdd_playwright_reporter": 1, + "reporter_error": "candidate title", + "reason": "invalid_title_path", + }, + { + "pdd_playwright_reporter": 1, + "reporter_error": "invalid_reporter_state", + "reason": "invalid_title_path", + "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( + 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", + [ + "const key='grep'; export default { [key]: /widget/ };\n", + "const controls={ ['webServer']: {command:'./server.sh'} }; export default {...controls};\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"),) + ) + + +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( + "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", + [ + "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|capability schema"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@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|capability schema"): + playwright_validator_config_digest( + root, commit, (PurePosixPath("tests/widget.spec.ts"),) + ) + + +@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|capability schema"): + playwright_validator_config_digest( + root, + _git(root, "rev-parse", "HEAD"), + (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"),) + ) + + +@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" + 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_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_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() + (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_toolchain_manifest_rejects_absolute_symlinks( + 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" + (toolchain / "browsers").mkdir() + (toolchain / "package-lock.json").write_text("{}", encoding="utf-8") + manifest.write_text(json.dumps({ + "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") + 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": 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") + assert _toolchain_manifest_identity(first / "manifest.json") == _toolchain_manifest_identity( + second / "manifest.json" + ) + + +def test_playwright_production_run_requires_and_rechecks_toolchain_manifest( + tmp_path: Path, trusted_toolchain_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, commit = _repository(tmp_path) + runner = _fake_playwright(tmp_path) + manifest = _toolchain_manifest( + trusted_toolchain_dir / "protected-toolchain", Path(sys.executable), runner + ) + installed = _manifest_entrypoint(manifest) + original_supervised = runner_module.run_supervised + + def mutate_after_playwright(*args, **kwargs): + result = original_supervised(*args, **kwargs) + installed.write_text(installed.read_text(encoding="utf-8") + "# changed\n") + return result + + monkeypatch.setattr(runner_module, "run_supervised", 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(installed)), + playwright_toolchain_manifest=manifest, + ), + ) + assert executions[0].outcome is EvidenceOutcome.COLLECTION_ERROR + 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: + 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" + ) + 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 +) -> 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.ERROR + 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"]) +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 + + +def test_playwright_candidate_node_modules_dependency_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" + 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") + + node = shutil.which("node") + assert node is not None + + _envelope, executions = _run( + root, + commit, + commit, + (node, str(_fake_node_playwright(tmp_path))), + ) + + assert executions[0].outcome is EvidenceOutcome.ERROR + assert "candidate node_modules" in executions[0].detail + + +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" + 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.ERROR + 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") + _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", + ) + + 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( + 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"), + [ + ("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 in {EvidenceOutcome.ERROR, 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 in {EvidenceOutcome.ERROR, 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_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_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("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" + "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("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).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("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( + 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( + "export const 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( + "export const 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) + 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: + 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 + + +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 or "entrypoint role" 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 or "manifest" in executions[0].detail + + +@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", + "const globalSetup = './setup';\nexport default { globalSetup };\n", + "const webServer = { command: 'npm run dev' };\nexport default { webServer };\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 + + +@pytest.mark.parametrize( + "config", + [ + 'export default { "grep": /widget/ };\n', + "export default { 'retries': 1 };\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 + + +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 + + +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 + _write_framework_observation(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) + return _trusted_completed(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({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") + _write_framework_observation(_kwargs, {"tests": [{"identity": IDENTITY, "status": "passed"}]}) + return _trusted_completed(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.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"),) + ) + + +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": "", + "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 firstCard = 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"),) + ) + + +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 + + +def test_playwright_missing_observation_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( + ("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: + root, commit = _repository(tmp_path) + + def supervised(command, **kwargs): + collection = "--list" in command + _write_framework_observation(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 _trusted_completed( + command, 0 if collection else 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_256_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_framework_observation(kwargs, { + "tests": [{"identity": IDENTITY, "status": "passed"}], + }) + return _trusted_completed(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 == 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) + + +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_reported_failure_has_bounded_diagnostics() -> None: + detail = _playwright_reported_failure_detail([{ + "identity": IDENTITY, + "status": "failed", + "error": "browser launch failed\n" + ("x" * 3000) + "\nloader tail", + }]) + + assert "browser launch failed" in detail + assert "loader tail" in detail + assert len(detail) < 2200 + + +@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|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: + """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 "allTestsFunction.call(suite)" 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(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 + assert "titles.join(' > ')" in source + assert "onTestEnd(test, result)" 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: + 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 diff --git a/tests/test_sync_core_runner_vitest.py b/tests/test_sync_core_runner_vitest.py index 55d0b5ac9f..f52ba4d60d 100644 --- a/tests/test_sync_core_runner_vitest.py +++ b/tests/test_sync_core_runner_vitest.py @@ -10,7 +10,6 @@ from dataclasses import replace from datetime import datetime, timezone from pathlib import Path, PurePosixPath -from types import SimpleNamespace import pytest @@ -42,7 +41,43 @@ 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( + 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") @@ -458,23 +493,6 @@ def test_vitest_grammar_dependencies_are_exactly_pinned() -> None: assert not any(item.startswith("tree-sitter-language-pack") for item in dependencies) -def test_real_vitest_workflow_uses_checked_in_locked_toolchain() -> None: - """Hosted protected Vitest must resolve one reviewed transitive closure.""" - root = Path(__file__).parents[1] - toolchain = root / ".github/toolchains/vitest" - package = json.loads((toolchain / "package.json").read_text(encoding="utf-8")) - lock = json.loads((toolchain / "package-lock.json").read_text(encoding="utf-8")) - workflow = (root / ".github/workflows/unit-tests.yml").read_text(encoding="utf-8") - - assert package["private"] is True - assert package["dependencies"] == {"vitest": "4.1.10"} - assert lock["packages"][""]["dependencies"] == package["dependencies"] - assert 'cp .github/toolchains/vitest/package.json "$toolchain/"' in workflow - assert 'cp .github/toolchains/vitest/package-lock.json "$toolchain/"' in workflow - assert 'npm ci --prefix "$toolchain" --ignore-scripts --no-audit --no-fund' in workflow - assert 'npm install --prefix "$toolchain"' not in workflow - - def test_vitest_uses_packaged_grammars_without_language_pack( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -733,6 +751,72 @@ 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.destination == descriptor.native_runtime[0] + assert proof.descriptor_identity == descriptor.identity + 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", "native_runtime", "schema" + } + assert attestation["native_runtime"] == [str(descriptor.native_runtime[0])] + + +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: @@ -1578,7 +1662,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", @@ -1819,82 +1903,23 @@ def test_vitest_result_fifo_without_writer_is_distinct_collection_error( assert executions[0].detail == "Vitest reporter produced no result" -@pytest.mark.parametrize( - ("termination", "returncode", "outcome", "detail"), - [ - ( - SimpleNamespace( - kind="exit", exit_code=23, signal_number=None, - timeout_seconds=None, resource_limit=None, - ), - 23, - EvidenceOutcome.ERROR, - "Vitest infrastructure termination: reporter=missing; kind=exit; " - "exit_code=23; diagnostic_sha256=ae8dd1580e8e3b5004f46f110fdcd006" - "444f03e81dd6faa10721ec41fdf737f3", - ), - ( - SimpleNamespace( - kind="signal", exit_code=None, signal_number=9, - timeout_seconds=None, resource_limit=None, - ), - -9, - EvidenceOutcome.ERROR, - "Vitest infrastructure termination: reporter=missing; kind=signal; " - "signal=SIGKILL; signal_number=9; diagnostic_sha256=ae8dd1580e8e3b5" - "004f46f110fdcd006444f03e81dd6faa10721ec41fdf737f3", - ), - ( - SimpleNamespace( - kind="signal", exit_code=None, signal_number=signal.SIGXCPU, - timeout_seconds=None, resource_limit=None, - ), - -signal.SIGXCPU, - EvidenceOutcome.ERROR, - "Vitest infrastructure termination: reporter=missing; kind=signal; " - f"signal=SIGXCPU; signal_number={signal.SIGXCPU}; " - "diagnostic_sha256=ae8dd1580e8e3b5004f46f110fdcd006444f03e81dd" - "6faa10721ec41fdf737f3", - ), - ( - SimpleNamespace( - kind="timeout", exit_code=None, signal_number=None, - timeout_seconds=7, resource_limit=None, - ), - 124, - EvidenceOutcome.TIMEOUT, - "Vitest infrastructure termination: reporter=missing; kind=timeout; " - "timeout_seconds=7; diagnostic_sha256=ae8dd1580e8e3b5004f46f110fdcd006" - "444f03e81dd6faa10721ec41fdf737f3", - ), - ( - SimpleNamespace( - kind="resource-limit", exit_code=None, signal_number=None, - timeout_seconds=None, resource_limit="output-bytes", - ), - 125, - EvidenceOutcome.ERROR, - "Vitest infrastructure termination: reporter=missing; " - "kind=resource-limit; resource_limit=output-bytes; " - "diagnostic_sha256=ae8dd1580e8e3b5004f46f110fdcd006444f03e81dd6faa1" - "0721ec41fdf737f3", - ), - ], -) -def test_vitest_missing_reporter_preserves_typed_infrastructure_termination( +def test_vitest_sigabrt_reports_only_trusted_zero_cgroup_deltas( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - termination: SimpleNamespace, - returncode: int, - outcome: EvidenceOutcome, - detail: str, ) -> None: - """Empty private evidence must retain trusted termination diagnostics.""" + """A generic abort stays fail-closed and is not mislabeled as a limit breach.""" root, _commit = _repository(tmp_path) - result = subprocess.CompletedProcess( - ["vitest"], returncode, "", "benign MIXED_EXPORTS warning" + 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), + ), ) - result.termination = termination monkeypatch.setattr( "pdd.sync_core.runner.run_supervised", lambda *_args, **_kwargs: (result, False), @@ -1903,14 +1928,21 @@ def test_vitest_missing_reporter_preserves_typed_infrastructure_termination( execution, identities = _run_vitest( root, (PurePosixPath("tests/widget.test.ts"),), - 7, + 30, _runner_config(tmp_path, _fake_vitest(tmp_path)), ) - assert execution.outcome is outcome - assert execution.detail == detail - assert "MIXED_EXPORTS" not in execution.detail - assert not identities + 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( @@ -1927,16 +1959,23 @@ def test_vitest_exit_failure_precedes_empty_fifo_collection_error( _envelope, executions = _run(root, commit, commit, runner) assert executions[0].outcome is outcome + if returncode == 1: + assert executions[0].detail == ( + "Vitest infrastructure termination: reporter=missing; kind=exit; " + "exit_code=1" + ) def test_vitest_linux_command_binds_wasm_guard(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root, _commit = _repository(tmp_path) config = _runner_config(tmp_path, _fake_vitest(tmp_path)) observed: list[list[str]] = [] + proofs = [] observed_limits: list[SupervisorLimits] = [] - def capture(command, *, result_fifo, result_fd, limits, **_kwargs): + def capture(command, *, result_fifo, result_fd, limits, **kwargs): observed.append(command) + proofs.append(kwargs["immutable_binding_proofs"]) observed_limits.append(limits) writer = os.open(result_fifo, os.O_WRONLY) try: @@ -1956,6 +1995,9 @@ def capture(command, *, result_fifo, result_fd, limits, **_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 assert observed_limits == [ SupervisorLimits(max_memory_bytes=4 * 1024 * 1024 * 1024) ] @@ -1974,8 +2016,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_supervisor.py b/tests/test_sync_core_supervisor.py index cc587ad3a5..bb36ad9cb9 100644 --- a/tests/test_sync_core_supervisor.py +++ b/tests/test_sync_core_supervisor.py @@ -1,70 +1,867 @@ """Adversarial tests for complete protected subprocess supervision.""" +import base64 +import io import os +import hashlib +import inspect import json import math -import shutil +import select import signal +import shutil +import stat import subprocess import sys +import tempfile import threading import time +from dataclasses import dataclass, replace from pathlib import Path +from pathlib import PurePosixPath +from types import SimpleNamespace import pytest from pdd.sync_core import supervisor from pdd.sync_core.supervisor import ( + ImmutableBindingProof, SupervisorLimits, - TerminationKind, _linked_libraries, _limited_command, _live_processes, - _private_result_command, - _authenticated_nested_returncode, - _subprocess_status_observation, - _subprocess_status_handoff, - _termination_status_header, - _runtime_roots, + _framework_observation_command, _sandbox_library_path, _sandbox_command, - _supervised_result, - _termination_evidence, _runtime_directories, run_supervised, ) -from pdd.sync_core.signer_process import run_signer -def _mock_trusted_tools( - monkeypatch: pytest.MonkeyPatch, *, missing: frozenset[str] = frozenset(), - unsafe: dict[str, Path] | None = None, +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]: + """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", + "unshare", + ): + 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", 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, + } + + +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 | { + "LANG": "C", "LC_ALL": "C", + "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) + + +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", + [ + {}, + [["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, +) -> 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 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_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: + """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_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: + """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, + ) + + 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 + ) + identity, aggregate = _playwright_snapshot_aggregate_identity( + proofs, reporter, roles, launcher, destination, roles.native_bindings + ) + 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() + 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, + observation_nonce="a" * 64, + ) + finally: + os.close(read_fd) + os.close(write_fd) + + 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 = 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"]} >= { + "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 "--preserve-fds" not in bwrap + 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") + + +# 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: + """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: + """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: - """Install explicit synthetic identities for command-construction tests.""" - unsafe = unsafe or {} + """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, + ) - def identity(name: str): - if name in missing: - raise RuntimeError( - "protected sandbox requires a trusted root-owned executable" + if mutation == "nonce": + with pytest.raises(RuntimeError, match="observation result is invalid"): + supervisor._load_root_observation_result( + Path("authority/result.json"), nonce, 1024, ) - if name in unsafe: - return supervisor._executable_identity(unsafe[name]) - path = Path(f"/usr/bin/{name}") - return supervisor._ExecutableIdentity( - path, (1, len(name), 0o755, 0, len(name), 1), - name.ljust(64, "0")[:64], + 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, ) - monkeypatch.setattr(supervisor, "_trusted_tool", identity) - monkeypatch.setattr(supervisor, "_trusted_helper_python", lambda: identity("python3")) - monkeypatch.setattr(supervisor, "_trusted_helper_runtime_roots", lambda _identity: ()) + +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 + 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", native_mode, 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": sys.platform.startswith("linux"), + }, + }, 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 _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) + member = next( + 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( + 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: candidate_uid) + monkeypatch.setattr(os, "getgid", lambda: candidate_gid) + _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 -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" @@ -74,11 +871,11 @@ 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( - _private_result_command(candidate, fifo, result_fd), + _framework_observation_command(candidate, result_fd, fifo), capture_output=True, text=True, timeout=10, @@ -86,47 +883,12 @@ 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 fifo.exists() + assert os.read(read_fd, 1024) == b"framework-result" finally: os.close(read_fd) -def test_candidate_environment_launcher_preserves_exact_exit_and_environment( - tmp_path: Path, -) -> None: - """The post-drop handoff must exec the child without xargs exit remapping.""" - environment_file = tmp_path / "candidate-env" - candidate = [ - sys.executable, - "-c", - "import os;raise SystemExit(5 if os.environ.get('ONLY') == 'value' " - "and 'HOSTILE' not in os.environ else 6)", - ] - environment_file.write_bytes( - b"ONLY=value\0" + b"\0".join(item.encode("utf-8") for item in candidate) - ) - - completed = subprocess.run( - [ - sys.executable, - "-I", - "-S", - "-c", - supervisor._candidate_environment_launcher(), - str(environment_file), - ], - env={"HOSTILE": "must-not-propagate"}, - capture_output=True, - text=True, - check=False, - ) - - assert completed.returncode == 5 - assert completed.stdout == "" - assert completed.stderr == "" - - def test_runtime_closure_ignores_synthetic_argv_interpreter_prefix( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -204,7 +966,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 @@ -245,7 +1009,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) @@ -255,7 +1019,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 @@ -345,20 +1112,10 @@ def test_native_runtime_roots_reject_ambiguous_library_roots( def test_linux_sandbox_uses_privileged_namespace_setup_then_drops_uid( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - helper_encodings = tmp_path / "system-python" / "encodings" - helper_encodings.mkdir(parents=True) monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) monkeypatch.setattr(os, "getgid", lambda: 2345) - monkeypatch.setattr(supervisor, "_SUPERVISOR_EXECUTABLE", Path("/usr/bin/python")) - _mock_trusted_tools(monkeypatch) - monkeypatch.setattr( - supervisor, - "_trusted_helper_runtime_roots", - lambda _identity: (helper_encodings,), - raising=False, - ) - 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, "", ""), @@ -366,78 +1123,30 @@ 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] == ["/usr/bin/sudo", "-n", "--"] - assert argv[3:10] == [ - "/usr/bin/unshare", "--mount", "--propagation", "private", - "--wd", "/", "/usr/bin/python3", - ] - assert argv[10:12] == ["-I", "-S"] - assert "-E" not in argv - bwrap = json.loads(argv[-2]) - helper = argv[argv.index("-c") + 1] - compile(helper, "", "exec") - assert "subprocess.run([mount,'--bind'" in helper - assert "subprocess.run([umount,str(target)]" in helper - assert all( - line in helper for line in _subprocess_status_observation().splitlines() - ) - assert all( - line in helper for line in _subprocess_status_handoff().splitlines() - ) - assert "if inner_status is None: raise RuntimeError" in helper - assert "SystemExit(result.returncode" not in helper - assert "@PDD-CANDIDATE-ENV@" in bwrap - termination_token = argv[-4] - assert len(termination_token) == 32 - assert termination_token not in json.dumps(bwrap) - status_mount = bwrap.index("/run/pdd-termination") - assert bwrap[status_mount - 2:status_mount] == [ - "--bind", "@PDD-TERMINATION-DIR@", - ] - helper_index = bwrap.index(str(helper_encodings)) - assert bwrap[helper_index - 2] == "--ro-bind" - assert "/usr/bin/xargs" in bwrap and "/usr/bin/env" in bwrap - assert "['mount'" not in helper and "['umount'" not in helper - assert {"--unshare-pid", "--unshare-net", "--unshare-cgroup"} <= set(bwrap) + 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 = plan.bwrap_argv + 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") - candidate_argv = bwrap[separator + 1:] - inner_launcher = candidate_argv[candidate_argv.index("-c") + 1] - assert inner_launcher == supervisor._inner_status_supervisor() - assert inner_launcher.splitlines()[0] == "import os,sys" - assert "import signal" not in inner_launcher - assert "os.unlink(path)" in inner_launcher - assert "os.O_CLOEXEC" in inner_launcher - assert "os.execv(command[0],command)" in inner_launcher - assert "os.waitpid(pid,os.WUNTRACED)" in inner_launcher - assert "os.killpg(pid,9)" in inner_launcher - drop_argv = candidate_argv[candidate_argv.index("/usr/bin/setpriv"):] - assert drop_argv[drop_argv.index("--") + 1] == "/usr/bin/python3" - assert "/usr/bin/xargs" not in candidate_argv - assert "/usr/bin/env" not in candidate_argv - launcher = drop_argv[drop_argv.index("-c") + 1] - compile(launcher, "", "exec") - assert "os.execve(command[0],command,environment)" in launcher 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 plan.launch_payload is not None + assert bwrap[bwrap.index("--ro-bind") + 1] in plan.launch_payload["path_tokens"] -def test_linux_sandbox_fails_closed_without_private_mount_namespace_tool( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_linux_sandbox_uses_upstream_bwrap_inherited_descriptor_contract( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Protected staging must not fall back to the host mount namespace.""" + """Ubuntu Bubblewrap receives no unsupported descriptor-preservation option.""" monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) - _mock_trusted_tools(monkeypatch, missing=frozenset({"unshare"})) - monkeypatch.setattr( - shutil, "which", - lambda name: None if name == "unshare" else f"/usr/bin/{name}", - ) + 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, "", ""), @@ -445,135 +1154,73 @@ def test_linux_sandbox_fails_closed_without_private_mount_namespace_tool( monkeypatch.setattr( "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () ) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: () + ) - with pytest.raises(RuntimeError, match="trusted root-owned executable"): - _sandbox_command(["/bin/true"], (tmp_path,)) + _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_runtime_closure_measures_unshare_and_excludes_it_from_candidate_roots( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + +def test_linux_sandbox_binds_inner_helper_python_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """The namespace executable is measured but hidden from candidate roots.""" - unshare = tmp_path / "unshare" - unshare.write_bytes(b"trusted-unshare") - monkeypatch.setattr( - shutil, "which", lambda name: str(unshare) if name == "unshare" else None - ) - supervisor.released_runtime_closure_paths.cache_clear() - try: - closure = dict(supervisor.released_runtime_closure_paths()) - assert closure["sandbox/unshare"] == unshare.resolve() - roots = _runtime_roots([sys.executable], tmp_path) - assert unshare.resolve() not in roots - finally: - supervisor.released_runtime_closure_paths.cache_clear() - + """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") -def test_privileged_helper_rejects_user_owned_path_shadow_tools( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Caller-owned executables must never cross the sudo boundary.""" - shadow = tmp_path / "unshare" - shadow.write_text("malicious", encoding="utf-8") - shadow.chmod(0o755) monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(os, "getuid", lambda: 1234) - _mock_trusted_tools( - monkeypatch, unsafe={"unshare": shadow, "mount": shadow} - ) - monkeypatch.setattr( - shutil, "which", - lambda name: str(shadow) if name in {"unshare", "mount"} else f"/usr/bin/{name}", + 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( - "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () - ) - - with pytest.raises(RuntimeError, match="protected executable"): - _sandbox_command(["/bin/true"], (tmp_path,)) - - -def test_privileged_helper_environment_is_fixed_and_candidate_independent() -> None: - candidate = { - "PATH": "/candidate/bin", - "PYTHONPATH": "/candidate/hooks", - "PYTHONHOME": "/candidate/python", - } - - helper_environment = supervisor._privileged_helper_environment(candidate) - - assert helper_environment == { - "HOME": "/root", - "LANG": "C", - "LC_ALL": "C", - "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", - } - - -def test_privileged_helper_revalidates_bound_executable_identity( - tmp_path: Path, -) -> None: - executable = tmp_path / "tool" - executable.write_bytes(b"first") - executable.chmod(0o755) - measured = supervisor._executable_identity(executable, require_root=False) - executable.write_bytes(b"second") - - with pytest.raises(RuntimeError, match="identity changed"): - supervisor._revalidate_executable(measured) - - -def test_privileged_helper_rejects_mode_change_before_exec(tmp_path: Path) -> None: - executable = tmp_path / "tool" - executable.write_bytes(b"trusted") - executable.chmod(0o755) - measured = supervisor._executable_identity(executable, require_root=False) - executable.chmod(0o777) - - with pytest.raises(RuntimeError, match="identity changed"): - supervisor._revalidate_executable(measured) - - -def test_executable_measurement_uses_descriptor_not_path_read( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - executable = tmp_path / "tool" - executable.write_bytes(b"trusted") - executable.chmod(0o755) - monkeypatch.setattr( - Path, "open", - lambda *_args, **_kwargs: (_ for _ in ()).throw( - AssertionError("path read crossed identity boundary") - ), + supervisor, "_linked_libraries", + lambda path: (helper_loader,) if path == helper_python else (), ) - identity = supervisor._executable_identity(executable, require_root=False) - - assert identity.sha256 + _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])]) -@pytest.mark.parametrize("swap", ["rename", "symlink"]) -def test_privileged_helper_rejects_path_entry_swaps( - tmp_path: Path, swap: str, -) -> None: - executable = tmp_path / "tool" - executable.write_bytes(b"trusted") - executable.chmod(0o755) - measured = supervisor._executable_identity(executable, require_root=False) - 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) + 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( @@ -582,8 +1229,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) - _mock_trusted_tools(monkeypatch) - 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, "", ""), @@ -595,46 +1241,70 @@ 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[-2]) - sources = json.loads(argv[-1]) + 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] - assert sources[int(placeholder.removeprefix("@FD:").removesuffix("@"))] == str( - copied.resolve() - ) + assert plan.launch_payload is not None + tokens = plan.launch_payload["path_tokens"] + assert sources[tokens.index(placeholder)] == str(copied.resolve()) -def test_linux_sandbox_fails_closed_for_root_caller( +def test_linux_sandbox_maps_bounded_scratch_to_writable_tmp( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(sys, "platform", "linux") - _mock_trusted_tools(monkeypatch) - monkeypatch.setattr(os, "getuid", lambda: 0) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + 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: () + ) + scratch = tmp_path / "scratch" + scratch.mkdir() - result, surviving = run_supervised( - ["/bin/true"], cwd=tmp_path, timeout=1, env={}, - writable_roots=(tmp_path,), + _argv, plan = _sandbox_command( + ["/bin/true"], + (scratch,), + writable_bindings=((scratch, Path("/tmp")),), ) - assert result.returncode == 125 - assert "non-root caller" in result.stderr - assert surviving is False + 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] + 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 + ) + assert token == placeholder + assert writable_roots[root_index] == str(scratch.resolve()) + assert relative == "." + assert str(scratch.resolve()) not in sources -def test_linux_sandbox_opens_and_unlinks_checker_fifo_before_candidate( +def test_linux_sandbox_deduplicates_identical_read_only_bindings( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(sys, "platform", "linux") - _mock_trusted_tools(monkeypatch) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + 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, "", ""), @@ -642,43 +1312,28 @@ def test_linux_sandbox_opens_and_unlinks_checker_fifo_before_candidate( monkeypatch.setattr( "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () ) + native = tmp_path / "native.so" + native.write_bytes(b"native") - channel = tmp_path / "channel" - channel.mkdir(mode=0o700) - fifo = channel / "checker.fifo" - os.mkfifo(fifo) - argv, profile = _sandbox_command( - ["/bin/true"], (tmp_path,), result_fifo=fifo, result_fd=198 + _argv, plan = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_roots=(native, native), + readable_bindings=((native, native),), ) - assert profile is None - assert argv[:3] == ["/usr/bin/sudo", "-n", "--"] - assert "-E" not in argv - assert "-C" not in argv[:6] - bwrap = json.loads(argv[-2]) - assert "--preserve-fds" not in bwrap - assert json.loads(argv[-1]) - assert str(channel) in bwrap - separator = bwrap.index("--") - candidate_argv = bwrap[separator + 1:] - protected_command = json.loads(argv[argv.index("-c") + 2]) - assert str(fifo) in protected_command - wrapper = protected_command[protected_command.index("-c") + 1] - assert "os.open(path,os.O_WRONLY);os.unlink(path)" in wrapper - assert protected_command.index(str(fifo)) < protected_command.index("/bin/true") - assert "@PDD-CANDIDATE-ENV@" in candidate_argv + bwrap = plan.bwrap_argv + assert bwrap.count(str(native)) == 1 -def test_sandbox_directory_bind_provides_parent_for_nested_file( +def test_linux_sandbox_rejects_declared_copied_loader_at_inferred_runtime_without_proof( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - nested = tmp_path / "candidate-venv" / "bin" - nested.mkdir(parents=True) - interpreter = nested / "python" - interpreter.write_text("python", encoding="utf-8") + """A distinct declared source cannot replace an inferred runtime source.""" monkeypatch.setattr(sys, "platform", "linux") - _mock_trusted_tools(monkeypatch) - monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + 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, "", ""), @@ -686,941 +1341,7533 @@ def test_sandbox_directory_bind_provides_parent_for_nested_file( 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([str(interpreter)], (tmp_path,), cwd=tmp_path) - bwrap = json.loads(argv[-2]) - directory_targets = { - bwrap[index + 1] for index, value in enumerate(bwrap[:-1]) - if value == "--dir" - } - assert str(nested) not in directory_targets + with pytest.raises(RuntimeError, match="conflicting bindings"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + ) -def test_sandbox_binds_resolved_runtime_sources_at_original_destinations( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_linux_sandbox_mounts_nested_declared_toolchain_after_phase_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """The namespace must retain the executable and loader lookup spellings.""" - runtime = tmp_path / "runtime" - executable_source = runtime / "python-real" - executable_source.parent.mkdir() - executable_source.write_text("python", encoding="utf-8") - executable_destination = tmp_path / "toolcache" / "bin" / "python" - executable_destination.parent.mkdir(parents=True) - executable_destination.symlink_to(executable_source) - loader_source = runtime / "libc-real.so" - loader_source.write_bytes(b"library") - loader_destination = tmp_path / "loader" / "libc.so.6" - loader_destination.parent.mkdir() - loader_destination.symlink_to(loader_source) - candidate = tmp_path / "candidate" / "bin" / "python" - candidate.parent.mkdir(parents=True) - candidate.write_text("python", encoding="utf-8") - workdir = tmp_path / "work" - workdir.mkdir() - + """A broad phase bind must not hide its protected node_modules overlay.""" monkeypatch.setattr(sys, "platform", "linux") - _mock_trusted_tools(monkeypatch) + _mock_linux_tools(tmp_path, monkeypatch) monkeypatch.setattr( - "pdd.sync_core.supervisor._SUPERVISOR_EXECUTABLE", - executable_destination, + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), ) - sandbox_tools = { - "bwrap": "/usr/bin/bwrap", - "sudo": "/usr/bin/sudo", - "setpriv": "/usr/bin/setpriv", - "unshare": "/usr/bin/unshare", - } monkeypatch.setattr( - shutil, - "which", - sandbox_tools.get, + "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 = plan.bwrap_argv + + 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: + """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._runtime_directories", tuple) monkeypatch.setattr( - "pdd.sync_core.supervisor.released_runtime_closure_paths", - lambda: ( - ("native/loader/libc.so.6", loader_destination), - ("native/runtime/libc-real.so", loader_source), - ), + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () ) - - argv, _profile = _sandbox_command( - [str(candidate), "-c", "pass"], (workdir,), cwd=workdir + 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,) ) - bwrap = json.loads(argv[-2]) - sources = json.loads(argv[-1]) - - 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])] - - assert str(executable_destination.parent) in { - bwrap[index + 1] for index, value in enumerate(bwrap[:-1]) - if value == "--dir" - } - assert bind_source(executable_destination) == str(executable_source) - assert bind_source(loader_destination) == str(loader_source) + proof = _descriptor_runtime_proof(copied_loader, host_loader) + assert json.loads(proof.descriptor_attestation)["adapter"] == "vitest" -@pytest.mark.skipif( - not sys.platform.startswith("linux") or not shutil.which("bwrap"), - reason="requires Linux kernel namespace containment", -) -def test_sandboxed_python_minimal_smoke(tmp_path: Path) -> None: - """A protected namespace can start the configured interpreter.""" - result, surviving = run_supervised( - [sys.executable, "-c", "pass"], - cwd=tmp_path, - timeout=10, - env=dict(os.environ), - writable_roots=(tmp_path,), + _argv, plan = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + immutable_binding_proofs=(proof,), ) - assert result.returncode == 0, result.stderr - assert surviving is False + 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 plan.launch_payload is not None + assert sources[plan.launch_payload["path_tokens"].index(bwrap[destination_index - 1])] == str( + copied_loader.resolve() + ) -@pytest.mark.skipif( - not sys.platform.startswith("linux") or not shutil.which("bwrap"), - reason="requires Linux kernel namespace containment", -) -def test_sandboxed_launcher_preserves_exit_five_and_clears_inherited_env( - tmp_path: Path, +def test_linux_sandbox_coalesces_descriptor_proven_loader_aliases( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Exercise the exact post-drop handoff inside the empty Linux root.""" - candidate = ( - "import os;raise SystemExit(5 if os.environ.get('ONLY') == 'value' " - "and 'HOSTILE' not in os.environ else 6)" - ) - result, surviving = run_supervised( - [sys.executable, "-c", candidate], - cwd=tmp_path, - timeout=10, - env={"ONLY": "value"}, - writable_roots=(tmp_path,), + """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, ) - assert result.returncode == 5, result.stderr - assert surviving is False + 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, plan = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=tuple((copied, host_loader) for copied in copied_loaders), + immutable_binding_proofs=proofs, + ) -def test_protected_runner_declares_finite_resource_limits() -> None: - limits = SupervisorLimits() - 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_cpu_seconds <= 600 - assert 0 < limits.max_processes <= 256 + 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 plan.launch_payload is not None + assert sources[plan.launch_payload["path_tokens"].index(bwrap[destination_index - 1])] == str( + copied_loaders[0].resolve() + ) @pytest.mark.parametrize( - ("returncode", "timed_out", "resource_limit", "kind", "field"), + "mutation", [ - (23, False, None, TerminationKind.EXIT, ("exit_code", 23)), - (-9, False, None, TerminationKind.SIGNAL, ("signal_number", 9)), - (0, True, None, TerminationKind.TIMEOUT, ("timeout_seconds", 7)), - ( - 0, - False, - "output-bytes", - TerminationKind.RESOURCE_LIMIT, - ("resource_limit", "output-bytes"), - ), - ( - -signal.SIGXCPU, - False, - None, - TerminationKind.SIGNAL, - ("signal_number", signal.SIGXCPU), - ), + "attestation", "identity", "protected_source", "destination", "role", + "path", "digest", "mode", "copied_contents", "unproved_binding", ], ) -def test_termination_evidence_preserves_trusted_cause( - returncode, timed_out, resource_limit, kind, field +def test_linux_sandbox_rejects_nonidentical_loader_alias_authority( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str, ) -> None: - evidence = _termination_evidence( - returncode, - timed_out=timed_out, - timeout_seconds=7, - resource_limit=resource_limit, + """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,) ) - assert evidence.kind is kind - assert getattr(evidence, field[0]) == field[1] + 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("candidate_signal", [signal.SIGXCPU, signal.SIGXFSZ]) -def test_candidate_self_resource_signal_is_only_observed_as_signal( - candidate_signal: signal.Signals, +@pytest.mark.parametrize("mutation", ["copied", "protected", "identity"]) +def test_linux_sandbox_rejects_tampered_descriptor_proof( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str, ) -> None: - """An untrusted child signal is not proof that a configured limit fired.""" - completed = subprocess.run( - [ - sys.executable, - "-c", - "import os,signal;s=int(os.environ['signal_number']);" - "signal.signal(s,signal.SIG_DFL);os.kill(os.getpid(),s)", - ], - env={**os.environ, "signal_number": str(int(candidate_signal))}, - capture_output=True, - text=True, - check=False, - ) - evidence = _termination_evidence( - completed.returncode, - timed_out=False, - timeout_seconds=7, - resource_limit=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, "", ""), ) - - assert completed.returncode == -candidate_signal - assert evidence.kind is TerminationKind.SIGNAL - assert evidence.signal_number == candidate_signal - assert evidence.resource_limit is None - - -@pytest.mark.parametrize( - ("mode", "expected_status", "expected_returncode"), - [ - ("exit", 23, 23), - ("catchable", -signal.SIGXCPU, -signal.SIGXCPU), - ("uncatchable", -signal.SIGKILL, -signal.SIGKILL), - ("stopping", -signal.SIGSTOP, 125), - ], -) -def test_staged_wrapper_safely_preserves_nested_subprocess_status( - mode: str, expected_status: int, expected_returncode: int, -) -> None: - """Exercise the exact helper observation/handoff in an isolated process.""" - child = ";".join(( - "import os,signal,sys", - "mode=sys.argv[1]", - "number={'catchable':signal.SIGXCPU,'uncatchable':signal.SIGKILL," - "'stopping':signal.SIGSTOP}.get(mode)", - "signal.signal(number,signal.SIG_DFL) if number is not None " - "and number not in {signal.SIGKILL,signal.SIGSTOP} else None", - "os.kill(os.getpid(),number) if number is not None else None", - "raise SystemExit(23)", - )) - script = "\n".join(( - "import os,signal,subprocess,sys", - f"argv=[sys.executable,'-c',{child!r},sys.argv[1]]", - "helper_env=os.environ.copy()", - _subprocess_status_observation(), - "print(status,flush=True)", - _subprocess_status_handoff(), - )) - - completed = subprocess.run( - [sys.executable, "-c", script, mode], - capture_output=True, - text=True, - timeout=5, - check=False, + 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 = _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: + proof = replace(proof, descriptor_identity="a" * 64) + + with pytest.raises(RuntimeError, match="immutable binding proof|conflicting bindings"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + immutable_binding_proofs=(proof,), + ) - assert completed.stdout == f"{expected_status}\n" - assert completed.returncode == expected_returncode - assert "Invalid argument" not in completed.stderr +def test_linux_sandbox_helper_rejects_replacement_after_command_construction( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """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) + _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 = _descriptor_runtime_proof(copied_loader, host_loader) -def test_nested_stopping_status_requires_authenticated_header() -> None: - token = "a" * 32 - header = _termination_status_header(token, -signal.SIGSTOP) + _argv, plan = _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + immutable_binding_proofs=(proof,), + ) + 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) - assert _authenticated_nested_returncode(header, token) == -signal.SIGSTOP - assert _authenticated_nested_returncode(header, "b" * 32) is None - assert _authenticated_nested_returncode(b" " * len(header), token) is None - assert _authenticated_nested_returncode( - _termination_status_header(token, -255), token - ) is None + with pytest.raises(RuntimeError, match="immutable binding"): + namespace["_stage_immutable_snapshot"]( + plan.immutable_binding_proofs[0], target, 1234, 2345 + ) + assert supervisor._IMMUTABLE_STAGING_SOURCE in plan.helper_source @pytest.mark.parametrize( - ("mode", "expected_status"), + ("mode", "candidate_permission"), [ - ("exit", 23), - ("exit137", 137), - ("exit152", 152), - ("catchable", -signal.SIGXCPU), - ("uncatchable", -signal.SIGKILL), - ("stopping", -signal.SIGSTOP), + (0o600, stat.S_IRUSR), + (0o640, stat.S_IRUSR), + (0o644, stat.S_IROTH), + (0o700, stat.S_IXUSR), + (0o750, stat.S_IXUSR), + (0o755, stat.S_IXOTH), ], ) -@pytest.mark.timeout(10) -def test_inner_status_supervisor_observes_candidate_before_boundary_normalization( - tmp_path: Path, mode: str, expected_status: int, -) -> None: - """The exact inner helper distinguishes exit 152 from signal 24 safely.""" - channel = tmp_path / "inner-status.fifo" - os.mkfifo(channel, mode=0o600) - read_fd = os.open(channel, os.O_RDONLY | os.O_NONBLOCK) - child = ";".join(( - "import os,signal,sys", - "mode=sys.argv[1]", - "number={'catchable':signal.SIGXCPU,'uncatchable':signal.SIGKILL," - "'stopping':signal.SIGSTOP}.get(mode)", - "signal.signal(number,signal.SIG_DFL) if number is not None " - "and number not in {signal.SIGKILL,signal.SIGSTOP} else None", - "os.kill(os.getpid(),number) if number is not None else None", - "raise SystemExit(137 if mode=='exit137' else 152 if mode=='exit152' else 23)", - )) - try: - completed = subprocess.run( - [ - sys.executable, "-I", "-S", "-c", - supervisor._inner_status_supervisor(), - str(channel), sys.executable, "-c", child, mode, - ], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - record = os.read(read_fd, 128) - finally: - os.close(read_fd) - - assert record == f"{expected_status}\n".encode("ascii") - assert completed.returncode == ( - 125 if mode == "stopping" else ( - 128 - expected_status if expected_status < 0 else expected_status - ) +def test_linux_sandbox_helper_stages_descriptor_mode_for_candidate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: int, + candidate_permission: int, +) -> None: + """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) + 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) + 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) + assert plan.launch_payload is not None + validated_uid, validated_gid = namespace["_validated_candidate_identity"]( + plan.launch_payload["candidate_identity"], list(plan.bwrap_argv) ) - assert not channel.exists() - - -def test_inner_status_supervisor_uses_only_narrow_bootstrap_runtime() -> None: - """The in-bwrap helper must not require an unmounted stdlib module.""" - helper = supervisor._inner_status_supervisor() - assert helper.splitlines()[0] == "import os,sys" - assert "import signal" not in helper - assert "import subprocess" not in helper - assert "__import__" not in helper - assert "os.killpg(pid,9)" in helper - compile(helper, "", "exec") + assert namespace["_stage_immutable_snapshot"]( + plan.immutable_binding_proofs[0], target, validated_uid, validated_gid + ) == 0 + 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 executable: + assert subprocess.run([target], check=False).returncode == 0 + 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 + assert "os.fchown" in plan.helper_source -@pytest.mark.skipif( - not sys.platform.startswith("linux") or not shutil.which("bwrap"), - reason="requires Linux Bubblewrap boundary", -) @pytest.mark.parametrize( - ("mode", "expected_status"), + ("identity", "sudo_uid", "sudo_gid", "argv_mutation"), [ - ("exit", 23), - ("exit137", 137), - ("exit152", 152), - ("isolation", 0), - ("catchable", -signal.SIGXCPU), - ("uncatchable", -signal.SIGKILL), - ("stopping", -signal.SIGSTOP), + ({"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"), ], ) -@pytest.mark.timeout(10) -def test_real_sandbox_preserves_candidate_status_before_bwrap_normalization( - tmp_path: Path, mode: str, expected_status: int, -) -> None: - """Exercise the real staged helper, bwrap PID namespace, and inner helper.""" - required = ("bwrap", "sudo", "unshare", "mount", "umount", "setpriv") - if any(shutil.which(tool) is None for tool in required): - pytest.skip("requires the privileged Linux sandbox toolchain") - if subprocess.run( - ["sudo", "-n", "true"], capture_output=True, check=False, - ).returncode != 0: - pytest.skip("requires passwordless sudo") - child = "\n".join(( - "import os,signal,sys", - "mode=sys.argv[1]", - "if mode=='isolation':", - " checks=(", - " lambda: os.open(f'/proc/{os.getppid()}/fd',os.O_RDONLY|os.O_DIRECTORY),", - " lambda: os.kill(os.getppid(),0),", - " lambda: os.open('/run/pdd-termination',os.O_RDONLY|os.O_DIRECTORY),", - " lambda: os.chmod('/run/pdd-termination',0o755),", - " )", - " for check in checks:", - " try: check()", - " except (PermissionError,FileNotFoundError): continue", - " raise SystemExit(41)", - " raise SystemExit(0)", - "number={'catchable':signal.SIGXCPU,'uncatchable':signal.SIGKILL," - "'stopping':signal.SIGSTOP}.get(mode)", - "if number is not None and number not in {signal.SIGKILL,signal.SIGSTOP}:", - " signal.signal(number,signal.SIG_DFL)", - "if number is not None: os.kill(os.getpid(),number)", - "raise SystemExit(137 if mode=='exit137' else 152 if mode=='exit152' else 23)", - )) - result, surviving = run_supervised( - [sys.executable, "-c", child, mode], cwd=tmp_path, timeout=5, - env={}, writable_roots=(tmp_path,), +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 = list(plan.bwrap_argv) + 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 + ) + - assert result.returncode == expected_status - assert result.termination.kind is ( - TerminationKind.EXIT if expected_status >= 0 else TerminationKind.SIGNAL +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,), ) - assert surviving is False + 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, 1234, 2345 + ) @pytest.mark.parametrize( - ("record", "expected"), + ("field", "value"), [ - (b"-24\n", -24), - (b"137\n", 137), - (b"152\n", 152), - (b"", None), - (b"-24", None), - (b"-24\n-24\n", None), - (b"+24\n", None), - (b"025\n", None), - (b"256\n", None), - (b"-256\n", None), - (b"-255\n", None), - (b"signal=24\n", None), - (b"\xff\n", None), + ("descriptor_identity", "a" * 64), + ("member_role", "launcher"), + ("member_path", "1"), + ("collision_category", "playwright_inferred_runtime"), ], ) -def test_inner_status_record_rejects_spoof_replay_partial_and_malformed( - record: bytes, expected: int | None, +def test_linux_sandbox_rejects_proof_authority_outside_exact_vitest_member( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, field: str, value: str, ) -> None: - namespace = {"inner_record": record, "signal": signal} - exec(supervisor._inner_status_record_parser(), {}, namespace) - assert namespace["inner_status"] == expected - + 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( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (host_loader,) + ) -@pytest.mark.parametrize("mode", ["missing", "malformed", "wrong-token"]) -def test_missing_or_unauthenticated_outer_status_is_sandbox_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: str, -) -> None: - """Infrastructure evidence must not fall back to an ambiguous outer exit.""" - def fake_sandbox(*_args, termination_token: str, **_kwargs): - if mode == "missing": - header = b"" - elif mode == "malformed": - header = b"x" * supervisor._TERMINATION_HEADER_BYTES - else: - header = _termination_status_header("b" * 32, -signal.SIGKILL) - script = ( - "import os,sys;sys.stdin.buffer.read();" - "os.write(2,bytes.fromhex(sys.argv[1]));raise SystemExit(137)" + with pytest.raises(RuntimeError, match="immutable binding proof"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied_loader, host_loader),), + immutable_binding_proofs=(proof,), ) - return [sys.executable, "-c", script, header.hex()], None - monkeypatch.setattr(supervisor, "_sandbox_command", fake_sandbox) - monkeypatch.setattr(supervisor, "_revalidate_privileged_command", lambda _argv: None) - monkeypatch.setattr(supervisor, "_sandbox_library_path", lambda _env: "") - result, surviving = run_supervised( - ["candidate"], cwd=tmp_path, timeout=5, env={}, - writable_roots=(tmp_path,), +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()) + scratch = tmp_path / "scratch" + scratch.mkdir() + proof = _descriptor_runtime_proof( + copied_loader, host_loader, destination=alias + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor._runtime_roots", lambda *_args: (alias,) ) - assert result.returncode == 125 - assert result.termination.kind is TerminationKind.SANDBOX_ERROR - assert result.termination.exit_code == 125 - assert surviving is False + with pytest.raises(RuntimeError, match="immutable binding proof|conflicting"): + _sandbox_command( + ["/bin/true"], + (scratch,), + readable_bindings=((copied_loader, alias),), + immutable_binding_proofs=(proof,), + ) @pytest.mark.parametrize( - ("mode", "kind", "returncode"), + "mutation", [ - ("timeout", TerminationKind.TIMEOUT, 124), - ("output", TerminationKind.RESOURCE_LIMIT, 125), + "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_supervisor_observation_precedes_missing_outer_status( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: str, - kind: TerminationKind, returncode: int, +def test_linux_sandbox_normalizes_malformed_proof_rejection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str, ) -> None: - """A missing status cannot erase limits directly observed by the supervisor.""" - def fake_sandbox(*_args, **_kwargs): - action = ( - "import time;time.sleep(5)" if mode == "timeout" - else "import os;os.write(1,b'x'*2048)" + 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") + ) + + 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"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=(proof,), ) - return [sys.executable, "-c", action], None - monkeypatch.setattr(supervisor, "_sandbox_command", fake_sandbox) - monkeypatch.setattr(supervisor, "_revalidate_privileged_command", lambda _argv: None) - monkeypatch.setattr(supervisor, "_sandbox_library_path", lambda _env: "") - limits = SupervisorLimits(max_output_bytes=512) - result, surviving = run_supervised( - ["candidate"], cwd=tmp_path, timeout=1, env={}, - writable_roots=(tmp_path,), limits=limits, +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: () ) - assert result.returncode == returncode - assert result.termination.kind is kind - assert surviving is False + with pytest.raises(RuntimeError, match="unused immutable binding proof"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=(proof,), + ) -@pytest.mark.skipif( - not sys.platform.startswith("linux") or not shutil.which("bwrap"), - reason="requires Linux Bubblewrap boundary", -) -@pytest.mark.timeout(10) -def test_real_sandbox_candidate_cannot_tamper_with_root_status_monitor( - tmp_path: Path, +def test_linux_sandbox_rejects_duplicate_or_ambiguous_binding_proofs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Candidate uid cannot forge the private record or signal its root parent.""" - required = ("bwrap", "sudo", "unshare", "mount", "umount", "setpriv") - if any(shutil.which(tool) is None for tool in required): - pytest.skip("requires the privileged Linux sandbox toolchain") - if subprocess.run( - ["sudo", "-n", "true"], capture_output=True, check=False, - ).returncode != 0: - pytest.skip("requires passwordless sudo") - candidate = "\n".join(( - "import json,os,signal", - "parent=os.getppid()", - "opened=[]", - "for number in range(64):", - " try:", - " fd=os.open(f'/proc/{parent}/fd/{number}',os.O_WRONLY|os.O_NONBLOCK)", - " except OSError: continue", - " else:", - " opened.append(number);os.write(fd,b'-9\\n');os.close(fd)", - "try:", - " os.kill(parent,signal.SIGSTOP);signal_denied=False", - "except OSError: signal_denied=True", - "try:", - " os.listdir('/run/pdd-termination');private_path_denied=False", - "except OSError: private_path_denied=True", - "print(json.dumps({'opened':opened,'signal_denied':signal_denied,", - " 'private_path_denied':private_path_denied}),flush=True)", - "raise SystemExit(137)", - )) + 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, + ) - result, surviving = run_supervised( - [sys.executable, "-c", candidate], cwd=tmp_path, timeout=5, - env={}, writable_roots=(tmp_path,), + +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), ) - assert result.returncode == 137, result.stderr - assert result.termination.kind is TerminationKind.EXIT - assert result.termination.exit_code == 137 - assert json.loads(result.stdout) == { - "opened": [], "signal_denied": True, "private_path_denied": True, - } - assert surviving is False + with pytest.raises(RuntimeError, match="multiply consumed"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected),), + immutable_binding_proofs=(proof,), + ) -def test_supervised_result_remains_subprocess_compatible() -> None: - evidence = _termination_evidence( - 3, timed_out=False, timeout_seconds=7, resource_limit=None +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: () ) - result = _supervised_result(["checker"], 3, "out", "err", evidence) - assert isinstance(result, subprocess.CompletedProcess) - assert result.returncode == 3 - assert result.termination is evidence + with pytest.raises(RuntimeError, match="conflicting bindings"): + _sandbox_command( + ["/bin/true"], + (tmp_path,), + readable_bindings=((copied, protected), (protected, protected)), + immutable_binding_proofs=(proof,), + ) -@pytest.mark.skipif( - not sys.platform.startswith("linux") or not shutil.which("bwrap"), - reason="requires Linux kernel namespace containment", -) -def test_sandboxed_python_imports_standard_library_after_command_construction( - tmp_path: Path, +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) + _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: () + ) + 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: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "getuid", lambda: 0) + _mock_linux_tools(tmp_path, monkeypatch) + result, surviving = run_supervised( - [sys.executable, "-c", "import math; print(math.pi)"], - cwd=tmp_path, - timeout=10, - env=dict(os.environ), + ["/bin/true"], cwd=tmp_path, timeout=1, env={}, writable_roots=(tmp_path,), ) - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == str(math.pi) + assert result.returncode == 125 + assert "non-root caller" in result.stderr assert surviving is False -def test_linked_libraries_keeps_loader_alias_and_resolved_path( +def test_linux_sandbox_uses_portable_framework_observation_fifo( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - target = tmp_path / "libm-real.so" - target.write_bytes(b"library") - alias = tmp_path / "libm.so.6" - alias.symlink_to(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, f"libm.so.6 => {alias} (0x0000)\n", "" - ), + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", lambda: () ) - assert _linked_libraries(tmp_path / "python") == (target, alias) + 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"], (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 = list(plan.bwrap_argv) + 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] + 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 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 + separator = bwrap.index("--") + candidate_argv = bwrap[separator + 1:] + assert str(fifo) not in candidate_argv + 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 -def test_sandbox_library_path_uses_only_measured_loader_directories( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + +def test_linux_sandbox_does_not_authorize_declared_fifo_staging( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - loader_alias = tmp_path / "lib" / "libm.so.6" - loader_target = tmp_path / "usr" / "lib" / "libm-real.so" + """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.released_runtime_closure_paths", - lambda: ( - ("native/lib/libm.so.6", loader_alias), - ("native/usr/lib/libm-real.so", loader_target), - ("interpreter/python", tmp_path / "python"), - ), + "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) - search_path = _sandbox_library_path({"LD_LIBRARY_PATH": "/checker/lib"}) - - assert search_path.split(os.pathsep)[:2] == [ - str(loader_alias.parent), str(loader_target.parent) - ] - assert "/checker/lib" in search_path.split(os.pathsep) + _argv, plan = _sandbox_command( + ["/bin/true"], (scratch,), + readable_bindings=((candidate_fifo, Path("/run/candidate.fifo")),), + ) + assert plan.launch_payload is not None + assert plan.launch_payload["fifo_indices"] == [] + + +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(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] = [] + + def sandbox(_command, _writable_roots, **kwargs): + control = str(kwargs["control_directory"]) + plan = SimpleNamespace( + unit_name="pdd-validator-00000000000000000000000000000000.scope", + tools=SimpleNamespace(), + launch_payload={ + "schema": "pdd-sandbox-launch-v1", "control": control, + }, + ) + return [sys.executable, "-c", _launch_aware_mock_helper(helper)], plan -@pytest.mark.skipif( - not sys.platform.startswith("linux") or not shutil.which("bwrap"), - reason="requires Linux kernel namespace containment", -) -def test_detached_session_descendant_is_terminated(tmp_path: Path) -> None: - marker = tmp_path / "escaped" - child = ( - "import os,sys,time; os.setsid(); os.close(1); os.close(2); time.sleep(1); " - "open(sys.argv[1], 'w').write('escaped')" + 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}), ) - parent = ( - "import subprocess,sys; " - "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]], " - "start_new_session=False)" + monkeypatch.setattr( + supervisor, "_stop_scope", + stop_scope or (lambda *_args: cleanup.append("scope")), ) - result, surviving = run_supervised( - [sys.executable, "-c", parent, child, str(marker)], - cwd=tmp_path, - timeout=10, - env=dict(os.environ), - writable_roots=(tmp_path,), + monkeypatch.setattr( + supervisor, "_cleanup_staging", lambda _plan: cleanup.append("mounts") ) - assert result.returncode == 0 - assert surviving is False - time.sleep(1.2) - assert not marker.exists() - - -@pytest.mark.skipif( - not sys.platform.startswith("linux") or not shutil.which("bwrap"), - reason="requires Linux kernel namespace containment", -) -def test_detached_descendant_cannot_escape_by_removing_marker(tmp_path: Path) -> None: - marker = tmp_path / "escaped-without-marker" - child = ( - "import os,sys,time; os.setsid(); os.close(1); os.close(2); time.sleep(1); " - "open(sys.argv[1], 'w').write('escaped')" + monkeypatch.setattr( + supervisor, "_scope_properties", lambda *_args: {"Result": "success"} ) - parent = ( - "import os,subprocess,sys,time; child_env=dict(os.environ); " - "child_env.pop('PDD_SUPERVISION_TOKEN', None); " - "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]], " - "env=child_env)" + 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 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 "" + ) + 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') +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) +raise SystemExit({encoded_exit}) +""" + + +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", parent, child, str(marker)], cwd=tmp_path, - timeout=10, env=dict(os.environ), writable_roots=(tmp_path,), + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, + env={}, writable_roots=(tmp_path,), ) - assert result.returncode == 0 + + assert result.returncode == 125 assert surviving is False - time.sleep(1.2) - assert not marker.exists() + assert "phase=launch-handoff: launch write failed" in result.stderr + assert cleanup == ["scope", "mounts"] -@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") -def test_candidate_cannot_read_absolute_host_sentinel(tmp_path: Path) -> None: - sentinel = tmp_path.parent / "protected-host-secret" - sentinel.write_text("secret", encoding="utf-8") +def test_launch_descriptor_and_eof_precede_non_descriptor_ready( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> 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", - "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,), + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, + env={}, writable_roots=(tmp_path,), ) - assert result.returncode != 0 + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "launch-ok" assert surviving is False + assert cleanup == ["scope", "mounts"] -@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") -def test_immediate_detached_child_cannot_forge_checker_result_channel( - tmp_path: Path, +@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: - scratch = tmp_path / "scratch" - scratch.mkdir() - result_channel = tmp_path / "junit.xml" - result_channel.write_text("checker-owned", encoding="utf-8") - child = ( - "import os,sys,time; os.setsid(); time.sleep(.1); " - "open(sys.argv[1], 'w').write('forged')" + """Malformed initial frames never reach ready and retain exact cleanup.""" + cleanup = _mock_scope_run( + tmp_path, monkeypatch, _terminal_helper(0, False) ) - parent = ( - "import os,subprocess,sys; env=dict(os.environ); " - "env.pop('PDD_SUPERVISION_TOKEN', None); " - "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]], env=env)" + + 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 ) - 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,), + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, + env={}, writable_roots=(tmp_path,), ) - assert completed.returncode == 0 - time.sleep(0.3) - assert result_channel.read_text(encoding="utf-8") == "checker-owned" + assert result.returncode == 125 + assert surviving is False + assert "exited before verification" in result.stderr + assert cleanup == ["scope", "mounts"] -@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") -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,), - ) - assert len(result.stdout.encode()) <= 16 * 1024 * 1024 - assert result.returncode != 0 +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) -@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") -@pytest.mark.parametrize( - "program", - [ - "bytearray(256 * 1024 * 1024)", - "open('large', 'wb').truncate(8 * 1024 * 1024)", - "while True: pass", - "import subprocess,sys,time; " - "children=[subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(2)']) " - "for _ in range(16)]; [child.wait() for child in children]", - ], -) -def test_memory_disk_and_process_limits_fail_closed(tmp_path: Path, program: str) -> None: - limits = SupervisorLimits( - max_memory_bytes=128 * 1024 * 1024, - max_writable_bytes=1024 * 1024, - max_cpu_seconds=1, - max_processes=4, - ) - result, _surviving = run_supervised( - [sys.executable, "-c", program], cwd=tmp_path, timeout=10, - env=dict(os.environ), writable_roots=(tmp_path,), limits=limits, - ) - assert result.returncode != 0 + assert result.returncode == 125 + assert "trusted postprocessing" in result.stderr + assert surviving is False -def test_macos_fails_closed_without_kernel_lifetime_containment( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_scope_setup_deadline_is_not_candidate_timeout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(supervisor, "_TRUSTED_SETUP_SECONDS", .03) + cleanup = _mock_scope_run(tmp_path, monkeypatch, "import time;time.sleep(30)") + result, surviving = run_supervised( - ["/bin/true"], cwd=tmp_path, timeout=1, env={}, - writable_roots=(tmp_path,), + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.03, + env=dict(os.environ), writable_roots=(tmp_path,), ) + assert result.returncode == 125 - assert "cannot prove process lifetime isolation" in result.stderr + assert result.termination.kind is supervisor.TerminationKind.SANDBOX_ERROR + assert "phase=scope-setup" in result.stderr + assert cleanup == ["scope", "mounts"] assert surviving is False -def test_file_size_limit_uses_output_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] - +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) -@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") -def test_binary_output_has_aggregate_limit_and_deterministic_decode(tmp_path: Path) -> None: - 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), - writable_roots=(tmp_path,), limits=limits, + 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 len(result.stdout.encode("utf-8")) + len(result.stderr.encode("utf-8")) <= 3 * 1024 - assert "\ufffd" in result.stdout + assert result.returncode == 0, result.stderr + assert cleanup == ["scope", "mounts"] + 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) - assert _live_processes({123: "old"}) == set() +@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) + ) -@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") -def test_writable_churn_cannot_escape_supervisor_cleanup(tmp_path: Path) -> None: - program = ( - "import pathlib, threading, time\n" - "root=pathlib.Path('.')\n" - "def churn():\n" - " while True:\n" - " p=root/'churn'; p.write_bytes(b'x'); p.unlink(missing_ok=True)\n" - "threading.Thread(target=churn, daemon=True).start(); time.sleep(.5)\n" + 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: + cleanup = _mock_scope_run(tmp_path, monkeypatch, _terminal_helper(124, True)) + result, surviving = run_supervised( - [sys.executable, "-c", program], cwd=tmp_path, timeout=5, + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, env=dict(os.environ), writable_roots=(tmp_path,), ) - assert result.returncode == 0 + + 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_parallel_staged_and_signer_bwrap_do_not_share_host_mounts( - tmp_path: Path, +def test_helper_exit_must_match_validated_timeout_record( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Keep a staged sandbox live while a signer starts in a sibling process.""" - if not sys.platform.startswith("linux"): - pytest.skip("requires Linux mount namespaces") - required = ("bwrap", "sudo", "unshare", "mount", "umount", "setpriv") - if any(shutil.which(tool) is None for tool in required): - pytest.skip("requires the privileged Linux sandbox toolchain") - if subprocess.run( - ["sudo", "-n", "true"], capture_output=True, check=False, - ).returncode != 0: - pytest.skip("requires passwordless sudo") + _mock_scope_run( + tmp_path, monkeypatch, + _terminal_helper(124, True, helper_exit=125), + ) - scratch = tmp_path / "scratch" - scratch.mkdir() - started = scratch / "candidate-started" - outcome: list[tuple[subprocess.CompletedProcess[str], bool]] = [] - observed_mount_leak = threading.Event() - sampling_done = threading.Event() - - def sample_parent_mounts() -> None: - while not sampling_done.wait(0.001): - if "pdd-binds-" in Path("/proc/self/mountinfo").read_text( - encoding="utf-8" - ): - observed_mount_leak.set() + result, _surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=.1, + env=dict(os.environ), writable_roots=(tmp_path,), + ) - def run_staged_candidate() -> None: - outcome.append(run_supervised( - [ - sys.executable, "-c", - "import pathlib,sys,time; " - "pathlib.Path(sys.argv[1]).write_text('started'); time.sleep(1.5)", - str(started), - ], - cwd=scratch, - timeout=10, - env=dict(os.environ), - writable_roots=(scratch,), - )) + assert result.returncode == 125 + assert "helper exit" in result.stderr - sampler = threading.Thread(target=sample_parent_mounts) - candidate = threading.Thread(target=run_staged_candidate) - sampler.start() - candidate.start() - try: - deadline = time.monotonic() + 10 - while not started.exists() and candidate.is_alive() and time.monotonic() < deadline: - time.sleep(0.005) - assert started.exists(), outcome - signer = run_signer( - (sys.executable, "-c", "print('signer-started')"), b"", timeout=5, - ) - assert signer.returncode == 0, signer.stderr.decode(errors="replace") - assert signer.stdout.strip() == b"signer-started" - finally: - candidate.join(timeout=15) - sampling_done.set() - sampler.join(timeout=2) +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)) - assert not candidate.is_alive() - assert outcome and outcome[0][0].returncode == 0, outcome - assert outcome[0][1] is False - assert not observed_mount_leak.is_set() - assert "pdd-binds-" not in Path("/proc/self/mountinfo").read_text( - encoding="utf-8" + 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_privileged_helper_cannot_import_candidate_sitecustomize( - tmp_path: Path, + +def test_helper_timeout_with_cleanup_failure_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Candidate Python hooks must not execute before the sandbox uid drop.""" - if not sys.platform.startswith("linux"): - pytest.skip("requires Linux privileged sandbox startup") - required = ("bwrap", "sudo", "unshare", "mount", "umount", "setpriv") - if any(shutil.which(tool) is None for tool in required): - pytest.skip("requires the privileged Linux sandbox toolchain") - if subprocess.run( - ["sudo", "-n", "true"], capture_output=True, check=False, - ).returncode != 0: - pytest.skip("requires passwordless sudo") - - hooks = tmp_path / "hooks" - scratch = tmp_path / "scratch" - hooks.mkdir() - scratch.mkdir() - sentinel = scratch / "root-hook-ran" - (hooks / "sitecustomize.py").write_text( - "import os, pathlib\n" - f"if os.geteuid() == 0: pathlib.Path({str(sentinel)!r}).write_text('unsafe')\n", - encoding="utf-8", + def fail_cleanup(*_args): + raise RuntimeError("scope remained loaded") + + _mock_scope_run( + tmp_path, monkeypatch, _terminal_helper(124, True), stop_scope=fail_cleanup ) - result, surviving = run_supervised( - ["/bin/true"], cwd=scratch, timeout=10, - env={"PATH": os.environ.get("PATH", ""), "PYTHONPATH": str(hooks)}, - writable_roots=(scratch,), readable_roots=(hooks,), + 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 surviving is False - assert not sentinel.exists() + assert result.returncode == 125 + assert result.termination.kind is supervisor.TerminationKind.SANDBOX_ERROR + assert "scope-cleanup" in result.stderr -def test_candidate_ld_preload_never_loads_in_root_setup_processes( - tmp_path: Path, +def test_helper_stall_after_candidate_exit_is_not_timeout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A candidate loader hook may run only after the sandbox credential drop.""" - if not sys.platform.startswith("linux"): - pytest.skip("requires Linux dynamic-loader and sudo boundaries") - required = ( - "bwrap", "sudo", "unshare", "mount", "umount", "setpriv", "gcc", + helper = """import pathlib,sys,time +control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') +while not (control/'start').exists(): time.sleep(.001) +time.sleep(30) +""" + _mock_scope_run(tmp_path, monkeypatch, helper) + 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,), ) - if any(shutil.which(tool) is None for tool in required): - pytest.skip("requires the privileged Linux sandbox toolchain and gcc") - if subprocess.run( - ["sudo", "-n", "true"], capture_output=True, check=False, - ).returncode != 0: - pytest.skip("requires passwordless sudo") - scratch = tmp_path / "scratch" - scratch.mkdir() - sentinel = scratch / "root-preload-ran" - source = tmp_path / "preload.c" - library = scratch / "preload.so" - source.write_text( - "#include \n#include \n" - "__attribute__((constructor)) static void probe(void) {\n" - f' if (geteuid() == 0) {{ int fd=open("{sentinel}",O_CREAT|O_WRONLY,0600); ' - "if(fd>=0) close(fd); }\n}\n", - encoding="utf-8", + assert result.returncode == 125 + assert "protected candidate record" in result.stderr + + +@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 = f"""import pathlib,sys,time +control=pathlib.Path(sys.argv[1]);(control/'ready').write_text('ready') +while not (control/'start').exists(): time.sleep(.001) +raise RuntimeError({failure!r}) +""" + _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,), ) - subprocess.run( - ["gcc", "-shared", "-fPIC", "-o", str(library), str(source)], check=True, + + assert result.returncode == 125 + assert "timeout phase=candidate-execution" not in result.stderr + + +def test_ordinary_candidate_exit_124_is_reserved_and_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _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,), ) - result, surviving = run_supervised( - ["/bin/true"], cwd=scratch, timeout=10, - env={"PATH": os.environ.get("PATH", ""), "LD_PRELOAD": str(library)}, - writable_roots=(scratch,), + assert result.returncode == 125 + assert "reserved exit status 124" in result.stderr + + +@pytest.mark.parametrize( + "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}, + ], +) +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) - assert result.returncode == 0, result.stderr + 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 + + +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_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_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) + + 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 + + +def test_sandbox_directory_bind_provides_parent_for_nested_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + nested = tmp_path / "candidate-venv" / "bin" + nested.mkdir(parents=True) + interpreter = nested / "python" + interpreter.write_text("python", encoding="utf-8") + 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: () + ) + + _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" + } + assert str(nested) not in directory_targets + + +def test_sandbox_binds_resolved_runtime_sources_at_original_destinations( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The namespace must retain the executable and loader lookup spellings.""" + runtime = tmp_path / "runtime" + executable_source = runtime / "python-real" + executable_source.parent.mkdir() + executable_source.write_text("python", encoding="utf-8") + executable_destination = tmp_path / "toolcache" / "bin" / "python" + executable_destination.parent.mkdir(parents=True) + executable_destination.symlink_to(executable_source) + loader_source = runtime / "libc-real.so" + loader_source.write_bytes(b"library") + loader_destination = tmp_path / "loader" / "libc.so.6" + loader_destination.parent.mkdir() + loader_destination.symlink_to(loader_source) + candidate = tmp_path / "candidate" / "bin" / "python" + candidate.parent.mkdir(parents=True) + candidate.write_text("python", encoding="utf-8") + workdir = tmp_path / "work" + workdir.mkdir() + + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr( + "pdd.sync_core.supervisor._SUPERVISOR_EXECUTABLE", + executable_destination, + ) + _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._runtime_directories", tuple) + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", + lambda: ( + ("native/loader/libc.so.6", loader_destination), + ("native/runtime/libc-real.so", loader_source), + ), + ) + + _argv, plan = _sandbox_command( + [str(candidate), "-c", "pass"], (workdir,), cwd=workdir + ) + 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] + assert plan.launch_payload is not None + tokens = plan.launch_payload["path_tokens"] + return sources[tokens.index(placeholder)] + + assert str(executable_destination.parent) in { + bwrap[index + 1] for index, value in enumerate(bwrap[:-1]) + if value == "--dir" + } + assert bind_source(executable_destination) == str(executable_source) + assert bind_source(loader_destination) == str(loader_source) + + +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not shutil.which("bwrap"), + reason="requires Linux kernel namespace containment", +) +def test_sandboxed_python_minimal_smoke(tmp_path: Path) -> None: + """A protected namespace can start the configured interpreter.""" + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], + cwd=tmp_path, + timeout=10, + env=_credential_free_environment(tmp_path), + writable_roots=(tmp_path,), + ) + + assert result.returncode == 0, result.stderr + assert surviving is False + + +def test_protected_runner_declares_finite_resource_limits() -> None: + limits = SupervisorLimits() + 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 + + +@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( + 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] + assert "RLIMIT_NPROC" not in command[2] + + +def test_linux_sandbox_binds_probe_identity_to_systemd_scope_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """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) + tools = _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: () + ) + + argv, plan = _sandbox_command([sys.executable, "-c", "pass"], (tmp_path,)) + + assert argv[:3] == [tools["sudo"], "-n", tools["systemd-run"]] + separator = argv.index("--") + assert argv[separator + 1:separator + 7] == [ + tools["unshare"], "--mount", "--propagation", "private", "--wd", "/", + ] + assert "--scope" in argv + assert "--wait" not in argv + assert "--collect" not in argv + assert f"--unit={plan.unit_name}" 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_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.""" + 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: () + ) + + argv, plan = _sandbox_command( + [sys.executable, "-c", "pass"], (tmp_path,), candidate_timeout=17 + ) + helper = plan.helper_source + + 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 + 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 + 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( + "os.execvpe(argv[0],argv,os.environ)" + ) + 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 supervisor._PIDFD_PROTOCOL_SOURCE.strip() in helper + assert "timeout=limits['trusted_timeout']" in helper + 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 + 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 + assert "statvfs" in helper + assert "writable quota" in helper + assert "copytree" in helper + 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( + "protocol_send({'kind':'ready'" + ) + assert helper.index("mount_lines=") < helper.index( + "(control/'ready').write_text" + ) + assert plan.bwrap_argv.count("@PDD-CGROUP@") == 1 + cgroup_source = plan.bwrap_argv.index("@PDD-CGROUP@") + 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): + 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() + + 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_scope_cleanup_error_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """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.subprocess, "run", + lambda command, **_kwargs: subprocess.CompletedProcess( + command, 1, "", "cannot remove scope" + ), + ) + + with pytest.raises(RuntimeError, match="scope teardown failed"): + supervisor._stop_scope( + "pdd-validator-00000000000000000000000000000000.scope", tools + ) + + +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: + """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() + 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") + 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(): + (candidate / name).write_text(value, encoding="ascii") + 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: cgroup) + plan = supervisor._ScopePlan( + supervisor._scope_unit_name(), tmp_path, "", (), (), (), tools, + ) + + actual_cgroup, memory, pids = supervisor._probe_scope( + plan, SupervisorLimits() + ) + + assert actual_cgroup == candidate + 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_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=_credential_free_environment(tmp_path), 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"), + [ + ("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_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: + """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", "show", "stop", "show", + ] + assert all(unit in command for command in commands) + 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", +) +def test_sandboxed_python_imports_standard_library_after_command_construction( + tmp_path: Path, +) -> None: + result, surviving = run_supervised( + [sys.executable, "-c", "import math; print(math.pi)"], + cwd=tmp_path, + timeout=10, + env=_credential_free_environment(tmp_path), + writable_roots=(tmp_path,), + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(math.pi) + assert surviving is False + + +def test_linked_libraries_keeps_loader_alias_and_resolved_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = tmp_path / "libm-real.so" + target.write_bytes(b"library") + alias = tmp_path / "libm.so.6" + alias.symlink_to(target) + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr( + "pdd.sync_core.supervisor.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + [], 0, f"libm.so.6 => {alias} (0x0000)\n", "" + ), + ) + + assert _linked_libraries(tmp_path / "python") == (target, alias) + + +def test_sandbox_library_path_uses_only_measured_loader_directories( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + loader_alias = tmp_path / "lib" / "libm.so.6" + loader_target = tmp_path / "usr" / "lib" / "libm-real.so" + monkeypatch.setattr( + "pdd.sync_core.supervisor.released_runtime_closure_paths", + lambda: ( + ("native/lib/libm.so.6", loader_alias), + ("native/usr/lib/libm-real.so", loader_target), + ("interpreter/python", tmp_path / "python"), + ), + ) + + search_path = _sandbox_library_path({"LD_LIBRARY_PATH": "/checker/lib"}) + + assert search_path.split(os.pathsep)[:2] == [ + str(loader_alias.parent), str(loader_target.parent) + ] + assert "/checker/lib" in search_path.split(os.pathsep) + + +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not shutil.which("bwrap"), + reason="requires Linux kernel namespace containment", +) +def test_detached_session_descendant_is_terminated(tmp_path: Path) -> None: + marker = tmp_path / "escaped" + child = ( + "import os,sys,time; os.setsid(); os.close(1); os.close(2); time.sleep(1); " + "open(sys.argv[1], 'w').write('escaped')" + ) + parent = ( + "import subprocess,sys; " + "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]], " + "start_new_session=False)" + ) + result, surviving = run_supervised( + [sys.executable, "-c", parent, child, str(marker)], + cwd=tmp_path, + timeout=10, + env=_credential_free_environment(tmp_path), + writable_roots=(tmp_path,), + ) + assert result.returncode == 0 + assert surviving is False + time.sleep(1.2) + assert not marker.exists() + + +@pytest.mark.skipif( + not sys.platform.startswith("linux") or not shutil.which("bwrap"), + reason="requires Linux kernel namespace containment", +) +def test_detached_descendant_cannot_escape_by_removing_marker(tmp_path: Path) -> None: + marker = tmp_path / "escaped-without-marker" + child = ( + "import os,sys,time; os.setsid(); os.close(1); os.close(2); time.sleep(1); " + "open(sys.argv[1], 'w').write('escaped')" + ) + parent = ( + "import os,subprocess,sys,time; child_env=dict(os.environ); " + "child_env.pop('PDD_SUPERVISION_TOKEN', None); " + "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]], " + "env=child_env)" + ) + result, surviving = run_supervised( + [sys.executable, "-c", parent, child, str(marker)], cwd=tmp_path, + timeout=10, env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), + ) + assert result.returncode == 0 + assert surviving is False + time.sleep(1.2) + assert not marker.exists() + + +@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") +def test_candidate_cannot_read_absolute_host_sentinel(tmp_path: Path) -> None: + sentinel = tmp_path.parent / "protected-host-secret" + sentinel.write_text("secret", encoding="utf-8") + result, surviving = run_supervised( + [ + sys.executable, "-c", + "import pathlib,sys; pathlib.Path(sys.argv[1]).read_text()", + str(sentinel), + ], + cwd=tmp_path, timeout=10, env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), + ) + assert result.returncode != 0 + assert surviving is False + + +@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") +def test_immediate_detached_child_cannot_forge_checker_result_channel( + tmp_path: Path, +) -> None: + scratch = tmp_path / "scratch" + scratch.mkdir() + result_channel = tmp_path / "junit.xml" + result_channel.write_text("checker-owned", encoding="utf-8") + child = ( + "import os,sys,time; os.setsid(); time.sleep(.1); " + "open(sys.argv[1], 'w').write('forged')" + ) + parent = ( + "import os,subprocess,sys; env=dict(os.environ); " + "env.pop('PDD_SUPERVISION_TOKEN', None); " + "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]], env=env)" + ) + completed, _surviving = run_supervised( + [sys.executable, "-c", parent, child, str(result_channel)], + cwd=scratch, timeout=10, env=_credential_free_environment(tmp_path), + 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_output_is_bounded(tmp_path: Path) -> None: + result, _surviving = run_supervised( + [sys.executable, "-c", "print('x' * 20000000)"], cwd=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 + + +@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") +@pytest.mark.parametrize( + "program", + [ + "bytearray(256 * 1024 * 1024)", + "open('large', 'wb').truncate(8 * 1024 * 1024)", + "while True: pass", + "import subprocess,sys,time; " + "children=[subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(2)']) " + "for _ in range(16)]; [child.wait() for child in children]", + ], +) +def test_memory_disk_and_process_limits_fail_closed(tmp_path: Path, program: str) -> None: + limits = SupervisorLimits( + max_memory_bytes=128 * 1024 * 1024, + max_writable_bytes=1024 * 1024, + max_cpu_seconds=1, + max_processes=4, + ) + result, _surviving = run_supervised( + [sys.executable, "-c", program], cwd=tmp_path, timeout=10, + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), limits=limits, + ) + assert result.returncode != 0 + + +def test_writable_accounting_errors_fail_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + inaccessible = tmp_path / "inaccessible" + inaccessible.mkdir() + monkeypatch.setattr( + Path, "rglob", lambda _path, _pattern: (_ for _ in ()).throw(OSError("denied")) + ) + + with pytest.raises(RuntimeError, match="writable accounting failed"): + supervisor._writable_size((inaccessible,)) + + +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_candidate_deadline_stops_before_trusted_postprocessing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + 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) +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(record),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): + control = str(kwargs["control_directory"]) + plan = SimpleNamespace( + unit_name="pdd-validator-00000000000000000000000000000000.scope", + tools=SimpleNamespace(), + launch_payload={ + "schema": "pdd-sandbox-launch-v1", "control": control, + }, + ) + return [sys.executable, "-c", _launch_aware_mock_helper(helper)], 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"} + ) + 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( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "platform", "darwin") + result, surviving = run_supervised( + [sys.executable, "-c", "pass"], cwd=tmp_path, timeout=1, env={}, + writable_roots=(tmp_path,), + ) + assert result.returncode == 125 + assert "cannot prove process lifetime isolation" in result.stderr + 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) + assert "987654" in command + assert "1234" not in command[1:7] + + +@pytest.mark.skipif(not shutil.which("bwrap"), reason="requires Linux bubblewrap") +def test_binary_output_has_aggregate_limit_and_deterministic_decode(tmp_path: Path) -> None: + 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=_credential_free_environment(tmp_path), + writable_roots=(tmp_path,), limits=limits, + ) + assert result.returncode == 125 + assert len(result.stdout.encode("utf-8")) + len(result.stderr.encode("utf-8")) <= 3 * 1024 + 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, "_fresh_supervision_token", lambda: 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"), + 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=_credential_free_environment(tmp_path), + 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=_credential_free_environment(tmp_path), 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 + + +_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 wchan(pid_dir): + return read_text(pid_dir/'wchan',pid_dir,'read wchan').strip() + +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: + continue + except (OSError,ValueError) as error: + fail(f'read descriptor: pid={pid_dir.name} fd={entry.name}: ' + 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.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]) + +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(): + 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') + 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 + 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') + 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') + 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=[] + 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: + 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) + 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: + 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_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=( + 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 + 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) +""" + + +@dataclass(frozen=True) +class _RootProcSelection: + """Bound the privileged procfs scan to selected process identities.""" + + watch_pids: tuple[int, ...] = () + scope_only: bool = False + root_holders: tuple[dict[str, object], ...] = () + + +def _root_proc_scan( + *, cgroup: Path | None = None, namespace: dict[str, object] | None = None, + targets: tuple[Path, ...] = (), target_prefix: Path | None = None, + selection: _RootProcSelection = _RootProcSelection(), +) -> 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(selection.watch_pids), + "scope_only": selection.scope_only, + "root_holders": list(selection.root_holders), + } + 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 _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 _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 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 + + +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, separators=(",", ":")) + + +@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 + + +_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, +) -> 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: + scan_stdin = _held_namespace_scan_stdin(holder, context) + payload["scanner"] = _NAMESPACE_MOUNT_SCANNER_SOURCE + 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, ...]: + """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("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"), + ) + + +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 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: + 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') +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(): + 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') +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) + +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: + 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: + 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 + 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') +PHASE='root-pin' +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) + 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.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') +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') +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') + 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': + if scan_fd is None: + transport_failure('invariant') + command=[payload['python'],'-I','-S','-c',payload['scanner'], + '/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), + '--root=/proc/self/fd/'+str(root_fd),'--',*command]) +""" + + +_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 +MAX_EXACT_SAMPLES=16 +MAX_RELATED_MOUNTS=8 +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') +PHASE='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 + 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.encode('utf-8'))>MAX_PATH_BYTES: + raise RuntimeError(f'oversized {label}') + return value + +def limited(value,label): + value=raw_text(value,label) + 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) + 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') + return path + +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: + pathlib.PurePosixPath(point).relative_to(prefix); return True + except ValueError: + 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 + +def valid_optional_fields(fields): + for field in fields: + field=unescape(field,'mountinfo optional field') + tag,separator,value=field.partition(':') + 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': + 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') +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') +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) +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') +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 + 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'])] +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=exact[:MAX_EXACT_SAMPLES] +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] +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], + 'mount_namespace':{'link':namespace_link,'inode':namespace_inode}, + '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, + 'paths':paths, + 'mountinfo':{'mounts':mounts,'exact_count':len(exact), + 'samples':exact_samples,'related':related}, +} +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') +""" + + +def _parse_held_namespace_diagnostic_legacy( + 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)) + + +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_BYTES + if detail else _HELD_NAMESPACE_DIAGNOSTIC_MAX_PATH_BYTES + ) + if ( + 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 + ) + ): + 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(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 [point for point, _mount_id in sample_keys] != mount_points[:len(sample_keys)] + ): + reject("has unordered mount samples") + if ( + 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 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) + + +_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"), +) + + +def _held_namespace_scan_stderr_category(line: str) -> str | None: + """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 + 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 + + +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" + 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 + 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 _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" + f"{revalidator_marker}\n" + "RuntimeError: unexpected failure payload=/private/secret\n" + f"{scanner_marker}\n" + ) + + classified = _sanitized_held_namespace_scan_stderr(stderr) + + assert classified == f"{revalidator_marker},{scanner_marker}" + 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 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 = ( + 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_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 = ( + f"{root_marker}\n" + f"{namespace_marker}\n" + f"{namespace_marker}\n" + ) + + assert _sanitized_held_namespace_scan_stderr(stderr) == ( + 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 = ( + *( + _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"{category}\n" for category in categories) + + classified = _sanitized_held_namespace_scan_stderr(stderr) + + assert classified == ",".join(categories[-4:]) + 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", + ), + ( + "nsenter: unexpected platform wording path=/private/secret", + "nsenter-unclassified", + ), + ), + ids=("reassociate", "execute", "unclassified"), +) +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, + 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") + + +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 _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 _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 _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, +) -> 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"])) + 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"] + 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() + + 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=stdin is None, + input=stdin, 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, + 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. + 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}") + + 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() + coordinator_present = scan is not None and any( + _process_key(record) == expected_coordinator for record in scan["watched"] + ) + 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( + _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_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 + return _namespace_holder_command_payload( + namespace_holder, namespace, operation, mount=mount, + context=holder_context(), + ) + + def holder_mount_scan() -> tuple[tuple[Path, ...], bool] | None: + if namespace_holder is None: + 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, + ] + 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", 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=" + + _held_namespace_scan_child_returncode_category(completed.returncode) + + " stderr_tail=" + + _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( + output, namespace=namespace, holder=namespace_holder, + control_prefix=control_prefix, targets=scan_targets, + ) + 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=(",", ":")) + ) + return mounts, diagnostic["root"]["matches"] is True + + 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 ( + namespace_holder is None + or namespace_holder.get("holder_kind") != "fd" + or scan is None + or scan["current_holders"] + 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( + unmount_mounts, key=lambda path: (len(path.parts), str(path)), reverse=True, + ): + if namespace_holder is None: + argv = ["sudo", "-n", "umount", str(mount)] + else: + 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() + 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_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) + ) + for holder in ownership.get("external_holders", ()): + expected = _process_key(holder) + holder_scan = scanner( + selection=_RootProcSelection((int(holder["pid"]),)), + ) + if any(_process_key(record) == expected for record in holder_scan["watched"]): + 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() + or "external namespace holder termination failed" + ) + 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"] + final_authoritative_mounts: tuple[Path, ...] | None = None + while time.monotonic() < verification_deadline: + scan = scan_owned() + load_state = command( + ["sudo", "-n", "systemctl", "show", unit, + "--property=LoadState", "--value"], + "verify exact scope absent", + ) + 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 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 = [ + _process_key(record) for record in ownership.get("external_holders", ()) + if _process_key(record) in current + ] + final_leaks = [] + 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"]: + 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 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)) + 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 as primary: + try: + failure_cleanup() + except BaseException as cleanup: # pylint: disable=broad-exception-caught + _cleanup_failure(primary, 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") + compile(_NSENTER_REVALIDATOR_SOURCE, "", "exec") + 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: RuntimeError: " + "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 ( + (_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 + 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") + 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( + 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/')) +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() + def canonical_fd_reference(value: str, prefix: str) -> int: + """Parse one fixed-role canonical FD argument.""" + assert value.startswith(prefix) + descriptor = value.removeprefix(prefix) + assert descriptor.isascii() and descriptor.isdecimal() + number = int(descriptor) + 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 = { + namespace_descriptor, root_descriptor, scan_descriptor, + } + assert len(expected_transport_descriptors) == 3 + assert captured["inheritable"] == sorted(expected_transport_descriptors) + 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", "expected_category"), + ( + (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"pdd-held-namespace-failure:scanner:transport:runtimeerror"), + ), + ids=("malformed", "trailing", "oversized"), +) +def test_namespace_scanner_rejects_exact_eof_trailing_oversized_and_malformed_frames( + raw: bytes, expected_category: 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 completed.stderr == expected_category + b"\n" + finally: + 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 completed.stderr == ( + b"pdd-held-namespace-failure:scanner:transport:runtimeerror\n" + ) + finally: + 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: + tag, separator, value = field.partition(":") + if ( + 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 + + +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_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") + ) + 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"): + _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") + 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} + + 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) for target in targets], + "mount_namespace": {"link": "mnt:[11]", "inode": 11}, + "root": { + "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), "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": [], + }, + } + raw = json.dumps(diagnostic, sort_keys=True, separators=(",", ":")) + parsed, mounts = _parse_held_namespace_diagnostic( + raw, namespace={"link": "mnt:[11]", "inode": 11}, + holder=holder, control_prefix=prefix, targets=targets, + ) + 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 + 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"] = { + "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}, + holder=holder, control_prefix=prefix, targets=targets, + ) + 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("/"), 999)] + malformed.append(json.dumps(mixed_mounts)) + missing_related = json.loads(raw) + missing_related["mountinfo"] = { + "mounts": [], "exact_count": 0, "samples": [], "related": [], + } + malformed.append(json.dumps(missing_related)) + unordered_samples = json.loads(raw) + unordered_samples["mountinfo"]["samples"] = list(reversed( + unordered_samples["mountinfo"]["samples"] + )) + malformed.append(json.dumps(unordered_samples)) + 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"] + ) + 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"): + _parse_held_namespace_diagnostic( + value, namespace={"link": "mnt:[11]", "inode": 11}, + holder=holder, control_prefix=prefix, targets=targets, + ) + + +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_stacked_nested_held_inventory_exactly( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Held-root cleanup preserves stacked layers and unmounts nested paths first.""" + prefix = tmp_path / "pdd-scope-owned" + stale = prefix / "binds" / "stale" + shared = prefix / "binds" / "shared" + nested = shared / "nested" + 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(((shared, shared, nested), ())) + 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(nested), str(shared), str(shared)] + + +@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, +) -> 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], + } + external = holder | {"pid": 33, "start_time": "101"} + if failure == "transport": + ownership["external_holders"] = [external] + 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 + selection = kwargs.get("selection") + return { + "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 [], + } + + 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\n" + "pdd-held-namespace-failure:scanner:mountinfo:runtimeerror\n" + ), + ) + if failure == "transport": + return SimpleNamespace( + 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="") + + 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-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-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 + + +@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, + ) + + +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: + 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: + """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"), + selection=_RootProcSelection((123,), scope_only=True), + ) + + assert captured["scope_only"] is True + + +@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} + exact |= { + "root_fd": 8, "root_path": "/proc/22/fd/8", + "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"): + _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) + 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:") + ] + 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], + "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, + "root_mode": root_metadata.st_mode, "root_mnt_id": root_mnt_id, + } + 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, separators=(",", ":"))], + capture_output=True, text=True, check=False, timeout=5, + ) + assert completed.returncode == 0, completed.stderr + 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, + json.dumps(payload, sort_keys=True, separators=(",", ":"))], + capture_output=True, text=True, check=False, timeout=5, + ) + assert rejected.returncode != 0 + 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) + 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, separators=(",", ":"))], + capture_output=True, text=True, check=False, timeout=5, + ) + assert rejected.returncode != 0 + assert rejected.stderr == _held_namespace_scan_failure_marker( + "revalidator", "root-pin", "runtimeerror", + ) + "\n" + finally: + os.close(host_root) + finally: + os.close(root_descriptor) + os.close(namespace_descriptor) + + +def test_external_holder_termination_requires_complete_pidfd_identity() -> None: + """Termination binds canonical complete holder data to a pidfd, never a PID.""" + namespace = {"link": "mnt:[11]", "inode": 11} + holder = { + "holder_kind": "fd", "pid": 2_147_483_647, "start_time": "100", + "namespace": "mnt:[1]", "namespace_inode": 1, + "fd": 7, "fd_path": "/proc/2147483647/fd/7", + "fd_link": "mnt:[11]", "fd_inode": 11, + } + + 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"}, + 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 + + +@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"} + reused = {"pid": 123, "start_time": "101"} + + assert _process_key(original) != _process_key(reused) + + +@pytest.mark.parametrize("failure", ("scan", "watched")) +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.""" + 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 = [] + calls = 0 + + 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"]: + return SimpleNamespace(returncode=0, stdout="not-found\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + coordinator_record = {"pid": coordinator, "start_time": "unit-test"} + selections = [] + + 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": [], + "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}, + "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"], + } + + def scan(): + if failure == "scan": + raise AssertionError("injected initial scan failure") + return {"watched": []} + + def assert_watched(scan_result): + if scan_result["watched"] != ["coordinator"]: + raise AssertionError("injected initial watched assertion failure") + + with pytest.raises(AssertionError, match="injected initial"): + _run_stalled_observation_setup( + scan, assert_watched, + lambda: _fallback_stalled_observation_cleanup( + ownership, (read_fd,), runner=runner, scanner=scanner, + ), + ) + + with pytest.raises(OSError): + 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"], + ["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", "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 = [ + {"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"), + reason="requires hosted Linux privileged descriptor supervision", +) +@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", + "initial-scan-failure", + "initial-watched-assertion-failure", + "fd-only-namespace-holder-cleanup", +), 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", + "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, +) -> 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 fcntl,json,os +def inventory(): + 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')) +""" + 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 None: + supervisor._write_descriptor_frame_fd( + process.stdin.fileno(), frame, + supervisor._DESCRIPTOR_PROTOCOL_MAX_CONTROL_BYTES, time.monotonic() + 5, + ) + + 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, + ) + + 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 scope_control_group(unit: str) -> Path: + """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: + 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 + 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 + last_scan = None + while time.monotonic() < deadline: + scan = _root_proc_scan( + cgroup=cgroup, targets=targets, target_prefix=target_prefix, + selection=_RootProcSelection(watch_pids, scope_only=True), + ) + 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] + namespace = { + "link": holder["namespace"], + "inode": holder["namespace_inode"], + } + exact_scan = _root_proc_scan( + cgroup=cgroup, namespace=namespace, targets=targets, + 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} + 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 (*exact_members, *exact_scan["watched"]) + } + coordinator_record = next( + (record for record in exact_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, + "namespace_holders": namespace_holders, + "coordinator": coordinator_record, + "recorded_identities": list(tracked.values()), + } + time.sleep(.05) + raise AssertionError( + "privileged helper namespace was not observable: " + + json.dumps(last_scan, sort_keys=True) + ) + + 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" + deadline = time.monotonic() + 10 + last_records = [] + while time.monotonic() < deadline: + 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( + "exact blocked four-role hierarchy was not observable: " + + 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( + 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"]: + 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: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if not leak_state(details): + return + time.sleep(.05) + assert not leak_state(details), ( + "automatic cleanup deadline expired: " + "; ".join(leak_state(details)) + ) + + 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(selection=_RootProcSelection((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, + ) + 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, details, process, should_succeed: bool) -> None: + try: + returncode = process.wait(timeout=20) + assert (returncode == 0) is should_succeed + await_automatic_cleanup(details) + except BaseException: + emergency_cleanup(details, process.pid) + raise + finally: + shutil.rmtree(control, ignore_errors=True) + + if case.startswith("parent-exit-"): + report = tmp_path / f"{case}.json" + coordinator = os.fork() + if coordinator == 0: + program = ( + live_hierarchy_program + if case == "parent-exit-during-execution" + else inventory_program + ) + 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": + await_live_role_identities(details) + await_same_blocked_role_identities(details) + 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")) + try: + await_automatic_cleanup(details) + except BaseException: + emergency_cleanup(details, details["pid"]) + raise + finally: + shutil.rmtree(details["control"], ignore_errors=True) + return + + if case in { + "stalled-observation-reader", "initial-scan-failure", + "initial-watched-assertion-failure", "fd-only-namespace-holder-cleanup", + }: + unit = "pdd-validator-" + "e" * 32 + ".scope" + token = "c" * 32 + 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 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( + selection=_RootProcSelection( + (coordinator,), scope_only=True, + ), + ) + if case == "initial-scan-failure": + raise AssertionError("injected initial scan failure") + return scan + + 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, + watch_pids=(coordinator,), + ) + 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: + 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 + + 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 + assert lifecycle_ready_read == -1 + assert lifecycle_release_write == -1 + return + + _run_stalled_observation_setup( + initial_scan, assert_initial_coordinator, early_failure_cleanup, + ) + details = capture_live_state( + unit, (), target_prefix=exact_control_prefix(), + watch_pids=(coordinator,), + ) + if case == "fd-only-namespace-holder-cleanup": + holder_source = r""" +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.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]) +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, + '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, + 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, + ) + 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_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"])), + selection=_RootProcSelection((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" + 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"], + "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] + details["require_fd_only_holder"] = True + owned_fds = ( + read_fd, write_fd, lifecycle_ready_read, + lifecycle_release_write, + ) + 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) + if waited == coordinator: + assert status == 0 + break + 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 + assert "timed out" in outcome["stderr"] + assert outcome["surviving"] is False + except BaseException as primary: + if not fallback_done: + try: + if details is None: + details = capture_live_state( + unit, (), target_prefix=exact_control_prefix(), + watch_pids=(coordinator,), + ) + 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: + fallback_done = True + raise + finally: + if read_fd >= 0: + 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: + 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 + 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.unit_name, plan.staging_targets, watch_pids=(process.pid,) + ) + should_succeed = case == "normal-hierarchy-environment" + try: + if case == "reordered-extra": + send({"kind": "ack", "nonce": "a" * 64, "digest": "0" * 64}, process) + process.stdin.close() + else: + start(process) + if case == "stalled-result-reader": + pass + elif case == "stalled-observation-reader": + pass + else: + result = read_result(process) + if case == "normal-hierarchy-environment": + parsed = supervisor._descriptor_result( + result, "a" * 64, aggregate.digest, 16 * 1024 + ) + rows = [json.loads(row) for row in parsed.observation.splitlines()] + assert [row["role"] for row in rows] == [ + "coordinator", "worker", "browser", "descendant" + ] + assert all(row["fds"] == [0, 1, 2, 198] for row in rows) + assert all(row["denied"] for row in rows) + environment = rows[0]["environment"] + expected_environment = supervisor._parse_candidate_environment_record( + supervisor._candidate_environment_record( + candidate_environment, + temp_directory=Path("/tmp"), + supervision_token="c" * 32, + ) + ) + assert environment == expected_environment + ack(result, process) + process.stdin.close() + elif case == "missing-ack": + process.stdin.close() + elif case == "duplicate-ack": + frame = ack(result, process) + send(frame, process) + process.stdin.close() + elif case == "trailing-frame": + ack(result, process) + send({"kind": "extra", "nonce": "a" * 64}, process) + process.stdin.close() + elif case == "trailing-raw": + ack(result, process) + os.write(process.stdin.fileno(), b"x") + process.stdin.close() + assert_case_cleanup(control, details, process, should_succeed) + except BaseException: + if process.poll() is None: + emergency_cleanup(details, process.pid) + raise + + +@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=_credential_free_environment(tmp_path), 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) + assert _live_processes({123: "old"}) == set() + + +@pytest.mark.parametrize("frame", [ + b"", b"\x00\x00\x00\x08{}", b"\x00\x00\x20\x00{}", + b"\x00\x00\x00\x02[]", +]) +def test_descriptor_transport_rejects_partial_malformed_and_oversized_frames( + frame: bytes, +) -> None: + """Descriptor authority accepts one exact canonical object frame only.""" + with pytest.raises(RuntimeError, match="descriptor"): + 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) + + +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) + + +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}, + {"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( + 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, + str(cgroup), 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_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":[]}' + payload = { + "kind": "result", "nonce": "a" * 64, "aggregate_digest": "b" * 64, + "candidate": {"version": 1, "state": "terminal", "returncode": 0, "timed_out": False}, + "stdout": base64.b64encode(b"").decode(), "stderr": base64.b64encode(b"").decode(), + "observation": base64.b64encode(observation).decode(), + "observation_sha256": hashlib.sha256(observation).hexdigest(), + "observation_size": len(observation), + } + with pytest.raises(RuntimeError, match="descriptor result"): + supervisor._descriptor_result(payload, "c" * 64, "b" * 64, 1024) + with pytest.raises(RuntimeError, match="descriptor result"): + supervisor._descriptor_result(payload, "a" * 64, "d" * 64, 1024) + + +def test_playwright_descriptor_helper_denies_candidate_control_descriptors() -> None: + """The aggregate branch replaces all candidate stdio before exec.""" + source = inspect.getsource(supervisor._staged_bwrap) + assert "os.dup2(null,0)" in source + 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 + + +def test_playwright_descriptor_transport_timeout_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A helper that sends READY but never a result cannot leave a passing state.""" + 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") + 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();" + "sys.stdin.buffer.read(4096);time.sleep(30)" + ) + + 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) + 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, "_TRUSTED_POSTPROCESS_SECONDS", .02) + 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=.01, 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 == 125 + assert result.termination.kind is supervisor.TerminationKind.SANDBOX_ERROR + assert "descriptor transport timed out" in result.stderr + 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 = ( + "import pathlib, threading, time\n" + "root=pathlib.Path('.')\n" + "def churn():\n" + " while True:\n" + " p=root/'churn'; p.write_bytes(b'x'); p.unlink(missing_ok=True)\n" + "threading.Thread(target=churn, daemon=True).start(); time.sleep(.5)\n" + ) + result, surviving = run_supervised( + [sys.executable, "-c", program], cwd=tmp_path, timeout=5, + env=_credential_free_environment(tmp_path), writable_roots=(tmp_path,), + ) + assert result.returncode == 0 assert surviving is False - assert not sentinel.exists() + assert not (tmp_path / "churn").exists() diff --git a/tests/test_sync_core_verification_profiles.py b/tests/test_sync_core_verification_profiles.py index 5885c1f9d9..dbcf66da7e 100644 --- a/tests/test_sync_core_verification_profiles.py +++ b/tests/test_sync_core_verification_profiles.py @@ -7,7 +7,11 @@ import pytest -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 from pdd.sync_core.verification import VerificationProfileError @@ -27,32 +31,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] } @@ -203,6 +208,67 @@ 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 profiles.coverage == 0.0 + 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: diff --git a/tests/test_unit_tests_workflow.py b/tests/test_unit_tests_workflow.py new file mode 100644 index 0000000000..636a5d8abb --- /dev/null +++ b/tests/test_unit_tests_workflow.py @@ -0,0 +1,368 @@ +"""Structural contracts for the Unit Tests GitHub Actions workflow.""" + +from __future__ import annotations + +import math +import re +import shlex +from collections.abc import Callable +from pathlib import Path + +import pytest +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 +) +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" +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::" + "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]", + 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]", + 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]", + 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", +) +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"), + ("command", "-v", "nsenter"), + ("sudo", "-n", "true"), + ("bwrap", "--version"), +) +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: + """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 top-level shell lines while excluding comments and heredoc bodies.""" + 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_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_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") + assert isinstance(jobs, dict) + job = jobs.get(LINUX_JOB_ID) + assert isinstance(job, dict) + assert job.get("runs-on") == "ubuntu-latest" + _assert_approved_draft_guard(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")) + assert provision_commands[:len(EXPECTED_PROVISION_COMMANDS)] == ( + EXPECTED_PROVISION_COMMANDS + ) + + hosted_commands = _shell_commands(hosted.get("run")) + 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: + """The broad-suite margin must account for required prior job work too.""" + workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") + timeout_minutes = _workflow()["jobs"][LINUX_JOB_ID]["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 + + +def test_unit_tests_requires_complete_privileged_descriptor_matrix() -> None: + """The active hosted Linux lane has one exact frozen pytest node set.""" + _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") + + assert "env=dict(os.environ)" not in workflow_text + assert workflow_text.count("env=environment") == 10 + + +@pytest.mark.parametrize( + "mutate", + ( + lambda command: command.replace( + REQUIRED_HOSTED_NODES[6], "# " + REQUIRED_HOSTED_NODES[6] + ), + 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" + ), + _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", "removed", "k", "file-selector"), +) +def test_unit_tests_hosted_contract_rejects_selector_mutations( + mutate: Callable[[str], str], +) -> None: + """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( + "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"), + ( + ("provision", "if", False), + ("hosted", "if", False), + ("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: + """Critical steps must stay unconditional and failure-propagating.""" + workflow = _workflow() + job = workflow["jobs"][LINUX_JOB_ID] + 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) + + +def test_unit_tests_hosted_contract_rejects_commented_prerequisite() -> None: + """A provisioning comment cannot satisfy the active Linux prerequisite contract.""" + workflow = _workflow() + 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): + _assert_hosted_linux_contract(workflow)