From db41d7e82202a022f7abeb6725604ecfa6a7623d Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:43:45 +0100 Subject: [PATCH] feat(runtime): bind process and job resources to authority --- .../wave-3-checkpoint-a-tests.txt | 145 ++++++ .../wave-3-checkpoint-a.md | 215 +++++++++ routes/cookbook_routes.py | 15 +- routes/shell_routes.py | 18 +- src/agent_runtime/authority.py | 82 +++- src/agent_runtime/owned_resources.py | 3 +- src/agent_runtime/process_resources.py | 418 ++++++++++++++++++ src/agent_runtime/resources.py | 167 ++++++- src/agent_tools/bg_job_tools.py | 22 +- src/agent_tools/subprocess_tools.py | 43 +- src/bg_jobs.py | 87 +++- src/bg_monitor.py | 14 +- src/builtin_actions.py | 36 +- src/constants.py | 1 + src/containment_worker.py | 36 ++ src/tool_approvals.py | 11 + src/tool_execution.py | 31 +- src/tools/cookbook.py | 4 +- tests/containment_helpers.py | 2 +- tests/process_resource_helpers.py | 98 ++++ tests/runtime_evidence_helpers.py | 14 + tests/test_agent_tmux_retirement.py | 3 +- tests/test_background_containment.py | 35 +- tests/test_background_resource_identity.py | 247 +++++++++++ tests/test_bg_job_tools.py | 33 +- tests/test_containment_enforcement.py | 4 + tests/test_cookbook_stop_without_procfs.py | 77 ++-- tests/test_native_execution_containment.py | 16 +- tests/test_orphan_reaping.py | 10 +- tests/test_process_resource_identity.py | 123 ++++++ tests/test_production_external_bridge.py | 6 +- tests/test_request_authority.py | 22 +- tests/test_resource_identity.py | 11 +- tests/test_runtime_resource_integration.py | 354 +++++++++++++++ tests/test_tool_approvals.py | 9 + tests/test_workspace_artifact_tool_floor.py | 13 + 36 files changed, 2251 insertions(+), 174 deletions(-) create mode 100644 docs/runtime-decomposition/wave-3-checkpoint-a-tests.txt create mode 100644 docs/runtime-decomposition/wave-3-checkpoint-a.md create mode 100644 src/agent_runtime/process_resources.py create mode 100644 tests/process_resource_helpers.py create mode 100644 tests/test_background_resource_identity.py create mode 100644 tests/test_process_resource_identity.py create mode 100644 tests/test_runtime_resource_integration.py diff --git a/docs/runtime-decomposition/wave-3-checkpoint-a-tests.txt b/docs/runtime-decomposition/wave-3-checkpoint-a-tests.txt new file mode 100644 index 000000000..12d84942d --- /dev/null +++ b/docs/runtime-decomposition/wave-3-checkpoint-a-tests.txt @@ -0,0 +1,145 @@ +tests/test_resource_identity.py +tests/test_owned_resource_identity.py +tests/test_remote_resource_identity.py +tests/test_request_authority.py +tests/test_tool_approvals.py +tests/test_tool_approval_single_action_scope.py +tests/test_tool_approval_task_scope.py +tests/test_workspace_confine.py +tests/test_tool_path_confinement.py +tests/test_path_confinement_boundary.py +tests/test_filesystem_tool_argument_validation.py +tests/test_code_nav_tools.py +tests/test_apply_patch_transaction.py +tests/test_execution_bridge.py +tests/test_production_external_bridge.py +tests/test_turn_contract.py +tests/test_turn_contract_read_operations.py +tests/test_turn_contract_integration.py +tests/test_agent_turn_contract_boundaries.py +tests/test_explicit_personal_turn_contract.py +tests/test_nested_invocation_ownership.py +tests/test_containment_contract.py +tests/test_containment_enforcement.py +tests/test_containment_process_tree.py +tests/test_native_execution_containment.py +tests/test_background_containment.py +tests/test_process_ownership.py +tests/test_bg_jobs_store.py +tests/test_bg_job_tools.py +tests/test_execution_filesystem_boundary.py +tests/test_mcp_manager.py +tests/test_mcp_reconnect_args.py +tests/test_mcp_text_error_normalization.py +tests/test_mcp_param_hint_hardening.py +tests/test_mcp_tool_params_in_prompt.py +tests/test_mcp_memory_owner_scope.py +tests/test_mcp_cache_invalidation.py +tests/test_multiple_mcp_servers_timeout.py +tests/test_mcp_dependency_compatibility.py +tests/test_builtin_mcp_bg_tasks.py +tests/test_builtin_mcp_pythonpath.py +tests/test_builtin_mcp_npx_cache.py +tests/test_mcp_add_server_args_validation.py +tests/test_manage_mcp_command_allowlist.py +tests/test_document_tool_owner_scope.py +tests/test_owned_document_query.py +tests/test_document_session_owner_scope.py +tests/test_active_document_mutation_guard.py +tests/test_native_document_stream.py +tests/test_document_followup_integrity.py +tests/test_document_active_restore.py +tests/test_attachment_refs.py +tests/test_upload_handler_atomicity.py +tests/test_upload_handler_cleanup.py +tests/test_upload_handler_rename_owner.py +tests/test_upload_routes_owner_scope.py +tests/test_resolve_upload_path_nondict.py +tests/test_personal_upload_isolation.py +tests/test_personal_upload_privilege.py +tests/test_extract_text_tool.py +tests/test_media_ingress.py +tests/test_session_tools_registry.py +tests/test_session_owner_attribution.py +tests/test_session_list_owner_scope.py +tests/test_session_endpoint_owner_scope.py +tests/test_session_search.py +tests/test_session_search_batch_fetch.py +tests/test_history_topics_owner_scope.py +tests/test_history_order_by_timestamp_regression.py +tests/test_history_db_fallback_hidden.py +tests/test_memory_owner_isolation.py +tests/test_memory_routes_session_owner.py +tests/test_manage_memory_json_contract.py +tests/test_manage_memory_list.py +tests/test_memory_store_unreadable_no_wipe.py +tests/test_manage_notes_search_contract.py +tests/test_notes_fail_closed_auth.py +tests/test_notes_checklist_state.py +tests/test_vault_password_not_in_argv.py +tests/test_vault_routes_shim.py +tests/test_external_context_tool_gate.py +tests/test_chat_route_tool_policy.py +tests/test_product_turn_contract_route.py +tests/test_native_tool_result_threading.py +tests/test_host_shell_polling.py +tests/test_integrations_url_join.py +tests/test_integration_api_call_ssrf.py +tests/test_integrations_api_call_truncation.py +tests/test_process_resource_identity.py +tests/test_background_resource_identity.py +tests/test_runtime_resource_integration.py +tests/test_process_lifecycle.py +tests/test_browser_lifecycle.py +tests/test_private_browser_tool.py +tests/test_browser_transport_recovery.py +tests/test_shell_routes.py +tests/test_agent_tmux_retirement.py +tests/test_cookbook_stop_without_procfs.py +tests/test_cookbook_serve_lifecycle.py +tests/test_task_scheduler_cancel.py +tests/test_task_shell_tools.py +tests/test_runtime_behavior_regressions.py +tests/test_workspace_artifact_tool_floor.py +tests/test_bg_monitor_stream.py +tests/test_orphan_reaping.py +tests/test_cookbook_agent_tool_ssh_validation.py +tests/test_codex_cookbook_admin_gate.py +tests/test_task_cookbook_admin_gate.py +tests/test_builtin_actions_cookbook_serve_state.py +tests/test_cookbook_local_serve_pid_winpid.py +tests/test_scheduler_restart_doublefire.py +tests/test_task_scheduler_session_delivery.py +tests/test_cookbook_cache_scan_isolation.py +tests/test_cookbook_cached_scan_refresh.py +tests/test_cookbook_chat_deeplinks_static.py +tests/test_cookbook_cpu_only_serve.py +tests/test_cookbook_dead_download_status.py +tests/test_cookbook_dependency_completion_regression.py +tests/test_cookbook_deps_recipes.py +tests/test_cookbook_diagnosis.py +tests/test_cookbook_diagnosis_js.py +tests/test_cookbook_docker_access.py +tests/test_cookbook_download_toast_duration.py +tests/test_cookbook_endpoint_registration.py +tests/test_cookbook_error_feedback.py +tests/test_cookbook_error_tail_lines.py +tests/test_cookbook_finished_download_label.py +tests/test_cookbook_gemma4_thinking_template.py +tests/test_cookbook_helpers.py +tests/test_cookbook_hf_token.py +tests/test_cookbook_official_trending_filter.py +tests/test_cookbook_package_detection.py +tests/test_cookbook_port_parsing_js.py +tests/test_cookbook_progress_signal_js.py +tests/test_cookbook_remote_windows_diffusers.py +tests/test_cookbook_same_host_server_profiles_js.py +tests/test_cookbook_tool_dry_run.py +tests/test_cookbook_windows_stop_tree_js.py +tests/test_scheduler_prompt_cache_time.py +tests/test_scheduler_scheduled_time_validation.py +tests/test_task_scheduler_cache.py +tests/test_task_scheduler_fixture_isolation.py +tests/test_tool_task_cancelled_on_disconnect.py +tests/test_background_tool_jobs.py +tests/test_deep_research_browser_fallback.py diff --git a/docs/runtime-decomposition/wave-3-checkpoint-a.md b/docs/runtime-decomposition/wave-3-checkpoint-a.md new file mode 100644 index 000000000..1a49fe6a4 --- /dev/null +++ b/docs/runtime-decomposition/wave-3-checkpoint-a.md @@ -0,0 +1,215 @@ +# Wave 3 Checkpoint A: process and job authority + +This checkpoint binds native process creation and background-job operations to +server-owned resources. It consumes the reconciled Wave 5B `ProcessIdentity` +and leaves lifecycle and signalling mechanics unchanged. Browser document +authority remains deferred; no browser session/page adapter is added here. + +## Baseline and boundaries + +Starting branch: `feature/runtime-resource-authority`. + +- HEAD: `d0d1b3697ccd567dad9f812ed9f4f4d4f7d0044f`. +- Tree: `9a8a7fd490d18ab5ad9d627b41ddad81206017f2`. +- Clean worktree, with `4052eecc`, `8ae6ee43` and `c3ad4d0b` as ancestors. +- Unchanged Wave 3 + Wave 5B baseline: 2902 passed, 2 skipped, 2 existing + xfails across 100 files, using functional bubblewrap. + +The new identities add no operations to RequestAuthority or TurnContract. +Transcription, OCR and tasks restrictions remain in force. There is no default +DATA_DIR creation floor, PID grant, job wildcard or automatic descendant grant. +Wave 4 effects, evidence, provenance and egress policy remain outside this +checkpoint. Existing runtime outcome fields continue to report actual execution +and teardown if identity attachment fails after execution. + +## Typed contracts + +`src/agent_runtime/resources.py` defines three immutable contracts: + +| Type | Binding | Source and validation | +| --- | --- | --- | +| `ProcessResource` | Producer namespace, application owner, originating request/thread, one nested Wave 5B `ProcessIdentity`, role, optional job and receipt linkage | Producer observation at spawn, or an already frozen containment lifecycle record. `owned()` and `exited()` validate the OS incarnation; they never establish application ownership. | +| `ProcessLaunchResource` | Native producer, owner/request/thread, server UUID generation, exact normalized tool/input digest, native backend, sealed creation boundary, inherited authority digest | Reservation created during server normalization before spawn. Publication is exclusive for that generation. No PID is predicted or recovered from model text. | +| `BackgroundJobResource` | Exact native store namespace, job ID, launch generation, owner/origin request/thread, containment ID, role-labelled process resources | The native producer registers the frozen supervisor observation before releasing the workload. Store, launch publication, authority sidecar and receipt must agree. | + +The admitted process producers are `native:containment` (leader and namespace +init) and `native:bg_jobs` (supervisor). Manager/PTY/service observations are not +silently enrolled; they require their own producer adapter. Leader, supervisor, +namespace init and server manager remain distinct in Wave 5B records. Legacy +flat PID/token fields remain for existing mechanics and are checked against the +nested identity; the new envelope does not duplicate incarnation fields. + +`ProcessLaunchScope` binds a native Bash/Python backend, a sealed filesystem +root, required containment dimensions, observed read-only runtime roots, +network selector and maximum runtime. The producer compares its actual spec to +the reservation. Changed roots, broader mounts, longer runtimes and changed +backends fail closed. Credentials and command/environment contents are not +serialized into resource identities. + +## Normalization and admission + +`src/agent_runtime/process_resources.py` centralizes scope sealing, resolution, +validation, publication and ContextVar binding. + +1. RequestAuthority grants the semantic operation and explicitly seals existing + workspace/backend scope. Without a sealed creation scope, Bash/Python cannot + fall back to the server's working directory. +2. Launch normalization issues one exact reservation. Job normalization resolves + the selector only within the immutable set of already admitted jobs. +3. The dispatcher validates the exact resources before the approval claim and + binds the normalized operation in a ContextVar. +4. Native producers revalidate operation, application binding, roots and spec. + Native Bash/Python dispatch remains pinned to the native backend and passes + owner/session context explicitly. +5. Foreground publication precedes containment execution. Resulting process + envelopes reference the frozen leader/namespace-init records, never a fresh + capture of their numeric PIDs. +6. Detached launch holds the supervisor on stdin. It observes its incarnation, + persists job/store/launch/sidecar linkage, then releases the command. The + worker independently checks those records, the supervisor, receipt and spec. + Publication failure closes the held worker and uses existing Wave 5B cleanup. + +Publication uses the existing atomic file/fsync and store-transaction APIs. +There is no new effect journal or distributed commit protocol. Partial metadata +cannot admit a job or release its workload. + +RequestAuthority snapshot version 4 carries explicit process, job and launch +scopes. Older snapshots restore empty scopes; missing identities are never +reconstructed by observing today's processes or jobs. + +## Approvals and child ceilings + +Proposal capture includes the exact reservation or job resource, including its +nested process, role, producer, ownership, generation and receipt. The approval +digest covers those resources and the existing exact operation/backend binding. +Execution validates before the one-use claim and at producer entry. Restoring an +exact operation restores no general process, job or launch scope. Unsupported +standalone PID controls have no adapter and cannot create an approval identity. + +Child process scopes intersect by full identity equality after validating both +parent and child observations. Jobs intersect by full store/ID/generation/ +owner/thread/receipt/process equality. Creation scopes may narrow roots, mounts, +runtime or network limits while retaining the backend and parent boundary +requirements. Semantic operation grants are intersected independently. A stale +parent fails before a newly observed child can renew it. Discovering descendants +or siblings adds no authority. + +ContextVar binding restores state on success, ordinary exception, cancellation +and nesting. Existing lifecycle tests exercise cancellation during spawn and +repeated cleanup; the new integration test also checks native dispatch context +restoration during cancellation. + +## Job history and continuations + +`peek()` and resolution do not refresh or reap jobs. Output refresh reconciles +only the selected job, including its owned subprocess handle. Stop/output/ack +require the caller's exact expected resource and revalidate linkage. Results +can update only an explicit result-field whitelist, never identity, owner, +generation, receipt, PID, command, path or authority fields. + +Completed generations remain readable if their lifecycle receipt has been +pruned, provided their application publication and sidecar remain exact. +Completed stop is a no-op and cannot signal a reused PID. Active jobs require +the exact native receipt and live supervisor; an existing receipt with changed +producer/owner/incarnation or external semantics is rejected even for history. + +The monitor checks sidecar, launch generation, job resource and session owner +before invoking a continuation and acknowledging that same generation. Missing +legacy sidecars do not acquire authority. Service-owned maintenance/reaping +remains independent of model authority; lookup never invokes it for siblings. +Research records in `background_tool_jobs.py` remain records, not OS processes. + +## Reachable production seams + +| Production call path | Enforcement or explicit boundary | +| --- | --- | +| `agent_loop` / native executor -> `tool_execution.execute_tool_block` -> `BashTool.execute` / `PythonTool.execute` -> `_run_owned_command` | Exact reservation, native backend pin, explicit owner/session context, sealed spec and pre-execution publication. | +| `execute_tool_block` -> `#!bg` -> `bg_jobs.launch` -> `containment_worker.supervise` | Held release until durable linkage; independent worker validation. | +| Dispatcher -> `ManageBgJobsTool.execute` -> `bg_jobs.get` / `kill` | Exact captured job set/selector, owner/thread binding and revalidation; no implicit list refresh. | +| App startup -> `bg_monitor._loop` -> `_run_followup` / `mark_followed_up` | Exact generation and sidecar/owner/thread validation before continuation and ack. | +| `TaskScheduler._execute_action` -> `action_run_local` / `action_run_script` / local `action_ssh_command` -> `_run_subprocess` | Existing scheduler authority must permit the exact operation; new runner consumes a sealed launch ceiling through containment. Missing workspace/legacy creation scope fails closed. | +| Dispatcher -> Cookbook native tools -> `/api/model/download`, `/api/model/serve`, `/api/cookbook/state`, `/api/cookbook/kill-pid` | Internal native mutation is rejected: UI state/session/PID discovery is not an application process registry. | +| Dispatcher -> `stop_served_model` / `cancel_download` -> `_cookbook_kill_session` | Local targets fail closed before OS discovery, signalling or state changes. | +| Generic `app_api` -> loopback shell/model/Cookbook namespaces | Generic private/owned route admission rejects these process-control namespaces. | +| Direct labelled or unlabelled loopback -> shell native controls / local Cookbook launch/control | Internal markers confer no admin floor. Anonymous/auth-disabled native control fails closed, including missing auth-manager configurations. Authenticated human-admin control remains a separate administrative boundary. | +| App startup -> process reaper / `bg_jobs.refresh` / `disown_unverified` / containment reaping | Existing service maintenance and frozen Wave 5B signal mechanics remain unchanged. | + +No production caller of `services/shell/service.py` was found; it is unchanged +and not claimed as covered. Browser lifecycle, research/private browsers and +their producer contracts are unchanged and outside Checkpoint A. + +## Unsupported paths and deployment consequences + +- Local Cookbook agent launch/control has no trustworthy application registry; + it is disabled instead of enrolling tmux/PID/UI observations. +- Legacy Cookbook scheduled auto-stop uses the rejected internal shell route + and cannot silently resume control of editable UI-backed sessions. Its + absence of a trustworthy producer registry is an explicit remaining gap; + native background-job and containment reapers continue to work. +- Auth-disabled native shell/Cookbook UI controls are unavailable: an anonymous + human request cannot be distinguished securely from a workload's loopback + request. No Origin header, browser key or local address substitutes for + resource authority. +- Legacy tasks without creation scope and jobs without exact generation/sidecar + linkage do not gain authority during restoration. +- Raw scheduled SSH execution fails closed until an exact external backend + producer exists. Existing remote Cookbook routes/MCP/bridges remain external; + a local SSH client is never enrolled as its remote workload. +- Standalone existing-process/PTY/manager control, new producer registration, + browser session/page/document authority and general outbound-effect policy + are not implemented by this slice. + +## Control state and adversarial verification + +`PROCESS_RESOURCES_DIR`, the active launch directory, job store/sidecars and +containment records are protected by central filesystem resource resolution. +Native writable launch boundaries containing control state or existing +symlink/hardlink aliases are rejected. Tests cover direct access, symlinks and +hardlinks to launch records, job stores, authority sidecars and receipt files. +These are pathname/inode observations. They do not claim race freedom against +concurrent link replacement after validation; Wave 3-S containment mechanics +have not been redesigned. + +The three new test files are `test_process_resource_identity.py`, +`test_background_resource_identity.py` and `test_runtime_resource_integration.py`. +They cover PID reuse/unverifiable or malformed observations, role/receipt/owner/ +request/thread substitution, generation replacement, publication failure and +held release, immutable result fields, historical reads, sidecar mismatch, +side-effect-free lookup, exact approval first use/replay/restoration, child +ceilings, context restoration, external refusal, native routing, scheduler and +anonymous/internal loopback bypasses, and TurnContract exclusions. + +The integrated manifest `wave-3-checkpoint-a-tests.txt` contains 145 files, +including every file in the previous exact 88-file Wave 3 gate. It adds relevant +Wave 5B lifecycle, shell, scheduler, Cookbook, background, browser transport and +research fallback regressions. Run in an environment with functional bubblewrap: + +```sh +python3 -m pytest -q -rs $(cat docs/runtime-decomposition/wave-3-checkpoint-a-tests.txt) +python3 -m compileall -q app.py core routes services src tests scripts +git diff --check +git grep -n -E '^(<<<<<<< |=======$|>>>>>>> )' || true +git ls-files -u +``` + +The final pre-commit gate passed 387 focused tests and 3364 integrated tests, +with 3 platform skips and 2 existing xfails. The focused gate spans 12 files; +the integrated gate spans the 145-file manifest. Validation used +`/tmp/odysseus-wave3-validation/bin/python` with functional bubblewrap. +Compileall, diff whitespace, conflict-marker and unmerged-index gates passed. +The post-commit integrated result is recorded in the final checkpoint report. +Platform skips remain +explicit: `/tmp` is not a symlink, RLIMIT_AS can be lowered on this host, and the +Windows-specific Ollama startup guard is not applicable on Linux. No missing +browser dependency is converted into a passing test. + +## Remaining review concerns + +No known P0 admission bypass remains in the supported process/job paths. +P1 compatibility gaps are the deliberately unsupported local Cookbook registry +and auth-disabled native administration, plus legacy/unscoped scheduled work. +P2 concerns are linear workspace/control-file scans and retention of private +launch publications beyond job/receipt retention; a future server-owned +maintenance policy must preserve exact historical linkage. Existing filesystem +observation races and outbound-effect boundaries remain explicit limitations. +Browser authority still requires the independent producer-contract lane. diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index 72b5e7d74..0e733f0e0 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -405,7 +405,20 @@ def _append_local_ollama_download_command_lines( def setup_cookbook_routes() -> APIRouter: - router = APIRouter(tags=["cookbook"]) + async def protect_native_control(request: Request): + if request.method in {"GET", "HEAD"}: + return + # Cookbook's UI records and session strings are not an application + # process registry. No loopback caller can use them as local authority. + path = request.url.path + from routes.shell_routes import _require_admin + if path in {"/api/cookbook/kill-pid", "/api/cookbook/state", "/api/cookbook/ssh-key"}: + _require_admin(request) + if path in {"/api/model/download", "/api/model/serve"}: + payload = await request.json() + if not payload.get("remote_host"): + _require_admin(request) + router = APIRouter(tags=["cookbook"], dependencies=[Depends(protect_native_control)]) _cookbook_state_path = Path(COOKBOOK_STATE_FILE) _state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None} _tasks_status_cache = {"ts": 0.0, "value": None} diff --git a/routes/shell_routes.py b/routes/shell_routes.py index 6a1c0f583..5d85c6375 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -59,21 +59,17 @@ from core.platform_compat import ( def _require_admin(request: Request): """Reject non-admin callers. Shell exec is admin-only — never expose to regular users; that's RCE-after-signup.""" - # In the explicitly single-user, auth-disabled deployment the middleware - # does not attach a current user. AuthManager is still instantiated by the - # app, so checking only for its presence incorrectly returns 403 here. + # Anonymous loopback is also reachable from an admitted native workload. + # It cannot be treated as a human admin or as process creation authority. + from src.agent_runtime.authority import is_internal_tool_request + if is_internal_tool_request(request): + raise HTTPException(403, "Internal shell execution requires a dedicated resource-bound producer") if _auth_disabled(): - return + raise HTTPException(403, "Anonymous native process control has no resource authority") auth_manager = getattr(request.app.state, "auth_manager", None) if not auth_manager: - # No auth at all — only safe in fully-trusted localhost dev mode - return + raise HTTPException(403, "Native process control requires authenticated administration") user = getattr(request.state, "current_user", None) - # In-process tool loopback. The AuthMiddleware already validated the - # internal token + loopback client before setting this marker, so - # honour it here as admin-equivalent. - if user == INTERNAL_TOOL_USER: - return if not user or user == "api": raise HTTPException(403, "Admin only") if not auth_manager.is_admin(user): diff --git a/src/agent_runtime/authority.py b/src/agent_runtime/authority.py index 1ffa9adad..57822c490 100644 --- a/src/agent_runtime/authority.py +++ b/src/agent_runtime/authority.py @@ -13,6 +13,7 @@ from uuid import uuid4 from src.agent_runtime.resources import ( FilesystemRoot, ExternalResource, NativeBackendResource, OwnedScope, + ProcessLaunchScope, ProcessResource, BackgroundJobResource, backend_from_dict, intersect_roots, seal_owned_scopes, ) from src.tool_policy import ToolPolicy, build_effective_tool_policy @@ -120,6 +121,9 @@ class RequestAuthority: resource_roots: tuple[FilesystemRoot, ...] | None = None backend_resources: tuple[ExternalResource | NativeBackendResource, ...] | None = None owned_scopes: tuple[OwnedScope, ...] | None = None + launch_scopes: tuple[ProcessLaunchScope, ...] | None = None + process_resources: tuple[ProcessResource, ...] = () + job_resources: tuple[BackgroundJobResource, ...] | None = None def __post_init__(self): if (not isinstance(self.request_id, str) or not self.request_id @@ -156,11 +160,29 @@ class RequestAuthority: or any(not isinstance(s, OwnedScope) or (s.owner, s.thread_id) != (self.owner, self.session_id) for s in self.owned_scopes)): raise ValueError("Malformed backend or owned resource scope") + from src.agent_runtime.process_resources import seal_launch_scopes, seal_jobs + if self.launch_scopes is None: + object.__setattr__(self, "launch_scopes", seal_launch_scopes(self)) + if self.job_resources is None: + object.__setattr__(self, "job_resources", seal_jobs(self)) + for field, kind in (("launch_scopes", ProcessLaunchScope), ("process_resources", ProcessResource), + ("job_resources", BackgroundJobResource)): + values = getattr(self, field) + if not isinstance(values, tuple) or any(not isinstance(r, kind) for r in values): + raise ValueError("Malformed process resource scope") + if any(r.owner != self.owner for r in (*self.process_resources, *self.job_resources)): + raise ValueError("Process resource owner changed") + if any(s.root.owner and s.root.owner != self.owner for s in self.launch_scopes): + raise ValueError("Launch resource owner changed") + if any(r.thread_id != self.session_id for r in self.job_resources): + raise ValueError("Job resource thread changed") + if any(r.thread_id != (self.session_id or "request:" + self.request_id) for r in self.process_resources): + raise ValueError("Process resource thread changed") @classmethod def empty(cls, *, owner=None, session_id=None, workspace=None): return cls(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""), - resource_roots=(), backend_resources=(), owned_scopes=()) + resource_roots=(), backend_resources=(), owned_scopes=(), launch_scopes=(), job_resources=()) def bound_to(self, *, owner=None, session_id=None, workspace=None): return (self.owner == _owner(owner) and self.session_id == str(session_id or "") @@ -188,6 +210,7 @@ class RequestAuthority: roots = () backends = () owned = () + launches = processes = jobs = () if (self.owner, self.session_id, self.workspace) == (child.owner, child.session_id, child.workspace): theirs = {g.tool: g for g in child.grants} grants = [g.intersect(theirs[g.tool]) for g in self.grants if g.tool in theirs] @@ -195,10 +218,15 @@ class RequestAuthority: backends = tuple(r for r in self.backend_resources if r in child.backend_resources) owned = tuple(s for left in self.owned_scopes for right in child.owned_scopes if (s := left.intersect(right)) is not None) + from src.agent_runtime.process_resources import intersect_observed, intersect_launch_scopes, validate_job + launches = intersect_launch_scopes(self.launch_scopes, child.launch_scopes) + processes = intersect_observed(self.process_resources, child.process_resources, lambda r: r.validate()) + jobs = intersect_observed(self.job_resources, child.job_resources, validate_job) return replace(self, grants=tuple(grants), denied=self.denied | child.denied, block_all=self.block_all or child.block_all, disable_mcp=self.disable_mcp or child.disable_mcp, inherited=True, - resource_roots=roots, backend_resources=backends, owned_scopes=owned) + resource_roots=roots, backend_resources=backends, owned_scopes=owned, + launch_scopes=launches, process_resources=processes, job_resources=jobs) def continuation(self, *, owner=None, session_id=None): """A server continuation may rebind a session, never change owner/grants.""" @@ -206,10 +234,12 @@ class RequestAuthority: return RequestAuthority.empty(owner=owner, session_id=session_id) rebound = str(session_id or "") return replace(self, session_id=rebound, inherited=True, - owned_scopes=tuple(replace(s, thread_id=rebound) for s in self.owned_scopes) if rebound else ()) + owned_scopes=tuple(replace(s, thread_id=rebound) for s in self.owned_scopes) if rebound else (), + process_resources=tuple(r for r in self.process_resources if r.thread_id == rebound), + job_resources=tuple(r for r in self.job_resources if r.thread_id == rebound)) def to_dict(self): - return {"version": 3, "request_id": self.request_id, "owner": self.owner, + return {"version": 4, "request_id": self.request_id, "owner": self.owner, "session_id": self.session_id, "workspace": self.workspace, "grants": [{"tool": g.tool, "actions": None if g.actions is None else sorted(g.actions), @@ -218,12 +248,15 @@ class RequestAuthority: "disable_mcp": self.disable_mcp, "inherited": self.inherited, "resource_roots": [r.to_dict() for r in self.resource_roots], "backend_resources": [r.to_dict() for r in self.backend_resources], - "owned_scopes": [s.to_dict() for s in self.owned_scopes]} + "owned_scopes": [s.to_dict() for s in self.owned_scopes], + "launch_scopes": [s.to_dict() for s in self.launch_scopes], + "process_resources": [r.to_dict() for r in self.process_resources], + "job_resources": [r.to_dict() for r in self.job_resources]} @classmethod def from_dict(cls, value): if (not isinstance(value, dict) or type(value.get("version")) is not int - or value["version"] not in {1, 2, 3}): + or value["version"] not in {1, 2, 3, 4}): raise ValueError("Unsupported authority snapshot") def limits(value): if value is None: @@ -234,8 +267,12 @@ class RequestAuthority: roots = value["resource_roots"] if value["version"] >= 2 else [] if not isinstance(roots, list): raise ValueError("Malformed request resource snapshot") - backends = value["backend_resources"] if value["version"] == 3 else [] - owned = value["owned_scopes"] if value["version"] == 3 else [] + backends = value["backend_resources"] if value["version"] >= 3 else [] + owned = value["owned_scopes"] if value["version"] >= 3 else [] + process_fields = {name: value[name] if value["version"] >= 4 else [] + for name in ("launch_scopes", "process_resources", "job_resources")} + if any(not isinstance(v, list) for v in process_fields.values()): + raise ValueError("Malformed process resource snapshot") if not isinstance(backends, list) or not isinstance(owned, list): raise ValueError("Malformed request resource scope snapshot") return cls(value["request_id"], value["owner"], value["session_id"], value["workspace"], @@ -243,7 +280,10 @@ class RequestAuthority: for g in value["grants"]), limits(value["denied"]), value["block_all"], value["disable_mcp"], value["inherited"], tuple(FilesystemRoot.from_dict(r) for r in roots), - tuple(backend_from_dict(r) for r in backends), tuple(OwnedScope.from_dict(s) for s in owned)) + tuple(backend_from_dict(r) for r in backends), tuple(OwnedScope.from_dict(s) for s in owned), + tuple(ProcessLaunchScope.from_dict(s) for s in process_fields["launch_scopes"]), + tuple(ProcessResource.from_dict(r) for r in process_fields["process_resources"]), + tuple(BackgroundJobResource.from_dict(r) for r in process_fields["job_resources"])) _BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find", @@ -462,7 +502,10 @@ def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authori workspace=parent.workspace, resource_roots=parent.resource_roots, backend_resources=parent.backend_resources, - owned_scopes=parent.owned_scopes)) + owned_scopes=parent.owned_scopes, + launch_scopes=parent.launch_scopes, + process_resources=parent.process_resources, + job_resources=parent.job_resources)) return _json({"task_input": [prompt, task_type, action], "authority": authority.to_dict()}) @@ -479,18 +522,27 @@ def restore_task_authority(snapshot, prompt, task_type, action, *, owner=None, s def _background_path(job_id): if not isinstance(job_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", job_id): raise ValueError("Invalid background authority identity") - from src.constants import BG_JOBS_DIR - return Path(BG_JOBS_DIR) / (job_id + ".authority.json") + from src.bg_jobs import _JOBS_DIR + return Path(_JOBS_DIR) / (job_id + ".authority.json") -def save_background_authority(job_id, authority): +def save_background_authority(job_id, authority, *, resource=None): from core.atomic_io import atomic_write_json - atomic_write_json(_background_path(job_id), authority.to_dict()) + if resource is None or resource.job_id != job_id: + raise ValueError("Background authority requires exact job linkage") + atomic_write_json(_background_path(job_id), {"authority": authority.to_dict(), "job": resource.to_dict()}) def restore_background_authority(job_id, *, owner=None, session_id=None): try: - authority = RequestAuthority.from_dict(json.loads(_background_path(job_id).read_text())) + value = json.loads(_background_path(job_id).read_text()) + resource = BackgroundJobResource.from_dict(value["job"]) + from src.agent_runtime.process_resources import validate_job + validate_job(resource) + authority = RequestAuthority.from_dict(value["authority"]) + if (resource.job_id, resource.owner, resource.thread_id, resource.request_id) != ( + job_id, authority.owner, authority.session_id, authority.request_id): + raise ValueError("Background authority linkage changed") if authority.session_id != str(session_id or ""): raise ValueError("Background session changed") return authority.continuation(owner=owner, session_id=session_id) diff --git a/src/agent_runtime/owned_resources.py b/src/agent_runtime/owned_resources.py index 676870381..7bb429288 100644 --- a/src/agent_runtime/owned_resources.py +++ b/src/agent_runtime/owned_resources.py @@ -257,7 +257,8 @@ def needs_owned_binding(operation): raise ResourceIdentityError("Unresolved internal resource selector") path = posixpath.normpath(urlsplit(path).path) private = {"document", "documents", "session", "sessions", "history", "chat", "chats", - "notes", "memory", "vault", "upload", "uploads", "attachments"} + "notes", "memory", "vault", "upload", "uploads", "attachments", + "shell", "model", "cookbook"} segments = path.strip("/").split("/") if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private: raise ResourceIdentityError("Owned records require a dedicated resource-bound tool") diff --git a/src/agent_runtime/process_resources.py b/src/agent_runtime/process_resources.py new file mode 100644 index 000000000..53e90054b --- /dev/null +++ b/src/agent_runtime/process_resources.py @@ -0,0 +1,418 @@ +"""Process/job admission. Lifecycle mechanics remain in process_lifecycle. + +Only trusted launch producers publish observations. Persisted legacy records +are never enrolled by looking at their PID. Receipts identify boundaries, not +application authority. Resource snapshots contain no command or environment. +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +import hashlib +import json +import os +from pathlib import Path +import re +from uuid import uuid4 +from core.atomic_io import store_transaction + +from src.agent_runtime.resources import ( + BackgroundJobResource, NativeBackendResource, ProcessLaunchResource, + ProcessLaunchScope, ProcessResource, ResourceIdentityError, +) +from src.constants import PROCESS_RESOURCES_DIR + +_LAUNCH_DIR = Path(PROCESS_RESOURCES_DIR) +LAUNCH_TOOLS = frozenset({"bash", "python"}) +JOB_TOOL = "manage_bg_jobs" +_ACTIVE = ContextVar("process_resource_operation", default=None) + + +def digest(value): + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _thread(authority): + return authority.session_id or "request:" + authority.request_id + + +def launch_path(generation): + if not isinstance(generation, str) or not re.fullmatch(r"[a-f0-9]{32}", generation): + raise ResourceIdentityError("Malformed launch generation") + return _LAUNCH_DIR / (generation + ".json") + + +def seal_launch_scopes(authority): + return tuple(seal_launch_scope(backend, root) + for backend in authority.backend_resources + if isinstance(backend, NativeBackendResource) and backend.tool_id in LAUNCH_TOOLS + for root in authority.resource_roots) + + +def seal_launch_scope(backend, root, *, env=None): + from src.agent_tools.subprocess_tools import _owned_spec + from src.tool_execution import _agent_subprocess_env + from src.agent_runtime.resources import PathObservation, FileObjectIdentity + env = _agent_subprocess_env() if env is None else env + extra = tuple(Path(p).resolve().as_posix() for p in str(env.get("ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES", "")).split(os.pathsep) + if p and os.path.isabs(p)) if backend.tool_id == "python" else () + spec = _owned_spec(root.path, env, 3600, extra) + return ProcessLaunchScope(backend, root, spec.required, + tuple(PathObservation(str(Path(p).resolve()), FileObjectIdentity.observe(Path(p).resolve())) for p in spec.readonly_extra), + spec.network, spec.wall_clock_s) + + +def validate_launch_spec(launch, spec): + scope = launch.scope + scope.validate() + if (spec.workspace != scope.root.path or spec.required != scope.required or spec.network != scope.network + or spec.wall_clock_s > scope.max_runtime_s or spec.writable_extra + or tuple(spec.readonly_extra) != tuple(r.path for r in scope.runtime_roots)): + raise ResourceIdentityError("Producer launch boundary exceeds the sealed reservation") + + +def job_from_record(record): + if not isinstance(record, dict): + raise ResourceIdentityError("Missing authoritative job") + try: + resource = BackgroundJobResource.from_dict(record["resource_identity"]) + if (resource.namespace != "native:bg_jobs" + or (record["id"], record["session_id"], record["containment_id"]) + != (resource.job_id, resource.thread_id, resource.containment_id)): + raise ValueError("Job linkage changed") + supervisor = next(p for p in resource.processes if p.role == "supervisor") + if (record.get("pid"), record.get("start_token"), record.get("pgid")) != ( + supervisor.identity.pid, supervisor.identity.start_token, supervisor.identity.pgid): + raise ValueError("Supervisor linkage changed") + launch = ProcessLaunchResource.from_dict(record["launch_resource"]) + if (launch.generation, launch.owner, launch.request_id, launch.thread_id) != ( + resource.generation, resource.owner, resource.request_id, resource.thread_id): + raise ValueError("Launch/job linkage changed") + return resource + except (ValueError, TypeError, KeyError, StopIteration, AttributeError) as error: + raise ResourceIdentityError("Malformed or unowned background job") from error + + +def validate_job(resource, *, mutation=False): + try: + return _validate_job(resource, mutation=mutation) + except ResourceIdentityError: + raise + except (ValueError, TypeError, OSError, KeyError, AttributeError) as error: + raise ResourceIdentityError("Background job linkage is missing or malformed") from error + + +def validate_job_receipt(resource, receipt): + from src import containment + supervisor = resource.processes[0] + if (not isinstance(receipt, dict) or receipt.get("id") != resource.containment_id + or receipt.get("launch_generation") != resource.generation + or receipt.get("owner") != "bg:" + resource.thread_id + or (receipt.get("supervisor_pid"), receipt.get("supervisor_token")) != + (supervisor.identity.pid, supervisor.identity.start_token) + or receipt.get("mechanism") not in {m.name for m in containment.MECHANISMS} + or receipt.get("external") is True): + raise ResourceIdentityError("Containment receipt linkage changed") + + +def _validate_job(resource, *, mutation=False): + from src import bg_jobs, containment + if not isinstance(resource, BackgroundJobResource): + raise ResourceIdentityError("Missing exact background job identity") + record = bg_jobs.peek(resource.job_id) + if job_from_record(record) != resource: + raise ResourceIdentityError("Background job resource changed") + if record.get("status") not in {"running", "done", "failed"}: + raise ResourceIdentityError("Unknown job lifecycle") + launch = ProcessLaunchResource.from_dict(record["launch_resource"]) + persisted = json.loads(launch_path(resource.generation).read_text()) + if (persisted.get("launch") != launch.to_dict() + or persisted.get("job") != resource.to_dict() + or persisted.get("containment_id") != resource.containment_id): + raise ResourceIdentityError("Job/launch publication changed") + sidecar = json.loads((bg_jobs._JOBS_DIR / (resource.job_id + ".authority.json")).read_text()) + origin = persisted.get("authority", {}) + if (sidecar.get("job") != resource.to_dict() or sidecar.get("authority") != origin + or (origin.get("owner"), origin.get("request_id"), origin.get("session_id")) != + (resource.owner, resource.request_id, resource.thread_id)): + raise ResourceIdentityError("Background authority linkage changed") + receipt = containment._load_records().get(resource.containment_id) + # Lifecycle receipts have a shorter retention than job results. A finished + # exact generation needs only its durable application linkage for history; + # it never regains signalling authority when its receipt has been pruned. + historical = record.get("status") in {"done", "failed"} + if receipt is None and not historical: + raise ResourceIdentityError("Missing active containment receipt") + if receipt is not None: + validate_job_receipt(resource, receipt) + if record.get("status") == "running": + for process in resource.processes: + try: + process.validate() + except ResourceIdentityError: + # Publication can precede store reconciliation. That exact + # completed generation is readable, but never signallable. + if mutation or not Path(record["exit_path"]).is_file(): + raise + report = json.loads(Path(record["result_path"]).read_text()) + if report.get("resource_identity") != resource.to_dict() or report.get("containment", {}).get("id") != resource.containment_id: + raise ResourceIdentityError("Historical result linkage changed") + # A completed record is readable history, never a new process observation. + return record + + +def seal_jobs(authority): + if not any(g.tool == JOB_TOOL for g in authority.grants) or not authority.session_id: + return () + from src import bg_jobs + admitted = [] + for record in bg_jobs._load().values(): + try: + resource = job_from_record(record) + if (resource.owner, resource.thread_id) == (authority.owner, authority.session_id): + validate_job(resource) + admitted.append(resource) + except (ValueError, TypeError, OSError, RuntimeError): + continue + return tuple(admitted) + + +def intersect_observed(parent, child, validate): + # Validate both sides before equality. Seeing a replacement cannot renew a + # stale parent observation, even when the child has just sealed it. + for resource in (*parent, *child): + validate(resource) + return tuple(resource for resource in parent if resource in child) + + +def intersect_launch_scopes(parent, child): + from src.agent_runtime.resources import FilesystemResource + for scope in (*parent, *child): + scope.validate() + narrowed = [] + for left in parent: + for right in child: + if (left.backend != right.backend or not left.required <= right.required + or right.max_runtime_s > left.max_runtime_s + or not set(right.runtime_roots) <= set(left.runtime_roots) + or (left.network == "none" and right.network != "none")): + continue + if Path(right.root.path).is_relative_to(left.root.path): + observation = FilesystemResource.resolve(left.root, right.root.path) + if observation.identity == right.root.identity: + narrowed.append(right) + return tuple(dict.fromkeys(narrowed)) + + +@dataclass(frozen=True) +class BoundProcessOperation: + operation: object + request_id: str + owner: str + thread_id: str + launch: ProcessLaunchResource | None = None + jobs: tuple[BackgroundJobResource, ...] = () + processes: tuple[ProcessResource, ...] = () + exact_approval: object | None = None + + def __post_init__(self): + from src.agent_runtime.authority import ExactOperation + if (not isinstance(self.operation, ExactOperation) or not isinstance(self.request_id, str) or not self.request_id + or not isinstance(self.owner, str) or not isinstance(self.thread_id, str) or not self.thread_id + or (self.launch is not None and not isinstance(self.launch, ProcessLaunchResource)) + or not isinstance(self.jobs, tuple) or any(not isinstance(j, BackgroundJobResource) for j in self.jobs) + or not isinstance(self.processes, tuple) or any(not isinstance(p, ProcessResource) for p in self.processes)): + raise ValueError("Malformed process-bound operation") + if self.launch is not None and ( + (self.launch.owner, self.launch.request_id, self.launch.thread_id, self.launch.tool, self.launch.input_digest) + != (self.owner, self.request_id, self.thread_id, self.operation.tool, digest(self.operation.input))): + raise ValueError("Launch operation/application binding changed") + if any((r.owner, r.thread_id) != (self.owner, self.thread_id) for r in (*self.jobs, *self.processes)): + raise ValueError("Observed resource application binding changed") + + def validate(self): + if self.launch is not None: + self.launch.validate() + guard_launch_workspace(self.launch.scope.root) + for job in self.jobs: + validate_job(job, mutation=self.operation.action in {"kill", "stop", "cancel", "terminate", "ack"}) + for process in self.processes: + process.validate() + + def to_dict(self): + return {"tool": self.operation.transport_tool, "input_digest": digest(self.operation.input), + "request_id": self.request_id, "owner": self.owner, "thread_id": self.thread_id, + "launch": self.launch.to_dict() if self.launch else None, + "jobs": [r.to_dict() for r in self.jobs], "processes": [r.to_dict() for r in self.processes]} + + +def needs_process_binding(operation, backend): + return isinstance(backend, NativeBackendResource) and operation.tool in LAUNCH_TOOLS | {JOB_TOOL} + + +def resolve_process_operation(authority, operation, backend, *, approved=None, exact_admission=False): + if not needs_process_binding(operation, backend): + raise ResourceIdentityError("No native process adapter for this backend") + if approved is not None: + if (approved.operation != operation or (approved.request_id, approved.owner, approved.thread_id) + != (authority.request_id, authority.owner, _thread(authority))): + raise ResourceIdentityError("Approved process operation binding changed") + bound = approved + elif operation.tool in LAUNCH_TOOLS: + scopes = [s for s in authority.launch_scopes if s.backend == backend] + if len(scopes) != 1: + raise ResourceIdentityError("Process creation requires a sealed workspace and launch scope") + launch = ProcessLaunchResource("native:containment", authority.owner, authority.request_id, + _thread(authority), uuid4().hex, operation.tool, digest(operation.input), scopes[0], + digest(json.dumps(authority.to_dict(), sort_keys=True))) + bound = BoundProcessOperation(operation, authority.request_id, authority.owner, _thread(authority), launch) + else: + try: + args = json.loads(operation.input) + action = str(args.get("action", "list")).strip().lower() + job_id = args.get("job_id", args.get("id", "")) + except (ValueError, TypeError, AttributeError) as error: + raise ResourceIdentityError("Malformed job operation") from error + if action in {"list", "ls", "jobs"}: + jobs = authority.job_resources + elif action in {"output", "get", "read", "tail", "status", "show", "kill", "stop", "cancel", "terminate", "ack"}: + if not isinstance(job_id, str) or not job_id: + raise ResourceIdentityError("An exact job selector is required") + jobs = tuple(r for r in authority.job_resources if r.job_id == job_id) + if len(jobs) != 1: + raise ResourceIdentityError("Job is outside admitted resource scope") + else: + raise ResourceIdentityError("Unsupported job operation") + bound = BoundProcessOperation(operation, authority.request_id, authority.owner, _thread(authority), jobs=jobs) + if not (approved is not None and exact_admission and not authority.inherited): + if bound.launch is not None and bound.launch.scope not in authority.launch_scopes: + raise ResourceIdentityError("Launch exceeds inherited creation scope") + if any(j not in authority.job_resources for j in bound.jobs) or any(p not in authority.process_resources for p in bound.processes): + raise ResourceIdentityError("Process/job exceeds inherited resource scope") + if bound.launch is not None and bound.launch.scope.backend != backend: + raise ResourceIdentityError("Launch backend changed") + bound.validate() + return bound + + +def active_process_operation(): + return _ACTIVE.get() + + +@contextmanager +def bind_process_operation(operation): + if operation is not None and not isinstance(operation, BoundProcessOperation): + raise TypeError("Process operation must be server-owned") + if operation is not None: + operation.validate() + token = _ACTIVE.set(operation) + try: + yield operation + finally: + _ACTIVE.reset(token) + + +def require_launch(tool, *, cwd, content=None): + bound = active_process_operation() + if bound is None or bound.launch is None or bound.operation.tool != tool: + raise ResourceIdentityError("Native process producer has no bound launch reservation") + require_process_admission(bound) + bound.validate() + if Path(cwd).resolve() != Path(bound.launch.scope.root.path): + raise ResourceIdentityError("Launch workspace changed") + if content is not None and content.strip() != bound.operation.input.strip(): + raise ResourceIdentityError("Launch operation changed at producer entry") + return bound.launch + + +def require_process_admission(bound): + from src.agent_runtime.authority import active_request_authority + authority = active_request_authority() + if authority is None or (authority.owner, authority.request_id, _thread(authority)) != ( + bound.owner, bound.request_id, bound.thread_id): + raise ResourceIdentityError("Producer application authority changed") + if not authority.permits(bound.operation): + approval = bound.exact_approval + if (authority.inherited or approval is None or not approval._claimed + or approval.pending.process_operation is None + or approval.pending.process_operation.to_dict() != bound.to_dict()): + raise ResourceIdentityError("Producer operation has no request admission or exact claim") + + +def guard_launch_workspace(root): + """Reject a boundary containing execution control state or its aliases. + + These are pathname/inode observations, not an atomic kernel access policy. + They do not claim freedom from concurrent link replacement after checking. + """ + from src import bg_jobs, containment, constants + from src.agent_runtime.resources import _control_plane_path + control = (Path(bg_jobs._STORE), Path(bg_jobs._JOBS_DIR), containment._store_path(), _LAUNCH_DIR, + Path(constants.APP_DB), Path(constants.AUTH_FILE), Path(constants.SETTINGS_FILE)) + base = Path(root.path) + if any(Path(p).resolve().is_relative_to(base) for p in control): + raise ResourceIdentityError("Launch boundary contains server control state") + def unresolved(error): + raise ResourceIdentityError("Launch workspace cannot be inspected") from error + for directory, dirs, files in os.walk(base, followlinks=False, onerror=unresolved): + for name in (*dirs, *files): + path = Path(directory) / name + info = path.lstat() + if (path.is_symlink() or info.st_nlink > 1) and _control_plane_path(str(path.resolve())): + raise ResourceIdentityError("Launch boundary aliases server control state") + + +@store_transaction(lambda: _LAUNCH_DIR / "publication") +def publish_launch(launch, authority, containment_id, *, job=None, processes=()): + from core.atomic_io import atomic_write_json + launch.validate() + if authority is None or (authority.owner, authority.request_id) != (launch.owner, launch.request_id): + raise ResourceIdentityError("Launch authority linkage changed") + path = launch_path(launch.generation) + if path.exists(): + raise ResourceIdentityError("Launch reservation has already been used") + atomic_write_json(path, {"launch": launch.to_dict(), "authority": authority.to_dict(), + "containment_id": containment_id, "job": job.to_dict() if job else None, + "processes": [p.to_dict() for p in processes]}) + + +@store_transaction(lambda: _LAUNCH_DIR / "publication") +def attach_containment_processes(launch, containment_id): + """Attach producer-frozen lifecycle records; never capture a current PID.""" + from src import containment + from src.process_lifecycle import ProcessIdentity + record = containment._load_records().get(containment_id, {}) + path = launch_path(launch.generation) + published = json.loads(path.read_text()) + if (published.get("launch") != launch.to_dict() or published.get("containment_id") != containment_id + or record.get("id") != containment_id or record.get("launch_generation") != launch.generation + or record.get("workspace") != launch.scope.root.path): + raise ResourceIdentityError("Launch/receipt changed during publication") + processes = [] + for role, pid_key, token_key, group_key in (("leader", "pid", "start_token", "pgid"), + ("namespace_init", "namespace_pid", "namespace_start_token", None)): + if record.get(pid_key): + processes.append(ProcessResource("native:containment", launch.owner, launch.request_id, + launch.thread_id, ProcessIdentity(record[pid_key], record.get(token_key), record.get(group_key) if group_key else None), + role, "", containment_id)) + from core.atomic_io import atomic_write_json + published["processes"] = [p.to_dict() for p in processes] + atomic_write_json(path, published) + + +def expected_job(job_id, *, action): + bound = active_process_operation() + if bound is None or bound.operation.tool != JOB_TOOL: + raise ResourceIdentityError("Job producer has no bound operation") + require_process_admission(bound) + # The caller's actual action must agree with the normalized proposal. + args = json.loads(bound.operation.input) + proposed = str(args.get("action", "list")).strip().lower() + if action != proposed: + raise ResourceIdentityError("Job action changed at producer entry") + target = next((j for j in bound.jobs if j.job_id == job_id), None) + if target is None: + raise ResourceIdentityError("Job selector is outside the bound operation") + validate_job(target, mutation=action in {"kill", "stop", "cancel", "terminate", "ack"}) + return target diff --git a/src/agent_runtime/resources.py b/src/agent_runtime/resources.py index d63a9ed00..7440950b1 100644 --- a/src/agent_runtime/resources.py +++ b/src/agent_runtime/resources.py @@ -37,7 +37,10 @@ def _control_plane_path(path): "SETTINGS_FILE", "SESSIONS_FILE", "USER_PREFS_FILE", "VAULT_FILE", "SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB", "MEMORY_FILE", "INTEGRATIONS_FILE", )} - job_dirs = {canonical_root(constants.BG_JOBS_DIR)} + job_dirs = {canonical_root(constants.BG_JOBS_DIR), canonical_root(constants.PROCESS_RESOURCES_DIR)} + processes = sys.modules.get("src.agent_runtime.process_resources") + if processes is not None: + job_dirs.add(canonical_root(processes._LAUNCH_DIR)) # Producers may have configured paths different from the default constants. # Inspect already-loaded server metadata without initializing a store here. bg = sys.modules.get("src.bg_jobs") @@ -275,25 +278,165 @@ def intersect_roots(parent, child): @dataclass(frozen=True) class ProcessResource: namespace: str - incarnation: str owner: str - pid: int - start_token: str + request_id: str + thread_id: str + identity: "ProcessIdentity" + role: str job_id: str = "" containment_id: str = "" - namespace_pid: int | None = None - namespace_start_token: str = "" def __post_init__(self): - for name in ("namespace", "incarnation", "owner", "start_token"): + from src.process_lifecycle import ProcessIdentity + for name in ("namespace", "request_id", "thread_id"): _text(getattr(self, name), name) - for name in ("job_id", "containment_id", "namespace_start_token"): + for name in ("owner", "job_id", "containment_id"): _text(getattr(self, name), name, optional=True) - if (type(self.pid) is not int or self.pid <= 0 - or (self.namespace_pid is not None and - (type(self.namespace_pid) is not int or self.namespace_pid <= 0)) - or bool(self.namespace_pid) != bool(self.namespace_start_token)): + if (not isinstance(self.identity, ProcessIdentity) + or type(self.identity.pid) is not int or self.identity.pid <= 0 + or (self.identity.pgid is not None and (type(self.identity.pgid) is not int or self.identity.pgid <= 0)) + or self.role not in {"supervisor", "leader", "namespace_init", "manager", "pty", "service"}): raise ValueError("Malformed process resource identity") + supported_roles = {"native:containment": {"leader", "namespace_init"}, + "native:bg_jobs": {"supervisor"}} + if self.role not in supported_roles.get(self.namespace, set()): + raise ValueError("Unsupported process producer or role") + _text(self.identity.start_token, "process start token") + + def validate(self): + if not self.identity.owned() or self.identity.exited(): + raise ResourceIdentityError("Process resource is stale or unverifiable") + + def to_dict(self): + return {"namespace": self.namespace, "owner": self.owner, "request_id": self.request_id, + "thread_id": self.thread_id, "identity": self.identity.to_record(), "role": self.role, + "job_id": self.job_id, "containment_id": self.containment_id} + + @classmethod + def from_dict(cls, value): + from src.process_lifecycle import ProcessIdentity + if not isinstance(value, dict) or set(value) != {"namespace", "owner", "request_id", "thread_id", "identity", "role", "job_id", "containment_id"}: + raise ValueError("Malformed process resource snapshot") + identity = value["identity"] + if not isinstance(identity, dict) or set(identity) != {"pid", "start_token", "pgid"}: + raise ValueError("Malformed lifecycle identity snapshot") + return cls(**{**value, "identity": ProcessIdentity(**identity)}) + + +@dataclass(frozen=True) +class ProcessLaunchScope: + backend: "NativeBackendResource" + root: FilesystemRoot + required: frozenset[str] + runtime_roots: tuple[PathObservation, ...] = () + network: str = "inherit" + max_runtime_s: int = 3600 + + def __post_init__(self): + if (not isinstance(self.backend, NativeBackendResource) or not isinstance(self.root, FilesystemRoot) + or not isinstance(self.required, frozenset) or not self.required + or any(not isinstance(v, str) or not v for v in self.required)): + raise ValueError("Malformed process launch scope") + if self.backend.tool_id not in {"bash", "python"}: + raise ValueError("Unsupported native launch producer") + if (not isinstance(self.runtime_roots, tuple) or any(not isinstance(r, PathObservation) for r in self.runtime_roots) + or self.network not in {"inherit", "none"} + or type(self.max_runtime_s) is not int or self.max_runtime_s <= 0): + raise ValueError("Malformed launch boundary selectors") + + def validate(self): + self.root.validate() + for runtime in self.runtime_roots: + if canonical_root(runtime.path) != runtime.path or FileObjectIdentity.observe(runtime.path) != runtime.identity: + raise ResourceIdentityError("Launch runtime root changed") + + def to_dict(self): + return {"backend": self.backend.to_dict(), "root": self.root.to_dict(), "required": sorted(self.required), + "runtime_roots": [{"path": r.path, "identity": asdict(r.identity)} for r in self.runtime_roots], + "network": self.network, "max_runtime_s": self.max_runtime_s} + + @classmethod + def from_dict(cls, value): + if not isinstance(value, dict) or set(value) != {"backend", "root", "required", "runtime_roots", "network", "max_runtime_s"} or not isinstance(value["required"], list) or not isinstance(value["runtime_roots"], list): + raise ValueError("Malformed launch scope snapshot") + return cls(backend_from_dict(value["backend"]), FilesystemRoot.from_dict(value["root"]), frozenset(value["required"]), + tuple(PathObservation(r["path"], FileObjectIdentity(**r["identity"])) for r in value["runtime_roots"]), + value["network"], value["max_runtime_s"]) + + +@dataclass(frozen=True) +class ProcessLaunchResource: + namespace: str + owner: str + request_id: str + thread_id: str + generation: str + tool: str + input_digest: str + scope: ProcessLaunchScope + ceiling_digest: str + + def __post_init__(self): + for name in ("namespace", "request_id", "thread_id", "generation", "tool", "input_digest", "ceiling_digest"): + _text(getattr(self, name), name) + _text(self.owner, "owner", optional=True) + if not isinstance(self.scope, ProcessLaunchScope) or self.tool != self.scope.backend.tool_id: + raise ValueError("Malformed launch resource") + import re + if (self.namespace != "native:containment" or not re.fullmatch(r"[a-f0-9]{32}", self.generation) + or any(not re.fullmatch(r"[a-f0-9]{64}", v) for v in (self.input_digest, self.ceiling_digest))): + raise ValueError("Malformed native launch producer or generation") + + def validate(self): + self.scope.validate() + + def to_dict(self): + return {**{k: getattr(self, k) for k in ("namespace", "owner", "request_id", "thread_id", "generation", "tool", "input_digest", "ceiling_digest")}, + "scope": self.scope.to_dict()} + + @classmethod + def from_dict(cls, value): + if not isinstance(value, dict) or set(value) != {"namespace", "owner", "request_id", "thread_id", "generation", "tool", "input_digest", "scope", "ceiling_digest"}: + raise ValueError("Malformed launch resource snapshot") + return cls(**{**value, "scope": ProcessLaunchScope.from_dict(value["scope"])}) + + +@dataclass(frozen=True) +class BackgroundJobResource: + namespace: str + job_id: str + generation: str + owner: str + request_id: str + thread_id: str + containment_id: str + processes: tuple[ProcessResource, ...] + + def __post_init__(self): + for name in ("namespace", "job_id", "generation", "request_id", "thread_id", "containment_id"): + _text(getattr(self, name), name) + _text(self.owner, "owner", optional=True) + import re + if (not re.fullmatch(r"[A-Za-z0-9_-]+", self.job_id) + or not re.fullmatch(r"[a-f0-9]{32}", self.generation)): + raise ValueError("Malformed job selector or launch generation") + if (not isinstance(self.processes, tuple) or not self.processes + or any(not isinstance(p, ProcessResource) or (p.owner, p.request_id, p.thread_id, p.job_id, p.containment_id) + != (self.owner, self.request_id, self.thread_id, self.job_id, self.containment_id) for p in self.processes) + or len({p.role for p in self.processes}) != len(self.processes)): + raise ValueError("Malformed background job resource") + if self.namespace != "native:bg_jobs" or any(p.namespace != "native:bg_jobs" or p.role != "supervisor" for p in self.processes): + raise ValueError("Unsupported job producer or process role") + + def to_dict(self): + return {**{k: getattr(self, k) for k in ("namespace", "job_id", "generation", "owner", "request_id", "thread_id", "containment_id")}, + "processes": [p.to_dict() for p in self.processes]} + + @classmethod + def from_dict(cls, value): + if not isinstance(value, dict) or set(value) != {"namespace", "job_id", "generation", "owner", "request_id", "thread_id", "containment_id", "processes"} or not isinstance(value["processes"], list): + raise ValueError("Malformed background resource snapshot") + return cls(**{**value, "processes": tuple(ProcessResource.from_dict(p) for p in value["processes"])}) @dataclass(frozen=True) diff --git a/src/agent_tools/bg_job_tools.py b/src/agent_tools/bg_job_tools.py index 692f459a8..8d3fd5c1a 100644 --- a/src/agent_tools/bg_job_tools.py +++ b/src/agent_tools/bg_job_tools.py @@ -67,8 +67,20 @@ class ManageBgJobsTool: if not session_id: return {"error": "manage_bg_jobs: no active chat session; background jobs are scoped to a chat.", "exit_code": 1} + from src.agent_runtime.process_resources import active_process_operation, expected_job, require_process_admission + from src.agent_runtime.resources import ResourceIdentityError + bound = active_process_operation() + if bound is None or (bound.owner, bound.thread_id) != (str(ctx.get("owner") or "").strip().casefold(), session_id): + return {"error": "manage_bg_jobs: no exact server resource binding", "exit_code": 1, + "blocked": True, "failure_kind": "resource_identity_denied"} + from src.agent_runtime.authority import ExactOperation + if bound.operation != ExactOperation.normalize("manage_bg_jobs", raw or "{}"): + return {"error": "Job operation changed at producer entry", "exit_code": 1, "blocked": True} + require_process_admission(bound) + if action in _LIST_ACTIONS: - jobs: List[Dict[str, Any]] = bg_jobs.list_for_session(session_id) + bound.validate() + jobs: List[Dict[str, Any]] = [bg_jobs.peek(j.job_id) for j in bound.jobs] if not jobs: return {"output": "No background jobs in this chat.", "exit_code": 0} jobs.sort(key=lambda r: r.get("started_at") or 0, reverse=True) @@ -78,7 +90,11 @@ class ManageBgJobsTool: if action in _OUTPUT_ACTIONS or action in _KILL_ACTIONS: if not job_id: return {"error": f"manage_bg_jobs: action '{action}' requires a job_id (see action='list').", "exit_code": 1} - rec = bg_jobs.get(job_id) + try: + resource = expected_job(job_id, action=action) + rec = bg_jobs.get(job_id, expected=resource) + except (ResourceIdentityError, OSError, ValueError) as error: + return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"} # Scope: only the chat that launched a job may see or control it. if rec is None or rec.get("session_id") != session_id: return {"error": f"manage_bg_jobs: no background job '{job_id}' in this chat.", "exit_code": 1} @@ -86,7 +102,7 @@ class ManageBgJobsTool: if action in _KILL_ACTIONS: if rec.get("status") != "running": return {"output": f"Job `{job_id}` already {_status_label(rec)}; nothing to kill.", "exit_code": 0} - killed = bg_jobs.kill(job_id) + killed = bg_jobs.kill(job_id, expected=resource) if not killed or not killed.get("killed"): return {"error": f"Could not verify termination of background job `{job_id}`.", "exit_code": 1, "teardown": (killed or {}).get("teardown")} diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index f10807358..9c0255fab 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -511,26 +511,47 @@ async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, arg from src.tool_execution import agent_cwd, _truncate grant = None + result = None try: + from src.agent_runtime.process_resources import require_launch, publish_launch, validate_launch_spec + from src.agent_runtime.authority import active_request_authority + launch = require_launch(tool, cwd=agent_cwd()) + authority = active_request_authority() + if (str(ctx.get("owner") or "").strip().casefold(), str(ctx.get("session_id") or "")) != ( + authority.owner, authority.session_id): + raise ValueError("Native producer owner or session changed") + spec = _owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout, readonly_extra) + validate_launch_spec(launch, spec) grant = containment.acquire( - _owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout, readonly_extra), + spec, owner=str(ctx.get("session_id") or ctx.get("owner") or tool), ) + containment._update_record(grant.id, launch_generation=launch.generation) + publish_launch(launch, authority, grant.id) if containment.FILESYSTEM not in grant.enforced: if argv: command = [*command[:-1], _replace_workspace_alias(command[-1], grant.workspace)] else: command = _replace_workspace_alias(command, grant.workspace) result = await containment.run(grant, command, argv=argv, progress_cb=ctx.get("progress_cb")) + from src.agent_runtime.process_resources import attach_containment_processes + attach_containment_processes(launch, grant.id) except containment.ContainmentUnavailable as exc: return containment.unavailable_tool_result(exc, tool=tool) except (OSError, RuntimeError, ValueError) as exc: - boundary = grant.to_dict() if grant else {} - boundary["executed"] = bool(getattr(exc, "containment_executed", False)) - if not getattr(exc, "containment_established", False): + if grant is not None: + record = containment._load_records().get(grant.id, {}) + if not record.get("pid") and not record.get("release"): + containment.release(grant, grace_s=0) + boundary = result.grant.to_dict() if result is not None else grant.to_dict() if grant else {} + boundary["executed"] = result is not None or bool(getattr(exc, "containment_executed", False)) + if result is None and not getattr(exc, "containment_established", False): boundary.update(contained=False, enforced=[]) return {"error": f"{tool}: execution failed: {exc}", "exit_code": 1, - "containment": boundary} + "containment": boundary, + **({"failure_kind": "resource_linkage_unavailable", + "teardown": result.release.to_dict() if result.release else {"dead": False}} + if result is not None else {})} boundary = result.grant.to_dict() boundary["executed"] = True @@ -590,6 +611,12 @@ class BashTool: ), "exit_code": 1, } + from src.agent_runtime.process_resources import require_launch + from src.agent_runtime.resources import ResourceIdentityError + try: + require_launch("bash", cwd=agent_cwd(), content=content) + except ResourceIdentityError as error: + return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"} if _ffmpeg_unicode_drawtext_needs_fontfile(content): resolved_font = _resolve_fontfile_for_text(content) resolved_hint = ( @@ -879,6 +906,12 @@ class PythonTool: ), "exit_code": 1, } + from src.agent_runtime.process_resources import require_launch + from src.agent_runtime.resources import ResourceIdentityError + try: + require_launch("python", cwd=agent_cwd(), content=content) + except ResourceIdentityError as error: + return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"} if "/tmp/" in content: isolated_tmp = _isolated_tmp_dir(agent_cwd()) content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/") diff --git a/src/bg_jobs.py b/src/bg_jobs.py index ec0d9b828..e33b43352 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -86,6 +86,20 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None, A trusted detached supervisor owns the shared containment runner, output, wall clock and exit metadata, independently of the request/server lifetime. """ + from src.agent_runtime.process_resources import require_launch, active_process_operation, publish_launch, launch_path, validate_launch_spec + from src.agent_runtime.authority import active_request_authority, save_background_authority + from src.agent_runtime.resources import ProcessResource, BackgroundJobResource + from src.process_lifecycle import ProcessIdentity + cwd = cwd or os.getcwd() + launch_resource = require_launch("bash", cwd=cwd) + bound = active_process_operation() + from src.tool_execution import _split_bg_marker + marked, proposed = _split_bg_marker(bound.operation.input) + if command != (proposed if marked else bound.operation.input).strip() or session_id != launch_resource.thread_id: + raise ValueError("Background launch operation or session changed") + authority = active_request_authority() + if authority is None or (authority.owner, authority.request_id) != (launch_resource.owner, launch_resource.request_id): + raise ValueError("Background launch authority changed") _JOBS_DIR.mkdir(parents=True, exist_ok=True) job_id = uuid.uuid4().hex[:12] log_path = _JOBS_DIR / f"{job_id}.log" @@ -94,6 +108,7 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None, from src import containment from src.agent_tools.subprocess_tools import _owned_spec, _replace_workspace_alias spec = _owned_spec(cwd or os.getcwd(), env, max_runtime_s) + validate_launch_spec(launch_resource, spec) grant = containment.acquire(spec, owner=f"bg:{session_id}") bounded_command = command if containment.FILESYSTEM not in grant.enforced: @@ -147,16 +162,33 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None, "start_token": process_ownership.capture(proc.pid)["start_token"], } try: + supervisor = ProcessResource("native:bg_jobs", launch_resource.owner, launch_resource.request_id, + launch_resource.thread_id, ProcessIdentity(proc.pid, rec["start_token"], rec["pgid"]), + "supervisor", job_id, grant.id) + supervisor.validate() + resource = BackgroundJobResource("native:bg_jobs", job_id, launch_resource.generation, + launch_resource.owner, launch_resource.request_id, launch_resource.thread_id, grant.id, (supervisor,)) + rec["resource_identity"] = resource.to_dict() + rec["launch_resource"] = launch_resource.to_dict() containment._update_record(grant.id, lifetime="background", supervisor_pid=proc.pid, - supervisor_token=rec["start_token"]) + supervisor_token=rec["start_token"], launch_generation=resource.generation) jobs = _load() jobs[job_id] = rec _save(jobs) + publish_launch(launch_resource, authority, grant.id, job=resource, processes=(supervisor,)) + save_background_authority(job_id, authority, resource=resource) + payload.update(job_store=str(_STORE.resolve()), job_id=job_id, + launch_path=str(launch_path(resource.generation)), + authority_path=str(_JOBS_DIR / (job_id + ".authority.json")), + resource_identity=resource.to_dict(), launch_resource=launch_resource.to_dict()) # The supervisor cannot execute until the identity and job record are durable. proc.stdin.write(json.dumps(payload).encode("utf-8")) proc.stdin.close() except BaseException: - kill_process_tree(proc.pid) + # EOF closes the unreleased worker even if identity observation failed. + if proc.stdin is not None and not proc.stdin.closed: + proc.stdin.close() + kill_process_tree(proc.pid, start_token=rec["start_token"], pgid=rec["pgid"], require_identity=True) proc.wait(timeout=5) containment.release(grant, grace_s=0) raise @@ -194,16 +226,20 @@ def _prune(jobs: Dict[str, Dict[str, Any]], now: float) -> bool: @store_transaction(lambda: _STORE) -def refresh() -> Dict[str, Dict[str, Any]]: +def refresh(job_id=None) -> Dict[str, Dict[str, Any]]: """Reconcile every running job against disk. Marks done/failed (incl. timeout). Idempotent — safe to call from a poll loop. Returns the store.""" jobs = _load() for pid, proc in list(_LIVE_PROCS.items()): + if job_id is not None and pid != jobs.get(job_id, {}).get("pid"): + continue if proc.poll() is not None: _LIVE_PROCS.pop(pid, None) changed = False now = time.time() - for rec in jobs.values(): + for jid, rec in jobs.items(): + if job_id is not None and jid != job_id: + continue if rec.get("status") != "running": continue exit_path = Path(rec.get("exit_path", "")) @@ -218,7 +254,15 @@ def refresh() -> Dict[str, Dict[str, Any]]: if rec.get("result_path"): try: report = json.loads(Path(rec["result_path"]).read_text(encoding="utf-8")) - rec.update(report) + # Result publication is not an identity producer. It cannot + # overwrite ownership, generations, PIDs, paths or authority. + if rec.get("resource_identity") and report.get("resource_identity") != rec["resource_identity"]: + raise ValueError("Result/job linkage mismatch") + if report.get("containment", {}).get("id") != rec.get("containment_id"): + raise ValueError("Result/receipt linkage mismatch") + for key in ("containment", "teardown", "output_truncated", "timed_out", "error", "failure_kind"): + if key in report: + rec[key] = report[key] except (OSError, ValueError): rec["status"], rec["exit_code"] = "failed", 1 rec["result_unavailable"] = True @@ -243,7 +287,7 @@ def refresh() -> Dict[str, Dict[str, Any]]: rec["ended_at"] = now rec["died"] = True changed = True - if _prune(jobs, now): + if job_id is None and _prune(jobs, now): changed = True if changed: _save(jobs) @@ -288,28 +332,45 @@ def pending_followups() -> List[Dict[str, Any]]: @store_transaction(lambda: _STORE) -def mark_followed_up(job_id: str) -> None: +def mark_followed_up(job_id: str, *, expected) -> None: jobs = _load() if job_id in jobs: + from src.agent_runtime.process_resources import validate_job + if expected.job_id != job_id: + raise ValueError("Acknowledgement job resource changed") + validate_job(expected, mutation=True) jobs[job_id]["followed_up"] = True _save(jobs) -def get(job_id: str) -> Optional[Dict[str, Any]]: - refresh() # reconcile against disk so status/exit_code are current +def peek(job_id: str) -> Optional[Dict[str, Any]]: + """Resolve one record without reaping or changing any job.""" + return _load().get(job_id) + + +def get(job_id: str, *, expected) -> Optional[Dict[str, Any]]: + from src.agent_runtime.process_resources import validate_job + if expected.job_id != job_id: + raise ValueError("Output job selector changed") + validate_job(expected) + refresh(job_id) + validate_job(expected) rec = _load().get(job_id) if rec: + from src.agent_runtime.process_resources import job_from_record + if job_from_record(rec) != expected: + raise ValueError("Output job resource changed") rec = dict(rec) rec["output"] = _read_output(rec) return rec def list_for_session(session_id: str) -> List[Dict[str, Any]]: - return [r for r in refresh().values() if r.get("session_id") == session_id] + return [r for r in _load().values() if r.get("session_id") == session_id] @store_transaction(lambda: _STORE) -def kill(job_id: str) -> Optional[Dict[str, Any]]: +def kill(job_id: str, *, expected) -> Optional[Dict[str, Any]]: """Terminate a running job's process tree and mark it killed. Returns the updated record, or None if the id is unknown. Idempotent: a job that already finished is returned unchanged. Sets followed_up so the monitor does not also @@ -318,6 +379,10 @@ def kill(job_id: str) -> Optional[Dict[str, Any]]: rec = jobs.get(job_id) if rec is None: return None + from src.agent_runtime.process_resources import validate_job + if expected.job_id != job_id: + raise ValueError("Job selector changed") + validate_job(expected, mutation=True) if rec.get("status") == "running": outcome = _kill_record(rec) rec["teardown"] = outcome.to_dict() diff --git a/src/bg_monitor.py b/src/bg_monitor.py index 086faae19..d8e3288ea 100644 --- a/src/bg_monitor.py +++ b/src/bg_monitor.py @@ -140,6 +140,17 @@ async def _run_followup(rec: dict) -> bool: from src.settings import get_setting authority = restore_background_authority( rec["id"], owner=getattr(sess, "owner", None), session_id=sess.id) + # A result can trigger a continuation only through the immutable producer + # linkage, never merely because it names an existing chat. + from src.agent_runtime.process_resources import job_from_record, validate_job + try: + resource = job_from_record(rec) + validate_job(resource) + if not authority.grants or (resource.owner, resource.thread_id, resource.request_id) != ( + str(getattr(sess, "owner", None) or "").strip().casefold(), sess.id, authority.request_id): + return False + except (ValueError, TypeError, OSError, RuntimeError): + return False authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ()) full, tool_events = await _drain_agent(sess, context, request_authority=authority) @@ -169,7 +180,8 @@ async def _loop(): for rec in bg_jobs.pending_followups(): try: if await _run_followup(rec): - bg_jobs.mark_followed_up(rec["id"]) + from src.agent_runtime.process_resources import job_from_record + bg_jobs.mark_followed_up(rec["id"], expected=job_from_record(rec)) except Exception as e: # Idempotent: leave followed_up=False so the next tick retries. logger.warning("bg-followup failed for %s (will retry): %s", rec.get("id"), e) diff --git a/src/builtin_actions.py b/src/builtin_actions.py index a7cdea3b1..419c579fd 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -878,22 +878,28 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]: async def _run_subprocess(argv, *, shell: bool = False, timeout: int = 120, label: str = "Command") -> Tuple[str, bool]: - """Shared subprocess runner. Wraps the blocking subprocess.run in - asyncio.to_thread so the event loop stays responsive.""" - import asyncio - import subprocess + """Scheduled local work consumes the request's sealed launch ceiling.""" + from src.agent_runtime.authority import active_request_authority, ExactOperation + from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation + from src.agent_runtime.resources import NativeBackendResource + from src.agent_tools.subprocess_tools import _run_owned_command + authority = active_request_authority() + if authority is None: + return "Scheduled process launch has no server authority.", False + if isinstance(argv, list) and argv and argv[0] == "ssh": + return "Remote scheduled workload requires an exact external backend binding.", False + command = argv[-1] if isinstance(argv, list) else argv + operation = ExactOperation.normalize("bash", command) + if not authority.permits(operation): + return "Scheduled launch differs from the sealed operation.", False try: - result = await asyncio.to_thread( - subprocess.run, argv, shell=shell, capture_output=True, text=True, timeout=timeout, - ) - output = (result.stdout or "").strip() - if result.returncode != 0 and result.stderr: - output += "\nSTDERR: " + result.stderr.strip() - return output or "(no output)", result.returncode == 0 - except subprocess.TimeoutExpired: - return f"{label} timed out ({timeout}s)", False - except Exception as e: - return str(e), False + bound = resolve_process_operation(authority, operation, NativeBackendResource("bash")) + with bind_process_operation(bound): + result = await _run_owned_command(command, {"owner": authority.owner, + "session_id": authority.session_id}, tool="bash", timeout=timeout) + return result.get("output") or result.get("error") or "(no output)", result.get("exit_code") == 0 + except (ValueError, OSError, RuntimeError) as error: + return str(error), False async def action_ssh_command(owner: str, command: str = "", host: str = "localhost", **kwargs) -> Tuple[str, bool]: diff --git a/src/constants.py b/src/constants.py index d11283646..ac114f9c8 100644 --- a/src/constants.py +++ b/src/constants.py @@ -89,6 +89,7 @@ EMOJI_CACHE_DIR = os.path.join(DATA_DIR, "emoji_cache") RAG_DIR = os.path.join(DATA_DIR, "rag") CHROMA_DIR = os.path.join(DATA_DIR, "chroma") BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs") +PROCESS_RESOURCES_DIR = os.path.join(DATA_DIR, "process_resources") DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research") MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth") GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images") diff --git a/src/containment_worker.py b/src/containment_worker.py index 84edee8fd..d15012be5 100644 --- a/src/containment_worker.py +++ b/src/containment_worker.py @@ -6,6 +6,7 @@ import json import signal import sys import types +import os from pathlib import Path # Launch by absolute script path, so a task workspace cannot shadow src. @@ -39,6 +40,40 @@ async def supervise(payload: dict) -> None: loop.add_signal_handler(signal.SIGTERM, task.cancel) loop.add_signal_handler(signal.SIGINT, task.cancel) try: + # The supervisor is held on stdin until *all* publication succeeds. + # No legacy payload can reconstruct ownership from its PID or receipt. + job = json.loads(Path(payload["job_store"]).read_text())[payload["job_id"]] + published = json.loads(Path(payload["launch_path"]).read_text()) + sidecar = json.loads(Path(payload["authority_path"]).read_text()) + resource = payload["resource_identity"] + launch = payload["launch_resource"] + from src.agent_runtime.resources import ProcessLaunchResource, BackgroundJobResource + from src.agent_runtime.process_resources import validate_launch_spec, validate_job_receipt + typed_launch = ProcessLaunchResource.from_dict(launch) + typed_job = BackgroundJobResource.from_dict(resource) + typed_launch.validate() + validate_launch_spec(typed_launch, spec) + supervisor = typed_job.processes[0] + supervisor.validate() + receipt = containment._load_records().get(grant.id) + validate_job_receipt(typed_job, receipt) + if (supervisor.identity.pid != os.getpid() + or (typed_job.owner, typed_job.request_id, typed_job.thread_id) != + (typed_launch.owner, typed_launch.request_id, typed_launch.thread_id) + or (published["authority"]["owner"], published["authority"]["request_id"], published["authority"]["session_id"]) != + (typed_job.owner, typed_job.request_id, typed_job.thread_id)): + raise ValueError("Detached producer ownership changed") + if (job.get("resource_identity") != resource or job.get("launch_resource") != launch + or published.get("job") != resource or published.get("launch") != launch + or sidecar.get("job") != resource or sidecar.get("authority") != published.get("authority") + or published.get("containment_id") != grant.id + or (receipt.get("owner"), receipt.get("mechanism"), receipt.get("mode"), receipt.get("workspace")) != + (grant.owner, grant.mechanism, grant.mode, spec.workspace) + or info.get("external") is True + or resource["containment_id"] != grant.id + or resource["generation"] != launch["generation"] + or receipt.get("launch_generation") != launch["generation"]): + raise ValueError("Detached launch authority linkage mismatch") with open(payload["log_path"], "w", encoding="utf-8") as log: def capture(text): log.write(text) @@ -75,6 +110,7 @@ async def supervise(payload: dict) -> None: except OSError: # A failed log initialization must not hide completion metadata. sys.stderr.write(output) + report["resource_identity"] = payload.get("resource_identity") atomic_write_json(payload["result_path"], report) # Publish completion last: refresh must never see an exit without metadata. atomic_write_text(payload["exit_path"], str(code if code is not None else 1)) diff --git a/src/tool_approvals.py b/src/tool_approvals.py index 416fa9e90..dfc5d1cca 100644 --- a/src/tool_approvals.py +++ b/src/tool_approvals.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: from src.agent_runtime.resource_binding import BoundFilesystemOperation from src.agent_runtime.remote_resources import BoundBackendOperation from src.agent_runtime.owned_resources import BoundOwnedOperation + from src.agent_runtime.process_resources import BoundProcessOperation DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60 @@ -127,6 +128,7 @@ def _binding_payload( resource_operation=None, backend_operation=None, owned_operation=None, + process_operation=None, ) -> dict[str, Any]: return { "owner": _normalized_owner(owner), @@ -151,6 +153,7 @@ def _binding_payload( "resource_operation": resource_operation.to_dict() if resource_operation is not None else None, "backend_operation": backend_operation.to_dict() if backend_operation is not None else None, "owned_operation": owned_operation.to_dict() if owned_operation is not None else None, + "process_operation": process_operation.to_dict() if process_operation is not None else None, } @@ -184,6 +187,7 @@ class PendingToolApproval: resource_operation: BoundFilesystemOperation | None = None backend_operation: BoundBackendOperation | None = None owned_operation: BoundOwnedOperation | None = None + process_operation: BoundProcessOperation | None = None def public_payload(self, *, reason: str | None = None) -> dict[str, Any]: return { @@ -296,6 +300,7 @@ class ExactToolApproval: resource_operation=self.pending.resource_operation, backend_operation=self.pending.backend_operation, owned_operation=self.pending.owned_operation, + process_operation=self.pending.process_operation, ) return _canonical_digest(expected) == self.pending.digest @@ -391,6 +396,7 @@ class ToolApprovalStore: resource_operation = None backend_operation = None owned_operation = None + process_operation = None from src.agent_runtime.remote_resources import BoundBackendOperation, resolve_backend from src.agent_runtime.owned_resources import needs_owned_binding, resolve_owned_operation from src.agent_runtime.resources import NativeBackendResource @@ -403,6 +409,9 @@ class ToolApprovalStore: backend_operation = BoundBackendOperation(backend, request_authority.request_id if request_authority is not None else "", _normalized_owner(owner), str(session_id or ""), operation.transport_tool, operation.input) + from src.agent_runtime.process_resources import needs_process_binding, resolve_process_operation + if request_authority is not None and needs_process_binding(operation, backend): + process_operation = resolve_process_operation(request_authority, operation, backend) if isinstance(backend, NativeBackendResource) and needs_owned_binding(operation): resolved_owned = resolve_owned_operation(operation, owner=_normalized_owner(owner), thread_id=str(session_id or ""), request_id=backend_operation.request_id, @@ -450,6 +459,7 @@ class ToolApprovalStore: resource_operation=resource_operation, backend_operation=backend_operation, owned_operation=owned_operation, + process_operation=process_operation, ) pending = PendingToolApproval( approval_id=secrets.token_urlsafe(32), @@ -477,6 +487,7 @@ class ToolApprovalStore: resource_operation=resource_operation, backend_operation=backend_operation, owned_operation=owned_operation, + process_operation=process_operation, ) with self._lock: self._purge_expired_locked(now) diff --git a/src/tool_execution.py b/src/tool_execution.py index 9b7a41a8a..f4f2cf5b0 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -978,7 +978,10 @@ def vet_workspace(raw: str) -> Optional[str]: def agent_cwd() -> str: """Working directory for agent subprocesses (bash/python/background jobs): the active workspace when set, else the persistent data dir.""" - return get_active_workspace() or _AGENT_WORKDIR + from src.agent_runtime.process_resources import active_process_operation + bound = active_process_operation() + return (bound.launch.scope.root.path if bound is not None and bound.launch is not None + else get_active_workspace() or _AGENT_WORKDIR) def get_mcp_manager(): @@ -1319,7 +1322,10 @@ async def _document_tool_dispatch( from src.agent_runtime.journal import dispatched, mark_authorized, mark_dispatch, record_action from src.agent_runtime.authority import ( MISSING_AUTHORITY, ExactOperation, RequestAuthority, active_request_authority, - bind_request_authority, save_background_authority, + bind_request_authority, +) +from src.agent_runtime.process_resources import ( + active_process_operation, bind_process_operation, needs_process_binding, resolve_process_operation, ) @@ -1415,6 +1421,12 @@ async def execute_tool_block( exact_admission=exact_admission) external_resource_call = isinstance(backend_operation.resource, ExternalResource) owned_operation = None + process_operation = None + if needs_process_binding(operation, backend_operation.resource): + if pending is not None and pending.process_operation is None: + raise ResourceIdentityError("Approved action has no sealed process/job identity") + process_operation = resolve_process_operation(authority, operation, backend_operation.resource, + approved=pending.process_operation if pending is not None else None, exact_admission=exact_admission) if needs_owned_binding(operation) and not external_resource_call: if pending is not None and pending.owned_operation is None: raise ResourceIdentityError("Approved action has no sealed owned resource identity") @@ -1541,10 +1553,13 @@ async def execute_tool_block( token = _active_workspace.set(workspace or None) try: backend_operation.validate(client_runtime_context) + if process_operation is not None and approval_claimed: + process_operation = replace(process_operation, exact_approval=exact_approval) normalized = resource_operation or owned_operation sealed_document = owned_operation or (exact_approval.pending if approval_claimed else None) with (bind_request_authority(authority), bind_resource_operation(resource_operation), - bind_backend_operation(backend_operation), bind_owned_operation(owned_operation)): + bind_backend_operation(backend_operation), bind_owned_operation(owned_operation), + bind_process_operation(process_operation)): output = await _execute_tool_block_impl( ToolBlock(transport, normalized.execution_input) if normalized is not None else block, session_id=session_id, @@ -1790,7 +1805,6 @@ async def _execute_tool_block_impl( return "bash (background): containment unavailable", containment.unavailable_tool_result(exc, tool="bash") # Only this server launch may seal detached-job authority; a # handler/bridge output carrying a job id is not a grant source. - save_background_authority(rec["id"], active_request_authority()) short = _bg_cmd.strip().split(chr(10))[0][:80] desc = f"bash (background): {short}" result = { @@ -1833,6 +1847,15 @@ async def _execute_tool_block_impl( or {"error": f"{tool}: execution failed", "exit_code": 1} if tool == "edit_file": desc = result.get("output") or result.get("error") or "edit_file" + elif tool in {"bash", "python"} and backend is not None and isinstance(backend.resource, NativeBackendResource): + # Native reservations are pinned to the native producer. Pass the + # application binding explicitly rather than the MCP fallback's empty + # owner/session context. + first_line = content.split(chr(10))[0][:80] + desc = f"{tool}: {first_line}" + result = await dispatched(_direct_fallback(tool, content, progress_cb=progress_cb, + owner=owner, session_id=session_id, client_runtime_context=client_runtime_context)) \ + or {"error": f"{tool}: execution failed", "exit_code": 1} elif tool in _MCP_TOOL_MAP: first_line = content.split(chr(10))[0][:80] desc = f"{tool}: {first_line}" diff --git a/src/tools/cookbook.py b/src/tools/cookbook.py index 9318de02a..e786f8671 100644 --- a/src/tools/cookbook.py +++ b/src/tools/cookbook.py @@ -1227,8 +1227,8 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "", ) target_label = f"{session_id} on {remote}" else: - cmd = f"tmux kill-session -t {shlex.quote(session_id)}" - target_label = session_id + return {"error": "Local Cookbook control has no admitted process resource; session discovery is not ownership", + "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"} # Capture what this session owns BEFORE the kill. Once tmux tears the # session down the pane is gone, and with it the only evidence linking a diff --git a/tests/containment_helpers.py b/tests/containment_helpers.py index e784e032d..3c0164db7 100644 --- a/tests/containment_helpers.py +++ b/tests/containment_helpers.py @@ -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): diff --git a/tests/process_resource_helpers.py b/tests/process_resource_helpers.py new file mode 100644 index 000000000..e5bb616fc --- /dev/null +++ b/tests/process_resource_helpers.py @@ -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) diff --git a/tests/runtime_evidence_helpers.py b/tests/runtime_evidence_helpers.py index e12c633bd..f235718ea 100644 --- a/tests/runtime_evidence_helpers.py +++ b/tests/runtime_evidence_helpers.py @@ -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()), )) diff --git a/tests/test_agent_tmux_retirement.py b/tests/test_agent_tmux_retirement.py index 4815a929a..583197a5d 100644 --- a/tests/test_agent_tmux_retirement.py +++ b/tests/test_agent_tmux_retirement.py @@ -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 diff --git a/tests/test_background_containment.py b/tests/test_background_containment.py index 664788173..b52f48da5 100644 --- a/tests/test_background_containment.py +++ b/tests/test_background_containment.py @@ -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 diff --git a/tests/test_background_resource_identity.py b/tests/test_background_resource_identity.py new file mode 100644 index 000000000..d72ceb2e0 --- /dev/null +++ b/tests/test_background_resource_identity.py @@ -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) diff --git a/tests/test_bg_job_tools.py b/tests/test_bg_job_tools.py index d2c035795..27ebf6e9b 100644 --- a/tests/test_bg_job_tools.py +++ b/tests/test_bg_job_tools.py @@ -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): diff --git a/tests/test_containment_enforcement.py b/tests/test_containment_enforcement.py index 2fbeaa349..d4d2d5a34 100644 --- a/tests/test_containment_enforcement.py +++ b/tests/test_containment_enforcement.py @@ -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 diff --git a/tests/test_cookbook_stop_without_procfs.py b/tests/test_cookbook_stop_without_procfs.py index 2aab3b613..8f31ce036 100644 --- a/tests/test_cookbook_stop_without_procfs.py +++ b/tests/test_cookbook_stop_without_procfs.py @@ -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") == [] diff --git a/tests/test_native_execution_containment.py b/tests/test_native_execution_containment.py index ecbc69dcd..d65be9420 100644 --- a/tests/test_native_execution_containment.py +++ b/tests/test_native_execution_containment.py @@ -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") diff --git a/tests/test_orphan_reaping.py b/tests/test_orphan_reaping.py index 456acf1f9..5d67f24fb 100644 --- a/tests/test_orphan_reaping.py +++ b/tests/test_orphan_reaping.py @@ -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 ( diff --git a/tests/test_process_resource_identity.py b/tests/test_process_resource_identity.py new file mode 100644 index 000000000..1425ca63f --- /dev/null +++ b/tests/test_process_resource_identity.py @@ -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) diff --git a/tests/test_production_external_bridge.py b/tests/test_production_external_bridge.py index 4c6d42382..8f365543a 100644 --- a/tests/test_production_external_bridge.py +++ b/tests/test_production_external_bridge.py @@ -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 diff --git a/tests/test_request_authority.py b/tests/test_request_authority.py index 2f25449ca..28b710f8b 100644 --- a/tests/test_request_authority.py +++ b/tests/test_request_authority.py @@ -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"), diff --git a/tests/test_resource_identity.py b/tests/test_resource_identity.py index bc61c15de..48e12c351 100644 --- a/tests/test_resource_identity.py +++ b/tests/test_resource_identity.py @@ -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") diff --git a/tests/test_runtime_resource_integration.py b/tests/test_runtime_resource_integration.py new file mode 100644 index 000000000..d9e3a065c --- /dev/null +++ b/tests/test_runtime_resource_integration.py @@ -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" diff --git a/tests/test_tool_approvals.py b/tests/test_tool_approvals.py index e5f793683..9ad1837b1 100644 --- a/tests/test_tool_approvals.py +++ b/tests/test_tool_approvals.py @@ -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) diff --git a/tests/test_workspace_artifact_tool_floor.py b/tests/test_workspace_artifact_tool_floor.py index 3d75b96c5..475795e6a 100644 --- a/tests/test_workspace_artifact_tool_floor.py +++ b/tests/test_workspace_artifact_tool_floor.py @@ -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