control-tower 6.0.0__py3-none-any.whl

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 (446) hide show
  1. control_tower/__init__.py +3 -0
  2. control_tower/backends/__init__.py +31 -0
  3. control_tower/backends/fleet/__init__.py +1 -0
  4. control_tower/backends/fleet/ami.py +45 -0
  5. control_tower/backends/fleet/aws_identity.py +9 -0
  6. control_tower/backends/fleet/bootstrap.sh +349 -0
  7. control_tower/backends/fleet/config.py +40 -0
  8. control_tower/backends/fleet/controller.py +2058 -0
  9. control_tower/backends/fleet/job.py +325 -0
  10. control_tower/backends/fleet/run.py +399 -0
  11. control_tower/backends/fleet/run_job.py +270 -0
  12. control_tower/backends/fleet/worker.py +1045 -0
  13. control_tower/backends/fleet/worker_cli.py +68 -0
  14. control_tower/backends/hawk/__init__.py +7 -0
  15. control_tower/backends/hawk/config.py +114 -0
  16. control_tower/backends/hawk/download.py +93 -0
  17. control_tower/backends/hawk/middleman.py +64 -0
  18. control_tower/backends/hawk/model_closure.py +39 -0
  19. control_tower/backends/hawk/preflight.py +142 -0
  20. control_tower/backends/hawk/pull_envs.py +160 -0
  21. control_tower/backends/hawk/runner.py +1050 -0
  22. control_tower/backends/hawk/sandbox.py +561 -0
  23. control_tower/backends/hawk/yaml_generator.py +206 -0
  24. control_tower/backends/local.py +223 -0
  25. control_tower/cli/__init__.py +3 -0
  26. control_tower/cli/base.py +207 -0
  27. control_tower/cli/cloud.py +132 -0
  28. control_tower/cli/completions/completion_callbacks.py +125 -0
  29. control_tower/cli/completions/completions_build.py +59 -0
  30. control_tower/cli/completions/completions_hooks.py +209 -0
  31. control_tower/cli/extras.py +89 -0
  32. control_tower/cli/login.py +77 -0
  33. control_tower/cli/main.py +51 -0
  34. control_tower/cli/mcp_meta.py +30 -0
  35. control_tower/cli/protocols.py +140 -0
  36. control_tower/cli/run.py +893 -0
  37. control_tower/cli/task_sets.py +123 -0
  38. control_tower/datasets/__init__.py +0 -0
  39. control_tower/datasets/check.py +88 -0
  40. control_tower/datasets/cli.py +599 -0
  41. control_tower/datasets/external.py +675 -0
  42. control_tower/datasets/importers/__init__.py +47 -0
  43. control_tower/datasets/importers/eval_log.py +596 -0
  44. control_tower/datasets/importers/monitoring_bench.py +766 -0
  45. control_tower/datasets/importers/mythos_5_incident.py +241 -0
  46. control_tower/datasets/importers/sessions.py +159 -0
  47. control_tower/datasets/importers/shade_arena.py +588 -0
  48. control_tower/datasets/importers/sleight_bench.py +487 -0
  49. control_tower/datasets/model.py +45 -0
  50. control_tower/environments/build.py +191 -0
  51. control_tower/environments/env_args.py +98 -0
  52. control_tower/environments/environment.py +92 -0
  53. control_tower/environments/internet_simulator/.dockerignore +3 -0
  54. control_tower/environments/internet_simulator/Dockerfile +89 -0
  55. control_tower/environments/internet_simulator/__init__.py +0 -0
  56. control_tower/environments/internet_simulator/__main__.py +4 -0
  57. control_tower/environments/internet_simulator/compose.yml +31 -0
  58. control_tower/environments/internet_simulator/constants.py +53 -0
  59. control_tower/environments/internet_simulator/core/assets.py +304 -0
  60. control_tower/environments/internet_simulator/core/exposed_services.py +71 -0
  61. control_tower/environments/internet_simulator/core/generate_ca.py +68 -0
  62. control_tower/environments/internet_simulator/core/logs.py +212 -0
  63. control_tower/environments/internet_simulator/core/network_mapping.py +63 -0
  64. control_tower/environments/internet_simulator/core/tls.py +124 -0
  65. control_tower/environments/internet_simulator/core/trajectory.py +17 -0
  66. control_tower/environments/internet_simulator/core/webhook.py +195 -0
  67. control_tower/environments/internet_simulator/malware/malware.c +143 -0
  68. control_tower/environments/internet_simulator/modules/forward_proxy.py +431 -0
  69. control_tower/environments/internet_simulator/modules/provider_policy.py +390 -0
  70. control_tower/environments/internet_simulator/modules/response_rules.py +193 -0
  71. control_tower/environments/internet_simulator/modules/shell_sessions.py +206 -0
  72. control_tower/environments/internet_simulator/modules/upstream_proxy.py +289 -0
  73. control_tower/environments/internet_simulator/modules/upstream_specs.py +221 -0
  74. control_tower/environments/internet_simulator/personas.py +150 -0
  75. control_tower/environments/internet_simulator/requirements.txt +3 -0
  76. control_tower/environments/internet_simulator/resolve_override.c +75 -0
  77. control_tower/environments/internet_simulator/runtimes/__init__.py +0 -0
  78. control_tower/environments/internet_simulator/runtimes/docker.py +630 -0
  79. control_tower/environments/internet_simulator/runtimes/hawk.py +193 -0
  80. control_tower/environments/internet_simulator/runtimes/provisioning.py +39 -0
  81. control_tower/environments/internet_simulator/server.py +660 -0
  82. control_tower/environments/internet_simulator/source_hash.py +78 -0
  83. control_tower/environments/list_utils.py +482 -0
  84. control_tower/environments/registry.py +275 -0
  85. control_tower/environments/remote/__init__.py +0 -0
  86. control_tower/environments/remote/process.py +248 -0
  87. control_tower/environments/remote/remote.py +407 -0
  88. control_tower/environments/remote/sample.py +244 -0
  89. control_tower/environments/setting_runtime.py +176 -0
  90. control_tower/environments/settings.py +1170 -0
  91. control_tower/environments/settings.yml +26 -0
  92. control_tower/environments/settings_cli.py +610 -0
  93. control_tower/environments/validate.py +116 -0
  94. control_tower/eval/README.md +38 -0
  95. control_tower/eval/__init__.py +1 -0
  96. control_tower/eval/game/__init__.py +11 -0
  97. control_tower/eval/game/blue.py +570 -0
  98. control_tower/eval/game/harness.py +146 -0
  99. control_tower/eval/game/history.py +221 -0
  100. control_tower/eval/game/messages.py +75 -0
  101. control_tower/eval/game/red.py +346 -0
  102. control_tower/eval/game/registry.py +29 -0
  103. control_tower/eval/game/solver.py +214 -0
  104. control_tower/eval/game/view.py +114 -0
  105. control_tower/eval/game/white.py +37 -0
  106. control_tower/eval/launch/__init__.py +1 -0
  107. control_tower/eval/launch/assemble.py +416 -0
  108. control_tower/eval/launch/dataset.py +611 -0
  109. control_tower/eval/launch/dispatch.py +142 -0
  110. control_tower/eval/launch/inspect_tasks.py +248 -0
  111. control_tower/eval/launch/metadata.py +39 -0
  112. control_tower/eval/launch/naming.py +37 -0
  113. control_tower/eval/launch/options.py +40 -0
  114. control_tower/eval/launch/preflight.py +90 -0
  115. control_tower/eval/launch/selection.py +151 -0
  116. control_tower/eval/log/README.md +76 -0
  117. control_tower/eval/log/__init__.py +0 -0
  118. control_tower/eval/log/action.py +72 -0
  119. control_tower/eval/log/blue_calls.py +93 -0
  120. control_tower/eval/log/convert.py +1773 -0
  121. control_tower/eval/log/eval_trace_metadata.py +15 -0
  122. control_tower/eval/log/extended_thinking.py +56 -0
  123. control_tower/eval/log/format_history.py +35 -0
  124. control_tower/eval/log/from_eval_log.py +141 -0
  125. control_tower/eval/log/merge.py +87 -0
  126. control_tower/eval/log/monitor_scores.py +112 -0
  127. control_tower/eval/log/sample.py +403 -0
  128. control_tower/eval/log/task_args.py +23 -0
  129. control_tower/eval/log/task_scores.py +110 -0
  130. control_tower/eval/log/tool_calls.py +40 -0
  131. control_tower/eval/log/translation.py +342 -0
  132. control_tower/eval/log/utils.py +85 -0
  133. control_tower/eval/log/workspace_diff.py +16 -0
  134. control_tower/eval/monitoring/__init__.py +1 -0
  135. control_tower/eval/monitoring/posthoc.py +339 -0
  136. control_tower/eval/monitoring/scorer.py +409 -0
  137. control_tower/extras/__init__.py +0 -0
  138. control_tower/extras/claude_host.py +26 -0
  139. control_tower/extras/dashboard.py +269 -0
  140. control_tower/extras/dashboard_cli.py +19 -0
  141. control_tower/extras/env_description/__init__.py +0 -0
  142. control_tower/extras/env_description/audit/README.md +80 -0
  143. control_tower/extras/env_description/audit/__init__.py +0 -0
  144. control_tower/extras/env_description/audit/cli.py +81 -0
  145. control_tower/extras/env_description/audit/models.py +63 -0
  146. control_tower/extras/env_description/audit/prompts/system.md +68 -0
  147. control_tower/extras/env_description/audit/prompts/user.md +30 -0
  148. control_tower/extras/env_description/audit/task.py +278 -0
  149. control_tower/extras/env_description/generate/README.md +75 -0
  150. control_tower/extras/env_description/generate/cli.py +146 -0
  151. control_tower/extras/env_description/generate/prompts/system.md +62 -0
  152. control_tower/extras/env_description/generate/prompts/user.md +21 -0
  153. control_tower/extras/env_description/generate/task.py +359 -0
  154. control_tower/extras/env_description/shared/concurrency.py +72 -0
  155. control_tower/extras/env_description/shared/spec.md +79 -0
  156. control_tower/extras/env_description/shared/spec_loader.py +24 -0
  157. control_tower/extras/env_source.py +230 -0
  158. control_tower/extras/integration_test_quality/README.md +59 -0
  159. control_tower/extras/integration_test_quality/cli.py +115 -0
  160. control_tower/extras/integration_test_quality/prompts/analyze.md +44 -0
  161. control_tower/extras/integration_test_quality/prompts/check_tasks.md +29 -0
  162. control_tower/extras/integration_test_quality/prompts/coverage.md +27 -0
  163. control_tower/extras/integration_test_quality/prompts/fix.md +14 -0
  164. control_tower/extras/integration_test_quality/task.py +179 -0
  165. control_tower/extras/pairing_analysis/README.md +30 -0
  166. control_tower/extras/pairing_analysis/cli.py +82 -0
  167. control_tower/extras/pairing_analysis/prompts/pairing.md +43 -0
  168. control_tower/extras/pairing_analysis/task.py +367 -0
  169. control_tower/extras/prompts.py +28 -0
  170. control_tower/extras/scorer_idempotency/base.py +216 -0
  171. control_tower/extras/scorer_idempotency/checks/_json.py +31 -0
  172. control_tower/extras/scorer_idempotency/checks/chroma.py +285 -0
  173. control_tower/extras/scorer_idempotency/checks/docker.py +366 -0
  174. control_tower/extras/scorer_idempotency/checks/elasticsearch.py +293 -0
  175. control_tower/extras/scorer_idempotency/checks/filesystem.py +155 -0
  176. control_tower/extras/scorer_idempotency/checks/generated_manifest.json +13 -0
  177. control_tower/extras/scorer_idempotency/checks/influxdb.py +549 -0
  178. control_tower/extras/scorer_idempotency/checks/listener.py +81 -0
  179. control_tower/extras/scorer_idempotency/checks/minio.py +360 -0
  180. control_tower/extras/scorer_idempotency/checks/mongo.py +338 -0
  181. control_tower/extras/scorer_idempotency/checks/openldap.py +241 -0
  182. control_tower/extras/scorer_idempotency/checks/postgres.py +317 -0
  183. control_tower/extras/scorer_idempotency/checks/process.py +134 -0
  184. control_tower/extras/scorer_idempotency/checks/rabbitmq.py +369 -0
  185. control_tower/extras/scorer_idempotency/checks/redis.py +291 -0
  186. control_tower/extras/scorer_idempotency/checks/sqlite.py +309 -0
  187. control_tower/extras/scorer_idempotency/checks/sshd.py +270 -0
  188. control_tower/extras/scorer_idempotency/checks/vault.py +376 -0
  189. control_tower/extras/scorer_idempotency/cli.py +575 -0
  190. control_tower/extras/scorer_idempotency/compose_analyzer.py +182 -0
  191. control_tower/extras/scorer_idempotency/detection.py +236 -0
  192. control_tower/extras/scorer_idempotency/dockerfile_analyzer.py +377 -0
  193. control_tower/extras/scorer_idempotency/llm_generator.py +448 -0
  194. control_tower/extras/scorer_idempotency/runner.py +449 -0
  195. control_tower/extras/scorer_idempotency/runtime_prober.py +378 -0
  196. control_tower/extras/side_task_folder.py +115 -0
  197. control_tower/extras/storage.py +74 -0
  198. control_tower/extras/task_analysis/compare_side_tasks/README.md +25 -0
  199. control_tower/extras/task_analysis/compare_side_tasks/cli.py +25 -0
  200. control_tower/extras/task_analysis/compare_side_tasks/task.py +127 -0
  201. control_tower/extras/task_analysis/doc_audit/README.md +77 -0
  202. control_tower/extras/task_analysis/doc_audit/audits/base.py +129 -0
  203. control_tower/extras/task_analysis/doc_audit/audits/goal.py +92 -0
  204. control_tower/extras/task_analysis/doc_audit/audits/sd.py +98 -0
  205. control_tower/extras/task_analysis/doc_audit/cli.py +185 -0
  206. control_tower/extras/task_analysis/doc_audit/heuristics/base.py +53 -0
  207. control_tower/extras/task_analysis/doc_audit/heuristics/goal.py +215 -0
  208. control_tower/extras/task_analysis/doc_audit/heuristics/sd.py +222 -0
  209. control_tower/extras/task_analysis/doc_audit/prompts/goal/system.md +109 -0
  210. control_tower/extras/task_analysis/doc_audit/prompts/goal/user.md +21 -0
  211. control_tower/extras/task_analysis/doc_audit/prompts/sd/system.md +311 -0
  212. control_tower/extras/task_analysis/doc_audit/prompts/sd/user.md +26 -0
  213. control_tower/extras/task_analysis/doc_audit/sd_audit_labels.json +98 -0
  214. control_tower/extras/task_analysis/doc_audit/task.py +200 -0
  215. control_tower/extras/task_analysis/failure_type_audit/cli.py +125 -0
  216. control_tower/extras/task_analysis/failure_type_audit/prompts/system.md +9 -0
  217. control_tower/extras/task_analysis/failure_type_audit/prompts/user.md +23 -0
  218. control_tower/extras/task_analysis/failure_type_audit/task.py +190 -0
  219. control_tower/extras/task_analysis/goal_completeness/README.md +56 -0
  220. control_tower/extras/task_analysis/goal_completeness/cli.py +125 -0
  221. control_tower/extras/task_analysis/goal_completeness/prompts/system.md +44 -0
  222. control_tower/extras/task_analysis/goal_completeness/prompts/user.md +50 -0
  223. control_tower/extras/task_analysis/goal_completeness/task.py +320 -0
  224. control_tower/extras/task_analysis/goal_completeness/viewer.py +142 -0
  225. control_tower/extras/task_analysis/reward_hacking/README.md +46 -0
  226. control_tower/extras/task_analysis/reward_hacking/cli.py +115 -0
  227. control_tower/extras/task_analysis/reward_hacking/prompts/system.md +71 -0
  228. control_tower/extras/task_analysis/reward_hacking/prompts/user.md +62 -0
  229. control_tower/extras/task_analysis/reward_hacking/task.py +360 -0
  230. control_tower/extras/task_analysis/reward_hacking/viewer.py +122 -0
  231. control_tower/extras/task_analysis/rt_alignment/README.md +45 -0
  232. control_tower/extras/task_analysis/rt_alignment/cli.py +119 -0
  233. control_tower/extras/task_analysis/rt_alignment/prompts/system.md +50 -0
  234. control_tower/extras/task_analysis/rt_alignment/prompts/user.md +57 -0
  235. control_tower/extras/task_analysis/rt_alignment/task.py +357 -0
  236. control_tower/extras/task_analysis/rt_alignment/viewer.py +124 -0
  237. control_tower/extras/task_analysis/side_task_review/README.md +43 -0
  238. control_tower/extras/task_analysis/side_task_review/cli.py +87 -0
  239. control_tower/extras/task_analysis/side_task_review/prompts/review.md +30 -0
  240. control_tower/extras/task_analysis/side_task_review/task.py +98 -0
  241. control_tower/extras/task_analysis/validate_cover/README.md +32 -0
  242. control_tower/extras/task_analysis/validate_cover/cli.py +97 -0
  243. control_tower/extras/task_analysis/validate_cover/container_script.py +230 -0
  244. control_tower/extras/task_analysis/validate_cover/full_env_context.py +66 -0
  245. control_tower/extras/task_analysis/validate_cover/task.py +97 -0
  246. control_tower/extras/task_analysis/validate_cover/validate_cover_prompt.md +35 -0
  247. control_tower/extras/timing_report/__init__.py +17 -0
  248. control_tower/extras/timing_report/cli.py +71 -0
  249. control_tower/extras/timing_report/report.py +324 -0
  250. control_tower/extras/traj_analysis/submit_guessability/README.md +49 -0
  251. control_tower/extras/traj_analysis/submit_guessability/cli.py +125 -0
  252. control_tower/extras/traj_analysis/submit_guessability/prompts/system.md +29 -0
  253. control_tower/extras/traj_analysis/submit_guessability/prompts/user.md +70 -0
  254. control_tower/extras/traj_analysis/submit_guessability/task.py +351 -0
  255. control_tower/extras/traj_analysis/submit_guessability/viewer.py +159 -0
  256. control_tower/extras/traj_analysis/submit_guessability_verified/README.md +52 -0
  257. control_tower/extras/traj_analysis/submit_guessability_verified/cli.py +143 -0
  258. control_tower/extras/traj_analysis/submit_guessability_verified/prompts/system.md +36 -0
  259. control_tower/extras/traj_analysis/submit_guessability_verified/prompts/user.md +61 -0
  260. control_tower/extras/traj_analysis/submit_guessability_verified/task.py +673 -0
  261. control_tower/extras/traj_analysis/submit_guessability_verified/viewer.py +186 -0
  262. control_tower/live/__init__.py +0 -0
  263. control_tower/live/agents.py +117 -0
  264. control_tower/live/assets/dev_container_extensions.json +3 -0
  265. control_tower/live/assets/live.gitignore +53 -0
  266. control_tower/live/assets/pyrightconfig.json +4 -0
  267. control_tower/live/assets/settings.json +3 -0
  268. control_tower/live/cli.py +1133 -0
  269. control_tower/live/compose.py +117 -0
  270. control_tower/live/context/print_context.py +98 -0
  271. control_tower/live/context/task_getters.py +65 -0
  272. control_tower/live/context/traj_dir.py +334 -0
  273. control_tower/live/integration_tests.py +128 -0
  274. control_tower/live/sandbox_ops.py +134 -0
  275. control_tower/live/score.py +85 -0
  276. control_tower/live/session.py +513 -0
  277. control_tower/live/setup.py +294 -0
  278. control_tower/live/task_state.py +145 -0
  279. control_tower/live/traj/__init__.py +0 -0
  280. control_tower/live/traj/gen_traj.py +319 -0
  281. control_tower/live/traj/git_status.py +33 -0
  282. control_tower/live/traj/git_traj.py +735 -0
  283. control_tower/live/traj/git_traj_helpers/FORMAT.md +25 -0
  284. control_tower/live/traj/git_traj_helpers/call_insert.py +15 -0
  285. control_tower/live/traj/git_traj_helpers/call_str_replace.py +20 -0
  286. control_tower/live/traj/macros.py +100 -0
  287. control_tower/live/traj/migrate_traj.py +80 -0
  288. control_tower/live/traj/patch.py +62 -0
  289. control_tower/live/traj/solution.py +242 -0
  290. control_tower/live/traj/templ/add.py +14 -0
  291. control_tower/live/traj/templ/add_tool_call.py +85 -0
  292. control_tower/live/traj/templ/bash.py +127 -0
  293. control_tower/live/traj/templ/git_utils.py +93 -0
  294. control_tower/live/traj/templ/input_utils.py +145 -0
  295. control_tower/live/traj/templ/template_from.py +118 -0
  296. control_tower/live/traj/templ/tool_call_builder.py +119 -0
  297. control_tower/live/traj/tool_call_utils.py +283 -0
  298. control_tower/live/traj/traj.py +294 -0
  299. control_tower/live/tui/__init__.py +0 -0
  300. control_tower/live/tui/popup.py +293 -0
  301. control_tower/live/tui/tui.py +1237 -0
  302. control_tower/live/tui/tui.tcss +32 -0
  303. control_tower/metrics/__init__.py +0 -0
  304. control_tower/metrics/audit.py +357 -0
  305. control_tower/metrics/control.py +120 -0
  306. control_tower/models/__init__.py +3 -0
  307. control_tower/models/aliases.py +59 -0
  308. control_tower/models/cache_scopes.py +26 -0
  309. control_tower/models/inspect_rates.py +32 -0
  310. control_tower/models/openrouter_provider.py +65 -0
  311. control_tower/models/pricing.py +183 -0
  312. control_tower/models/rates.py +240 -0
  313. control_tower/models/role_model.py +179 -0
  314. control_tower/models/usage_hooks.py +14 -0
  315. control_tower/monitors/__init__.py +40 -0
  316. control_tower/monitors/_monitor.py +722 -0
  317. control_tower/monitors/checklist.py +95 -0
  318. control_tower/monitors/jev.py +217 -0
  319. control_tower/monitors/jev_questions.yml +32 -0
  320. control_tower/monitors/presets.yml +29 -0
  321. control_tower/monitors/prompts/action-goals.md +78 -0
  322. control_tower/monitors/prompts/action.md +70 -0
  323. control_tower/monitors/prompts/goal-analysis.md +72 -0
  324. control_tower/monitors/prompts/goals.md +76 -0
  325. control_tower/monitors/prompts/no-history.md +37 -0
  326. control_tower/monitors/prompts/off-task.md +86 -0
  327. control_tower/monitors/prompts/quick-goals.md +65 -0
  328. control_tower/monitors/prompts/quick.md +57 -0
  329. control_tower/monitors/prompts/simple.md +19 -0
  330. control_tower/monitors/prompts/strong-goals.md +87 -0
  331. control_tower/monitors/prompts/strong.md +79 -0
  332. control_tower/monitors/prompts/trajectory.md +16 -0
  333. control_tower/monitors/registry.py +18 -0
  334. control_tower/monitors/single_goal.py +67 -0
  335. control_tower/monitors/steps/checklist.md +38 -0
  336. control_tower/monitors/steps/single_goal.md +75 -0
  337. control_tower/monitors/steps/single_goal_analysis.md +71 -0
  338. control_tower/monitors/steps/three_scores.md +107 -0
  339. control_tower/monitors/three_scores.py +68 -0
  340. control_tower/policies/__init__.py +1 -0
  341. control_tower/policies/attack.py +185 -0
  342. control_tower/policies/honest.py +23 -0
  343. control_tower/policies/obvious_attack.py +67 -0
  344. control_tower/policies/prompts/attack.md +7 -0
  345. control_tower/policies/prompts/attack_planning.md +22 -0
  346. control_tower/policies/replay.py +201 -0
  347. control_tower/policies/scripted.py +115 -0
  348. control_tower/protocols/__init__.py +1 -0
  349. control_tower/protocols/defer_to_trusted.py +141 -0
  350. control_tower/protocols/null_blue.py +27 -0
  351. control_tower/protocols/prompts/trusted_editing.md +9 -0
  352. control_tower/protocols/resample.py +191 -0
  353. control_tower/protocols/trusted_editing.py +143 -0
  354. control_tower/protocols/trusted_monitoring.py +90 -0
  355. control_tower/protocols/watcher.py +352 -0
  356. control_tower/py.typed +0 -0
  357. control_tower/runs/README.md +93 -0
  358. control_tower/runs/__init__.py +0 -0
  359. control_tower/runs/convert.py +100 -0
  360. control_tower/runs/convert_eval_log_to_run.py +92 -0
  361. control_tower/runs/eval_log_io.py +200 -0
  362. control_tower/runs/io.py +190 -0
  363. control_tower/runs/models.py +64 -0
  364. control_tower/runs/mongo_docs.py +359 -0
  365. control_tower/runs/runs_cli.py +735 -0
  366. control_tower/runs/s3_upload.py +167 -0
  367. control_tower/runs/source.py +24 -0
  368. control_tower/runs/timing_utils.py +149 -0
  369. control_tower/runs/viewer/__init__.py +0 -0
  370. control_tower/runs/viewer/api.py +464 -0
  371. control_tower/runs/viewer/auth.py +50 -0
  372. control_tower/runs/viewer/io.py +204 -0
  373. control_tower/runs/viewer/models.py +25 -0
  374. control_tower/runs/viewer/upload.py +216 -0
  375. control_tower/runs/viewer/urls.py +501 -0
  376. control_tower/sandbox/__init__.py +6 -0
  377. control_tower/sandbox/_alpine_rootfs.py +230 -0
  378. control_tower/sandbox/_ssh.py +471 -0
  379. control_tower/sandbox/cli.py +236 -0
  380. control_tower/sandbox/constants.py +21 -0
  381. control_tower/sandbox/docker.py +685 -0
  382. control_tower/sandbox/docker_in_vm.py +527 -0
  383. control_tower/sandbox/firecracker.py +363 -0
  384. control_tower/sandbox/inspect_tools.py +46 -0
  385. control_tower/sandbox/qemu.py +251 -0
  386. control_tower/sandbox/replay/__init__.py +23 -0
  387. control_tower/sandbox/replay/environment.py +435 -0
  388. control_tower/sandbox/replay/score.py +37 -0
  389. control_tower/sandbox/simulated/__init__.py +25 -0
  390. control_tower/sandbox/simulated/actions.py +90 -0
  391. control_tower/sandbox/simulated/compose.py +192 -0
  392. control_tower/sandbox/simulated/environment.py +190 -0
  393. control_tower/sandbox/simulated/judge.py +158 -0
  394. control_tower/sandbox/simulated/prompts/judge.md +31 -0
  395. control_tower/sandbox/simulated/prompts/simulator.md +74 -0
  396. control_tower/sandbox/simulated/prompts/simulator_no_exec.md +22 -0
  397. control_tower/sandbox/simulated/scratch/Dockerfile +11 -0
  398. control_tower/sandbox/simulated/scratch/compose.yaml +42 -0
  399. control_tower/sandbox/simulated/session.py +155 -0
  400. control_tower/scanners.py +130 -0
  401. control_tower/task/__init__.py +0 -0
  402. control_tower/task/final_diff.py +88 -0
  403. control_tower/task/sample_metadata.py +46 -0
  404. control_tower/task/scorers.py +327 -0
  405. control_tower/task/select_utils.py +106 -0
  406. control_tower/task/selectors.py +49 -0
  407. control_tower/task/setup.py +115 -0
  408. control_tower/task/space.py +45 -0
  409. control_tower/task/task.py +230 -0
  410. control_tower/task/task_sets/__init__.py +203 -0
  411. control_tower/task/traj_dir.py +39 -0
  412. control_tower/task/workspace_baseline.py +581 -0
  413. control_tower/tools/__init__.py +0 -0
  414. control_tower/tools/answer.py +45 -0
  415. control_tower/tools/bash_tools.py +37 -0
  416. control_tower/tools/declarations.py +23 -0
  417. control_tower/tools/diff_display_text_editor.py +66 -0
  418. control_tower/tools/registry.py +34 -0
  419. control_tower/tools/split_text_editor_tools.py +125 -0
  420. control_tower/tools/task_tools.py +204 -0
  421. control_tower/tools/text_editor_output.py +53 -0
  422. control_tower/tools/tool_calls.py +44 -0
  423. control_tower/tools/tool_registry.py +363 -0
  424. control_tower/utils/__init__.py +0 -0
  425. control_tower/utils/cli_args.py +20 -0
  426. control_tower/utils/direct_api_generator.py +124 -0
  427. control_tower/utils/docs_utils.py +18 -0
  428. control_tower/utils/env.py +191 -0
  429. control_tower/utils/file_patterns.py +89 -0
  430. control_tower/utils/fs.py +137 -0
  431. control_tower/utils/function_registry/__init__.py +0 -0
  432. control_tower/utils/function_registry/base_model.py +30 -0
  433. control_tower/utils/function_registry/registry.py +535 -0
  434. control_tower/utils/function_registry/selection.py +68 -0
  435. control_tower/utils/paths.py +91 -0
  436. control_tower/utils/sanitize.py +29 -0
  437. control_tower/utils/suggestions.py +25 -0
  438. control_tower/utils/time.py +8 -0
  439. control_tower/utils/transcript.py +20 -0
  440. control_tower/utils/versioning.py +327 -0
  441. control_tower-6.0.0.dist-info/METADATA +91 -0
  442. control_tower-6.0.0.dist-info/RECORD +446 -0
  443. control_tower-6.0.0.dist-info/WHEEL +5 -0
  444. control_tower-6.0.0.dist-info/entry_points.txt +5 -0
  445. control_tower-6.0.0.dist-info/licenses/LICENSE +21 -0
  446. control_tower-6.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,685 @@
1
+ """Compose rewriting that shapes a Control Tower sample for Inspect's Docker sandbox."""
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import tempfile
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import yaml
15
+ from inspect_ai.util import SandboxEnvironmentSpec, is_dockerfile
16
+
17
+ from control_tower.environments.internet_simulator.constants import (
18
+ INTERNET_SIMULATOR_PROXY_SERVICE_NAME,
19
+ INTERNET_SIMULATOR_SERVICE_NAME,
20
+ )
21
+ from control_tower.environments.internet_simulator.runtimes.docker import (
22
+ EXPOSED_SERVICES_VOLUME,
23
+ SIMULATOR_EGRESS_NETWORK,
24
+ exposed_services_proxy_service,
25
+ simulator_service,
26
+ )
27
+ from control_tower.utils.env import EnvVar
28
+ from control_tower.utils.paths import cache_dir, find_repo_root
29
+ from control_tower.utils.versioning import (
30
+ REPO_DIGEST_FORMAT,
31
+ expand_compose_vars,
32
+ )
33
+ from ct_setting_sdk.env.constants import AGENT_DIR
34
+ from ct_setting_sdk.env.internet_simulator.config import InternetSimulatorConfig
35
+ from ct_setting_sdk.errors import StateError, ValidationError
36
+
37
+ if TYPE_CHECKING:
38
+ from control_tower.environments.remote.process import EnvProcess
39
+
40
+ logger: logging.Logger = logging.getLogger(__name__)
41
+
42
+
43
+ def compose_file(spec: SandboxEnvironmentSpec) -> Path | None:
44
+ """The compose file a ``docker`` spec names, or None for a Dockerfile or any other sandbox.
45
+
46
+ Raises:
47
+ ValidationError: The compose path is relative, so it would resolve against Control Tower's cwd.
48
+ """
49
+ config = spec.config
50
+ if spec.type != "docker" or not isinstance(config, str) or is_dockerfile(config):
51
+ return None
52
+ if not Path(config).is_absolute():
53
+ raise ValidationError(
54
+ f"The docker sandbox's compose path {config!r} must be absolute"
55
+ )
56
+ return Path(config)
57
+
58
+
59
+ def uses_published_images() -> bool:
60
+ """Whether ``CONTROL_TOWER_IMAGES`` is ``pull`` or ``offline``: run the
61
+ published env images by tag instead of building them locally (the fleet sets
62
+ ``pull`` on every worker)."""
63
+ return EnvVar().images != "build"
64
+
65
+
66
+ def _relative_path(value: object) -> bool:
67
+ return (
68
+ isinstance(value, str)
69
+ and not Path(value).is_absolute()
70
+ and not value.startswith(("$", "~", "git@"))
71
+ and "://" not in value
72
+ )
73
+
74
+
75
+ def absolutize_compose_paths(compose_data: dict[str, Any], base_dir: Path) -> None:
76
+ """Rewrite the relative host paths compose resolves against its file's directory to absolute ones."""
77
+
78
+ def absolute(value: str) -> str:
79
+ return str((base_dir / value).resolve())
80
+
81
+ for service in (compose_data.get("services") or {}).values():
82
+ if not isinstance(service, dict):
83
+ continue
84
+ build = service.get("build")
85
+ if _relative_path(build):
86
+ service["build"] = absolute(build)
87
+ elif isinstance(build, dict):
88
+ if _relative_path(build.get("context")):
89
+ build["context"] = absolute(build["context"])
90
+ contexts = build.get("additional_contexts")
91
+ for name, context in (
92
+ contexts if isinstance(contexts, dict) else {}
93
+ ).items():
94
+ if _relative_path(context) and not context.startswith("service:"):
95
+ contexts[name] = absolute(context)
96
+ volumes = service.get("volumes")
97
+ for index, volume in enumerate(volumes if isinstance(volumes, list) else []):
98
+ # Short-syntax sources are host paths only when they start with "."; bare names are named volumes.
99
+ if isinstance(volume, str) and volume.startswith("."):
100
+ source, _, rest = volume.partition(":")
101
+ volumes[index] = (
102
+ f"{absolute(source)}:{rest}" if rest else absolute(source)
103
+ )
104
+ elif (
105
+ isinstance(volume, dict)
106
+ and volume.get("type") == "bind"
107
+ and _relative_path(volume.get("source"))
108
+ ):
109
+ volume["source"] = absolute(volume["source"])
110
+ env_files = service.get("env_file")
111
+ if _relative_path(env_files):
112
+ service["env_file"] = absolute(env_files)
113
+ for index, entry in enumerate(env_files if isinstance(env_files, list) else []):
114
+ if _relative_path(entry):
115
+ env_files[index] = absolute(entry)
116
+ elif isinstance(entry, dict) and _relative_path(entry.get("path")):
117
+ entry["path"] = absolute(entry["path"])
118
+ extends = service.get("extends")
119
+ if isinstance(extends, dict) and _relative_path(extends.get("file")):
120
+ extends["file"] = absolute(extends["file"])
121
+ for section in ("configs", "secrets"):
122
+ for entry in (compose_data.get(section) or {}).values():
123
+ if isinstance(entry, dict) and _relative_path(entry.get("file")):
124
+ entry["file"] = absolute(entry["file"])
125
+
126
+
127
+ _temp_compose_sources: dict[Path, Path] = {}
128
+
129
+
130
+ def compose_source_dir(compose_path: Path) -> Path:
131
+ """The env directory a compose came from, following written temp composes back to their source."""
132
+ return _temp_compose_sources.get(compose_path, compose_path.parent)
133
+
134
+
135
+ def compose_cache_dir() -> Path:
136
+ """This checkout's directory for generated composes, so live orphan cleanup can tell its own apart."""
137
+ return cache_dir(
138
+ "compose", hashlib.md5(str(find_repo_root()).encode()).hexdigest()[:8]
139
+ )
140
+
141
+
142
+ def write_temp_compose(
143
+ compose_path: Path, tag: str, compose_data: dict[str, Any]
144
+ ) -> Path:
145
+ """Write a transformed compose into CT's cache, named by tag and source-path hash, with host paths
146
+ made absolute; replaced atomically since concurrent evals on one host share it."""
147
+ source_dir = compose_source_dir(compose_path)
148
+ absolutize_compose_paths(compose_data, source_dir)
149
+ path_hash = hashlib.md5(str(compose_path).encode()).hexdigest()[:8]
150
+ temp_path = env_compose_cache_dir(source_dir) / f"compose-{tag}-{path_hash}.yml"
151
+ fd, staged = tempfile.mkstemp(prefix=f"{temp_path.name}.", dir=temp_path.parent)
152
+ with os.fdopen(fd, "w") as f:
153
+ yaml.dump(compose_data, f, default_flow_style=False)
154
+ os.replace(staged, temp_path)
155
+ _temp_compose_sources[temp_path] = source_dir
156
+ return temp_path
157
+
158
+
159
+ def env_compose_cache_dir(source_dir: Path) -> Path:
160
+ """The cache folder for one env's generated composes."""
161
+ env_dir = (
162
+ compose_cache_dir() / hashlib.md5(str(source_dir).encode()).hexdigest()[:8]
163
+ )
164
+ env_dir.mkdir(exist_ok=True)
165
+ return env_dir
166
+
167
+
168
+ _MANIFEST_DIGEST_FORMAT = "{{json .Manifest.Digest}}"
169
+ _image_digests: dict[str, str | None] = {}
170
+ _image_digest_errors: dict[str, str] = {}
171
+
172
+
173
+ def resolve_image_digest(image_ref: str, timeout: int = 120) -> str | None:
174
+ """Return ``repo@sha256:...`` for a ``repo:tag`` reference, or ``None`` when
175
+ the image has no registry digest (local-only) or buildx is unavailable.
176
+ Under ``CONTROL_TOWER_IMAGES=offline`` a local image without a registry
177
+ digest resolves to ``image_ref`` itself.
178
+
179
+ Resolved once per reference per process; subsequent samples reuse the cache.
180
+
181
+ Args:
182
+ image_ref: The ``repo:tag`` reference to resolve.
183
+ timeout: Seconds to wait for the registry before giving up.
184
+
185
+ Raises:
186
+ StateError: ``CONTROL_TOWER_IMAGES`` is ``offline`` and the image is
187
+ not in the local Docker image store.
188
+ """
189
+ if "@sha256:" in image_ref:
190
+ return image_ref
191
+ if image_ref in _image_digests:
192
+ return _image_digests[image_ref]
193
+ if EnvVar().images == "offline":
194
+ inspected = subprocess.run(
195
+ ["docker", "image", "inspect", "--format", REPO_DIGEST_FORMAT, image_ref],
196
+ capture_output=True,
197
+ text=True,
198
+ timeout=timeout,
199
+ )
200
+ if inspected.returncode != 0:
201
+ raise StateError(
202
+ f"Image {image_ref} is not in the local Docker image store and "
203
+ "CONTROL_TOWER_IMAGES is offline; run `ct settings pull-images` with internet first."
204
+ )
205
+ local = inspected.stdout.strip()
206
+ _image_digests[image_ref] = local if "@sha256:" in local else image_ref
207
+ return _image_digests[image_ref]
208
+
209
+ name, _, tag = image_ref.rpartition(":")
210
+ if not name or "/" in tag:
211
+ _image_digests[image_ref] = None
212
+ return None
213
+
214
+ digest: str | None = None
215
+ error: list[str] = []
216
+ try:
217
+ result = subprocess.run(
218
+ [
219
+ "docker",
220
+ "buildx",
221
+ "imagetools",
222
+ "inspect",
223
+ image_ref,
224
+ "--format",
225
+ _MANIFEST_DIGEST_FORMAT,
226
+ ],
227
+ capture_output=True,
228
+ text=True,
229
+ timeout=timeout,
230
+ )
231
+ if result.returncode == 0:
232
+ sha = result.stdout.strip().strip('"')
233
+ if sha.startswith("sha256:"):
234
+ digest = f"{name}@{sha}"
235
+ else:
236
+ error = [f"inspect returned no manifest digest: {sha!r}"]
237
+ else:
238
+ error = result.stderr.strip().splitlines()
239
+ except (OSError, subprocess.SubprocessError) as exc:
240
+ digest = None
241
+ error = [str(exc)]
242
+
243
+ if digest is None:
244
+ _image_digest_errors[image_ref] = error[-1] if error else "no registry digest"
245
+ logger.warning(
246
+ "Could not resolve a registry digest for %s; leaving the tag in place%s",
247
+ image_ref,
248
+ f" ({error[-1]})" if error else "",
249
+ )
250
+ _image_digests[image_ref] = digest
251
+ return digest
252
+
253
+
254
+ def strip_build_sections(compose_path: Path, image_tag: str | None) -> Path:
255
+ """Remove 'build' sections and pin mutable image tags to digests.
256
+
257
+ Returns the path to the transformed compose. Each service ``image`` is
258
+ resolved to ``repo@sha256:...`` so per-sample cache reuse cannot serve a
259
+ stale ``latest``; images already digest-pinned or without a registry digest
260
+ keep their original reference. ``ENV_IMAGE_TAG_<ENV>`` reads ``image_tag``.
261
+
262
+ Services that build a single-segment image name (no registry or namespace,
263
+ e.g. BashArena's per-task ``bash-arena-default-<task_id>:latest``) keep
264
+ their ``build`` section: such names can only come from a local build —
265
+ ``docker.io/library/*`` is reserved for official images — so stripping the
266
+ build would leave an unpullable reference.
267
+ """
268
+ compose_data = yaml.safe_load(compose_path.read_text())
269
+
270
+ for service_config in (compose_data.get("services") or {}).values():
271
+ if not isinstance(service_config, dict):
272
+ continue
273
+ had_build_section = "build" in service_config
274
+ image = service_config.get("image")
275
+ if had_build_section and isinstance(image, str) and "/" not in image:
276
+ continue
277
+ service_config.pop("build", None)
278
+ if not isinstance(image, str):
279
+ continue
280
+ if had_build_section:
281
+ service_config["pull_policy"] = "missing"
282
+ tag_keys = re.findall(r"\$\{(ENV_IMAGE_TAG_\w+)", image)
283
+ image_ref = expand_compose_vars(
284
+ image, dict.fromkeys(tag_keys, image_tag) if image_tag else None
285
+ )
286
+ pinned = resolve_image_digest(image_ref)
287
+ if pinned is None and tag_keys and image_tag:
288
+ raise StateError(
289
+ f"Could not resolve the env image {image_ref}: "
290
+ f"{_image_digest_errors.get(image_ref, 'no registry digest')}. Set "
291
+ f"CONTROL_TOWER_IMAGES=build to build locally, or set {tag_keys[0]} to a published tag."
292
+ )
293
+ service_config["image"] = pinned or image_ref
294
+
295
+ return write_temp_compose(compose_path, "nobuild", compose_data)
296
+
297
+
298
+ # `$$` escapes; `${NAME}` and `${NAME:?err}` require a value, the default and alternate forms don't.
299
+ _HOST_READ = re.compile(r"\$\$|\$\{([A-Za-z_]\w*)(:?[-?+])?|\$([A-Za-z_]\w*)")
300
+ _NESTED_READ = re.compile(r"\$\{[A-Za-z_]\w*:?[-?+](?:\$\$|[^}$])*\$(?:\{|[A-Za-z_])")
301
+ # Compose fills COMPOSE_PROJECT_NAME itself; which image to pull is CT's choice, from its environment.
302
+ _NOT_SETTING_READ = re.compile(r"COMPOSE_PROJECT_NAME|ENV_IMAGE_(?:TAG|REPO)_\w+")
303
+
304
+
305
+ def compose_reads(
306
+ compose_path: Path, values: Mapping[str, str], values_file: Path
307
+ ) -> set[str]:
308
+ """Every setting variable the compose file reads from the host: a ``$NAME`` or ``${NAME...}``
309
+ anywhere, or a bare ``environment`` entry.
310
+
311
+ Raises:
312
+ ValidationError: The file reads a ``CT_*`` variable, which Control Tower reserves, or
313
+ nests a read in another's default.
314
+ StateError: A required variable has no value in ``values``, read from ``values_file``, or
315
+ the env still keeps a ``.env`` beside its compose file.
316
+ """
317
+ source = compose_source_dir(compose_path)
318
+ if (stale := source / ".env").is_file():
319
+ raise StateError(
320
+ f"{stale} is no longer read: move its values to {values_file}."
321
+ )
322
+ names: set[str] = set()
323
+ required: set[str] = set()
324
+
325
+ def scan(node: Any) -> None:
326
+ if isinstance(node, str):
327
+ if _NESTED_READ.search(node):
328
+ raise ValidationError(
329
+ f"The compose file in {source} nests a variable in another's default "
330
+ f"({node!r}), which Control Tower does not read."
331
+ )
332
+ for match in _HOST_READ.finditer(node):
333
+ if name := match[1] or match[3]:
334
+ names.add(name)
335
+ if match[2] is None or "?" in match[2]:
336
+ required.add(name)
337
+ elif isinstance(node, list):
338
+ for item in node:
339
+ scan(item)
340
+ elif isinstance(node, dict):
341
+ for item in node.values():
342
+ scan(item)
343
+
344
+ compose_data = yaml.safe_load(compose_path.read_text())
345
+ for service in (compose_data.get("services") or {}).values():
346
+ environment = (service or {}).get("environment") or {}
347
+ bare = (
348
+ {entry for entry in environment if "=" not in entry}
349
+ if isinstance(environment, list)
350
+ else {key for key, value in environment.items() if value is None}
351
+ )
352
+ names |= bare
353
+ required |= bare
354
+ scan(compose_data)
355
+ names = {name for name in names if not _NOT_SETTING_READ.fullmatch(name)}
356
+ if reserved := sorted(name for name in names if name.startswith("CT_")):
357
+ raise ValidationError(
358
+ f"The compose file in {source} reads {', '.join(reserved)}; Control Tower reserves "
359
+ "CT_* variables."
360
+ )
361
+ if missing := sorted((names & required) - values.keys()):
362
+ raise StateError(
363
+ f"No value for {', '.join(missing)}, which the compose file in {source} requires: "
364
+ f"put each in {values_file}."
365
+ )
366
+ return names
367
+
368
+
369
+ _compose_env_tokens: dict[tuple[tuple[str, str], ...], str] = {}
370
+
371
+
372
+ def patch_compose_for_env_values(
373
+ compose_path: Path, values: Mapping[str, str], values_file: Path
374
+ ) -> Path:
375
+ """Give every setting variable the compose file reads the value in ``values``,
376
+ through a ``CT_ENV_<token>_NAME`` copy in this process's environment, so no value is written
377
+ to disk and the host's own variables never reach the file.
378
+
379
+ Raises:
380
+ ValidationError: See ``compose_reads``.
381
+ StateError: See ``compose_reads``.
382
+ """
383
+ compose_data = yaml.safe_load(compose_path.read_text())
384
+ names = compose_reads(compose_path, values, values_file)
385
+ if not names:
386
+ return compose_path
387
+ values = {name: values[name] for name in names if name in values}
388
+ token = _compose_env_tokens.setdefault(
389
+ tuple(sorted(values.items())), str(len(_compose_env_tokens))
390
+ )
391
+ for name, value in values.items():
392
+ os.environ[f"CT_ENV_{token}_{name}"] = value
393
+
394
+ def point(match: re.Match[str]) -> str:
395
+ name = match[1] or match[3]
396
+ if name not in names:
397
+ return match[0]
398
+ return match[0].replace(name, f"CT_ENV_{token}_{name}", 1)
399
+
400
+ def rewrite(node: Any) -> Any:
401
+ if isinstance(node, str):
402
+ return _HOST_READ.sub(point, node)
403
+ if isinstance(node, list):
404
+ return [rewrite(item) for item in node]
405
+ if isinstance(node, dict):
406
+ return {key: rewrite(item) for key, item in node.items()}
407
+ return node
408
+
409
+ for service in compose_data["services"].values():
410
+ environment = (service or {}).get("environment")
411
+ if isinstance(environment, list):
412
+ environment = {
413
+ name: value if sep else None
414
+ for name, sep, value in (entry.partition("=") for entry in environment)
415
+ }
416
+ if environment:
417
+ service["environment"] = {
418
+ key: "${" + key + "}" if value is None and key in names else value
419
+ for key, value in environment.items()
420
+ }
421
+ return write_temp_compose(compose_path, f"env-{token}", rewrite(compose_data))
422
+
423
+
424
+ def patch_compose_for_no_internet(compose_path: Path) -> Path:
425
+ """Make every project network internal with an isolated gateway.
426
+
427
+ Docker enforces this from the host, so root in the container cannot undo it.
428
+ The sample's internet simulator keeps a bridge of its own and is the only path out.
429
+ """
430
+ compose_data = yaml.safe_load(compose_path.read_text())
431
+ if "include" in compose_data:
432
+ raise ValueError(
433
+ f"Compose file {compose_path} uses include:, which merges after this "
434
+ "scan; --no-internet cannot isolate what it cannot see."
435
+ )
436
+ services: dict[str, dict[str, Any] | None] = compose_data.get("services") or {}
437
+ networks: dict[str, dict[str, Any] | None] = compose_data.get("networks") or {}
438
+
439
+ for service_name, service in services.items():
440
+ service = service or {}
441
+ if "extends" in service:
442
+ raise ValueError(
443
+ f"Compose file {compose_path}: service {service_name!r} uses extends:, "
444
+ "which merges after this scan; --no-internet cannot isolate what it "
445
+ "cannot see."
446
+ )
447
+ mode = service.get("network_mode")
448
+ if mode is not None and not (
449
+ isinstance(mode, str) and (mode == "none" or mode.startswith("service:"))
450
+ ):
451
+ raise ValueError(
452
+ f"Compose file {compose_path}: service {service_name!r} uses "
453
+ f"network_mode: {mode}, which --no-internet cannot isolate."
454
+ )
455
+ for network_name, network in networks.items():
456
+ network = network or {}
457
+ if network.get("external") or network.get("name"):
458
+ raise ValueError(
459
+ f"Compose file {compose_path}: network {network_name!r} is external "
460
+ "or has a fixed name; --no-internet can only isolate networks the "
461
+ "project owns."
462
+ )
463
+ if network.get("driver", "bridge") != "bridge":
464
+ raise ValueError(
465
+ f"Compose file {compose_path}: network {network_name!r} uses the "
466
+ f"{network['driver']} driver; --no-internet isolates bridge networks only."
467
+ )
468
+
469
+ for service in services.values():
470
+ service = service or {}
471
+ if "network_mode" in service:
472
+ continue
473
+ if "default" in (service.get("networks") or ["default"]):
474
+ networks.setdefault("default", None)
475
+ if not networks:
476
+ return compose_path
477
+ # gateway_mode isolated also closes the host's own address on the bridge.
478
+ compose_data["networks"] = {
479
+ name: {
480
+ **(network or {}),
481
+ "internal": True,
482
+ "driver_opts": {
483
+ **((network or {}).get("driver_opts") or {}),
484
+ "com.docker.network.bridge.gateway_mode_ipv4": "isolated",
485
+ "com.docker.network.bridge.gateway_mode_ipv6": "isolated",
486
+ },
487
+ }
488
+ for name, network in networks.items()
489
+ }
490
+
491
+ return write_temp_compose(compose_path, "no-internet", compose_data)
492
+
493
+
494
+ def patch_compose_for_internet_simulator(
495
+ compose_path: Path,
496
+ internet_simulator: InternetSimulatorConfig,
497
+ *,
498
+ allow_internet: bool,
499
+ ) -> Path:
500
+ """Add the sample's internet simulator, aliased to its personas, and an nginx service for its
501
+ exposed services on the networks the simulator and their targets join. Without internet access
502
+ the simulator joins every project network and keeps a bridge of its own to reach upstream APIs.
503
+
504
+ Raises:
505
+ ValidationError: An exposed service targets a host that is not a service of the compose file,
506
+ or seed assets are declared without a ``default`` service to take them from.
507
+ """
508
+ compose_data = yaml.safe_load(compose_path.read_text())
509
+ services: dict[str, Any] = compose_data["services"]
510
+ if internet_simulator.seed_assets_from and "default" not in services:
511
+ raise ValidationError(
512
+ f"seed_assets_from is taken from the default service, which {compose_path} does not declare."
513
+ )
514
+ aliases = sorted(internet_simulator.personas)
515
+ if allow_internet:
516
+ joined: dict[str, Any] = {internet_simulator.network: {"aliases": aliases}}
517
+ else:
518
+ joined = {
519
+ name: {"aliases": aliases} for name in compose_data.get("networks") or {}
520
+ }
521
+ joined[SIMULATOR_EGRESS_NETWORK] = {}
522
+ compose_data.setdefault("networks", {})[SIMULATOR_EGRESS_NETWORK] = None
523
+ added: dict[str, Any] = {}
524
+ if internet_simulator.exposed_services:
525
+ targets = {
526
+ target.split(":", 1)[0]
527
+ for target in internet_simulator.exposed_services.values()
528
+ }
529
+ if missing := {
530
+ domain: target
531
+ for domain, target in internet_simulator.exposed_services.items()
532
+ if target.split(":", 1)[0] not in services
533
+ }:
534
+ raise ValidationError(
535
+ f"exposed_services maps {missing} to services that {compose_path} does not declare."
536
+ )
537
+ proxy_networks = (set(joined) - {SIMULATOR_EGRESS_NETWORK}).union(
538
+ *(services[target].get("networks") or ["default"] for target in targets)
539
+ )
540
+ added[INTERNET_SIMULATOR_PROXY_SERVICE_NAME] = exposed_services_proxy_service(
541
+ internet_simulator.exposed_services, sorted(proxy_networks)
542
+ )
543
+ compose_data["volumes"] = {
544
+ **(compose_data.get("volumes") or {}),
545
+ EXPOSED_SERVICES_VOLUME: None,
546
+ }
547
+ added[INTERNET_SIMULATOR_SERVICE_NAME] = {
548
+ **simulator_service(internet_simulator, services),
549
+ "networks": joined,
550
+ }
551
+ services.update(added)
552
+ digest = hashlib.sha256(json.dumps(added, sort_keys=True).encode()).hexdigest()
553
+ return write_temp_compose(
554
+ compose_path, f"internet-simulator-{digest[:8]}", compose_data
555
+ )
556
+
557
+
558
+ def _image_exists_locally(image: str) -> bool:
559
+ """Check whether a Docker image exists locally."""
560
+ result = subprocess.run(
561
+ ["docker", "image", "inspect", image],
562
+ capture_output=True,
563
+ )
564
+ return result.returncode == 0
565
+
566
+
567
+ def _pre_populate_mount(image: str, mount_dir: Path, compose_path: Path) -> None:
568
+ """Copy the image's AGENT_DIR contents into the host mount directory, for WSL.
569
+
570
+ Starts no container; an image missing locally is built with ``docker compose build``.
571
+ """
572
+ mount_path = Path(mount_dir).resolve()
573
+ mount_path.mkdir(parents=True, exist_ok=True)
574
+ if any(mount_path.iterdir()):
575
+ return
576
+
577
+ resolved_image = expand_compose_vars(image)
578
+
579
+ if not _image_exists_locally(resolved_image):
580
+ print(
581
+ f"Image {resolved_image} not found locally, building...",
582
+ )
583
+ subprocess.run(
584
+ [
585
+ "docker",
586
+ "compose",
587
+ "-f",
588
+ str(Path(compose_path).resolve()),
589
+ "build",
590
+ "default",
591
+ ],
592
+ check=True,
593
+ )
594
+
595
+ print(f"Pre-populating mount from image {resolved_image}...")
596
+ container_id = subprocess.run(
597
+ ["docker", "create", resolved_image],
598
+ capture_output=True,
599
+ text=True,
600
+ check=True,
601
+ ).stdout.strip()
602
+
603
+ try:
604
+ subprocess.run(
605
+ ["docker", "cp", f"{container_id}:{AGENT_DIR}/.", str(mount_path)],
606
+ check=True,
607
+ )
608
+ finally:
609
+ subprocess.run(["docker", "rm", container_id], capture_output=True)
610
+
611
+ print(f"Pre-populated {mount_path} from {image}")
612
+
613
+
614
+ def patch_compose_for_mount(compose_path: Path, mount_dir: Path) -> Path:
615
+ """Bind ``mount_dir`` writable at ``AGENT_DIR`` in the ``default`` service, filled from its image first when
616
+ empty, and opt the compose out of Inspect's writable host mount check."""
617
+ compose_data = yaml.safe_load(compose_path.read_text())
618
+ service = (compose_data.get("services") or {}).get("default")
619
+ if service is None:
620
+ raise ValidationError(
621
+ f"Compose file {compose_path} has no 'default' service to mount into"
622
+ )
623
+ if image := service.get("image"):
624
+ _pre_populate_mount(image, mount_dir, compose_path)
625
+ mount_spec = f"{mount_dir.resolve()}:{AGENT_DIR}"
626
+ volumes = service.get("volumes") or []
627
+ if mount_spec not in volumes:
628
+ volumes.append(mount_spec)
629
+ service["volumes"] = volumes
630
+ sandbox_settings = compose_data.get("x-inspect_docker_sandbox") or {}
631
+ sandbox_settings["unsafe_allow_writable_host_mounts"] = True
632
+ compose_data["x-inspect_docker_sandbox"] = sandbox_settings
633
+ tag = f"mount-{hashlib.md5(mount_spec.encode()).hexdigest()[:8]}"
634
+ return write_temp_compose(compose_path, tag, compose_data)
635
+
636
+
637
+ def sample_sandbox_spec(
638
+ spec: SandboxEnvironmentSpec,
639
+ *,
640
+ allow_internet: bool = False,
641
+ internet_simulator: InternetSimulatorConfig | None = None,
642
+ mount_dir: Path | None = None,
643
+ values_from: "EnvProcess | None" = None,
644
+ image_tag: str | None = None,
645
+ ) -> SandboxEnvironmentSpec:
646
+ """The sandbox a sample starts: a Docker compose spec rewritten for the network policy, the
647
+ sample's internet simulator, the setting values of ``values_from`` and a mount, and any other sandbox's
648
+ spec unchanged.
649
+
650
+ Raises:
651
+ ValidationError: A flag asks for a compose rewrite that ``spec`` has no compose file for.
652
+ StateError: See ``compose_reads``.
653
+ """
654
+ current = compose_file(spec)
655
+ if current is None:
656
+ unenforceable = [
657
+ flag
658
+ for flag, requested in (
659
+ ("--no-internet", not allow_internet),
660
+ ("--mount-dir", mount_dir is not None),
661
+ )
662
+ if requested
663
+ ]
664
+ if unenforceable:
665
+ raise ValidationError(
666
+ f"{' and '.join(unenforceable)} {'needs' if len(unenforceable) == 1 else 'need'} "
667
+ f"a Docker compose file to rewrite, and the {spec.type!r} sandbox has none. "
668
+ "Pass --internet to accept that sandbox's own network policy."
669
+ )
670
+ return spec
671
+ if uses_published_images():
672
+ current = strip_build_sections(current, image_tag)
673
+ if not allow_internet:
674
+ current = patch_compose_for_no_internet(current)
675
+ if internet_simulator is not None:
676
+ current = patch_compose_for_internet_simulator(
677
+ current, internet_simulator, allow_internet=allow_internet
678
+ )
679
+ if values_from is not None:
680
+ current = patch_compose_for_env_values(
681
+ current, values_from.values, values_from.values_file
682
+ )
683
+ if mount_dir is not None:
684
+ current = patch_compose_for_mount(current, mount_dir)
685
+ return SandboxEnvironmentSpec(type=spec.type, config=str(current))