fix(runtime): verify namespace init death and record Wave 3-S validation

This commit is contained in:
Alexandre Teixeira
2026-10-01 23:17:01 +01:00
parent 0dec375631
commit 57143a14ba
7 changed files with 720 additions and 37 deletions
@@ -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"
]
@@ -0,0 +1,2 @@
added 4 packages in 560ms
@@ -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.
+218 -36
View File
@@ -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())
+8
View File
@@ -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)
+103
View File
@@ -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"
+5 -1
View File
@@ -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"]