From bcd8ce6f96736f454dd56138ed9dd87484255be1 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Thu, 15 Oct 2026 14:12:54 +0200 Subject: [PATCH 1/4] fix(queue): isolate patch verification effects (ticket-449) --- project/TICKETS.md | 2 + project/ticket-449/README.md | 31 +++++ project/ticket-449/intent.json | 105 ++++++++++++++++ src/koru/queue/transaction/service.py | 3 +- src/koru/queue/transaction/staging.py | 5 +- src/koru/queue/verification_sandbox.py | 134 ++++++++++++++++++++ src/koru/queue/workspace.py | 3 +- tests/test_queue_verification_sandbox.py | 153 +++++++++++++++++++++++ tests/test_queue_verify_profiles.py | 3 +- 9 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 project/ticket-449/README.md create mode 100644 project/ticket-449/intent.json create mode 100644 src/koru/queue/verification_sandbox.py create mode 100644 tests/test_queue_verification_sandbox.py diff --git a/project/TICKETS.md b/project/TICKETS.md index 6dfece6e..9e3a3911 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -443,4 +443,6 @@ project analysis or human-owned ticket inputs. | **ticket-443** | [`README.md`](./ticket-443/README.md) | - | - | - | - | - | | **ticket-445** | [`README.md`](./ticket-445/README.md) | - | - | - | - | - | | **ticket-447** | [`README.md`](./ticket-447/README.md) | - | - | - | - | - | +| **ticket-448** | [`README.md`](./ticket-448/README.md) | - | - | - | - | - | +| **ticket-449** | [`README.md`](./ticket-449/README.md) | - | - | - | - | - | diff --git a/project/ticket-449/README.md b/project/ticket-449/README.md new file mode 100644 index 00000000..df0de908 --- /dev/null +++ b/project/ticket-449/README.md @@ -0,0 +1,31 @@ +# Ticket 449: Contain patch verification filesystem and network effects + +- **ID**: ticket-449 +- **Owner**: agent:codex +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION + +## Goal and scope + +SESSION_EXECUTION_AUTHORIZATION: user said "wykonaj" for Maskservice PLF-15923 standard/enforcement repairs. This bounded product slice isolates Koru patch verification before invoking any untrusted test or its child processes. Keep source, Git metadata, other repositories and host temporary directories read-only. Only a fresh repository-owned operational scratch directory is writable. Deny network and host Unix socket creation; no unsandboxed fallback when kernel support is missing. + +This changes verification execution only. It grants no editing, lease, publication or MCP authority, does not resume blocked Willman ticket-217, and does not activate a global host policy. Other LLM tool routes and standard/adoption work remain under the parent plan. + +## Acceptance criteria + +- [x] AC-01: Baseline, staged and in-place patch verification all use the isolated runner. Missing bubblewrap/seccomp support refuses before callback execution. +- [x] AC-02: Real kernel tests preserve sentinels for out-of-scope, parent traversal, symlink and child-process writes; host temporary writes and sockets fail; private scratch remains usable. +- [x] AC-03: Focused transaction tests, required project checks and managed governance pass; test scratch stays inside this worktree. +- [ ] AC-04: Independent exact-head publication and separate delivery states are recorded. No global enforcement claim. + +## Accepted scope refinement + +The broader queue regression run exposed that an empty or malformed `.governance` marker is treated as unmanaged when `manifest.json` is missing. The existing 35 refusal regressions fail before this slice. Include `workspace.py` to close that fail-open classification without granting editing authority. The rendered-profile assertion must check the command inside the sandbox envelope. Ownership of both paths was independently re-observed as free; preserve foreign ticket-446. Unrelated pre-existing provider/execution-plan failures remain outside this slice. + +## Validation and limits + +Final focused suite: 167 tests and 10 subtests passed, including real kernel denials and a real transaction whose attempted out-of-scope verifier write was denied and whose candidate patch was rolled back. The required critical suite passed 257 tests. Managed governance passed with zero errors/warnings. Test temporary files, JUnit and bounded receipts are kept in this checkout’s `.subactor` namespaces. + +A broad queue run passed 390 tests and 28 subtests, with two existing provider tests failing and five existing execution-plan fixture errors. Replaying the original HEAD transaction modules reproduced those failures before the patch, along with the 35 partial-governance refusal failures now repaired. The two provider tests belong to foreign active ticket-446; no edits or takeover were made there. The full repository suite is not claimed clean. + +This boundary requires Linux, a protected system bubblewrap binary and libseccomp. Missing support refuses execution. Verification is offline and source is read-only; only private repository-owned scratch is writable. It is a write/IPC boundary, not a claim of secret-read isolation, controller admission, global LLM coverage, MCP authorization or deployment. Independent exact-head publication remains pending. diff --git a/project/ticket-449/intent.json b/project/ticket-449/intent.json new file mode 100644 index 00000000..fc744858 --- /dev/null +++ b/project/ticket-449/intent.json @@ -0,0 +1,105 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-449", + "summary": "Contain patch verification filesystem and network effects", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-449/**", + "TODO.md", + "project/TICKETS.md", + "src/koru/queue/transaction/service.py", + "src/koru/queue/transaction/staging.py", + "src/koru/queue/verification_sandbox.py", + "tests/test_queue_evidence.py", + "tests/test_queue_journal.py", + "tests/test_queue_recovery.py", + "tests/test_queue_transaction_phases.py", + "tests/test_queue_verification_sandbox.py", + "src/koru/queue/workspace.py", + "tests/test_queue_verify_profiles.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "febdb1e2beac504e7ca939b89537f9c507d5dcc5", + "targetBranch": "main", + "outcome": "Kernel-isolate baseline, staged and in-place Koru patch verification so candidate tests and all descendants cannot write source/host paths or invoke network/host Unix socket effects; retain writable repository-owned scratch only. Reject partial or malformed governance markers before patch writes; preserve the rendered verification command inside the sandbox.", + "nonGoals": [ + "No editing, admission, lease, MCP or publication grant", + "No global host configuration or runtime activation", + "No changes to foreign active ticket446 or blocked ticket444", + "No unsandboxed compatibility fallback" + ], + "complexity": "M", + "estimatedMinutes": 60, + "budgets": { + "maxImplementationFiles": 9, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 2 + }, + "architecture": { + "status": "accepted", + "decision": "Mandatory verification wrapper retains the injected shell-runner contract but runs its command through Linux bubblewrap plus a native-architecture libseccomp filter. All host mounts are read-only except exclusive repository-local scratch. Clear inherited environment; deny socket and namespace escape syscalls; fail closed if support is unavailable. Existing protected write/promotion boundaries are unchanged.", + "components": [ + { + "name": "verification-boundary", + "paths": [ + "src/koru/queue/verification_sandbox.py", + "src/koru/queue/transaction/staging.py", + "src/koru/queue/transaction/service.py", + "src/koru/queue/workspace.py" + ] + }, + { + "name": "regressions", + "paths": [ + "tests/test_queue_verification_sandbox.py", + "tests/test_queue_transaction_phases.py", + "tests/test_queue_journal.py", + "tests/test_queue_evidence.py", + "tests/test_queue_recovery.py", + "tests/test_queue_verify_profiles.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the material implementation through protected publication; do not weaken sandbox requirements or issue authority from test receipts." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python3 -m pytest -q tests/test_queue_verification_sandbox.py tests/test_queue_transaction_phases.py tests/test_queue_journal.py tests/test_queue_evidence.py tests/test_queue_recovery.py", + "python3 -m pytest -q tests/test_queue_governed_patch_admission.py tests/test_queue_verify_profiles.py" + ], + "evidence": "Owning worktree .subactor/receipts/ticket-449 focused JUnit and bounded checkpoint" + }, + { + "criterion": "AC-03", + "commands": [ + "./project/governance-check.sh" + ], + "evidence": "Managed governance plus declared stack checks and exact-head protected publication" + } + ] + } +} diff --git a/src/koru/queue/transaction/service.py b/src/koru/queue/transaction/service.py index 82817633..06c33d8c 100644 --- a/src/koru/queue/transaction/service.py +++ b/src/koru/queue/transaction/service.py @@ -65,6 +65,7 @@ from koru.queue.transaction.staging import stage_patch from koru.queue.types import CommandResult from koru.queue.workspace import StagingAdmissionRequired, require_unmanaged_patch_workspace +from koru.queue.verification_sandbox import run_verification ShellRunner = Callable[[str, Path], CommandResult] Authorizer = Callable[[PatchPlan, dict], PatchOutcome | None] @@ -430,7 +431,7 @@ def _apply_to_workspace( if verify: try: - gate = shell_runner(plan.verify_command, plan.project) + gate = run_verification(plan.verify_command, plan.project, shell_runner) except Exception as exc: gate = SimpleNamespace(returncode=1, stdout="", stderr=f"{type(exc).__name__}: {exc}") if gate.returncode != 0: diff --git a/src/koru/queue/transaction/staging.py b/src/koru/queue/transaction/staging.py index 0eaaec87..ec038db1 100644 --- a/src/koru/queue/transaction/staging.py +++ b/src/koru/queue/transaction/staging.py @@ -30,6 +30,7 @@ ) from koru.queue.types import CommandResult from koru.queue.workspace import StagingAdmissionRequired +from koru.queue.verification_sandbox import run_verification def stage_patch( @@ -72,7 +73,7 @@ def _stage_patch( ), ) - verify = shell_runner(plan.verify_command, staged) + verify = run_verification(plan.verify_command, staged, shell_runner) if verify.returncode != 0: return StagingResult.refused(_staged_verify_failure(plan, baseline, verify)) @@ -105,7 +106,7 @@ def _record_baseline( """ if skip_verify_baseline(plan.ticket): return _Baseline() - result = shell_runner(plan.verify_command, staged) + result = run_verification(plan.verify_command, staged, shell_runner) if result.returncode == 0: return _Baseline() return _Baseline(ok=False, output=verify_output(result, limit=BASELINE_OUTPUT_LIMIT)) diff --git a/src/koru/queue/verification_sandbox.py b/src/koru/queue/verification_sandbox.py new file mode 100644 index 00000000..8d63ddbe --- /dev/null +++ b/src/koru/queue/verification_sandbox.py @@ -0,0 +1,134 @@ +"""Run patch verification with read-only host mounts and private scratch. + +Verification consumes candidate code, so its shell and all children are +untrusted. This boundary grants no source, Git, controller or publication +writes. Network syscalls are denied as well as isolated: a read-only mount +alone does not prevent connecting to an existing host Unix socket. +""" +from __future__ import annotations + +import ctypes +import ctypes.util +import errno +import os +from pathlib import Path +import shlex +import shutil +import stat +import subprocess +import sys +import tempfile +from collections.abc import Callable + + +class SandboxUnavailable(ValueError): + """The verification command must not run without its boundary.""" + + +def _safe_path(path: Path) -> Path: + if ".." in Path(path).parts: + raise SandboxUnavailable("noncanonical verification path") + path = Path(os.path.abspath(path)) + if any(part.is_symlink() for part in (path, *path.parents)): + raise SandboxUnavailable("symlinked verification path") + return path + + +def _export_filter(path: Path) -> None: + """Export a native-architecture seccomp program, never load it in the parent.""" + name = ctypes.util.find_library("seccomp") + if not name: + raise SandboxUnavailable("libseccomp is required") + library = ctypes.CDLL(name) + library.seccomp_init.argtypes = [ctypes.c_uint32] + library.seccomp_init.restype = ctypes.c_void_p + library.seccomp_release.argtypes = [ctypes.c_void_p] + library.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p] + library.seccomp_syscall_resolve_name.restype = ctypes.c_int + library.seccomp_rule_add.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int, ctypes.c_uint] + library.seccomp_rule_add.restype = ctypes.c_int + library.seccomp_export_bpf.argtypes = [ctypes.c_void_p, ctypes.c_int] + library.seccomp_export_bpf.restype = ctypes.c_int + context = library.seccomp_init(0x7FFF0000) # SCMP_ACT_ALLOW; other ABIs fail closed. + if not context: + raise SandboxUnavailable("seccomp initialization failed") + try: + for name in ( + "socket", "socketcall", "connect", "sendto", "sendmsg", + "mount", "umount2", "pivot_root", "setns", "unshare", + "fsopen", "fsconfig", "fsmount", "move_mount", "open_tree", "mount_setattr", + "ptrace", "process_vm_writev", "bpf", "keyctl", "add_key", "request_key", + "io_uring_setup", "io_uring_enter", "io_uring_register", + ): + number = library.seccomp_syscall_resolve_name(name.encode("ascii")) + if number == -1: + if name in {"socket", "connect", "io_uring_setup"}: + raise SandboxUnavailable("required seccomp syscall is unknown") + continue + if library.seccomp_rule_add(context, 0x00050000 | errno.EPERM, number, 0): + raise SandboxUnavailable("seccomp rule installation failed") + with path.open("xb") as output: + if library.seccomp_export_bpf(context, output.fileno()): + raise SandboxUnavailable("seccomp export failed") + if not path.stat().st_size: + raise SandboxUnavailable("empty seccomp program") + finally: + library.seccomp_release(context) + + +def _command(command: str, project: Path, scratch: Path, binary: str) -> str: + filter_path = scratch / "filter.bpf" + _export_filter(filter_path) + for folder in ("tmp", "home", "cache", "config", "empty"): + (scratch / folder).mkdir(mode=0o700) + environment = { + "PATH": os.environ.get("PATH", os.defpath), + "LANG": "C.UTF-8", + "HOME": str(scratch / "home"), + "TMPDIR": str(scratch / "tmp"), + "XDG_CACHE_HOME": str(scratch / "cache"), + "XDG_CONFIG_HOME": str(scratch / "config"), + "PYTHONDONTWRITEBYTECODE": "1", + "PYTEST_ADDOPTS": "-o cache_dir=" + str(scratch / "cache"), + } + if os.environ.get("PYTHONPATH"): + environment["PYTHONPATH"] = os.environ["PYTHONPATH"] + argv = ["env", "-i", *(key + "=" + value for key, value in environment.items()), + binary, "--ro-bind", "/", "/", "--bind", str(scratch), str(scratch), + "--unshare-all", "--unshare-user", "--disable-userns", "--assert-userns-disabled", + "--die-with-parent", "--new-session", "--cap-drop", "ALL", + "--proc", "/proc", "--dev", "/dev", + "--ro-bind", str(scratch / "empty"), "/dev/shm", "--seccomp", "9", + "--chdir", str(project), "--", "/bin/sh", "-c", command] + # The existing runner accepts a shell string. Open the filter in that + # shell so subprocess close_fds cannot discard its descriptor before bwrap. + return "exec 9<" + shlex.quote(str(filter_path)) + " && exec " + shlex.join(argv) + + +def run_verification(command: str, project: Path, runner: Callable): + """Keep the existing runner contract while making isolation mandatory.""" + scratch = None + try: + if sys.platform != "linux" or not (binary := shutil.which("bwrap")): + raise SandboxUnavailable("Linux bubblewrap is required") + root = _safe_path(project) + backend = Path(binary).resolve(strict=True) + info = backend.stat() + if (not root.is_dir() or backend.is_relative_to(root) + or not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022): + raise SandboxUnavailable("invalid verification root or backend") + binary = str(backend) + cache = _safe_path(root / ".subactor/cache/koru-verification") + cache.mkdir(parents=True, exist_ok=True, mode=0o700) + _safe_path(cache) + scratch = tempfile.TemporaryDirectory(prefix="run-", dir=cache) + isolated = _command(command, root, Path(scratch.name), binary) + except (SandboxUnavailable, OSError, AttributeError): + if scratch is not None: + scratch.cleanup() + return subprocess.CompletedProcess( + ["verification-sandbox"], 125, "", + "verification_sandbox_unavailable: verified Linux bubblewrap/seccomp execution is required", + ) + with scratch: + return runner(isolated, root) diff --git a/src/koru/queue/workspace.py b/src/koru/queue/workspace.py index 491f693b..bb421e33 100644 --- a/src/koru/queue/workspace.py +++ b/src/koru/queue/workspace.py @@ -314,7 +314,8 @@ def require_unmanaged_patch_workspace(project: Path) -> None: if common.returncode != 0 or not common.stdout.strip(): raise StagingAdmissionRequired("Cannot establish the primary checkout for patch execution") primary = Path(common.stdout.strip()).parent - if (project / ".governance" / "manifest.json").exists() or (primary / ".governance" / "manifest.json").exists(): + if any((root / ".governance").exists() or (root / ".governance").is_symlink() + for root in (project, primary)): raise StagingAdmissionRequired( "Governed patch execution requires native ticket allocation and protected controller " "admission; legacy temporary worktrees and local lease files do not grant it" diff --git a/tests/test_queue_verification_sandbox.py b/tests/test_queue_verification_sandbox.py new file mode 100644 index 00000000..1a17ff51 --- /dev/null +++ b/tests/test_queue_verification_sandbox.py @@ -0,0 +1,153 @@ +"""Actual verification processes must not mutate their host or contact services.""" +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile + +import pytest + +from koru.queue.verification_sandbox import run_verification + + +def shell(command, project): + return subprocess.run(command, shell=True, cwd=project, capture_output=True, text=True) + + +def test_missing_backend_never_executes_command(tmp_path, monkeypatch): + monkeypatch.setattr("koru.queue.verification_sandbox.shutil.which", lambda _: None) + calls = [] + result = run_verification("touch escaped", tmp_path, lambda *args: calls.append(args)) + assert result.returncode != 0 + assert "verification_sandbox_unavailable" in result.stderr + assert calls == [] + + +def test_symlinked_checkout_is_refused(tmp_path): + target = tmp_path / "target" + target.mkdir() + alias = tmp_path / "alias" + alias.symlink_to(target, target_is_directory=True) + calls = [] + result = run_verification("true", alias, lambda *args: calls.append(args)) + assert result.returncode != 0 + assert calls == [] + + +def test_missing_seccomp_never_executes_command(tmp_path, monkeypatch): + monkeypatch.setattr("koru.queue.verification_sandbox.ctypes.util.find_library", lambda _: None) + calls = [] + result = run_verification("touch escaped", tmp_path, lambda *args: calls.append(args)) + assert result.returncode != 0 + assert calls == [] + + +def test_symlinked_scratch_is_refused(tmp_path): + target = tmp_path / "outside" + target.mkdir() + (tmp_path / ".subactor").symlink_to(target, target_is_directory=True) + calls = [] + result = run_verification("true", tmp_path, lambda *args: calls.append(args)) + assert result.returncode != 0 + assert calls == [] + assert list(target.iterdir()) == [] + + +def test_kernel_denies_writes_and_host_sockets_in_children(): + if sys.platform != "linux" or not shutil.which("bwrap"): + pytest.skip("Linux bubblewrap is required for real kernel evidence") + cache = Path.cwd() / ".subactor/cache/ticket-449/kernel-tests" + cache.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=cache) as temporary: + fixture = Path(temporary) + project = fixture / "project" + project.mkdir() + protected = project / "out-of-scope.txt" + sibling = fixture / "other-repository.txt" + protected.write_text("sentinel") + sibling.write_text("sentinel") + alias = project / "escape-link" + alias.symlink_to(sibling) + temporary_target = Path("/tmp") / (fixture.name + "-must-not-exist") + assert not temporary_target.exists() + targets = [str(protected), "../other-repository.txt", str(alias), str(temporary_target), + "/dev/shm/koru-must-not-exist"] + script = ''' +import json, os, pathlib, socket, subprocess, sys +targets = json.loads(sys.argv[1]) +for target in targets: + try: + pathlib.Path(target).write_text('escaped') + except OSError: + pass + else: + raise AssertionError('write was admitted: ' + target) +for family in (socket.AF_UNIX, socket.AF_INET): + try: + socket.socket(family) + except OSError: + pass + else: + raise AssertionError('socket was admitted') +child = subprocess.run([sys.executable, '-c', + "from pathlib import Path; Path(__import__('sys').argv[1]).write_text('child')", targets[0]]) +assert child.returncode != 0 +assert 'KORU_VERIFICATION_TEST_CREDENTIAL' not in os.environ +scratch = pathlib.Path(os.environ['TMPDIR']) +(scratch / 'fixture.txt').write_text('allowed') +assert scratch.is_relative_to(pathlib.Path.cwd()) +try: + os.link(targets[0], scratch / 'host-hardlink') +except OSError: + pass +else: + raise AssertionError('host hardlink was admitted') +print('kernel-boundary-passed') +''' + old = os.environ.get("KORU_VERIFICATION_TEST_CREDENTIAL") + os.environ["KORU_VERIFICATION_TEST_CREDENTIAL"] = "private-test-value" + try: + command = shlex.join([sys.executable, "-c", script, json.dumps(targets)]) + result = run_verification(command, project, shell) + finally: + if old is None: + os.environ.pop("KORU_VERIFICATION_TEST_CREDENTIAL", None) + else: + os.environ["KORU_VERIFICATION_TEST_CREDENTIAL"] = old + assert result.returncode == 0, result.stderr + assert "kernel-boundary-passed" in result.stdout + assert protected.read_text() == sibling.read_text() == "sentinel" + assert not temporary_target.exists() + + +def test_real_transaction_applies_patch_but_verifier_cannot_write(monkeypatch): + from koru.queue.transaction import execute_patch_transaction + + if sys.platform != "linux" or not shutil.which("bwrap"): + pytest.skip("Linux bubblewrap is required for real kernel evidence") + cache = Path.cwd() / ".subactor/cache/ticket-449/transaction-tests" + cache.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=cache) as temporary: + project = Path(temporary) + subprocess.run(["git", "init", "-q", str(project)], check=True) + (project / "a.txt").write_text("old\n") + (project / "sentinel.txt").write_text("sentinel") + subprocess.run(["git", "add", "."], cwd=project, check=True) + subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", + "commit", "-qm", "fixture"], cwd=project, check=True) + probe = "from pathlib import Path; print('VERIFIER-STARTED', flush=True); Path('sentinel.txt').write_text('escaped')" + command = shlex.join([sys.executable, "-c", probe]) + ticket = {"id": "sandbox-probe", "inputs": { + "verify_command": command, "promotion_mode": "apply"}} + reply = subprocess.CompletedProcess([], 0, + "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n", "") + monkeypatch.setenv("KORU_QUEUE_WORKTREE", "0") + result = execute_patch_transaction(project, reply, ticket, shell) + assert result.outcome is not None + assert "rolled back" in result.outcome.message + assert "VERIFIER-STARTED" in result.outcome.message + assert (project / "a.txt").read_text() == "old\n" + assert (project / "sentinel.txt").read_text() == "sentinel" diff --git a/tests/test_queue_verify_profiles.py b/tests/test_queue_verify_profiles.py index 2233500a..52b5bb65 100644 --- a/tests/test_queue_verify_profiles.py +++ b/tests/test_queue_verify_profiles.py @@ -7,6 +7,7 @@ from __future__ import annotations +import shlex import subprocess import tempfile import unittest @@ -405,7 +406,7 @@ def gate(command: str, cwd: Path): self.assertTrue(seen, "the gate must have run") self.assertIn("node --check", seen[0]) self.assertIn("a.js", seen[0]) - self.assertTrue(seen[0].startswith("timeout 60s "), seen[0]) + self.assertTrue(shlex.split(seen[0])[-1].startswith("timeout 60s "), seen[0]) class TestVerifyProfileDataclass(unittest.TestCase): From 10b000d6520cdb4ad0b5471f48476d26bb52e694 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Thu, 15 Oct 2026 14:21:18 +0200 Subject: [PATCH 2/4] style(queue): sort verification boundary imports (ticket-449) --- src/koru/queue/transaction/service.py | 2 +- src/koru/queue/transaction/staging.py | 2 +- src/koru/queue/verification_sandbox.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/koru/queue/transaction/service.py b/src/koru/queue/transaction/service.py index 06c33d8c..60d8ca87 100644 --- a/src/koru/queue/transaction/service.py +++ b/src/koru/queue/transaction/service.py @@ -64,8 +64,8 @@ from koru.queue.transaction.rollback import roll_back_failed_verify from koru.queue.transaction.staging import stage_patch from koru.queue.types import CommandResult -from koru.queue.workspace import StagingAdmissionRequired, require_unmanaged_patch_workspace from koru.queue.verification_sandbox import run_verification +from koru.queue.workspace import StagingAdmissionRequired, require_unmanaged_patch_workspace ShellRunner = Callable[[str, Path], CommandResult] Authorizer = Callable[[PatchPlan, dict], PatchOutcome | None] diff --git a/src/koru/queue/transaction/staging.py b/src/koru/queue/transaction/staging.py index ec038db1..a0da07e6 100644 --- a/src/koru/queue/transaction/staging.py +++ b/src/koru/queue/transaction/staging.py @@ -29,8 +29,8 @@ verify_output, ) from koru.queue.types import CommandResult -from koru.queue.workspace import StagingAdmissionRequired from koru.queue.verification_sandbox import run_verification +from koru.queue.workspace import StagingAdmissionRequired def stage_patch( diff --git a/src/koru/queue/verification_sandbox.py b/src/koru/queue/verification_sandbox.py index 8d63ddbe..605d02d4 100644 --- a/src/koru/queue/verification_sandbox.py +++ b/src/koru/queue/verification_sandbox.py @@ -11,7 +11,6 @@ import ctypes.util import errno import os -from pathlib import Path import shlex import shutil import stat @@ -19,6 +18,7 @@ import sys import tempfile from collections.abc import Callable +from pathlib import Path class SandboxUnavailable(ValueError): From 233908b22503efe5f43db6333a68992136c7dfef Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Thu, 15 Oct 2026 14:33:36 +0200 Subject: [PATCH 3/4] test(queue): separate transaction doubles from kernel verification (ticket-449) --- project/ticket-449/README.md | 12 ++++-------- project/ticket-449/intent.json | 10 +++++++--- src/koru/queue/workspace.py | 3 +-- tests/test_planfile_queue.py | 9 +++++++++ tests/test_queue_governed_patch_admission.py | 5 +++++ 5 files changed, 26 insertions(+), 13 deletions(-) diff --git a/project/ticket-449/README.md b/project/ticket-449/README.md index df0de908..d1129d84 100644 --- a/project/ticket-449/README.md +++ b/project/ticket-449/README.md @@ -18,14 +18,10 @@ This changes verification execution only. It grants no editing, lease, publicati - [x] AC-03: Focused transaction tests, required project checks and managed governance pass; test scratch stays inside this worktree. - [ ] AC-04: Independent exact-head publication and separate delivery states are recorded. No global enforcement claim. -## Accepted scope refinement +## Final scope and validation -The broader queue regression run exposed that an empty or malformed `.governance` marker is treated as unmanaged when `manifest.json` is missing. The existing 35 refusal regressions fail before this slice. Include `workspace.py` to close that fail-open classification without granting editing authority. The rendered-profile assertion must check the command inside the sandbox envelope. Ownership of both paths was independently re-observed as free; preserve foreign ticket-446. Unrelated pre-existing provider/execution-plan failures remain outside this slice. +Preserve the existing distinction between docs-only metadata and native project adoption. The first refinement treated every `.governance` directory as native; CI exposed that this reversed the deliberate docs-only behavior from commit 89488311. Restore the original production classifier and update the native-admission fixtures to create `manifest.json`. The 35 earlier failures came from fixtures missing that native marker, rather than a proven production admission bypass. -## Validation and limits +Focused tests and real kernel denials passed locally; final counts are recorded in the owning receipts. The required critical suite passed 257 tests. The broad queue run also confirmed two existing provider failures and five existing execution-plan fixture errors on baseline HEAD. Preserve foreign active ticket-446. -Final focused suite: 167 tests and 10 subtests passed, including real kernel denials and a real transaction whose attempted out-of-scope verifier write was denied and whose candidate patch was rolled back. The required critical suite passed 257 tests. Managed governance passed with zero errors/warnings. Test temporary files, JUnit and bounded receipts are kept in this checkout’s `.subactor` namespaces. - -A broad queue run passed 390 tests and 28 subtests, with two existing provider tests failing and five existing execution-plan fixture errors. Replaying the original HEAD transaction modules reproduced those failures before the patch, along with the 35 partial-governance refusal failures now repaired. The two provider tests belong to foreign active ticket-446; no edits or takeover were made there. The full repository suite is not claimed clean. - -This boundary requires Linux, a protected system bubblewrap binary and libseccomp. Missing support refuses execution. Verification is offline and source is read-only; only private repository-owned scratch is writable. It is a write/IPC boundary, not a claim of secret-read isolation, controller admission, global LLM coverage, MCP authorization or deployment. Independent exact-head publication remains pending. +OneDev runs transaction unit tests on a host without the sandbox tools. Give those unit tests an explicit verifier test double; the separate kernel tests continue to run the real wrapper and real transaction. Production verification remains mandatory and fails closed when support is absent. This slice makes no secret-read, global LLM, MCP, editing authority or deployment claim. diff --git a/project/ticket-449/intent.json b/project/ticket-449/intent.json index fc744858..7f477417 100644 --- a/project/ticket-449/intent.json +++ b/project/ticket-449/intent.json @@ -21,7 +21,9 @@ "tests/test_queue_transaction_phases.py", "tests/test_queue_verification_sandbox.py", "src/koru/queue/workspace.py", - "tests/test_queue_verify_profiles.py" + "tests/test_queue_verify_profiles.py", + "tests/test_planfile_queue.py", + "tests/test_queue_governed_patch_admission.py" ], "forbiddenPaths": [ "project/ticket-*/user-*.md" @@ -33,7 +35,7 @@ "delivery": { "acceptedBaseSha": "febdb1e2beac504e7ca939b89537f9c507d5dcc5", "targetBranch": "main", - "outcome": "Kernel-isolate baseline, staged and in-place Koru patch verification so candidate tests and all descendants cannot write source/host paths or invoke network/host Unix socket effects; retain writable repository-owned scratch only. Reject partial or malformed governance markers before patch writes; preserve the rendered verification command inside the sandbox.", + "outcome": "Kernel-isolate baseline, staged and in-place Koru patch verification so candidate tests and descendants cannot write source/host paths or invoke network/host Unix socket effects; retain writable repository-owned scratch only. Preserve native-adoption classification and correct native fixtures and verification test doubles.", "nonGoals": [ "No editing, admission, lease, MCP or publication grant", "No global host configuration or runtime activation", @@ -69,7 +71,9 @@ "tests/test_queue_journal.py", "tests/test_queue_evidence.py", "tests/test_queue_recovery.py", - "tests/test_queue_verify_profiles.py" + "tests/test_queue_verify_profiles.py", + "tests/test_planfile_queue.py", + "tests/test_queue_governed_patch_admission.py" ] } ], diff --git a/src/koru/queue/workspace.py b/src/koru/queue/workspace.py index bb421e33..491f693b 100644 --- a/src/koru/queue/workspace.py +++ b/src/koru/queue/workspace.py @@ -314,8 +314,7 @@ def require_unmanaged_patch_workspace(project: Path) -> None: if common.returncode != 0 or not common.stdout.strip(): raise StagingAdmissionRequired("Cannot establish the primary checkout for patch execution") primary = Path(common.stdout.strip()).parent - if any((root / ".governance").exists() or (root / ".governance").is_symlink() - for root in (project, primary)): + if (project / ".governance" / "manifest.json").exists() or (primary / ".governance" / "manifest.json").exists(): raise StagingAdmissionRequired( "Governed patch execution requires native ticket allocation and protected controller " "admission; legacy temporary worktrees and local lease files do not grant it" diff --git a/tests/test_planfile_queue.py b/tests/test_planfile_queue.py index 8beab834..3c7e4787 100644 --- a/tests/test_planfile_queue.py +++ b/tests/test_planfile_queue.py @@ -1854,6 +1854,15 @@ def setUp(self) -> None: patcher = patch.dict(os.environ, {"KORU_QUEUE_PROMOTION_MODE": "apply"}) patcher.start() self.addCleanup(patcher.stop) + # These are transaction unit tests with injected gate callbacks. + # Actual namespace/IPC enforcement has separate kernel regressions. + for module in ("service", "staging"): + boundary = patch( + f"koru.queue.transaction.{module}.run_verification", + side_effect=lambda command, cwd, runner: runner(command, cwd), + ) + boundary.start() + self.addCleanup(boundary.stop) def _git_repo(self, tmp: str) -> Path: # Retained staging is test-owned and must stay inside this fixture. diff --git a/tests/test_queue_governed_patch_admission.py b/tests/test_queue_governed_patch_admission.py index ed7cabd2..f769b527 100644 --- a/tests/test_queue_governed_patch_admission.py +++ b/tests/test_queue_governed_patch_admission.py @@ -37,6 +37,7 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def governed_root(repo: Path, linked: bool) -> Path: (repo / ".governance").mkdir() + (repo / ".governance/manifest.json").write_text("{}") if not linked: return repo # Only the registered primary has the marker: linked lookup must find it. @@ -107,6 +108,8 @@ def test_unmanaged_direct_apply_keeps_legacy_behavior( repo: Path, monkeypatch: pytest.MonkeyPatch, verify: bool, ) -> None: monkeypatch.setenv("KORU_QUEUE_WORKTREE", "0") + monkeypatch.setattr("koru.queue.transaction.service.run_verification", + lambda command, cwd, runner: runner(command, cwd)) runner = Mock(return_value=_repolab.reply()) inputs = {"promotion_mode": "apply"} if verify: @@ -125,6 +128,7 @@ def test_recheck_after_local_authorizer(repo: Path, monkeypatch: pytest.MonkeyPa def local_callback(plan, frozen): (repo / ".governance").mkdir() + (repo / ".governance/manifest.json").write_text("{}") return None result = execute_patch_transaction( @@ -141,6 +145,7 @@ def test_recheck_after_staging_before_fallback_or_promotion(repo: Path, staged: def stage_then_change_authority(plan, shell_runner): (repo / ".governance").mkdir() + (repo / ".governance/manifest.json").write_text("{}") return StagingResult.verified() if staged else StagingResult.unavailable() with patch("koru.queue.transaction.service.stage_patch", side_effect=stage_then_change_authority): From 1d565cd8199b5739ab3ee9dfb29b54c5a718f0dd Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Thu, 15 Oct 2026 14:49:21 +0200 Subject: [PATCH 4/4] test(queue): require kernel sandbox evidence without skips --- tests/test_queue_verification_sandbox.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_queue_verification_sandbox.py b/tests/test_queue_verification_sandbox.py index 1a17ff51..841b9492 100644 --- a/tests/test_queue_verification_sandbox.py +++ b/tests/test_queue_verification_sandbox.py @@ -8,8 +8,6 @@ import sys import tempfile -import pytest - from koru.queue.verification_sandbox import run_verification @@ -26,6 +24,14 @@ def test_missing_backend_never_executes_command(tmp_path, monkeypatch): assert calls == [] +def test_unsupported_platform_never_executes_command(tmp_path, monkeypatch): + monkeypatch.setattr("koru.queue.verification_sandbox.sys.platform", "unsupported") + calls = [] + result = run_verification("touch escaped", tmp_path, lambda *args: calls.append(args)) + assert result.returncode != 0 + assert calls == [] + + def test_symlinked_checkout_is_refused(tmp_path): target = tmp_path / "target" target.mkdir() @@ -57,8 +63,7 @@ def test_symlinked_scratch_is_refused(tmp_path): def test_kernel_denies_writes_and_host_sockets_in_children(): - if sys.platform != "linux" or not shutil.which("bwrap"): - pytest.skip("Linux bubblewrap is required for real kernel evidence") + assert sys.platform == "linux" and shutil.which("bwrap"), "Kernel evidence requires Linux bubblewrap" cache = Path.cwd() / ".subactor/cache/ticket-449/kernel-tests" cache.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(dir=cache) as temporary: @@ -126,8 +131,7 @@ def test_kernel_denies_writes_and_host_sockets_in_children(): def test_real_transaction_applies_patch_but_verifier_cannot_write(monkeypatch): from koru.queue.transaction import execute_patch_transaction - if sys.platform != "linux" or not shutil.which("bwrap"): - pytest.skip("Linux bubblewrap is required for real kernel evidence") + assert sys.platform == "linux" and shutil.which("bwrap"), "Kernel evidence requires Linux bubblewrap" cache = Path.cwd() / ".subactor/cache/ticket-449/transaction-tests" cache.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(dir=cache) as temporary: