agentic-dev-loop 1.0.0a10__tar.gz → 1.0.0a12__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 (70) hide show
  1. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/PKG-INFO +74 -1
  2. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/README.md +72 -0
  3. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/pyproject.toml +2 -1
  4. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/config.py +37 -0
  5. agentic_dev_loop-1.0.0a12/src/agentic_dev_loop/engine/deps.py +77 -0
  6. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/isolation.py +122 -26
  7. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/stages.py +21 -2
  8. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/PKG-INFO +74 -1
  9. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/SOURCES.txt +3 -0
  10. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/requires.txt +1 -0
  11. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_cli.py +4 -0
  12. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_config.py +46 -0
  13. agentic_dev_loop-1.0.0a12/tests/test_deps.py +76 -0
  14. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_dry_run.py +1 -0
  15. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_e2e_fixtures.py +5 -4
  16. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_graph.py +38 -0
  17. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_hostboundary.py +1 -0
  18. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_hostboundary_integration.py +1 -0
  19. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_isolation.py +159 -3
  20. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_isolation_integration.py +72 -0
  21. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_secrets.py +1 -0
  22. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_secrets_pipeline.py +2 -0
  23. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_stages.py +52 -1
  24. agentic_dev_loop-1.0.0a12/tests/test_trigger_script.py +111 -0
  25. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_viz.py +1 -0
  26. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/LICENSE +0 -0
  27. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/setup.cfg +0 -0
  28. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/__init__.py +0 -0
  29. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/__init__.py +0 -0
  30. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/results.py +0 -0
  31. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/verdict.py +0 -0
  32. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/__init__.py +0 -0
  33. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/actions.py +0 -0
  34. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/agents.py +0 -0
  35. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/cli.py +0 -0
  36. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  37. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  38. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  39. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/graph.py +0 -0
  40. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  41. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/llm.py +0 -0
  42. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/loaders.py +0 -0
  43. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/outputs.py +0 -0
  44. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/proc.py +0 -0
  45. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/registry.py +0 -0
  46. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/secrets.py +0 -0
  47. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/py.typed +0 -0
  48. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__init__.py +0 -0
  49. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__main__.py +0 -0
  50. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/server.py +0 -0
  51. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  52. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  53. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  54. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_actions.py +0 -0
  55. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_agents.py +0 -0
  56. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_aidlc.py +0 -0
  57. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_isolation_pbt.py +0 -0
  58. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_llm.py +0 -0
  59. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_loaders.py +0 -0
  60. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_module_contract.py +0 -0
  61. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_outputs.py +0 -0
  62. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_outputs_pbt.py +0 -0
  63. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_proc.py +0 -0
  64. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_publish.py +0 -0
  65. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_registry.py +0 -0
  66. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_results.py +0 -0
  67. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_sdk_compat.py +0 -0
  68. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_secrets_integration.py +0 -0
  69. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_source_resolution.py +0 -0
  70. {agentic_dev_loop-1.0.0a10 → agentic_dev_loop-1.0.0a12}/tests/test_verdict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a10
3
+ Version: 1.0.0a12
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
@@ -35,6 +35,7 @@ Requires-Dist: langgraph-checkpoint-sqlite>=3.0
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: hypothesis>=6.0; extra == "dev"
37
37
  Requires-Dist: pytest>=8.0; extra == "dev"
38
+ Requires-Dist: pip-audit>=2.0; extra == "dev"
38
39
  Dynamic: license-file
39
40
 
40
41
  # agentic-dev-loop
@@ -213,6 +214,8 @@ steps:
213
214
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
214
215
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
215
216
  | `extensions_dir` | Where consumer `extensions:` modules resolve — defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
217
+ | `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
218
+ | `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` — a `locked` pipeline with no lockfiles fails fast at load. |
216
219
 
217
220
  ### Secrets (`secrets:`) — refs only, never values
218
221
 
@@ -278,6 +281,76 @@ steps:
278
281
  for stages that need it (QA, integration). The residual risk is documented
279
282
  in [docs/setup.md](docs/setup.md).
280
283
 
284
+ ## Dependency policy (locked by default)
285
+
286
+ The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
287
+ default**. The engine injects the policy into every agent step's context —
288
+ the rule lives in the engine, not in hand-authored `dev.agent.md` prose (the
289
+ class of rule that deadlocked the NuGet cache).
290
+
291
+ ```yaml
292
+ deps_policy: locked # or: open (default: locked)
293
+ lockfiles: [uv.lock] # or: [packages.lock.json, package-lock.json]
294
+ ```
295
+
296
+ - **`locked`** — the human-owned lockfile is the source of truth; the agent
297
+ may not add dependencies. The injected rule says *"You may only use
298
+ dependencies already present in `<lockfile>`. If the design requires a
299
+ dependency that is not present, declare it in your final response under the
300
+ fixed header `## Missing dependencies` (one `name==version` per line) and
301
+ finish what you can. Do not add it to the manifest."* A non-empty
302
+ `## Missing dependencies` declaration **fails the run fast** with
303
+ `Extend <lockfile> with these pins and re-run: …` — one human lockfile edit,
304
+ not a retry loop. The declaration is advisory to the human; the engine never
305
+ auto-fetches the declared packages.
306
+ - **`open`** — the bootstrap protocol (018): the agent may add exact-pinned
307
+ manifest entries directly (never a package-manager `add` command); the
308
+ network-on restore step fetches them. The engine injects the same rule
309
+ canonically. Nothing about `open` is default.
310
+
311
+ The **restore step** runs the toolchain's locked mode and fails when the
312
+ manifest and lockfile disagree:
313
+
314
+ | Toolchain | Locked restore | Lockfile |
315
+ | --- | --- | --- |
316
+ | .NET | `dotnet restore --locked-mode` | `packages.lock.json` (set `RestorePackagesWithLockFile=true`) |
317
+ | node | `npm ci` | `package-lock.json` |
318
+ | python | `uv sync --locked` (or pip-tools `pip-sync requirements.lock`) | `uv.lock` — pip has no native lockfile; `uv` is the canonical locked-install tool |
319
+
320
+ ### Audit escalation in the restore step
321
+
322
+ The restore step also fails on known-CVE findings, at a per-toolchain
323
+ threshold:
324
+
325
+ | Toolchain | Audit (fails the step) |
326
+ | --- | --- |
327
+ | .NET | `NuGetAudit` at `error` — any audit finding fails the restore |
328
+ | node | `npm audit --audit-level=high` |
329
+ | python | `pip-audit` — any finding fails |
330
+
331
+ Under `locked`, a failing audit means the lockfile pins a vulnerable version:
332
+ the run fails and the human regenerates the lockfile at a patched version.
333
+ Under `open`, the failure routes to `dev` like any restore failure — the dev
334
+ agent can bump the pin, but that pin is audited again on the next pass, so
335
+ the loop self-corrects.
336
+
337
+ ### Generating a lockfile
338
+
339
+ A human generates the lockfile once, on a network machine, and commits it —
340
+ the engine never fetches on the agent's behalf:
341
+
342
+ ```bash
343
+ uv lock # python — writes uv.lock from pyproject.toml
344
+ uv sync --locked # installs exactly uv.lock; fails on any drift
345
+ npm install --package-lock-only # node — writes package-lock.json
346
+ npm ci # installs exactly package-lock.json
347
+ dotnet restore --use-lock-file # .NET — writes packages.lock.json
348
+ dotnet restore --locked-mode # installs exactly packages.lock.json
349
+ ```
350
+
351
+ Keep the lockfile tracked (do not gitignore it) and re-run the locked restore
352
+ to prove it holds from a clean cache before setting `deps_policy: locked`.
353
+
281
354
  ## Host boundary (security model)
282
355
 
283
356
  The orchestrator, tool layer, and the `commit` action run in the **host
@@ -174,6 +174,8 @@ steps:
174
174
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
175
175
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
176
176
  | `extensions_dir` | Where consumer `extensions:` modules resolve — defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
177
+ | `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
178
+ | `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` — a `locked` pipeline with no lockfiles fails fast at load. |
177
179
 
178
180
  ### Secrets (`secrets:`) — refs only, never values
179
181
 
@@ -239,6 +241,76 @@ steps:
239
241
  for stages that need it (QA, integration). The residual risk is documented
240
242
  in [docs/setup.md](docs/setup.md).
241
243
 
244
+ ## Dependency policy (locked by default)
245
+
246
+ The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
247
+ default**. The engine injects the policy into every agent step's context —
248
+ the rule lives in the engine, not in hand-authored `dev.agent.md` prose (the
249
+ class of rule that deadlocked the NuGet cache).
250
+
251
+ ```yaml
252
+ deps_policy: locked # or: open (default: locked)
253
+ lockfiles: [uv.lock] # or: [packages.lock.json, package-lock.json]
254
+ ```
255
+
256
+ - **`locked`** — the human-owned lockfile is the source of truth; the agent
257
+ may not add dependencies. The injected rule says *"You may only use
258
+ dependencies already present in `<lockfile>`. If the design requires a
259
+ dependency that is not present, declare it in your final response under the
260
+ fixed header `## Missing dependencies` (one `name==version` per line) and
261
+ finish what you can. Do not add it to the manifest."* A non-empty
262
+ `## Missing dependencies` declaration **fails the run fast** with
263
+ `Extend <lockfile> with these pins and re-run: …` — one human lockfile edit,
264
+ not a retry loop. The declaration is advisory to the human; the engine never
265
+ auto-fetches the declared packages.
266
+ - **`open`** — the bootstrap protocol (018): the agent may add exact-pinned
267
+ manifest entries directly (never a package-manager `add` command); the
268
+ network-on restore step fetches them. The engine injects the same rule
269
+ canonically. Nothing about `open` is default.
270
+
271
+ The **restore step** runs the toolchain's locked mode and fails when the
272
+ manifest and lockfile disagree:
273
+
274
+ | Toolchain | Locked restore | Lockfile |
275
+ | --- | --- | --- |
276
+ | .NET | `dotnet restore --locked-mode` | `packages.lock.json` (set `RestorePackagesWithLockFile=true`) |
277
+ | node | `npm ci` | `package-lock.json` |
278
+ | python | `uv sync --locked` (or pip-tools `pip-sync requirements.lock`) | `uv.lock` — pip has no native lockfile; `uv` is the canonical locked-install tool |
279
+
280
+ ### Audit escalation in the restore step
281
+
282
+ The restore step also fails on known-CVE findings, at a per-toolchain
283
+ threshold:
284
+
285
+ | Toolchain | Audit (fails the step) |
286
+ | --- | --- |
287
+ | .NET | `NuGetAudit` at `error` — any audit finding fails the restore |
288
+ | node | `npm audit --audit-level=high` |
289
+ | python | `pip-audit` — any finding fails |
290
+
291
+ Under `locked`, a failing audit means the lockfile pins a vulnerable version:
292
+ the run fails and the human regenerates the lockfile at a patched version.
293
+ Under `open`, the failure routes to `dev` like any restore failure — the dev
294
+ agent can bump the pin, but that pin is audited again on the next pass, so
295
+ the loop self-corrects.
296
+
297
+ ### Generating a lockfile
298
+
299
+ A human generates the lockfile once, on a network machine, and commits it —
300
+ the engine never fetches on the agent's behalf:
301
+
302
+ ```bash
303
+ uv lock # python — writes uv.lock from pyproject.toml
304
+ uv sync --locked # installs exactly uv.lock; fails on any drift
305
+ npm install --package-lock-only # node — writes package-lock.json
306
+ npm ci # installs exactly package-lock.json
307
+ dotnet restore --use-lock-file # .NET — writes packages.lock.json
308
+ dotnet restore --locked-mode # installs exactly packages.lock.json
309
+ ```
310
+
311
+ Keep the lockfile tracked (do not gitignore it) and re-run the locked restore
312
+ to prove it holds from a clean cache before setting `deps_policy: locked`.
313
+
242
314
  ## Host boundary (security model)
243
315
 
244
316
  The orchestrator, tool layer, and the `commit` action run in the **host
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a10"
7
+ version = "1.0.0a12"
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"
@@ -55,6 +55,7 @@ agentic-dev-loop = "agentic_dev_loop.engine.cli:main"
55
55
  dev = [
56
56
  "hypothesis>=6.0",
57
57
  "pytest>=8.0",
58
+ "pip-audit>=2.0",
58
59
  ]
59
60
 
60
61
  [tool.setuptools.packages.find]
@@ -79,6 +79,13 @@ class IsolationConfig(BaseModel):
79
79
  cpus: float | None = None # override exec container cpus
80
80
  allow_git_mutations: bool = False # BR-6 knob
81
81
  state_dir: str = "~/.agentic-dev-loop/state" # stamps + audit (host-only)
82
+ # 022 relocation opt-in: an out-of-workspace host dir that is bind-mounted
83
+ # at ``{ws}/.venv-sandbox`` (rw) in both the exec and provision containers,
84
+ # keeping the workspace free of container venv artifacts. Unset keeps the
85
+ # legacy workspace-local ``.venv-sandbox`` unchanged. Must resolve outside
86
+ # the workspace — validated at startup, not load (the workspace is unknown
87
+ # here).
88
+ sandbox_venv_dir: str | None = None
82
89
 
83
90
  @field_validator("memory")
84
91
  @classmethod
@@ -214,6 +221,27 @@ class PipelineConfig(BaseModel):
214
221
  max_attempts: int = 3
215
222
  agents_dir: str
216
223
  steps: list[StepConfig] = Field(min_length=1)
224
+ # 021 supply-chain dependency dial: `locked` (default) = the human-owned
225
+ # lockfile is the source of truth and the agent may not add dependencies;
226
+ # `open` = the bootstrap protocol (the agent may add exact-pinned manifest
227
+ # entries for the restore step to fetch). `lockfiles` names the pipeline's
228
+ # declared lockfile(s) — injected into agent context and named by the
229
+ # missing-dependency fail-fast.
230
+ deps_policy: str = "locked"
231
+ lockfiles: list[str] = []
232
+
233
+ @field_validator("deps_policy")
234
+ @classmethod
235
+ def _known_deps_policy(cls, v: str) -> str:
236
+ if v not in ("locked", "open"):
237
+ raise ValueError(
238
+ f"deps_policy must be 'locked' or 'open', got {v!r}")
239
+ return v
240
+
241
+ @field_validator("lockfiles", mode="before")
242
+ @classmethod
243
+ def _coerce_lockfiles(cls, v: Any) -> Any:
244
+ return [v] if isinstance(v, str) else v
217
245
 
218
246
  @field_validator("secrets", mode="before")
219
247
  @classmethod
@@ -232,6 +260,15 @@ class PipelineConfig(BaseModel):
232
260
  "values never belong in the pipeline yaml")
233
261
  return v
234
262
 
263
+ @model_validator(mode="after")
264
+ def _locked_requires_lockfiles(self) -> "PipelineConfig":
265
+ if self.deps_policy == "locked" and not self.lockfiles:
266
+ raise ValueError(
267
+ "deps_policy 'locked' requires at least one entry in "
268
+ "'lockfiles:' — name the lockfile(s) the locked boundary "
269
+ "enforces, or set 'deps_policy: open'")
270
+ return self
271
+
235
272
  @model_validator(mode="after")
236
273
  def _check_graph(self) -> "PipelineConfig":
237
274
  names = [s.name for s in self.steps]
@@ -0,0 +1,77 @@
1
+ """Supply-chain dependency dial (021): canonical policy injection and
2
+ missing-dependency declaration parsing.
3
+
4
+ The rule text lives HERE, in the engine, not in consumer `dev.agent.md` prose
5
+ — the hand-authored rule is exactly the class that deadlocked the NuGet cache
6
+ in 018. `deps_policy: locked` (the default) bounds the agent to the
7
+ human-owned lockfile and fails a run fast when an agent declares a missing
8
+ dependency; `deps_policy: open` injects the bootstrap protocol (the agent may
9
+ add exact-pinned manifest entries for the restore step to fetch).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from agentic_dev_loop.core.config import PipelineConfig
18
+
19
+ MISSING_DEPS_HEADER = "## Missing dependencies"
20
+
21
+ _LOCKED_TEMPLATE = (
22
+ "You may only use dependencies already present in {lockfiles}. If the "
23
+ "design requires a dependency that is not present, declare it in your "
24
+ "final response under the fixed header `{header}` (one `name==version` "
25
+ "per line) and finish what you can. Do not add it to the manifest."
26
+ )
27
+
28
+ _OPEN_TEXT = (
29
+ "If the design needs a dependency that is not already present, add the "
30
+ "exact-pinned entry to the dependency manifest directly (do not run a "
31
+ "package-manager `add` command — it may need the network). Your offline "
32
+ "builds may fail until the restore step fetches it — that is expected; "
33
+ "write the code to match and leave the manifest entry in place."
34
+ )
35
+
36
+
37
+ def _lockfile_label(cfg: "PipelineConfig") -> str:
38
+ if cfg.lockfiles:
39
+ return ", ".join(f"`{name}`" for name in cfg.lockfiles)
40
+ return "the lockfile"
41
+
42
+
43
+ def policy_injection(cfg: "PipelineConfig") -> str:
44
+ """The canonical dependency-policy paragraph injected into every agent
45
+ step's context, keyed on ``deps_policy`` and the declared lockfiles."""
46
+ if cfg.deps_policy == "open":
47
+ return _OPEN_TEXT
48
+ return _LOCKED_TEMPLATE.format(
49
+ lockfiles=_lockfile_label(cfg), header=MISSING_DEPS_HEADER)
50
+
51
+
52
+ def parse_missing_dependencies(text: str | None) -> list[str]:
53
+ """Parse a final agent response for the fixed ``## Missing dependencies``
54
+ section. Returns the declared pins (one non-empty line each) beneath the
55
+ header, until the next ``## `` section or end of text. An absent header or
56
+ an empty/malformed section yields an empty list — only a non-empty
57
+ declaration triggers the fail-fast."""
58
+ if not text:
59
+ return []
60
+ lines = text.splitlines()
61
+ for i, line in enumerate(lines):
62
+ if line.strip() == MISSING_DEPS_HEADER:
63
+ pins: list[str] = []
64
+ for rest in lines[i + 1:]:
65
+ if rest.startswith("## "):
66
+ break
67
+ stripped = rest.strip()
68
+ if stripped:
69
+ pins.append(stripped)
70
+ return pins
71
+ return []
72
+
73
+
74
+ def extension_message(cfg: "PipelineConfig", pins: list[str]) -> str:
75
+ """The fail-fast message naming the lockfile(s) and the declared pins."""
76
+ return (f"Extend {_lockfile_label(cfg)} with these pins and re-run:\n\n"
77
+ + "\n".join(pins))
@@ -7,10 +7,13 @@ concrete implementation (user directive 2026-10-19). Everything else here is
7
7
  either pure translation logic (the PBT-03 targets) or host-side audit/stamp
8
8
  helpers.
9
9
 
10
- Host-only vs sandboxed: the sandbox mount set is EXACTLY {workspace} (Q1).
11
- The isolation state dir (stamps + audit JSONL) and the pipeline run-state dir
12
- (checkpoint DB + last-error.md) are never mounted — sandboxed code cannot
13
- reach them.
10
+ Host-only vs sandboxed: the sandbox mount set is {workspace} plus the
11
+ host-only mask mounts (022): a read-only empty tmpfs at ``{ws}/.venv`` so the
12
+ host's throwaway venv can never be written from inside the sandbox. When
13
+ ``isolation.sandbox_venv_dir`` relocates the sandbox venv, one extra rw bind
14
+ mount at ``{ws}/.venv-sandbox`` is added (opt-in). The isolation state dir
15
+ (stamps + audit JSONL) and the pipeline run-state dir (checkpoint DB +
16
+ last-error.md) are never mounted — sandboxed code cannot reach them.
14
17
  """
15
18
 
16
19
  from __future__ import annotations
@@ -74,6 +77,10 @@ PROVISION_TIMEOUT_SECONDS = 3600
74
77
  # container, so it must be detected and re-provisioned there.
75
78
  SANDBOX_VENV_DIR = ".venv-sandbox"
76
79
  SANDBOX_VENV_PYTHON = ".venv-sandbox/bin/python"
80
+ # Host-only dirs masked inside the sandbox (022): each gets a read-only empty
81
+ # tmpfs submount at ``{ws}/<dir>`` so agents fail loudly on any write instead
82
+ # of silently mutating a host-owned throwaway venv.
83
+ HOST_ONLY_DIRS = (".venv",)
77
84
 
78
85
 
79
86
  class SandboxError(RuntimeError):
@@ -277,6 +284,27 @@ def translate_mount_path(path: str) -> str:
277
284
  return str(p)
278
285
 
279
286
 
287
+ def resolve_sandbox_venv_dir(sandbox_venv_dir: str | None,
288
+ workspace: str | Path) -> str | None:
289
+ """Resolve the opt-in relocated sandbox venv (022).
290
+
291
+ ``None`` keeps the legacy workspace-local ``.venv-sandbox`` (bit-compatible
292
+ default — payloads are unchanged). A set value is expanduser + absolute and
293
+ must resolve OUTSIDE the workspace: an in-workspace value defeats the whole
294
+ relocation and fails loud (AE-SBX-015).
295
+ """
296
+ if sandbox_venv_dir is None:
297
+ return None
298
+ resolved = Path(translate_mount_path(sandbox_venv_dir))
299
+ ws = Path(translate_mount_path(os.fspath(workspace)))
300
+ if resolved == ws or ws in resolved.parents:
301
+ raise SandboxError(
302
+ f"AE-SBX-015: isolation.sandbox_venv_dir {resolved!r} must resolve "
303
+ "outside the workspace — an in-workspace value defeats the 022 "
304
+ "relocation (docs/setup.md §3)")
305
+ return str(resolved)
306
+
307
+
280
308
  def setup_stamp_key(setup_cmd: str, lockfile_hashes: dict[str, str]) -> str:
281
309
  """sha256 hex of canonical JSON {setup_cmd, lockfiles: sorted hashes}."""
282
310
  canonical = {
@@ -323,10 +351,44 @@ def venv_probe_state(stdout: str, exit_code: int | None) -> str:
323
351
  return line if line in ("valid", "missing", "broken") else "unknown"
324
352
 
325
353
 
326
- def remove_sandbox_venv(workspace: str | Path) -> None:
327
- """Remove the workspace's sandbox venv before re-provision (016 self-heal).
328
- Fails loud when the venv exists but cannot be removed — a silent skip
329
- would re-create the ENOENT failure the next run."""
354
+ def _clear_dir_contents(path: Path) -> None:
355
+ """Delete every entry under ``path``, keeping ``path`` itself. The 022
356
+ relocated venv is the bind-mount source for the still-running execution
357
+ container, so the source dir must survive removal (deleting it would leave
358
+ the container pointed at a stale inode)."""
359
+ if not path.exists():
360
+ return
361
+ for child in path.iterdir():
362
+ if child.is_symlink() or child.is_file():
363
+ child.unlink()
364
+ elif child.is_dir():
365
+ shutil.rmtree(child)
366
+
367
+
368
+ def remove_sandbox_venv(workspace: str | Path, *,
369
+ sandbox_venv_dir: str | None = None) -> None:
370
+ """Remove the sandbox venv before re-provision (016 self-heal). Fails loud
371
+ when the venv exists but cannot be removed — a silent skip would re-create
372
+ the ENOENT failure the next run.
373
+
374
+ With the 022 relocation opt-in (``sandbox_venv_dir``), the venv lives at
375
+ that host path and is the source of a live bind mount in the execution
376
+ container — so only its CONTENTS are removed (the mount source dir stays),
377
+ leaving the container's ``{ws}/.venv-sandbox`` mount valid for re-provision.
378
+ The default (unset) path removes the whole workspace-local dir as before.
379
+ """
380
+ if sandbox_venv_dir is not None:
381
+ venv = Path(sandbox_venv_dir)
382
+ try:
383
+ if venv.is_symlink() or venv.is_file():
384
+ venv.unlink()
385
+ elif venv.is_dir():
386
+ _clear_dir_contents(venv)
387
+ except OSError as e:
388
+ raise SandboxError(
389
+ f"AE-SBX-014: cannot remove invalid sandbox venv at {venv}: {e} "
390
+ f"— remove it manually and re-run (docs/setup.md §5)") from e
391
+ return
330
392
  venv = Path(workspace) / SANDBOX_VENV_DIR
331
393
  try:
332
394
  if venv.is_symlink() or venv.is_file():
@@ -360,15 +422,34 @@ def exec_container_name(run_id: str, network: bool = False) -> str:
360
422
  return f"{run_id}-exec-net" if network else f"{run_id}-exec"
361
423
 
362
424
 
425
+ def _sandbox_mounts(workspace: str, sandbox_venv_dir: str | None) -> list[str]:
426
+ """The mount segment shared by the exec and provision payloads (022).
427
+
428
+ ``{workspace}`` rw is always first; each host-only dir is then shadowed by
429
+ a read-only empty tmpfs submount (Docker cannot exclude a subdirectory of a
430
+ bind — a submount is the only way to hide it, and ``ro`` makes any write
431
+ attempt fail loudly instead of silently succeeding against a decoy). When
432
+ ``sandbox_venv_dir`` relocates the sandbox venv, one rw bind mount maps it
433
+ to the unchanged container path ``{ws}/.venv-sandbox``.
434
+ """
435
+ mounts = ["-v", f"{workspace}:{workspace}"]
436
+ for host_only in HOST_ONLY_DIRS:
437
+ mounts += ["--tmpfs", f"{workspace}/{host_only}:ro"]
438
+ if sandbox_venv_dir is not None:
439
+ mounts += ["-v", f"{sandbox_venv_dir}:{workspace}/{SANDBOX_VENV_DIR}"]
440
+ return mounts
441
+
442
+
363
443
  def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
364
- workspace: str, network: bool = False) -> list[str]:
444
+ workspace: str, network: bool = False,
445
+ sandbox_venv_dir: str | None = None) -> list[str]:
365
446
  """Exact ``docker run -d --rm`` argv for the execution container (§3.3, KD-8).
366
- Single mount {workspace} (Q1); ``--network none`` (bridge when ``network``
367
- is true — 014 per-stage networking), --read-only, --cap-drop ALL; --user
368
- host uid:gid (host-owned bind mounts, AE-SBX-009 root cause) with
369
- HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs). ``--rm`` lets the
370
- daemon auto-remove the container the instant it exits, however it exits
371
- (D1) — a SIGKILLed CLI never reaches close()."""
447
+ Mount set {workspace} + host-only masks (022); ``--network none`` (bridge
448
+ when ``network`` is true — 014 per-stage networking), --read-only,
449
+ --cap-drop ALL; --user host uid:gid (host-owned bind mounts, AE-SBX-009
450
+ root cause) with HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs).
451
+ ``--rm`` lets the daemon auto-remove the container the instant it exits,
452
+ however it exits (D1) — a SIGKILLed CLI never reaches close()."""
372
453
  payload = [
373
454
  "docker", "run", "--detach", "--rm", "--name",
374
455
  exec_container_name(run_id, network),
@@ -380,8 +461,8 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
380
461
  user = host_user_flag()
381
462
  if user is not None:
382
463
  payload.extend(["--user", user, "--env", "HOME=/tmp"])
464
+ payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
383
465
  payload.extend([
384
- "-v", f"{workspace}:{workspace}",
385
466
  "--workdir", workspace,
386
467
  image, "sleep", "infinity",
387
468
  ])
@@ -389,10 +470,12 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
389
470
 
390
471
 
391
472
  def docker_provision_payload(*, run_id: str, image: str, workspace: str,
392
- setup_cmd: str) -> list[str]:
473
+ setup_cmd: str,
474
+ sandbox_venv_dir: str | None = None) -> list[str]:
393
475
  """Exact ``docker run --rm`` argv for the provision container (§3.4).
394
- Network ON (default bridge), rw rootfs, --cap-drop ALL, single mount
395
- {workspace} (Q1); --user host uid:gid (AE-SBX-009) with HOME=/tmp."""
476
+ Network ON (default bridge), rw rootfs, --cap-drop ALL, mount set
477
+ {workspace} + host-only masks (022); --user host uid:gid (AE-SBX-009) with
478
+ HOME=/tmp."""
396
479
  payload = [
397
480
  "docker", "run", "--rm", "--name", f"{run_id}-provision",
398
481
  "--network", "bridge", "--cap-drop", "ALL",
@@ -401,8 +484,8 @@ def docker_provision_payload(*, run_id: str, image: str, workspace: str,
401
484
  user = host_user_flag()
402
485
  if user is not None:
403
486
  payload.extend(["--user", user, "--env", "HOME=/tmp"])
487
+ payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
404
488
  payload.extend([
405
- "-v", f"{workspace}:{workspace}",
406
489
  "--workdir", workspace,
407
490
  image, "sh", "-c", setup_cmd,
408
491
  ])
@@ -502,7 +585,7 @@ class DockerBackend(Sandbox):
502
585
 
503
586
  def __init__(self, *, image: str, workspace: str, iso_state_dir: str,
504
587
  profile: EffectiveProfile, runner: CommandRunner | None = None,
505
- network: bool = False):
588
+ network: bool = False, sandbox_venv_dir: str | None = None):
506
589
  self._image = image
507
590
  self._workspace = workspace
508
591
  self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
@@ -512,6 +595,13 @@ class DockerBackend(Sandbox):
512
595
  self._container = exec_container_name(self._run_id, network=False)
513
596
  self._network_container = exec_container_name(self._run_id, network=True)
514
597
  self._network_enabled = network
598
+ self._sandbox_venv_dir = sandbox_venv_dir # 022 relocated venv (opt-in)
599
+ if sandbox_venv_dir is not None:
600
+ # 022: the relocated venv dir is a bind-mount source. On native
601
+ # Linux, Docker auto-creates a missing source as root, which the
602
+ # --user uid:gid containers then cannot write (the AE-SBX-009
603
+ # ownership class). Pre-create it host-side so it is uid-owned.
604
+ Path(sandbox_venv_dir).mkdir(parents=True, exist_ok=True)
515
605
  self._started = False
516
606
  self._network_started = False
517
607
 
@@ -551,7 +641,8 @@ class DockerBackend(Sandbox):
551
641
  infrastructure work happens mid-run (014 per-stage networking)."""
552
642
  payload = docker_create_payload(
553
643
  run_id=self._run_id, image=self._image, profile=self._profile,
554
- workspace=self._workspace, network=False)
644
+ workspace=self._workspace, network=False,
645
+ sandbox_venv_dir=self._sandbox_venv_dir)
555
646
  res = self._runner.run(payload, timeout=60)
556
647
  if res.exit_code != 0:
557
648
  raise SandboxError(
@@ -562,7 +653,8 @@ class DockerBackend(Sandbox):
562
653
  return
563
654
  net_payload = docker_create_payload(
564
655
  run_id=self._run_id, image=self._image, profile=self._profile,
565
- workspace=self._workspace, network=True)
656
+ workspace=self._workspace, network=True,
657
+ sandbox_venv_dir=self._sandbox_venv_dir)
566
658
  net_res = self._runner.run(net_payload, timeout=60)
567
659
  if net_res.exit_code != 0:
568
660
  # reclaim the already-started network-none container — never leak it.
@@ -587,12 +679,13 @@ class DockerBackend(Sandbox):
587
679
  """
588
680
  if not force and read_stamp(Path(stamp_dir), stamp_key):
589
681
  if self._probe_venv() == "broken":
590
- remove_sandbox_venv(self._workspace)
682
+ remove_sandbox_venv(self._workspace,
683
+ sandbox_venv_dir=self._sandbox_venv_dir)
591
684
  else:
592
685
  return
593
686
  payload = docker_provision_payload(
594
687
  run_id=self._run_id, image=self._image, workspace=self._workspace,
595
- setup_cmd=setup_cmd)
688
+ setup_cmd=setup_cmd, sandbox_venv_dir=self._sandbox_venv_dir)
596
689
  res = self._runner.run(payload, timeout=PROVISION_TIMEOUT_SECONDS)
597
690
  if res.exit_code != 0:
598
691
  tail = (res.stdout or res.stderr or "")[-4000:]
@@ -846,9 +939,12 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
846
939
  image = resolve_image_ref(profile.image)
847
940
  iso_state_dir = isolation_state_dir(cfg)
848
941
  needs_network = any(step.network for step in cfg.steps)
942
+ sandbox_venv_dir = resolve_sandbox_venv_dir(
943
+ cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
849
944
  backend = DockerBackend(
850
945
  image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
851
- profile=profile, runner=runner, network=needs_network)
946
+ profile=profile, runner=runner, network=needs_network,
947
+ sandbox_venv_dir=sandbox_venv_dir)
852
948
  backend.ensure_image()
853
949
  backend.create_execution_container()
854
950
  if refresh is None:
@@ -32,6 +32,7 @@ from openhands.sdk import Conversation
32
32
 
33
33
  from .agents import load_agent, register_sandbox_terminal
34
34
  from agentic_dev_loop.core.config import TERMINALS, PipelineConfig, Route, StepConfig, route_target
35
+ from .deps import extension_message, parse_missing_dependencies, policy_injection
35
36
  from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
36
37
  from .outputs import CapturedOutput
37
38
  from .secrets import redact_values, step_secret_env
@@ -198,11 +199,13 @@ def _run_command(command: str, workspace: Path, timeout: float,
198
199
  def build_context(runtime: Runtime, docs: dict[str, str], subject: str,
199
200
  notes: list[str] | None = None) -> str:
200
201
  """Assemble the attempt's shared message: subject, doc bundle, the
201
- expected-output-paths section generated from step defs (a prompt merely
202
- naming files lets agents skip them), and previous-attempt notes."""
202
+ canonical dependency-policy paragraph (021), the expected-output-paths
203
+ section generated from step defs (a prompt merely naming files lets agents
204
+ skip them), and previous-attempt notes."""
203
205
  parts = [f"You are working on {subject}.\n"]
204
206
  for name, content in docs.items():
205
207
  parts.append(f"## {name}\n\n{content}")
208
+ parts.append("## Dependency policy\n\n" + policy_injection(runtime.cfg))
206
209
  lines = []
207
210
  for step in runtime.cfg.steps:
208
211
  if step.agent is None:
@@ -301,6 +304,22 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
301
304
  result.truncated, max_iterations)],
302
305
  "retry_target": _unclear_target(runtime, step)}
303
306
 
307
+ # 021: under `locked`, a declared missing dependency is a fail-fast —
308
+ # one human lockfile edit, never a retry loop. The extension message
309
+ # is written to last-error.md (the run-failure channel) and printed.
310
+ if (runtime.cfg.deps_policy == "locked"
311
+ and result.response is not None):
312
+ pins = parse_missing_dependencies(result.response)
313
+ if pins:
314
+ msg = extension_message(runtime.cfg, pins)
315
+ runtime.state_dir.mkdir(parents=True, exist_ok=True)
316
+ (runtime.state_dir / LAST_ERROR).write_text(
317
+ f"# Missing dependencies\n\n```\n{msg}\n```\n",
318
+ encoding="utf-8")
319
+ print(redact_values(msg, runtime.secrets.values()),
320
+ file=sys.stderr, flush=True)
321
+ return {"failed": True, "failed_step": step.name}
322
+
304
323
  if verdicts_map is None:
305
324
  merged = {**state["step_verdicts"], step.name: "pass"} if captures else \
306
325
  state["step_verdicts"]