agentic-dev-loop 1.0.0a46__tar.gz → 1.0.0a48__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 (104) hide show
  1. {agentic_dev_loop-1.0.0a46/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a48}/PKG-INFO +6 -5
  2. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/README.md +5 -4
  3. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/isolation.py +66 -21
  5. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48/src/agentic_dev_loop.egg-info}/PKG-INFO +6 -5
  6. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_isolation.py +80 -2
  7. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_isolation_integration.py +79 -1
  8. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/LICENSE +0 -0
  9. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/setup.cfg +0 -0
  10. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/__init__.py +0 -0
  11. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/__init__.py +0 -0
  12. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/config.py +0 -0
  13. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/paths.py +0 -0
  14. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/results.py +0 -0
  15. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/core/verdict.py +0 -0
  16. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/__init__.py +0 -0
  17. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/actions.py +0 -0
  18. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  19. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  20. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/agents.py +0 -0
  21. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  22. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
  23. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/brief_prompt.md +0 -0
  24. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/brief_prompt.py +0 -0
  25. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/cli.py +0 -0
  26. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/containers.py +0 -0
  27. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/deps.py +0 -0
  28. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  29. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  30. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  31. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  32. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  33. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  34. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/graph.py +0 -0
  35. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  36. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/llm.py +0 -0
  37. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/loaders.py +0 -0
  38. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/mounts.py +0 -0
  39. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/outputs.py +0 -0
  40. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/presets.py +0 -0
  41. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/proc.py +0 -0
  42. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/registry.py +0 -0
  43. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  44. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  45. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/secrets.py +0 -0
  46. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/stages.py +0 -0
  47. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  48. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  49. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/py.typed +0 -0
  50. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/viz/__init__.py +0 -0
  51. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/viz/__main__.py +0 -0
  52. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop/viz/server.py +0 -0
  53. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  54. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  55. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  56. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  57. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  58. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_028_declared_caches.py +0 -0
  59. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_029_merge_on_conflict.py +0 -0
  60. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_033_brief_step.py +0 -0
  61. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_035_finalize_nudge.py +0 -0
  62. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  63. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_041_brief_coverage.py +0 -0
  64. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_046_brief_prompt.py +0 -0
  65. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_actions.py +0 -0
  66. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_agent_error_guide.py +0 -0
  67. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_agents.py +0 -0
  68. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_aidlc.py +0 -0
  69. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_cli.py +0 -0
  70. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_config.py +0 -0
  71. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_containers.py +0 -0
  72. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_deps.py +0 -0
  73. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_dry_run.py +0 -0
  74. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_e2e_fixtures.py +0 -0
  75. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_egress_proxy.py +0 -0
  76. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_graph.py +0 -0
  77. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_hostboundary.py +0 -0
  78. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_hostboundary_integration.py +0 -0
  79. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_isolation_pbt.py +0 -0
  80. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_llm.py +0 -0
  81. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_loaders.py +0 -0
  82. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_module_contract.py +0 -0
  83. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_outputs.py +0 -0
  84. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_outputs_pbt.py +0 -0
  85. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_proc.py +0 -0
  86. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_publish.py +0 -0
  87. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_qa_prompt.py +0 -0
  88. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_registry.py +0 -0
  89. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_repo_ascii.py +0 -0
  90. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_results.py +0 -0
  91. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_sdk_compat.py +0 -0
  92. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_secrets.py +0 -0
  93. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_secrets_integration.py +0 -0
  94. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_secrets_pipeline.py +0 -0
  95. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_source_resolution.py +0 -0
  96. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_stages.py +0 -0
  97. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_step_workspace.py +0 -0
  98. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_sync_back.py +0 -0
  99. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_trigger_script.py +0 -0
  100. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_unit5_config.py +0 -0
  101. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_unit5_pbt.py +0 -0
  102. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_unit5_pure.py +0 -0
  103. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/tests/test_verdict.py +0 -0
  104. {agentic_dev_loop-1.0.0a46 → agentic_dev_loop-1.0.0a48}/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.0a46
3
+ Version: 1.0.0a48
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
@@ -384,10 +384,11 @@ steps:
384
384
  structurally enforced.** A networked step runs on a per-run internal
385
385
  docker network with no route to the host. Its traffic is forced
386
386
  through a containerized egress proxy (injected via
387
- `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
388
- on the internal network); a request to an undeclared host is denied
389
- and logged as an enforcement event. The engine never adds an
390
- undeclared target. `egress: { class: browser }` is the explicit
387
+ `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy's internal IP
388
+ on the internal network; each distinct egress policy gets its own
389
+ proxy with a distinct internal IP); a request to an undeclared host
390
+ is denied and logged as an enforcement event. The engine never adds
391
+ an undeclared target. `egress: { class: browser }` is the explicit
391
392
  broad-but-logged class (every connection logged, nothing filtered); command
392
393
  and agent steps default to a strict allowlist.
393
394
  - **Command, agent, and sandbox-action steps** may declare it. The host-side
@@ -340,10 +340,11 @@ steps:
340
340
  structurally enforced.** A networked step runs on a per-run internal
341
341
  docker network with no route to the host. Its traffic is forced
342
342
  through a containerized egress proxy (injected via
343
- `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
344
- on the internal network); a request to an undeclared host is denied
345
- and logged as an enforcement event. The engine never adds an
346
- undeclared target. `egress: { class: browser }` is the explicit
343
+ `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy's internal IP
344
+ on the internal network; each distinct egress policy gets its own
345
+ proxy with a distinct internal IP); a request to an undeclared host
346
+ is denied and logged as an enforcement event. The engine never adds
347
+ an undeclared target. `egress: { class: browser }` is the explicit
347
348
  broad-but-logged class (every connection logged, nothing filtered); command
348
349
  and agent steps default to a strict allowlist.
349
350
  - **Command, agent, and sandbox-action steps** may declare it. The host-side
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a46"
7
+ version = "1.0.0a48"
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"
@@ -31,7 +31,7 @@ import sys
31
31
  import time
32
32
  import uuid
33
33
  from abc import ABC, abstractmethod
34
- from collections.abc import Mapping
34
+ from collections.abc import Iterable, Mapping
35
35
  from dataclasses import dataclass
36
36
  from datetime import datetime, timezone
37
37
  from pathlib import Path, PurePosixPath
@@ -299,10 +299,10 @@ def proxy_container_name(run_id: str, profile_key: str) -> str:
299
299
  return f"{run_id}-proxy-{profile_key}"
300
300
 
301
301
 
302
- def proxy_url(proxy_name: str, port: int = EGRESS_PROXY_PORT) -> str:
303
- """The sandbox-reachable proxy URL: the proxy container's internal DNS
304
- name on the internal network (038, D3). Never 127.0.0.1 or the host-gateway."""
305
- return f"http://{proxy_name}:{port}"
302
+ def proxy_url(proxy_host: str, port: int = EGRESS_PROXY_PORT) -> str:
303
+ """The sandbox-reachable proxy URL: the proxy's internal address on the
304
+ internal network (038, D3). Never 127.0.0.1 or the host-gateway."""
305
+ return f"http://{proxy_host}:{port}"
306
306
 
307
307
 
308
308
  def egress_events_path(iso_state_dir: str | Path, run_id: str) -> Path:
@@ -310,8 +310,10 @@ def egress_events_path(iso_state_dir: str | Path, run_id: str) -> Path:
310
310
  return Path(iso_state_dir) / f"{EGRESS_EVENTS_PREFIX}-{run_id}.jsonl"
311
311
 
312
312
 
313
- def first_usable_ip(subnets: str) -> str | None:
314
- """First usable non-gateway IPv4 address from one or more CIDR subnets."""
313
+ def proxy_usable_ips(subnets: str) -> list[str]:
314
+ """Usable non-gateway IPv4 host addresses from the first viable subnet
315
+ (048 D1): ``hosts[1:]``, skipping the gateway at ``hosts[0]``. Empty when
316
+ no subnet offers a non-gateway host."""
315
317
  for token in subnets.split():
316
318
  try:
317
319
  network = ipaddress.ip_network(token, strict=False)
@@ -322,8 +324,8 @@ def first_usable_ip(subnets: str) -> str | None:
322
324
  hosts = list(network.hosts())
323
325
  if len(hosts) < 2:
324
326
  continue
325
- return str(hosts[1])
326
- return None
327
+ return [str(host) for host in hosts[1:]]
328
+ return []
327
329
 
328
330
 
329
331
 
@@ -964,6 +966,7 @@ class DockerBackend(Sandbox):
964
966
  self._run_net = run_network_name(self._run_id)
965
967
  self._network_created = False
966
968
  self._proxy_containers: list[str] = []
969
+ self._proxy_ips: dict[str, str] = {}
967
970
  self._egress_events_path = egress_events_path(self._iso_state_dir,
968
971
  self._run_id)
969
972
  self._sandbox_venv_dir = sandbox_venv_dir # 022 relocated venv (opt-in)
@@ -1309,9 +1312,9 @@ class DockerBackend(Sandbox):
1309
1312
  proxy_url_value: str | None = None
1310
1313
  if network and self._export_root is not None:
1311
1314
  proxy_name = proxy_container_name(self._run_id, key[0][:16])
1312
- self._start_proxy_container(proxy_name, policy,
1313
- self._dial_rewrites)
1314
- proxy_url_value = proxy_url(proxy_name)
1315
+ proxy_ip = self._start_proxy_container(proxy_name, policy,
1316
+ self._dial_rewrites)
1317
+ proxy_url_value = proxy_url(proxy_ip)
1315
1318
  payload = docker_create_payload(
1316
1319
  run_id=self._run_id, image=self._image, profile=self._profile,
1317
1320
  workspace=self._workspace, network=network,
@@ -1334,7 +1337,7 @@ class DockerBackend(Sandbox):
1334
1337
  started=True)
1335
1338
 
1336
1339
  def _start_proxy_container(self, name: str, policy: EgressPolicy,
1337
- dial_rewrites: dict[str, str] | None = None) -> None:
1340
+ dial_rewrites: dict[str, str] | None = None) -> str:
1338
1341
  """Create one containerized proxy (038 D2/D5, 039 D1-D4): host-access
1339
1342
  network at create time with a pinned internal IP, then attach the internal
1340
1343
  network; the proxy binds that address only and is readiness probed from
@@ -1350,7 +1353,7 @@ class DockerBackend(Sandbox):
1350
1353
  policy_json = egress_proxy_module.policy_to_json(policy)
1351
1354
  dial_rewrites = dict(dial_rewrites or {})
1352
1355
  dial_rewrites_json = json.dumps(dial_rewrites, sort_keys=True)
1353
- bind_host = self._proxy_internal_ip()
1356
+ bind_host = self._proxy_internal_ip(name)
1354
1357
  payload = docker_proxy_payload(
1355
1358
  run_id=self._run_id, image=self._image, name=name,
1356
1359
  policy_json=policy_json, dial_rewrites_json=dial_rewrites_json,
@@ -1379,22 +1382,63 @@ class DockerBackend(Sandbox):
1379
1382
  f"{bind_host}:{EGRESS_PROXY_PORT} after "
1380
1383
  f"{PROXY_READY_ATTEMPTS} readiness probes")
1381
1384
  self._proxy_containers.append(name)
1382
-
1383
- def _proxy_internal_ip(self) -> str:
1384
- """Return the pinned first-usable IPv4 address on the internal network."""
1385
+ return bind_host
1386
+
1387
+ def _proxy_internal_ip(self, proxy_name: str) -> str:
1388
+ """Return the per-proxy pinned IPv4 address on the internal network
1389
+ (048 D1): inspect the subnet and the addresses containers already hold
1390
+ on it, then allocate the next free host (profile containers join the
1391
+ run network with a daemon auto-assigned IP, so those must be skipped
1392
+ or the later ``network connect --ip`` collides)."""
1385
1393
  inspect = self._runner.run(
1386
1394
  ["docker", "network", "inspect", "-f",
1387
- "{{range .IPAM.Config}}{{.Subnet}} {{end}}", self._run_net],
1395
+ "{{range .IPAM.Config}}{{.Subnet}} {{end}}|"
1396
+ "{{range .Containers}}{{.IPv4Address}} {{end}}",
1397
+ self._run_net],
1388
1398
  timeout=30)
1389
1399
  if inspect.exit_code != 0:
1390
1400
  raise SandboxError(
1391
1401
  f"AE-SBX-021: internal network inspect failed: "
1392
1402
  f"{inspect.stderr.strip() or inspect.stdout.strip()}")
1393
- ip = first_usable_ip(inspect.stdout)
1394
- if ip is None:
1403
+ subnets, sep, in_use_part = inspect.stdout.partition("|")
1404
+ in_use = in_use_part.split() if sep else ()
1405
+ return self._allocate_proxy_ip(subnets, proxy_name, in_use)
1406
+
1407
+ def _allocate_proxy_ip(self, subnets: str, proxy_name: str,
1408
+ in_use: Iterable[str] = ()) -> str:
1409
+ """Allocate one internal IPv4 address per proxy (048 D1/D2). An
1410
+ existing allocation for ``proxy_name`` is reused; otherwise the first
1411
+ free non-gateway host wins (the first proxy keeps ``hosts[1]``).
1412
+ Addresses in ``in_use`` (held by other containers on the network) are
1413
+ skipped alongside the already-allocated proxy IPs. Raises when the
1414
+ subnet has no free host instead of letting the daemon emit a
1415
+ confusing collision error."""
1416
+ existing = self._proxy_ips.get(proxy_name)
1417
+ if existing is not None:
1418
+ return existing
1419
+ hosts = proxy_usable_ips(subnets)
1420
+ if not hosts:
1395
1421
  raise SandboxError(
1396
1422
  f"AE-SBX-021: internal network has no usable IPv4 subnet: "
1397
- f"{inspect.stdout.strip()!r}")
1423
+ f"{subnets.strip()!r}")
1424
+ taken = set(self._proxy_ips.values())
1425
+ for token in in_use:
1426
+ addr = token.split("/", 1)[0].strip()
1427
+ try:
1428
+ ip = ipaddress.ip_address(addr)
1429
+ except ValueError:
1430
+ continue
1431
+ if isinstance(ip, ipaddress.IPv4Address):
1432
+ taken.add(str(ip))
1433
+ free = [host for host in hosts if host not in taken]
1434
+ if not free:
1435
+ raise SandboxError(
1436
+ f"AE-SBX-021: internal network {subnets.strip()!r} has "
1437
+ f"{len(hosts)} usable proxy address(es) but "
1438
+ f"{len(self._proxy_ips) + 1} egress proxies were requested - "
1439
+ f"no free address to assign")
1440
+ ip = free[0]
1441
+ self._proxy_ips[proxy_name] = ip
1398
1442
  return ip
1399
1443
 
1400
1444
  def _wait_proxy_ready(self, name: str, host: str) -> bool:
@@ -1541,6 +1585,7 @@ class DockerBackend(Sandbox):
1541
1585
  for name in self._proxy_containers:
1542
1586
  self._rm_container(name)
1543
1587
  self._proxy_containers.clear()
1588
+ self._proxy_ips.clear()
1544
1589
  if self._network_created:
1545
1590
  self._runner.run(["docker", "network", "rm", self._run_net], timeout=60)
1546
1591
  self._network_created = False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a46
3
+ Version: 1.0.0a48
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
@@ -384,10 +384,11 @@ steps:
384
384
  structurally enforced.** A networked step runs on a per-run internal
385
385
  docker network with no route to the host. Its traffic is forced
386
386
  through a containerized egress proxy (injected via
387
- `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy container's DNS name
388
- on the internal network); a request to an undeclared host is denied
389
- and logged as an enforcement event. The engine never adds an
390
- undeclared target. `egress: { class: browser }` is the explicit
387
+ `HTTP_PROXY`/`HTTPS_PROXY`, pointed at the proxy's internal IP
388
+ on the internal network; each distinct egress policy gets its own
389
+ proxy with a distinct internal IP); a request to an undeclared host
390
+ is denied and logged as an enforcement event. The engine never adds
391
+ an undeclared target. `egress: { class: browser }` is the explicit
391
392
  broad-but-logged class (every connection logged, nothing filtered); command
392
393
  and agent steps default to a strict allowlist.
393
394
  - **Command, agent, and sandbox-action steps** may declare it. The host-side
@@ -1581,7 +1581,7 @@ class TestUnit5RuntimeEnforcement:
1581
1581
 
1582
1582
  def test_profile_container_mounts_and_proxy_env(self, tmp_path):
1583
1583
  """038: a step's mount set + egress policy key a pooled container;
1584
- the proxy env points at the proxy container's internal DNS name
1584
+ the proxy env points at the proxy container's internal IP
1585
1585
  (never 127.0.0.1 or the host-gateway), the proxy binds a pinned
1586
1586
  internal IP after a readiness probe, and the exec cwd is the export
1587
1587
  root."""
@@ -1633,7 +1633,7 @@ class TestUnit5RuntimeEnforcement:
1633
1633
  if a.startswith("HTTP_PROXY=")
1634
1634
  or a.startswith("HTTPS_PROXY=")]
1635
1635
  assert proxy_envs and all(
1636
- f"http://{proxy_name}:3128" in a for a in proxy_envs)
1636
+ "http://172.18.0.2:3128" in a for a in proxy_envs)
1637
1637
  assert run_argv[run_argv.index("--workdir") + 1] == str(export)
1638
1638
  inspect = runner.calls[6]
1639
1639
  assert inspect == ["docker", "inspect", "-f",
@@ -2434,6 +2434,84 @@ class TestNetworkEgressEnforcement:
2434
2434
 
2435
2435
 
2436
2436
 
2437
+ class TestProxyIpAllocation:
2438
+ """048: per-proxy internal IP allocation - distinct ``--ip`` per proxy,
2439
+ the first proxy keeps ``hosts[1]``, subnet exhaustion fails fast, and a
2440
+ failed attach surfaces the daemon stderr."""
2441
+
2442
+ def test_two_proxies_get_distinct_ips(self, tmp_path):
2443
+ runner = FakeRunner(
2444
+ ok(""), ok("172.18.0.0/16 "), ok("proxy"), ok(""), ok(""),
2445
+ ok("172.18.0.0/16 "), ok("proxy"), ok(""), ok(""))
2446
+ backend = _backend(tmp_path, runner, network=True)
2447
+ backend._start_proxy_container("r-proxy-aaa", EgressPolicy())
2448
+ backend._start_proxy_container("r-proxy-bbb", EgressPolicy())
2449
+ connects = [c for c in backend._runner.calls if "--ip" in c]
2450
+ assert [c[c.index("--ip") + 1] for c in connects] == \
2451
+ ["172.18.0.2", "172.18.0.3"]
2452
+ runs = [c for c in backend._runner.calls if "--host" in c]
2453
+ assert [c[c.index("--host") + 1] for c in runs] == \
2454
+ ["172.18.0.2", "172.18.0.3"]
2455
+
2456
+ def test_allocation_reused_for_same_name(self, tmp_path):
2457
+ backend = _backend(tmp_path, FakeRunner())
2458
+ first = backend._allocate_proxy_ip("172.18.0.0/16 ", "p1")
2459
+ again = backend._allocate_proxy_ip("172.18.0.0/16 ", "p1")
2460
+ assert first == "172.18.0.2"
2461
+ assert again == first
2462
+
2463
+ def test_exhaustion_raises_naming_subnet_and_count(self, tmp_path):
2464
+ backend = _backend(tmp_path, FakeRunner())
2465
+ backend._allocate_proxy_ip("172.18.0.0/30 ", "p1")
2466
+ with pytest.raises(SandboxError,
2467
+ match=r"1 usable proxy address.*2 egress proxies"):
2468
+ backend._allocate_proxy_ip("172.18.0.0/30 ", "p2")
2469
+
2470
+ def test_in_use_addresses_are_skipped(self, tmp_path):
2471
+ """048 fix: addresses held by other containers on the network (the
2472
+ profile containers auto-join it) must be skipped, not re-pinned."""
2473
+ backend = _backend(tmp_path, FakeRunner())
2474
+ first = backend._allocate_proxy_ip("172.18.0.0/16 ", "p1")
2475
+ second = backend._allocate_proxy_ip(
2476
+ "172.18.0.0/16 ", "p2", in_use=["172.18.0.3/16"])
2477
+ assert first == "172.18.0.2"
2478
+ assert second == "172.18.0.4"
2479
+
2480
+ def test_in_use_parsing_ignores_malformed_and_ipv6_tokens(self, tmp_path):
2481
+ backend = _backend(tmp_path, FakeRunner())
2482
+ backend._allocate_proxy_ip("172.18.0.0/16 ", "p1")
2483
+ second = backend._allocate_proxy_ip(
2484
+ "172.18.0.0/16 ", "p2",
2485
+ in_use=["172.18.0.2/16", "172.18.0.3/16", "fd00::2/64",
2486
+ "garbage"])
2487
+ assert second == "172.18.0.4"
2488
+
2489
+ def test_second_proxy_skips_container_held_ips(self, tmp_path):
2490
+ """048 fix: the inspect output reports in-use container addresses;
2491
+ the second proxy skips the first proxy's IP AND the auto-assigned
2492
+ profile-container IP (the CI AE-SBX-020 collision)."""
2493
+ runner = FakeRunner(
2494
+ ok(""), ok("172.18.0.0/16 |"), ok("proxy"), ok(""), ok(""),
2495
+ ok("172.18.0.0/16 |172.18.0.2/16 172.18.0.3/16 "),
2496
+ ok("proxy"), ok(""), ok(""))
2497
+ backend = _backend(tmp_path, runner, network=True)
2498
+ backend._start_proxy_container("r-proxy-aaa", EgressPolicy())
2499
+ backend._start_proxy_container("r-proxy-bbb", EgressPolicy())
2500
+ connects = [c for c in backend._runner.calls if "--ip" in c]
2501
+ assert [c[c.index("--ip") + 1] for c in connects] == \
2502
+ ["172.18.0.2", "172.18.0.4"]
2503
+
2504
+ def test_connect_failure_message_carries_stderr_tail(self, tmp_path):
2505
+ runner = FakeRunner(
2506
+ ok(""), ok("172.18.0.0/16 "), ok("proxy"),
2507
+ fail(1, "Error response from daemon: Address already in use"))
2508
+ backend = _backend(tmp_path, runner, network=True)
2509
+ with pytest.raises(SandboxError,
2510
+ match="Address already in use") as exc:
2511
+ backend._start_proxy_container("r-proxy-abc", EgressPolicy())
2512
+ assert "AE-SBX-020" in str(exc.value)
2513
+
2514
+
2437
2515
  class TestRegisterBeforeCreate:
2438
2516
  """042 D1: every create site appends its registry record before the
2439
2517
  create command, so a failed create still leaves a stale entry the close()
@@ -3,16 +3,18 @@ reason when no daemon or integration image is present; never silently skipped
3
3
  in CI (the workflows pre-install Docker and the image)."""
4
4
  from __future__ import annotations
5
5
 
6
+ import http.server
6
7
  import json
7
8
  import os
8
9
  import shutil
9
10
  import subprocess
10
11
  import sys
12
+ import threading
11
13
 
12
14
  import pytest
13
15
 
14
16
  from agentic_dev_loop.core.config import IsolationConfig, MountSpec
15
- from agentic_dev_loop.engine.egress_policy import EgressPolicy
17
+ from agentic_dev_loop.engine.egress_policy import EgressPolicy, TargetEntry
16
18
  from agentic_dev_loop.engine.isolation import (
17
19
  CONTAINER_REGISTRY, DockerBackend, SandboxExecutionRequest, SandboxError,
18
20
  SubprocessRunner, probe_docker, process_start, read_stamp,
@@ -409,3 +411,79 @@ class TestReaperE2E:
409
411
  "--format", "{{.Names}}"],
410
412
  timeout=30)
411
413
  assert name not in inspect.stdout
414
+
415
+
416
+
417
+ class TestTwoProxyDistinctIpsE2E:
418
+ """048: two distinct egress policies on one run network each get their
419
+ own proxy container (distinct internal IP) and both relay an allowed
420
+ loopback dial through the host gateway."""
421
+
422
+ def _target(self, body):
423
+ class _Handler(http.server.BaseHTTPRequestHandler):
424
+ def do_GET(self):
425
+ payload = body.encode()
426
+ self.send_response(200)
427
+ self.send_header("Content-Length", str(len(payload)))
428
+ self.end_headers()
429
+ self.wfile.write(payload)
430
+
431
+ def log_message(self, *args):
432
+ pass
433
+
434
+ server = http.server.ThreadingHTTPServer(("0.0.0.0", 0), _Handler)
435
+ threading.Thread(target=server.serve_forever, daemon=True).start()
436
+ return server
437
+
438
+ def _backend_with_export(self, tmp_path):
439
+ reason = _docker_available()
440
+ if reason is not None:
441
+ pytest.skip(reason)
442
+ ws = tmp_path / "ws"
443
+ (ws / "src").mkdir(parents=True)
444
+ (ws / "src" / "a.py").write_text("orig")
445
+ export = tmp_path / "state" / "exports" / "r1"
446
+ StepWorkspace.materialise(ws, [], "linux", export)
447
+ profile = resolve_effective_profile(IsolationConfig(image=TEST_IMAGE))
448
+ b = DockerBackend(image=TEST_IMAGE, workspace=str(ws),
449
+ iso_state_dir=str(tmp_path / "state"), profile=profile,
450
+ export_root=str(export))
451
+ b.ensure_image()
452
+ b.create_execution_container()
453
+ return b, export
454
+
455
+ def test_two_proxies_attach_and_relay(self, tmp_path):
456
+ server_a = self._target("hello-from-a")
457
+ server_b = self._target("hello-from-b")
458
+ b, export = self._backend_with_export(tmp_path)
459
+ try:
460
+ cases = [
461
+ (EgressPolicy(targets=(TargetEntry(
462
+ host="host.docker.internal",
463
+ port=server_a.server_address[1]),)),
464
+ server_a.server_address[1], "hello-from-a"),
465
+ (EgressPolicy(targets=(TargetEntry(
466
+ host="host.docker.internal",
467
+ port=server_b.server_address[1]),)),
468
+ server_b.server_address[1], "hello-from-b"),
469
+ ]
470
+ ms = resolve_mount_set([MountSpec(path=".", mode="ro")])
471
+ for policy, port, body in cases:
472
+ code = (
473
+ "import urllib.request;"
474
+ f"print(urllib.request.urlopen('http://host.docker.internal:{port}/').read().decode())"
475
+ )
476
+ req = SandboxExecutionRequest(
477
+ argv=["python", "-c", code],
478
+ cwd=str(export),
479
+ env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"},
480
+ network=True, mount_set=ms, egress_policy=policy)
481
+ result = b.run(req)
482
+ assert result.exit_code == 0, result.stderr
483
+ assert body in result.stdout
484
+ ips = sorted(b._proxy_ips.values())
485
+ assert len(ips) == 2 and ips[0] != ips[1]
486
+ finally:
487
+ b.close()
488
+ server_a.shutdown()
489
+ server_b.shutdown()