agentic-dev-loop 1.0.0a35__tar.gz → 1.0.0a37__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.

Potentially problematic release.


This version of agentic-dev-loop might be problematic. Click here for more details.

Files changed (99) hide show
  1. {agentic_dev_loop-1.0.0a35/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a37}/PKG-INFO +19 -17
  2. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/README.md +18 -16
  3. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/agent_error_guide.md +1 -0
  5. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/egress_policy.py +16 -6
  6. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/egress_proxy.py +112 -17
  7. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/isolation.py +333 -51
  8. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/sync_back.py +1 -1
  9. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37/src/agentic_dev_loop.egg-info}/PKG-INFO +19 -17
  10. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_agent_error_guide.py +6 -3
  11. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_e2e_fixtures.py +91 -10
  12. agentic_dev_loop-1.0.0a37/tests/test_egress_proxy.py +428 -0
  13. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_isolation.py +210 -36
  14. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_isolation_integration.py +4 -4
  15. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_isolation_pbt.py +4 -3
  16. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_sync_back.py +1 -1
  17. agentic_dev_loop-1.0.0a35/tests/test_egress_proxy.py +0 -205
  18. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/LICENSE +0 -0
  19. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/setup.cfg +0 -0
  20. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/__init__.py +0 -0
  21. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/__init__.py +0 -0
  22. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/config.py +0 -0
  23. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/paths.py +0 -0
  24. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/results.py +0 -0
  25. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/core/verdict.py +0 -0
  26. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/__init__.py +0 -0
  27. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/actions.py +0 -0
  28. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  29. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/agents.py +0 -0
  30. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  31. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/cli.py +0 -0
  32. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/deps.py +0 -0
  33. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  34. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  35. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  36. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  37. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/graph.py +0 -0
  38. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  39. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/llm.py +0 -0
  40. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/loaders.py +0 -0
  41. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/mounts.py +0 -0
  42. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/outputs.py +0 -0
  43. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/presets.py +0 -0
  44. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/proc.py +0 -0
  45. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/registry.py +0 -0
  46. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  47. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  48. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/secrets.py +0 -0
  49. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/stages.py +0 -0
  50. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  51. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/py.typed +0 -0
  52. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/viz/__init__.py +0 -0
  53. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/viz/__main__.py +0 -0
  54. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop/viz/server.py +0 -0
  55. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  56. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  57. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  58. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  59. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  60. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_028_declared_caches.py +0 -0
  61. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_029_merge_on_conflict.py +0 -0
  62. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_032_stop_when_green.py +0 -0
  63. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_033_brief_step.py +0 -0
  64. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_035_finalize_nudge.py +0 -0
  65. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  66. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_actions.py +0 -0
  67. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_agents.py +0 -0
  68. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_aidlc.py +0 -0
  69. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_cli.py +0 -0
  70. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_config.py +0 -0
  71. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_deps.py +0 -0
  72. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_dry_run.py +0 -0
  73. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_graph.py +0 -0
  74. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_hostboundary.py +0 -0
  75. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_hostboundary_integration.py +0 -0
  76. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_llm.py +0 -0
  77. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_loaders.py +0 -0
  78. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_module_contract.py +0 -0
  79. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_outputs.py +0 -0
  80. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_outputs_pbt.py +0 -0
  81. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_proc.py +0 -0
  82. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_publish.py +0 -0
  83. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_qa_prompt.py +0 -0
  84. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_registry.py +0 -0
  85. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_repo_ascii.py +0 -0
  86. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_results.py +0 -0
  87. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_sdk_compat.py +0 -0
  88. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_secrets.py +0 -0
  89. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_secrets_integration.py +0 -0
  90. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_secrets_pipeline.py +0 -0
  91. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_source_resolution.py +0 -0
  92. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_stages.py +0 -0
  93. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_step_workspace.py +0 -0
  94. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_trigger_script.py +0 -0
  95. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_unit5_config.py +0 -0
  96. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_unit5_pbt.py +0 -0
  97. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_unit5_pure.py +0 -0
  98. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_verdict.py +0 -0
  99. {agentic_dev_loop-1.0.0a35 → agentic_dev_loop-1.0.0a37}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a35
3
+ Version: 1.0.0a37
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -242,7 +242,7 @@ steps:
242
242
  | `stop_when:` | Optional agent-step termination: `{ green_command: <argv regex>, post_green_turns: <int, default 2> }`. When the agent's most recent terminal observation matching `green_command` exits 0 and no workspace writes have happened since, the engine injects one nudge turn, allows `post_green_turns` further turns, then closes the conversation. Absent = run to finish. |
243
243
  | `terminal_timeout:` | Optional agent-step per-action timeout in seconds (gt 0). `SandboxTerminalExecutor` uses it when the agent leaves the terminal action timeout unset, overriding the SDK tool's default. `None` = current behavior. |
244
244
  | `finalize_nudge_turns:` | Optional agent-step final-response nudge (gt 0). Requires `max_iterations:` and must be less than it. At `max_iterations - finalize_nudge_turns` completed turns the engine injects one advisory turn ("`N turns remain: produce your final response now`"), exactly once; the agent may still exhaust its turns and existing stuck/unclear handling applies unchanged. Absent = current behavior. Provisional pending one consumer run (revert if noisy or the raised cap inflates cost). |
245
- | `custom_prompt:` | Planned (planning-docs/039). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
245
+ | `custom_prompt:` | Planned (planning-docs/040). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
246
246
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
247
247
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox before the step runs (command or agent steps; network ON; stamp-cached, re-runs when the pipeline's lockfiles change); `network: true` lets a command/agent step reach the hosts its `egress:` allowlist names (default off) - see [Per-stage networking](#per-stage-networking). |
248
248
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -328,11 +328,13 @@ steps:
328
328
  a command like `curl ... -o spec.json` has no writable cwd to write into.
329
329
  Declare `mounts: [{ path: ".", mode: rw }]` (or a narrower rw path) for any
330
330
  networked step that needs to produce files.
331
- - **`network: true` is now scoped by the `egress:` allowlist.** A networked
332
- step's traffic is forced through an engine-run per-policy egress proxy
333
- (injected via `HTTP_PROXY`/`HTTPS_PROXY` and reached through the
334
- `host.docker.internal` host-gateway); a request to an undeclared host is
335
- denied and logged as an enforcement event. The engine never adds an
331
+ - **`network: true` is scoped by the `egress:` allowlist, and egress is
332
+ structurally enforced.** A networked step runs on a per-run internal
333
+ docker network with no route to the host. Its traffic is forced
334
+ through a containerized egress proxy (injected via
335
+ `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
336
+ on the internal network); a request to an undeclared host is denied
337
+ and logged as an enforcement event. The engine never adds an
336
338
  undeclared target. `egress: { class: browser }` is the explicit
337
339
  broad-but-logged class (every connection logged, nothing filtered); command
338
340
  and agent steps default to a strict allowlist.
@@ -342,16 +344,16 @@ steps:
342
344
  to exactly the declared hosts for that stage; the default stays network-off
343
345
  everywhere. Reserve it for stages that need it (QA, integration). The
344
346
  residual risks are documented in [docs/setup.md](docs/setup.md).
345
- - **Host-service reachability (planned - planning-docs/037, 038).** A host
346
- service reached at `host.docker.internal` through the proxy fails today:
347
- the policy admits it, but the proxy dials from the host, where that
348
- container-only name does not resolve (a 502 for the client). 037 rewrites
349
- `host.docker.internal` to host loopback at the proxy dial site, so signed
350
- host-service traffic (an S3 SDK against a local MinIO, say) reaches the
351
- host through the enforced, logged path. 038 then replaces the
352
- host-gateway model with a per-run internal network: sandboxes get no host
353
- route, and a containerized proxy on a second host-access network is the
354
- only egress path.
347
+ - **Host-service reachability (planning-docs/037, 038).** The egress
348
+ proxy is a container on the run's internal network with a second
349
+ host-access attachment carrying the `host.docker.internal`
350
+ host-gateway alias, so the name resolves natively inside the proxy
351
+ container and host services (an S3 SDK against a local MinIO, say)
352
+ are reached through the enforced, logged path. Sandbox containers
353
+ have no host route: the internal network carries no default route to
354
+ the host, and sandbox containers get no host-gateway alias.
355
+ `dial_rewrites` on the proxy remains the platform override for hosts
356
+ where the alias does not resolve.
355
357
 
356
358
  ### Sync-back conflicts (host edits during a run)
357
359
 
@@ -200,7 +200,7 @@ steps:
200
200
  | `stop_when:` | Optional agent-step termination: `{ green_command: <argv regex>, post_green_turns: <int, default 2> }`. When the agent's most recent terminal observation matching `green_command` exits 0 and no workspace writes have happened since, the engine injects one nudge turn, allows `post_green_turns` further turns, then closes the conversation. Absent = run to finish. |
201
201
  | `terminal_timeout:` | Optional agent-step per-action timeout in seconds (gt 0). `SandboxTerminalExecutor` uses it when the agent leaves the terminal action timeout unset, overriding the SDK tool's default. `None` = current behavior. |
202
202
  | `finalize_nudge_turns:` | Optional agent-step final-response nudge (gt 0). Requires `max_iterations:` and must be less than it. At `max_iterations - finalize_nudge_turns` completed turns the engine injects one advisory turn ("`N turns remain: produce your final response now`"), exactly once; the agent may still exhaust its turns and existing stuck/unclear handling applies unchanged. Absent = current behavior. Provisional pending one consumer run (revert if noisy or the raised cap inflates cost). |
203
- | `custom_prompt:` | Planned (planning-docs/039). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
203
+ | `custom_prompt:` | Planned (planning-docs/040). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
204
204
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
205
205
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox before the step runs (command or agent steps; network ON; stamp-cached, re-runs when the pipeline's lockfiles change); `network: true` lets a command/agent step reach the hosts its `egress:` allowlist names (default off) - see [Per-stage networking](#per-stage-networking). |
206
206
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -286,11 +286,13 @@ steps:
286
286
  a command like `curl ... -o spec.json` has no writable cwd to write into.
287
287
  Declare `mounts: [{ path: ".", mode: rw }]` (or a narrower rw path) for any
288
288
  networked step that needs to produce files.
289
- - **`network: true` is now scoped by the `egress:` allowlist.** A networked
290
- step's traffic is forced through an engine-run per-policy egress proxy
291
- (injected via `HTTP_PROXY`/`HTTPS_PROXY` and reached through the
292
- `host.docker.internal` host-gateway); a request to an undeclared host is
293
- denied and logged as an enforcement event. The engine never adds an
289
+ - **`network: true` is scoped by the `egress:` allowlist, and egress is
290
+ structurally enforced.** A networked step runs on a per-run internal
291
+ docker network with no route to the host. Its traffic is forced
292
+ through a containerized egress proxy (injected via
293
+ `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
294
+ on the internal network); a request to an undeclared host is denied
295
+ and logged as an enforcement event. The engine never adds an
294
296
  undeclared target. `egress: { class: browser }` is the explicit
295
297
  broad-but-logged class (every connection logged, nothing filtered); command
296
298
  and agent steps default to a strict allowlist.
@@ -300,16 +302,16 @@ steps:
300
302
  to exactly the declared hosts for that stage; the default stays network-off
301
303
  everywhere. Reserve it for stages that need it (QA, integration). The
302
304
  residual risks are documented in [docs/setup.md](docs/setup.md).
303
- - **Host-service reachability (planned - planning-docs/037, 038).** A host
304
- service reached at `host.docker.internal` through the proxy fails today:
305
- the policy admits it, but the proxy dials from the host, where that
306
- container-only name does not resolve (a 502 for the client). 037 rewrites
307
- `host.docker.internal` to host loopback at the proxy dial site, so signed
308
- host-service traffic (an S3 SDK against a local MinIO, say) reaches the
309
- host through the enforced, logged path. 038 then replaces the
310
- host-gateway model with a per-run internal network: sandboxes get no host
311
- route, and a containerized proxy on a second host-access network is the
312
- only egress path.
305
+ - **Host-service reachability (planning-docs/037, 038).** The egress
306
+ proxy is a container on the run's internal network with a second
307
+ host-access attachment carrying the `host.docker.internal`
308
+ host-gateway alias, so the name resolves natively inside the proxy
309
+ container and host services (an S3 SDK against a local MinIO, say)
310
+ are reached through the enforced, logged path. Sandbox containers
311
+ have no host route: the internal network carries no default route to
312
+ the host, and sandbox containers get no host-gateway alias.
313
+ `dial_rewrites` on the proxy remains the platform override for hosts
314
+ where the alias does not resolve.
313
315
 
314
316
  ### Sync-back conflicts (host edits during a run)
315
317
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a35"
7
+ version = "1.0.0a37"
8
8
  description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -4,3 +4,4 @@
4
4
  | AE-SBX-010 | The sandbox could not create the execution container for the command, so the command did not run. | The command is not the problem: retry once; if it persists, mention the code in your final response and stop. |
5
5
  | AE-SBX-016 | The loop refused to sync an edit because the real repo changed underneath on a path this workspace also touches; the file in this workspace is stale and its edits will not be applied. | The command is not the problem: retry once, do not edit or work around the named path, and mention the code in your final response if it persists. |
6
6
  | AE-SBX-018 | The network egress proxy for this step stopped, so the sandbox refused the command rather than run without enforcement. | The command is not the problem: do not retry it, mention the code in your final response, and stop. |
7
+ | AE-SBX-020 | The sandbox could not start the network egress proxy for this step, so the command did not run. | The command is not the problem: retry once; if it persists, mention the code in your final response and stop. |
@@ -10,11 +10,6 @@ from __future__ import annotations
10
10
 
11
11
  from dataclasses import dataclass
12
12
 
13
- from agentic_dev_loop.core.config import (
14
- EgressSpec, parse_egress_port, parse_egress_target,
15
- )
16
- from .presets import expand_presets
17
-
18
13
  ALLOWLIST = "allowlist"
19
14
  # The explicit broad-but-logged browser class (BR-7): no target filtering,
20
15
  # every connection logged host-side.
@@ -32,6 +27,7 @@ class TargetEntry:
32
27
 
33
28
  @classmethod
34
29
  def from_spec(cls, spec: str) -> "TargetEntry":
30
+ from agentic_dev_loop.core.config import parse_egress_target
35
31
  host, port = parse_egress_target(spec)
36
32
  return cls(host=host, port=port)
37
33
 
@@ -51,6 +47,7 @@ class EgressPolicy:
51
47
  policy_class: str = ALLOWLIST
52
48
 
53
49
  def to_spec(self) -> EgressSpec:
50
+ from agentic_dev_loop.core.config import EgressSpec
54
51
  cls = None if self.policy_class == ALLOWLIST else BROWSER
55
52
  return EgressSpec(
56
53
  class_=cls,
@@ -72,6 +69,7 @@ def assemble_egress_policy(egress: EgressSpec | None) -> EgressPolicy:
72
69
  cls = egress.class_ or ALLOWLIST
73
70
  if cls == BROWSER:
74
71
  return EgressPolicy(policy_class=BROWSER)
72
+ from .presets import expand_presets
75
73
  entries: list[TargetEntry] = []
76
74
  if egress.presets:
77
75
  for spec in expand_presets(egress.presets):
@@ -81,6 +79,18 @@ def assemble_egress_policy(egress: EgressSpec | None) -> EgressPolicy:
81
79
  return EgressPolicy(targets=tuple(entries), policy_class=ALLOWLIST)
82
80
 
83
81
 
82
+ def _parse_port(spec: str, port: str) -> int | None:
83
+ """Validate a target port (BR-6.3), standalone-safe (no engine package)."""
84
+ if port == "":
85
+ raise ValueError(f"malformed egress target {spec!r}: empty port")
86
+ if not port.isdigit():
87
+ raise ValueError(f"malformed egress target {spec!r}: port {port!r} not numeric")
88
+ n = int(port)
89
+ if not 1 <= n <= 65535:
90
+ raise ValueError(f"malformed egress target {spec!r}: port {n} out of range")
91
+ return n
92
+
93
+
84
94
  def _split_host_port(hostname: str) -> tuple[str, int | None]:
85
95
  """Split a CONNECT target into (host, port). Bare hostnames carry no port."""
86
96
  value = hostname.strip()
@@ -91,7 +101,7 @@ def _split_host_port(hostname: str) -> tuple[str, int | None]:
91
101
  host = value[1:end]
92
102
  rest = value[end + 1:]
93
103
  if rest.startswith(":"):
94
- return host, parse_egress_port(hostname, rest[1:])
104
+ return host, _parse_port(hostname, rest[1:])
95
105
  return host, None
96
106
  if value.count(":") == 1:
97
107
  host, port = value.rsplit(":", 1)
@@ -8,13 +8,21 @@ request hostname (CONNECT target) per connection against the expanded policy
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import argparse
11
12
  import asyncio
13
+ import json
14
+ import sys
12
15
  import threading
16
+ import time
13
17
  from dataclasses import dataclass, field
14
18
  from datetime import datetime, timezone
19
+ from pathlib import Path
15
20
  from urllib.parse import urlsplit
16
21
 
17
- from .egress_policy import EgressPolicy, match_target
22
+ if __package__:
23
+ from .egress_policy import EgressPolicy, TargetEntry, match_target
24
+ else: # standalone container-mode entrypoint (run as a script)
25
+ from egress_policy import EgressPolicy, TargetEntry, match_target
18
26
 
19
27
 
20
28
  @dataclass(frozen=True)
@@ -33,6 +41,8 @@ class EgressProxy:
33
41
 
34
42
  policy: EgressPolicy
35
43
  events: list[EnforcementEvent] = field(default_factory=list)
44
+ dial_rewrites: dict[str, str] = field(default_factory=dict)
45
+ event_file: Path | None = None
36
46
 
37
47
  def __post_init__(self) -> None:
38
48
  self._server: asyncio.AbstractServer | None = None
@@ -47,24 +57,40 @@ class EgressProxy:
47
57
  """True while the proxy's server thread is running."""
48
58
  return self._thread is not None and self._thread.is_alive()
49
59
 
50
- def start(self, host: str = "127.0.0.1", port: int = 0) -> int:
60
+ def start(self, host: str = "127.0.0.1", port: int = 0,
61
+ bind_timeout: float = 5.0) -> int:
51
62
  """Bind the proxy on ``host:port`` (ephemeral when port=0) and serve
52
- in a background thread. Returns the bound port."""
63
+ in a background thread. Returns the bound port.
64
+
65
+ ``bind_timeout`` bounds how long the bind may wait for the requested
66
+ address to appear (the container-mode entrypoint binds an
67
+ internal-network IP that is attached after the container starts)."""
53
68
  self._loop = asyncio.new_event_loop()
54
69
 
55
70
  def _run() -> None:
56
71
  asyncio.set_event_loop(self._loop)
57
- self._server = self._loop.run_until_complete(
58
- asyncio.start_server(self._handle, host, port))
72
+ deadline = time.monotonic() + bind_timeout
73
+ while True:
74
+ try:
75
+ self._server = self._loop.run_until_complete(
76
+ asyncio.start_server(self._handle, host, port))
77
+ break
78
+ except OSError:
79
+ if time.monotonic() >= deadline:
80
+ raise
81
+ time.sleep(0.1)
59
82
  self._port = self._server.sockets[0].getsockname()[1]
60
83
  self._loop.run_forever()
61
84
 
62
85
  self._thread = threading.Thread(target=_run, daemon=True)
63
86
  self._thread.start()
64
- deadline = datetime.now().timestamp() + 5.0
87
+ deadline = datetime.now().timestamp() + bind_timeout + 1.0
65
88
  while self._port is None:
89
+ if not self._thread.is_alive():
90
+ raise RuntimeError("egress proxy failed to bind")
66
91
  if datetime.now().timestamp() > deadline:
67
- raise RuntimeError("egress proxy failed to bind within 5s")
92
+ raise RuntimeError(
93
+ f"egress proxy failed to bind within {bind_timeout}s")
68
94
  threading.Event().wait(0.01)
69
95
  return self._port
70
96
 
@@ -138,7 +164,7 @@ class EgressProxy:
138
164
  # upstream answers with a decode alert - the restore step's SSL
139
165
  # TLSV1_ALERT_DECODE_ERROR).
140
166
  await _drain_headers(reader)
141
- conn = await _open(host, port)
167
+ conn = await self._open(host, port)
142
168
  if conn is None:
143
169
  _write(writer, b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
144
170
  return
@@ -159,7 +185,7 @@ class EgressProxy:
159
185
  _write(writer, b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
160
186
  return
161
187
  self._record(host, True)
162
- conn = await _open(host, port)
188
+ conn = await self._open(host, port)
163
189
  if conn is None:
164
190
  _write(writer, b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
165
191
  return
@@ -174,9 +200,23 @@ class EgressProxy:
174
200
  await _relay_both(reader, writer, upstream_reader, upstream_writer)
175
201
 
176
202
  def _record(self, hostname: str, allowed: bool) -> None:
177
- self.events.append(EnforcementEvent(
203
+ event = EnforcementEvent(
178
204
  hostname=hostname, allowed=allowed,
179
- timestamp=datetime.now(timezone.utc).isoformat()))
205
+ timestamp=datetime.now(timezone.utc).isoformat())
206
+ self.events.append(event)
207
+ if self.event_file is not None:
208
+ _append_event_file(self.event_file, event)
209
+
210
+ async def _open(self, host: str, port: int):
211
+ """Dial the upstream, applying the configured dial rewrite (D8).
212
+ ``match_target`` already ran against the original hostname, and the
213
+ request/Host header keep that hostname; only the upstream dial address
214
+ is rewritten."""
215
+ dial_host = self.dial_rewrites.get(host, host)
216
+ try:
217
+ return await asyncio.open_connection(dial_host, port)
218
+ except OSError:
219
+ return None
180
220
 
181
221
 
182
222
  def _parse_request_line(line: bytes) -> tuple[str, str, str]:
@@ -207,12 +247,6 @@ def _write(writer: asyncio.StreamWriter, data: bytes) -> None:
207
247
  writer.write(data)
208
248
 
209
249
 
210
- async def _open(host: str, port: int):
211
- try:
212
- return await asyncio.open_connection(host, port)
213
- except OSError:
214
- return None
215
-
216
250
 
217
251
  async def _forward_headers(reader, upstream, host: str) -> None:
218
252
  saw_host = False
@@ -262,3 +296,64 @@ async def _relay(src, dst) -> None:
262
296
  dst.write_eof()
263
297
  except Exception:
264
298
  pass
299
+
300
+
301
+
302
+ def _append_event_file(path: Path, event: EnforcementEvent) -> None:
303
+ """Append one enforcement event to the mounted JSONL file (best-effort:
304
+ a broken events file must never break enforcement)."""
305
+ record = {
306
+ "hostname": event.hostname,
307
+ "allowed": event.allowed,
308
+ "timestamp": event.timestamp,
309
+ }
310
+ try:
311
+ with path.open("a", encoding="utf-8") as fh:
312
+ fh.write(json.dumps(record, sort_keys=True) + "\n")
313
+ except OSError:
314
+ pass
315
+
316
+
317
+ def policy_to_json(policy: EgressPolicy) -> str:
318
+ """Serialize a resolved policy for the container-mode entrypoint."""
319
+ return json.dumps({
320
+ "class": policy.policy_class,
321
+ "targets": [{"host": t.host, "port": t.port} for t in policy.targets],
322
+ }, sort_keys=True)
323
+
324
+
325
+ def policy_from_json(text: str) -> EgressPolicy:
326
+ """Rebuild a resolved policy from the entrypoint's ``--policy`` JSON."""
327
+ data = json.loads(text)
328
+ return EgressPolicy(
329
+ targets=tuple(
330
+ TargetEntry(host=t["host"], port=t.get("port"))
331
+ for t in data.get("targets", [])),
332
+ policy_class=data.get("class", "allowlist"),
333
+ )
334
+
335
+
336
+ def main(argv: list[str] | None = None) -> int:
337
+ """Container-mode entrypoint: serve the policy on the internal network
338
+ and append enforcement events to a mounted file until SIGTERM."""
339
+ parser = argparse.ArgumentParser(description="agentic-dev-loop egress proxy")
340
+ parser.add_argument("--policy", required=True)
341
+ parser.add_argument("--events", required=True)
342
+ parser.add_argument("--dial-rewrites", default="{}")
343
+ parser.add_argument("--host", default="127.0.0.1")
344
+ parser.add_argument("--port", type=int, default=0)
345
+ parser.add_argument("--bind-timeout", type=float, default=5.0)
346
+ args = parser.parse_args(argv)
347
+
348
+ proxy = EgressProxy(
349
+ policy=policy_from_json(args.policy),
350
+ dial_rewrites=json.loads(args.dial_rewrites),
351
+ event_file=Path(args.events),
352
+ )
353
+ proxy.start(host=args.host, port=args.port, bind_timeout=args.bind_timeout)
354
+ threading.Event().wait()
355
+ return 0
356
+
357
+
358
+ if __name__ == "__main__":
359
+ sys.exit(main())