agentic-dev-loop 1.0.0a31__tar.gz → 1.0.0a34__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.0a31/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a34}/PKG-INFO +20 -5
  2. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/README.md +19 -4
  3. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/config.py +37 -0
  5. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/paths.py +22 -0
  6. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/agents.py +22 -5
  7. agentic_dev_loop-1.0.0a34/src/agentic_dev_loop/engine/allowlist.py +66 -0
  8. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/extensions/aidlc.py +6 -0
  9. agentic_dev_loop-1.0.0a34/src/agentic_dev_loop/engine/forbidden.py +91 -0
  10. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/graph.py +4 -1
  11. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/isolation.py +19 -3
  12. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/stages.py +130 -39
  13. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/sync_back.py +24 -4
  14. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34/src/agentic_dev_loop.egg-info}/PKG-INFO +20 -5
  15. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/SOURCES.txt +4 -0
  16. agentic_dev_loop-1.0.0a34/tests/test_035_finalize_nudge.py +211 -0
  17. agentic_dev_loop-1.0.0a34/tests/test_036_banned_commands_and_lane_boundary.py +273 -0
  18. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_e2e_fixtures.py +4 -0
  19. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/LICENSE +0 -0
  20. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/setup.cfg +0 -0
  21. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/__init__.py +0 -0
  22. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/results.py +0 -0
  24. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/verdict.py +0 -0
  25. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/__init__.py +0 -0
  26. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/actions.py +0 -0
  27. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  28. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  29. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/cli.py +0 -0
  30. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/deps.py +0 -0
  31. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  32. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  33. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  34. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  35. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  36. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/llm.py +0 -0
  37. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/loaders.py +0 -0
  38. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/mounts.py +0 -0
  39. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/outputs.py +0 -0
  40. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/presets.py +0 -0
  41. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/proc.py +0 -0
  42. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/registry.py +0 -0
  43. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  44. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  45. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/secrets.py +0 -0
  46. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  47. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/py.typed +0 -0
  48. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/viz/__init__.py +0 -0
  49. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/viz/__main__.py +0 -0
  50. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/viz/server.py +0 -0
  51. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  52. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  53. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  54. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  55. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_028_declared_caches.py +0 -0
  56. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_029_merge_on_conflict.py +0 -0
  57. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_032_stop_when_green.py +0 -0
  58. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_033_brief_step.py +0 -0
  59. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_actions.py +0 -0
  60. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_agent_error_guide.py +0 -0
  61. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_agents.py +0 -0
  62. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_aidlc.py +0 -0
  63. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_cli.py +0 -0
  64. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_config.py +0 -0
  65. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_deps.py +0 -0
  66. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_dry_run.py +0 -0
  67. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_egress_proxy.py +0 -0
  68. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_graph.py +0 -0
  69. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_hostboundary.py +0 -0
  70. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_hostboundary_integration.py +0 -0
  71. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_isolation.py +0 -0
  72. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_isolation_integration.py +0 -0
  73. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_isolation_pbt.py +0 -0
  74. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_llm.py +0 -0
  75. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_loaders.py +0 -0
  76. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_module_contract.py +0 -0
  77. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_outputs.py +0 -0
  78. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_outputs_pbt.py +0 -0
  79. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_proc.py +0 -0
  80. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_publish.py +0 -0
  81. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_qa_prompt.py +0 -0
  82. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_registry.py +0 -0
  83. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_repo_ascii.py +0 -0
  84. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_results.py +0 -0
  85. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_sdk_compat.py +0 -0
  86. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_secrets.py +0 -0
  87. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_secrets_integration.py +0 -0
  88. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_secrets_pipeline.py +0 -0
  89. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_source_resolution.py +0 -0
  90. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_stages.py +0 -0
  91. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_step_workspace.py +0 -0
  92. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_sync_back.py +0 -0
  93. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_trigger_script.py +0 -0
  94. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_unit5_config.py +0 -0
  95. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_unit5_pbt.py +0 -0
  96. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_unit5_pure.py +0 -0
  97. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/tests/test_verdict.py +0 -0
  98. {agentic_dev_loop-1.0.0a31 → agentic_dev_loop-1.0.0a34}/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.0a31
3
+ Version: 1.0.0a34
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
@@ -224,6 +224,8 @@ steps:
224
224
  agent: qa
225
225
  artifact: qa-report.md
226
226
  verdicts: { pass: PASS, fail: FAIL }
227
+ max_iterations: 100
228
+ finalize_nudge_turns: 10
227
229
  on_pass: commit
228
230
  on_fail: { goto: dev, retry: true }
229
231
 
@@ -239,6 +241,8 @@ steps:
239
241
  | `brief_inputs:` | Optional agent-step input spec: `{ include: [<paths/globs>], env_facts: [<strings>] }`. The engine resolves the include list against the workspace, passes the resolved docs + include contents + env facts as that step's task message, and regenerates the step only when those inputs change. A produces-only agent step such as `brief` is the documented pattern for per-run context digestion. |
240
242
  | `stop_when:` | Optional agent-step termination: `{ green_command: <argv regex>, post_green_turns: <int, default 2> }`. When the agent's most recent terminal observation matching `green_command` exits 0 and no workspace writes have happened since, the engine injects one nudge turn, allows `post_green_turns` further turns, then closes the conversation. Absent = run to finish. |
241
243
  | `terminal_timeout:` | Optional agent-step per-action timeout in seconds (gt 0). `SandboxTerminalExecutor` uses it when the agent leaves the terminal action timeout unset, overriding the SDK tool's default. `None` = current behavior. |
244
+ | `finalize_nudge_turns:` | Optional agent-step final-response nudge (gt 0). Requires `max_iterations:` and must be less than it. At `max_iterations - finalize_nudge_turns` completed turns the engine injects one advisory turn ("`N turns remain: produce your final response now`"), exactly once; the agent may still exhaust its turns and existing stuck/unclear handling applies unchanged. Absent = current behavior. Provisional pending one consumer run (revert if noisy or the raised cap inflates cost). |
245
+ | `custom_prompt:` | Planned (planning-docs/039). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
242
246
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
243
247
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox before the step runs (command or agent steps; network ON; stamp-cached, re-runs when the pipeline's lockfiles change); `network: true` lets a command/agent step reach the hosts its `egress:` allowlist names (default off) - see [Per-stage networking](#per-stage-networking). |
244
248
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -338,6 +342,16 @@ steps:
338
342
  to exactly the declared hosts for that stage; the default stays network-off
339
343
  everywhere. Reserve it for stages that need it (QA, integration). The
340
344
  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.
341
355
 
342
356
  ### Sync-back conflicts (host edits during a run)
343
357
 
@@ -421,9 +435,10 @@ an agent burned budget learning it mid-run:
421
435
  the dev agent's next context; the dev prompt makes every listed issue a
422
436
  required fix.
423
437
  - **No git mutations, ever.** The `commit` step is the single commit point.
424
- - **`max_iterations` per role.** qa is bounded probing (60 in both
425
- reference repos); dev is open-ended construction and stays uncapped
426
- (the engine default).
438
+ - **`max_iterations` per role.** qa is bounded probing (100 in this
439
+ repo's dogfood loop, with `finalize_nudge_turns: 10` so it never ends
440
+ silently at the cap - both provisional); dev is open-ended construction
441
+ and stays uncapped (the engine default).
427
442
 
428
443
  ### The pipeline file
429
444
 
@@ -438,7 +453,7 @@ adapt per repo.
438
453
  | editor-tests | command | network + `presets: [package-registries]` | frontend suites that cannot run offline (`npm ci && npm test`). A separate step, never merged into `test`: the step name is the failure diagnosis, its timeout does not contend the backend suite's, and a retry re-runs only its own chain |
439
454
  | conformance | command | network + `presets: [package-registries]` | repo-specific fitness checks (the consumer repo: jscpd + DDD rules) |
440
455
  | review | agent | ro, offline | static conformance verdict |
441
- | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 60`; inject secrets only if the probes need them |
456
+ | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 100` + `finalize_nudge_turns: 10` (provisional); inject secrets only if the probes need them |
442
457
  | commit | action | host-side | the single commit point |
443
458
 
444
459
  Adaptation checklist for a new repo:
@@ -182,6 +182,8 @@ steps:
182
182
  agent: qa
183
183
  artifact: qa-report.md
184
184
  verdicts: { pass: PASS, fail: FAIL }
185
+ max_iterations: 100
186
+ finalize_nudge_turns: 10
185
187
  on_pass: commit
186
188
  on_fail: { goto: dev, retry: true }
187
189
 
@@ -197,6 +199,8 @@ steps:
197
199
  | `brief_inputs:` | Optional agent-step input spec: `{ include: [<paths/globs>], env_facts: [<strings>] }`. The engine resolves the include list against the workspace, passes the resolved docs + include contents + env facts as that step's task message, and regenerates the step only when those inputs change. A produces-only agent step such as `brief` is the documented pattern for per-run context digestion. |
198
200
  | `stop_when:` | Optional agent-step termination: `{ green_command: <argv regex>, post_green_turns: <int, default 2> }`. When the agent's most recent terminal observation matching `green_command` exits 0 and no workspace writes have happened since, the engine injects one nudge turn, allows `post_green_turns` further turns, then closes the conversation. Absent = run to finish. |
199
201
  | `terminal_timeout:` | Optional agent-step per-action timeout in seconds (gt 0). `SandboxTerminalExecutor` uses it when the agent leaves the terminal action timeout unset, overriding the SDK tool's default. `None` = current behavior. |
202
+ | `finalize_nudge_turns:` | Optional agent-step final-response nudge (gt 0). Requires `max_iterations:` and must be less than it. At `max_iterations - finalize_nudge_turns` completed turns the engine injects one advisory turn ("`N turns remain: produce your final response now`"), exactly once; the agent may still exhaust its turns and existing stuck/unclear handling applies unchanged. Absent = current behavior. Provisional pending one consumer run (revert if noisy or the raised cap inflates cost). |
203
+ | `custom_prompt:` | Planned (planning-docs/039). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
200
204
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
201
205
  | `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox before the step runs (command or agent steps; network ON; stamp-cached, re-runs when the pipeline's lockfiles change); `network: true` lets a command/agent step reach the hosts its `egress:` allowlist names (default off) - see [Per-stage networking](#per-stage-networking). |
202
206
  | `mounts:` / `egress:` | Per-step workspace isolation. `mounts:` lists `{path, mode}` entries (`rw`/`ro`, default `ro`) and is **strict-by-default** - empty/missing mounts nothing; only declared-rw paths sync back to the repo. `egress:` is `{class, presets, targets}` (`class: browser` = broad-but-logged; otherwise a strict allowlist). See [Per-stage networking](#per-stage-networking) and [docs/setup.md](docs/setup.md). |
@@ -296,6 +300,16 @@ steps:
296
300
  to exactly the declared hosts for that stage; the default stays network-off
297
301
  everywhere. Reserve it for stages that need it (QA, integration). The
298
302
  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.
299
313
 
300
314
  ### Sync-back conflicts (host edits during a run)
301
315
 
@@ -379,9 +393,10 @@ an agent burned budget learning it mid-run:
379
393
  the dev agent's next context; the dev prompt makes every listed issue a
380
394
  required fix.
381
395
  - **No git mutations, ever.** The `commit` step is the single commit point.
382
- - **`max_iterations` per role.** qa is bounded probing (60 in both
383
- reference repos); dev is open-ended construction and stays uncapped
384
- (the engine default).
396
+ - **`max_iterations` per role.** qa is bounded probing (100 in this
397
+ repo's dogfood loop, with `finalize_nudge_turns: 10` so it never ends
398
+ silently at the cap - both provisional); dev is open-ended construction
399
+ and stays uncapped (the engine default).
385
400
 
386
401
  ### The pipeline file
387
402
 
@@ -396,7 +411,7 @@ adapt per repo.
396
411
  | editor-tests | command | network + `presets: [package-registries]` | frontend suites that cannot run offline (`npm ci && npm test`). A separate step, never merged into `test`: the step name is the failure diagnosis, its timeout does not contend the backend suite's, and a retry re-runs only its own chain |
397
412
  | conformance | command | network + `presets: [package-registries]` | repo-specific fitness checks (the consumer repo: jscpd + DDD rules) |
398
413
  | review | agent | ro, offline | static conformance verdict |
399
- | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 60`; inject secrets only if the probes need them |
414
+ | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 100` + `finalize_nudge_turns: 10` (provisional); inject secrets only if the probes need them |
400
415
  | commit | action | host-side | the single commit point |
401
416
 
402
417
  Adaptation checklist for a new repo:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a31"
7
+ version = "1.0.0a34"
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"
@@ -387,9 +387,11 @@ class StepConfig(BaseModel):
387
387
  timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
388
388
  terminal_timeout: float | None = Field(default=None, gt=0) # seconds, agent steps only (032)
389
389
  stop_when: StopWhenSpec | None = None # agent steps only (032)
390
+ finalize_nudge_turns: int | None = Field(default=None, gt=0) # agent steps only (035)
390
391
  brief_inputs: BriefInputsSpec | None = None # agent steps only (033)
391
392
  env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
392
393
  setup: str | None = None # BR-13: command steps only
394
+ forbidden_commands: list[str] = [] # banned-command regex deny-list (036 D1)
393
395
  network: bool = False # per-stage egress (command/agent + sandbox actions; host-side actions reject at registry time)
394
396
  # unit-5: per-step mount granularity + egress policy. Empty mounts =
395
397
  # strict-by-default (BR-1.1); egress None = strict empty allowlist
@@ -419,6 +421,26 @@ class StepConfig(BaseModel):
419
421
  def _coerce_produces(cls, v: Any) -> Any:
420
422
  return [v] if isinstance(v, str) else v
421
423
 
424
+ @field_validator("forbidden_commands", mode="before")
425
+ @classmethod
426
+ def _coerce_forbidden_commands(cls, v: Any) -> Any:
427
+ return [v] if isinstance(v, str) else v
428
+
429
+ @field_validator("forbidden_commands")
430
+ @classmethod
431
+ def _valid_forbidden_commands(cls, v: list[str]) -> list[str]:
432
+ for pattern in v:
433
+ if not isinstance(pattern, str) or not pattern.strip():
434
+ raise ValueError(
435
+ "forbidden_commands entries must be non-empty strings")
436
+ try:
437
+ re.compile(pattern)
438
+ except re.error as e:
439
+ raise ValueError(
440
+ f"forbidden_commands entry {pattern!r} is not a valid "
441
+ f"regex: {e}") from e
442
+ return v
443
+
422
444
  @model_validator(mode="after")
423
445
  def _check_shape(self) -> "StepConfig":
424
446
  for key in ("agent", "action", "command"):
@@ -460,6 +482,21 @@ class StepConfig(BaseModel):
460
482
  raise ValueError(
461
483
  f"step {self.name!r}: 'stop_when' requires 'agent'"
462
484
  )
485
+ if self.finalize_nudge_turns is not None and self.agent is None:
486
+ raise ValueError(
487
+ f"step {self.name!r}: 'finalize_nudge_turns' requires 'agent'"
488
+ )
489
+ if self.finalize_nudge_turns is not None and self.max_iterations is None:
490
+ raise ValueError(
491
+ f"step {self.name!r}: 'finalize_nudge_turns' requires "
492
+ "'max_iterations'"
493
+ )
494
+ if (self.finalize_nudge_turns is not None
495
+ and self.finalize_nudge_turns >= self.max_iterations):
496
+ raise ValueError(
497
+ f"step {self.name!r}: 'finalize_nudge_turns' must be less "
498
+ "than 'max_iterations'"
499
+ )
463
500
  if self.brief_inputs is not None and self.agent is None:
464
501
  raise ValueError(
465
502
  f"step {self.name!r}: 'brief_inputs' requires 'agent'"
@@ -67,3 +67,25 @@ def dir_excluded(dir_rel: str, excludes: tuple[str, ...] | list[str]) -> bool:
67
67
  if parts[:len(spec_parts)] == spec_parts:
68
68
  return True
69
69
  return False
70
+
71
+
72
+ def path_allowed(rel: str, allowlist: tuple[str, ...] | list[str]) -> bool:
73
+ """True when a repo-relative posix path matches an allowed path/glob.
74
+
75
+ Mirrors ``path_excluded`` semantics: literal entries match by component
76
+ prefix (the dir and everything under it); wildcard entries match by
77
+ ``fnmatch.fnmatchcase`` on the full posix relpath. A ``.`` (or blank)
78
+ entry allows the whole tree.
79
+ """
80
+ parts = relative_parts(rel)
81
+ for spec in allowlist:
82
+ if _has_wildcards(spec):
83
+ if fnmatch.fnmatchcase(rel, spec):
84
+ return True
85
+ else:
86
+ spec_parts = relative_parts(spec)
87
+ if not spec_parts:
88
+ return True
89
+ if parts[:len(spec_parts)] == spec_parts:
90
+ return True
91
+ return False
@@ -15,6 +15,7 @@ from openhands.tools.file_editor import FileEditorTool # noqa: F401 - registers
15
15
  from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 - registers "task_tracker"
16
16
  from openhands.tools.terminal import TerminalTool # noqa: F401 - registers "terminal"
17
17
 
18
+ from .forbidden import ForbiddenRule, match_forbidden_command
18
19
  from .isolation import (SandboxError, SandboxExecutionRequest, command_argv,
19
20
  resolve_sandbox_env)
20
21
 
@@ -68,7 +69,9 @@ class SandboxTerminalExecutor(ToolExecutor):
68
69
  pipeline: str | None = None, step: str | None = None,
69
70
  network: bool = False, mount_set=None, egress_policy=None,
70
71
  terminal_timeout: float | None = None,
71
- on_terminal_result=None):
72
+ on_terminal_result=None,
73
+ forbidden_commands: tuple[ForbiddenRule, ...] | None = None,
74
+ allowlist: tuple[str, ...] | None = None):
72
75
  self._sandbox = sandbox
73
76
  self._workspace = workspace
74
77
  self._env = env or {}
@@ -79,6 +82,8 @@ class SandboxTerminalExecutor(ToolExecutor):
79
82
  self._egress_policy = egress_policy
80
83
  self._terminal_timeout = terminal_timeout
81
84
  self._on_terminal_result = on_terminal_result
85
+ self._forbidden_commands = forbidden_commands
86
+ self._allowlist = allowlist
82
87
 
83
88
  def __call__(self, action, conversation=None):
84
89
  from openhands.tools.terminal import TerminalObservation
@@ -99,11 +104,19 @@ class SandboxTerminalExecutor(ToolExecutor):
99
104
  timeout = action.timeout
100
105
  if timeout is None and self._terminal_timeout is not None:
101
106
  timeout = self._terminal_timeout
107
+ argv = command_argv(action.command)
108
+ forbidden = match_forbidden_command(argv, self._forbidden_commands)
109
+ if forbidden is not None:
110
+ return TerminalObservation.from_text(
111
+ f"[forbidden: {forbidden}]", is_error=True,
112
+ command=action.command, exit_code=None, timeout=False,
113
+ metadata=CmdOutputMetadata(exit_code=-1))
102
114
  request = SandboxExecutionRequest(
103
- argv=command_argv(action.command), cwd=self._workspace,
115
+ argv=argv, cwd=self._workspace,
104
116
  env=self._env, timeout=timeout,
105
117
  pipeline=self._pipeline, step=self._step, network=self._network,
106
- mount_set=self._mount_set, egress_policy=self._egress_policy)
118
+ mount_set=self._mount_set, egress_policy=self._egress_policy,
119
+ allowlist=self._allowlist)
107
120
  try:
108
121
  result = self._sandbox.run(request)
109
122
  except SandboxError as exc:
@@ -142,7 +155,9 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
142
155
  mount_set=None, egress_policy=None,
143
156
  terminal_timeout: float | None = None,
144
157
  on_terminal_result=None,
145
- secrets: dict[str, str] | None = None) -> None:
158
+ secrets: dict[str, str] | None = None,
159
+ forbidden_commands: tuple[ForbiddenRule, ...] | None = None,
160
+ allowlist: tuple[str, ...] | None = None) -> None:
146
161
  """Per-run re-registration of the `terminal` name (KD-4): a callable
147
162
  factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
148
163
  Pipelines are sequential, so re-registering per agent step is safe.
@@ -161,6 +176,8 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
161
176
  pipeline=pipeline, step=step, network=network,
162
177
  mount_set=mount_set, egress_policy=egress_policy,
163
178
  terminal_timeout=terminal_timeout,
164
- on_terminal_result=on_terminal_result))
179
+ on_terminal_result=on_terminal_result,
180
+ forbidden_commands=forbidden_commands,
181
+ allowlist=allowlist))
165
182
 
166
183
  register_tool("terminal", factory)
@@ -0,0 +1,66 @@
1
+ """Engine-lane allowlist (036 D2 v1).
2
+
3
+ The active AI-DLC unit directory may carry ``engine-allowlist.yaml`` - a YAML
4
+ list of repo-relative allowed paths/globs. Absent = unrestricted (backward
5
+ compatible). Present = sync-back asserts every merged/synced path matches an
6
+ entry and fails the step otherwise. v2 (read-only export root with rw
7
+ bind-mounts for the allowlisted paths) is deferred.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ from agentic_dev_loop.core.config import _validate_declared_path
17
+ from .loaders import select_loader
18
+
19
+ ALLOWLIST_FILENAME = "engine-allowlist.yaml"
20
+
21
+
22
+ def load_engine_allowlist(path: str | Path) -> list[str]:
23
+ """Parse and validate an ``engine-allowlist.yaml`` file.
24
+
25
+ The file is a plain YAML list of repo-relative path/glob strings (the
26
+ same declared-path vocabulary as ``sync_excludes:``). An empty file is a
27
+ valid, deliberately empty allowlist (nothing matches, so every synced
28
+ path violates the lane). Missing files are handled by the caller as the
29
+ unrestricted case, not here.
30
+ """
31
+ path = Path(path)
32
+ if not path.is_file():
33
+ raise FileNotFoundError(f"engine allowlist file not found: {path}")
34
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
35
+ if data is None:
36
+ return []
37
+ if not isinstance(data, list):
38
+ raise ValueError(
39
+ f"{ALLOWLIST_FILENAME} must be a YAML list of paths/globs, "
40
+ f"got {type(data).__name__}")
41
+ return [_validate_declared_path(entry, allow_wildcards=True)
42
+ for entry in data]
43
+
44
+
45
+ def resolve_engine_allowlist(registry, args) -> list[str] | None:
46
+ """Resolve the active unit's allowlist, or None when unrestricted.
47
+
48
+ Only AI-DLC unit loaders expose a unit directory; other loaders (plan,
49
+ none) have no lane boundary. The file lives at
50
+ ``<unit_dir>/engine-allowlist.yaml``.
51
+ """
52
+ loader = select_loader(registry, args)
53
+ if loader is None:
54
+ return None
55
+ unit_dir = getattr(loader, "unit_dir", None)
56
+ if not callable(unit_dir):
57
+ return None
58
+ try:
59
+ path = Path(unit_dir(args)) / ALLOWLIST_FILENAME
60
+ except (FileNotFoundError, RuntimeError):
61
+ # No active AI-DLC unit can be determined (e.g. a resumed run with no
62
+ # aidlc-state file) -> no lane boundary, exactly like an absent file.
63
+ return None
64
+ if not path.is_file():
65
+ return None
66
+ return load_engine_allowlist(path)
@@ -138,6 +138,12 @@ class AidlcDocumentLoader:
138
138
  _, sources = resolve_unit_docs_with_sources(self.docs_dir, unit)
139
139
  return sources
140
140
 
141
+ def unit_dir(self, args: argparse.Namespace) -> Path:
142
+ """The active unit directory (036): where the engine lane boundary
143
+ looks for ``engine-allowlist.yaml``."""
144
+ unit = getattr(args, "aidlc_unit", None) or get_current_unit(self.docs_dir)
145
+ return Path(self.docs_dir) / "construction" / unit
146
+
141
147
 
142
148
  class AidlcExtension:
143
149
  name = "ai-dlc"
@@ -0,0 +1,91 @@
1
+ """Banned-command deny-list (036 D1).
2
+
3
+ The sandbox terminal enforces a deny-list before any shell spawn: a command
4
+ whose argv matches a step's ``forbidden_commands:`` regex (merged with the
5
+ engine defaults) returns a structured observation instead of reaching the
6
+ sandbox. Command steps are unaffected - they are declared infrastructure and
7
+ already fail fast on their exit code.
8
+
9
+ The default list carries human-readable labels; custom entries use their
10
+ regex text as the label. ``dotnet restore`` and ``dotnet add`` are network
11
+ gated: denied only while the step's ``network:`` flag is off.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ForbiddenRule:
22
+ label: str
23
+ regex: re.Pattern[str]
24
+
25
+
26
+ _DEFAULT_PATTERNS: tuple[tuple[str, str], ...] = (
27
+ ("git commit", r"\bgit\s+commit\b"),
28
+ ("git push", r"\bgit\s+push\b"),
29
+ ("git rebase", r"\bgit\s+rebase\b"),
30
+ ("git checkout", r"\bgit\s+checkout\b"),
31
+ ("git reset", r"\bgit\s+reset\b"),
32
+ ("git tag", r"\bgit\s+tag\b"),
33
+ ("npm install", r"\bnpm\s+install\b"),
34
+ ("npm ci", r"\bnpm\s+ci\b"),
35
+ ("pip install", r"\bpip\s+install\b"),
36
+ ("uv add", r"\buv\s+add\b"),
37
+ ("pip-compile", r"\bpip-compile\b"),
38
+ ("dotnet add package", r"\bdotnet\s+add\s+package\b"),
39
+ ("npx playwright", r"\bnpx\s+playwright\b"),
40
+ ("playwright", r"\bplaywright\b"),
41
+ ("docker", r"\bdocker\b"),
42
+ )
43
+
44
+ _NETWORK_GATED_PATTERNS: tuple[tuple[str, str], ...] = (
45
+ ("dotnet restore", r"\bdotnet\s+restore\b"),
46
+ ("dotnet add", r"\bdotnet\s+add\b"),
47
+ )
48
+
49
+
50
+ def _compile(label: str, pattern: str) -> ForbiddenRule:
51
+ return ForbiddenRule(label=label, regex=re.compile(pattern, re.IGNORECASE))
52
+
53
+
54
+ DEFAULT_FORBIDDEN_RULES: tuple[ForbiddenRule, ...] = tuple(
55
+ _compile(label, pattern) for label, pattern in _DEFAULT_PATTERNS)
56
+
57
+ NETWORK_GATED_FORBIDDEN_RULES: tuple[ForbiddenRule, ...] = tuple(
58
+ _compile(label, pattern) for label, pattern in _NETWORK_GATED_PATTERNS)
59
+
60
+
61
+ def resolve_forbidden_rules(custom_patterns: list[str] | tuple[str, ...] | None,
62
+ network: bool) -> tuple[ForbiddenRule, ...]:
63
+ """Merge the engine defaults with a step's custom regex list.
64
+
65
+ Custom entries are appended after the defaults, so the first match wins
66
+ on overlap. Network-gated defaults are included only while ``network`` is
67
+ off; there is no disable escape hatch for the engine defaults.
68
+ """
69
+ rules = list(DEFAULT_FORBIDDEN_RULES)
70
+ if not network:
71
+ rules.extend(NETWORK_GATED_FORBIDDEN_RULES)
72
+ for pattern in custom_patterns or ():
73
+ rules.append(_compile(pattern, pattern))
74
+ return tuple(rules)
75
+
76
+
77
+ def match_forbidden_command(argv: list[str],
78
+ rules: tuple[ForbiddenRule, ...] | None) -> str | None:
79
+ """Return the label of the first matching rule for ``argv``, else None.
80
+
81
+ Match is a regex search over the space-joined argv, so shell-wrapped
82
+ commands (``/bin/sh -c "git commit ..."``) still match. Empty argv never
83
+ matches.
84
+ """
85
+ if not argv:
86
+ return None
87
+ text = " ".join(argv)
88
+ for rule in rules or ():
89
+ if rule.regex.search(text):
90
+ return rule.label
91
+ return None
@@ -18,6 +18,7 @@ from langgraph.graph import END, START, StateGraph
18
18
 
19
19
  from agentic_dev_loop import viz
20
20
  from .actions import git_worktree_clean, kill_listeners_on_port
21
+ from .allowlist import resolve_engine_allowlist
21
22
  from agentic_dev_loop.core.config import load_config
22
23
  from .hostboundary import (check_orchestrator_install,
23
24
  check_tooling_integrity, tooling_rel_paths)
@@ -170,11 +171,13 @@ def run_pipeline(cfg_path, args, *, viz_bus=None,
170
171
  def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
171
172
  sandbox, resolved_secrets, tooling_paths) -> tuple[bool, int]:
172
173
  export_root = getattr(sandbox, "export_root", None)
174
+ engine_allowlist = resolve_engine_allowlist(registry, args)
173
175
  runtime = Runtime(cfg=cfg, registry=registry, llm=build_llm(cfg.llm),
174
176
  agents_dir=base / cfg.agents_dir, state_dir=state_dir,
175
177
  workspace=Path.cwd(), sandbox=sandbox,
176
178
  sandbox_cwd=Path(export_root) if export_root else None,
177
- secrets=resolved_secrets)
179
+ secrets=resolved_secrets,
180
+ engine_allowlist=engine_allowlist)
178
181
  state_dir.mkdir(parents=True, exist_ok=True)
179
182
 
180
183
  resume = getattr(args, "resume", None)
@@ -121,6 +121,10 @@ class SandboxExecutionRequest:
121
121
  # Host-side audit metadata (BR-11) - never sent into the container.
122
122
  pipeline: str | None = None
123
123
  step: str | None = None
124
+ # 036 lane boundary (v1): the unit allowlist every synced path must match.
125
+ # None = unrestricted (no engine-allowlist.yaml); an empty tuple = present
126
+ # but empty file (deny all synced paths).
127
+ allowlist: tuple[str, ...] | None = None
124
128
 
125
129
  def __post_init__(self) -> None:
126
130
  if not self.argv:
@@ -1071,7 +1075,7 @@ class DockerBackend(Sandbox):
1071
1075
  result = self._exec(request, prof.name)
1072
1076
  report: SyncBackReport | None = None
1073
1077
  if mount_set.rw_paths() and self._export_root is not None:
1074
- report = self._sync_back(mount_set)
1078
+ report = self._sync_back(mount_set, request.allowlist)
1075
1079
  if report is not None and (report.synced_paths or report.merged_paths):
1076
1080
  result = SandboxExecutionResult(
1077
1081
  exit_code=result.exit_code, stdout=result.stdout,
@@ -1169,7 +1173,8 @@ class DockerBackend(Sandbox):
1169
1173
  export_path.parent.mkdir(parents=True, exist_ok=True)
1170
1174
  shutil.copy2(host_path, export_path, follow_symlinks=False)
1171
1175
 
1172
- def _sync_back(self, mount_set: MountSet) -> SyncBackReport | None:
1176
+ def _sync_back(self, mount_set: MountSet,
1177
+ allowlist: tuple[str, ...] | None = None) -> SyncBackReport | None:
1173
1178
  """Sync declared-rw export changes back to the real repo (BR-4/F1),
1174
1179
  git-aware, host-side, with the sandbox step already complete.
1175
1180
 
@@ -1184,7 +1189,18 @@ class DockerBackend(Sandbox):
1184
1189
  self._export_root, self._workspace, list(mount_set.rw_paths()),
1185
1190
  baseline=self._baseline, step_id=None,
1186
1191
  cache_paths=self._cache_paths, excludes=self._sync_excludes,
1187
- merge_on_conflict=self._merge_on_conflict)
1192
+ merge_on_conflict=self._merge_on_conflict, allowlist=allowlist)
1193
+
1194
+ # 036 lane boundary (v1): fail the step before any copy/delete/merge
1195
+ # so an out-of-lane write is never promoted into the real repo.
1196
+ if report.lane_violations:
1197
+ _announce(
1198
+ "lane violation: "
1199
+ + ", ".join(report.lane_violations)
1200
+ + " outside engine-allowlist.yaml")
1201
+ raise SandboxError(
1202
+ "engine lane violation: synced/merged paths outside the unit "
1203
+ "engine-allowlist.yaml: " + ", ".join(report.lane_violations))
1188
1204
 
1189
1205
  # 029 step 2: union-merge declared merge paths first, so a mixed
1190
1206
  # merge+hard conflict still converges the append-style logs before