feat(runtime): bind process and job resources to authority

This commit is contained in:
Alexandre Teixeira
2026-10-02 18:54:09 +01:00
parent 7b8ac6f631
commit db41d7e822
36 changed files with 2251 additions and 174 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ from src import containment
def capture_owned_spawn(monkeypatch, tmp_path):
captured = {}
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path.parent / (tmp_path.name + "-control") / "grants.json")
monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid)
async def fake_exec(*argv, **kwargs):
+98
View File
@@ -0,0 +1,98 @@
"""Explicit trusted producer fixtures; no production authority fallback."""
from contextlib import contextmanager
from dataclasses import replace
import json
from pathlib import Path
from uuid import uuid4
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
from src.agent_runtime.resources import BackgroundJobResource, NativeBackendResource, ProcessResource
from src.agent_runtime.process_resources import bind_process_operation, resolve_process_operation, publish_launch
from src.process_lifecycle import ProcessIdentity
@contextmanager
def launch_authority(content, workspace, *, tool="bash", owner="", session_id="chat", authority=None):
authority = authority or RequestAuthority("producer-test", owner, session_id, str(workspace), (OperationGrant(tool),))
bound = resolve_process_operation(authority, ExactOperation.normalize(tool, content), NativeBackendResource(tool))
with bind_request_authority(authority), bind_process_operation(bound):
yield authority, bound
def launch(command, session_id="chat", *, cwd, **kwargs):
from src import bg_jobs
with launch_authority(command, cwd, session_id=session_id):
return bg_jobs.launch(command, session_id, cwd=cwd, **kwargs)
def identity(job_id):
from src import bg_jobs
from src.agent_runtime.process_resources import job_from_record
return job_from_record(bg_jobs.peek(job_id))
def get(job_id):
from src import bg_jobs
return bg_jobs.get(job_id, expected=identity(job_id))
def kill(job_id):
from src import bg_jobs
return bg_jobs.kill(job_id, expected=identity(job_id))
def seed_linkage(record, workspace, *, owner="", request_id="producer-test"):
"""A fake server spawn record, with an explicit fake lifecycle observation."""
from src import bg_jobs, containment
from src.agent_runtime.authority import save_background_authority
from src.agent_runtime.process_resources import resolve_process_operation
authority = RequestAuthority(request_id, owner, record["session_id"], str(workspace), (OperationGrant("bash"),))
bound = resolve_process_operation(authority, ExactOperation.normalize("bash", record["command"]), NativeBackendResource("bash"))
receipt = uuid4().hex
record.update(containment_id=receipt, start_token="test-boot:start", pgid=record["pid"])
process = ProcessResource("native:bg_jobs", owner, request_id, record["session_id"],
ProcessIdentity(record["pid"], record["start_token"], record["pgid"]), "supervisor", record["id"], receipt)
resource = BackgroundJobResource("native:bg_jobs", record["id"], bound.launch.generation,
owner, request_id, record["session_id"], receipt, (process,))
record.update(resource_identity=resource.to_dict(), launch_resource=bound.launch.to_dict())
from core.atomic_io import atomic_write_json
receipts = containment._load_records()
receipts[receipt] = {"id": receipt, "launch_generation": resource.generation,
"owner": "bg:" + resource.thread_id, "supervisor_pid": process.identity.pid,
"supervisor_token": process.identity.start_token, "mechanism": "process_group"}
atomic_write_json(containment._store_path(), receipts)
publish_launch(bound.launch, authority, receipt, job=resource, processes=(process,))
save_background_authority(record["id"], authority, resource=resource)
return resource
def authorized_handler(handler, workspace):
async def execute(content, ctx):
from src.agent_runtime.process_resources import active_process_operation
from src.agent_runtime.authority import active_request_authority
if active_process_operation() is not None or active_request_authority() is not None:
return await handler(content, ctx)
tool = "python" if handler.__qualname__.startswith("PythonTool") else "bash"
from src.agent_runtime.resources import FilesystemRoot
from src.agent_runtime.process_resources import seal_launch_scope
owner = str(ctx.get("owner") or "").casefold()
authority = RequestAuthority("producer-test", owner, str(ctx.get("session_id") or ""), str(workspace), (OperationGrant(tool),))
authority = replace(authority, launch_scopes=(seal_launch_scope(NativeBackendResource(tool),
FilesystemRoot.seal(workspace, owner=owner), env=ctx.get("subproc_env")),))
with launch_authority(content, workspace, tool=tool, authority=authority):
return await handler(content, ctx)
return execute
def install_native_authority(monkeypatch, workspace):
from src.agent_tools import subprocess_tools
from src import tool_execution
from src.constants import DATA_DIR
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
original = cls.execute
async def execute(self, content, ctx, _original=original):
selected = Path(tool_execution.agent_cwd())
if selected == Path(DATA_DIR):
selected = Path(workspace)
return await authorized_handler(_original.__get__(self), selected)(content, ctx)
monkeypatch.setattr(cls, "execute", execute)
+14
View File
@@ -15,6 +15,11 @@ def server_authorized_executor(executor):
from src.tool_policy import known_tool_names
from src.turn_contract import canonical_tool
from src.agent_runtime.remote_resources import seal_backends
from src.agent_runtime.resources import FilesystemRoot, NativeBackendResource, ProcessLaunchScope
from src.containment import DEFAULT_REQUIRED
from src.agent_runtime.process_resources import seal_launch_scope
from pathlib import Path
import tempfile
call_signature = signature(executor)
@wraps(executor)
async def execute(*args, **kwargs):
@@ -22,10 +27,19 @@ def server_authorized_executor(executor):
parameters = bound.arguments
grants = tuple(OperationGrant(name) for name in sorted(
{canonical_tool(n) for n in known_tool_names()} | {"list_dir", "find_files"}))
original = parameters.get("exact_approval")
authority = original.pending.request_authority if original is not None else None
if authority is not None:
kwargs.setdefault("request_authority", authority)
scratch = Path(tempfile.mkdtemp(prefix="odysseus-dispatch-fixture-"))
launch_scopes = (None if parameters.get("workspace") else tuple(
seal_launch_scope(NativeBackendResource(tool), FilesystemRoot.seal(scratch))
for tool in ("bash", "python")))
kwargs.setdefault("request_authority", RequestAuthority(
"standalone-test-request", str(parameters.get("owner") or "").strip().casefold(),
str(parameters.get("session_id") or ""), str(parameters.get("workspace") or ""),
grants,
launch_scopes=launch_scopes,
backend_resources=seal_backends((g.tool for g in grants), context=parameters.get("client_runtime_context"),
owner=str(parameters.get("owner") or "").strip().casefold()),
))
+2 -1
View File
@@ -18,7 +18,8 @@ async def test_a_chat_session_always_uses_the_owned_runner(monkeypatch, tmp_path
async def forbidden(*args, **kwargs):
pytest.fail("native Bash resurrected a persistent tmux shell")
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", forbidden)
result = await subprocess_tools.BashTool().execute("printf ok", {"session_id": "same-chat"})
from tests.process_resource_helpers import authorized_handler
result = await authorized_handler(subprocess_tools.BashTool().execute, tmp_path)("printf ok", {"session_id": "same-chat"})
assert result["output"] == "ok"
assert result["teardown"]["dead"] is True
assert "tmux_session" not in result
+19 -16
View File
@@ -9,10 +9,15 @@ import pytest
from src import bg_jobs, containment, process_ownership, process_reaper, tool_execution
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
from tests.runtime_evidence_helpers import server_authorized_executor
from tests.process_resource_helpers import launch, get, kill
@pytest.fixture
def jobs(tmp_path, monkeypatch):
from src.agent_runtime import process_resources
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "jobs")
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "jobs.json")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
@@ -20,11 +25,11 @@ def jobs(tmp_path, monkeypatch):
monkeypatch.setattr(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
launched = []
yield tmp_path, launched
yield workspace, launched
for record in launched:
current = bg_jobs.get(record["id"])
current = get(record["id"])
if current and current["status"] == "running":
bg_jobs.kill(record["id"])
kill(record["id"])
proc = bg_jobs._LIVE_PROCS.pop(record["pid"], None)
if proc:
proc.wait(timeout=8)
@@ -33,7 +38,7 @@ def jobs(tmp_path, monkeypatch):
def finished(job_id):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
record = bg_jobs.get(job_id)
record = get(job_id)
if record["status"] != "running":
return record
time.sleep(0.03)
@@ -42,7 +47,7 @@ def finished(job_id):
def test_detached_execution_owns_boundary_and_reports_death(jobs):
path, launched = jobs
record = bg_jobs.launch("printf captured", "chat", cwd=str(path))
record = launch("printf captured", "chat", cwd=str(path))
launched.append(record)
result = finished(record["id"])
assert result["output"] == "captured"
@@ -73,7 +78,7 @@ def test_supervisor_setup_failure_closes_unstarted_grant(jobs):
result = subprocess.run([sys.executable, str(worker)], input=json.dumps(payload),
capture_output=True, text=True, timeout=10)
assert result.returncode == 0 # Supervisor publishes the failed job result.
assert "FileNotFoundError" in result.stderr
assert "KeyError" in result.stderr # Legacy unlinked payload fails before execution.
assert not (path / "must-not-exist").exists()
assert containment.active_grants() == []
assert (path / "exit").read_text() == "1"
@@ -102,7 +107,7 @@ async def test_bg_marker_refuses_without_spawning_and_authority_still_gates(jobs
def test_detached_supervisor_enforces_timeout(jobs):
path, launched = jobs
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
record = launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
launched.append(record)
result = finished(record["id"])
assert result["timed_out"] is True
@@ -111,11 +116,11 @@ def test_detached_supervisor_enforces_timeout(jobs):
def test_restart_keeps_verified_background_supervisor(jobs):
path, launched = jobs
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path))
record = launch("sleep 60", "chat", cwd=str(path))
launched.append(record)
report = process_reaper.reap_containment_grants()
assert report["background_kept"] == 1
killed = bg_jobs.kill(record["id"])
killed = kill(record["id"])
assert killed["killed"] is True
assert killed["teardown"]["dead"] is True
@@ -126,16 +131,14 @@ def test_kill_never_marks_a_foreign_pid_killed(jobs, monkeypatch):
bg_jobs._save({"stale": record})
monkeypatch.setattr(process_ownership, "verify", lambda *args: process_ownership.FOREIGN)
monkeypatch.setattr(bg_jobs, "_kill", lambda *args, **kwargs: pytest.fail("foreign process signalled"))
result = bg_jobs.kill("stale")
assert result["status"] == "running"
assert result.get("killed") is not True
assert result["teardown"]["dead"] is False
result = bg_jobs._kill_record(record) # Service cleanup still refuses foreign identity.
assert result.dead is False
def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
path, launched = jobs
for number in range(3):
launched.append(bg_jobs.launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
launched.append(launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
for number, record in enumerate(launched):
assert finished(record["id"])["output"] == f"job-{number}"
grants = containment._load_records()
@@ -145,11 +148,11 @@ def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
def test_detached_output_is_available_while_running(jobs):
path, launched = jobs
record = bg_jobs.launch("printf progress; sleep 5", "chat", cwd=str(path))
record = launch("printf progress; sleep 5", "chat", cwd=str(path))
launched.append(record)
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
current = bg_jobs.get(record["id"])
current = get(record["id"])
if "progress" in current["output"]:
assert current["status"] == "running"
return
+247
View File
@@ -0,0 +1,247 @@
from dataclasses import replace
import json
import os
import time
import pytest
from src import bg_jobs, containment, process_ownership
from src.agent_runtime import process_resources as resources
from src.agent_runtime.authority import RequestAuthority, OperationGrant, ExactOperation, restore_background_authority
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError, BackgroundJobResource, FilesystemRoot, FilesystemResource
from src.process_lifecycle import ProcessIdentity
from tests.process_resource_helpers import seed_linkage, launch_authority
@pytest.fixture
def store(tmp_path, monkeypatch):
workspace = tmp_path / "workspace"
workspace.mkdir()
private = tmp_path / "private"
monkeypatch.setattr(resources, "_LAUNCH_DIR", private / "launches")
monkeypatch.setattr(bg_jobs, "_STORE", private / "jobs.json")
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", private / "jobs")
monkeypatch.setattr(containment, "_store_path", lambda: private / "receipts.json")
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
monkeypatch.setattr(bg_jobs, "_pid_alive", lambda pid: True)
return workspace
def seed(workspace, job_id="job", status="running"):
bg_jobs._JOBS_DIR.mkdir(parents=True, exist_ok=True)
record = {"id": job_id, "session_id": "thread", "command": "printf output", "pid": 4321,
"status": status, "started_at": time.time(), "max_runtime_s": 3600,
"exit_path": str(bg_jobs._JOBS_DIR / (job_id + ".exit")),
"result_path": str(bg_jobs._JOBS_DIR / (job_id + ".result.json")),
"log_path": str(bg_jobs._JOBS_DIR / (job_id + ".log"))}
resource = seed_linkage(record, workspace, owner="alice", request_id="origin")
jobs = bg_jobs._load()
jobs[job_id] = record
bg_jobs._save(jobs)
return resource, record
@pytest.mark.parametrize("field,value", [("job_id", "sibling"), ("generation", "f" * 32), ("containment_id", "other-receipt"),
("owner", "bob"), ("request_id", "other-request"), ("thread_id", "other-thread")])
def test_job_substitution_fails_closed(store, field, value):
resource, _ = seed(store)
changed = resource.to_dict()
changed[field] = value
for process in changed["processes"]:
if field in process:
process[field] = value
expected = BackgroundJobResource.from_dict(changed)
with pytest.raises((ResourceIdentityError, OSError)):
resources.validate_job(expected)
@pytest.mark.parametrize("field,value", [("role", "leader"), ("namespace", "external:ssh"), ("identity", {"pid": 4321, "start_token": "replacement", "pgid": 4321})])
def test_role_producer_and_process_replacement_fail(store, field, value):
resource, _ = seed(store)
changed = resource.to_dict()
changed["processes"][0][field] = value
with pytest.raises((ValueError, OSError)):
resources.validate_job(BackgroundJobResource.from_dict(changed))
def test_completed_history_does_not_target_reused_process(store, monkeypatch):
resource, rec = seed(store, status="done")
with open(rec["log_path"], "w") as log:
log.write("historical output")
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.FOREIGN)
monkeypatch.setattr(bg_jobs, "_kill", lambda *a, **k: pytest.fail("historical process targeted"))
assert bg_jobs.get("job", expected=resource)["output"] == "historical output"
assert bg_jobs.kill("job", expected=resource)["status"] == "done"
def test_same_id_new_generation_does_not_inherit_authority(store):
old, _ = seed(store)
seed(store) # Same store key, new trusted launch generation.
with pytest.raises(ResourceIdentityError):
bg_jobs.kill("job", expected=old)
with pytest.raises(ResourceIdentityError):
bg_jobs.get("job", expected=old)
def test_receipt_substitution_is_revalidated_before_mutation(store, monkeypatch):
resource, _ = seed(store)
receipts = containment._load_records()
receipts[resource.containment_id]["launch_generation"] = "replacement"
from core.atomic_io import atomic_write_json
atomic_write_json(containment._store_path(), receipts)
monkeypatch.setattr(bg_jobs, "_kill_record", lambda *a: pytest.fail("replaced receipt used"))
with pytest.raises(ResourceIdentityError):
bg_jobs.kill("job", expected=resource)
def test_result_publication_cannot_overwrite_authoritative_fields(store):
resource, rec = seed(store)
report = {"resource_identity": resource.to_dict(), "containment": {"id": resource.containment_id},
"owner": "bob", "pid": 9999, "start_token": "replacement", "id": "other",
"launch_resource": {}, "session_id": "other", "containment_id": "fake"}
from pathlib import Path
Path(rec["result_path"]).write_text(json.dumps(report))
Path(rec["exit_path"]).write_text("0")
final = bg_jobs.refresh("job")["job"]
assert resources.job_from_record(final) == resource
assert final["pid"] == rec["pid"] and final["session_id"] == "thread"
def test_resolution_and_lookup_do_not_reap_unrelated_jobs(store, monkeypatch):
resource, _ = seed(store, status="done")
sibling, rec = seed(store, "sibling")
jobs = bg_jobs._load()
jobs["sibling"]["started_at"] = 0
bg_jobs._save(jobs)
monkeypatch.setattr(bg_jobs, "_kill_record", lambda *a: pytest.fail("unrelated job reaped"))
authority = RequestAuthority("lookup", "alice", "thread", "", (OperationGrant("manage_bg_jobs"),))
bound = resources.resolve_process_operation(authority, ExactOperation.normalize("manage_bg_jobs", '{"action":"output","job_id":"job"}'), NativeBackendResource("manage_bg_jobs"))
assert bound.jobs == (resource,)
bg_jobs.get("job", expected=resource)
assert bg_jobs.peek("sibling")["status"] == "running"
def test_child_cannot_target_sibling_or_replaced_job(store):
first, _ = seed(store, "first")
second, _ = seed(store, "second")
parent = RequestAuthority("parent", "alice", "thread", "", (OperationGrant("manage_bg_jobs"),), job_resources=(first,))
child = replace(parent, job_resources=(second,))
inherited = parent.intersect(child)
assert inherited.job_resources == ()
with pytest.raises(ResourceIdentityError):
resources.resolve_process_operation(inherited, ExactOperation.normalize("manage_bg_jobs", '{"action":"kill","job_id":"second"}'), NativeBackendResource("manage_bg_jobs"))
seed(store, "first")
with pytest.raises(ResourceIdentityError):
parent.intersect(child)
@pytest.mark.parametrize("field,value", [("generation", "f" * 32), ("owner", "bob"), ("request_id", "other"), ("thread_id", "other")])
def test_continuation_sidecar_mismatch_fails_closed(store, field, value):
resource, _ = seed(store, status="done")
sidecar = bg_jobs._JOBS_DIR / "job.authority.json"
data = json.loads(sidecar.read_text())
data["job"][field] = value
sidecar.write_text(json.dumps(data))
assert restore_background_authority("job", owner="alice", session_id="thread").grants == ()
def test_matching_continuation_preserves_original_authority(store):
seed(store, status="done")
authority = restore_background_authority("job", owner="alice", session_id="thread")
assert authority.request_id == "origin" and authority.inherited
assert authority.permits(ExactOperation.normalize("bash", "printf output"))
assert restore_background_authority("job", owner="bob", session_id="thread").grants == ()
@pytest.mark.parametrize("alias", ["direct", "symlink", "hardlink"])
@pytest.mark.parametrize("state", ["launch", "job_store", "sidecar", "receipt"])
def test_launch_and_job_control_files_are_protected(store, tmp_path, alias, state):
resource, _ = seed(store)
control = {"launch": resources.launch_path(resource.generation), "job_store": bg_jobs._STORE,
"sidecar": bg_jobs._JOBS_DIR / "job.authority.json", "receipt": containment._store_path()}[state]
target = control
if alias == "symlink":
target = store / "alias"
target.symlink_to(control)
elif alias == "hardlink":
target = store / "alias"
try:
os.link(control, target)
except OSError as e:
pytest.skip(f"hardlinks unavailable: {e}")
root = FilesystemRoot.seal(tmp_path)
with pytest.raises(ValueError):
FilesystemResource.resolve(root, str(target))
with pytest.raises(ResourceIdentityError):
resources.guard_launch_workspace(root)
if alias != "direct":
with pytest.raises(ResourceIdentityError):
resources.guard_launch_workspace(FilesystemRoot.seal(store))
def test_external_jobs_cannot_become_local_or_attest_containment(store):
resource, _ = seed(store)
external = resource.to_dict()
external["namespace"] = "external:ssh"
with pytest.raises(ValueError):
BackgroundJobResource.from_dict(external)
external = resource.to_dict()
external["contained"] = True
with pytest.raises(ValueError):
BackgroundJobResource.from_dict(external)
@pytest.mark.parametrize("field,value", [("external", True), ("mechanism", "external_bridge"),
("supervisor_token", "reused"), ("supervisor_pid", 9876), ("owner", "bg:other")])
def test_receipt_cannot_replace_producer_or_claim_external_containment(store, field, value):
resource, _ = seed(store, status="done")
receipts = containment._load_records()
receipts[resource.containment_id][field] = value
from core.atomic_io import atomic_write_json
atomic_write_json(containment._store_path(), receipts)
with pytest.raises(ResourceIdentityError):
bg_jobs.get("job", expected=resource)
with pytest.raises(ResourceIdentityError):
bg_jobs.mark_followed_up("job", expected=resource)
def test_target_lookup_does_not_wait_on_unrelated_live_handle(store, monkeypatch):
resource, _ = seed(store, status="done")
class OtherProcess:
def poll(self):
pytest.fail("Unrelated producer was reaped during lookup")
monkeypatch.setattr(bg_jobs, "_LIVE_PROCS", {9876: OtherProcess()})
bg_jobs.get("job", expected=resource)
def test_completed_result_outlives_lifecycle_receipt_without_signalling(store, monkeypatch):
resource, rec = seed(store, status="done")
from pathlib import Path
Path(rec["log_path"]).write_text("retained historical output")
from core.atomic_io import atomic_write_json
atomic_write_json(containment._store_path(), {})
monkeypatch.setattr(bg_jobs, "_kill_record", lambda *a: pytest.fail("Historical resource was signalled"))
assert bg_jobs.get("job", expected=resource)["output"] == "retained historical output"
assert bg_jobs.kill("job", expected=resource)["status"] == "done"
bg_jobs.mark_followed_up("job", expected=resource)
jobs = bg_jobs._load()
jobs["job"]["status"] = "running"
bg_jobs._save(jobs)
with pytest.raises(ResourceIdentityError):
bg_jobs.kill("job", expected=resource)
@pytest.mark.parametrize("state", ["unknown_status", "malformed_sidecar", "missing_publication"])
def test_unresolved_or_malformed_authoritative_state_fails_closed(store, state):
resource, _ = seed(store, status="done")
if state == "unknown_status":
jobs = bg_jobs._load()
jobs["job"]["status"] = "unknown"
bg_jobs._save(jobs)
elif state == "malformed_sidecar":
(bg_jobs._JOBS_DIR / "job.authority.json").write_text("[]")
else:
resources.launch_path(resource.generation).unlink()
with pytest.raises(ResourceIdentityError):
bg_jobs.get("job", expected=resource)
+26 -7
View File
@@ -13,10 +13,18 @@ import pytest
from src import bg_jobs, containment, process_ownership
from src.agent_tools.bg_job_tools import ManageBgJobsTool
from tests.process_resource_helpers import seed_linkage, get, kill
@pytest.fixture
def store(tmp_path, monkeypatch):
from src.agent_runtime import process_resources
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "receipts.json")
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(bg_jobs, "_test_workspace", workspace, raising=False)
monkeypatch.setattr(containment, "reap_record", lambda *a: containment.ReleaseOutcome(dead=True, escalated=False))
jobs_dir = tmp_path / "bg_jobs"
jobs_dir.mkdir()
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "bg_jobs.json")
@@ -43,6 +51,7 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
}
if output:
(bg_jobs._JOBS_DIR / f"{job_id}.log").write_text(output, encoding="utf-8")
seed_linkage(rec, bg_jobs._test_workspace)
jobs = bg_jobs._load()
jobs[job_id] = rec
bg_jobs._save(jobs)
@@ -50,14 +59,24 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
def _run(args, session_id="sess-a"):
return asyncio.run(ManageBgJobsTool().execute(json.dumps(args), {"session_id": session_id, "owner": None}))
from src.agent_runtime.authority import RequestAuthority, OperationGrant, ExactOperation, bind_request_authority
from src.agent_runtime.resources import NativeBackendResource
from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation
content = json.dumps(args)
authority = RequestAuthority("job-client-test", "", session_id, "", (OperationGrant("manage_bg_jobs"),))
try:
bound = resolve_process_operation(authority, ExactOperation.normalize("manage_bg_jobs", content), NativeBackendResource("manage_bg_jobs"))
with bind_request_authority(authority), bind_process_operation(bound):
return asyncio.run(ManageBgJobsTool().execute(content, {"session_id": session_id, "owner": None}))
except (ValueError, OSError) as e:
return {"error": str(e), "exit_code": 1}
# ── bg_jobs.kill ────────────────────────────────────────────────────────────
def test_kill_marks_killed_and_suppresses_followup(store):
_seed(job_id="job0001", pid=4321)
rec = bg_jobs.kill("job0001")
rec = kill("job0001")
assert rec["status"] == "failed"
assert rec["killed"] is True
assert rec["exit_code"] == -1
@@ -67,20 +86,20 @@ def test_kill_marks_killed_and_suppresses_followup(store):
def test_kill_unknown_job_returns_none(store):
assert bg_jobs.kill("nope") is None
assert bg_jobs.kill("nope", expected=None) is None
def test_kill_finished_job_is_noop(store):
_seed(job_id="done01", status="done")
rec = bg_jobs.kill("done01")
rec = kill("done01")
assert rec["status"] == "done"
assert store["killed"] == [] # no signal sent to an already-finished job
def test_result_text_reports_killed(store):
rec = _seed(job_id="job0001")
bg_jobs.kill("job0001")
assert "killed" in bg_jobs.result_text(bg_jobs.get("job0001")).lower()
kill("job0001")
assert "killed" in bg_jobs.result_text(get("job0001")).lower()
# ── manage_bg_jobs tool ─────────────────────────────────────────────────────
@@ -118,7 +137,7 @@ def test_kill_via_tool(store):
out = _run({"action": "kill", "job_id": "job0001"})
assert "Killed" in out["output"]
assert store["killed"] == [999]
assert bg_jobs.get("job0001")["killed"] is True
assert get("job0001")["killed"] is True
def test_kill_cross_session_denied(store):
+4
View File
@@ -17,6 +17,10 @@ def workspace(tmp_path, monkeypatch):
path.mkdir()
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(path))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
from tests.process_resource_helpers import install_native_authority
from src.agent_runtime import process_resources
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
install_native_authority(monkeypatch, path)
return path
+28 -49
View File
@@ -1,22 +1,9 @@
"""Stopping a Cookbook server, on a host with procfs and on one without.
"""Cookbook selectors and OS observations never mint application authority.
The tmux kill is what actually stops the server; the pid sweep that follows it
only catches model servers that survive the session's SIGHUP. Two invariants
live here.
**The stop must not fail because the host cannot be inspected.** Letting a
procfs scan raise on macOS turned a successful stop into a reported failure and
skipped the state write that marks the session stopped for the Cookbook UI
(ODY-94). Skipping the sweep silently fixed the crash and left the other half:
the stop then claimed success without having looked at all. So the sweep now
runs through ``ps`` where there is no procfs, and says so when it cannot look.
**The sweep signals only processes the session owns.** It used to kill anything
whose full command line matched the tracked one. The Cookbook composed that
command line, so an identical one is just as likely to be a server the user
started by hand — killing it is indistinguishable from killing ours, which is
the "stop only what we started" failure. Ownership now comes from the tmux
pane's process tree, captured before the kill; a lookalike is reported instead.
These legacy UI-backed targets have no authoritative launch registry. Local
agent stops therefore fail closed before discovery, signalling or state writes,
on both procfs and other hosts. Shared Wave 5B lifecycle mechanics are tested
separately in test_process_lifecycle and test_process_ownership.
"""
import asyncio
import json
@@ -160,7 +147,7 @@ def _install_effective_kill(monkeypatch, table):
@pytest.mark.asyncio
async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
async def test_unadmitted_stop_refused_when_the_host_has_no_procfs(
monkeypatch, tmp_path
):
"""The ODY-94 regression: no procfs must not turn a working stop into a failure."""
@@ -176,13 +163,12 @@ async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert result["output"].startswith("Stopped server serve-abc123")
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert result["failure_kind"] == "resource_identity_denied"
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
async def test_stop_says_so_when_the_session_cannot_be_inspected(
async def test_unadmitted_stop_refused_when_the_session_cannot_be_inspected(
monkeypatch, tmp_path
):
"""A sweep that could not look must not read as a sweep that found nothing.
@@ -209,15 +195,14 @@ async def test_stop_says_so_when_the_session_cannot_be_inspected(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert "could not identify the session's processes" in result["output"]
assert result["failure_kind"] == "resource_identity_denied"
assert signalled == []
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
"""A process under the session's pane is ours, so it gets signalled."""
async def test_pane_descendant_is_not_application_owned(monkeypatch, tmp_path):
"""A process under a named pane still requires prior application admission."""
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
state = _tracked_state(cmd=tracked_cmd)
posts = _install_httpx_client(monkeypatch, state)
@@ -232,14 +217,13 @@ async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert (101, signal.SIGTERM) in signalled
assert "killed 2 surviving process(es)" in result["output"]
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert result["failure_kind"] == "resource_identity_denied"
assert signalled == [] # OS lineage alone never establishes app ownership.
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
async def test_unadmitted_stop_never_signals_a_command_line_lookalike(
monkeypatch, tmp_path
):
"""The headline change: matching the command line is not owning the process.
@@ -262,13 +246,9 @@ async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert result["failure_kind"] == "resource_identity_denied"
assert not any(pid == 202 for pid, _sig in signalled)
# Reported rather than silently dropped: the old behaviour acted on this
# information, so giving it up entirely would be a regression of its own.
assert "202" in result["output"]
assert "not signalled" in result["output"]
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
@@ -302,10 +282,10 @@ async def test_stop_does_not_signal_a_pid_whose_identity_changed(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
# The pane shell is genuinely ours and is signalled; 101 never is.
assert result["failure_kind"] == "resource_identity_denied"
# Neither pane discovery nor a matching token creates application scope.
assert not any(pid == 101 for pid, _sig in signalled)
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
@@ -323,8 +303,8 @@ def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
@pytest.mark.asyncio
async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tmp_path):
"""Captured as ours, unverifiable at sweep time: not signalled, and said so."""
async def test_unadmitted_stop_refused_with_unverifiable_process(monkeypatch, tmp_path):
"""An unverifiable OS observation cannot create an application grant."""
from src import process_ownership
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
@@ -347,10 +327,9 @@ async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tm
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
assert result["exit_code"] == 0
assert result["failure_kind"] == "resource_identity_denied"
assert not any(pid == 101 for pid, _sig in signalled)
assert "could not be re-identified and were not signalled (pid 101)" in result["output"]
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
@@ -383,6 +362,6 @@ async def test_stop_never_signals_a_pid_reissued_between_the_table_and_its_captu
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
assert result["exit_code"] == 0
assert result["failure_kind"] == "resource_identity_denied"
assert not any(pid == 101 for pid, _sig in signalled)
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
+13 -3
View File
@@ -11,13 +11,23 @@ from src.agent_tools import subprocess_tools
@pytest.fixture(autouse=True)
def native_boundary(tmp_path, monkeypatch):
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
from src.agent_runtime import process_resources
from tests.process_resource_helpers import authorized_handler
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(workspace))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
original = cls.execute
async def execute(self, content, ctx, _original=original):
return await authorized_handler(_original.__get__(self), workspace)(content, ctx)
monkeypatch.setattr(cls, "execute", execute)
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "MECHANISMS", tuple(
m for m in containment.MECHANISMS if m.name == "process_group"
))
return tmp_path
return workspace
@pytest.mark.skipif(os.name == "nt", reason="real POSIX group teardown")
+8 -2
View File
@@ -399,10 +399,16 @@ def test_already_finished_jobs_are_not_reconsidered(job_store, monkeypatch):
assert bg_jobs.disown_unverified() == {"seen": 0, "retired": 0, "kept": 0}
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store):
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store, tmp_path, monkeypatch):
"""Without this the record is unverifiable forever and the reaper can only
refuse — the token has to be captured at launch or not at all."""
record = bg_jobs.launch("true", "chat-1")
from tests.process_resource_helpers import launch
from src.agent_runtime import process_resources
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
record = launch("true", "chat-1", cwd=str(workspace))
assert "start_token" in record
assert process_ownership.verify(record["pid"], record["start_token"]) in (
+123
View File
@@ -0,0 +1,123 @@
from dataclasses import replace
import json
import signal
import pytest
from src import process_ownership
from src.process_lifecycle import ProcessIdentity, signal_identity
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
from src.agent_runtime.resources import ProcessResource, NativeBackendResource, FilesystemRoot, ProcessLaunchScope, ResourceIdentityError
from src.agent_runtime.process_resources import resolve_process_operation
from src.containment import DEFAULT_REQUIRED
def process():
return ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(4321, "boot:start", 4321), "leader", "job", "receipt")
@pytest.mark.parametrize("verdict", [process_ownership.FOREIGN, process_ownership.GONE, process_ownership.UNVERIFIABLE])
def test_stale_reused_or_unverifiable_identity_cannot_be_admitted(monkeypatch, verdict):
monkeypatch.setattr(process_ownership, "verify", lambda *a: verdict)
with pytest.raises(ResourceIdentityError):
process().validate()
@pytest.mark.parametrize("field,value", [("pid", 0), ("pid", "4321"), ("pid", True), ("pgid", "4321"), ("start_token", None), ("start_token", ""), ("start_token", {})])
def test_malformed_lifecycle_observations_fail_closed(field, value):
record = process().to_dict()
record["identity"][field] = value
with pytest.raises((ValueError, TypeError)):
ProcessResource.from_dict(record)
def test_no_duplicate_lifecycle_fields_and_strict_restore():
resource = process()
record = resource.to_dict()
assert ProcessResource.from_dict(record) == resource
assert "pid" not in record and "start_token" not in record
record["identity"]["incarnation"] = "invented"
with pytest.raises(ValueError):
ProcessResource.from_dict(record)
def test_incarnation_is_not_application_ownership(monkeypatch):
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
resource = process()
resource.validate()
for field in ("namespace", "owner", "request_id", "thread_id", "role", "job_id", "containment_id"):
if field in {"namespace", "role"}:
with pytest.raises(ValueError):
replace(resource, **{field: "supervisor" if field == "role" else "external:ssh"})
continue
changed = replace(resource, **{field: "supervisor" if field == "role" else "other"})
assert changed != resource
with pytest.raises(ValueError):
RequestAuthority("request", "bob", "thread", "", process_resources=(resource,))
with pytest.raises(ValueError):
RequestAuthority("request", "alice", "other-thread", "", process_resources=(resource,))
def test_pid_reuse_at_signal_boundary_uses_wave5b_engine(monkeypatch):
verdicts = iter([process_ownership.OWNED, process_ownership.OWNED, process_ownership.FOREIGN])
monkeypatch.setattr(process_ownership, "verify", lambda *a: next(verdicts))
monkeypatch.setattr("src.process_lifecycle.is_zombie", lambda pid: False)
monkeypatch.setattr("os.kill", lambda *a: pytest.fail("reused PID signalled"))
target = process()
target.validate()
assert signal_identity(target.identity, signal.SIGTERM) is False
def test_child_cannot_renew_replaced_parent_process(monkeypatch):
old = process()
fresh = replace(old, identity=replace(old.identity, start_token="boot:replacement"))
monkeypatch.setattr(process_ownership, "verify", lambda pid, token: process_ownership.FOREIGN if token == "boot:start" else process_ownership.OWNED)
parent = RequestAuthority("parent", "alice", "thread", "", process_resources=(old,))
child = replace(parent, request_id="child", process_resources=(fresh,))
with pytest.raises(ResourceIdentityError):
parent.intersect(child)
def test_legacy_authority_cannot_reconstruct_creation_scope(tmp_path):
authority = RequestAuthority("request", "alice", "thread", str(tmp_path), (OperationGrant("bash"),))
snapshot = authority.to_dict()
snapshot["version"] = 3
for field in ("launch_scopes", "process_resources", "job_resources"):
snapshot.pop(field)
restored = RequestAuthority.from_dict(snapshot)
assert restored.launch_scopes == restored.process_resources == restored.job_resources == ()
with pytest.raises(ResourceIdentityError):
resolve_process_operation(restored, ExactOperation.normalize("bash", "pwd"), NativeBackendResource("bash"))
def test_launch_is_server_generation_exact_operation_and_credential_free(tmp_path):
authority = RequestAuthority("request", "alice", "thread", str(tmp_path), (OperationGrant("bash"),))
operation = ExactOperation.normalize("bash", "printf secret-token")
bound = resolve_process_operation(authority, operation, NativeBackendResource("bash"))
assert "secret-token" not in json.dumps(bound.to_dict())
assert len(bound.launch.generation) == 32
assert bound.launch.scope.root == authority.resource_roots[0]
with pytest.raises(ResourceIdentityError):
resolve_process_operation(authority, ExactOperation.normalize("bash", "pwd"), NativeBackendResource("bash"), approved=bound, exact_admission=True)
def test_child_launch_scope_can_narrow_but_cannot_broaden(tmp_path):
sub = tmp_path / "child"
sub.mkdir()
parent = RequestAuthority("request", "alice", "thread", str(tmp_path), (OperationGrant("bash"),))
smaller = ProcessLaunchScope(NativeBackendResource("bash"), FilesystemRoot.seal(sub, owner="alice"), DEFAULT_REQUIRED)
child = replace(parent, launch_scopes=(smaller,))
assert parent.intersect(child).launch_scopes == (smaller,)
assert child.intersect(parent).launch_scopes == ()
def test_child_launch_cannot_refresh_a_replaced_root(tmp_path):
root = tmp_path / "root"
root.mkdir()
parent = RequestAuthority("request", "alice", "thread", str(root), (OperationGrant("bash"),))
root.rename(tmp_path / "retired")
root.mkdir()
child = RequestAuthority("child", "alice", "thread", str(root), (OperationGrant("bash"),))
with pytest.raises(ResourceIdentityError):
parent.intersect(child)
+5 -1
View File
@@ -184,10 +184,14 @@ async def test_external_record_does_not_grant_authority(tmp_path):
async def test_native_local_bash_python_behavior_unchanged(tmp_path, monkeypatch):
"""4. Native local Bash/Python behavior is unchanged."""
tool_bash = subprocess_tools.BashTool()
from tests.process_resource_helpers import authorized_handler
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(_te, "agent_cwd", lambda: str(workspace))
ctx = {
"session_id": "native-session",
}
result = await tool_bash.execute("echo 'native run'", ctx)
result = await authorized_handler(tool_bash.execute, workspace)("echo 'native run'", ctx)
assert result["exit_code"] == 0
assert "native run" in result["output"]
assert "containment" in result
+13 -9
View File
@@ -158,15 +158,15 @@ async def test_missing_and_malformed_dispatch_authority_fail_closed(monkeypatch,
@pytest.mark.asyncio
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch):
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch, tmp_path):
from src import tool_execution as execution
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
for disabled in (set(), {"bash"}):
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
owner="alice", session_id="s", disabled_tools=disabled,
owner="alice", session_id="s", workspace=str(tmp_path), disabled_tools=disabled,
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
request_authority=authority("bash"))
request_authority=authority("bash", workspace=str(tmp_path)))
assert result["exit_code"] == (1 if disabled else 0)
assert implementation.await_count == 1
@@ -224,10 +224,11 @@ def test_background_snapshot_preserves_scope_and_rejects_other_session(monkeypat
import src.constants
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
grant = authority("transcribe_media").restrict(disabled_tools={"bash"})
save_background_authority("job1", grant)
# Legacy authority-only snapshots have no exact job generation to restore.
with pytest.raises(ValueError):
save_background_authority("job1", grant)
restored = restore_background_authority("job1", owner="alice", session_id="s")
assert restored.request_id == grant.request_id
assert restored.denied == frozenset({"bash"})
assert restored.grants == ()
assert not restored.permits(ExactOperation.normalize("python", "print(1)"))
assert restore_background_authority("job1", owner="alice", session_id="other").grants == ()
@@ -243,8 +244,7 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
request_authority=authority("bash"))
restored = restore_background_authority("server-job", owner="alice", session_id="s")
assert restored.request_id == "request-test"
assert restored.permits(ExactOperation.normalize("bash", "printf trusted"))
assert restored.grants == () # A launch double returning an ID cannot publish authority.
handler = AsyncMock(return_value=("transcribe_media", {"bg_job_id": "forged-job", "exit_code": 0}))
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'),
@@ -254,12 +254,16 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
@pytest.mark.asyncio
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch):
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch, tmp_path):
from src import tool_execution as execution
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
store = ToolApprovalStore()
original = authority("transcribe_media")
from src.agent_runtime.resources import ProcessLaunchScope, FilesystemRoot, NativeBackendResource
from src.containment import DEFAULT_REQUIRED
original = replace(original, launch_scopes=(ProcessLaunchScope(NativeBackendResource("bash"),
FilesystemRoot.seal(tmp_path), DEFAULT_REQUIRED),))
pending = store.create(owner="alice", session_id="s", origin_run_id="journal-parent",
tool_name="bash", content="printf approved", workspace=None,
external_untrusted_context_seen=True, capabilities=capabilities_for_action("bash", "printf approved"),
+7 -4
View File
@@ -397,8 +397,10 @@ def test_task_and_background_continuations_keep_original_roots(tmp_path, monkeyp
import src.constants
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
grant = authority(tmp_path, "read_file")
save_background_authority("job", grant)
assert restore_background_authority("job", owner="alice", session_id="s").resource_roots == grant.resource_roots
# A roots-only sidecar is legacy state and cannot invent a job generation.
with pytest.raises(ValueError):
save_background_authority("job", grant)
assert restore_background_authority("job", owner="alice", session_id="s").resource_roots == ()
assert restore_background_authority("job", owner="bob", session_id="s").resource_roots == ()
with bind_request_authority(grant):
sealed = seal_task_authority("Read files in the workspace", "llm", None, owner="alice")
@@ -708,10 +710,11 @@ def test_nonfilesystem_identities_are_inert_and_distinguish_producers_from_pages
page = BrowserPageResource(producer, "page-1", 2, "https://example.test")
assert replace(producer, incarnation="incarnation-2") != producer
assert replace(page, navigation_generation=3) != page
ProcessResource("local", "boot/process", "alice", 123, "boot:start", "job", "receipt", 124, "boot:init")
from src.process_lifecycle import ProcessIdentity
ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(123, "boot:start"), "leader", "job", "receipt")
OwnedResource("documents", "alice", "thread", "documents", "document", "revision")
assert ExternalResource("mcp", "endpoint", "server", "tool", "connection").external is True
with pytest.raises(ValueError):
ExternalResource("mcp", "endpoint", "server", "tool", "connection", external=False)
with pytest.raises(ValueError):
ProcessResource("local", "incarnation", "alice", 123, "", containment_id="receipt")
ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(123, ""), "leader", containment_id="receipt")
+354
View File
@@ -0,0 +1,354 @@
import asyncio
from dataclasses import replace
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from src import bg_jobs, containment, process_ownership, tool_execution
from src.agent_runtime import process_resources as resources
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority, create_request_authority
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError
from src.agent_tools.subprocess_tools import BashTool
from src.process_lifecycle import ProcessIdentity
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
from src.tool_types import ToolBlock
from tests.process_resource_helpers import launch_authority, seed_linkage
@pytest.fixture
def workspace(tmp_path, monkeypatch):
work = tmp_path / "workspace"
work.mkdir()
monkeypatch.setattr(resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "private" / "jobs.json")
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "private" / "jobs")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "receipts.json")
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
return work
def authority(workspace, tool="bash"):
return RequestAuthority("request", "alice", "thread", str(workspace), (OperationGrant(tool),))
def approval_for(authority, tool, content):
store = ToolApprovalStore()
pending = store.create(owner=authority.owner, session_id=authority.session_id, origin_run_id="run",
tool_name=tool, content=content, workspace=authority.workspace,
capabilities=capabilities_for_action(tool, content), external_untrusted_context_seen=True,
request_authority=authority)
return store.consume(pending.approval_id, owner=authority.owner, session_id=authority.session_id, decision="approve")
async def dispatch(authority, tool, content, approval=None):
return await tool_execution.execute_tool_block(ToolBlock(tool, content), owner=authority.owner,
session_id=authority.session_id, workspace=authority.workspace,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=bool(approval)),
request_authority=authority, exact_approval=approval)
async def test_native_producer_without_binding_cannot_spawn(workspace, monkeypatch):
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("unbound spawn"))
result = await BashTool().execute("printf unsafe", {})
assert result["failure_kind"] == "resource_identity_denied"
async def test_producer_rejects_changed_command_after_admission(workspace, monkeypatch):
with launch_authority("printf admitted", workspace):
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("retargeted spawn"))
result = await BashTool().execute("printf changed", {})
assert result["blocked"]
@pytest.mark.parametrize("ctx", [{"owner": "bob", "session_id": "thread"},
{"owner": "alice", "session_id": "replacement"}])
async def test_native_producer_rechecks_application_binding(workspace, monkeypatch, ctx):
admitted = authority(workspace)
operation = ExactOperation.normalize("bash", "printf admitted")
bound = resources.resolve_process_operation(admitted, operation, NativeBackendResource("bash"))
monkeypatch.setattr(containment, "acquire", lambda *a, **k: pytest.fail("Rebound producer acquired boundary"))
with bind_request_authority(admitted), resources.bind_process_operation(bound):
result = await BashTool().execute(operation.input, ctx)
assert result["exit_code"] == 1 and "owner or session changed" in result["error"]
async def test_scheduled_local_runner_uses_exact_launch_ceiling(workspace):
from src import builtin_actions
output, success = await builtin_actions.action_run_local("alice", script="printf scheduled")
assert not success and "no server authority" in output
admitted = replace(authority(workspace), grants=(OperationGrant("bash", inputs=frozenset({"printf scheduled"})),))
with bind_request_authority(admitted):
output, success = await builtin_actions.action_run_local("alice", script="printf scheduled")
assert success and output == "scheduled"
output, success = await builtin_actions.action_run_local("alice", script="printf changed")
assert not success and "sealed operation" in output
output, success = await builtin_actions.action_ssh_command("alice", command="printf scheduled", host="remote.example")
assert not success and "external backend" in output
async def test_attachment_failure_after_execution_does_not_claim_no_execution(workspace, monkeypatch):
def failure(*args):
raise OSError("attachment publication failed")
monkeypatch.setattr(resources, "attach_containment_processes", failure)
_, result = await dispatch(authority(workspace), "bash", "printf occurred > effect")
assert (workspace / "effect").read_text() == "occurred"
assert result["exit_code"] == 1 and result["failure_kind"] == "resource_linkage_unavailable"
assert result["containment"]["executed"] is True and result["teardown"]["dead"] is True
async def test_exact_launch_first_use_replay_and_empty_scope_restoration(workspace):
original = authority(workspace)
approval = approval_for(original, "bash", "printf exact")
assert approval.pending.process_operation.launch is not None
restored = replace(original, grants=(), resource_roots=(), backend_resources=(), launch_scopes=(), process_resources=(), job_resources=())
_, first = await dispatch(restored, "bash", "printf exact", approval)
assert first["exit_code"] == 0 and first["output"] == "exact"
assert restored.launch_scopes == restored.job_resources == restored.process_resources == ()
_, replay = await dispatch(restored, "bash", "printf exact", approval)
assert replay["exit_code"] == 1
_, sibling = await dispatch(restored, "bash", "printf sibling")
assert sibling["failure_kind"] == "request_authority_denied"
async def test_exact_job_first_use_replay_and_empty_scope_restoration(workspace, monkeypatch):
bg_jobs._JOBS_DIR.mkdir(parents=True)
record = {"id": "job", "session_id": "thread", "command": "printf history", "pid": 4321,
"status": "done", "started_at": 1, "max_runtime_s": 3600,
"log_path": str(bg_jobs._JOBS_DIR / "job.log")}
seed_linkage(record, workspace, owner="alice")
Path(record["log_path"]).write_text("historical result")
bg_jobs._save({"job": record})
original = authority(workspace, "manage_bg_jobs")
content = '{"action":"output","job_id":"job"}'
approval = approval_for(original, "manage_bg_jobs", content)
restored = replace(original, grants=(), resource_roots=(), backend_resources=(),
launch_scopes=(), process_resources=(), job_resources=())
_, first = await dispatch(restored, "manage_bg_jobs", content, approval)
assert first["exit_code"] == 0 and "historical result" in first["output"]
_, replay = await dispatch(restored, "manage_bg_jobs", content, approval)
assert replay["exit_code"] == 1
_, unapproved = await dispatch(restored, "manage_bg_jobs", content)
assert unapproved["failure_kind"] == "request_authority_denied"
assert restored.process_resources == restored.job_resources == restored.launch_scopes == ()
async def test_cancellation_at_native_spawn_restores_all_context(workspace, monkeypatch):
entered = asyncio.Event()
async def held_run(grant, command, **kwargs):
assert resources.active_process_operation().launch is not None
entered.set()
try:
await asyncio.Future()
finally:
containment.release(grant, grace_s=0)
monkeypatch.setattr(containment, "run", held_run)
async def invoke():
try:
await dispatch(authority(workspace), "bash", "sleep 60")
finally:
from src.agent_runtime.authority import active_request_authority
assert resources.active_process_operation() is None
assert active_request_authority() is None
task = asyncio.create_task(invoke())
await asyncio.wait_for(entered.wait(), timeout=5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert containment.active_grants() == []
@pytest.mark.parametrize("field,value", [("owner", "bob"), ("request_id", "replacement"), ("session_id", "other-thread")])
async def test_exact_launch_binding_substitution_fails(workspace, field, value):
original = authority(workspace)
approval = approval_for(original, "bash", "printf exact")
changed = replace(original, **{field: value}, resource_roots=None, backend_resources=None,
owned_scopes=None, launch_scopes=None)
_, denied = await dispatch(changed, "bash", "printf exact", approval)
assert denied["exit_code"] == 1 and not approval._claimed
async def test_exact_launch_replaced_workspace_fails_before_claim(workspace):
original = authority(workspace)
approval = approval_for(original, "bash", "pwd")
workspace.rename(workspace.with_name("retired"))
workspace.mkdir()
_, result = await dispatch(original, "bash", "pwd", approval)
assert result["failure_kind"] == "resource_identity_denied" and not approval._claimed
@pytest.mark.parametrize("phase", ["success", "error", "cancel", "nested"])
async def test_process_context_restores(workspace, phase):
original = authority(workspace)
bound = resources.resolve_process_operation(original, ExactOperation.normalize("bash", "pwd"), NativeBackendResource("bash"))
async def call():
with resources.bind_process_operation(bound):
assert resources.active_process_operation() is bound
if phase == "error":
raise RuntimeError("ordinary")
if phase == "cancel":
raise asyncio.CancelledError()
if phase == "nested":
with resources.bind_process_operation(None):
assert resources.active_process_operation() is None
assert resources.active_process_operation() is bound
try:
await call()
except (RuntimeError, asyncio.CancelledError):
pass
assert resources.active_process_operation() is None
@pytest.mark.parametrize("publication", ["launch", "sidecar", "job"])
def test_detached_publication_failure_cannot_release_workload(workspace, monkeypatch, publication):
effect = workspace / "effect"
if publication == "launch":
monkeypatch.setattr(resources, "publish_launch", lambda *a, **k: (_ for _ in ()).throw(OSError("publication failed")))
elif publication == "sidecar":
monkeypatch.setattr("src.agent_runtime.authority.save_background_authority", lambda *a, **k: (_ for _ in ()).throw(OSError("sidecar failed")))
else:
monkeypatch.setattr(bg_jobs, "_save", lambda *a: (_ for _ in ()).throw(OSError("job failed")))
with launch_authority("printf unsafe > effect", workspace):
with pytest.raises(OSError):
bg_jobs.launch("printf unsafe > effect", "chat", cwd=str(workspace))
assert not effect.exists()
assert containment.active_grants() == []
def test_detached_release_observes_complete_durable_linkage(workspace, monkeypatch):
real_popen = bg_jobs.subprocess.Popen
observations = []
def popen(*args, **kwargs):
proc = real_popen(*args, **kwargs)
original = proc.stdin
class Gate:
@property
def closed(self):
return original.closed
def close(self):
return original.close()
def write(self, content):
payload = json.loads(content)
published = json.loads(Path(payload["launch_path"]).read_text())
sidecar = json.loads(Path(payload["authority_path"]).read_text())
rec = bg_jobs.peek(payload["job_id"])
assert rec["resource_identity"] == published["job"] == sidecar["job"]
assert sidecar["authority"] == published["authority"]
observations.append(True)
return original.write(content)
proc.stdin = Gate()
return proc
monkeypatch.setattr(bg_jobs.subprocess, "Popen", popen)
with launch_authority("printf released", workspace):
rec = bg_jobs.launch("printf released", "chat", cwd=str(workspace))
assert observations == [True]
proc = bg_jobs._LIVE_PROCS.pop(rec["pid"])
proc.wait(timeout=10)
bg_jobs.refresh(rec["id"])
assert bg_jobs.peek(rec["id"])["status"] == "done"
@pytest.mark.parametrize("replacement", ["pid", "job", "receipt", "role"])
async def test_job_approval_revalidates_exact_resource_before_claim(workspace, monkeypatch, replacement):
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
bg_jobs._JOBS_DIR.mkdir(parents=True)
record = {"id": "job", "session_id": "thread", "command": "sleep 60", "pid": 4321,
"status": "running", "started_at": 1, "max_runtime_s": 3600,
"exit_path": str(bg_jobs._JOBS_DIR / "job.exit"), "log_path": str(bg_jobs._JOBS_DIR / "job.log")}
seed_linkage(record, workspace, owner="alice")
bg_jobs._save({"job": record})
admitted = authority(workspace, "manage_bg_jobs")
content = '{"action":"kill","job_id":"job"}'
approval = approval_for(admitted, "manage_bg_jobs", content)
assert approval.pending.process_operation.jobs
if replacement == "pid":
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.FOREIGN)
else:
jobs = bg_jobs._load()
if replacement == "job":
jobs["job"]["resource_identity"]["generation"] = "f" * 32
elif replacement == "role":
jobs["job"]["resource_identity"]["processes"][0]["role"] = "leader"
else:
jobs["job"]["containment_id"] = "replacement"
bg_jobs._save(jobs)
_, result = await dispatch(admitted, "manage_bg_jobs", content, approval)
assert result["failure_kind"] == "resource_identity_denied" and not approval._claimed
@pytest.mark.parametrize("request_text", ["Transcribe /workspace/audio.wav", "OCR this image", "List my tasks"])
async def test_new_resources_do_not_expand_turn_contract_classes(workspace, request_text):
admitted = create_request_authority(request_text, owner="alice", session_id="thread", workspace=str(workspace))
_, denied = await dispatch(admitted, "bash", "pwd")
assert denied["failure_kind"] == "request_authority_denied"
def test_internal_shell_control_has_no_admin_floor_even_without_auth(monkeypatch):
from routes import shell_routes
from core.middleware import INTERNAL_TOOL_USER
from fastapi import HTTPException
request = SimpleNamespace(headers={}, state=SimpleNamespace(current_user=INTERNAL_TOOL_USER))
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: True)
with pytest.raises(HTTPException) as error:
shell_routes._require_admin(request)
assert error.value.status_code == 403
@pytest.mark.parametrize("mode", ["auth_disabled", "missing_manager"])
def test_unlabelled_loopback_cannot_gain_native_control(monkeypatch, mode):
from routes import shell_routes
from fastapi import HTTPException
request = SimpleNamespace(headers={}, state=SimpleNamespace(current_user=None),
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)))
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: mode == "auth_disabled")
with pytest.raises(HTTPException) as error:
shell_routes._require_admin(request)
assert error.value.status_code == 403
def test_authenticated_human_administration_is_not_an_internal_tool_floor(monkeypatch):
from routes import shell_routes
request = SimpleNamespace(headers={}, state=SimpleNamespace(current_user="admin"),
app=SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(is_admin=lambda u: u == "admin"))))
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: False)
shell_routes._require_admin(request)
@pytest.mark.parametrize("path,payload", [("/api/cookbook/kill-pid", {"pid": 4321}),
("/api/cookbook/state", {"tasks": []}), ("/api/model/serve", {}), ("/api/model/download", {})])
async def test_anonymous_native_cookbook_control_rejected_before_producer(monkeypatch, path, payload):
from routes import cookbook_routes, shell_routes
from fastapi import FastAPI
import httpx
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: True)
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("Anonymous producer reached"))
monkeypatch.setattr(asyncio, "create_subprocess_shell", lambda *a, **k: pytest.fail("Anonymous producer reached"))
app = FastAPI()
app.include_router(cookbook_routes.setup_cookbook_routes())
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://local") as client:
result = await client.post(path, json=payload)
assert result.status_code == 403
@pytest.mark.parametrize("path", ["/api/shell/exec", "/api/model/serve", "/api/cookbook/kill-pid", "/api/cookbook/state", "/api/shell/../cookbook/kill-pid"])
def test_generic_loopback_cannot_bypass_process_resources(path):
from src.agent_runtime.owned_resources import needs_owned_binding
with pytest.raises(ResourceIdentityError):
needs_owned_binding(ExactOperation.normalize("app_api", json.dumps({"path": path})))
async def test_direct_local_cookbook_control_does_not_enroll_discovered_processes(monkeypatch):
from src.tools import cookbook
async def state():
return {}
monkeypatch.setattr(cookbook, "_capture_session_processes", lambda *a: pytest.fail("discovery enrolled as ownership"))
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("unbound Cookbook control"))
# No server session registry exists for this selector; observation cannot
# mint a process resource even when the UI supplies a matching name.
result = await cookbook._cookbook_kill_session("serve-unowned")
assert result["failure_kind"] == "resource_identity_denied"
+9
View File
@@ -32,6 +32,15 @@ def _pending(store, **overrides):
"capabilities": capabilities_for_action("bash", "printf exact"),
}
values.update(overrides)
if "request_authority" not in values:
import tempfile
from src.agent_runtime.authority import RequestAuthority, OperationGrant
from src.agent_runtime.resources import ProcessLaunchScope, FilesystemRoot, NativeBackendResource
from src.containment import DEFAULT_REQUIRED
tool = values["tool_name"]
scopes = (ProcessLaunchScope(NativeBackendResource(tool), FilesystemRoot.seal(tempfile.mkdtemp(prefix="w3-approval-fixture-")), DEFAULT_REQUIRED),) if tool in {"bash", "python"} else ()
values["request_authority"] = RequestAuthority("standalone-test-request", str(values["owner"]).casefold(),
str(values["session_id"] or ""), str(values["workspace"] or ""), (OperationGrant(tool),), launch_scopes=scopes)
return store.create(**values)
@@ -3,6 +3,19 @@ from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def native_resource_authority(tmp_path, monkeypatch):
from tests.process_resource_helpers import install_native_authority
from src.agent_runtime import process_resources
from src import containment
workspace = tmp_path / "native-workspace"
workspace.mkdir()
control = tmp_path.parent / (tmp_path.name + "-control")
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", control / "launches")
monkeypatch.setattr(containment, "_store_path", lambda: control / "grants.json")
install_native_authority(monkeypatch, workspace)
def test_unoffered_artifact_recovery_is_bounded():
from src.agent_loop import _artifact_unoffered_recovery_exhausted