activegraph 1.6.0__tar.gz → 1.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. {activegraph-1.6.0/activegraph.egg-info → activegraph-1.7.1}/PKG-INFO +1 -1
  2. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/__init__.py +1 -1
  3. activegraph-1.7.1/activegraph/sandbox/__init__.py +502 -0
  4. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/sandbox/_child.py +96 -11
  5. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/retention.py +55 -3
  6. {activegraph-1.6.0 → activegraph-1.7.1/activegraph.egg-info}/PKG-INFO +1 -1
  7. {activegraph-1.6.0 → activegraph-1.7.1}/pyproject.toml +1 -1
  8. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_compaction.py +35 -0
  9. activegraph-1.7.1/tests/test_sandbox_trial.py +730 -0
  10. activegraph-1.6.0/activegraph/sandbox/__init__.py +0 -257
  11. activegraph-1.6.0/tests/test_sandbox_trial.py +0 -367
  12. {activegraph-1.6.0 → activegraph-1.7.1}/LICENSE +0 -0
  13. {activegraph-1.6.0 → activegraph-1.7.1}/NOTICE +0 -0
  14. {activegraph-1.6.0 → activegraph-1.7.1}/README.md +0 -0
  15. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/__main__.py +0 -0
  16. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/_signature.py +0 -0
  17. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/behaviors/__init__.py +0 -0
  18. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/behaviors/base.py +0 -0
  19. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/behaviors/decorators.py +0 -0
  20. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/cli/__init__.py +0 -0
  21. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/cli/main.py +0 -0
  22. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/cli/quickstart.py +0 -0
  23. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/cli/renderers.py +0 -0
  24. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/__init__.py +0 -0
  25. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/clock.py +0 -0
  26. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/event.py +0 -0
  27. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/graph.py +0 -0
  28. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/graph_store.py +0 -0
  29. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/ids.py +0 -0
  30. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/patch.py +0 -0
  31. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/view.py +0 -0
  32. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/errors.py +0 -0
  33. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/frame.py +0 -0
  34. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/__init__.py +0 -0
  35. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/anthropic.py +0 -0
  36. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/cache.py +0 -0
  37. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/embedding.py +0 -0
  38. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/errors.py +0 -0
  39. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/native.py +0 -0
  40. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/openai.py +0 -0
  41. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/parsing.py +0 -0
  42. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/prompt.py +0 -0
  43. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/provider.py +0 -0
  44. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/recorded.py +0 -0
  45. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/types.py +0 -0
  46. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/wire.py +0 -0
  47. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/__init__.py +0 -0
  48. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/logging.py +0 -0
  49. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/metrics.py +0 -0
  50. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/migration.py +0 -0
  51. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/otel.py +0 -0
  52. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/prometheus.py +0 -0
  53. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/status.py +0 -0
  54. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/__init__.py +0 -0
  55. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/__init__.py +0 -0
  56. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/behaviors.py +0 -0
  57. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/fixtures/__init__.py +0 -0
  58. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/fixtures/companies.py +0 -0
  59. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/object_types.py +0 -0
  60. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/prompts/document_researcher.md +0 -0
  61. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/prompts/memo_synthesizer.md +0 -0
  62. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/prompts/question_generator.md +0 -0
  63. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/prompts/risk_identifier.md +0 -0
  64. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/settings.py +0 -0
  65. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/tools.py +0 -0
  66. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/loader.py +0 -0
  67. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/manifest.py +0 -0
  68. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/scaffold.py +0 -0
  69. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/policy.py +0 -0
  70. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/__init__.py +0 -0
  71. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/_live.py +0 -0
  72. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/behavior_graph.py +0 -0
  73. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/budget.py +0 -0
  74. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/config_errors.py +0 -0
  75. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/diff.py +0 -0
  76. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/errors.py +0 -0
  77. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/exec_errors.py +0 -0
  78. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/patterns.py +0 -0
  79. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/promote.py +0 -0
  80. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/queue.py +0 -0
  81. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/registration_errors.py +0 -0
  82. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/registry.py +0 -0
  83. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/runtime.py +0 -0
  84. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/scheduler.py +0 -0
  85. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/view_builder.py +0 -0
  86. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/__init__.py +0 -0
  87. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/base.py +0 -0
  88. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/conformance.py +0 -0
  89. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/errors.py +0 -0
  90. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/falkordb.py +0 -0
  91. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/graph_conformance.py +0 -0
  92. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/memory.py +0 -0
  93. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/postgres.py +0 -0
  94. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/serde.py +0 -0
  95. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/sqlite.py +0 -0
  96. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/url.py +0 -0
  97. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/__init__.py +0 -0
  98. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/base.py +0 -0
  99. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/cache.py +0 -0
  100. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/context.py +0 -0
  101. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/decorators.py +0 -0
  102. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/errors.py +0 -0
  103. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/graph_query.py +0 -0
  104. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/recorded.py +0 -0
  105. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/web_fetch.py +0 -0
  106. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/trace/__init__.py +0 -0
  107. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/trace/causal.py +0 -0
  108. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/trace/printer.py +0 -0
  109. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/SOURCES.txt +0 -0
  110. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/dependency_links.txt +0 -0
  111. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/entry_points.txt +0 -0
  112. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/requires.txt +0 -0
  113. {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/top_level.txt +0 -0
  114. {activegraph-1.6.0 → activegraph-1.7.1}/setup.cfg +0 -0
  115. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_activate_after.py +0 -0
  116. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_causal_cross_tool.py +0 -0
  117. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_cli.py +0 -0
  118. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_cli_docs_flags.py +0 -0
  119. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_clock.py +0 -0
  120. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_diff.py +0 -0
  121. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_diligence_pack.py +0 -0
  122. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_diligence_with_tools.py +0 -0
  123. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_disable_pack.py +0 -0
  124. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_doc_links.py +0 -0
  125. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_doc_python_snippets.py +0 -0
  126. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_doc_site_reachable.py +0 -0
  127. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_embedding_provider.py +0 -0
  128. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_errors_format.py +0 -0
  129. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_event.py +0 -0
  130. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_falkordb_store.py +0 -0
  131. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_fork.py +0 -0
  132. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_graph.py +0 -0
  133. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_graph_store.py +0 -0
  134. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_ids.py +0 -0
  135. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_license.py +0 -0
  136. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_anthropic.py +0 -0
  137. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_behavior.py +0 -0
  138. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_budget.py +0 -0
  139. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_causal.py +0 -0
  140. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_claim_extraction.py +0 -0
  141. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_default_model.py +0 -0
  142. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_determinism.py +0 -0
  143. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_failure.py +0 -0
  144. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_native_structured_output.py +0 -0
  145. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_openai.py +0 -0
  146. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_prompt.py +0 -0
  147. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_provider_fixtures.py +0 -0
  148. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_replay.py +0 -0
  149. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_tool_loop.py +0 -0
  150. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_trace.py +0 -0
  151. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_trace_snapshot.py +0 -0
  152. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_types.py +0 -0
  153. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_wire.py +0 -0
  154. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llms_txt.py +0 -0
  155. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_manifest_warning_tier.py +0 -0
  156. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_migration.py +0 -0
  157. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_observability_logging.py +0 -0
  158. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_observability_metrics.py +0 -0
  159. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_operate_example.py +0 -0
  160. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pack_manifest.py +0 -0
  161. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pack_scaffold.py +0 -0
  162. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_packs.py +0 -0
  163. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_patch.py +0 -0
  164. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pattern_matcher.py +0 -0
  165. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pattern_parser.py +0 -0
  166. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pattern_subscriptions.py +0 -0
  167. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_persistence.py +0 -0
  168. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_postgres_store.py +0 -0
  169. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_promote.py +0 -0
  170. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_quickstart.py +0 -0
  171. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_quickstart_snapshot.py +0 -0
  172. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_reason_codes_docs.py +0 -0
  173. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_registration_validation.py +0 -0
  174. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_replay.py +0 -0
  175. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_replay_trace_snapshot.py +0 -0
  176. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_requeue_unfired.py +0 -0
  177. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_resume_example.py +0 -0
  178. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_runtime.py +0 -0
  179. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_runtime_status.py +0 -0
  180. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_serde.py +0 -0
  181. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_store_conformance.py +0 -0
  182. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_store_url.py +0 -0
  183. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_tool_replay.py +0 -0
  184. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_tool_trace_snapshot.py +0 -0
  185. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_tools.py +0 -0
  186. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_trace.py +0 -0
  187. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_trace_accessors.py +0 -0
  188. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_tutorial_snippets.py +0 -0
  189. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_v1_0_1_patches.py +0 -0
  190. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_v1_0_3_behavior_failed_ux.py +0 -0
  191. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_v1_0_3_tool_multiturn.py +0 -0
  192. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_version_sync.py +0 -0
  193. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_view.py +0 -0
  194. {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_wheel_completeness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activegraph
3
- Version: 1.6.0
3
+ Version: 1.7.1
4
4
  Summary: An event-sourced reactive graph runtime for long-running, auditable, agentic systems.
5
5
  Author: Active Graph contributors
6
6
  License-Expression: Apache-2.0
@@ -242,4 +242,4 @@ __all__ = [
242
242
  "tool",
243
243
  ]
244
244
 
245
- __version__ = "1.6.0"
245
+ __version__ = "1.7.1"
@@ -0,0 +1,502 @@
1
+ """Subprocess fork-trial isolation. CONTRACT v1.5 #1.
2
+
3
+ Implements ``trial-isolation-design.md``: candidate pack code trials
4
+ run in a **fresh interpreter**, against a fork of the parent run,
5
+ materialized from artifacts pinned by the bundle hash — so the parent
6
+ process is out of the blast radius of the T1 accident class (runaway
7
+ memory/CPU, corrupted in-process state) and the bytes trialed are the
8
+ bytes the proposal recorded.
9
+
10
+ The division of authority is the design's:
11
+
12
+ * **The parent forks.** The child receives only the fork's run id —
13
+ it never gets fork authority, and it appends only to that run.
14
+ * **The child is a fresh interpreter** (``sys.executable -m
15
+ activegraph.sandbox._child``), not ``os.fork()`` — no inherited
16
+ Python state, no shared clients. Environment is allow-list only.
17
+ * **Three independent nets**: ``resource.setrlimit`` (address
18
+ space + CPU, POSIX; degrade to the other two nets on Windows),
19
+ parent-side wall-clock kill, and the runtime's own event/LLM
20
+ budgets inside the child.
21
+ * **The store is the record.** The child's stdout tail is a signal;
22
+ the parent re-reads the fork run from the store and the store's
23
+ numbers win any disagreement.
24
+
25
+ TWO CHANNELS, KEPT SEPARATE (v1.7, the Replit soak fix):
26
+
27
+ * **The environment allow-list stays CLOSED.** Only ``PATH``,
28
+ ``HOME``, ``LANG`` plus an explicit ``env_passthrough`` cross the
29
+ boundary. No ambient parent env — secrets, API keys, platform
30
+ config like ``REPLIT_*`` — leaks into a child running hostile
31
+ candidate code. This is a security control and does not widen.
32
+ * **Code location is an EXPLICIT channel, not an ambient one.** A
33
+ child must be able to import ``activegraph`` (and its deps) on any
34
+ box where the *parent* could — but on some platforms (Replit,
35
+ Nix) the package is discoverable only via a platform env var the
36
+ allow-list correctly strips, so a naive child dies at import
37
+ before running. The parent therefore RESOLVES the ``sys.path``
38
+ entries that make its own code importable and passes them to the
39
+ child as a computed ``PYTHONPATH`` (see :func:`_child_env`).
40
+ ``PYTHONPATH`` is thus derived from the parent's resolved
41
+ ``sys.path``, never forwarded from ambient env — code locations
42
+ are not secrets, and making the child's discovery of its own code
43
+ an explicit input keeps the allow-list closed while fixing the
44
+ restricted-env break. Call :func:`preflight` once at boot to
45
+ verify a child can start under this env and fail loud if not.
46
+
47
+ HONEST LIMITS (the design's §5, restated where users will read it):
48
+ this is crash/state isolation, **not a security sandbox**. A fresh
49
+ interpreter with rlimits does not stop a malicious candidate from
50
+ opening sockets or reading the filesystem — syscall and network
51
+ confinement are host territory (containers, seccomp), and this module
52
+ does not imitate them. The child can also, in principle, open the
53
+ shared SQLite file directly and touch other runs; per-run
54
+ authorization inside one SQLite file is not something SQLite gives us
55
+ honestly, so that caveat is stated rather than solved. The evolution
56
+ pack's static gates remain the pre-execution filter.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import json
62
+ import logging
63
+ import os
64
+ import subprocess
65
+ import sys
66
+ from dataclasses import dataclass, field
67
+ from pathlib import Path
68
+ from typing import Optional
69
+
70
+ _log = logging.getLogger("activegraph.sandbox")
71
+
72
+
73
+ #: The closed outcome set. ``crashed`` is the implementation's one
74
+ #: addition over the design draft: a child that dies without a
75
+ #: parseable tail (segfault, os._exit, kill) is reported as what it
76
+ #: is, never guessed into a neater bucket.
77
+ TRIAL_OUTCOMES = (
78
+ "completed",
79
+ "scenario_failed",
80
+ "limits_exceeded",
81
+ "materialization_failed",
82
+ "crashed",
83
+ )
84
+
85
+ # Child exit codes, mirrored in _child.py. Anything else → crashed.
86
+ _EXIT_TO_OUTCOME = {
87
+ 0: "completed",
88
+ 30: "scenario_failed",
89
+ 40: "limits_exceeded",
90
+ 50: "materialization_failed",
91
+ }
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class PackSource:
96
+ """Where the child materializes the candidate pack from.
97
+
98
+ ``expected_bundle_hash`` is the external pin (the §4 walk WITH
99
+ ``manifest.toml``, per the v1.4 bundle-hash amendment) — the child
100
+ verifies it via ``activegraph.packs.manifest.verify_bundle_hash``
101
+ before importing anything, so the bytes trialed are the bytes the
102
+ proposal recorded. ``manifest_required=True`` additionally runs
103
+ ``load_manifest`` + ``verify_surface`` against the live ``Pack``,
104
+ making the trial child the first consumer of the full manifest
105
+ chain end-to-end.
106
+ """
107
+
108
+ root_dir: str
109
+ expected_bundle_hash: str = ""
110
+ manifest_required: bool = True
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class TrialLimits:
115
+ """The trial's resource nets. Zero/None disables a given net.
116
+
117
+ ``max_llm_calls=0`` (the default) means key-freedom is
118
+ STRUCTURAL: the child configures no LLM provider at all, so a
119
+ candidate with LLM behaviors fails loud at registration
120
+ (``MissingProviderError``) rather than reaching a network. A
121
+ positive cap is accepted and recorded for a future
122
+ provider-wiring seam; the v1 child never configures a live
123
+ provider either way. ``env_passthrough`` is the ONLY ambient parent
124
+ environment forwarded beyond the closed allow-list
125
+ (``PATH``/``HOME``/``LANG``) — the helper never forwards the
126
+ environment wholesale, so parent API keys don't leak into
127
+ candidate code by default. The child's ``PYTHONPATH`` is NOT an
128
+ ambient forward: it is computed from the parent's resolved
129
+ ``sys.path`` so the child can import its own code (see the module
130
+ docstring's "two channels").
131
+ """
132
+
133
+ wall_clock_seconds: float = 120.0
134
+ max_rss_bytes: Optional[int] = None
135
+ max_events: Optional[int] = 2_000
136
+ max_llm_calls: Optional[int] = 0
137
+ env_passthrough: tuple[str, ...] = ()
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class TrialReport:
142
+ """What a trial produced. Store-derived numbers, child-signaled shape.
143
+
144
+ ``events_appended`` and ``behavior_failures`` are re-read from the
145
+ fork's run in the store by the parent AFTER the child exits — the
146
+ stdout tail is a signal only, and any disagreement resolves in
147
+ favor of the store. Richer evidence (tracebacks, diffs) is read
148
+ from the fork's log directly: ``Runtime.load(store, run_id=
149
+ report.fork_run_id)`` then ``trace.failures()`` / ``diff()``.
150
+
151
+ ``warnings`` (v1.7.1) carries any resource net that DEGRADED on
152
+ this platform — most notably the memory cap (RLIMIT_AS) on macOS,
153
+ which the Darwin kernel refuses. A degraded net is announced, never
154
+ silently skipped: the same strings are also folded into ``detail``,
155
+ and the parent logs each one.
156
+ """
157
+
158
+ outcome: str
159
+ fork_run_id: str
160
+ events_appended: int
161
+ behavior_failures: int
162
+ detail: str
163
+ exit_code: Optional[int]
164
+ warnings: tuple[str, ...] = ()
165
+
166
+
167
+ class SandboxStartupError(RuntimeError):
168
+ """A trial child could not START under the sandbox env.
169
+
170
+ Raised by :func:`preflight` when the child fails before it can run
171
+ any trial — the common cause is the sandbox env being unable to
172
+ import ``activegraph`` on this box (the exact restricted-env break
173
+ the explicit package-path channel exists to prevent). The message
174
+ carries the child's stderr tail so the cause is never opaque.
175
+ """
176
+
177
+
178
+ def _child_code_paths() -> list[str]:
179
+ """The ``sys.path`` entries a child needs to import the parent's
180
+ code, resolved in the parent (v1.7 explicit package-path channel).
181
+
182
+ ``activegraph``'s own root comes first, resolved from
183
+ ``activegraph.__file__`` — this is what an editable install or a
184
+ platform that surfaces packages via a stripped env var (Replit's
185
+ ``REPLIT_PYTHONPATH``) would otherwise hide from the child. The
186
+ parent's real ``sys.path`` directories follow so third-party deps
187
+ resolve too. Non-directory entries (namespace-finder hooks, the
188
+ empty CWD entry, zip imports) are dropped: they either don't help
189
+ a fresh child or would import from its CWD, which we do not want.
190
+ """
191
+ import activegraph
192
+
193
+ ordered: list[str] = []
194
+ ag_root = str(Path(activegraph.__file__).resolve().parent.parent)
195
+ ordered.append(ag_root)
196
+ for entry in sys.path:
197
+ if entry and os.path.isdir(entry) and entry not in ordered:
198
+ ordered.append(entry)
199
+ return ordered
200
+
201
+
202
+ def _child_env(limits: TrialLimits) -> dict[str, str]:
203
+ """Build the child's environment: closed allow-list + the computed
204
+ ``PYTHONPATH`` code channel. See the module docstring's "two
205
+ channels".
206
+
207
+ The allow-list is ``PATH``/``HOME``/``LANG`` plus the caller's
208
+ explicit ``env_passthrough`` — no other ambient env crosses. Then
209
+ ``PYTHONPATH`` is set from :func:`_child_code_paths` (the parent's
210
+ resolved code locations), prepended before any ``PYTHONPATH`` the
211
+ caller opted to pass through, so the child imports the same code
212
+ the parent can regardless of platform package discovery.
213
+ """
214
+ env = {
215
+ key: os.environ[key]
216
+ for key in ("PATH", "HOME", "LANG")
217
+ if key in os.environ
218
+ }
219
+ for key in limits.env_passthrough:
220
+ if key in os.environ:
221
+ env[key] = os.environ[key]
222
+ channel = os.pathsep.join(_child_code_paths())
223
+ passed_through = env.get("PYTHONPATH", "")
224
+ env["PYTHONPATH"] = (
225
+ channel + os.pathsep + passed_through if passed_through else channel
226
+ )
227
+ return env
228
+
229
+
230
+ def _stderr_tail(stderr: str, *, max_lines: int = 20, max_chars: int = 1500) -> str:
231
+ """The last few lines of a child's stderr, capped — the actual
232
+ cause of a pre-``_report`` crash the DEVNULL default used to lose.
233
+ """
234
+ text = (stderr or "").strip()
235
+ if not text:
236
+ return ""
237
+ lines = text.splitlines()[-max_lines:]
238
+ tail = "\n".join(lines)
239
+ return tail[-max_chars:]
240
+
241
+
242
+ def _parse_report_tail(stdout: str) -> dict[str, object]:
243
+ """The child's last valid JSON report line, or ``{}``."""
244
+ for line in reversed((stdout or "").strip().splitlines()):
245
+ try:
246
+ parsed = json.loads(line)
247
+ except json.JSONDecodeError:
248
+ continue
249
+ if isinstance(parsed, dict) and (
250
+ "outcome" in parsed or "preflight" in parsed
251
+ ):
252
+ return parsed
253
+ return {}
254
+
255
+
256
+ def _run_child(
257
+ job: dict[str, object],
258
+ *,
259
+ env: dict[str, str],
260
+ wall_clock: float,
261
+ python_flags: tuple[str, ...] = (),
262
+ ) -> tuple[Optional[int], str, str, bool]:
263
+ """Spawn the trial child, feed it ``job`` on stdin, and return
264
+ ``(exit_code, stdout, stderr, timed_out)``.
265
+
266
+ Shared by :func:`run_forked_trial` and :func:`preflight`. stderr is
267
+ PIPED (never DEVNULL) so a pre-``_report`` crash's cause survives.
268
+ ``python_flags`` is an interpreter-flag seam used by tests to
269
+ reproduce a restricted env (``-S``); production passes none.
270
+ """
271
+ proc = subprocess.Popen(
272
+ [sys.executable, *python_flags, "-m", "activegraph.sandbox._child"],
273
+ stdin=subprocess.PIPE,
274
+ stdout=subprocess.PIPE,
275
+ stderr=subprocess.PIPE,
276
+ env=env,
277
+ text=True,
278
+ )
279
+ try:
280
+ stdout, stderr = proc.communicate(
281
+ input=json.dumps(job), timeout=wall_clock
282
+ )
283
+ return proc.returncode, stdout, stderr, False
284
+ except subprocess.TimeoutExpired:
285
+ proc.kill()
286
+ stdout, stderr = proc.communicate()
287
+ return proc.returncode, stdout, stderr, True
288
+
289
+
290
+ #: A representative memory cap the preflight probe applies when the
291
+ #: caller left ``max_rss_bytes`` unset — generous enough not to cap a
292
+ #: just-started child into a false ``MemoryError`` on Linux (a child
293
+ #: after importing activegraph sits near ~40 MiB of address space),
294
+ #: finite enough to actually exercise the ``setrlimit(RLIMIT_AS)`` call
295
+ #: that Darwin rejects. The point of the probe is to trip the platform
296
+ #: check, not to bound anything.
297
+ _PREFLIGHT_PROBE_RSS = 1024 * 2**20
298
+
299
+
300
+ def _limits_job_block(limits: TrialLimits, *, probe: bool) -> dict[str, object]:
301
+ """The ``limits`` sub-dict of a child job. ``probe=True`` fills an
302
+ unset ``max_rss_bytes`` with :data:`_PREFLIGHT_PROBE_RSS` so
303
+ preflight always exercises the RLIMIT_AS path."""
304
+ max_rss = limits.max_rss_bytes
305
+ if probe and max_rss is None:
306
+ max_rss = _PREFLIGHT_PROBE_RSS
307
+ return {
308
+ "max_rss_bytes": max_rss,
309
+ "max_events": limits.max_events,
310
+ "max_llm_calls": limits.max_llm_calls,
311
+ "cpu_seconds": int(limits.wall_clock_seconds) + 5,
312
+ }
313
+
314
+
315
+ def _preflight_with(
316
+ env: dict[str, str],
317
+ *,
318
+ limits: TrialLimits = TrialLimits(),
319
+ python_flags: tuple[str, ...] = (),
320
+ timeout: float = 30.0,
321
+ ) -> tuple[str, ...]:
322
+ """The preflight check against a specific ``env`` (the testable
323
+ seam under :func:`preflight`). Applies a representative limits block
324
+ so the resource-limit path is actually exercised. Returns any
325
+ degradation warnings (empty when every requested net applied);
326
+ raises :class:`SandboxStartupError` if a child cannot start and
327
+ report ``preflight: ok``.
328
+ """
329
+ job = {"preflight": True, "limits": _limits_job_block(limits, probe=True)}
330
+ exit_code, stdout, stderr, timed_out = _run_child(
331
+ job, env=env, wall_clock=timeout, python_flags=python_flags
332
+ )
333
+ tail = _parse_report_tail(stdout)
334
+ if not timed_out and exit_code == 0 and tail.get("preflight") == "ok":
335
+ return tuple(str(w) for w in (tail.get("warnings") or ()))
336
+ cause = _stderr_tail(stderr) or (
337
+ "child timed out" if timed_out else "no preflight tail on stdout"
338
+ )
339
+ raise SandboxStartupError(
340
+ f"trial child could not start (exit {exit_code}): {cause}"
341
+ )
342
+
343
+
344
+ def preflight(
345
+ *, limits: TrialLimits = TrialLimits(), timeout: float = 30.0
346
+ ) -> tuple[str, ...]:
347
+ """Verify a trial child can START under the sandbox env on this box.
348
+
349
+ Spawns the child with a null job that exercises the full startup
350
+ path a real trial hits — importing ``activegraph`` under the sandbox
351
+ env AND applying resource limits (v1.7.1: a go/no-go gate that
352
+ skipped limit application passed on macOS where every real trial
353
+ then crashed on RLIMIT_AS). No fork, no store, no candidate.
354
+
355
+ Returns a tuple of degradation warnings — empty when every net
356
+ applied cleanly, non-empty (and logged) when a net degraded on this
357
+ platform (e.g. the memory cap on macOS). Raises
358
+ :class:`SandboxStartupError` with the child's stderr tail if a child
359
+ cannot start at all. So on macOS this PASSES with a memory-net
360
+ warning rather than pass-then-crash; on a box that cannot import
361
+ activegraph it RAISES with the real cause. Consumers call it once at
362
+ boot to fail loud or learn what degraded before the first trial.
363
+
364
+ Pass the same ``limits`` a real trial will carry to probe that exact
365
+ configuration; the default probes with a representative memory cap.
366
+ """
367
+ warnings = _preflight_with(_child_env(limits), limits=limits, timeout=timeout)
368
+ for w in warnings:
369
+ _log.warning("sandbox preflight: %s", w)
370
+ return warnings
371
+
372
+
373
+ def run_forked_trial(
374
+ store_path: str,
375
+ *,
376
+ parent_run_id: str,
377
+ at_event: str,
378
+ pack_source: PackSource,
379
+ scenario: str = "",
380
+ limits: TrialLimits = TrialLimits(),
381
+ label: str = "trial",
382
+ extra_packs: tuple[PackSource, ...] = (),
383
+ ) -> TrialReport:
384
+ """Fork the parent at ``at_event`` and trial the candidate pack in
385
+ a fresh subprocess. CONTRACT v1.5 #1.
386
+
387
+ The fork is created HERE, in the parent process, with full
388
+ ``fork()`` semantics (lineage recorded, promote-block cut guard);
389
+ the child receives only the fork's run id. ``scenario`` is
390
+ ``"relative/path.py"`` or ``"relative/path.py::func"`` inside the
391
+ CANDIDATE's pack root — the function (default ``main``) is called
392
+ with the fork's ``Runtime`` and drives the trial; empty means
393
+ just ``run_until_idle()``. The scenario contract is
394
+ ``def main(rt): ...``.
395
+
396
+ **The default is candidate-only isolation**: the child loads
397
+ nothing but the candidate, so the trial exercises it apart from
398
+ every other pack's behaviors. ``extra_packs`` is the opt-in for
399
+ cross-pack interaction trials (CONTRACT v1.5 #1 addendum 1b):
400
+ each entry is materialized in the child exactly like the
401
+ candidate — bundle hash verified before import, manifest schema
402
+ + two-way surface check when required — and loaded, in order,
403
+ BEFORE the candidate. Any extra pack failing its pins is
404
+ ``materialization_failed`` for the whole trial; the scenario
405
+ still resolves inside the candidate's root only.
406
+
407
+ Deterministic and key-free by default (``max_llm_calls=0``, empty
408
+ environment pass-through). Returns a :class:`TrialReport`; never
409
+ raises for in-trial failures — those are outcomes. Raises only
410
+ for parent-side setup problems (bad store, bad fork point), with
411
+ the same errors ``Runtime.load`` / ``fork()`` raise.
412
+ """
413
+ from activegraph.runtime.runtime import Runtime
414
+
415
+ parent_rt = Runtime.load(store_path, run_id=parent_run_id, behaviors=[])
416
+ fork_rt = parent_rt.fork(at_event=at_event, label=label, behaviors=[])
417
+ fork_run_id = fork_rt.run_id
418
+ initial_events = len(fork_rt.graph.events)
419
+ del fork_rt # the child owns the fork from here
420
+
421
+ job = {
422
+ "store_path": store_path,
423
+ "fork_run_id": fork_run_id,
424
+ "initial_events": initial_events,
425
+ "pack_root": str(Path(pack_source.root_dir).resolve()),
426
+ "expected_bundle_hash": pack_source.expected_bundle_hash,
427
+ "manifest_required": pack_source.manifest_required,
428
+ "extra_packs": [
429
+ {
430
+ "pack_root": str(Path(p.root_dir).resolve()),
431
+ "expected_bundle_hash": p.expected_bundle_hash,
432
+ "manifest_required": p.manifest_required,
433
+ }
434
+ for p in extra_packs
435
+ ],
436
+ "scenario": scenario,
437
+ "limits": _limits_job_block(limits, probe=False),
438
+ }
439
+ exit_code, stdout, stderr, timed_out = _run_child(
440
+ job, env=_child_env(limits), wall_clock=limits.wall_clock_seconds
441
+ )
442
+ tail = _parse_report_tail(stdout)
443
+
444
+ if timed_out:
445
+ outcome = "limits_exceeded"
446
+ detail = (
447
+ f"wall clock exceeded {limits.wall_clock_seconds}s; child killed"
448
+ )
449
+ elif tail:
450
+ outcome = str(tail.get("outcome", "crashed"))
451
+ if outcome not in TRIAL_OUTCOMES:
452
+ outcome = "crashed"
453
+ detail = str(tail.get("detail", ""))
454
+ else:
455
+ outcome = _EXIT_TO_OUTCOME.get(exit_code or -1, "crashed")
456
+ if outcome == "completed":
457
+ # Exit 0 with no tail is itself suspicious; say so.
458
+ outcome = "crashed"
459
+ detail = f"child exited {exit_code} with no report tail"
460
+
461
+ # The pre-execution gate must never swallow its own failure cause:
462
+ # when the child crashed before it could _report (module-import
463
+ # failure, rlimit setup, anything pre-main), the actual exception
464
+ # is on the child's stderr, not in any tail. Fold it into detail so
465
+ # it reaches the caller's recorded gate_result (v1.7 fix 1).
466
+ if outcome == "crashed":
467
+ err_tail = _stderr_tail(stderr)
468
+ if err_tail:
469
+ detail = f"{detail}; child stderr:\n{err_tail}"
470
+
471
+ # Degraded resource nets (v1.7.1): announced, never silent. A memory
472
+ # cap the platform refused (RLIMIT_AS on macOS) is folded into detail
473
+ # and logged, so a trial that ran WITHOUT the memory net it asked for
474
+ # is loud rather than quietly unbounded.
475
+ warnings = tuple(str(w) for w in (tail.get("warnings") or ()))
476
+ if warnings:
477
+ joined = "; ".join(warnings)
478
+ detail = f"{detail} [degraded: {joined}]" if detail else f"[degraded: {joined}]"
479
+ for w in warnings:
480
+ _log.warning("sandbox trial %s: %s", fork_run_id, w)
481
+
482
+ # The store is the record: re-read the fork run for the numbers.
483
+ events_appended = 0
484
+ behavior_failures = 0
485
+ try:
486
+ fork_view = Runtime.load(store_path, run_id=fork_run_id, behaviors=[])
487
+ events_appended = max(
488
+ 0, len(fork_view.graph.events) - initial_events
489
+ )
490
+ behavior_failures = len(fork_view.trace.failures())
491
+ except Exception as e: # noqa: BLE001 — report, never mask the trial
492
+ detail = f"{detail} (store re-read failed: {e})".strip()
493
+
494
+ return TrialReport(
495
+ outcome=outcome,
496
+ fork_run_id=fork_run_id,
497
+ events_appended=events_appended,
498
+ behavior_failures=behavior_failures,
499
+ detail=detail,
500
+ exit_code=exit_code,
501
+ warnings=warnings,
502
+ )
@@ -22,6 +22,9 @@ from pathlib import Path
22
22
  from typing import Any, Callable, Optional
23
23
 
24
24
 
25
+ _LIMIT_WARNINGS: list[str] = []
26
+
27
+
25
28
  def _report(
26
29
  outcome: str,
27
30
  *,
@@ -39,6 +42,11 @@ def _report(
39
42
  "events_appended": events_appended,
40
43
  "behavior_failures": behavior_failures,
41
44
  "detail": detail[:500],
45
+ # Degraded-limit warnings (v1.7.1): a resource net that
46
+ # could not be applied on this platform is announced,
47
+ # never silently skipped. The parent folds these into
48
+ # TrialReport.warnings and TrialReport.detail.
49
+ "warnings": list(_LIMIT_WARNINGS),
42
50
  }
43
51
  ),
44
52
  flush=True,
@@ -46,27 +54,79 @@ def _report(
46
54
  raise SystemExit(exit_code)
47
55
 
48
56
 
49
- def _apply_rlimits(limits: dict[str, Any]) -> None:
50
- """POSIX resource caps; on platforms without ``resource`` the
51
- wall-clock and budget nets still hold (stated in the design)."""
57
+ def _apply_rlimits(limits: dict[str, Any]) -> list[str]:
58
+ """Apply POSIX resource caps portably, returning a warning for any
59
+ cap that could not be set on this platform.
60
+
61
+ RLIMIT_AS (the memory net) is settable on Linux but rejected by the
62
+ Darwin kernel (``ValueError: current limit exceeds maximum limit``
63
+ — Darwin does not support address-space limiting). Rather than
64
+ crash (a trial child that can't start is useless) or silently skip
65
+ (a memory net that quietly does nothing is worse than one that
66
+ announces it is off), an unsettable cap DEGRADES: we record a loud
67
+ warning and let the wall-clock kill and event budget — which are
68
+ unaffected — remain the active nets. We never RAISE a hard limit
69
+ (that needs privilege and is what triggered the Darwin crash): the
70
+ target is clamped to the existing hard limit, so the call only ever
71
+ lowers, and the strong soft==hard cap is preserved where the kernel
72
+ allows it. Returns the list of degradation warnings (empty when all
73
+ requested caps applied).
74
+ """
75
+ warnings: list[str] = []
52
76
  try:
53
77
  import resource
54
78
  except ImportError: # pragma: no cover — Windows
55
- return
79
+ if limits.get("max_rss_bytes"):
80
+ warnings.append(_mem_off("no `resource` module on this platform"))
81
+ return warnings
82
+
83
+ def _cap(kind: str, name: str, requested: int) -> None:
84
+ limit = getattr(resource, name)
85
+ _, hard = resource.getrlimit(limit)
86
+ target = (
87
+ requested
88
+ if hard == resource.RLIM_INFINITY
89
+ else min(requested, hard)
90
+ )
91
+ try:
92
+ resource.setrlimit(limit, (target, target))
93
+ except (ValueError, OSError) as e:
94
+ warnings.append(
95
+ _mem_off(f"{sys.platform}: {type(e).__name__}: {e}")
96
+ if kind == "memory"
97
+ else (
98
+ f"CPU cap (RLIMIT_CPU) could not be applied on "
99
+ f"{sys.platform}: {type(e).__name__}: {e}"
100
+ )
101
+ )
102
+
56
103
  max_rss = limits.get("max_rss_bytes")
57
104
  if max_rss:
58
- resource.setrlimit(resource.RLIMIT_AS, (max_rss, max_rss))
105
+ _cap("memory", "RLIMIT_AS", max_rss)
59
106
  cpu = limits.get("cpu_seconds")
60
107
  if cpu:
61
- resource.setrlimit(resource.RLIMIT_CPU, (cpu, cpu))
108
+ _cap("cpu", "RLIMIT_CPU", cpu)
109
+ return warnings
110
+
111
+
112
+ def _mem_off(reason: str) -> str:
113
+ return (
114
+ f"memory cap (RLIMIT_AS) could not be applied ({reason}); the "
115
+ f"memory net is OFF for this trial — the wall-clock kill and "
116
+ f"event budget still bound a runaway"
117
+ )
62
118
 
63
119
 
64
120
  def _materialize_pack(job: dict[str, Any]) -> Any:
65
- """Verify pins, then import the candidate and return its Pack.
121
+ """Verify pins, then import one pack source and return its Pack.
66
122
 
67
- Order is the design's §3: bundle hash BEFORE any import (the pin
68
- covers manifest.toml per the v1.4 amendment), then manifest schema,
69
- then import, then the two-way surface check against the live Pack.
123
+ ``job`` carries ``pack_root`` / ``expected_bundle_hash`` /
124
+ ``manifest_required`` the top-level job spec for the candidate,
125
+ or one ``extra_packs`` entry (v1.7 addendum 1b), which uses the
126
+ identical chain. Order is the design's §3: bundle hash BEFORE any
127
+ import (the pin covers manifest.toml per the v1.4 amendment),
128
+ then manifest schema, then import, then the two-way surface check
129
+ against the live Pack.
70
130
  """
71
131
  from activegraph.packs import Pack
72
132
  from activegraph.packs.manifest import (
@@ -133,9 +193,29 @@ def _resolve_scenario(
133
193
 
134
194
  def main() -> None:
135
195
  job = json.loads(sys.stdin.read())
196
+
197
+ # Apply resource limits BEFORE the preflight branch (v1.7.1): the
198
+ # limit-application path is exactly what varies by host (Darwin
199
+ # rejects RLIMIT_AS), so preflight must exercise it to be a real
200
+ # go/no-go gate — a null job that skipped it would pass on a box
201
+ # where every real trial then crashed. Degradation warnings are
202
+ # stashed for _report and echoed on the preflight tail.
203
+ _LIMIT_WARNINGS.extend(_apply_rlimits(job.get("limits", {})))
204
+
205
+ # Preflight (v1.7): a null job that proves the child could START —
206
+ # importing activegraph under the sandbox env succeeded (before this
207
+ # function ran) AND resource limits could be applied (just above).
208
+ # Echo the marker plus any degradation warnings and exit clean. No
209
+ # fork, no store, no candidate touched.
210
+ if job.get("preflight"):
211
+ print(
212
+ json.dumps({"preflight": "ok", "warnings": list(_LIMIT_WARNINGS)}),
213
+ flush=True,
214
+ )
215
+ raise SystemExit(0)
216
+
136
217
  fork_run_id = str(job["fork_run_id"])
137
218
  initial = int(job.get("initial_events", 0))
138
- _apply_rlimits(job.get("limits", {}))
139
219
 
140
220
  def counts(rt: Any) -> tuple[int, int]:
141
221
  return (
@@ -144,6 +224,9 @@ def main() -> None:
144
224
  )
145
225
 
146
226
  try:
227
+ # Extra packs first (addendum 1b): each pinned and verified
228
+ # exactly like the candidate, loaded before it below.
229
+ extra = [_materialize_pack(p) for p in job.get("extra_packs", [])]
147
230
  pack = _materialize_pack(job)
148
231
  except Exception as e: # noqa: BLE001 — outcome, not crash
149
232
  _report(
@@ -174,6 +257,8 @@ def main() -> None:
174
257
  behaviors=[],
175
258
  budget=budget or None,
176
259
  )
260
+ for trusted in extra:
261
+ rt.load_pack(trusted)
177
262
  rt.load_pack(pack)
178
263
 
179
264
  try: