agentic-dev-loop 1.0.0a36__tar.gz → 1.0.0a38__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 (98) hide show
  1. {agentic_dev_loop-1.0.0a36/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a38}/PKG-INFO +18 -16
  2. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/README.md +17 -15
  3. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/config.py +41 -1
  5. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/egress_proxy.py +26 -8
  6. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/isolation.py +115 -21
  7. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/stages.py +22 -7
  8. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38/src/agentic_dev_loop.egg-info}/PKG-INFO +18 -16
  9. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_033_brief_step.py +12 -0
  10. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_config.py +60 -0
  11. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_e2e_fixtures.py +91 -10
  12. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_egress_proxy.py +63 -0
  13. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_isolation.py +93 -34
  14. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_stages.py +24 -0
  15. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/LICENSE +0 -0
  16. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/setup.cfg +0 -0
  17. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/__init__.py +0 -0
  18. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/__init__.py +0 -0
  19. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/paths.py +0 -0
  20. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/results.py +0 -0
  21. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/core/verdict.py +0 -0
  22. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/actions.py +0 -0
  24. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  25. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  26. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/agents.py +0 -0
  27. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  28. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/cli.py +0 -0
  29. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/deps.py +0 -0
  30. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  31. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  32. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  33. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  34. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  35. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/graph.py +0 -0
  36. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  37. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/llm.py +0 -0
  38. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/loaders.py +0 -0
  39. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/mounts.py +0 -0
  40. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/outputs.py +0 -0
  41. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/presets.py +0 -0
  42. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/proc.py +0 -0
  43. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/registry.py +0 -0
  44. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  45. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  46. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/secrets.py +0 -0
  47. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  48. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  49. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/py.typed +0 -0
  50. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/viz/__init__.py +0 -0
  51. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/viz/__main__.py +0 -0
  52. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop/viz/server.py +0 -0
  53. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  54. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  55. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  56. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  57. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  58. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_028_declared_caches.py +0 -0
  59. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_029_merge_on_conflict.py +0 -0
  60. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_032_stop_when_green.py +0 -0
  61. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_035_finalize_nudge.py +0 -0
  62. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  63. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_actions.py +0 -0
  64. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_agent_error_guide.py +0 -0
  65. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_agents.py +0 -0
  66. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_aidlc.py +0 -0
  67. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_cli.py +0 -0
  68. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_deps.py +0 -0
  69. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_dry_run.py +0 -0
  70. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_graph.py +0 -0
  71. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_hostboundary.py +0 -0
  72. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_hostboundary_integration.py +0 -0
  73. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_isolation_integration.py +0 -0
  74. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_isolation_pbt.py +0 -0
  75. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_llm.py +0 -0
  76. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_loaders.py +0 -0
  77. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_module_contract.py +0 -0
  78. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_outputs.py +0 -0
  79. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_outputs_pbt.py +0 -0
  80. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_proc.py +0 -0
  81. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_publish.py +0 -0
  82. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_qa_prompt.py +0 -0
  83. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_registry.py +0 -0
  84. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_repo_ascii.py +0 -0
  85. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_results.py +0 -0
  86. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_sdk_compat.py +0 -0
  87. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_secrets.py +0 -0
  88. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_secrets_integration.py +0 -0
  89. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_secrets_pipeline.py +0 -0
  90. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_source_resolution.py +0 -0
  91. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_step_workspace.py +0 -0
  92. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_sync_back.py +0 -0
  93. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_trigger_script.py +0 -0
  94. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_unit5_config.py +0 -0
  95. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_unit5_pbt.py +0 -0
  96. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_unit5_pure.py +0 -0
  97. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/tests/test_verdict.py +0 -0
  98. {agentic_dev_loop-1.0.0a36 → agentic_dev_loop-1.0.0a38}/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.0a36
3
+ Version: 1.0.0a38
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
@@ -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
 
@@ -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.0a36"
7
+ version = "1.0.0a38"
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"
@@ -389,6 +389,7 @@ class StepConfig(BaseModel):
389
389
  stop_when: StopWhenSpec | None = None # agent steps only (032)
390
390
  finalize_nudge_turns: int | None = Field(default=None, gt=0) # agent steps only (035)
391
391
  brief_inputs: BriefInputsSpec | None = None # agent steps only (033)
392
+ custom_prompt: str | None = None # agent steps only (040); file path resolved at load
392
393
  env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
393
394
  setup: str | None = None # BR-13: command steps only
394
395
  forbidden_commands: list[str] = [] # banned-command regex deny-list (036 D1)
@@ -441,6 +442,13 @@ class StepConfig(BaseModel):
441
442
  f"regex: {e}") from e
442
443
  return v
443
444
 
445
+ @field_validator("custom_prompt")
446
+ @classmethod
447
+ def _nonempty_custom_prompt(cls, v: str | None) -> str | None:
448
+ if v is not None and not v.strip():
449
+ raise ValueError("custom_prompt must be a non-empty string")
450
+ return v
451
+
444
452
  @model_validator(mode="after")
445
453
  def _check_shape(self) -> "StepConfig":
446
454
  for key in ("agent", "action", "command"):
@@ -501,6 +509,10 @@ class StepConfig(BaseModel):
501
509
  raise ValueError(
502
510
  f"step {self.name!r}: 'brief_inputs' requires 'agent'"
503
511
  )
512
+ if self.custom_prompt is not None and self.agent is None:
513
+ raise ValueError(
514
+ f"step {self.name!r}: 'custom_prompt' requires 'agent'"
515
+ )
504
516
  if (self.brief_inputs is not None
505
517
  and (self.artifact is not None or self.verdicts is not None)):
506
518
  raise ValueError(
@@ -797,6 +809,28 @@ class PipelineConfig(BaseModel):
797
809
  return self
798
810
 
799
811
 
812
+ def _custom_prompt_looks_like_path(value: str) -> bool:
813
+ """040/D2: a value counts as path-shaped when it carries a path
814
+ separator or a file extension (a dot). A path-shaped value that names no
815
+ existing file is a hard error, never silent text."""
816
+ return "/" in value or "\\" in value or Path(value).suffix != ""
817
+
818
+
819
+ def resolve_custom_prompt(value: str, pipeline_dir: Path, step_name: str) -> str:
820
+ """Resolve a ``custom_prompt`` value (040/D2): an existing file
821
+ (relative to the pipeline's directory, or absolute) is read as the
822
+ prompt; any other value is literal text. A path-shaped value that names
823
+ no file fails loud, naming the path."""
824
+ candidate = Path(value)
825
+ path = candidate if candidate.is_absolute() else pipeline_dir / candidate
826
+ if path.is_file():
827
+ return path.read_text(encoding="utf-8")
828
+ if _custom_prompt_looks_like_path(value):
829
+ raise ValueError(
830
+ f"step {step_name!r}: 'custom_prompt' file not found: {value}")
831
+ return value
832
+
833
+
800
834
  def default_state_dir(pipeline_path: Path) -> str:
801
835
  """The new run-state default (Q1): checkpoint DB + last-error.md live OUTSIDE
802
836
  the workspace mount, under ~/.agentic-dev-loop/runs/<pipeline-hash>/ where
@@ -816,4 +850,10 @@ def load_config(path: Path) -> PipelineConfig:
816
850
  )
817
851
  if "state_dir" not in data:
818
852
  data["state_dir"] = default_state_dir(path)
819
- return PipelineConfig.model_validate(data)
853
+ cfg = PipelineConfig.model_validate(data)
854
+ pipeline_dir = path.resolve().parent
855
+ for step in cfg.steps:
856
+ if step.custom_prompt is not None:
857
+ step.custom_prompt = resolve_custom_prompt(
858
+ step.custom_prompt, pipeline_dir, step.name)
859
+ return cfg
@@ -13,6 +13,7 @@ import asyncio
13
13
  import json
14
14
  import sys
15
15
  import threading
16
+ import time
16
17
  from dataclasses import dataclass, field
17
18
  from datetime import datetime, timezone
18
19
  from pathlib import Path
@@ -56,24 +57,40 @@ class EgressProxy:
56
57
  """True while the proxy's server thread is running."""
57
58
  return self._thread is not None and self._thread.is_alive()
58
59
 
59
- 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:
60
62
  """Bind the proxy on ``host:port`` (ephemeral when port=0) and serve
61
- 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)."""
62
68
  self._loop = asyncio.new_event_loop()
63
69
 
64
70
  def _run() -> None:
65
71
  asyncio.set_event_loop(self._loop)
66
- self._server = self._loop.run_until_complete(
67
- 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)
68
82
  self._port = self._server.sockets[0].getsockname()[1]
69
83
  self._loop.run_forever()
70
84
 
71
85
  self._thread = threading.Thread(target=_run, daemon=True)
72
86
  self._thread.start()
73
- deadline = datetime.now().timestamp() + 5.0
87
+ deadline = datetime.now().timestamp() + bind_timeout + 1.0
74
88
  while self._port is None:
89
+ if not self._thread.is_alive():
90
+ raise RuntimeError("egress proxy failed to bind")
75
91
  if datetime.now().timestamp() > deadline:
76
- raise RuntimeError("egress proxy failed to bind within 5s")
92
+ raise RuntimeError(
93
+ f"egress proxy failed to bind within {bind_timeout}s")
77
94
  threading.Event().wait(0.01)
78
95
  return self._port
79
96
 
@@ -323,8 +340,9 @@ def main(argv: list[str] | None = None) -> int:
323
340
  parser.add_argument("--policy", required=True)
324
341
  parser.add_argument("--events", required=True)
325
342
  parser.add_argument("--dial-rewrites", default="{}")
326
- parser.add_argument("--host", default="0.0.0.0")
343
+ parser.add_argument("--host", default="127.0.0.1")
327
344
  parser.add_argument("--port", type=int, default=0)
345
+ parser.add_argument("--bind-timeout", type=float, default=5.0)
328
346
  args = parser.parse_args(argv)
329
347
 
330
348
  proxy = EgressProxy(
@@ -332,7 +350,7 @@ def main(argv: list[str] | None = None) -> int:
332
350
  dial_rewrites=json.loads(args.dial_rewrites),
333
351
  event_file=Path(args.events),
334
352
  )
335
- proxy.start(host=args.host, port=args.port)
353
+ proxy.start(host=args.host, port=args.port, bind_timeout=args.bind_timeout)
336
354
  threading.Event().wait()
337
355
  return 0
338
356
 
@@ -19,6 +19,7 @@ last-error.md) are never mounted - sandboxed code cannot reach them.
19
19
  from __future__ import annotations
20
20
 
21
21
  import hashlib
22
+ import ipaddress
22
23
  import json
23
24
  import math
24
25
  import os
@@ -103,6 +104,14 @@ EGRESS_PROXY_PORT = 3128
103
104
  HOST_ACCESS_NETWORK = "bridge"
104
105
  EGRESS_EVENTS_PREFIX = "egress-events"
105
106
 
107
+ # 039: hardened proxy-container runtime posture and readiness bounds.
108
+ PROXY_USER = "65534:65534"
109
+ PROXY_MEMORY = "64m"
110
+ PROXY_CPUS = 0.5
111
+ PROXY_BIND_TIMEOUT = 15.0
112
+ PROXY_READY_ATTEMPTS = 5
113
+ PROXY_READY_DELAY = 0.2
114
+
106
115
 
107
116
  class SandboxError(RuntimeError):
108
117
  """A loud fail-fast sandbox failure; the message names the problem and
@@ -301,6 +310,37 @@ def egress_events_path(iso_state_dir: str | Path, run_id: str) -> Path:
301
310
  return Path(iso_state_dir) / f"{EGRESS_EVENTS_PREFIX}-{run_id}.jsonl"
302
311
 
303
312
 
313
+ def first_usable_ip(subnets: str) -> str | None:
314
+ """First usable non-gateway IPv4 address from one or more CIDR subnets."""
315
+ for token in subnets.split():
316
+ try:
317
+ network = ipaddress.ip_network(token, strict=False)
318
+ except ValueError:
319
+ continue
320
+ if network.version != 4:
321
+ continue
322
+ hosts = list(network.hosts())
323
+ if len(hosts) < 2:
324
+ continue
325
+ return str(hosts[1])
326
+ return None
327
+
328
+
329
+
330
+ def proxy_readiness_argv(name: str, host: str,
331
+ port: int = EGRESS_PROXY_PORT) -> list[str]:
332
+ """The in-container TCP-connect probe for proxy readiness (039, D2)."""
333
+ probe = (
334
+ "import socket,sys;"
335
+ "s=socket.socket();"
336
+ "s.settimeout(2);"
337
+ "s.connect((sys.argv[1], int(sys.argv[2])));"
338
+ "s.close()"
339
+ )
340
+ return ["docker", "exec", name, "python3", "-c", probe, host, str(port)]
341
+
342
+
343
+
304
344
  def mount_set_docker_flags(export_root: str, mount_set: MountSet) -> list[str]:
305
345
  """Translate a resolved MountSet into Docker ``-v`` flags over the
306
346
  export (ID-4/BR-1). Strict-by-default: an empty mount set mounts nothing
@@ -730,12 +770,16 @@ def docker_proxy_payload(*, run_id: str, image: str, name: str,
730
770
  events_host_path: str,
731
771
  egress_proxy_host_path: str,
732
772
  egress_policy_host_path: str,
773
+ bind_host: str,
733
774
  port: int = EGRESS_PROXY_PORT,
734
775
  host_network: str = HOST_ACCESS_NETWORK) -> list[str]:
735
- """Exact argv for the containerized egress proxy (038, D2/D5). It is
736
- created on the host-access network with a host-gateway mapping (D8), then
737
- attached to the internal network so sandbox containers reach it only via
738
- its internal DNS name. The policy is passed as JSON; the two engine
776
+ """Exact argv for the containerized egress proxy (038 D2/D5, 039 D1/D4).
777
+
778
+ Created on the host-access network with a host-gateway mapping (D8), then
779
+ attached to the internal network with the pinned ``bind_host``; the proxy
780
+ binds that internal address only, so the host-access attachment carries no
781
+ listener. Hardened with a read-only rootfs, dropped capabilities, a non-root
782
+ user, and memory/CPU limits. The policy is passed as JSON; the two engine
739
783
  modules are bind-mounted read-only so the image does not need the engine
740
784
  package installed."""
741
785
  payload = [
@@ -744,6 +788,10 @@ def docker_proxy_payload(*, run_id: str, image: str, name: str,
744
788
  "--label", f"agentic-dev-loop.run={run_id}",
745
789
  "--network", host_network,
746
790
  "--add-host", "host.docker.internal:host-gateway",
791
+ "--read-only",
792
+ "--cap-drop", "ALL",
793
+ "--user", PROXY_USER,
794
+ "--memory", PROXY_MEMORY, "--cpus", str(PROXY_CPUS),
747
795
  "-v", f"{egress_proxy_host_path}:/egress/egress_proxy.py:ro",
748
796
  "-v", f"{egress_policy_host_path}:/egress/egress_policy.py:ro",
749
797
  "-v", f"{events_host_path}:/egress/events.jsonl",
@@ -753,11 +801,13 @@ def docker_proxy_payload(*, run_id: str, image: str, name: str,
753
801
  "--policy", policy_json,
754
802
  "--events", "/egress/events.jsonl",
755
803
  "--dial-rewrites", dial_rewrites_json,
756
- "--host", "0.0.0.0",
804
+ "--host", bind_host,
805
+ "--bind-timeout", str(PROXY_BIND_TIMEOUT),
757
806
  "--port", str(port),
758
807
  ]
759
808
  return payload
760
809
 
810
+
761
811
  def docker_provision_payload(*, run_id: str, image: str, workspace: str,
762
812
  setup_cmd: str,
763
813
  sandbox_venv_dir: str | None = None,
@@ -891,7 +941,8 @@ class DockerBackend(Sandbox):
891
941
  export_root: str | None = None,
892
942
  cache_mounts: list[tuple[str, str, str]] | None = None,
893
943
  sync_excludes: list[str] | None = None,
894
- merge_on_conflict: list[str] | None = None):
944
+ merge_on_conflict: list[str] | None = None,
945
+ dial_rewrites: dict[str, str] | None = None):
895
946
  self._image = image
896
947
  self._workspace = workspace
897
948
  self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
@@ -927,6 +978,7 @@ class DockerBackend(Sandbox):
927
978
  # 029: append-style logs union-merged on a concurrent-edit conflict
928
979
  # instead of raising (host wins, export-only lines appended).
929
980
  self._merge_on_conflict = list(merge_on_conflict or ())
981
+ self._dial_rewrites = dict(dial_rewrites or {})
930
982
  self._baseline: dict[str, str] = {}
931
983
  # The profile-keyed pool (D-1/BR-10): profile key -> running container.
932
984
  self._pool: dict[tuple[str, bool], _ProfileContainer] = {}
@@ -1154,11 +1206,6 @@ class DockerBackend(Sandbox):
1154
1206
  if prof is None:
1155
1207
  prof = self._start_profile(key, mount_set, policy, request.network)
1156
1208
  self._pool[key] = prof
1157
- if request.network and not prof.started:
1158
- raise SandboxError(
1159
- "AE-SBX-013: networked execution requested but no networked "
1160
- "container is running - declare `network: true` on the step "
1161
- "(docs/setup.md §3)")
1162
1209
  if request.network:
1163
1210
  if prof.proxy_name is None:
1164
1211
  raise SandboxError(
@@ -1238,7 +1285,8 @@ class DockerBackend(Sandbox):
1238
1285
  proxy_url_value: str | None = None
1239
1286
  if network and self._export_root is not None:
1240
1287
  proxy_name = proxy_container_name(self._run_id, key[0][:16])
1241
- self._start_proxy_container(proxy_name, policy)
1288
+ self._start_proxy_container(proxy_name, policy,
1289
+ self._dial_rewrites)
1242
1290
  proxy_url_value = proxy_url(proxy_name)
1243
1291
  payload = docker_create_payload(
1244
1292
  run_id=self._run_id, image=self._image, profile=self._profile,
@@ -1261,37 +1309,81 @@ class DockerBackend(Sandbox):
1261
1309
  egress_policy=policy, proxy_name=proxy_name,
1262
1310
  started=True)
1263
1311
 
1264
- def _start_proxy_container(self, name: str, policy: EgressPolicy) -> None:
1265
- """Create one containerized proxy (038, D2/D5): host-access network
1266
- at create time, then attach the internal network; the events file is
1267
- pre-created so the single-file bind mount is a file, not a dir."""
1312
+ def _start_proxy_container(self, name: str, policy: EgressPolicy,
1313
+ dial_rewrites: dict[str, str] | None = None) -> None:
1314
+ """Create one containerized proxy (038 D2/D5, 039 D1-D4): host-access
1315
+ network at create time with a pinned internal IP, then attach the internal
1316
+ network; the proxy binds that address only and is readiness probed from
1317
+ inside the container before the first sandbox exec (D2)."""
1268
1318
  self._ensure_run_network()
1269
1319
  events_host = self._egress_events_path
1270
1320
  events_host.parent.mkdir(parents=True, exist_ok=True)
1271
1321
  events_host.touch(exist_ok=True)
1322
+ try:
1323
+ os.chmod(events_host, 0o666)
1324
+ except OSError:
1325
+ pass
1272
1326
  policy_json = egress_proxy_module.policy_to_json(policy)
1327
+ dial_rewrites = dict(dial_rewrites or {})
1328
+ dial_rewrites_json = json.dumps(dial_rewrites, sort_keys=True)
1329
+ bind_host = self._proxy_internal_ip()
1273
1330
  payload = docker_proxy_payload(
1274
1331
  run_id=self._run_id, image=self._image, name=name,
1275
- policy_json=policy_json, dial_rewrites_json="{}",
1332
+ policy_json=policy_json, dial_rewrites_json=dial_rewrites_json,
1276
1333
  events_host_path=str(events_host),
1277
1334
  egress_proxy_host_path=str(Path(egress_proxy_module.__file__)),
1278
1335
  egress_policy_host_path=str(Path(egress_policy_module.__file__)),
1279
- port=EGRESS_PROXY_PORT)
1336
+ bind_host=bind_host, port=EGRESS_PROXY_PORT)
1280
1337
  res = self._runner.run(payload, timeout=60)
1281
1338
  if res.exit_code != 0:
1282
1339
  raise SandboxError(
1283
1340
  f"AE-SBX-020: egress proxy container run failed: "
1284
1341
  f"{res.stderr.strip() or res.stdout.strip()}")
1285
1342
  connect = self._runner.run(
1286
- ["docker", "network", "connect", self._run_net, name], timeout=60)
1343
+ ["docker", "network", "connect", "--ip", bind_host,
1344
+ self._run_net, name], timeout=60)
1287
1345
  if connect.exit_code != 0:
1288
1346
  self._rm_container(name)
1289
1347
  raise SandboxError(
1290
1348
  f"AE-SBX-020: egress proxy network attach failed: "
1291
1349
  f"{connect.stderr.strip() or connect.stdout.strip()}")
1350
+ if not self._wait_proxy_ready(name, bind_host):
1351
+ self._rm_container(name)
1352
+ raise SandboxError(
1353
+ f"AE-SBX-021: egress proxy failed to bind "
1354
+ f"{bind_host}:{EGRESS_PROXY_PORT} after "
1355
+ f"{PROXY_READY_ATTEMPTS} readiness probes")
1292
1356
  self._register_container(name)
1293
1357
  self._proxy_containers.append(name)
1294
1358
 
1359
+ def _proxy_internal_ip(self) -> str:
1360
+ """Return the pinned first-usable IPv4 address on the internal network."""
1361
+ inspect = self._runner.run(
1362
+ ["docker", "network", "inspect", "-f",
1363
+ "{{range .IPAM.Config}}{{.Subnet}} {{end}}", self._run_net],
1364
+ timeout=30)
1365
+ if inspect.exit_code != 0:
1366
+ raise SandboxError(
1367
+ f"AE-SBX-021: internal network inspect failed: "
1368
+ f"{inspect.stderr.strip() or inspect.stdout.strip()}")
1369
+ ip = first_usable_ip(inspect.stdout)
1370
+ if ip is None:
1371
+ raise SandboxError(
1372
+ f"AE-SBX-021: internal network has no usable IPv4 subnet: "
1373
+ f"{inspect.stdout.strip()!r}")
1374
+ return ip
1375
+
1376
+ def _wait_proxy_ready(self, name: str, host: str) -> bool:
1377
+ """Bounded in-container TCP-connect readiness probe (039 D2)."""
1378
+ for _ in range(PROXY_READY_ATTEMPTS):
1379
+ res = self._runner.run(proxy_readiness_argv(name, host),
1380
+ timeout=15)
1381
+ if res.exit_code == 0:
1382
+ return True
1383
+ time.sleep(PROXY_READY_DELAY)
1384
+ return False
1385
+
1386
+
1295
1387
  def _proxy_alive(self, name: str) -> bool:
1296
1388
  """Query the daemon for proxy liveness (038 AE-SBX-018)."""
1297
1389
  res = self._runner.run(
@@ -1809,7 +1901,8 @@ def _announce(msg: str) -> None:
1809
1901
  def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
1810
1902
  state_dir: str | Path,
1811
1903
  runner: CommandRunner | None = None,
1812
- refresh: bool | None = None) -> Sandbox:
1904
+ refresh: bool | None = None,
1905
+ dial_rewrites: dict[str, str] | None = None) -> Sandbox:
1813
1906
  """Eager startup sequence (Q2): probe → image → materialise export →
1814
1907
  backend → ensure_image → create+start → pre-provision every declared setup
1815
1908
  (stamp-cached).
@@ -1866,7 +1959,8 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
1866
1959
  sandbox_venv_dir=sandbox_venv_dir, seccomp_profile=seccomp_profile,
1867
1960
  export_root=export_root, cache_mounts=cache_mounts,
1868
1961
  sync_excludes=list(cfg.sync_excludes),
1869
- merge_on_conflict=list(cfg.merge_on_conflict))
1962
+ merge_on_conflict=list(cfg.merge_on_conflict),
1963
+ dial_rewrites=dial_rewrites)
1870
1964
  _announce(f"preparing sandbox image {image} (first pull can take a while)")
1871
1965
  backend.ensure_image()
1872
1966
  _announce("sandbox image ready")
@@ -58,6 +58,13 @@ DEFAULT_MAX_ITERATIONS = 500
58
58
  DEFAULT_COMMAND_TIMEOUT = 600.0 # seconds - a pytest-class suite budget
59
59
  LAST_ERROR = "last-error.md"
60
60
  ARTIFACT_CONTEXT_CAP = 24_000 # chars per artifact injected into a step's context
61
+ CUSTOM_PROMPT_HEADING = "Run-specific instructions"
62
+
63
+
64
+ def custom_prompt_section(text: str) -> str:
65
+ """The per-step ``custom_prompt`` section (040): appended verbatim, no
66
+ rewording, under a fixed heading."""
67
+ return f"## {CUSTOM_PROMPT_HEADING}\n\n{text}"
61
68
 
62
69
 
63
70
  class _TurnPauseController:
@@ -432,15 +439,19 @@ def _artifact_file(runtime: Runtime, step_name: str) -> str | None:
432
439
 
433
440
  def build_context(runtime: Runtime, docs: dict[str, str], subject: str,
434
441
  notes: list[str] | None = None,
435
- artifacts: dict[str, str] | None = None) -> str:
442
+ artifacts: dict[str, str] | None = None,
443
+ custom_prompt: str | None = None) -> str:
436
444
  """Assemble the attempt's shared message: the always-on agent-facing error
437
- guide (034), subject, doc bundle, the canonical dependency-policy paragraph
438
- (021), the expected-output-paths section generated from step defs (a prompt
439
- merely naming files lets agents skip them), accumulated stage artifacts
440
- (027), and previous-attempt notes."""
445
+ guide (034), subject, doc bundle, the run-specific instructions appended
446
+ from the step's ``custom_prompt`` (040), the canonical dependency-policy
447
+ paragraph (021), the expected-output-paths section generated from step
448
+ defs (a prompt merely naming files lets agents skip them), accumulated
449
+ stage artifacts (027), and previous-attempt notes."""
441
450
  parts = [f"You are working on {subject}.\n", guide_section()]
442
451
  for name, content in docs.items():
443
452
  parts.append(f"## {name}\n\n{content}")
453
+ if custom_prompt:
454
+ parts.append(custom_prompt_section(custom_prompt))
444
455
  if artifacts:
445
456
  parts.append("## Stage artifacts")
446
457
  for step_name, content in artifacts.items():
@@ -613,11 +624,14 @@ def build_brief_context(runtime: Runtime, step: StepConfig,
613
624
  """Assemble the brief agent's task message (033): the always-on
614
625
  agent-facing error guide (034), the resolved unit/plan docs (re-read from
615
626
  disk where sources are known), the closed ``brief_inputs.include`` file
616
- set, and the env-facts block. Prior verdicts and feedback are deliberately
627
+ set, the run-specific instructions appended from ``custom_prompt`` (040),
628
+ and the env-facts block. Prior verdicts and feedback are deliberately
617
629
  absent - the notes channel delivers them per attempt."""
618
630
  sections, env_facts = _brief_sections(runtime, step, docs, doc_sources)
619
631
  parts = [guide_section()]
620
632
  parts += [f"## {name}\n\n{content}" for name, content in sections.items()]
633
+ if step.custom_prompt:
634
+ parts.append(custom_prompt_section(step.custom_prompt))
621
635
  if env_facts:
622
636
  parts.append("## Environment facts\n\n"
623
637
  + "\n".join(f"- {fact}" for fact in env_facts))
@@ -683,7 +697,8 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
683
697
  max_iterations = step.max_iterations or DEFAULT_MAX_ITERATIONS
684
698
  message = build_context(runtime, state["docs"], state["subject"],
685
699
  state["notes"],
686
- artifacts=state.get("artifacts", {}))
700
+ artifacts=state.get("artifacts", {}),
701
+ custom_prompt=step.custom_prompt)
687
702
  print(f"[{subject}] Running {step.agent} agent...", flush=True)
688
703
  result = runtime.run_agent(step.agent, message,
689
704
  max_iterations=max_iterations, step=step)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a36
3
+ Version: 1.0.0a38
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
@@ -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