agentic-dev-loop 1.0.0a3__tar.gz → 1.0.0a4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/PKG-INFO +28 -3
  2. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/README.md +27 -2
  3. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/agents.py +7 -4
  5. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/config.py +6 -0
  6. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/dryrun.py +2 -0
  7. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/isolation.py +73 -23
  8. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/stages.py +7 -4
  9. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/PKG-INFO +28 -3
  10. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_agents.py +17 -0
  11. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_config.py +33 -0
  12. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_dry_run.py +11 -0
  13. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_isolation.py +130 -5
  14. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_isolation_integration.py +54 -0
  15. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_isolation_pbt.py +24 -2
  16. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_stages.py +10 -0
  17. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/LICENSE +0 -0
  18. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/setup.cfg +0 -0
  19. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/__init__.py +0 -0
  20. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/actions.py +0 -0
  21. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/cli.py +0 -0
  22. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/extensions/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/extensions/aidlc.py +0 -0
  24. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/graph.py +0 -0
  25. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/llm.py +0 -0
  26. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/loaders.py +0 -0
  27. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/outputs.py +0 -0
  28. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/proc.py +0 -0
  29. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/py.typed +0 -0
  30. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/registry.py +0 -0
  31. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/results.py +0 -0
  32. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop/viz.py +0 -0
  33. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  34. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  35. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  36. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  37. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  38. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_actions.py +0 -0
  39. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_aidlc.py +0 -0
  40. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_cli.py +0 -0
  41. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_e2e_fixtures.py +0 -0
  42. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_graph.py +0 -0
  43. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_llm.py +0 -0
  44. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_loaders.py +0 -0
  45. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_outputs.py +0 -0
  46. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_outputs_pbt.py +0 -0
  47. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_proc.py +0 -0
  48. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_publish.py +0 -0
  49. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_registry.py +0 -0
  50. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_results.py +0 -0
  51. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_sdk_compat.py +0 -0
  52. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_source_resolution.py +0 -0
  53. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/tests/test_verdict.py +0 -0
  54. {agentic_dev_loop-1.0.0a3 → agentic_dev_loop-1.0.0a4}/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.0a3
3
+ Version: 1.0.0a4
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
@@ -69,7 +69,7 @@ flowchart LR
69
69
  | Feature | What it gives you |
70
70
  | --- | --- |
71
71
  | **Stage-gated agent loop** | Agents do the work, test stages verify it, and the pipeline only advances when each stage passes its gates — looping back with full context when one fails. |
72
- | **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: no network access, no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
72
+ | **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** — authors opt specific stages in](#per-stage-networking), no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
73
73
  | **Crash-safe resume** | Interrupt a run at any time and pick it up later — finished stages stay finished, and you resume from exactly where it stopped. |
74
74
  | **Live graph visualisation** | Follow every run in the browser: stage status, pass/fail verdicts, retry cycles, and live console output as it happens. |
75
75
  | **Extensible** | Plug in your own actions, tools, and document loaders to adapt the loop to your projects. |
@@ -191,10 +191,35 @@ steps:
191
191
  | `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call); actions are registered host-side functions. |
192
192
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
193
193
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
194
- | `env:` / `setup:` (command steps) | Declared env inside the sandbox (fixed safe base + declared vars only); `setup:` provisions the sandbox (network ON, stamp-cached, `--refresh` forces). |
194
+ | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` grants egress to a command or agent step (default off) — see [Per-stage networking](#per-stage-networking). |
195
195
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
196
196
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
197
197
 
198
+ ### Per-stage networking
199
+
200
+ The sandbox is network-off by default. A step that legitimately needs egress —
201
+ a QA step that curls a service it booted, an agent that must hit an API or
202
+ fetch package metadata mid-conversation — declares it explicitly:
203
+
204
+ ```yaml
205
+ steps:
206
+ - name: fetch-docs
207
+ command: curl -fsSL https://example.com/spec.json -o spec.json
208
+ network: true # egress for this step only
209
+ ```
210
+
211
+ - **Default off.** Every step runs with `--network none` unless it declares
212
+ `network: true`. A networked step runs in a second, otherwise-identical
213
+ sandbox container (`--read-only`, `--cap-drop ALL`, single workspace mount)
214
+ on the Docker bridge network — the default network-none container is always
215
+ created too, so no infrastructure work happens mid-run.
216
+ - **Command and agent steps** may declare it. Action steps are host-side and
217
+ never take it (a load-time error).
218
+ - **Author opt-in is the control.** A networked step gives its agent raw
219
+ egress for that stage; the default stays network-off everywhere. Reserve it
220
+ for stages that need it (QA, integration). The residual risk is documented
221
+ in [docs/setup.md](docs/setup.md).
222
+
198
223
  ## Development environment
199
224
 
200
225
  ```bash
@@ -30,7 +30,7 @@ flowchart LR
30
30
  | Feature | What it gives you |
31
31
  | --- | --- |
32
32
  | **Stage-gated agent loop** | Agents do the work, test stages verify it, and the pipeline only advances when each stage passes its gates — looping back with full context when one fails. |
33
- | **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: no network access, no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
33
+ | **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** — authors opt specific stages in](#per-stage-networking), no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
34
34
  | **Crash-safe resume** | Interrupt a run at any time and pick it up later — finished stages stay finished, and you resume from exactly where it stopped. |
35
35
  | **Live graph visualisation** | Follow every run in the browser: stage status, pass/fail verdicts, retry cycles, and live console output as it happens. |
36
36
  | **Extensible** | Plug in your own actions, tools, and document loaders to adapt the loop to your projects. |
@@ -152,10 +152,35 @@ steps:
152
152
  | `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call); actions are registered host-side functions. |
153
153
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
154
154
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
155
- | `env:` / `setup:` (command steps) | Declared env inside the sandbox (fixed safe base + declared vars only); `setup:` provisions the sandbox (network ON, stamp-cached, `--refresh` forces). |
155
+ | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` grants egress to a command or agent step (default off) — see [Per-stage networking](#per-stage-networking). |
156
156
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
157
157
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
158
158
 
159
+ ### Per-stage networking
160
+
161
+ The sandbox is network-off by default. A step that legitimately needs egress —
162
+ a QA step that curls a service it booted, an agent that must hit an API or
163
+ fetch package metadata mid-conversation — declares it explicitly:
164
+
165
+ ```yaml
166
+ steps:
167
+ - name: fetch-docs
168
+ command: curl -fsSL https://example.com/spec.json -o spec.json
169
+ network: true # egress for this step only
170
+ ```
171
+
172
+ - **Default off.** Every step runs with `--network none` unless it declares
173
+ `network: true`. A networked step runs in a second, otherwise-identical
174
+ sandbox container (`--read-only`, `--cap-drop ALL`, single workspace mount)
175
+ on the Docker bridge network — the default network-none container is always
176
+ created too, so no infrastructure work happens mid-run.
177
+ - **Command and agent steps** may declare it. Action steps are host-side and
178
+ never take it (a load-time error).
179
+ - **Author opt-in is the control.** A networked step gives its agent raw
180
+ egress for that stage; the default stays network-off everywhere. Reserve it
181
+ for stages that need it (QA, integration). The residual risk is documented
182
+ in [docs/setup.md](docs/setup.md).
183
+
159
184
  ## Development environment
160
185
 
161
186
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a3"
7
+ version = "1.0.0a4"
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"
@@ -64,12 +64,14 @@ class SandboxTerminalExecutor(ToolExecutor):
64
64
 
65
65
  def __init__(self, sandbox: "Sandbox", *, workspace: str = "",
66
66
  env: dict[str, str] | None = None,
67
- pipeline: str | None = None, step: str | None = None):
67
+ pipeline: str | None = None, step: str | None = None,
68
+ network: bool = False):
68
69
  self._sandbox = sandbox
69
70
  self._workspace = workspace
70
71
  self._env = env or {}
71
72
  self._pipeline = pipeline
72
73
  self._step = step
74
+ self._network = network
73
75
 
74
76
  def __call__(self, action, conversation=None):
75
77
  from openhands.tools.terminal import TerminalObservation
@@ -90,7 +92,7 @@ class SandboxTerminalExecutor(ToolExecutor):
90
92
  request = SandboxExecutionRequest(
91
93
  argv=command_argv(action.command), cwd=self._workspace,
92
94
  env=self._env, timeout=action.timeout,
93
- pipeline=self._pipeline, step=self._step)
95
+ pipeline=self._pipeline, step=self._step, network=self._network)
94
96
  result = self._sandbox.run(request)
95
97
  output = result.stdout + result.stderr
96
98
  return TerminalObservation.from_text(
@@ -107,7 +109,8 @@ class SandboxTerminalExecutor(ToolExecutor):
107
109
 
108
110
  def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None] | None = None,
109
111
  workspace: str = "", pipeline: str | None = None,
110
- step: str | None = None) -> None:
112
+ step: str | None = None,
113
+ network: bool = False) -> None:
111
114
  """Per-run re-registration of the `terminal` name (KD-4): a callable
112
115
  factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
113
116
  Pipelines are sequential, so re-registering per agent step is safe."""
@@ -120,6 +123,6 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
120
123
  conv_state,
121
124
  executor=SandboxTerminalExecutor(
122
125
  sandbox, workspace=workspace, env=resolved_env,
123
- pipeline=pipeline, step=step))
126
+ pipeline=pipeline, step=step, network=network))
124
127
 
125
128
  register_tool("terminal", factory)
@@ -85,6 +85,7 @@ class StepConfig(BaseModel):
85
85
  timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
86
86
  env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
87
87
  setup: str | None = None # BR-13: command steps only
88
+ network: bool = False # per-stage egress (command + agent steps; action steps reject)
88
89
  on_pass: RouteTarget | None = None
89
90
  on_fail: RouteTarget | None = None
90
91
 
@@ -142,6 +143,11 @@ class StepConfig(BaseModel):
142
143
  raise ValueError(
143
144
  f"step {self.name!r}: 'setup' is only allowed on 'command' steps"
144
145
  )
146
+ if self.network and self.action is not None:
147
+ raise ValueError(
148
+ f"step {self.name!r}: 'network' is only allowed on 'agent' or "
149
+ "'command' steps"
150
+ )
145
151
  return self
146
152
 
147
153
 
@@ -73,6 +73,8 @@ def _step_summary(step: StepConfig) -> str:
73
73
  parts = [f"action={step.action}"]
74
74
  if step.params:
75
75
  parts.append(f"params={step.params}")
76
+ if step.network:
77
+ parts.append("network=True")
76
78
  return ", ".join(parts)
77
79
 
78
80
 
@@ -85,6 +85,7 @@ class SandboxExecutionRequest:
85
85
  cwd: str # absolute container-side path (== host mount path)
86
86
  env: dict[str, str] # resolved sandbox env (already scrubbed)
87
87
  timeout: float | None = None # seconds; None = no limit
88
+ network: bool = False # route to the bridge-network exec container when True
88
89
  # Host-side audit metadata (BR-11) — never sent into the container.
89
90
  pipeline: str | None = None
90
91
  step: str | None = None
@@ -92,6 +93,9 @@ class SandboxExecutionRequest:
92
93
  def __post_init__(self) -> None:
93
94
  if not self.argv:
94
95
  raise ValueError("SandboxExecutionRequest.argv must be non-empty")
96
+ # Coerce Path-like cwd to str so the invariant "cwd is always a str"
97
+ # holds at the boundary (audit digest etc. serialize it).
98
+ object.__setattr__(self, "cwd", os.fspath(self.cwd))
95
99
  if not (Path(self.cwd).is_absolute()
96
100
  or PurePosixPath(self.cwd).is_absolute()):
97
101
  raise ValueError(f"SandboxExecutionRequest.cwd must be absolute: {self.cwd!r}")
@@ -280,17 +284,27 @@ def host_user_flag() -> str | None:
280
284
  return None
281
285
 
282
286
 
287
+ def exec_container_name(run_id: str, network: bool = False) -> str:
288
+ """The execution container name for a network mode. Networked requests
289
+ land in a second, bridge-network container; the default is the always-on
290
+ network-none container (014 per-stage networking)."""
291
+ return f"{run_id}-exec-net" if network else f"{run_id}-exec"
292
+
293
+
283
294
  def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
284
- workspace: str) -> list[str]:
295
+ workspace: str, network: bool = False) -> list[str]:
285
296
  """Exact ``docker run -d --rm`` argv for the execution container (§3.3, KD-8).
286
- Single mount {workspace} (Q1); --network none, --read-only, --cap-drop ALL;
287
- --user host uid:gid (host-owned bind mounts, AE-SBX-009 root cause) with
297
+ Single mount {workspace} (Q1); ``--network none`` (bridge when ``network``
298
+ is true — 014 per-stage networking), --read-only, --cap-drop ALL; --user
299
+ host uid:gid (host-owned bind mounts, AE-SBX-009 root cause) with
288
300
  HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs). ``--rm`` lets the
289
301
  daemon auto-remove the container the instant it exits, however it exits
290
302
  (D1) — a SIGKILLed CLI never reaches close()."""
291
303
  payload = [
292
- "docker", "run", "--detach", "--rm", "--name", f"{run_id}-exec",
293
- "--network", "none", "--read-only", "--cap-drop", "ALL",
304
+ "docker", "run", "--detach", "--rm", "--name",
305
+ exec_container_name(run_id, network),
306
+ "--network", "bridge" if network else "none",
307
+ "--read-only", "--cap-drop", "ALL",
294
308
  "--memory", profile.memory, "--cpus", str(profile.cpus),
295
309
  "--tmpfs", "/tmp", "--tmpfs", "/root",
296
310
  ]
@@ -410,20 +424,27 @@ def _request_digest(argv: list[str], cwd: str, env: dict[str, str]) -> str:
410
424
 
411
425
 
412
426
  class DockerBackend(Sandbox):
413
- """Docker-based sandbox: one persistent execution container per run
414
- (eagerly created + started, Q2), ``docker exec`` per request, destroyed on
415
- close(). The injected ``runner`` callable is the unit-test seam."""
427
+ """Docker-based sandbox: a persistent network-none execution container per
428
+ run, plus — when any step declares ``network: true`` — a second
429
+ bridge-network container (eagerly created + started, Q2). ``docker exec``
430
+ per request routes to the container matching its network mode; both are
431
+ destroyed on close(). The injected ``runner`` callable is the unit-test
432
+ seam."""
416
433
 
417
434
  def __init__(self, *, image: str, workspace: str, iso_state_dir: str,
418
- profile: EffectiveProfile, runner: CommandRunner | None = None):
435
+ profile: EffectiveProfile, runner: CommandRunner | None = None,
436
+ network: bool = False):
419
437
  self._image = image
420
438
  self._workspace = workspace
421
439
  self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
422
440
  self._profile = profile
423
441
  self._runner = runner or _default_runner
424
442
  self._run_id = uuid.uuid4().hex
425
- self._container = f"{self._run_id}-exec"
443
+ self._container = exec_container_name(self._run_id, network=False)
444
+ self._network_container = exec_container_name(self._run_id, network=True)
445
+ self._network_enabled = network
426
446
  self._started = False
447
+ self._network_started = False
427
448
 
428
449
  # -- infrastructure (startup) -------------------------------------------
429
450
 
@@ -456,16 +477,32 @@ class DockerBackend(Sandbox):
456
477
  def create_execution_container(self) -> None:
457
478
  """Eager create + start via ``docker run -d --rm`` (Q2, D1) — never
458
479
  lazy; ``run -d`` is create + start in one call, so there is no
459
- separate ``docker start``."""
480
+ separate ``docker start``. When any step declares ``network: true`` a
481
+ second, otherwise-identical bridge-network container is started so no
482
+ infrastructure work happens mid-run (014 per-stage networking)."""
460
483
  payload = docker_create_payload(
461
484
  run_id=self._run_id, image=self._image, profile=self._profile,
462
- workspace=self._workspace)
485
+ workspace=self._workspace, network=False)
463
486
  res = self._runner.run(payload, timeout=60)
464
487
  if res.exit_code != 0:
465
488
  raise SandboxError(
466
489
  f"AE-SBX-010: container run failed: "
467
490
  f"{res.stderr.strip() or res.stdout.strip()}")
468
491
  self._started = True
492
+ if not self._network_enabled:
493
+ return
494
+ net_payload = docker_create_payload(
495
+ run_id=self._run_id, image=self._image, profile=self._profile,
496
+ workspace=self._workspace, network=True)
497
+ net_res = self._runner.run(net_payload, timeout=60)
498
+ if net_res.exit_code != 0:
499
+ # reclaim the already-started network-none container — never leak it.
500
+ self._runner.run(["docker", "rm", "-f", self._container], timeout=60)
501
+ self._started = False
502
+ raise SandboxError(
503
+ f"AE-SBX-013: networked container run failed: "
504
+ f"{net_res.stderr.strip() or net_res.stdout.strip()}")
505
+ self._network_started = True
469
506
 
470
507
  def provision(self, setup_cmd: str, stamp_key: str, stamp_dir: str,
471
508
  force: bool) -> None:
@@ -488,11 +525,17 @@ class DockerBackend(Sandbox):
488
525
 
489
526
  def run(self, request: SandboxExecutionRequest) -> SandboxExecutionResult:
490
527
  _guard_git(request, self._profile.git_mutations_blocked)
528
+ container = self._network_container if request.network else self._container
529
+ if request.network and not self._network_started:
530
+ raise SandboxError(
531
+ "AE-SBX-013: networked execution requested but no networked "
532
+ "container is running — declare `network: true` on the step "
533
+ "(docs/setup.md §3)")
491
534
  start = time.monotonic()
492
535
  exec_argv = ["docker", "exec"]
493
536
  for key, value in request.env.items():
494
537
  exec_argv += ["-e", f"{key}={value}"]
495
- exec_argv += ["-w", self._workspace, self._container]
538
+ exec_argv += ["-w", self._workspace, container]
496
539
  runner_timeout = None
497
540
  if request.timeout is not None:
498
541
  exec_argv += ["/usr/bin/timeout", str(math.ceil(request.timeout))]
@@ -504,7 +547,7 @@ class DockerBackend(Sandbox):
504
547
  result = SandboxExecutionResult(
505
548
  exit_code=res.exit_code, stdout=res.stdout, stderr=res.stderr,
506
549
  duration_seconds=duration, backend="docker", timed_out=timed_out)
507
- self._audit(request, result)
550
+ self._audit(request, result, container)
508
551
  if res.exit_code is None:
509
552
  raise SandboxError(
510
553
  f"AE-SBX-012: Docker daemon lost mid-run: "
@@ -516,12 +559,18 @@ class DockerBackend(Sandbox):
516
559
  daemon may already have auto-removed the container by the time close()
517
560
  runs, so a nonzero stop/rm exit code ("no such container") is treated
518
561
  as success, never raised (D2)."""
519
- if not self._started:
520
- return
521
- self._runner.run(["docker", "stop", "-t", "5", self._container], timeout=60)
522
- # rm -f returns rc != 0 when --rm already removed it — ignore, never raise.
523
- self._runner.run(["docker", "rm", "-f", self._container], timeout=60)
524
- self._started = False
562
+ if self._started:
563
+ self._runner.run(["docker", "stop", "-t", "5", self._container],
564
+ timeout=60)
565
+ # rm -f returns rc != 0 when --rm already removed it — ignore, never raise.
566
+ self._runner.run(["docker", "rm", "-f", self._container], timeout=60)
567
+ self._started = False
568
+ if self._network_started:
569
+ self._runner.run(["docker", "stop", "-t", "5", self._network_container],
570
+ timeout=60)
571
+ self._runner.run(["docker", "rm", "-f", self._network_container],
572
+ timeout=60)
573
+ self._network_started = False
525
574
 
526
575
  # -- internal -----------------------------------------------------------
527
576
 
@@ -538,14 +587,14 @@ class DockerBackend(Sandbox):
538
587
  "failed; refusing to continue")
539
588
 
540
589
  def _audit(self, request: SandboxExecutionRequest,
541
- result: SandboxExecutionResult) -> None:
590
+ result: SandboxExecutionResult, container: str) -> None:
542
591
  append_audit_record(self._iso_state_dir, {
543
592
  "timestamp": datetime.now(timezone.utc).isoformat(),
544
593
  "pipeline": request.pipeline or "",
545
594
  "step": request.step or "",
546
595
  "request_digest": _request_digest(request.argv, request.cwd, request.env),
547
596
  "backend": "docker",
548
- "container_id": self._container,
597
+ "container_id": container,
549
598
  "exit_code": result.exit_code,
550
599
  "timed_out": result.timed_out,
551
600
  "duration_seconds": result.duration_seconds,
@@ -710,9 +759,10 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
710
759
  profile = resolve_effective_profile(cfg.isolation)
711
760
  image = resolve_image_ref(profile.image)
712
761
  iso_state_dir = isolation_state_dir(cfg)
762
+ needs_network = any(step.network for step in cfg.steps)
713
763
  backend = DockerBackend(
714
764
  image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
715
- profile=profile, runner=runner)
765
+ profile=profile, runner=runner, network=needs_network)
716
766
  backend.ensure_image()
717
767
  backend.create_execution_container()
718
768
  if refresh is None:
@@ -110,7 +110,8 @@ def run_agent(agent_name: str, message: str, runtime: Runtime,
110
110
  env=step.env if step is not None else None,
111
111
  workspace=str(runtime.workspace),
112
112
  pipeline=runtime.cfg.name,
113
- step=step.name if step is not None else None)
113
+ step=step.name if step is not None else None,
114
+ network=step.network if step is not None else False)
114
115
  agent = load_agent(runtime.agents_dir, agent_name, runtime.llm)
115
116
  conv = Conversation(agent=agent, workspace=str(runtime.workspace),
116
117
  max_iteration_per_run=max_iterations)
@@ -151,7 +152,8 @@ def _run_command(command: str, workspace: Path, timeout: float,
151
152
  env: dict[str, str] | None = None,
152
153
  sandbox: Sandbox | None = None,
153
154
  pipeline: str | None = None,
154
- step: str | None = None) -> tuple[int | None, str, bool]:
155
+ step: str | None = None,
156
+ network: bool = False) -> tuple[int | None, str, bool]:
155
157
  """Run a command step in the workspace, capturing combined stdout/stderr.
156
158
 
157
159
  With a ``Sandbox`` the command routes through the isolation boundary: git
@@ -166,7 +168,7 @@ def _run_command(command: str, workspace: Path, timeout: float,
166
168
  if sandbox is not None:
167
169
  request = SandboxExecutionRequest(
168
170
  argv=command_argv(command), cwd=str(workspace), env=env or {},
169
- timeout=timeout, pipeline=pipeline, step=step)
171
+ timeout=timeout, pipeline=pipeline, step=step, network=network)
170
172
  result = sandbox.run(request)
171
173
  return result.exit_code, result.stdout + result.stderr, result.timed_out
172
174
  if needs_shell(command):
@@ -346,7 +348,8 @@ def make_command_node(runtime: Runtime, step: StepConfig):
346
348
  try:
347
349
  exit_code, output, timed_out = _run_command(
348
350
  step.command, runtime.workspace, timeout, env,
349
- sandbox=sandbox, pipeline=runtime.cfg.name, step=step.name)
351
+ sandbox=sandbox, pipeline=runtime.cfg.name, step=step.name,
352
+ network=step.network)
350
353
  except Exception:
351
354
  print(traceback.format_exc()[-4000:], file=sys.stderr, flush=True)
352
355
  return {"failed": True, "failed_step": step.name}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a3
3
+ Version: 1.0.0a4
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
@@ -69,7 +69,7 @@ flowchart LR
69
69
  | Feature | What it gives you |
70
70
  | --- | --- |
71
71
  | **Stage-gated agent loop** | Agents do the work, test stages verify it, and the pipeline only advances when each stage passes its gates — looping back with full context when one fails. |
72
- | **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: no network access, no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
72
+ | **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** — authors opt specific stages in](#per-stage-networking), no writes outside your workspace. No sandbox available → the run stops instead of degrading. |
73
73
  | **Crash-safe resume** | Interrupt a run at any time and pick it up later — finished stages stay finished, and you resume from exactly where it stopped. |
74
74
  | **Live graph visualisation** | Follow every run in the browser: stage status, pass/fail verdicts, retry cycles, and live console output as it happens. |
75
75
  | **Extensible** | Plug in your own actions, tools, and document loaders to adapt the loop to your projects. |
@@ -191,10 +191,35 @@ steps:
191
191
  | `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call); actions are registered host-side functions. |
192
192
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
193
193
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
194
- | `env:` / `setup:` (command steps) | Declared env inside the sandbox (fixed safe base + declared vars only); `setup:` provisions the sandbox (network ON, stamp-cached, `--refresh` forces). |
194
+ | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` grants egress to a command or agent step (default off) — see [Per-stage networking](#per-stage-networking). |
195
195
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
196
196
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
197
197
 
198
+ ### Per-stage networking
199
+
200
+ The sandbox is network-off by default. A step that legitimately needs egress —
201
+ a QA step that curls a service it booted, an agent that must hit an API or
202
+ fetch package metadata mid-conversation — declares it explicitly:
203
+
204
+ ```yaml
205
+ steps:
206
+ - name: fetch-docs
207
+ command: curl -fsSL https://example.com/spec.json -o spec.json
208
+ network: true # egress for this step only
209
+ ```
210
+
211
+ - **Default off.** Every step runs with `--network none` unless it declares
212
+ `network: true`. A networked step runs in a second, otherwise-identical
213
+ sandbox container (`--read-only`, `--cap-drop ALL`, single workspace mount)
214
+ on the Docker bridge network — the default network-none container is always
215
+ created too, so no infrastructure work happens mid-run.
216
+ - **Command and agent steps** may declare it. Action steps are host-side and
217
+ never take it (a load-time error).
218
+ - **Author opt-in is the control.** A networked step gives its agent raw
219
+ egress for that stage; the default stays network-off everywhere. Reserve it
220
+ for stages that need it (QA, integration). The residual risk is documented
221
+ in [docs/setup.md](docs/setup.md).
222
+
198
223
  ## Development environment
199
224
 
200
225
  ```bash
@@ -144,6 +144,23 @@ class TestSandboxTerminalExecutor:
144
144
  obs = ex(TerminalAction(command="sleep 5", timeout=1), None)
145
145
  assert obs.timeout is True
146
146
 
147
+ def test_network_reaches_request(self, fake_sandbox, tmp_path):
148
+ from openhands.tools.terminal import TerminalAction
149
+
150
+ from agentic_dev_loop.agents import SandboxTerminalExecutor
151
+ ex = SandboxTerminalExecutor(fake_sandbox, workspace=str(tmp_path),
152
+ env={}, network=True)
153
+ ex(TerminalAction(command="curl https://example.com"), None)
154
+ assert fake_sandbox.requests[0].network is True
155
+
156
+ def test_network_defaults_off(self, fake_sandbox, tmp_path):
157
+ from openhands.tools.terminal import TerminalAction
158
+
159
+ from agentic_dev_loop.agents import SandboxTerminalExecutor
160
+ ex = SandboxTerminalExecutor(fake_sandbox, workspace=str(tmp_path), env={})
161
+ ex(TerminalAction(command="echo hi"), None)
162
+ assert fake_sandbox.requests[0].network is False
163
+
147
164
 
148
165
  class TestRegisterSandboxTerminal:
149
166
  def test_resolves_to_sandbox_executor(self, fake_sandbox, tmp_path):
@@ -460,3 +460,36 @@ class TestStepEnvAndSetup:
460
460
  step = _load(tmp_path, data).steps[0]
461
461
  rt = type(step).model_validate(step.model_dump())
462
462
  assert rt.env == {"A": "x", "B": None}
463
+
464
+
465
+ class TestStepNetwork:
466
+ def test_network_defaults_false(self, tmp_path):
467
+ step = _load(tmp_path, _base()).steps[0]
468
+ assert step.network is False
469
+
470
+ def test_network_on_command_step(self, tmp_path):
471
+ data = _base()
472
+ data["steps"] = [{"name": "fetch", "command": "curl https://example.com",
473
+ "network": True, "on_pass": "success"}]
474
+ assert _load(tmp_path, data).steps[0].network is True
475
+
476
+ def test_network_on_agent_step(self, tmp_path):
477
+ data = _base()
478
+ data["steps"] = [{"name": "qa", "agent": "qa", "network": True,
479
+ "on_pass": "success"}]
480
+ assert _load(tmp_path, data).steps[0].network is True
481
+
482
+ def test_network_on_action_step_rejected(self, tmp_path):
483
+ data = _base()
484
+ data["steps"] = [{"name": "commit", "action": "commit", "network": True,
485
+ "on_pass": "success"}]
486
+ with pytest.raises(ValidationError,
487
+ match="'network' is only allowed on 'agent' or "
488
+ "'command' steps"):
489
+ _load(tmp_path, data)
490
+
491
+ def test_network_false_on_action_step_allowed(self, tmp_path):
492
+ data = _base()
493
+ data["steps"] = [{"name": "commit", "action": "commit", "network": False,
494
+ "on_pass": "success"}]
495
+ assert _load(tmp_path, data).steps[0].network is False
@@ -100,6 +100,17 @@ class TestDryRun:
100
100
  "--no-viz", "--resume", "some-thread-id",
101
101
  "--refresh", "--max-attempts", "5"]) == 0
102
102
 
103
+ def test_network_step_surfaced_in_summary(self, repo, capsys):
104
+ (repo / "pipeline.yaml").write_text(
105
+ DRY_RUN_YAML.replace(
106
+ " command: .venv-sandbox/bin/python -m pytest tests/ -q\n",
107
+ " command: .venv-sandbox/bin/python -m pytest tests/ -q\n"
108
+ " network: true\n"))
109
+ assert main(["pipeline.yaml", "--dry-run", "--plan", "plan.md"]) == 0
110
+ out = capsys.readouterr().out
111
+ assert "test [command]" in out
112
+ assert "network=True" in out
113
+
103
114
 
104
115
  class TestDryRunHelp:
105
116
  def test_help_documents_flag(self, repo, capsys):
@@ -19,9 +19,9 @@ from agentic_dev_loop.isolation import (
19
19
  DockerBackend, EffectiveProfile, ProbeResult, SandboxError,
20
20
  SandboxExecutionRequest, SandboxExecutionResult, append_audit_record,
21
21
  clean_stamps, docker_create_payload, docker_provision_payload,
22
- is_git_mutation, probe_docker, read_stamp, resolve_effective_profile,
23
- resolve_image_ref, resolve_sandbox_env, select_backend, setup_stamp_key,
24
- translate_mount_path, write_stamp,
22
+ exec_container_name, is_git_mutation, probe_docker, read_stamp,
23
+ resolve_effective_profile, resolve_image_ref, resolve_sandbox_env,
24
+ select_backend, setup_stamp_key, translate_mount_path, write_stamp,
25
25
  )
26
26
 
27
27
 
@@ -311,6 +311,30 @@ class TestPayloads:
311
311
  assert "--user" not in create
312
312
  assert "--user" not in provision
313
313
 
314
+ def test_networked_create_payload(self, tmp_path):
315
+ """014: `network: true` swaps the network mode AND the container name,
316
+ but nothing else — the networked container is otherwise identical
317
+ (read-only, cap-dropped, single mount)."""
318
+ ws = str(tmp_path)
319
+ p = docker_create_payload(run_id="r1", image="img",
320
+ profile=self._profile(), workspace=ws,
321
+ network=True)
322
+ assert "--network" in p and "bridge" in p
323
+ assert "none" not in p
324
+ assert "--name" in p and f"{p[p.index('--name') + 1]}" == "r1-exec-net"
325
+ assert "--read-only" in p
326
+ assert "--cap-drop" in p and "ALL" in p
327
+ mounts = [p[i + 1] for i, a in enumerate(p) if a == "-v"]
328
+ assert mounts == [f"{ws}:{ws}"]
329
+
330
+
331
+ class TestExecContainerName:
332
+ def test_default_is_network_none(self):
333
+ assert exec_container_name("abc") == "abc-exec"
334
+
335
+ def test_networked_suffix(self):
336
+ assert exec_container_name("abc", network=True) == "abc-exec-net"
337
+
314
338
 
315
339
  class TestSelectBackend:
316
340
  def test_totality(self):
@@ -374,13 +398,14 @@ class TestResolveImageRef:
374
398
  # ---------------------------------------------------------------------------
375
399
 
376
400
 
377
- def _backend(tmp_path, runner, *, image="img@sha256:abc", git_blocked=True):
401
+ def _backend(tmp_path, runner, *, image="img@sha256:abc", git_blocked=True,
402
+ network=False):
378
403
  return DockerBackend(
379
404
  image=image, workspace=str(tmp_path / "ws"),
380
405
  iso_state_dir=str(tmp_path / "state"),
381
406
  profile=EffectiveProfile(True, True, True, True, git_blocked,
382
407
  image, "4g", 2.0),
383
- runner=runner)
408
+ runner=runner, network=network)
384
409
 
385
410
 
386
411
  class TestEnsureImage:
@@ -432,6 +457,30 @@ class TestCreateExecutionContainer:
432
457
  assert "--network" in runner.calls[0] and "none" in runner.calls[0]
433
458
  assert len(runner.calls) == 1
434
459
 
460
+ def test_networked_second_container_eagerly_created(self, tmp_path):
461
+ """014: when any step declares network, startup creates BOTH the
462
+ network-none container and a bridge-network container — zero infra
463
+ work is deferred to the run phase."""
464
+ runner = FakeRunner(ok("cid-none"), ok("cid-net"))
465
+ backend = _backend(tmp_path, runner, network=True)
466
+ backend.create_execution_container()
467
+ assert len(runner.calls) == 2
468
+ none_argv, net_argv = runner.calls
469
+ assert "--network" in none_argv and "none" in none_argv
470
+ assert none_argv[none_argv.index("--name") + 1].endswith("-exec")
471
+ assert "--network" in net_argv and "bridge" in net_argv
472
+ assert net_argv[net_argv.index("--name") + 1].endswith("-exec-net")
473
+
474
+ def test_networked_container_failure_is_loud_and_reclaims_main(self, tmp_path):
475
+ runner = FakeRunner(ok("cid-none"), fail(125, "network bridge unavailable"))
476
+ backend = _backend(tmp_path, runner, network=True)
477
+ with pytest.raises(SandboxError, match="AE-SBX-013"):
478
+ backend.create_execution_container()
479
+ assert backend._started is False
480
+ # the already-started network-none container is reclaimed, never leaked
481
+ rm = [c for c in runner.calls if c[1] == "rm"]
482
+ assert rm and backend._container in rm[-1]
483
+
435
484
 
436
485
  class TestRun:
437
486
  def test_exec_argv_assembly(self, tmp_path):
@@ -457,6 +506,34 @@ class TestRun:
457
506
  assert backend._container in runner.calls[-1]
458
507
  assert "/usr/bin/timeout" not in runner.calls[-1]
459
508
 
509
+ def test_networked_request_routes_to_network_container(self, tmp_path):
510
+ """014: an exec routes to the bridge-network container iff the request
511
+ declares network — the default stays network-none."""
512
+ runner = FakeRunner(ok(""), ok(""))
513
+ backend = _backend(tmp_path, runner, network=True)
514
+ backend._container = "c1"
515
+ backend._network_container = "c1-net"
516
+ backend._started = True
517
+ backend._network_started = True
518
+ req = SandboxExecutionRequest(argv=["curl", "https://example.com"],
519
+ cwd=str(tmp_path / "ws"), env={},
520
+ network=True)
521
+ backend.run(req)
522
+ assert "c1-net" in runner.calls[-1]
523
+ assert "c1" not in runner.calls[-1]
524
+
525
+ def test_networked_request_without_network_container_is_loud(self, tmp_path):
526
+ runner = FakeRunner()
527
+ backend = _backend(tmp_path, runner, network=True)
528
+ backend._container = "c1"
529
+ backend._started = True # network container never started
530
+ req = SandboxExecutionRequest(argv=["curl", "https://example.com"],
531
+ cwd=str(tmp_path / "ws"), env={},
532
+ network=True)
533
+ with pytest.raises(SandboxError, match="AE-SBX-013"):
534
+ backend.run(req)
535
+ assert not runner.calls # never reached docker exec
536
+
460
537
  def test_exit_124_is_timeout(self, tmp_path):
461
538
  runner = FakeRunner(CmdResult(124, "out", ""))
462
539
  backend = _backend(tmp_path, runner)
@@ -545,6 +622,18 @@ class TestClose:
545
622
  backend.close() # must not raise
546
623
  assert not backend._started
547
624
 
625
+ def test_closes_network_container_too(self, tmp_path):
626
+ runner = FakeRunner(ok(""), ok(""), ok(""), ok(""))
627
+ backend = _backend(tmp_path, runner, network=True)
628
+ backend._container = "c1"
629
+ backend._network_container = "c1-net"
630
+ backend._started = True
631
+ backend._network_started = True
632
+ backend.close()
633
+ stopped = [c[-1] for c in runner.calls if c[1] == "stop"]
634
+ assert stopped == ["c1", "c1-net"]
635
+ assert not backend._started and not backend._network_started
636
+
548
637
 
549
638
  # ---------------------------------------------------------------------------
550
639
  # Provision + stamps + audit (real temp dirs)
@@ -649,6 +738,42 @@ class TestStartupSandbox:
649
738
  finally:
650
739
  sandbox.close()
651
740
 
741
+ def test_network_declaration_starts_second_container(self, tmp_path, monkeypatch):
742
+ """014: declaring `network: true` on any step makes eager startup
743
+ create the bridge-network container in addition to the default
744
+ network-none one — the default (no declaration) stays single-container."""
745
+ from agentic_dev_loop.config import PipelineConfig
746
+ from agentic_dev_loop.isolation import startup_sandbox
747
+
748
+ ws = tmp_path / "ws"
749
+ ws.mkdir()
750
+ cfg = PipelineConfig.model_validate({
751
+ "name": "p", "llm": {"model": "m", "api_key_env": "K"},
752
+ "agents_dir": "agents",
753
+ "steps": [{"name": "dev", "agent": "dev", "on_pass": "test"},
754
+ {"name": "test", "command": "make", "network": True,
755
+ "on_pass": "success"}],
756
+ "isolation": {"state_dir": str(tmp_path / "iso-state")},
757
+ })
758
+ monkeypatch.setattr("agentic_dev_loop.isolation.shutil.which",
759
+ lambda *a: "/usr/bin/docker")
760
+ monkeypatch.setenv("AGENTIC_DEV_LOOP_DEV_IMAGE", "img:tag")
761
+ runner = FakeRunner(
762
+ ok("1.54"), # probe version
763
+ ok("linux"), # probe ostype
764
+ ok(""), # image inspect
765
+ ok(""), # docker run -d --rm (network-none exec)
766
+ ok(""), # docker run -d --rm (networked exec)
767
+ )
768
+ sandbox = startup_sandbox(cfg, workspace=ws, runner=runner)
769
+ try:
770
+ run_argv = [c for c in runner.calls if c[1] == "run"]
771
+ assert len(run_argv) == 2
772
+ assert "--network" in run_argv[0] and "none" in run_argv[0]
773
+ assert "--network" in run_argv[1] and "bridge" in run_argv[1]
774
+ finally:
775
+ sandbox.close()
776
+
652
777
  def test_refresh_forces_provision(self, tmp_path, monkeypatch):
653
778
  from agentic_dev_loop.isolation import (setup_stamp_key, startup_sandbox,
654
779
  write_stamp)
@@ -110,6 +110,60 @@ class TestGitGuardE2E:
110
110
  assert not isinstance(result, SandboxError)
111
111
 
112
112
 
113
+ class TestNetworkedContainerE2E:
114
+ """014: `network: true` steps run in a second, bridge-network container.
115
+ The distinguishing observable is the interface set — network-none has only
116
+ loopback; the bridge container additionally gets a routable eth0. This is
117
+ deterministic (no external egress required)."""
118
+
119
+ @pytest.fixture
120
+ def networked_backend(self, tmp_path):
121
+ reason = _docker_available()
122
+ if reason is not None:
123
+ pytest.skip(reason)
124
+ inspect = SubprocessRunner().run(
125
+ ["docker", "image", "inspect", TEST_IMAGE, "--format", "{{.Id}}"],
126
+ timeout=30)
127
+ if inspect.exit_code != 0:
128
+ pytest.skip(f"integration image {TEST_IMAGE!r} not present locally")
129
+ ws = tmp_path / "ws"
130
+ ws.mkdir()
131
+ profile = resolve_effective_profile(IsolationConfig(image=TEST_IMAGE))
132
+ b = DockerBackend(image=TEST_IMAGE, workspace=str(ws),
133
+ iso_state_dir=str(tmp_path / "state"), profile=profile,
134
+ network=True)
135
+ b.ensure_image()
136
+ b.create_execution_container()
137
+ yield b, ws
138
+ b.close()
139
+
140
+ def test_networked_container_has_bridge_interface(self, networked_backend):
141
+ b, ws = networked_backend
142
+ req = SandboxExecutionRequest(
143
+ argv=["python", "-c",
144
+ "import socket; print(' '.join(sorted(n for _, n in socket.if_nameindex())))"],
145
+ cwd=ws,
146
+ env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"},
147
+ network=True)
148
+ result = b.run(req)
149
+ assert result.exit_code == 0, result.stderr
150
+ names = result.stdout.split()
151
+ assert "lo" in names
152
+ assert any(name != "lo" for name in names)
153
+
154
+ def test_default_container_still_network_none(self, networked_backend):
155
+ b, ws = networked_backend
156
+ req = SandboxExecutionRequest(
157
+ argv=["python", "-c",
158
+ "print(len(open('/proc/net/route').read().strip().splitlines()[1:]))"],
159
+ cwd=ws,
160
+ env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"},
161
+ network=False)
162
+ result = b.run(req)
163
+ assert result.exit_code == 0, result.stderr
164
+ assert result.stdout.strip() == "0" # no routes at all — no egress
165
+
166
+
113
167
  class TestProvisionE2E:
114
168
  def test_provision_stamp_skip_refresh(self, backend, tmp_path):
115
169
  b, ws = backend
@@ -12,8 +12,9 @@ from hypothesis import assume, given, strategies as st
12
12
 
13
13
  from agentic_dev_loop.isolation import (
14
14
  EffectiveProfile, ProbeResult, SandboxExecutionRequest, SandboxExecutionResult,
15
- docker_create_payload, is_git_mutation, resolve_effective_profile,
16
- resolve_sandbox_env, select_backend, setup_stamp_key, translate_mount_path,
15
+ docker_create_payload, exec_container_name, is_git_mutation,
16
+ resolve_effective_profile, resolve_sandbox_env, select_backend,
17
+ setup_stamp_key, translate_mount_path,
17
18
  )
18
19
 
19
20
  profiles = st.builds(
@@ -69,6 +70,27 @@ class TestResolveSandboxEnvScrub:
69
70
  assert set(env) == {"PATH", "HOME", "LANG", "LC_ALL", "TERM"}
70
71
 
71
72
 
73
+ class TestNetworkModePayload:
74
+ @given(profile=profiles, workspace=st.text(min_size=1), run_id=st.text(min_size=1))
75
+ def test_default_is_network_none(self, profile, workspace, run_id):
76
+ p = docker_create_payload(run_id=run_id, image="img", profile=profile,
77
+ workspace=workspace, network=False)
78
+ assert "--network" in p and "none" in p
79
+ assert "--name" in p and p[p.index("--name") + 1] == exec_container_name(run_id)
80
+
81
+ @given(profile=profiles, workspace=st.text(min_size=1), run_id=st.text(min_size=1))
82
+ def test_network_true_swaps_mode_and_name(self, profile, workspace, run_id):
83
+ p = docker_create_payload(run_id=run_id, image="img", profile=profile,
84
+ workspace=workspace, network=True)
85
+ assert "--network" in p and "bridge" in p
86
+ assert "--name" in p and p[p.index("--name") + 1] == exec_container_name(
87
+ run_id, network=True)
88
+ # the rest of the hardening invariants still hold for the networked container
89
+ assert "--read-only" in p and "--cap-drop" in p and "ALL" in p
90
+ mounts = [p[i + 1] for i, a in enumerate(p) if a == "-v"]
91
+ assert mounts == [f"{workspace}:{workspace}"]
92
+
93
+
72
94
  class TestSetupStampKey:
73
95
  @given(cmd=st.text(), lockfiles=st.dictionaries(st.text(min_size=1), st.text()))
74
96
  def test_stable(self, cmd, lockfiles):
@@ -511,6 +511,16 @@ class TestSandboxedCommandNode:
511
511
  assert fake_sandbox.requests[0].env["MY_VAR"] == "declared"
512
512
  assert fake_sandbox.requests[0].env["PATH"].startswith("/usr/local/bin")
513
513
 
514
+ def test_network_defaults_off(self, runtime, fake_sandbox):
515
+ node = self._node(runtime, "echo routed")
516
+ node(make_state())
517
+ assert fake_sandbox.requests[0].network is False
518
+
519
+ def test_network_true_reaches_request(self, runtime, fake_sandbox):
520
+ node = self._node(runtime, "curl https://example.com", network=True)
521
+ node(make_state())
522
+ assert fake_sandbox.requests[0].network is True
523
+
514
524
  def test_git_mutation_hard_fails_not_retried(self, runtime, fake_sandbox):
515
525
  cfg = make_cfg([
516
526
  {"name": "dev", "agent": "dev", "on_pass": "test"},