diff --git a/docs/runtime-decomposition/validation/wave-3-closure-focused-tests.txt b/docs/runtime-decomposition/validation/wave-3-closure-focused-tests.txt new file mode 100644 index 000000000..50e316a64 --- /dev/null +++ b/docs/runtime-decomposition/validation/wave-3-closure-focused-tests.txt @@ -0,0 +1,102 @@ +tests/test_action_intents_shell_verbs.py +tests/test_auth_config_lock_concurrency.py +tests/test_auth_disabled_document_access.py +tests/test_auth_event_loop.py +tests/test_auth_policy.py +tests/test_auth_regressions.py +tests/test_auth_require_privilege_nondict.py +tests/test_auth_root_path.py +tests/test_auth_session_revocation.py +tests/test_background_chat_completion_ui_static.py +tests/test_background_containment.py +tests/test_background_resource_identity.py +tests/test_background_tool_jobs.py +tests/test_bg_job_tools.py +tests/test_bg_jobs_store.py +tests/test_bg_monitor_stream.py +tests/test_browser_identity_transport.py +tests/test_browser_lifecycle.py +tests/test_browser_observation.py +tests/test_browser_producer_live_contract.py +tests/test_browser_progress.py +tests/test_browser_resource_identity.py +tests/test_browser_screenshot_artifact_safety.py +tests/test_browser_target_correction.py +tests/test_browser_transport_recovery.py +tests/test_builtin_actions_cookbook_serve_state.py +tests/test_builtin_actions_nonstring.py +tests/test_builtin_actions_owner_scope.py +tests/test_builtin_mcp_bg_tasks.py +tests/test_chat_background_stream_isolation.py +tests/test_chat_helpers_bg_tasks_tracked.py +tests/test_chat_preprocess_tool_policy.py +tests/test_codex_cookbook_admin_gate.py +tests/test_containment_process_tree.py +tests/test_cookbook_agent_tool_ssh_validation.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_local_serve_pid_winpid.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_serve_lifecycle.py +tests/test_cookbook_stop_without_procfs.py +tests/test_cookbook_tool_dry_run.py +tests/test_cookbook_windows_stop_tree_js.py +tests/test_deep_research_browser_fallback.py +tests/test_doc_library_open_orphaned.py +tests/test_docs_no_orphan_images.py +tests/test_document_editor_background_static.py +tests/test_email_oauth_connect_smtp_security.py +tests/test_email_oauth_docker_config.py +tests/test_email_oauth_settings_redirect.py +tests/test_host_shell_polling.py +tests/test_orphan_reaping.py +tests/test_owned_resource_identity.py +tests/test_pr6020_browser_review_regressions.py +tests/test_private_browser_tool.py +tests/test_process_lifecycle.py +tests/test_process_ownership.py +tests/test_process_resource_identity.py +tests/test_remote_resource_identity.py +tests/test_request_authority.py +tests/test_reserved_username_admin_escalation.py +tests/test_resolve_session_auth_chatgpt.py +tests/test_resource_identity.py +tests/test_runtime_resource_integration.py +tests/test_scheduled_remote_ssh_refusal.py +tests/test_security_regressions.py +tests/test_settings_shell_js_behavior.py +tests/test_setup_device_auth_static.py +tests/test_shell_routes.py +tests/test_shell_service.py +tests/test_stale_process_intersection.py +tests/test_startup_shell_js.py +tests/test_task_cookbook_admin_gate.py +tests/test_task_shell_tools.py +tests/test_wave3_background_followup.py +tests/test_wave3_browser_platform.py +tests/test_wave3_diagnostics.py +tests/test_wave3_launch_cost_lifecycle.py +tests/test_wave3_local_control.py +tests/test_wave3_subprocess_environment.py +tests/test_webhook_trigger_auth_exempt.py diff --git a/docs/runtime-decomposition/wave-3-browser-authority.md b/docs/runtime-decomposition/wave-3-browser-authority.md index 992a51942..df562905a 100644 --- a/docs/runtime-decomposition/wave-3-browser-authority.md +++ b/docs/runtime-decomposition/wave-3-browser-authority.md @@ -160,12 +160,12 @@ Re-audit of Checkpoint A seams found: | Path | Remaining enforcement | | --- | --- | -| PTY/native manager routes | `routes/shell_routes.py:setup_shell_routes.shell_exec/shell_stream` call `_require_admin` before `_exec_shell/_generate_pty/_generate_tmux`; internal/anonymous controls denied, authenticated human administration separate | +| PTY/native manager routes | `routes/shell_routes.py:setup_shell_routes.shell_exec/shell_stream` call `_require_admin` before `_exec_shell/_generate_pty/_generate_tmux`; internal tool controls denied; auth-enabled human administration and explicit auth-disabled direct-local operator administration remain separate | | Additional process producers | `resources.ProcessResource.__post_init__` admits only frozen native producer/role combinations; `process_resources.resolve_process_operation` requires sealed observations | | Raw scheduled SSH | `TaskScheduler._execute_action` → `builtin_actions.action_ssh_command` → `_run_subprocess` refuses SSH without an external workload adapter | | Local Cookbook scheduled auto-stop | `routes/cookbook_routes.py:setup_cookbook_routes.protect_native_control` applies shell admin boundary to local mutation; `tools/cookbook._cookbook_kill_session` refuses registry-less local control; legacy internal shell route cannot gain administration | | Legacy/unscoped tasks | `authority.restore_task_authority` → `process_resources.resolve_process_operation` admits no missing creation scope | -| Anonymous administration / generic app_api | `owned_resources.needs_owned_binding` rejects shell/model/Cookbook namespaces; `_require_admin` also rejects unlabelled loopback when anonymous or unauthenticated | +| Anonymous administration / generic app_api | `owned_resources.needs_owned_binding` rejects shell/model/Cookbook namespaces; `_require_admin` rejects auth-enabled anonymous and auth-disabled untrusted/forwarded requests; direct-local operator administration is supported | No model-reachable page producer entry remains in the native/research wrapper. Trusted observation/setup methods are not tools or routes. Native arbitrary diff --git a/docs/runtime-decomposition/wave-4-effects-provenance-integration.md b/docs/runtime-decomposition/wave-4-effects-provenance-integration.md index d0a175f7f..ce2c45858 100644 --- a/docs/runtime-decomposition/wave-4-effects-provenance-integration.md +++ b/docs/runtime-decomposition/wave-4-effects-provenance-integration.md @@ -303,3 +303,25 @@ the rebase: refusals (no claim, no execution id). 8. Rerun the four Wave 4 suites plus `test_runtime_resource_integration.py` and the `test_wave3_*` suites on the rebased tree. + +## Integration with frozen lab `b1666951` (Wave 3 merged) + +Merged (not rebased) so the Wave 4 commit SHAs are preserved. Resolution: + +- `resources.py`: Wave 3's `_control_plane_snapshot()` / `_control_plane_path(path, *, snapshot=None)` + architecture is kept. The snapshot computes `_effect_store_dirs()` and adds them to + the returned prefix directories only after the recursive `job_dirs` inventory, and + never references `path`. `_control_plane_path` checks inventoried identities after + its `os.stat`, then calls `_aliases_effect_store` only for `st_nlink > 1`. +- `bg_monitor.py`: settlement stays immediately after the first successful + `validate_job`, before the authority comparison; Wave 3's post-drain revalidation is + unchanged. The deleted-session branch (terminal before linkage validation) now also + settles a validated launch, because that job is later pruned and its publication + retired, which would otherwise leave its effect RUNNING. +- Background publication is retired only by `bg_jobs._prune`, after a job is followed + up or terminal-unfollowable, so every path that reaches retirement has already had + its settlement attempt. A job with invalid linkage is never settled (no authority). +- Scheduled builtin actions (e.g. `cookbook_serve`) run in the scheduler outside any + agent journal and never reached `mark_dispatch`; Wave 3 only added their backend + authority. Agent-dispatched local control (`download_model`, `serve_model`, + `serve_preset`) is claimed by `dispatched()` before its handler mints a capability. diff --git a/routes/codex_routes.py b/routes/codex_routes.py index 9fe36a822..f42c6b632 100644 --- a/routes/codex_routes.py +++ b/routes/codex_routes.py @@ -118,6 +118,17 @@ def _require_cookbook_scope(request: Request, allowed: set[str]) -> str: because cookbook surfaces expose host topology, task logs, tmux commands, and model-serving controls. """ + # Internal transport/owner attribution is not a scoped external credential. + # In no-login mode, this wrapper must preserve the native local-operator + # boundary even though it invokes endpoint functions without dependencies. + from src.agent_runtime.authority import is_internal_tool_request + from src.auth_helpers import _auth_disabled + from core.middleware import INTERNAL_TOOL_HEADER + if is_internal_tool_request(request) or request.headers.get(INTERNAL_TOOL_HEADER): + raise HTTPException(403, "Internal Cookbook calls require a dedicated producer") + if _auth_disabled(): + from routes.shell_routes import _require_admin + _require_admin(request) owner = _scope_owner(request, allowed) if not getattr(request.state, "api_token", False): require_admin(request) diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index 0e733f0e0..02b419894 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -408,8 +408,8 @@ def setup_cookbook_routes() -> APIRouter: 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. + # UI records/session strings are not process authority. Local tool + # launches require a one-use capability from their admitted producer. 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"}: @@ -417,8 +417,22 @@ def setup_cookbook_routes() -> APIRouter: if path in {"/api/model/download", "/api/model/serve"}: payload = await request.json() if not payload.get("remote_host"): - _require_admin(request) + from src.agent_runtime.local_model_control import consume_model_control + from src.agent_runtime.resources import ResourceIdentityError + try: + claimed = consume_model_control(request, payload) + except (ResourceIdentityError, ValueError, TypeError): + raise HTTPException(403, "Local model capability denied") from None + if not claimed: + _require_admin(request) router = APIRouter(tags=["cookbook"], dependencies=[Depends(protect_native_control)]) + + def protect_local_model_producer(request, remote_host): + # Scoped wrappers can call endpoint functions directly, without FastAPI + # dependencies. Enforce native control at the actual producer as well. + if not remote_host and getattr(request.state, "local_model_authority", None) is None: + from routes.shell_routes import _require_admin + _require_admin(request) _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} @@ -1093,6 +1107,7 @@ def setup_cookbook_routes() -> APIRouter: """Download a HuggingFace model in a tmux session. Uses `hf download` CLI directly — runs in tmux via `script -qc` for real TTY progress, streams ANSI-stripped output via log file.""" + protect_local_model_producer(request, req.remote_host) require_admin(request) # Defence-in-depth: even though this endpoint is admin-gated, refuse # values that would land in shell contexts with metacharacters. @@ -2011,6 +2026,7 @@ def setup_cookbook_routes() -> APIRouter: keep strict validation, but serving local cached models must not require a fake org/name wrapper. """ + protect_local_model_producer(request, req.remote_host) require_admin(request) # Defence-in-depth: reject values that could break out of shell contexts. validate_remote_host(req.remote_host) diff --git a/routes/shell_routes.py b/routes/shell_routes.py index 5d85c6375..8e2e969e0 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -59,12 +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.""" - # Anonymous loopback is also reachable from an admitted native workload. - # It cannot be treated as a human admin or as process creation authority. + # Tool authentication is never human administration. Operator-disabled + # login has a separate direct-local transport contract below; it supplies + # no resource grant to model producers. from src.agent_runtime.authority import is_internal_tool_request - if is_internal_tool_request(request): + from core.middleware import INTERNAL_TOOL_HEADER + if is_internal_tool_request(request) or request.headers.get(INTERNAL_TOOL_HEADER): raise HTTPException(403, "Internal shell execution requires a dedicated resource-bound producer") if _auth_disabled(): + from src.auth_helpers import is_direct_loopback_request + if is_direct_loopback_request(request): + 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: diff --git a/specs/auth-security.md b/specs/auth-security.md index 3f6e99260..bc1d6ec0d 100644 --- a/specs/auth-security.md +++ b/specs/auth-security.md @@ -74,6 +74,17 @@ Missing-owner values remain state-dependent at legacy call sites, but new storag - Auth-enabled, configured auth with no `current_user` is unauthenticated and should fail closed at route dependencies. - `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode. + Native shell and local Cookbook administration additionally require a direct + loopback connection without proxy forwarding, cross-site indicators, or an + internal-tool header. Remote/proxied anonymous traffic remains denied at + these controls. Auth-enabled administration still requires a human admin. + This mode trusts local programs as well as the local operator: a headerless + loopback request cannot identify which local program sent it. Agent program + launches retain inherited networking; this is not protection against hostile + local code. Use authenticated mode when local programs are outside that trust. + Local Cookbook tools use a separate one-use capability for an admitted exact + request/operation/native backend and resolved launch body; that capability + cannot administer shell, PID, SSH-key, or arbitrary Cookbook state routes. - Chat/agent code that reads `get_current_user(request)` directly gets `None` when auth middleware is disabled, because no middleware stamps request state. - SQL `NULL`/JSON missing owners remain legacy/shared compatibility data, not the same thing as a logged-out authenticated caller. - `"api"` and `"internal-tool"` are request sentinels. They must not be persisted as normal storage owners unless a route explicitly defines that behavior. diff --git a/src/agent_runtime/authority.py b/src/agent_runtime/authority.py index d1059a8e1..cd5197300 100644 --- a/src/agent_runtime/authority.py +++ b/src/agent_runtime/authority.py @@ -523,6 +523,13 @@ def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authori if operation is not None: authority = replace(authority, grants=(OperationGrant(operation.tool, inputs=frozenset({operation.input})),)) + if task_type == "action" and action == "cookbook_serve": + # The direct admin scheduling ingress selects the native Cookbook + # producer. Restore never infers this from task names/availability. + # Any model-created task still intersects with its parent's ceiling. + backend = NativeBackendResource("serve_model") + authority = replace(authority, backend_resources=tuple(dict.fromkeys( + (*authority.backend_resources, backend)))) parent = active_request_authority() if parent_authority is MISSING_AUTHORITY else parent_authority if parent_authority is None: parent = RequestAuthority.empty(owner=owner) diff --git a/src/agent_runtime/local_model_control.py b/src/agent_runtime/local_model_control.py new file mode 100644 index 000000000..ebc5068b0 --- /dev/null +++ b/src/agent_runtime/local_model_control.py @@ -0,0 +1,105 @@ +"""One-use transport capabilities for admitted local Cookbook producers. + +The internal HTTP token authenticates transport only. A capability bridges one +server-owned request/operation/backend to one exact resolved local launch body. +It is never persisted, returned to the model, or usable for shell/job control. +""" +from contextlib import contextmanager +from dataclasses import dataclass +import hashlib +import json +import secrets +import threading +import time + +from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError + +CAPABILITY_HEADER = "X-Odysseus-Local-Model-Capability" +_ROUTES = {"download_model": "/api/model/download", "serve_model": "/api/model/serve", + "serve_preset": "/api/model/serve"} +_PENDING = {} +_LOCK = threading.Lock() + + +def _digest(payload): + return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), + allow_nan=False).encode()).hexdigest() + + +@dataclass(frozen=True) +class _Capability: + authority: object + operation: object + backend: NativeBackendResource + path: str + payload_digest: str + deadline: float + + +@contextmanager +def model_control_headers(tool, content, owner, payload, *, scheduled=False): + from src.tools._common import _internal_headers + headers = _internal_headers(owner) + if payload.get("remote_host"): + yield headers # Remote workload authority/transport is unchanged. + return + from src.agent_runtime.authority import active_request_authority, ExactOperation + from src.agent_runtime.remote_resources import active_backend_operation + from src.tool_security import owner_is_admin_or_single_user + authority = active_request_authority() + operation = ExactOperation.normalize(tool, content) + backend = active_backend_operation() + if (authority is None or authority.owner != str(owner or "").strip().casefold() + or tool not in _ROUTES or not owner_is_admin_or_single_user(owner)): + raise ResourceIdentityError("Local model producer has no matching server authority") + if scheduled: + # Called only by the server-owned scheduled action, after restoration of + # its immutable input ceiling. A task name or owner alone is not enough. + if tool != "serve_model" or not authority.permits(operation): + raise ResourceIdentityError("Scheduled local model input is outside authority") + resource = NativeBackendResource(tool) + if resource not in authority.backend_resources: + raise ResourceIdentityError("Scheduled local model backend is outside authority") + else: + # This binding exists only after dispatch admission (including one-use + # exact approval). A generic tool grant/header cannot create it over HTTP. + if (backend is None or backend.resource != NativeBackendResource(tool) + or (backend.request_id, backend.owner, backend.session_id, + backend.transport_tool, backend.exact_input) != + (authority.request_id, authority.owner, authority.session_id, tool, operation.input)): + raise ResourceIdentityError("Local model producer operation or backend changed") + resource = backend.resource + capability = _Capability(authority, operation, resource, _ROUTES[tool], _digest(payload), time.monotonic() + 60) + token = secrets.token_urlsafe(32) + headers.update({CAPABILITY_HEADER: token, "X-Odysseus-Owner": authority.owner}) + with _LOCK: + _PENDING[token] = capability + try: + yield headers + finally: + with _LOCK: + _PENDING.pop(token, None) + + +def consume_model_control(request, payload): + """Claim exactly once at the local route, before any producer effect.""" + from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER + from src.auth_helpers import is_direct_loopback_request + token = request.headers.get(CAPABILITY_HEADER) + if not token: + return False + if (not is_direct_loopback_request(request) + or not secrets.compare_digest(request.headers.get(INTERNAL_TOOL_HEADER, ""), INTERNAL_TOOL_TOKEN)): + raise ResourceIdentityError("Local model transport is untrusted") + with _LOCK: + capability = _PENDING.get(token) + if (capability is None or capability.deadline < time.monotonic() + or request.method != "POST" or request.url.path != capability.path + or payload.get("remote_host") or _digest(payload) != capability.payload_digest + or request.headers.get("X-Odysseus-Owner", "") != capability.authority.owner + or getattr(request.state, "current_user", None) not in + (None, INTERNAL_TOOL_USER, capability.authority.owner)): + raise ResourceIdentityError("Local model capability binding changed or expired") + del _PENDING[token] + request.state.local_model_authority = capability.authority + return True diff --git a/src/agent_runtime/owned_resources.py b/src/agent_runtime/owned_resources.py index 7bb429288..a83c8180b 100644 --- a/src/agent_runtime/owned_resources.py +++ b/src/agent_runtime/owned_resources.py @@ -260,6 +260,8 @@ def needs_owned_binding(operation): "notes", "memory", "vault", "upload", "uploads", "attachments", "shell", "model", "cookbook"} segments = path.strip("/").split("/") + if len(segments) >= 3 and segments[:3] == ["api", "codex", "cookbook"]: + raise ResourceIdentityError("Cookbook wrappers require a dedicated resource-bound tool") if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private: raise ResourceIdentityError("Owned records require a dedicated resource-bound tool") return False diff --git a/src/agent_runtime/process_resources.py b/src/agent_runtime/process_resources.py index 571b66ffe..ef244730f 100644 --- a/src/agent_runtime/process_resources.py +++ b/src/agent_runtime/process_resources.py @@ -8,12 +8,13 @@ from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar -from dataclasses import dataclass +from dataclasses import dataclass, field import hashlib import json import os from pathlib import Path import re +import threading from uuid import uuid4 from core.atomic_io import store_transaction @@ -220,6 +221,19 @@ def intersect_launch_scopes(parent, child): return tuple(dict.fromkeys(narrowed)) +class _LaunchUse: + """Non-persisted one-use producer reservation, shared by approval copies.""" + def __init__(self): + self.used = False + self.lock = threading.Lock() + + def claim(self): + with self.lock: + if self.used: + raise ResourceIdentityError("Launch reservation has already been used") + self.used = True + + @dataclass(frozen=True) class BoundProcessOperation: operation: object @@ -230,6 +244,7 @@ class BoundProcessOperation: jobs: tuple[BackgroundJobResource, ...] = () processes: tuple[ProcessResource, ...] = () exact_approval: object | None = None + _launch_use: _LaunchUse = field(default_factory=_LaunchUse, compare=False, repr=False) def __post_init__(self): from src.agent_runtime.authority import ExactOperation @@ -249,7 +264,8 @@ class BoundProcessOperation: def validate(self): if self.launch is not None: self.launch.validate() - guard_launch_workspace(self.launch.scope.root) + if self._launch_use.used: + raise ResourceIdentityError("Launch reservation has already been used") for job in self.jobs: validate_job(job, mutation=self.operation.action in {"kill", "stop", "cancel", "terminate", "ack"}) for process in self.processes: @@ -321,6 +337,11 @@ def bind_process_operation(operation): raise TypeError("Process operation must be server-owned") if operation is not None: operation.validate() + if operation.launch is not None: + # One fresh authoritative scan for each execution binding. Resolution + # and producer entry retain cheap exact identity checks; no scan is + # reused across independent bindings or persisted in an approval. + guard_launch_workspace(operation.launch.scope.root) token = _ACTIVE.set(operation) try: yield operation @@ -363,7 +384,7 @@ def guard_launch_workspace(root): """ from src import bg_jobs, containment, constants from src import browser_identity - from src.agent_runtime.resources import _control_plane_path + from src.agent_runtime.resources import _control_plane_path, _control_plane_snapshot control = (Path(bg_jobs._STORE), Path(bg_jobs._JOBS_DIR), containment._store_path(), _LAUNCH_DIR, Path(constants.BROWSER_RESOURCES_DIR), browser_identity.STATE_ROOT, @@ -373,12 +394,16 @@ def guard_launch_workspace(root): raise ResourceIdentityError("Launch boundary contains server control state") def unresolved(error): raise ResourceIdentityError("Launch workspace cannot be inspected") from error + snapshot = None 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") + if path.is_symlink() or info.st_nlink > 1: + if snapshot is None: + snapshot = _control_plane_snapshot() + if _control_plane_path(str(path.resolve()), snapshot=snapshot): + raise ResourceIdentityError("Launch boundary aliases server control state") @store_transaction(lambda: _LAUNCH_DIR / "publication") @@ -390,11 +415,83 @@ def publish_launch(launch, authority, containment_id, *, job=None, processes=()) path = launch_path(launch.generation) if path.exists(): raise ResourceIdentityError("Launch reservation has already been used") + bound = active_process_operation() + if bound is not None: + if bound.launch != launch: + raise ResourceIdentityError("Publication differs from the bound launch") + bound._launch_use.claim() 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 retire_launch(launch, containment_id, *, job=None): + """Remove only this exact producer publication; never a replacement. + + Callers establish the lifetime end (verified foreground teardown, or exact + background history pruning). Missing/malformed/replaced state is retained. + One-use launch reservations live in the bound operation, not this file. + """ + path = launch_path(launch.generation) + try: + published = json.loads(path.read_text()) + except FileNotFoundError: + return False + if (not isinstance(published, dict) + or published.get("launch") != launch.to_dict() + or published.get("containment_id") != containment_id + or published.get("job") != (job.to_dict() if job else None)): + return False + path.unlink() + return True + + +@store_transaction(lambda: _LAUNCH_DIR / "publication") +def prune_foreground_publications(): + """Startup-only recovery: retire foreground generations without a caller. + + A dead/replaced manager cannot resume attachment. A missing receipt also + makes attachment impossible; publication cannot reconstruct that receipt. + Its process tree still belongs to containment recovery; deleting a + publication never signals or asserts tree death. Live/unverifiable managers + retain publication even after child teardown: attachment may still need it. + Background history stays intact. + """ + from src import containment + from src import process_ownership + try: + receipts = json.loads(containment._store_path().read_text()) + except FileNotFoundError: + receipts = {} + except (OSError, ValueError): + return 0 # Unreadable state is not evidence that consumers are gone. + if not isinstance(receipts, dict) or any(not isinstance(r, dict) for r in receipts.values()): + return 0 + retired = 0 + for path in _LAUNCH_DIR.glob("*.json"): + try: + published = json.loads(path.read_text()) + launch = ProcessLaunchResource.from_dict(published["launch"]) + receipt = receipts.get(published["containment_id"]) + abandoned = (receipt is not None + and type(receipt.get("manager_pid")) is int and receipt["manager_pid"] > 0 + and isinstance(receipt.get("manager_token"), str) and bool(receipt["manager_token"]) + and process_ownership.verify(receipt["manager_pid"], receipt["manager_token"]) in { + process_ownership.GONE, process_ownership.FOREIGN}) + if (published.get("job") is None and path == launch_path(launch.generation) + and (receipt is None or ( + receipt.get("launch_generation") == launch.generation + and receipt.get("id") == published["containment_id"] + and abandoned))): + # Already under the publication lock; no nested file lock. + path.unlink() + retired += 1 + except (ValueError, TypeError, KeyError, OSError): + continue + return retired + + @store_transaction(lambda: _LAUNCH_DIR / "publication") def attach_containment_processes(launch, containment_id): """Attach producer-frozen lifecycle records; never capture a current PID.""" @@ -410,10 +507,13 @@ def attach_containment_processes(launch, containment_id): 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)) + pid = record.get(pid_key) + token = record.get(token_key) + if not pid or not token: + continue + processes.append(ProcessResource("native:containment", launch.owner, launch.request_id, + launch.thread_id, ProcessIdentity(pid, token, 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) diff --git a/src/agent_runtime/resources.py b/src/agent_runtime/resources.py index df3c9100b..701a381fd 100644 --- a/src/agent_runtime/resources.py +++ b/src/agent_runtime/resources.py @@ -67,7 +67,7 @@ def _aliases_effect_store(candidate, directories): return False -def _control_plane_path(path): +def _control_plane_snapshot(): # Execution snapshots/receipts are server state, even if a workspace root # contains the data directory. A writable user file cannot mint authority. from src import constants @@ -85,11 +85,9 @@ def _control_plane_path(path): if processes is not None: job_dirs.add(canonical_root(processes._LAUNCH_DIR)) # Durable effect claims/outcomes/observations are server evidence state. - # The store is not inventoried here: it grows with every run. Aliases are - # caught below by ``_aliases_effect_store`` instead. + # They are prefix-protected below, but never inventoried: the store grows + # with every run. Hardlink aliases are caught by ``_aliases_effect_store``. effect_dirs = _effect_store_dirs() - if any(Path(path).is_relative_to(directory) for directory in effect_dirs): - return True # 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") @@ -118,8 +116,6 @@ def _control_plane_path(path): protected.add(canonical_root(Path(uploader.upload_dir) / "uploads.json")) for directory in job_dirs: jobs = Path(directory) - if Path(path).is_relative_to(jobs): - return True if jobs.exists(): # Uninspectable state fails closed; hardlinks retain object identity. protected.update(canonical_root(p) for p in jobs.rglob("*") if p.is_file()) @@ -128,22 +124,32 @@ def _control_plane_path(path): for suffix in ("-wal", "-shm", "-journal")) protected.add(canonical_root(Path(constants.DATA_DIR) / ".app_key")) protected.add(canonical_root(Path(constants.UPLOAD_DIR) / "uploads.json")) - if path in protected: - return True - try: - candidate = os.stat(path) - except FileNotFoundError: - return False - if _aliases_effect_store(candidate, effect_dirs): - return True + identities = set() for control in protected: try: observed = os.stat(control) except FileNotFoundError: continue - if (candidate.st_dev, candidate.st_ino) == (observed.st_dev, observed.st_ino): - return True - return False + identities.add((observed.st_dev, observed.st_ino)) + # Effect directories join the prefix set only after the recursive inventory. + return frozenset(job_dirs | effect_dirs), frozenset(protected), frozenset(identities) + + +def _control_plane_path(path, *, snapshot=None): + # A scan-local snapshot bounds repeated hardlink checks. Ordinary resource + # resolution always observes fresh state. Neither form is an atomic kernel + # access policy, and snapshots must never survive a workspace guard call. + directories, protected, identities = _control_plane_snapshot() if snapshot is None else snapshot + if any(Path(path).is_relative_to(directory) for directory in directories) or path in protected: + return True + try: + candidate = os.stat(path) + except FileNotFoundError: + return False + if (candidate.st_dev, candidate.st_ino) in identities: + return True + # Only a multiply linked file can alias the (uninventoried) effect store. + return candidate.st_nlink > 1 and _aliases_effect_store(candidate, directories & _effect_store_dirs()) class FilesystemScope(str, Enum): diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index fc34eb046..ea23b9ad3 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -513,6 +513,7 @@ async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, arg from src.tool_execution import agent_cwd, _truncate grant = None + launch = None result = None try: from src.agent_runtime.process_resources import require_launch, publish_launch, validate_launch_spec @@ -554,6 +555,16 @@ async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, arg **({"failure_kind": "resource_linkage_unavailable", "teardown": result.release.to_dict() if result.release else {"dead": False}} if result is not None else {})} + finally: + if launch is not None and grant is not None: + record = containment._load_records().get(grant.id, {}) + if (record.get("launch_generation") == launch.generation + and (record.get("release") or {}).get("dead") is True): + from src.agent_runtime.process_resources import retire_launch + try: + retire_launch(launch, grant.id) + except (OSError, ValueError, TypeError): + logger.warning("Foreground launch publication retirement failed", exc_info=True) boundary = result.grant.to_dict() boundary["executed"] = True diff --git a/src/auth_helpers.py b/src/auth_helpers.py index d290396c2..de1a1dcf0 100644 --- a/src/auth_helpers.py +++ b/src/auth_helpers.py @@ -7,6 +7,27 @@ from fastapi import Request, HTTPException from src.owner_identity import auth_disabled, effective_storage_owner +def is_direct_loopback_request(request: Request) -> bool: + """Local operator transport, excluding reverse proxies and cross-site calls. + + Locality supplies no model/tool authority. Native administration uses this + only in the operator's explicit auth-disabled single-user mode. + """ + client = getattr(request, "client", None) + if not client or client.host not in {"127.0.0.1", "::1"}: + return False + forwarding = ("cf-connecting-ip", "cf-ray", "cf-visitor", "x-forwarded-for", + "x-forwarded-host", "x-forwarded-proto", "x-real-ip", "forwarded") + if any(request.headers.get(name) for name in forwarding): + return False + if request.headers.get("sec-fetch-site") in {"cross-site", "same-site"}: + return False + origin = request.headers.get("origin") + if origin and origin != str(request.base_url).rstrip("/"): + return False + return True + + def get_current_user(request: Request) -> Optional[str]: """Get current username from request state (set by auth middleware).""" return getattr(request.state, 'current_user', None) diff --git a/src/bg_jobs.py b/src/bg_jobs.py index 9a258af35..a8c3d4c32 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -213,10 +213,22 @@ def _prune(jobs: Dict[str, Dict[str, Any]], now: float) -> bool: """Drop records (and their on-disk files) for jobs that finished, were followed up, and are older than the retention window. Mutates `jobs`.""" stale = [jid for jid, rec in jobs.items() - if rec.get("followed_up") and rec.get("ended_at") + if rec.get("status") in {"done", "failed"} + and (rec.get("followed_up") or rec.get("followup_state") == "terminal_unfollowable") + and rec.get("ended_at") + and (rec.get("teardown") or {}).get("dead") is not False and (now - rec["ended_at"]) > _RETENTION_S] for jid in stale: - jobs.pop(jid, None) + rec = jobs.pop(jid) + from src.agent_runtime.process_resources import job_from_record, retire_launch + from src.agent_runtime.resources import ProcessLaunchResource + try: + resource = job_from_record(rec) + retire_launch(ProcessLaunchResource.from_dict(rec["launch_resource"]), + resource.containment_id, job=resource) + except (ValueError, TypeError, OSError): + # Malformed/replaced publications never become deletion authority. + pass for p in _JOBS_DIR.glob(f"{jid}.*"): # .sh .cmd.sh .log .exit try: p.unlink() @@ -333,10 +345,29 @@ def _kill_record(rec): def pending_followups() -> List[Dict[str, Any]]: """Finished jobs the agent hasn't been re-invoked for yet. The monitor - drains these; mark_followed_up() flips the flag only on success.""" + drains these; valid continuations acknowledge success, invalid immutable + linkage receives a terminal disposition without fabricating delivery.""" jobs = refresh() return [r for r in jobs.values() - if r.get("status") in ("done", "failed") and not r.get("followed_up")] + if r.get("status") in ("done", "failed") and not r.get("followed_up") + and r.get("followup_state") != "terminal_unfollowable"] + + +@store_transaction(lambda: _STORE) +def mark_unfollowable(job_id: str, *, expected_record) -> bool: + """Suppress only the exact completed snapshot inspected by the monitor. + + This conveys no read/signal/continuation authority and cannot renew a PID. + It deliberately needs no invalid/missing authority sidecar to suppress it. + """ + jobs = _load() + record = jobs.get(job_id) + if (record is None or record != expected_record or record.get("id") != job_id + or record.get("status") not in {"done", "failed"}): + return False + record["followup_state"] = "terminal_unfollowable" + _save(jobs) + return True @store_transaction(lambda: _STORE) @@ -373,10 +404,6 @@ def get(job_id: str, *, expected) -> Optional[Dict[str, Any]]: return rec -def list_for_session(session_id: str) -> List[Dict[str, Any]]: - return [r for r in _load().values() if r.get("session_id") == session_id] - - @store_transaction(lambda: _STORE) def kill(job_id: str, *, expected) -> Optional[Dict[str, Any]]: """Terminate a running job's process tree and mark it killed. Returns the diff --git a/src/bg_monitor.py b/src/bg_monitor.py index 790783bc9..9d0c7d2bb 100644 --- a/src/bg_monitor.py +++ b/src/bg_monitor.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio import json import logging +from enum import Enum, auto from src import bg_jobs from src.prompt_security import untrusted_context_message @@ -26,6 +27,12 @@ POLL_INTERVAL_S = 5 _FOLLOWUP_MAX_ROUNDS = 12 +class FollowupResult(Enum): + RETRYABLE_LATER = auto() + COMPLETED = auto() + TERMINAL_UNFOLLOWABLE = auto() + + def _background_result_message(rec): inject = ( f"[Background job {rec['id']} finished]\n\n" @@ -120,22 +127,30 @@ async def _drain_agent(sess, messages, request_authority=None): return full, tool_events -async def _run_followup(rec: dict) -> bool: - """Re-invoke the agent in the job's session with the result. Returns True - if the follow-up completed (or there's nothing to do) — i.e. it's safe to - mark followed_up. Returns False to retry on the next tick.""" +async def _run_followup(rec: dict) -> FollowupResult: + """Continue only an exactly linked result; distinguish retry from terminal.""" from src.ai_interaction import get_session_manager from core.models import ChatMessage sm = get_session_manager() if not sm: - return False # not ready yet — retry + return FollowupResult.RETRYABLE_LATER sess = sm.get_session(rec["session_id"]) if not sess: # Session was deleted — nothing to continue. Consider it handled so we # don't retry forever. logger.info("bg-followup: session %s gone for job %s — skipping", rec.get("session_id"), rec.get("id")) - return True + # The job is retired without a continuation, then pruned with its + # publication. Settle its launch effect first so it is not left RUNNING. + from src.agent_runtime.process_resources import job_from_record, validate_job + try: + resource = job_from_record(rec) + validate_job(resource) + except (ValueError, TypeError, OSError, RuntimeError): + pass # no validated linkage: nothing may be settled + else: + _settle_launch_effect(resource, rec) + return FollowupResult.TERMINAL_UNFOLLOWABLE # Don't write into a session that's mid-stream. The followup appends to # history + save_sessions(); a concurrent live turn does the same, and with @@ -145,13 +160,10 @@ async def _run_followup(rec: dict) -> bool: from src import agent_runs if agent_runs.is_active(sess.id): logger.info("bg-followup: session %s busy (live turn) — deferring job %s", sess.id, rec.get("id")) - return False + return FollowupResult.RETRYABLE_LATER except Exception: pass - context = sess.get_context_messages() - context.append(_background_result_message(rec)) - from src.agent_runtime.authority import restore_background_authority from src.settings import get_setting authority = restore_background_authority( @@ -165,11 +177,19 @@ async def _run_followup(rec: dict) -> bool: _settle_launch_effect(resource, rec) 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 + return FollowupResult.TERMINAL_UNFOLLOWABLE except (ValueError, TypeError, OSError, RuntimeError): - return False + return FollowupResult.TERMINAL_UNFOLLOWABLE + context = sess.get_context_messages() + context.append(_background_result_message(rec)) authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ()) full, tool_events = await _drain_agent(sess, context, request_authority=authority) + # An awaited continuation must not deliver a result after its immutable + # linkage disappears or is replaced. This check grants no new authority. + try: + validate_job(resource) + except (ValueError, TypeError, OSError, RuntimeError): + return FollowupResult.TERMINAL_UNFOLLOWABLE # Persist ONLY the assistant continuation so it renders as a normal agent # turn — a standard chat bubble plus `tool_events` that the frontend @@ -188,7 +208,19 @@ async def _run_followup(rec: dict) -> bool: sm.save_sessions() logger.info("bg-followup: auto-continued session %s for job %s (%d chars, %d tools)", sess.id, rec["id"], len(full), len(tool_events)) - return True + return FollowupResult.COMPLETED + + +async def _process_followup(rec): + outcome = await _run_followup(rec) + if outcome is FollowupResult.COMPLETED: + from src.agent_runtime.process_resources import job_from_record + bg_jobs.mark_followed_up(rec["id"], expected=job_from_record(rec)) + elif outcome is FollowupResult.TERMINAL_UNFOLLOWABLE: + if not bg_jobs.mark_unfollowable(rec["id"], expected_record=rec): + return FollowupResult.RETRYABLE_LATER + logger.warning("bg-followup: job %s has no valid continuation linkage; retired from pending", rec.get("id")) + return outcome async def _loop(): @@ -196,9 +228,7 @@ async def _loop(): try: for rec in bg_jobs.pending_followups(): try: - if await _run_followup(rec): - from src.agent_runtime.process_resources import job_from_record - bg_jobs.mark_followed_up(rec["id"], expected=job_from_record(rec)) + await _process_followup(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/browser_identity.py b/src/browser_identity.py index cfac39f2c..71131bf40 100644 --- a/src/browser_identity.py +++ b/src/browser_identity.py @@ -30,6 +30,8 @@ from src.process_lifecycle import ProcessIdentity, observe from src.constants import BROWSER_RESOURCES_DIR PRODUCER_VERSION = "0.35.0" +# Wave 3 session metadata supports only these observed glibc Linux artifacts. +# macOS/Windows and other architectures fail closed before any producer call. PRODUCER_HASHES = { "linux-x64": "b7a28c3a43a7008dd02585e2e60c391c08983f7a099149caed63c9f13f57b752", "linux-arm64": "92cd7d0897837ac648b9a6ab1965c69c5920e0f54df57e4295cdb1143b0541c8", diff --git a/src/builtin_actions.py b/src/builtin_actions.py index 419c579fd..38776710e 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -3387,10 +3387,12 @@ async def action_cookbook_serve( if srv.get("platform"): body["platform"] = srv["platform"] try: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.post(f"{internal_api_base()}/api/model/serve", - json=body, headers=headers) - data = r.json() if r.content else {} + from src.agent_runtime.local_model_control import model_control_headers + with model_control_headers("serve_model", command, owner, body, scheduled=True) as launch_headers: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.post(f"{internal_api_base()}/api/model/serve", + json=body, headers=launch_headers) + data = r.json() if r.content else {} except Exception as e: return f"Launch HTTP failed: {e}", False if not data.get("ok"): diff --git a/src/process_reaper.py b/src/process_reaper.py index 875c24ad7..f29f689ba 100644 --- a/src/process_reaper.py +++ b/src/process_reaper.py @@ -284,11 +284,21 @@ def reap_orphans() -> Dict[str, Any]: Blocking: a teardown escalates SIGTERM → grace → SIGKILL and waits for the process to actually go. Call it off the event loop. """ + # Observe publication consumers before receipt recovery can forget a dead + # manager's record. Publication retirement itself neither signals nor + # asserts successful teardown; containment remains the recovery authority. + from src.agent_runtime.process_resources import prune_foreground_publications + try: + publications_retired = prune_foreground_publications() + except (OSError, ValueError, TypeError): + publications_retired = 0 + logger.warning("process_reaper: foreground publication retirement failed", exc_info=True) report = { "mechanism": process_ownership.inspection_mechanism(), "grants": reap_containment_grants(), "bg_jobs": reap_bg_jobs(), "agent_tmux": reap_legacy_agent_tmux(), + "foreground_publications_retired": publications_retired, } if report["mechanism"] == process_ownership.MECHANISM_NONE: logger.error( diff --git a/src/tool_execution.py b/src/tool_execution.py index f54eb9d47..e495dd1fb 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -1246,8 +1246,6 @@ def _split_bg_marker(content: str): return False, content -import re as _re - # Variables a legitimate agent bash/python subprocess needs from the host. # Anything not listed here is never inherited. _SAFE_SUBPROCESS_VARS = frozenset({ @@ -1267,19 +1265,11 @@ _SAFE_SUBPROCESS_VARS = frozenset({ "LD_LIBRARY_PATH", }) -# Defence-in-depth: reject any allowlisted variable whose *name* matches -# a credential-bearing pattern (e.g. a user who sets PATH_TOKEN=...). -_SENSITIVE_PATTERN = _re.compile( - r"(?:KEY|TOKEN|SECRET|PASSW|AUTH|CREDENTIAL|PRIVATE|DATABASE_URL)", - _re.IGNORECASE, -) - - def _agent_subprocess_env() -> dict: base = { key: os.environ[key] for key in _SAFE_SUBPROCESS_VARS - if key in os.environ and not _SENSITIVE_PATTERN.search(key) + if key in os.environ } base.setdefault("PATH", os.environ.get("PATH") or os.defpath or "/usr/local/bin:/usr/bin:/bin") base.setdefault("LANG", "C.UTF-8") @@ -1425,7 +1415,7 @@ async def execute_tool_block( owner=owner, session_id=session_id, workspace=workspace, tool_name=getattr(block, "tool_type", None), content=getattr(block, "content", None))) admitted = valid and (authority.permits(operation) or exact_admission) - except (ValueError, TypeError, AttributeError) as error: + except (ValueError, TypeError) as error: return f"{getattr(block, 'tool_type', '')}: invalid arguments", { "error": (f"Tool arguments are not valid JSON: {error}" if isinstance(error, json.JSONDecodeError) else str(error)), @@ -1503,7 +1493,7 @@ async def execute_tool_block( authority, operation, document_id=active_document_id, approved=pending.owned_operation if pending is not None else None, exact_admission=exact_admission) - except (ValueError, TypeError, OSError, RuntimeError, AttributeError) as error: + except (ValueError, TypeError, OSError) as error: return f"{transport}: BLOCKED", { "error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied", diff --git a/src/tools/cookbook.py b/src/tools/cookbook.py index e786f8671..14ab35c85 100644 --- a/src/tools/cookbook.py +++ b/src/tools/cookbook.py @@ -772,9 +772,11 @@ async def do_download_model(content: str, owner: Optional[str] = None) -> Dict: if env_cfg.get("platform"): payload["platform"] = env_cfg["platform"] if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"] try: - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.post(f"{_INTERNAL_BASE}/api/model/download", - json=payload, headers=_internal_headers()) + from src.agent_runtime.local_model_control import model_control_headers + with model_control_headers("download_model", content, owner, payload) as launch_headers: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(f"{_INTERNAL_BASE}/api/model/download", + json=payload, headers=launch_headers) data = resp.json() if data.get("ok"): sid = data.get("session_id", "?") @@ -857,9 +859,11 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict: if env_cfg.get("platform"): payload["platform"] = env_cfg["platform"] if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"] try: - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve", - json=payload, headers=_internal_headers()) + from src.agent_runtime.local_model_control import model_control_headers + with model_control_headers("serve_model", content, owner, payload) as launch_headers: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve", + json=payload, headers=launch_headers) data = resp.json() if data.get("ok"): sid = data.get("session_id", "?") @@ -1908,9 +1912,11 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict: payload["ssh_port"] = env_cfg["ssh_port"] try: - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve", - json=payload, headers=_internal_headers()) + from src.agent_runtime.local_model_control import model_control_headers + with model_control_headers("serve_preset", content, owner, payload) as launch_headers: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve", + json=payload, headers=launch_headers) data = resp.json() if data.get("ok"): sid = data.get("session_id", "?") diff --git a/tests/test_cookbook_docker_access.py b/tests/test_cookbook_docker_access.py index 5acf49e0a..d8fe8d404 100644 --- a/tests/test_cookbook_docker_access.py +++ b/tests/test_cookbook_docker_access.py @@ -1,4 +1,5 @@ from unittest.mock import AsyncMock +from types import SimpleNamespace import pytest @@ -11,6 +12,11 @@ from src.host_docker_access import HOST_DOCKER_ACCESS_HINT from tests.helpers.unix_sockets import bound_unix_socket +@pytest.fixture(autouse=True) +def authenticated_admin_mode(monkeypatch): + monkeypatch.setenv("AUTH_ENABLED", "true") + + def _model_serve_endpoint(): router = cookbook_routes.setup_cookbook_routes() for route in router.routes: @@ -27,6 +33,8 @@ def _admin_request() -> Request: "path": "/api/model/serve", "headers": [], "state": {}, + "app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace( + is_configured=True, is_admin=lambda user: user == "admin"))), } ) request.state.current_user = "admin" @@ -139,7 +147,6 @@ async def test_local_container_serve_returns_host_docker_opt_in_hint( assert cookbook_routes.shutil.which(binary) == "/usr/bin/docker" return False - monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None) monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available) monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True) monkeypatch.setattr( @@ -199,7 +206,6 @@ async def test_local_container_serve_allows_generated_docker_exec_when_enabled( launched_commands.append(command) return _Process() - monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None) monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available) monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True) monkeypatch.setattr( diff --git a/tests/test_effect_journal_persistence.py b/tests/test_effect_journal_persistence.py index c733deb85..56dac58d4 100644 --- a/tests/test_effect_journal_persistence.py +++ b/tests/test_effect_journal_persistence.py @@ -329,6 +329,14 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp alias = workspace / "sneaky.jsonl" os.link(store / f"{7:032x}.jsonl", alias) assert resources._control_plane_path(str(alias)) is True + # Wave 3's scan-local snapshot form: the store is a prefix, not inventory. + snapshot = resources._control_plane_snapshot() + assert str(store) in snapshot[0] + assert resources._control_plane_path(str(store / "new.jsonl"), snapshot=snapshot) is True + listed.clear() + assert resources._control_plane_path(str(ordinary), snapshot=snapshot) is False + assert str(store) not in listed + assert resources._control_plane_path(str(alias), snapshot=snapshot) is True assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried" # Multiply linked files elsewhere stay ordinary. elsewhere = tmp_path / "other.txt" diff --git a/tests/test_runtime_resource_integration.py b/tests/test_runtime_resource_integration.py index d9e3a065c..b5d0799e6 100644 --- a/tests/test_runtime_resource_integration.py +++ b/tests/test_runtime_resource_integration.py @@ -330,7 +330,7 @@ async def test_anonymous_native_cookbook_control_rejected_before_producer(monkey 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: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=("192.0.2.1", 123)), base_url="http://local") as client: result = await client.post(path, json=payload) assert result.status_code == 403 @@ -352,3 +352,156 @@ async def test_direct_local_cookbook_control_does_not_enroll_discovered_processe # 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" + + +def test_direct_containment_attachment_skips_unobservable_token(workspace, monkeypatch): + import uuid + from src.agent_runtime.resources import ProcessResource + + # 1. Unobservable child token: pid exists, start_token is None + op = ExactOperation.normalize("bash", "printf test") + bound = resources.resolve_process_operation(authority(workspace), op, NativeBackendResource("bash")) + launch = bound.launch + containment_id = uuid.uuid4().hex + + resources.publish_launch(launch, authority(workspace), containment_id) + containment._save_records({ + containment_id: { + "id": containment_id, + "launch_generation": launch.generation, + "workspace": launch.scope.root.path, + "pid": 54321, + "start_token": None, + "pgid": 54321, + "mechanism": "process_group", + } + }) + + # Guard: ensure no attempt is made to rediscover/rebind from process table + monkeypatch.setattr(process_ownership, "process_table", lambda *a, **k: pytest.fail("re-read process table")) + monkeypatch.setattr(process_ownership, "start_token", lambda *a, **k: pytest.fail("re-read current PID start_token")) + + # Must NOT raise + resources.attach_containment_processes(launch, containment_id) + + # Publication remains valid + pub_path = resources.launch_path(launch.generation) + published = json.loads(pub_path.read_text()) + assert published["launch"] == launch.to_dict() + assert published["containment_id"] == containment_id + assert published["processes"] == [] + + # 2. Record with pid + valid token still publishes exact ProcessResource + op_valid = ExactOperation.normalize("bash", "printf valid") + bound_valid = resources.resolve_process_operation(authority(workspace), op_valid, NativeBackendResource("bash")) + launch_valid = bound_valid.launch + cid_valid = uuid.uuid4().hex + + resources.publish_launch(launch_valid, authority(workspace), cid_valid) + containment._save_records({ + cid_valid: { + "id": cid_valid, + "launch_generation": launch_valid.generation, + "workspace": launch_valid.scope.root.path, + "pid": 65432, + "start_token": "procfs:boot:token65432", + "pgid": 65432, + "mechanism": "process_group", + } + }) + + resources.attach_containment_processes(launch_valid, cid_valid) + published_valid = json.loads(resources.launch_path(launch_valid.generation).read_text()) + assert len(published_valid["processes"]) == 1 + leader_res = ProcessResource.from_dict(published_valid["processes"][0]) + assert leader_res.role == "leader" + assert leader_res.identity.pid == 65432 + assert leader_res.identity.start_token == "procfs:boot:token65432" + assert leader_res.identity.pgid == 65432 + + +@pytest.mark.parametrize("tool,command,expected_out", [ + ("bash", "printf hi", "hi"), + ("python", "print('hi', end='')", "hi"), +]) +async def test_end_to_end_fast_exit_preserves_command_result(workspace, monkeypatch, tool, command, expected_out): + import os + original_capture = process_ownership.capture + def mocked_capture(pid): + if pid == os.getpid(): + return original_capture(pid) + return {"pid": pid, "start_token": None} + monkeypatch.setattr(process_ownership, "capture", mocked_capture) + + # Observe the real attachment before foreground lifecycle retirement. + published = [] + attach = resources.attach_containment_processes + def observe_attachment(launch, containment_id): + attach(launch, containment_id) + published.append(json.loads(resources.launch_path(launch.generation).read_text())) + monkeypatch.setattr(resources, "attach_containment_processes", observe_attachment) + + auth = authority(workspace, tool=tool) + approval = approval_for(auth, tool, command) + _, result = await dispatch(auth, tool, command, approval) + + assert result["exit_code"] == 0 + assert result.get("output") == expected_out + assert "failure_kind" not in result or result["failure_kind"] != "resource_linkage_unavailable" + + launches_dir = resources._LAUNCH_DIR + launch_files = list(launches_dir.glob("*.json")) + assert not launch_files + cid = result.get("containment", {}).get("id") + assert cid + matching = [record for record in published if record.get("containment_id") == cid] + assert len(matching) == 1 + assert matching[0]["processes"] == [] + + +def test_missing_start_token_security_negative(workspace, monkeypatch): + import uuid + import src.process_lifecycle as pl + from src.process_lifecycle import ProcessIdentity + + op = ExactOperation.normalize("bash", "printf test") + bound = resources.resolve_process_operation(authority(workspace), op, NativeBackendResource("bash")) + launch = bound.launch + cid = uuid.uuid4().hex + + resources.publish_launch(launch, authority(workspace), cid) + containment._save_records({ + cid: { + "id": cid, + "launch_generation": launch.generation, + "workspace": launch.scope.root.path, + "pid": 77777, + "start_token": None, + "pgid": 77777, + "mechanism": "process_group", + } + }) + + created_identities = [] + orig_identity_init = ProcessIdentity.__init__ + def spy_identity_init(self, pid, start_token, pgid=None): + created_identities.append((pid, start_token, pgid)) + return orig_identity_init(self, pid, start_token, pgid=pgid) + + monkeypatch.setattr(ProcessIdentity, "__init__", spy_identity_init) + monkeypatch.setattr(process_ownership, "process_table", lambda *a, **k: pytest.fail("PID rediscovery attempted via process_table")) + monkeypatch.setattr(process_ownership, "start_token", lambda *a, **k: pytest.fail("PID rediscovery attempted via start_token")) + + resources.attach_containment_processes(launch, cid) + + # 1. No ProcessIdentity created for this unobservable process + assert not any(pid == 77777 for pid, token, pgid in created_identities) + + # 2. No process authority published + published = json.loads(resources.launch_path(launch.generation).read_text()) + assert published["processes"] == [] + + # 3. No signal authority + fake_ident = ProcessIdentity(77777, None, 77777) + assert fake_ident.verdict() == process_ownership.UNVERIFIABLE + assert pl.signal_identity(fake_ident, 15) is False diff --git a/tests/test_wave3_background_followup.py b/tests/test_wave3_background_followup.py new file mode 100644 index 000000000..7c826fb74 --- /dev/null +++ b/tests/test_wave3_background_followup.py @@ -0,0 +1,115 @@ +"""Permanent linkage loss suppresses continuation without granting authority.""" +from types import SimpleNamespace +import time + +import pytest +from src import bg_jobs, bg_monitor +from src.agent_runtime import process_resources as resources +from tests.test_background_resource_identity import store, seed +from src.agent_runtime.resources import ResourceIdentityError + + +@pytest.fixture +def monitor_session(monkeypatch): + messages = [] + sess = SimpleNamespace(id='thread', owner='alice', model='test-model', get_context_messages=lambda: []) + sm = SimpleNamespace(get_session=lambda sid: sess, add_message=lambda *args: messages.append(args), save_sessions=lambda: None) + import src.ai_interaction as ai + monkeypatch.setattr(ai, 'get_session_manager', lambda: sm) + import src.agent_runs + monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False) + async def drain(*args, **kwargs): + messages.append('drained') + return 'continued', [] + monkeypatch.setattr(bg_monitor, '_drain_agent', drain) + return messages + + +@pytest.mark.parametrize('damage', ['missing', 'corrupt', 'wrong_owner', 'wrong_pid']) +async def test_invalid_linkage_is_terminal_without_message(store, monkeypatch, monitor_session, damage): + resource, rec = seed(store, status='done') + sidecar = bg_jobs._JOBS_DIR / 'job.authority.json' + if damage == 'missing': sidecar.unlink() + elif damage == 'corrupt': sidecar.write_text('{}') + else: + jobs = bg_jobs._load() + if damage == 'wrong_owner': jobs['job']['resource_identity']['owner'] = 'bob' + else: jobs['job']['pid'] = 99999 + bg_jobs._save(jobs) + rec = jobs['job'] + # Invalid data must not even be rendered into a synthetic result message. + monkeypatch.setattr(bg_monitor, '_background_result_message', lambda rec: pytest.fail('Invalid result rendered')) + assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE + assert not monitor_session + assert not bg_jobs.pending_followups() + assert bg_jobs.peek('job')['followup_state'] == 'terminal_unfollowable' + assert not bg_jobs.peek('job').get('followed_up') + with pytest.raises(ResourceIdentityError): + resources.validate_job(resource) + + +async def test_busy_session_retries_then_continues(store, monkeypatch, monitor_session): + _, rec = seed(store, status='done') + import src.agent_runs + monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: True) + assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.RETRYABLE_LATER + assert bg_jobs.pending_followups() and not monitor_session + monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False) + assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.COMPLETED + assert bg_jobs.peek('job')['followed_up'] + assert monitor_session and not bg_jobs.pending_followups() + + +async def test_terminal_record_prunes_exact_generation(store, monitor_session): + resource, rec = seed(store, status='done') + rec['ended_at'] = time.time() - bg_jobs._RETENTION_S - 10 + bg_jobs._save({'job': rec}) + (bg_jobs._JOBS_DIR / 'job.authority.json').unlink() + assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE + assert not bg_jobs.pending_followups() + assert bg_jobs.peek('job') is None + assert not resources.launch_path(resource.generation).exists() + assert not monitor_session + + +def test_stale_terminal_snapshot_cannot_suppress_new_generation(store): + _, old = seed(store, status='done') + new, _ = seed(store, status='done') + assert not bg_jobs.mark_unfollowable('job', expected_record=old) + assert 'followup_state' not in bg_jobs.peek('job') + assert resources.launch_path(new.generation).exists() + + +async def test_linkage_lost_during_continuation_cannot_deliver(store, monkeypatch, monitor_session): + _, rec = seed(store, status='done') + async def interrupted(*args, **kwargs): + (bg_jobs._JOBS_DIR / 'job.authority.json').unlink() + return 'must not be delivered', [] + monkeypatch.setattr(bg_monitor, '_drain_agent', interrupted) + assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE + assert not monitor_session + assert not bg_jobs.pending_followups() + + +async def test_stale_terminal_outcome_retries_current_record(store, monkeypatch, monitor_session): + _, old = seed(store, status='done') + seed(store, status='done') + async def terminal(rec): return bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE + monkeypatch.setattr(bg_monitor, '_run_followup', terminal) + assert await bg_monitor._process_followup(old) is bg_monitor.FollowupResult.RETRYABLE_LATER + assert bg_jobs.pending_followups() + + +@pytest.mark.parametrize('linkage', ['valid', 'damaged']) +async def test_deleted_session_settles_only_a_validated_launch(store, monkeypatch, monitor_session, linkage): + """Wave 4: a job retired for a deleted session must not leave its launch effect RUNNING.""" + resource, rec = seed(store, status='done') + if linkage == 'damaged': + (bg_jobs._JOBS_DIR / 'job.authority.json').write_text('{}') + import src.ai_interaction as ai + monkeypatch.setattr(ai, 'get_session_manager', lambda: SimpleNamespace(get_session=lambda sid: None)) + settled = [] + monkeypatch.setattr(bg_monitor, '_settle_launch_effect', lambda job, record: settled.append(job)) + assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE + assert settled == ([resource] if linkage == 'valid' else []) + assert not monitor_session diff --git a/tests/test_wave3_browser_platform.py b/tests/test_wave3_browser_platform.py new file mode 100644 index 000000000..a0ae58d71 --- /dev/null +++ b/tests/test_wave3_browser_platform.py @@ -0,0 +1,21 @@ +"""Wave 3 metadata requires a real allowlisted Linux producer artifact.""" +import pytest +from src import browser_identity as browser +from src.agent_runtime.resources import ResourceIdentityError + + +@pytest.mark.parametrize('system,machine', [('Darwin', 'x86_64'), ('Darwin', 'arm64'), + ('Windows', 'AMD64'), ('Windows', 'ARM64'), ('Linux', 'riscv64')]) +async def test_unsupported_platform_fails_before_producer_execution(monkeypatch, system, machine): + monkeypatch.setattr(browser.platform, 'system', lambda: system) + monkeypatch.setattr(browser.platform, 'machine', lambda: machine) + async def forbidden(*args, **kwargs): pytest.fail('Unsupported producer was executed') + monkeypatch.setattr(browser, 'run_client', forbidden) + with pytest.raises(ResourceIdentityError, match='Unsupported browser producer platform'): + await browser.trusted_producer() + + +def test_observed_release_hash_contract_is_explicit(): + assert set(browser.PRODUCER_HASHES) == {'linux-x64', 'linux-arm64'} + assert browser.PRODUCER_VERSION == '0.35.0' + assert browser.SESSION_ACTIONS == {'session_info'} diff --git a/tests/test_wave3_diagnostics.py b/tests/test_wave3_diagnostics.py new file mode 100644 index 000000000..358a6a43e --- /dev/null +++ b/tests/test_wave3_diagnostics.py @@ -0,0 +1,48 @@ +"""Unexpected programming defects must not look like successful policy denial.""" +import pytest +from src import tool_execution +from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority +from src.agent_runtime.resources import ResourceIdentityError +from src.tool_capabilities import ToolRunSecurityContext +from src.tool_types import ToolBlock + + +@pytest.mark.parametrize('seam,tool,content', [ + ('bind_backend_for_operation', 'bash', 'printf probe'), + ('resolve_process_operation', 'bash', 'printf probe'), + ('admit_owned_operation', 'edit_document', '{"document_id":"doc","content":"changed"}'), +]) +@pytest.mark.parametrize('error_type', [AttributeError, ResourceIdentityError, ValueError, TypeError]) +async def test_binding_errors_keep_diagnostic_identity(tmp_path, monkeypatch, seam, tool, content, error_type): + authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant(tool),)) + monkeypatch.setattr(tool_execution, '_owner_is_admin', lambda owner: True) + def broken(*args, **kwargs): + raise error_type('injected defect') + monkeypatch.setattr(tool_execution, seam, broken) + args = dict(owner='alice', session_id='thread', workspace=str(tmp_path), request_authority=authority, + security_context=ToolRunSecurityContext()) + if error_type is AttributeError: + with pytest.raises(AttributeError, match='injected defect'): + await tool_execution.execute_tool_block(ToolBlock(tool, content), **args) + else: + _, result = await tool_execution.execute_tool_block(ToolBlock(tool, content), **args) + assert result['failure_kind'] == 'resource_identity_denied' and result['blocked'] + + +async def test_argument_normalization_defect_propagates(tmp_path, monkeypatch): + authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant('bash'),)) + def broken(*args, **kwargs): raise AttributeError('internal-only diagnostic') + monkeypatch.setattr(ExactOperation, 'normalize', broken) + with pytest.raises(AttributeError): + await tool_execution.execute_tool_block(ToolBlock('bash', 'printf probe'), owner='alice', + session_id='thread', workspace=str(tmp_path), request_authority=authority, + security_context=ToolRunSecurityContext()) + + +@pytest.mark.parametrize('content', ['{invalid', None]) +async def test_expected_bad_input_still_has_authority_denial(tmp_path, content): + authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant('api_call'),)) + _, result = await tool_execution.execute_tool_block(ToolBlock('api_call', content), owner='alice', + session_id='thread', workspace=str(tmp_path), request_authority=authority, + security_context=ToolRunSecurityContext()) + assert result['failure_kind'] == 'request_authority_denied' diff --git a/tests/test_wave3_launch_cost_lifecycle.py b/tests/test_wave3_launch_cost_lifecycle.py new file mode 100644 index 000000000..55cf92960 --- /dev/null +++ b/tests/test_wave3_launch_cost_lifecycle.py @@ -0,0 +1,217 @@ +"""Structural dispatch cost and exact publication lifetime regressions.""" +import asyncio +from dataclasses import replace +import json +import os +import time + +import pytest +from core.atomic_io import atomic_write_json +from src import bg_jobs, containment, process_ownership +from src.agent_runtime import resources as identities +from src.agent_runtime.authority import ExactOperation, bind_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 tests.test_runtime_resource_integration import workspace, authority, dispatch +from tests.test_background_resource_identity import seed +from src.agent_runtime import process_resources as resources + + +@pytest.mark.parametrize('tool,content', [('bash', 'printf guarded'), ('python', 'print("guarded")')]) +async def test_real_dispatch_scans_workspace_once_per_binding(workspace, monkeypatch, tool, content): + (workspace / 'child').mkdir() + (workspace / 'child' / 'link').symlink_to(workspace / 'child') + calls = [] + walk = os.walk + def counted(*args, **kwargs): + calls.append(args[0]) + return walk(*args, **kwargs) + monkeypatch.setattr(os, 'walk', counted) + admitted = authority(workspace, tool) + for _ in range(2): + calls.clear() + _, result = await dispatch(admitted, tool, content) + assert result['exit_code'] == 0, result + assert calls == [workspace] + assert not list(resources._LAUNCH_DIR.glob('*.json')) + + +async def test_alias_created_after_resolution_is_denied_at_binding(workspace): + admitted = authority(workspace) + op = ExactOperation.normalize('bash', 'printf safe') + bound = resources.resolve_process_operation(admitted, op, NativeBackendResource('bash')) + resources._LAUNCH_DIR.mkdir(parents=True) + state = resources._LAUNCH_DIR / ('a' * 32 + '.json') + state.write_text('{}') + (workspace / 'alias').symlink_to(state) + with bind_request_authority(admitted), pytest.raises(ResourceIdentityError): + with resources.bind_process_operation(bound): + pytest.fail('New control-plane alias admitted') + + +async def test_publication_retained_during_launch_and_retired_after_teardown(workspace, monkeypatch): + entered, resume = asyncio.Event(), asyncio.Event() + run = containment.run + paths = [] + async def held(grant, command, **kwargs): + launch = resources.active_process_operation().launch + path = resources.launch_path(launch.generation) + assert path.is_file() + paths.append(path) + entered.set() + await resume.wait() + return await run(grant, command, **kwargs) + monkeypatch.setattr(containment, 'run', held) + task = asyncio.create_task(dispatch(authority(workspace), 'bash', 'printf foreground')) + await asyncio.wait_for(entered.wait(), 5) + assert paths[0].is_file() + resume.set() + _, result = await task + assert result['exit_code'] == 0 and result['teardown']['dead'] + assert not paths[0].exists() + + +async def test_retired_publication_cannot_replay_bound_reservation(workspace): + admitted = authority(workspace) + op = ExactOperation.normalize('bash', 'printf once') + bound = resources.resolve_process_operation(admitted, op, NativeBackendResource('bash')) + from src import tool_execution + token = tool_execution._active_workspace.set(str(workspace)) + try: + with bind_request_authority(admitted), resources.bind_process_operation(bound): + ctx = {'owner': 'alice', 'session_id': 'thread'} + first = await BashTool().execute(op.input, ctx) + assert first['exit_code'] == 0 + assert not resources.launch_path(bound.launch.generation).exists() + second = await BashTool().execute(op.input, ctx) + assert second['failure_kind'] == 'resource_identity_denied' + copy = replace(bound, exact_approval=None) + with pytest.raises(ResourceIdentityError): + with resources.bind_process_operation(copy): + pytest.fail('Approval copy renewed a consumed launch') + finally: + tool_execution._active_workspace.reset(token) + + +@pytest.mark.parametrize('publication', [[], None, 'malformed']) +def test_nonobject_publication_cannot_be_retired(workspace, publication): + resource, rec = seed(workspace, status='done') + path = resources.launch_path(resource.generation) + path.write_text(json.dumps(publication)) + launch = identities.ProcessLaunchResource.from_dict(rec['launch_resource']) + assert not resources.retire_launch(launch, resource.containment_id, job=resource) + assert json.loads(path.read_text()) == publication + + +async def test_corrupt_publication_retirement_preserves_command_result(workspace, monkeypatch): + attach = resources.attach_containment_processes + paths = [] + def corrupt_after_attachment(launch, containment_id): + observed = attach(launch, containment_id) + path = resources.launch_path(launch.generation) + path.write_text('[]') + paths.append(path) + return observed + monkeypatch.setattr(resources, 'attach_containment_processes', corrupt_after_attachment) + _, result = await dispatch(authority(workspace), 'bash', 'printf completed') + assert result['exit_code'] == 0 and result['output'] == 'completed', result + assert paths[0].read_text() == '[]' + + +@pytest.mark.parametrize('status,followed_up,old,removed', [ + ('running', True, True, False), ('done', False, True, False), + ('done', True, False, False), ('done', True, True, True), ('failed', True, True, True), +]) +def test_background_publication_tracks_supported_history_lifetime(workspace, status, followed_up, old, removed): + resource, rec = seed(workspace, status=status) + rec.update(followed_up=followed_up, ended_at=time.time() - (bg_jobs._RETENTION_S + 10 if old else 0)) + jobs = {'job': rec} + bg_jobs._save(jobs) + assert resources.launch_path(resource.generation).exists() + bg_jobs._prune(jobs, time.time()) + assert resources.launch_path(resource.generation).exists() is not removed + assert ('job' not in jobs) is removed + if removed: + bg_jobs._save(jobs) + with pytest.raises(ResourceIdentityError): + resources.validate_job(resource) + + +def test_old_generation_retirement_cannot_delete_replacement(workspace): + old, rec = seed(workspace, status='done') + new, _ = seed(workspace, status='done') + old_launch = identities.ProcessLaunchResource.from_dict(rec['launch_resource']) + assert resources.retire_launch(old_launch, old.containment_id, job=old) + assert resources.launch_path(new.generation).is_file() + # Even a replaced file at the old generation's slot is not deletable by old linkage. + replacement = json.loads(resources.launch_path(new.generation).read_text()) + atomic_write_json(resources.launch_path(old.generation), replacement) + assert not resources.retire_launch(old_launch, old.containment_id, job=old) + assert resources.launch_path(old.generation).is_file() + + +@pytest.mark.parametrize('manager,release,retired', [ + (process_ownership.OWNED, False, False), (process_ownership.UNVERIFIABLE, False, False), + (process_ownership.OWNED, True, False), (process_ownership.UNVERIFIABLE, True, False), + (process_ownership.GONE, False, True), (process_ownership.FOREIGN, False, True), + (process_ownership.GONE, True, True), +]) +def test_startup_retirement_does_not_invent_process_death(workspace, monkeypatch, manager, release, retired): + admitted = authority(workspace) + launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf recovery'), NativeBackendResource('bash')).launch + cid = 'receipt' + resources.publish_launch(launch, admitted, cid) + atomic_write_json(containment._store_path(), {cid: {'id': cid, 'launch_generation': launch.generation, + 'manager_pid': 123, 'manager_token': 'old-manager', 'release': {'dead': release}}}) + monkeypatch.setattr(process_ownership, 'verify', lambda *args: manager) + assert resources.prune_foreground_publications() == int(retired) + assert resources.launch_path(launch.generation).exists() is not retired + assert containment._load_records()[cid]['release']['dead'] is release + + +def test_restart_never_prunes_background_linkage(workspace, monkeypatch): + resource, _ = seed(workspace, status='done') + monkeypatch.setattr(process_ownership, 'verify', lambda *args: process_ownership.GONE) + assert resources.prune_foreground_publications() == 0 + assert resources.launch_path(resource.generation).is_file() + + +def test_snapshot_is_rebuilt_for_each_guard(workspace): + resources._LAUNCH_DIR.mkdir(parents=True) + target = workspace / 'data'; target.write_text('ordinary') + (workspace / 'link').symlink_to(target) + resources.guard_launch_workspace(identities.FilesystemRoot.seal(workspace)) + os.link(target, resources._LAUNCH_DIR / ('b' * 32 + '.json')) + with pytest.raises(ResourceIdentityError): + resources.guard_launch_workspace(identities.FilesystemRoot.seal(workspace)) + + +def test_missing_receipt_publication_cannot_recover_authority(workspace): + admitted = authority(workspace) + launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf recovery'), NativeBackendResource('bash')).launch + resources.publish_launch(launch, admitted, 'missing-receipt') + assert resources.prune_foreground_publications() == 1 + assert not resources.launch_path(launch.generation).exists() + assert not containment._load_records() + + +@pytest.mark.parametrize('receipt_data', ['{corrupt', '[]', '{"receipt":null}']) +def test_unreadable_receipts_cannot_retire_live_consumers(workspace, receipt_data): + admitted = authority(workspace) + launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf pending'), NativeBackendResource('bash')).launch + resources.publish_launch(launch, admitted, 'receipt') + containment._store_path().write_text(receipt_data) + assert resources.prune_foreground_publications() == 0 + assert resources.launch_path(launch.generation).is_file() + + +def test_missing_manager_identity_cannot_retire_attachment(workspace, monkeypatch): + admitted = authority(workspace) + launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf pending'), NativeBackendResource('bash')).launch + resources.publish_launch(launch, admitted, 'receipt') + atomic_write_json(containment._store_path(), {'receipt': {'id': 'receipt', + 'launch_generation': launch.generation, 'release': {'dead': True}}}) + monkeypatch.setattr(process_ownership, 'verify', lambda *args: pytest.fail('Missing manager treated as observed')) + assert resources.prune_foreground_publications() == 0 + assert resources.launch_path(launch.generation).is_file() diff --git a/tests/test_wave3_local_control.py b/tests/test_wave3_local_control.py new file mode 100644 index 000000000..33cd4533e --- /dev/null +++ b/tests/test_wave3_local_control.py @@ -0,0 +1,246 @@ +"""Local administration and exact Cookbook caller-to-route regressions.""" +import asyncio +import json +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import MagicMock + +import httpx +import pytest +from fastapi import FastAPI, HTTPException +from starlette.requests import Request + +from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER +from routes import cookbook_routes, shell_routes +from src import builtin_actions, tool_execution +from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority, seal_task_authority, restore_task_authority +from src.agent_runtime.remote_resources import bind_backend_for_operation, bind_backend_operation +from src.agent_runtime.local_model_control import CAPABILITY_HEADER, model_control_headers +from src.agent_runtime.resources import ResourceIdentityError +from src.tools import cookbook +from src.tool_capabilities import ToolRunSecurityContext +from src.tool_types import ToolBlock + + +def request(host='127.0.0.1', headers=None, user=None): + req = Request({'type': 'http', 'method': 'POST', 'scheme': 'http', 'path': '/api/shell/exec', + 'server': ('127.0.0.1', 7000), 'client': (host, 1234), + 'headers': [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()], + 'app': SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(is_admin=lambda u: u == 'alice')))}) + req.state.current_user = user + return req + + +@pytest.mark.parametrize('host,headers,allowed', [ + ('127.0.0.1', {}, True), ('::1', {}, True), ('192.0.2.1', {}, False), + ('127.0.0.1', {'x-forwarded-for': '192.0.2.1'}, False), + ('127.0.0.1', {'forwarded': 'for=192.0.2.1'}, False), + ('127.0.0.1', {'cf-ray': 'proxy'}, False), + ('127.0.0.1', {'x-forwarded-proto': 'https'}, False), + ('127.0.0.1', {'sec-fetch-site': 'cross-site'}, False), + ('127.0.0.1', {'origin': 'https://evil.example'}, False), + ('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}, False), + ('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}, False), +]) +def test_auth_disabled_operator_transport(monkeypatch, host, headers, allowed): + monkeypatch.setenv('AUTH_ENABLED', 'false') + req = request(host, headers) + if allowed: + shell_routes._require_admin(req) + else: + with pytest.raises(HTTPException) as error: + shell_routes._require_admin(req) + assert error.value.status_code == 403 + + +@pytest.mark.parametrize('user,allowed', [('alice', True), ('bob', False), (None, False), ('api', False), (INTERNAL_TOOL_USER, False)]) +def test_auth_enabled_administration(monkeypatch, user, allowed): + monkeypatch.setenv('AUTH_ENABLED', 'true') + if allowed: + shell_routes._require_admin(request('192.0.2.1', user=user)) + else: + with pytest.raises(HTTPException): + shell_routes._require_admin(request(user=user)) + + +@pytest.fixture +def control_app(tmp_path, monkeypatch): + # Auth tests can reload middleware after collection. Authenticate the live + # transport token used by real producers, rather than a collection snapshot. + from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER + monkeypatch.setenv('AUTH_ENABLED', 'true') + manager = SimpleNamespace(is_configured=True, users={'alice': {}}, is_admin=lambda u: u == 'alice') + import core.auth + monkeypatch.setattr(core.auth, 'AuthManager', lambda: manager) + monkeypatch.setattr(tool_execution, '_owner_is_admin', lambda u: u == 'alice') + monkeypatch.setattr(cookbook_routes, 'TMUX_LOG_DIR', tmp_path / 'tmux') + state = tmp_path / 'cookbook.json' + state.write_text(json.dumps({'presets': [{'name': 'preset', 'model': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}]})) + monkeypatch.setattr(cookbook_routes, 'COOKBOOK_STATE_FILE', str(state)) + monkeypatch.setattr(builtin_actions, 'COOKBOOK_STATE_FILE', str(state)) + spawned = [] + async def spawn(command, **kwargs): + spawned.append(command) + async def wait(): return 0 + async def read(): return b'' + return SimpleNamespace(returncode=0, wait=wait, stderr=SimpleNamespace(read=read)) + monkeypatch.setattr(asyncio, 'create_subprocess_shell', spawn) + async def remote_probe(*args, **kwargs): + async def communicate(): return b'tmux', b'' + return SimpleNamespace(returncode=0, communicate=communicate) + monkeypatch.setattr(asyncio, 'create_subprocess_exec', remote_probe) + import src.assistant_log + monkeypatch.setattr(src.assistant_log, 'log_to_assistant', lambda *a, **k: None) + async def endpoint(**kwargs): return {'added': True, 'endpoint_id': 'endpoint'} + monkeypatch.setattr(cookbook, '_ensure_served_endpoint', endpoint) + app = FastAPI() + app.state.auth_manager = manager + @app.middleware('http') + async def attribution(req, next): + if req.headers.get(INTERNAL_TOOL_HEADER) == INTERNAL_TOOL_TOKEN: + req.state.current_user = req.headers.get('X-Odysseus-Owner') or INTERNAL_TOOL_USER + return await next(req) + app.include_router(cookbook_routes.setup_cookbook_routes()) + app.include_router(shell_routes.setup_shell_routes()) + real_client = httpx.AsyncClient + def client_factory(*args, **kwargs): + kwargs.setdefault('transport', httpx.ASGITransport(app=app)) + return real_client(*args, **kwargs) + monkeypatch.setattr(httpx, 'AsyncClient', client_factory) + return app, spawned, tmp_path + + +@pytest.mark.parametrize('tool,args', [ + ('download_model', {'repo_id': 'org/model', 'local': True}), + ('serve_model', {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'local': True}), + ('serve_preset', {'name': 'preset'}), +]) +async def test_real_local_tool_dispatch_reaches_real_model_route(control_app, tool, args): + app, spawned, work = control_app + authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant(tool),)) + _, result = await tool_execution.execute_tool_block(ToolBlock(tool, json.dumps(args)), owner='alice', + session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext()) + assert result['exit_code'] == 0, result + assert result['session_id'].startswith('cookbook-' if tool == 'download_model' else 'serve-') + assert len(spawned) == 1 and 'tmux new-session' in spawned[0] + + +async def test_real_scheduled_local_action_uses_restored_exact_authority(control_app): + app, spawned, work = control_app + command = json.dumps({'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'set_default': False}) + snapshot = seal_task_authority(command, 'action', 'cookbook_serve', owner='alice') + authority = restore_task_authority(snapshot, command, 'action', 'cookbook_serve', owner='alice') + with bind_request_authority(authority): + message, ok = await builtin_actions.action_cookbook_serve('alice', command=command) + assert ok, message + assert len(spawned) == 1 + with bind_request_authority(authority): + _, ok = await builtin_actions.action_cookbook_serve('alice', command=command.replace('samplepkg', 'changedpkg')) + assert not ok and len(spawned) == 1 + + +@pytest.mark.parametrize('host,headers', [ + ('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}), + ('192.0.2.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}), + ('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}), + ('127.0.0.1', {CAPABILITY_HEADER: 'forged', INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}), +]) +async def test_header_only_cannot_launch(control_app, host, headers): + app, spawned, _ = control_app + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client: + for path in ('/api/model/download', '/api/model/serve', '/api/shell/exec'): + r = await client.post(path, json={'repo_id': 'org/model', 'cmd': 'printf nope', 'command': 'printf nope'}, headers=headers) + assert r.status_code == 403 + assert not spawned + + +@pytest.mark.parametrize('substitute', ['body', 'route', 'owner', 'remote', 'proxy', 'replay']) +async def test_capability_exact_transport_binding(control_app, substitute): + app, spawned, work = control_app + content = json.dumps({'repo_id': 'org/model', 'local': True}) + authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),)) + operation = ExactOperation.normalize('download_model', content) + backend = bind_backend_for_operation(authority, operation) + body = {'repo_id': 'org/model'} + with bind_request_authority(authority), bind_backend_operation(backend), model_control_headers('download_model', content, 'alice', body) as headers: + changed = dict(headers); payload = dict(body); path = '/api/model/download'; host = '127.0.0.1' + if substitute == 'body': payload['repo_id'] = 'org/changed' + if substitute == 'route': path = '/api/model/serve' + if substitute == 'owner': changed['X-Odysseus-Owner'] = 'bob' + if substitute == 'remote': host = '192.0.2.1' + if substitute == 'proxy': changed['x-forwarded-for'] = '192.0.2.1' + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client: + if substitute == 'replay': + assert (await client.post(path, json=payload, headers=changed)).status_code == 200 + r = await client.post(path, json=payload, headers=changed) + assert r.status_code == 403 + assert len(spawned) == (1 if substitute == 'replay' else 0) + + +@pytest.mark.parametrize('field', ['owner', 'request_id', 'session_id']) +def test_producer_wrong_application_binding(control_app, field): + _, _, work = control_app + content = '{"repo_id":"org/model","local":true}' + authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),)) + backend = bind_backend_for_operation(authority, ExactOperation.normalize('download_model', content)) + changed = replace(authority, **{field: 'replacement'}, resource_roots=None, backend_resources=None, owned_scopes=None) + with bind_request_authority(changed), bind_backend_operation(backend), pytest.raises(ResourceIdentityError): + with model_control_headers('download_model', content, 'alice', {'repo_id': 'org/model'}): + pytest.fail('Substituted producer obtained a capability') + + +async def test_remote_route_semantics_remain_unchanged(control_app): + app, spawned, _ = control_app + async with httpx.AsyncClient(base_url='http://127.0.0.1') as client: + r = await client.post('/api/model/download', json={'repo_id': 'org/model', 'remote_host': 'gpu.example'}, + headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}) + assert r.status_code == 200 and r.json()['ok'], r.text + assert len(spawned) == 1 and 'ssh ' in spawned[0] + + +async def test_auth_disabled_local_route_usage(control_app, monkeypatch): + app, spawned, _ = control_app + monkeypatch.setenv('AUTH_ENABLED', 'false') + async with httpx.AsyncClient(base_url='http://127.0.0.1') as client: + shell = await client.post('/api/shell/exec', json={'command': ''}) + state = await client.post('/api/cookbook/state', json={'tasks': []}) + launch = await client.post('/api/model/download', json={'repo_id': 'org/model'}) + assert shell.status_code == state.status_code == launch.status_code == 200 + assert launch.json()['ok'] and len(spawned) == 1 + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=('192.0.2.1', 1)), base_url='http://127.0.0.1') as client: + for path, body in [('/api/shell/exec', {'command': ''}), ('/api/cookbook/state', {'tasks': []}), ('/api/model/download', {'repo_id': 'org/model'})]: + assert (await client.post(path, json=body)).status_code == 403 + + +@pytest.mark.parametrize('host,internal', [('127.0.0.1', True), ('192.0.2.1', False)]) +async def test_scoped_wrapper_cannot_bypass_native_control(control_app, monkeypatch, host, internal): + from routes.codex_routes import setup_codex_routes + app, spawned, _ = control_app + app.include_router(setup_codex_routes()) + monkeypatch.setenv('AUTH_ENABLED', 'false') + headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if internal else {} + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client: + r = await client.post('/api/codex/cookbook/serve', json={'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}, headers=headers) + assert r.status_code == 403 and not spawned + + +@pytest.mark.parametrize('path', ['/api/codex/cookbook/serve', '/api/codex/cookbook/stop/job', '/api/codex/%63ookbook/serve']) +async def test_generic_app_api_cannot_substitute_scoped_wrapper(control_app, path): + _, spawned, work = control_app + authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('app_api'),)) + content = json.dumps({'action': 'call', 'method': 'POST', 'path': path, 'body': {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}}) + _, result = await tool_execution.execute_tool_block(ToolBlock('app_api', content), owner='alice', + session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext()) + assert result['failure_kind'] == 'resource_identity_denied' and not spawned + + +async def test_direct_endpoint_call_keeps_producer_gate(control_app, monkeypatch): + from routes.cookbook_helpers import ModelDownloadRequest + app, spawned, _ = control_app + router = cookbook_routes.setup_cookbook_routes() + endpoint = next(route.endpoint for route in router.routes if getattr(route, 'path', '') == '/api/model/download') + monkeypatch.setenv('AUTH_ENABLED', 'false') + req = request('192.0.2.1') + with pytest.raises(HTTPException) as exc: + await endpoint(req, ModelDownloadRequest(repo_id='org/model')) + assert exc.value.status_code == 403 and not spawned diff --git a/tests/test_wave3_subprocess_environment.py b/tests/test_wave3_subprocess_environment.py new file mode 100644 index 000000000..f0702c729 --- /dev/null +++ b/tests/test_wave3_subprocess_environment.py @@ -0,0 +1,27 @@ +"""Closed inheritance is the complete subprocess environment boundary.""" +from src import tool_execution + +def test_closed_subprocess_environment_drops_all_unlisted_credentials(monkeypatch): + from unittest.mock import patch + import os + ambient = {'PATH': '/usr/bin', 'LANG': 'C.UTF-8', 'OPENAI_API_KEY': 'secret', 'HF_TOKEN': 'secret', + 'AUTH_ENABLED': 'false', 'DATABASE_URL': 'secret', 'PATH_TOKEN': 'secret', + 'AWS_SECRET_ACCESS_KEY': 'secret', 'ARBITRARY': 'secret', 'HOME': '/server/secret'} + with patch.dict(os.environ, ambient, clear=True): + child = tool_execution._agent_subprocess_env() + assert child['PATH'] == '/usr/bin' + assert child['HOME'] == tool_execution._AGENT_WORKDIR + assert set(child) <= tool_execution._SAFE_SUBPROCESS_VARS | {'HOME', 'TERM', 'COLUMNS', 'LINES'} + assert all(child.get(name) != value for name, value in ambient.items() if name not in {'PATH', 'LANG'}) + + +async def test_real_python_child_does_not_inherit_ambient_credentials(workspace, monkeypatch): + from tests.test_runtime_resource_integration import authority, dispatch + for name in ('OPENAI_API_KEY', 'HF_TOKEN', 'PATH_TOKEN', 'DATABASE_URL', 'ODYSSEUS_INTERNAL_TOKEN'): + monkeypatch.setenv(name, 'never-inherit-this-value') + _, result = await dispatch(authority(workspace, 'python'), 'python', + 'import os\nprint(any(v == "never-inherit-this-value" for v in os.environ.values()))') + assert result['exit_code'] == 0 and result['output'] == 'False' + + +from tests.test_runtime_resource_integration import workspace diff --git a/website/configuration-reference.md b/website/configuration-reference.md index 17d1dc0de..40d6309d2 100644 --- a/website/configuration-reference.md +++ b/website/configuration-reference.md @@ -75,7 +75,7 @@ The source tree reads **112** `ODYSSEUS_*` variables: 81 an operator may want to | `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15361` | How many video frames one tool result may contribute. Clamped to 1-8. | | `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15329` | How many images one tool result may contribute to the model turn. Clamped to 1-8. | | `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. | -| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_runtime/process_resources.py:58` (+2 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. | +| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_runtime/process_resources.py:59` (+2 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. | | `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:925` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. | | `ODYSSEUS_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:645` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. |