mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-07 15:32:21 +02:00
fix(runtime): verify namespace init death and record Wave 3-S validation
This commit is contained in:
@@ -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
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user