From 57143a14ba39de28c3456e740a7806a7972de05c Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:17:01 +0100 Subject: [PATCH] fix(runtime): verify namespace init death and record Wave 3-S validation --- .../validation/wave-3-s-failed-nodes.json | 128 +++++++++ .../validation/wave-3-s-node-environment.txt | 2 + .../wave-3-s-delivery-2026-10-01.md | 256 ++++++++++++++++++ src/containment.py | 254 ++++++++++++++--- tests/containment_helpers.py | 8 + tests/test_containment_enforcement.py | 103 +++++++ tests/test_native_execution_containment.py | 6 +- 7 files changed, 720 insertions(+), 37 deletions(-) create mode 100644 docs/runtime-decomposition/validation/wave-3-s-failed-nodes.json create mode 100644 docs/runtime-decomposition/validation/wave-3-s-node-environment.txt create mode 100644 docs/runtime-decomposition/wave-3-s-delivery-2026-10-01.md diff --git a/docs/runtime-decomposition/validation/wave-3-s-failed-nodes.json b/docs/runtime-decomposition/validation/wave-3-s-failed-nodes.json new file mode 100644 index 000000000..932ea7f65 --- /dev/null +++ b/docs/runtime-decomposition/validation/wave-3-s-failed-nodes.json @@ -0,0 +1,128 @@ +[ + "tests/test_app_db_permissions.py::test_app_db_created_with_0600", + "tests/test_app_db_permissions.py::test_app_db_sidecars_relocked", + "tests/test_app_db_permissions.py::test_app_db_file_uri_created_with_0600", + "tests/test_app_db_permissions.py::test_app_db_localhost_file_uri_created_with_0600", + "tests/test_app_db_permissions.py::test_app_db_non_uri_mode_query_created_with_0600", + "tests/test_app_db_permissions.py::test_app_db_plain_file_uri_created_with_0600", + "tests/test_auth_config_lock_concurrency.py::TestConcurrentCreateUser::test_parallel_creates_no_lost_users", + "tests/test_auth_config_lock_concurrency.py::TestConcurrentCreateUser::test_parallel_creates_same_username_only_one_wins", + "tests/test_auth_config_lock_concurrency.py::TestConcurrentDeleteUser::test_parallel_deletes_no_corruption", + "tests/test_auth_config_lock_concurrency.py::TestConcurrentRenameUser::test_parallel_renames_no_lost_users", + "tests/test_auth_config_lock_concurrency.py::TestConcurrentMixedOperations::test_mixed_operations_no_corruption", + "tests/test_auth_config_lock_concurrency.py::TestDiskConsistency::test_file_always_valid_json_during_concurrent_ops", + "tests/test_auth_root_path.py::test_real_auth_middleware_uses_application_relative_path", + "tests/test_caldav_bidirectional_sync.py::test_event_to_ical_serializes_core_fields_and_rrule", + "tests/test_caldav_google_principal_url.py::test_google_sync_pulls_events_instead_of_empty", + "tests/test_caldav_writeback.py::test_build_ical_timed_event_has_core_fields", + "tests/test_caldav_writeback.py::test_build_ical_all_day_uses_date_values", + "tests/test_caldav_writeback.py::test_build_ical_includes_rrule", + "tests/test_caldav_writeback.py::test_push_create_calls_save_event", + "tests/test_caldav_writeback.py::test_push_update_overwrites_existing", + "tests/test_doc_library_open_orphaned.py::test_mobile_explicit_load_restores_full_editor_from_bottom_dock", + "tests/test_document_followup_integrity.py::test_unavailable_active_target_never_falls_back_to_other_document[deleted-document-edit_document]", + "tests/test_document_followup_integrity.py::test_unavailable_active_target_never_falls_back_to_other_document[deleted-document-update_document]", + "tests/test_document_followup_integrity.py::test_unavailable_active_target_never_falls_back_to_other_document[foreign-document-edit_document]", + "tests/test_document_followup_integrity.py::test_unavailable_active_target_never_falls_back_to_other_document[foreign-document-update_document]", + "tests/test_document_followup_integrity.py::test_targeted_edit_and_undo_preserve_other_occurrences", + "tests/test_document_followup_integrity.py::test_no_target_legacy_fallback_still_scopes_to_owner", + "tests/test_document_followup_integrity.py::test_invalid_multi_edit_saves_only_exact_matches_and_reports_remainder", + "tests/test_document_followup_integrity.py::test_batch_with_only_bad_anchors_reports_all_without_saving", + "tests/test_document_followup_integrity.py::test_long_proofreading_batch_saves_safe_matches_and_identifies_remainder", + "tests/test_document_followup_integrity.py::test_inline_suggestion_is_reviewable_then_applies_only_its_target", + "tests/test_document_followup_integrity.py::test_whole_document_update_persists_exact_replacement", + "tests/test_document_followup_integrity.py::test_ambiguous_or_partial_word_edits_do_not_mutate[alpha-beta]", + "tests/test_document_followup_integrity.py::test_ambiguous_or_partial_word_edits_do_not_mutate[vio-new]", + "tests/test_document_followup_integrity.py::test_ambiguous_or_partial_word_edits_do_not_mutate[tha-that]", + "tests/test_document_followup_integrity.py::test_explicit_replace_all_corrects_every_occurrence", + "tests/test_document_followup_integrity.py::test_replace_all_cannot_change_fragments_of_correct_words", + "tests/test_document_followup_integrity.py::test_ambiguous_suggestion_returns_exact_recovery_anchors", + "tests/test_document_followup_integrity.py::test_mixed_suggestion_batch_queues_valid_items_and_reports_bad_anchors", + "tests/test_document_history_controls.py::test_mobile_rich_text_history_state_and_document_switch", + "tests/test_document_library_mobile_footer.py::test_mobile_open_in_new_chat_copies_to_materialized_session", + "tests/test_document_module_api.py::test_default_export_surface_is_complete_and_callable", + "tests/test_document_module_api.py::test_named_exports_survive_and_stay_callable", + "tests/test_document_module_api.py::test_window_bridge_is_the_default_export", + "tests/test_document_outline.py::test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile", + "tests/test_document_rich_checklist_enter.py::test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly", + "tests/test_document_rich_color_reset_and_contrast.py::test_rich_colors_follow_theme_and_undo_as_one_edit", + "tests/test_document_rich_docx_export.py::test_browser_word_export_contains_native_rich_docx_ooxml", + "tests/test_document_rich_docx_export.py::test_browser_markdown_word_export_keeps_heading_and_inline_formatting", + "tests/test_document_rich_find_boundaries.py::test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replacement", + "tests/test_document_rich_font_color_controls.py::test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile", + "tests/test_document_rich_heading_enter.py::test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable", + "tests/test_document_rich_heading_enter.py::test_heading_enter_preserves_shift_middle_and_empty_heading_semantics", + "tests/test_document_rich_image_caption.py::test_mobile_image_caption_survives_resize_history_and_empty_removal", + "tests/test_document_rich_input_rules.py::test_typing_markers_converts_blocks_and_preserves_following_text", + "tests/test_document_rich_keyboard_shortcuts.py::test_rich_document_shortcuts_work_at_desktop_and_mobile_widths", + "tests/test_document_rich_selection_toolbar.py::test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports", + "tests/test_document_rich_slash_menu.py::test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile", + "tests/test_document_rich_smart_link_paste.py::test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks", + "tests/test_document_rich_structure_tools.py::test_mobile_headings_page_break_history_and_persistence", + "tests/test_document_rich_table_cell_alignment.py::test_mobile_table_cell_alignment_tracks_state_and_native_history", + "tests/test_document_rich_table_header_preservation.py::test_mobile_structural_edits_preserve_header_modes_and_history", + "tests/test_document_rich_table_headers.py::test_mobile_header_row_and_column_toggle_independently_with_undo", + "tests/test_document_rich_table_merge_split.py::test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history", + "tests/test_document_rich_table_tab_history.py::test_mobile_table_tab_navigation_row_creation_and_history", + "tests/test_document_rich_toolbar_menus.py::test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens", + "tests/test_document_rich_toolbar_menus.py::test_mobile_toolbar_menu_preserves_selection_and_restores_focus", + "tests/test_document_rich_toolbar_menus.py::test_rich_toolbar_menus_track_live_formatting_values", + "tests/test_document_save_shortcut.py::test_ctrl_s_saves_rich_text_immediately_once_and_updates_status", + "tests/test_document_save_status.py::test_save_status_is_dirty_race_safe_and_reports_failures", + "tests/test_document_toolbar_order.py::test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile", + "tests/test_email_library_module_graph_js.py::test_every_package_module_evaluates_on_its_own_in_a_browser", + "tests/test_email_library_module_graph_js.py::test_wrapper_and_entry_module_hand_out_the_same_functions", + "tests/test_email_package_compatibility.py::test_legacy_email_modules_alias_canonical_module_objects", + "tests/test_escape_inner_layers.py::test_rich_escape_closes_toolbar_then_selection_badge", + "tests/test_escape_inner_layers.py::test_email_escape_closes_inner_states_without_closing_library", + "tests/test_extract_text_tool.py::test_extract_text_renders_and_ocr_scans_pdf_pages", + "tests/test_history_resume_rendering_js.py::test_history_resume_rendering_browser_suite", + "tests/test_image_provider_transport.py::test_image_provider_protocol[https://openrouter.ai/api/v1-True]", + "tests/test_image_provider_transport.py::test_image_provider_protocol[https://openrouter.ai/api/v1-False]", + "tests/test_image_provider_transport.py::test_image_provider_protocol[https://api.openai.com/v1-True]", + "tests/test_image_provider_transport.py::test_image_provider_protocol[https://api.openai.com/v1-False]", + "tests/test_live_fallback_round_attribution.py::test_detached_resume_reconciles_canonical_terminal_failures", + "tests/test_live_fallback_round_attribution.py::test_detached_resume_surfaces_fallback_then_provider_alias_without_reload", + "tests/test_live_fallback_round_attribution.py::test_detached_resume_renders_preoutput_error_without_empty_reload", + "tests/test_manage_tasks_cron.py::test_cron_create_edit_resume_and_invalid_edit_rollback", + "tests/test_manage_tasks_cron.py::test_named_weekdays_create_and_edit_preserve_actual_clock", + "tests/test_manage_tasks_cron.py::test_time_only_edit_changes_cron_clock_not_calendar_fields[15 9 * * 1,3,5]", + "tests/test_manage_tasks_cron.py::test_time_only_edit_changes_cron_clock_not_calendar_fields[15 9 15 * *]", + "tests/test_manage_tasks_cron.py::test_time_only_edit_changes_cron_clock_not_calendar_fields[0,30 8-10 * * 2,4]", + "tests/test_manage_tasks_cron.py::test_invalid_cron_retime_rolls_back_all_edits", + "tests/test_reserved_username_admin_escalation.py::test_rename_into_reserved_username_is_blocked[internal-tool]", + "tests/test_reserved_username_admin_escalation.py::test_rename_into_reserved_username_is_blocked[api]", + "tests/test_reserved_username_admin_escalation.py::test_rename_into_reserved_username_is_blocked[demo]", + "tests/test_reserved_username_admin_escalation.py::test_rename_into_reserved_username_is_blocked[system]", + "tests/test_reserved_username_admin_escalation.py::test_rename_into_reserved_username_is_blocked[__odysseus_local__]", + "tests/test_reserved_username_admin_escalation.py::test_normal_usernames_still_allowed", + "tests/test_review_calendar_invitation.py::test_reschedule_and_cancellation_target_same_event", + "tests/test_review_calendar_invitation.py::test_cancellation_before_invite_does_not_create_event", + "tests/test_review_calendar_invitation.py::test_same_ics_uid_is_scoped_to_owner", + "tests/test_review_calendar_invitation.py::test_attendee_reply_does_not_create_event", + "tests/test_review_calendar_invitation.py::test_overlapping_revisions_do_not_race", + "tests/test_review_calendar_invitation.py::test_same_title_time_does_not_link_different_senders", + "tests/test_review_calendar_invitation.py::test_occurrence_reschedule_excludes_original_without_moving_series", + "tests/test_review_calendar_invitation.py::test_occurrence_cancellation_before_series_is_preserved", + "tests/test_review_calendar_invitation.py::test_series_cancellation_also_cancels_detached_events", + "tests/test_review_document_conversion.py::test_imported_office_document_is_owned_at_first_commit", + "tests/test_review_endpoint_credentials.py::test_credential_resolution_is_exact_and_owner_scoped[alice-https://api.example.test/v1/chat/completions-Bearer alice-secret-task]", + "tests/test_review_endpoint_credentials.py::test_credential_resolution_is_exact_and_owner_scoped[alice-https://api.example.test/v1/chat/completions-Bearer alice-secret-skill]", + "tests/test_review_endpoint_credentials.py::test_credential_resolution_is_exact_and_owner_scoped[bob-https://api.example.test/v1/chat/completions-None-task]", + "tests/test_review_endpoint_credentials.py::test_credential_resolution_is_exact_and_owner_scoped[bob-https://api.example.test/v1/chat/completions-None-skill]", + "tests/test_review_endpoint_credentials.py::test_credential_resolution_is_exact_and_owner_scoped[alice-https://api.example.test.evil.test/v1-None-task]", + "tests/test_review_endpoint_credentials.py::test_credential_resolution_is_exact_and_owner_scoped[alice-https://api.example.test.evil.test/v1-None-skill]", + "tests/test_review_endpoint_credentials.py::test_credential_resolution_is_exact_and_owner_scoped[alice-https://evil.test/https://api.example.test/v1-None-task]", + "tests/test_review_endpoint_credentials.py::test_credential_resolution_is_exact_and_owner_scoped[alice-https://evil.test/https://api.example.test/v1-None-skill]", + "tests/test_setup_admin_user.py::test_create_default_admin_normalizes_env_username", + "tests/test_setup_admin_user.py::test_main_loads_admin_password_from_env_file", + "tests/test_turn_rendering_js.py::test_turn_rendering_browser_suite", + "tests/test_research_endpoint_owner_scope.py::test_endpoint_id_rejects_another_owners_private_endpoint", + "tests/test_research_endpoint_owner_scope.py::test_endpoint_id_returns_callers_own_endpoint", + "tests/test_research_endpoint_owner_scope.py::test_endpoint_id_allows_legacy_null_owner_shared_row", + "tests/test_research_endpoint_owner_scope.py::test_endpoint_id_skips_disabled_even_when_owned", + "tests/test_research_endpoint_owner_scope.py::test_fallback_never_picks_another_owners_endpoint", + "tests/test_research_endpoint_owner_scope.py::test_fallback_returns_none_when_only_others_endpoints", + "tests/test_research_endpoint_owner_scope.py::test_null_owner_is_legacy_single_user_noop", + "tests/test_research_endpoint_owner_scope.py::test_runtime_resolution_uses_provider_auth_for_chatgpt_subscription" +] diff --git a/docs/runtime-decomposition/validation/wave-3-s-node-environment.txt b/docs/runtime-decomposition/validation/wave-3-s-node-environment.txt new file mode 100644 index 000000000..628e49689 --- /dev/null +++ b/docs/runtime-decomposition/validation/wave-3-s-node-environment.txt @@ -0,0 +1,2 @@ + +added 4 packages in 560ms diff --git a/docs/runtime-decomposition/wave-3-s-delivery-2026-10-01.md b/docs/runtime-decomposition/wave-3-s-delivery-2026-10-01.md new file mode 100644 index 000000000..f858c2b0b --- /dev/null +++ b/docs/runtime-decomposition/wave-3-s-delivery-2026-10-01.md @@ -0,0 +1,256 @@ +# Wave 3-S delivery record + +Branch: `feature/runtime-containment`. The final production/delivery commit +contains namespace-init verification, this record and validation evidence; +its exact HEAD is in the delivery message. All commits are local. No push, +PR, merge into lab, branch switch, +reset, rebase, merge abort, cleanup, or other Odysseus worktree mutation occurred. + +## Reconciliation + +| Revision | Exact commit | +| --- | --- | +| Original containment head | `8e101fdcb8e775105bd4297298be580988bc7ad0` | +| Frozen integration lab | `1e3c50d2dd66484dd515c8caff3614e4ee9cea20` | +| Merge base | `d6c3c98c75e03f70c05ebe4058c6fa12e0395f62` | +| Reconciliation checkpoint | `083a573f7eab63d014331e669178cc367c22a2c8` | + +The checkpoint has exactly the original containment head and frozen lab as its +two parents. The in-progress merge was recovered, not restarted. Its only +unmerged path was `website/configuration-reference.md`. All three conflict +stages were inspected; regenerating the reference from the merged sources +preserved containment references and newer lab references together. + +Automatic merges of `src/agent_tools/subprocess_tools.py`, +`src/tool_execution.py`, and `tests/test_agent_bash_windows.py` preserved the +Windows Bash environment/cwd/capture contract and authority before dispatch. +The checkpoint also corrected two test assumptions: exact result equality after +adding containment metadata, and an approval-test database stub that needed to +be isolated to that test. Reconciliation validation passed 1,224 tests before +the merge was committed. + +RequestAuthority, SemanticIntent, ExactOperation, OperationGrant, TurnContract, +approval policy, and trusted/untrusted request boundaries were preserved. +Since reconciliation, `src/agent_runtime/authority.py`, `src/turn_contract.py`, +and `src/tool_approvals.py` have no changes. The edits to tool execution pass the +existing trusted environment into the contained background launcher and report +its refusal; authority evaluation and background authority sealing retain their +original ordering and owner. + +## Subsequent commits + +| Commit | Change | +| --- | --- | +| `5bb1326183306e8341d3ca1e6e6f31e4bf9cb0b3` | ODY-152: shared native execution, capture, persistence and teardown | +| `765d79cadf3113e973048ff2e04b0c51d64a88b6` | ODY-143: unconditional native Python containment | +| `f48931407a81bac138cd231d95b95ec0b326ad5b` | Correct the Python namespace test's outside-sibling fixture | +| `127f9b0836456cd95ac8fe4bd5a7ee0c238d8f0d` | ODY-145: contained detached Bash supervisor | +| `f63d333a61404656885be9546e5102f46c248b1c` | ODY-147: retire automatic tmux sessions and reap verified legacy sessions | +| `929987dde7920afb90f0590c24474ae3fa2b4e58` | ODY-150: replace pane capture with bounded, explicit output capture | +| `865968c8d5c0ff72c3faeeaa993705064dca33d9` | ODY-141 LAST: functional namespaces, readiness, cancellation and enforcement | +| `a655abf69839f5a83f14bd48675a9fb178a9b028` | Release and report a background supervisor's failed initialization | +| Commit containing this record | Verify namespace-init death, pin the probed binary, make completed release idempotent, and record final validation | + +## Item status + +| Item | Status and evidence | +| --- | --- | +| ODY-152 | Implemented. Native tools, detached jobs and compatibility callers use shared containment/teardown; transactional stores preserve concurrent job receipts. | +| ODY-143 | Implemented. Every native Python execution takes the shared boundary, independent of source content. Final-expression output and configured imports remain supported. | +| ODY-145 | Implemented. `#!bg` acquires the same required dimensions before supervisor launch; the supervisor receives the command only after durable ownership/job recording. | +| ODY-147 | Implemented. Chat IDs no longer create tmux shells. Legacy cleanup checks launcher, runtime HOME, session generation, server/pane lineage and start tokens. Ambiguous sessions remain unsignalled and reported. | +| ODY-150 | Implemented. Native Bash no longer reads a 2,000-line pane. A 3,002-line result is complete; actual byte/presentation truncation has metadata and a visible notice. | +| ODY-141 | Implemented last. Shipped mode is enforcing. Missing required dimensions or failed namespace initialization refuse execution deterministically. No tool/configuration host-access mode was introduced. | + +## Final containment architecture + +`agent_spec` fixes the required dimensions from trusted runtime configuration; +tool text cannot weaken them. `acquire` selects capabilities without examining +the command. Installed bubblewrap must pass a functional PID/mount namespace +probe. Launch uses the absolute trusted binary path, so the execution environment +cannot substitute a workspace binary through PATH. `run` checks the declared mechanism's dimensions again, establishes the +namespace, and consumes a private readiness receipt before acknowledging the +trusted wrapper and starting model code. Bind/setup failure cannot produce a +successful containment result. + +The shared bubblewrap recipe uses a private root, private PID namespace, private +`/proc` and devices, read-only system/interpreter mounts, private `/tmp`, and +writable workspace mounts. Extras are mounted before the workspace, so a +read-only ancestor cannot hide its writable workspace bind. Active Python +environments under `/home` are bound explicitly rather than assumed visible. +The compatibility namespace builder also uses this shared recipe. + +Spawn is shielded until its process handle is recovered. Timeout, initialization +failure, clean exit and cancellation converge on shared teardown. Repeated +cancellation cannot interrupt TERM, bounded wait, KILL and death verification. +Bubblewrap's separate info pipe records the namespace's PID 1 before model +execution starts. Linux held owners and namespace init use pidfds when available. +Release verifies death of both, including init's kernel cleanup of descendants +that used `setsid()` or double-fork/session escape. Outer-owner exit alone cannot +claim whole-tree death. The receipt retains a live/unverifiable init after failed +signals; recovered teardown validates its start identity before signalling it. +Completed release is idempotent and cannot signal a reused PID; a released grant +cannot execute again. The namespace target uses the same escalating teardown +primitive, not a second escalation implementation. + +Detached jobs run a trusted supervisor, not model code outside the boundary. +Its child executes through `containment.run`; completion metadata is published +before the exit receipt. Failed log initialization releases an unstarted grant +and still publishes failure metadata when those destinations are available. +An owned live supervisor remains responsible across server restart; killing a +job validates ownership and checks actual teardown before claiming it was killed. + +Process ownership compares PID plus start identity. Linux tokens now include +boot identity, preventing a receipt from matching the same start tick after a +reboot. Recovered teardown validates identity and the recorded PGID before +signals, including again before escalation. EPERM means unknown/live, never +verified death. A gone leader with a populated but unowned group is retained as +a failed cleanup rather than signalled. Foreign/unverifiable receipts remain +visible. JSON read/modify/write operations are serialized across processes. + +`src/path_confinement.py` remains the centralized canonical path boundary for +in-process tools. It was preserved rather than replaced by a second policy. + +## Explicit dimensions + +| Dimension | Native contract | +| --- | --- | +| Filesystem | Required. Functional mount namespace and the trusted workspace/mount recipe. No alias-rewrite fallback in shipped enforcement. | +| Process tree | Required. Private PID namespace and parent-death semantics. Process groups and Windows taskkill do **not** advertise this dimension. | +| Wall clock | Required. Startup/readiness, stdin backpressure and child waiting share the execution timeout; teardown then has bounded escalation waits. | +| Network | Inherited by default, explicitly reported, not isolated. Explicit `none` requests add a real network namespace or refuse at initialization. Loopback sidecars remain reachable by default. | +| Memory | Optional existing Linux RLIMIT_AS hook when the requested hard limit can be applied. No generic resource authority was added. | +| Process count | Optional existing RLIMIT_NPROC hook where supported and not root. This is a user-level limit, not a per-grant quota. | +| Output | Bounded bytes per stream, fully drained to avoid pipe deadlock; UTF-8 decoding spans chunks. Truncation is visible and reported. Presentation caps also carry a notice. | + +## Production and test inventory + +Production changes after the reconciliation checkpoint: + +```text +core/atomic_io.py +core/platform_compat.py +src/agent_tools/bg_job_tools.py +src/agent_tools/subprocess_tools.py +src/bg_jobs.py +src/containment.py +src/containment_worker.py +src/process_ownership.py +src/process_reaper.py +src/tool_execution.py +website/configuration-reference.md +``` + +Tests changed or added after reconciliation: + +```text +tests/containment_helpers.py +tests/test_agent_bash_tmux_env.py +tests/test_agent_bash_windows.py +tests/test_agent_tmux_retirement.py +tests/test_background_containment.py +tests/test_bg_job_tools.py +tests/test_containment_contract.py +tests/test_containment_enforcement.py +tests/test_containment_process_tree.py +tests/test_execution_filesystem_boundary.py +tests/test_native_execution_containment.py +tests/test_orphan_reaping.py +tests/test_process_ownership.py +tests/test_workspace_artifact_tool_floor.py +tests/test_workspace_confine.py +``` + +The reconciliation commit additionally imports the frozen lab's production/test +changes, including its authority and PTY changes; these are distinct from the +Wave 3-S edits above. `git diff --name-only +8e101fdcb8e775105bd4297298be580988bc7ad0 +083a573f7eab63d014331e669178cc367c22a2c8` gives that exact inventory. +The only additional test edits made while reconciling were the Windows result +assertion and `tests/test_tool_approvals.py`'s isolated stub. + +## Validation + +| Check | Result | +| --- | --- | +| Reconciliation overlap | 1,224 passed | +| ODY-152 focused | 193 passed, 2 skipped | +| ODY-143 focused, corrected sibling fixture | 186 passed | +| ODY-145 focused | 205 passed, 1 skipped | +| ODY-147 focused, including private real tmux server | 71 passed | +| ODY-150 focused | 64 passed | +| ODY-141 focused | 306 passed, 1 skipped | +| Final containment/path/background/authority/PTY/Windows overlap | 657 passed, 2 skipped | +| Supervisor follow-up plus containment/authority/bridge/PTY/Windows tests | 426 passed, 1 skipped | +| Namespace-init ownership/teardown follow-up | 626 passed, 2 skipped | +| Final delivery containment/background/authority/turn-contract/PTY/Windows overlap | 1,608 passed, 2 skipped | +| Full Python suite, single completed run | 11,727 passed; 118 failed; 8 errors; 68 skipped; 2 xfailed; 6 subtests passed; 182 warnings | +| Exact failed/error nodes after environment repair | All 126 passed; 4 deprecation warnings | +| `compileall app.py core routes src tests` | Passed, including final production revision | +| JS/MJS syntax | Not applicable: no JS/MJS changed from the original containment head; affected browser tests were exercised by targeted recovery. | +| Whitespace, conflict markers and unmerged paths | Checked at reconciliation and delivery; no remaining conflict markers or unmerged paths. Captured log trailing whitespace normalized for the final diff check. | + +Counts overlap and must not be summed. The initial system-Python full attempt +stopped at collection with 16 missing-dependency errors and ran no tests. It is +preserved as `validation/wave-3-s-full-collection.txt`. An isolated ignored +`.venv` with system packages was created in this worktree. Missing test/runtime +dependencies from `requirements.txt` were installed there; `npm ci` used the +existing lockfile in this worktree. No package manifest or lockfile was changed. + +The completed full run is preserved as `validation/wave-3-s-full.txt`; it was +**not green**. Its failures included missing bcrypt/calendar/cron/PDF-rendering +dependencies, import mocks following failed ORM pre-import, and absent Node +test packages. Repairing those dependencies and executing exactly its 126 +failed/error node IDs produced 126 passes. The full suite was not repeated, in +accordance with the one-run instruction. This proves targeted recovery, not a +new all-green full run in the repaired environment. The final supervisor and +namespace-init fixes were validated by focused follow-ups after that full run. + +Focused commands and summaries are retained under `validation/wave-3-s-*`. +Real tests cover private PID namespaces, a hidden host sibling, sidecar +connectivity, explicit network isolation or deterministic refusal, escaped +session death on timeout and clean parent exit, startup failure, stdin closure, +cancellation during spawn, repeated cancellation during escalation, denied +namespace-init signals after owner death, recovered/reused init identities, +idempotent release, the old PATH substitution and its pinned-path fix, concurrent +job recording, server restart ownership, verified legacy tmux cleanup and +output above 2,000 lines. Existing request-authority and #44/#45 regression +tests passed in the overlap runs. + +## Limits, concerns and independent review + +No unresolved P0/P1 was observed in the tested Wave 3-S native execution paths. +The implementation and focused Wave 3-S validation are complete. The original +full-run failure result remains part of the delivery evidence. + +Platform support is deliberately truthful. Native required containment refuses +on macOS/Windows without a suitable mechanism and on Docker/Linux where +bubblewrap is missing or namespace creation is blocked. Windows Bash contract +tests used platform simulation; no real Windows/macOS machine was validated. +Installing bubblewrap alone does not establish Docker namespace support. +Network egress/LAN access remains inherited by default. Existing externally +owned Wave 2 bridges are not attested as locally contained by this work. + +P2 follow-up concerns: independently validate the entire suite in the repaired +environment/CI; adversarially review identity/token and PGID races in recovered +or legacy processes that lack a retained kernel handle; inspect migration of +older identity receipts and ambiguous legacy sessions. Token granularity remains +finite (Linux clock ticks, macOS seconds); boot identity removes cross-boot +matches, not every inspection-to-signal race. Failed/unverifiable receipts are +kept visible rather than expired as if teardown succeeded. Remote bridge +containment claims require an independent assessment of the remote owner. + +Maestrum was used for bounded read review. An earlier audit identified the +functional namespace, session escape and cancellation gaps that were verified +and addressed. Its suggestion to signal a group after losing leader identity +was rejected; retaining uncertain receipts is deliberate. Its store-lock claim +did not account for the current transactional writer decorators. The final +review of `865968c8d5c0ff72c3faeeaa993705064dca33d9` failed before any worker ran +because Maestrum placement selected an unrecognized model. The current +orchestrate-work skill assigns placement/retries to Maestrum and directs failed +work to targeted local inspection; no native worker fallback was used. Final +independent adversarial review remains outstanding, especially for detached +supervisor cancellation and recovered ownership under hostile timing. + +Work stops at Wave 3-S. No subsequent authority, provenance/egress, browser, +generic lifecycle or decomposition wave was started. diff --git a/src/containment.py b/src/containment.py index 64997ee5c..4df650fe2 100644 --- a/src/containment.py +++ b/src/containment.py @@ -215,6 +215,8 @@ class ContainmentGrant: #: it outlives the leader's pid: the leader can exit while the processes it #: backgrounded keep running in the same group. pgid: Optional[int] = None + namespace_pid: Optional[int] = None + namespace_start_token: Optional[str] = None @property def contained(self) -> bool: @@ -476,6 +478,8 @@ def _write_record(grant: ContainmentGrant) -> None: "external": grant.external, "pid": grant.pid, "pgid": grant.pgid, + "namespace_pid": grant.namespace_pid, + "namespace_start_token": grant.namespace_start_token, "acquired_at": time.time(), "released_at": None, "release": None, @@ -827,8 +831,11 @@ def _bwrap_prefix(spec: ContainmentSpec) -> list[str]: namespace was for. Anything a command legitimately needs outside the workspace is named by the spec, as ``readonly_extra`` or ``writable_extra``. """ + executable = shutil.which("bwrap") + if not executable: + raise ContainmentUnavailable(spec.required, "bubblewrap") args = [ - "bwrap", "--die-with-parent", "--new-session", "--unshare-pid", + os.path.abspath(executable), "--die-with-parent", "--new-session", "--unshare-pid", "--tmpfs", "/", "--dir", "/usr", "--ro-bind", "/usr", "/usr", "--symlink", "usr/bin", "/bin", @@ -890,7 +897,7 @@ def _rlimit_preexec(grant: ContainmentGrant) -> Optional[Callable[[], None]]: def _launch_argv(grant: ContainmentGrant, command: Any, *, argv: bool, - ready_marker: Optional[str] = None) -> list[str]: + ready_marker: Optional[str] = None, info_fd: Optional[int] = None) -> list[str]: spec = grant.spec if argv: parts = [str(part) for part in command] @@ -920,7 +927,8 @@ def _launch_argv(grant: ContainmentGrant, command: Any, *, argv: bool, 'printf "%s\\n" "$1"; IFS= read -r ody_ack || exit 125; ' '[ "$ody_ack" = "$1" ] || exit 125; shift; exec "$@"', "ody-boundary", ready_marker, *parts] - return _bwrap_prefix(spec) + parts + info_args = ["--info-fd", str(info_fd)] if info_fd is not None else [] + return _bwrap_prefix(spec) + info_args + parts return parts @@ -1003,6 +1011,8 @@ async def run( "containment: an external-bridge grant describes execution this " "backend does not own; it cannot be run locally" ) + if (_load_records().get(grant.id) or {}).get("released_at"): + raise ValueError("containment: a released grant cannot execute again") missing = frozenset(grant.spec.required) - frozenset(grant.enforced) mechanism = next((item for item in MECHANISMS if item.name == grant.mechanism), None) provided = mechanism.provides(grant.spec) if mechanism is not None else frozenset() @@ -1013,8 +1023,15 @@ async def run( spec = grant.spec marker = uuid.uuid4().hex if grant.mechanism == "bubblewrap" else None + info_read = info_write = None try: - launch = _launch_argv(grant, command, argv=argv, ready_marker=marker) + if marker is not None: + info_read, info_write = os.pipe() + os.set_blocking(info_read, False) + launch = _launch_argv(grant, command, argv=argv, ready_marker=marker, info_fd=info_write) + spawn_kwargs = _spawn_kwargs(grant) + if info_write is not None: + spawn_kwargs["pass_fds"] = (info_write,) spawning = asyncio.create_task(asyncio.create_subprocess_exec( *launch, stdin=asyncio.subprocess.PIPE if stdin is not None or marker is not None else asyncio.subprocess.DEVNULL, @@ -1022,7 +1039,7 @@ async def run( stderr=asyncio.subprocess.PIPE, cwd=spec.workspace, env=dict(spec.env), - **_spawn_kwargs(grant), + **spawn_kwargs, )) try: proc = await asyncio.shield(spawning) @@ -1034,6 +1051,10 @@ async def run( except Exception: release(grant, grace_s=0) else: + if info_write is not None: + os.close(info_write) + info_write = None + proc._ody_info_read = info_read live = replace(grant, pid=proc.pid, pgid=None if IS_WINDOWS else proc.pid) await _complete_cleanup(_release_awaited(live, proc), propagate_cancel=False) raise @@ -1041,6 +1062,12 @@ async def run( if "proc" not in locals(): release(grant, grace_s=0) raise + finally: + if info_write is not None: + os.close(info_write) + if "proc" not in locals() and info_read is not None: + os.close(info_read) + proc._ody_info_read = info_read # start_new_session makes the child its own group leader, so the group id # is the child's pid. Captured here rather than at teardown: once the leader # exits, getpgid can no longer tell us which group its children are in. @@ -1069,7 +1096,7 @@ async def run( ready = marker is None execution_started = marker is None async def _wait() -> None: - nonlocal ready, execution_started + nonlocal ready, execution_started, live if marker is not None: expected = (marker + "\n").encode("ascii") try: @@ -1078,9 +1105,14 @@ async def run( receipt = b"" if receipt != expected: raise ContainmentUnavailable(spec.required, grant.mechanism) + await _capture_namespace_identity(proc) + live = replace(live, namespace_pid=proc._ody_namespace_pid, + namespace_start_token=proc._ody_namespace_token) # The trusted child is waiting for acknowledgment, so model code # cannot exit/recycle the leader before we record its identity. _update_record(grant.id, start_token=process_ownership.capture(proc.pid)["start_token"]) + _update_record(grant.id, namespace_pid=live.namespace_pid, + namespace_start_token=live.namespace_start_token) if hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"): try: proc._ody_pidfd = os.pidfd_open(proc.pid) @@ -1135,9 +1167,7 @@ async def run( out_budget[0] = -1 task.cancel() await asyncio.gather(task, return_exceptions=True) - pidfd = getattr(proc, "_ody_pidfd", None) - if pidfd is not None: - os.close(pidfd) + _close_process_handles(proc) try: try: await asyncio.wait_for(_wait(), timeout=spec.wall_clock_s) @@ -1184,6 +1214,41 @@ async def _complete_cleanup(awaitable, *, propagate_cancel: bool = True): return result +async def _capture_namespace_identity(proc) -> None: + """Read bwrap's trusted init identity before acknowledging model execution.""" + fd = getattr(proc, "_ody_info_read", None) + if fd is None: + return + data = bytearray() + try: + while True: + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + await asyncio.sleep(.01) + continue + if not chunk: + break + data.extend(chunk) + if len(data) > 4096: + raise ValueError("oversized namespace identity") + info = json.loads(data) + pid = int(info["child-pid"]) + if pid <= 0 or pid == os.getpid(): + raise ValueError("invalid namespace init identity") + proc._ody_namespace_pid = pid + proc._ody_namespace_token = process_ownership.capture(pid)["start_token"] + if hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"): + proc._ody_namespace_pidfd = os.pidfd_open(pid) + elif not proc._ody_namespace_token: + raise ValueError("namespace init identity cannot be inspected") + except (OSError, ValueError, KeyError, TypeError) as exc: + raise ContainmentUnavailable(frozenset({PROCESS_TREE}), "bubblewrap") from exc + finally: + os.close(fd) + proc._ody_info_read = None + + # ── release ───────────────────────────────────────────────────────────────── # core.platform_compat.kill_process_tree delegates here as well. Native tools, # detached jobs and compatibility callers share escalation and death probes. @@ -1368,6 +1433,65 @@ def _ownership_gate( def release(grant: ContainmentGrant, *, grace_s: float = 2.0, start_token: Optional[str] = None, require_identity: bool = False) -> ReleaseOutcome: + """Release owner and recorded namespace init; report death only for both.""" + record = _load_records().get(grant.id, {}) + previous = record.get("release") or {} + if record.get("released_at") and previous.get("dead"): + # Death belongs to the completed grant, not the current occupant of a + # reused PID slot. Repeated release must never signal it again. + return ReleaseOutcome(dead=True, escalated=bool(previous.get("escalated")), + mechanism=previous.get("mechanism", grant.mechanism), + ownership=previous.get("ownership")) + namespace_pid = grant.namespace_pid or record.get("namespace_pid") + namespace_token = grant.namespace_start_token or record.get("namespace_start_token") + owner = _release_owner(grant, grace_s=grace_s, start_token=start_token, + require_identity=require_identity, _record_release=False) + outcome = owner + if namespace_pid: + try: + namespace_pid = int(namespace_pid) + if namespace_pid <= 0 or namespace_pid == os.getpid(): + raise ValueError("invalid namespace init") + except (TypeError, ValueError): + namespace = ReleaseOutcome(dead=False, escalated=False, + ownership=process_ownership.UNVERIFIABLE) + else: + verdict = process_ownership.verify(namespace_pid, namespace_token) if pid_alive(namespace_pid) else process_ownership.GONE + if verdict in (process_ownership.GONE, process_ownership.FOREIGN): + # Reusing PID 1's host slot proves its original namespace has + # completed death; never signal its new occupant. + namespace = ReleaseOutcome(dead=True, escalated=False, ownership=verdict) + else: + target = replace(grant, id=grant.id + ":namespace", mechanism="process_group", + pid=namespace_pid, pgid=None, namespace_pid=None, + namespace_start_token=None) + namespace_fd = None + try: + if hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"): + try: + namespace_fd = os.pidfd_open(namespace_pid) + except OSError: + pass + namespace = _release_owner(target, grace_s=grace_s, start_token=namespace_token, + require_identity=True, _record_release=False, + _pidfd=namespace_fd) + finally: + if namespace_fd is not None: + os.close(namespace_fd) + outcome = replace(owner, dead=owner.dead and namespace.dead, + escalated=owner.escalated or namespace.escalated, + survivors=tuple(dict.fromkeys((*owner.survivors, *namespace.survivors)))) + elif grant.mechanism == "bubblewrap" and record.get("execution_started"): + # A pre-upgrade receipt lacks proof of namespace completion. Keep it + # visible rather than declaring a potentially blocked tree dead. + outcome = replace(owner, dead=False, ownership=process_ownership.UNVERIFIABLE) + _finish_release(grant, outcome) + return outcome + + +def _release_owner(grant: ContainmentGrant, *, grace_s: float = 2.0, + start_token: Optional[str] = None, require_identity: bool = False, + _record_release: bool = True, _pidfd: Optional[int] = None) -> ReleaseOutcome: """Authoritative teardown: signal the group, escalate, then verify. Returns whether the tree is **observed** gone. A caller must not record a @@ -1380,6 +1504,10 @@ def release(grant: ContainmentGrant, *, grace_s: float = 2.0, a zombie still belongs to its process group, so the group probe would otherwise report a tree that is already gone. """ + def finish(target, outcome): + if _record_release: + _finish_release(target, outcome) + pid, pgid = grant.pid, grant.pgid # A grant that carries its own pid belongs to the process holding it: this # caller launched the child and no identity question arises. A grant whose @@ -1405,16 +1533,28 @@ def release(grant: ContainmentGrant, *, grace_s: float = 2.0, if pgid is not None and pgid <= 0: pgid = None grant = replace(grant, pid=pid or None, pgid=pgid) + def gone(): + if _pidfd is not None: + return bool(select.select([_pidfd], [], [], 0)[0]) + return _tree_gone(pid, pgid, reap=True) + def send(sig): + if _pidfd is not None: + try: + signal.pidfd_send_signal(_pidfd, sig) + except OSError: + pass + else: + _signal_tree(pid, pgid, sig) if not pid and not _group_present(pgid): outcome = _outcome_for(grant, dead=True, escalated=False) - _finish_release(grant, outcome) + finish(grant, outcome) return outcome if recovered or require_identity: refusal = _ownership_gate(grant, pid, pgid, token) if refusal is not None: - _finish_release(grant, refusal) + finish(grant, refusal) return refusal if IS_WINDOWS: @@ -1431,36 +1571,36 @@ def release(grant: ContainmentGrant, *, grace_s: float = 2.0, while time.monotonic() < deadline and pid_alive(pid): time.sleep(_DEATH_POLL_S) outcome = _outcome_for(grant, dead=not pid_alive(pid), escalated=True) - _finish_release(grant, outcome) + finish(grant, outcome) return outcome - if _tree_gone(pid, pgid, reap=True): + if gone(): outcome = _outcome_for(grant, dead=True, escalated=False) - _finish_release(grant, outcome) + finish(grant, outcome) return outcome - _signal_tree(pid, pgid, signal.SIGTERM) + send(signal.SIGTERM) escalated = False deadline = time.monotonic() + max(grace_s, 0.0) - while time.monotonic() < deadline and not _tree_gone(pid, pgid, reap=True): + while time.monotonic() < deadline and not gone(): time.sleep(_DEATH_POLL_S) - if not _tree_gone(pid, pgid, reap=True): + if not gone(): escalated = True if recovered or require_identity: refusal = _ownership_gate(grant, pid, pgid, token) if refusal is not None: - _finish_release(grant, refusal) + finish(grant, refusal) return refusal - _signal_tree(pid, pgid, signal.SIGKILL) + send(signal.SIGKILL) # SIGKILL cannot be caught, so a short verification window is enough. # Anything still here is out of our reach — a zombie whose parent is # not us, or a pid we never owned. deadline = time.monotonic() + 1.0 - while time.monotonic() < deadline and not _tree_gone(pid, pgid, reap=True): + while time.monotonic() < deadline and not gone(): time.sleep(_DEATH_POLL_S) - outcome = _outcome_for(grant, dead=_tree_gone(pid, pgid, reap=True), escalated=escalated) - _finish_release(grant, outcome) + outcome = _outcome_for(grant, dead=gone(), escalated=escalated) + finish(grant, outcome) return outcome @@ -1506,6 +1646,25 @@ def reap_record(record: Mapping[str, Any], *, grace_s: float = 2.0) -> ReleaseOu async def _release_awaited( + grant: ContainmentGrant, + proc: "asyncio.subprocess.Process", + *, grace_s: float = 2.0, +) -> ReleaseOutcome: + try: + return await _release_awaited_impl(grant, proc, grace_s=grace_s) + finally: + _close_process_handles(proc) + + +def _close_process_handles(proc) -> None: + for name in ("_ody_info_read", "_ody_pidfd", "_ody_namespace_pidfd"): + fd = getattr(proc, name, None) + if fd is not None: + os.close(fd) + setattr(proc, name, None) + + +async def _release_awaited_impl( grant: ContainmentGrant, proc: "asyncio.subprocess.Process", *, @@ -1531,30 +1690,51 @@ async def _release_awaited( if IS_WINDOWS: return release(grant, grace_s=grace_s) + if getattr(proc, "_ody_info_read", None) is not None: + try: + await asyncio.wait_for(_capture_namespace_identity(proc), timeout=1) + except (ContainmentUnavailable, asyncio.TimeoutError): + pass # Setup never reached acknowledgment; no model code ran. pid, pgid = grant.pid, grant.pgid - if grant.mechanism == "bubblewrap" and proc.returncode is not None: - # Namespace-owner death already destroyed the namespace. Its numeric - # PID/PGID may now be reused; no further signal is necessary or safe. - await proc.wait() - outcome = _outcome_for(grant, dead=True, escalated=False) - _finish_release(grant, outcome) - return outcome pidfd = getattr(proc, "_ody_pidfd", None) + namespace_pid = getattr(proc, "_ody_namespace_pid", None) + namespace_token = getattr(proc, "_ody_namespace_token", None) + namespace_fd = getattr(proc, "_ody_namespace_pidfd", None) + if namespace_pid: + _update_record(grant.id, namespace_pid=namespace_pid, namespace_start_token=namespace_token) + def namespace_gone(): + if namespace_fd is not None: + return bool(select.select([namespace_fd], [], [], 0)[0]) + if namespace_pid: + if not pid_alive(namespace_pid): + return True + return process_ownership.verify(namespace_pid, namespace_token) in ( + process_ownership.GONE, process_ownership.FOREIGN, + ) + return True def gone(): if pidfd is not None: - return bool(select.select([pidfd], [], [], 0)[0]) - return _tree_gone(pid, pgid) + owner_gone = bool(select.select([pidfd], [], [], 0)[0]) + elif grant.mechanism == "bubblewrap": + owner_gone = proc.returncode is not None + else: + owner_gone = _tree_gone(pid, pgid) + return owner_gone and namespace_gone() def send(sig): if pidfd is not None: try: - # Killing the bwrap owner destroys its private PID namespace, - # including descendants that changed session/group. A pidfd - # keeps a recycled numeric PID out of the signal path. signal.pidfd_send_signal(pidfd, sig) except OSError: pass - else: + elif proc.returncode is None or grant.mechanism != "bubblewrap": _signal_tree(pid, pgid, sig) + if namespace_fd is not None: + try: + signal.pidfd_send_signal(namespace_fd, sig) + except OSError: + pass + elif namespace_pid and process_ownership.verify(namespace_pid, namespace_token) == process_ownership.OWNED: + _signal_tree(namespace_pid, None, sig) send(signal.SIGTERM) try: await asyncio.wait_for(proc.wait(), timeout=max(grace_s, 0.05)) @@ -1577,6 +1757,8 @@ async def _release_awaited( await asyncio.sleep(_DEATH_POLL_S) outcome = _outcome_for(grant, dead=gone(), escalated=escalated) + if not namespace_gone(): + outcome = replace(outcome, survivors=tuple(dict.fromkeys((*outcome.survivors, namespace_pid)))) _finish_release(grant, outcome) return outcome @@ -1588,4 +1770,4 @@ def _finish_release(grant: ContainmentGrant, outcome: ReleaseOutcome) -> None: # Deliberately NOT released: the record stays active so a reaper sees it # again. A record claiming teardown it did not achieve is the defect # this reverses. - _update_record(grant.id, release=outcome.to_dict()) + _update_record(grant.id, released_at=None, release=outcome.to_dict()) diff --git a/tests/containment_helpers.py b/tests/containment_helpers.py index 78f6f07ef..e784e032d 100644 --- a/tests/containment_helpers.py +++ b/tests/containment_helpers.py @@ -1,5 +1,7 @@ """Captured spawns exercise the production runner without signalling fake PIDs.""" import asyncio +import json +import os from types import SimpleNamespace from src import containment @@ -13,6 +15,9 @@ def capture_owned_spawn(monkeypatch, tmp_path): async def fake_exec(*argv, **kwargs): captured.update(argv=argv, kwargs=kwargs, command=argv[-1]) + if "--info-fd" in argv: + fd = int(argv[argv.index("--info-fd") + 1]) + os.write(fd, json.dumps({"child-pid": 99999998}).encode()) stdout = asyncio.StreamReader() if "ody-boundary" in argv: stdout.feed_data((argv[argv.index("ody-boundary") + 1] + "\n").encode()) @@ -33,6 +38,9 @@ def capture_owned_spawn(monkeypatch, tmp_path): return containment.ReleaseOutcome(dead=True, escalated=False) monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + real_capture = containment.process_ownership.capture + monkeypatch.setattr(containment.process_ownership, "capture", lambda pid: + {"pid": pid, "start_token": "captured-fake"} if pid in (99999998, 99999999) else real_capture(pid)) if hasattr(containment.os, "pidfd_open"): monkeypatch.delattr(containment.os, "pidfd_open") monkeypatch.setattr(containment, "_release_awaited", release) diff --git a/tests/test_containment_enforcement.py b/tests/test_containment_enforcement.py index e569442fa..2fbeaa349 100644 --- a/tests/test_containment_enforcement.py +++ b/tests/test_containment_enforcement.py @@ -79,7 +79,11 @@ async def test_fully_overclaimed_group_grant_cannot_spawn(workspace, monkeypatch await containment.run(forged, "echo forbidden") +@pytest.mark.skipif(os.name == "nt", reason="POSIX namespace recipe") def test_home_interpreter_is_bound_read_only(workspace, monkeypatch): + original = containment.shutil.which + monkeypatch.setattr(containment.shutil, "which", lambda name: + "/usr/bin/bwrap" if name == "bwrap" else original(name)) monkeypatch.setattr(sys, "prefix", "/home/test/venv") spec = subprocess_tools._owned_spec(str(workspace), {}, 5) assert "/home/test/venv" in spec.readonly_extra @@ -111,6 +115,76 @@ async def test_actual_namespace_hides_host_pid_tree_and_sibling(namespaces): assert result["teardown"]["dead"] is True +@pytest.mark.skipif(not hasattr(os, "pidfd_open"), reason="Linux kernel handles") +async def test_dead_owner_cannot_hide_live_namespace_init(workspace, monkeypatch): + from types import SimpleNamespace + child = await asyncio.create_subprocess_exec(sys.executable, "-c", "import time; time.sleep(60)", + start_new_session=True) + spec = containment.ContainmentSpec(str(workspace), dict(os.environ), 5, required={containment.WALL_CLOCK}) + grant = containment.acquire(spec, owner="init-life") + token = containment.process_ownership.start_token(child.pid) + live = replace(grant, mechanism="bubblewrap", pid=99999999, pgid=99999999) + async def wait(): + return 0 + owner = SimpleNamespace(returncode=0, wait=wait, _ody_namespace_pid=child.pid, + _ody_namespace_token=token, _ody_namespace_pidfd=os.pidfd_open(child.pid)) + def denied(*args): + raise PermissionError("EPERM") + monkeypatch.setattr(containment.signal, "pidfd_send_signal", denied) + try: + result = await containment._release_awaited(live, owner, grace_s=0) + assert result.dead is False + assert child.pid in result.survivors + assert child.returncode is None + record = containment.active_grants()[0] + assert record["release"]["dead"] is False + assert record["released_at"] is None + finally: + child.kill() + await child.wait() + containment.release(live, grace_s=0) + + +@pytest.mark.skipif(not hasattr(os, "pidfd_open"), reason="Linux kernel handles") +@pytest.mark.parametrize("foreign", [False, True]) +async def test_recovered_namespace_init_identity_survives_owner_exit(workspace, foreign): + child = await asyncio.create_subprocess_exec(sys.executable, "-c", "import time; time.sleep(60)", + start_new_session=True) + spec = containment.ContainmentSpec(str(workspace), dict(os.environ), 5, required={containment.WALL_CLOCK}) + grant = containment.acquire(spec, owner="recovered-init") + token = "different-boot" if foreign else containment.process_ownership.start_token(child.pid) + containment._update_record(grant.id, pid=99999999, pgid=99999999, start_token="old-owner", + namespace_pid=child.pid, namespace_start_token=token, execution_started=True) + try: + result = containment.reap_record(containment.active_grants()[0], grace_s=0) + assert result.dead is True + if foreign: + assert child.returncode is None # Never signal a reused namespace PID. + else: + await asyncio.wait_for(child.wait(), 3) + assert child.returncode is not None + assert containment.active_grants() == [] + finally: + if child.returncode is None: + child.kill() + await child.wait() + + +async def test_repeated_release_does_not_signal_reused_pid(workspace, monkeypatch): + spec = containment.ContainmentSpec(str(workspace), dict(os.environ), 5, required={containment.WALL_CLOCK}) + grant = containment.acquire(spec, owner="completed") + assert containment.release(grant).dead + def forbidden(*args): + pytest.fail("completed grant signalled a reused slot") + monkeypatch.setattr(containment, "_signal_tree", forbidden) + assert containment.release(replace(grant, pid=12345678, pgid=12345678)).dead + async def forbidden_spawn(*args, **kwargs): + pytest.fail("released grant spawned another process") + monkeypatch.setattr(asyncio, "create_subprocess_exec", forbidden_spawn) + with pytest.raises(ValueError, match="released grant"): + await containment.run(grant, "echo forbidden") + + async def test_default_namespace_preserves_loopback_sidecars(namespaces): async def reply(reader, writer): writer.write(b"sidecar\n") @@ -137,6 +211,35 @@ async def test_namespace_handshake_closes_model_stdin(namespaces): assert result["teardown"]["dead"] is True +@pytest.mark.parametrize("legacy_name", [True, False]) +async def test_execution_environment_cannot_replace_probed_bwrap(namespaces, monkeypatch, legacy_name): + bin_dir = namespaces / "bin" + bin_dir.mkdir() + outside = namespaces.parent / "uncontained-effect" + impostor = bin_dir / "bwrap" + import shlex + impostor.write_text("#!/bin/sh\nprintf escaped > " + shlex.quote(str(outside)) + "\n") + impostor.chmod(0o700) + if legacy_name: + original = containment._bwrap_prefix + def bare_name(spec): + argv = original(spec) + argv[0] = "bwrap" + return argv + monkeypatch.setattr(containment, "_bwrap_prefix", bare_name) + result = await subprocess_tools.BashTool().execute("printf contained", { + "subproc_env": {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ.get("PATH", "")}, + }) + if legacy_name: + # Reproduce the old mismatch: availability probed the host binary, + # while launch resolved a different binary through the child's PATH. + assert "containment unavailable" in result["error"] + assert outside.read_text() == "escaped" + else: + assert result["output"] == "contained", result + assert not outside.exists() + + async def test_partial_initialization_reaps_before_model_code_starts(namespaces, monkeypatch): from src.agent_runtime import journal effect = namespaces / "must-not-exist" diff --git a/tests/test_native_execution_containment.py b/tests/test_native_execution_containment.py index 7a195bf80..ecbc69dcd 100644 --- a/tests/test_native_execution_containment.py +++ b/tests/test_native_execution_containment.py @@ -108,15 +108,19 @@ async def test_blocked_stdin_is_inside_wall_clock(native_boundary): @pytest.mark.parametrize("source", ["print(1 + 1)", "import os; print(os.getcwd())", "exec('print(2)')", "print('/workspace')"]) +@pytest.mark.skipif(os.name == "nt", reason="POSIX namespace argv; Windows refusal tested separately") async def test_python_namespace_is_independent_of_content(source, native_boundary, monkeypatch): from tests.containment_helpers import capture_owned_spawn captured = capture_owned_spawn(monkeypatch, native_boundary) monkeypatch.setattr(containment, "MECHANISMS", (containment.Mechanism( "bubblewrap", 30, lambda: True, lambda spec: containment.DEFAULT_REQUIRED, ),)) + original_which = containment.shutil.which + monkeypatch.setattr(containment.shutil, "which", lambda name: + "/usr/bin/bwrap" if name == "bwrap" else original_which(name)) monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_ENFORCING) result = await subprocess_tools.PythonTool().execute(source, {}) - assert captured["argv"][0] == "bwrap" + assert os.path.basename(captured["argv"][0]) == "bwrap" assert "--bind" in captured["argv"] assert result["containment"]["enforced"] == sorted(containment.DEFAULT_REQUIRED) assert "-I" in captured["argv"]