Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
27 changes: 27 additions & 0 deletions project/ticket-449/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 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.

## Final scope and validation

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.

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.

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.
109 changes: 109 additions & 0 deletions project/ticket-449/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
{
"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",
"tests/test_planfile_queue.py",
"tests/test_queue_governed_patch_admission.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 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",
"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",
"tests/test_planfile_queue.py",
"tests/test_queue_governed_patch_admission.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"
}
]
}
}
3 changes: 2 additions & 1 deletion src/koru/queue/transaction/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
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.verification_sandbox import run_verification
from koru.queue.workspace import StagingAdmissionRequired, require_unmanaged_patch_workspace

ShellRunner = Callable[[str, Path], CommandResult]
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions src/koru/queue/transaction/staging.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
verify_output,
)
from koru.queue.types import CommandResult
from koru.queue.verification_sandbox import run_verification
from koru.queue.workspace import StagingAdmissionRequired


Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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))
Expand Down
134 changes: 134 additions & 0 deletions src/koru/queue/verification_sandbox.py
Original file line number Diff line number Diff line change
@@ -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
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path


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)
9 changes: 9 additions & 0 deletions tests/test_planfile_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions tests/test_queue_governed_patch_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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):
Expand Down
Loading
Loading