agentic-dev-loop 1.0.0a32__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.
- {agentic_dev_loop-1.0.0a32/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a34}/PKG-INFO +12 -1
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/README.md +11 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/config.py +21 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/paths.py +22 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/agents.py +22 -5
- agentic_dev_loop-1.0.0a34/src/agentic_dev_loop/engine/allowlist.py +66 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/extensions/aidlc.py +6 -0
- agentic_dev_loop-1.0.0a34/src/agentic_dev_loop/engine/forbidden.py +91 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/graph.py +4 -1
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/isolation.py +19 -3
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/stages.py +45 -6
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/sync_back.py +24 -4
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34/src/agentic_dev_loop.egg-info}/PKG-INFO +12 -1
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/SOURCES.txt +3 -0
- agentic_dev_loop-1.0.0a34/tests/test_036_banned_commands_and_lane_boundary.py +273 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_e2e_fixtures.py +4 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/presets.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_028_declared_caches.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_029_merge_on_conflict.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_032_stop_when_green.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_033_brief_step.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_035_finalize_nudge.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_agent_error_guide.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_isolation.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_isolation_integration.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_sync_back.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_unit5_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/tests/test_viz.py +0 -0
{agentic_dev_loop-1.0.0a32/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a34}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
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
|
|
@@ -242,6 +242,7 @@ steps:
|
|
|
242
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. |
|
|
243
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
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. |
|
|
245
246
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
246
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). |
|
|
247
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). |
|
|
@@ -341,6 +342,16 @@ steps:
|
|
|
341
342
|
to exactly the declared hosts for that stage; the default stays network-off
|
|
342
343
|
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
343
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.
|
|
344
355
|
|
|
345
356
|
### Sync-back conflicts (host edits during a run)
|
|
346
357
|
|
|
@@ -200,6 +200,7 @@ steps:
|
|
|
200
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. |
|
|
201
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
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. |
|
|
203
204
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
204
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). |
|
|
205
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). |
|
|
@@ -299,6 +300,16 @@ steps:
|
|
|
299
300
|
to exactly the declared hosts for that stage; the default stays network-off
|
|
300
301
|
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
301
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.
|
|
302
313
|
|
|
303
314
|
### Sync-back conflicts (host edits during a run)
|
|
304
315
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentic-dev-loop"
|
|
7
|
-
version = "1.0.
|
|
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"
|
|
@@ -391,6 +391,7 @@ class StepConfig(BaseModel):
|
|
|
391
391
|
brief_inputs: BriefInputsSpec | None = None # agent steps only (033)
|
|
392
392
|
env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
|
|
393
393
|
setup: str | None = None # BR-13: command steps only
|
|
394
|
+
forbidden_commands: list[str] = [] # banned-command regex deny-list (036 D1)
|
|
394
395
|
network: bool = False # per-stage egress (command/agent + sandbox actions; host-side actions reject at registry time)
|
|
395
396
|
# unit-5: per-step mount granularity + egress policy. Empty mounts =
|
|
396
397
|
# strict-by-default (BR-1.1); egress None = strict empty allowlist
|
|
@@ -420,6 +421,26 @@ class StepConfig(BaseModel):
|
|
|
420
421
|
def _coerce_produces(cls, v: Any) -> Any:
|
|
421
422
|
return [v] if isinstance(v, str) else v
|
|
422
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
|
+
|
|
423
444
|
@model_validator(mode="after")
|
|
424
445
|
def _check_shape(self) -> "StepConfig":
|
|
425
446
|
for key in ("agent", "action", "command"):
|
|
@@ -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
|
{agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/agents.py
RENAMED
|
@@ -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=
|
|
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
|
|
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
|
{agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/graph.py
RENAMED
|
@@ -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)
|
{agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/isolation.py
RENAMED
|
@@ -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
|
|
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
|
{agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/stages.py
RENAMED
|
@@ -38,7 +38,9 @@ from agentic_dev_loop.core.config import TERMINALS, PipelineConfig, Route, StepC
|
|
|
38
38
|
from agentic_dev_loop.core.paths import _has_wildcards
|
|
39
39
|
from .deps import extension_message, parse_missing_dependencies, policy_injection
|
|
40
40
|
from .egress_policy import assemble_egress_policy
|
|
41
|
-
from .
|
|
41
|
+
from .forbidden import resolve_forbidden_rules
|
|
42
|
+
from .isolation import (SandboxError, SandboxExecutionRequest, command_argv,
|
|
43
|
+
resolve_sandbox_env)
|
|
42
44
|
from .mounts import resolve_mount_set
|
|
43
45
|
from .outputs import CapturedOutput
|
|
44
46
|
from .secrets import redact_values, step_secret_env
|
|
@@ -171,6 +173,9 @@ class Runtime:
|
|
|
171
173
|
# the real workspace (no export materialised - fake/legacy paths).
|
|
172
174
|
sandbox_cwd: Path | None = None
|
|
173
175
|
secrets: dict[str, str] = field(default_factory=dict)
|
|
176
|
+
# 036 lane boundary (v1): the active unit's engine-allowlist.yaml paths;
|
|
177
|
+
# None = unrestricted (no allowlist file).
|
|
178
|
+
engine_allowlist: list[str] | None = None
|
|
174
179
|
|
|
175
180
|
def run_agent(self, agent_name: str, message: str,
|
|
176
181
|
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
|
@@ -200,6 +205,13 @@ class Runtime:
|
|
|
200
205
|
cwd, mount_set, egress_policy = _step_sandbox(self, step)
|
|
201
206
|
else:
|
|
202
207
|
cwd, mount_set, egress_policy = (self.workspace, None, None)
|
|
208
|
+
forbidden_commands = (
|
|
209
|
+
resolve_forbidden_rules(step.forbidden_commands,
|
|
210
|
+
step.network)
|
|
211
|
+
if step is not None else None)
|
|
212
|
+
allowlist = (
|
|
213
|
+
tuple(self.engine_allowlist)
|
|
214
|
+
if self.engine_allowlist is not None else None)
|
|
203
215
|
register_sandbox_terminal(
|
|
204
216
|
self.sandbox,
|
|
205
217
|
env=step.env if step is not None else None,
|
|
@@ -214,7 +226,9 @@ class Runtime:
|
|
|
214
226
|
on_terminal_result=(controller.on_terminal_result
|
|
215
227
|
if controller is not None else None),
|
|
216
228
|
secrets=(step_secret_env(self.cfg, step, self.secrets)
|
|
217
|
-
if step is not None else None)
|
|
229
|
+
if step is not None else None),
|
|
230
|
+
forbidden_commands=forbidden_commands,
|
|
231
|
+
allowlist=allowlist)
|
|
218
232
|
agent = load_agent(self.agents_dir, agent_name, self.llm)
|
|
219
233
|
callbacks = []
|
|
220
234
|
if controller is not None:
|
|
@@ -364,7 +378,8 @@ def _run_command(command: str, workspace: Path, timeout: float,
|
|
|
364
378
|
network: bool = False,
|
|
365
379
|
mount_set=None,
|
|
366
380
|
egress_policy=None,
|
|
367
|
-
cwd: Path | None = None
|
|
381
|
+
cwd: Path | None = None,
|
|
382
|
+
allowlist: tuple[str, ...] | None = None) -> tuple[int | None, str, bool]:
|
|
368
383
|
"""Run a command step in the workspace, capturing combined stdout/stderr.
|
|
369
384
|
|
|
370
385
|
With a ``Sandbox`` the command routes through the isolation boundary: git
|
|
@@ -381,7 +396,8 @@ def _run_command(command: str, workspace: Path, timeout: float,
|
|
|
381
396
|
request = SandboxExecutionRequest(
|
|
382
397
|
argv=command_argv(command), cwd=str(cwd or workspace), env=env or {},
|
|
383
398
|
timeout=timeout, pipeline=pipeline, step=step, network=network,
|
|
384
|
-
mount_set=mount_set, egress_policy=egress_policy
|
|
399
|
+
mount_set=mount_set, egress_policy=egress_policy,
|
|
400
|
+
allowlist=allowlist)
|
|
385
401
|
result = sandbox.run(request)
|
|
386
402
|
return result.exit_code, result.stdout + result.stderr, result.timed_out
|
|
387
403
|
if needs_shell(command):
|
|
@@ -822,6 +838,8 @@ def make_command_node(runtime: Runtime, step: StepConfig):
|
|
|
822
838
|
timeout = step.timeout if step.timeout is not None else DEFAULT_COMMAND_TIMEOUT
|
|
823
839
|
sandbox = runtime.sandbox
|
|
824
840
|
cwd, mount_set, egress_policy = _step_sandbox(runtime, step)
|
|
841
|
+
allowlist = (tuple(runtime.engine_allowlist)
|
|
842
|
+
if runtime.engine_allowlist is not None else None)
|
|
825
843
|
if sandbox is not None:
|
|
826
844
|
env = resolve_sandbox_env(
|
|
827
845
|
step.env, secrets=step_secret_env(runtime.cfg, step, runtime.secrets))
|
|
@@ -837,7 +855,16 @@ def make_command_node(runtime: Runtime, step: StepConfig):
|
|
|
837
855
|
step.command, runtime.workspace, timeout, env,
|
|
838
856
|
sandbox=sandbox, pipeline=runtime.cfg.name, step=step.name,
|
|
839
857
|
network=step.network, mount_set=mount_set,
|
|
840
|
-
egress_policy=egress_policy, cwd=cwd)
|
|
858
|
+
egress_policy=egress_policy, cwd=cwd, allowlist=allowlist)
|
|
859
|
+
except SandboxError:
|
|
860
|
+
err = redact_values(traceback.format_exc(),
|
|
861
|
+
runtime.secrets.values())[-4000:]
|
|
862
|
+
print(err, file=sys.stderr, flush=True)
|
|
863
|
+
runtime.state_dir.mkdir(parents=True, exist_ok=True)
|
|
864
|
+
(runtime.state_dir / LAST_ERROR).write_text(
|
|
865
|
+
f"# Command step failed\n\n```\n{err}\n```\n",
|
|
866
|
+
encoding="utf-8")
|
|
867
|
+
return {"failed": True, "failed_step": step.name}
|
|
841
868
|
except Exception:
|
|
842
869
|
print(redact_values(traceback.format_exc(),
|
|
843
870
|
runtime.secrets.values())[-4000:],
|
|
@@ -903,6 +930,8 @@ def make_action_node(runtime: Runtime, step: StepConfig):
|
|
|
903
930
|
timeout = DEFAULT_COMMAND_TIMEOUT
|
|
904
931
|
env = resolve_sandbox_env(step.env)
|
|
905
932
|
cwd, mount_set, egress_policy = _step_sandbox(runtime, step)
|
|
933
|
+
allowlist = (tuple(runtime.engine_allowlist)
|
|
934
|
+
if runtime.engine_allowlist is not None else None)
|
|
906
935
|
|
|
907
936
|
def node(state: dict) -> dict:
|
|
908
937
|
attempt = state["attempt"]
|
|
@@ -919,7 +948,17 @@ def make_action_node(runtime: Runtime, step: StepConfig):
|
|
|
919
948
|
exit_code, output, timed_out = _run_command(
|
|
920
949
|
command, runtime.workspace, timeout, env, sandbox=runtime.sandbox,
|
|
921
950
|
pipeline=runtime.cfg.name, step=step.name, network=step.network,
|
|
922
|
-
mount_set=mount_set, egress_policy=egress_policy, cwd=cwd
|
|
951
|
+
mount_set=mount_set, egress_policy=egress_policy, cwd=cwd,
|
|
952
|
+
allowlist=allowlist)
|
|
953
|
+
except SandboxError:
|
|
954
|
+
err = redact_values(traceback.format_exc(),
|
|
955
|
+
runtime.secrets.values())[-4000:]
|
|
956
|
+
print(err, file=sys.stderr, flush=True)
|
|
957
|
+
runtime.state_dir.mkdir(parents=True, exist_ok=True)
|
|
958
|
+
(runtime.state_dir / LAST_ERROR).write_text(
|
|
959
|
+
f"# Action step failed\n\n```\n{err}\n```\n",
|
|
960
|
+
encoding="utf-8")
|
|
961
|
+
return {"failed": True, "failed_step": step.name}
|
|
923
962
|
except Exception:
|
|
924
963
|
print(redact_values(traceback.format_exc(),
|
|
925
964
|
runtime.secrets.values())[-4000:],
|
{agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a34}/src/agentic_dev_loop/engine/sync_back.py
RENAMED
|
@@ -22,7 +22,7 @@ from datetime import datetime, timezone
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
from typing import Mapping
|
|
24
24
|
|
|
25
|
-
from agentic_dev_loop.core.paths import dir_excluded, path_excluded
|
|
25
|
+
from agentic_dev_loop.core.paths import dir_excluded, path_allowed, path_excluded
|
|
26
26
|
|
|
27
27
|
# Never sync these components, whatever the declaration says (BR-2/BR-4).
|
|
28
28
|
_FORBIDDEN = frozenset({".git", ".venv"})
|
|
@@ -45,6 +45,7 @@ class SyncBackReport:
|
|
|
45
45
|
synced_paths: tuple[str, ...]
|
|
46
46
|
conflicts: tuple[str, ...]
|
|
47
47
|
merged_paths: tuple[str, ...] = ()
|
|
48
|
+
lane_violations: tuple[str, ...] = ()
|
|
48
49
|
step_id: str | None = None
|
|
49
50
|
timestamp: str = ""
|
|
50
51
|
|
|
@@ -187,7 +188,8 @@ def sync_back(export_root: str | Path, real_repo: str | Path,
|
|
|
187
188
|
step_id: str | None = None,
|
|
188
189
|
cache_paths: list[str] | tuple[str, ...] = (),
|
|
189
190
|
excludes: list[str] | tuple[str, ...] = (),
|
|
190
|
-
merge_on_conflict: list[str] | tuple[str, ...] = ()
|
|
191
|
+
merge_on_conflict: list[str] | tuple[str, ...] = (),
|
|
192
|
+
allowlist: list[str] | tuple[str, ...] | None = None) -> SyncBackReport:
|
|
191
193
|
"""Diff-copy declared-rw export changes into the real repo (ID-5).
|
|
192
194
|
|
|
193
195
|
A conflict - a declared-rw path changed in the export AND in the real repo
|
|
@@ -251,6 +253,24 @@ def sync_back(export_root: str | Path, real_repo: str | Path,
|
|
|
251
253
|
merged_paths = tuple(sorted(merged_paths))
|
|
252
254
|
hard_conflicts = tuple(sorted(hard_conflicts))
|
|
253
255
|
|
|
256
|
+
# 036 lane boundary (v1): every path that would be merged or synced must
|
|
257
|
+
# match the unit allowlist. A violation fails before any copy/delete/merge
|
|
258
|
+
# is applied - the out-of-lane write is detection, not promotion into the
|
|
259
|
+
# real repo. None = unrestricted (no allowlist file).
|
|
260
|
+
if allowlist is not None:
|
|
261
|
+
checked = tuple(sorted(set(merged_paths) | set(plan.to_copy)
|
|
262
|
+
| set(plan.to_delete)))
|
|
263
|
+
lane_violations = tuple(p for p in checked
|
|
264
|
+
if not path_allowed(p, allowlist))
|
|
265
|
+
else:
|
|
266
|
+
lane_violations = ()
|
|
267
|
+
|
|
268
|
+
if lane_violations:
|
|
269
|
+
return SyncBackReport(
|
|
270
|
+
synced_paths=(), conflicts=hard_conflicts,
|
|
271
|
+
merged_paths=merged_paths, lane_violations=lane_violations,
|
|
272
|
+
step_id=step_id, timestamp=datetime.now(timezone.utc).isoformat())
|
|
273
|
+
|
|
254
274
|
# A hard (non-merge) conflict keeps the BR-4.4 loud failure and the
|
|
255
275
|
# copy/delete stays atomic: nothing non-merge copies or deletes when one
|
|
256
276
|
# remains. Merge paths are still reported so the caller union-merges them
|
|
@@ -271,5 +291,5 @@ def sync_back(export_root: str | Path, real_repo: str | Path,
|
|
|
271
291
|
|
|
272
292
|
return SyncBackReport(
|
|
273
293
|
synced_paths=plan.to_copy + plan.to_delete,
|
|
274
|
-
conflicts=(), merged_paths=merged_paths,
|
|
275
|
-
timestamp=datetime.now(timezone.utc).isoformat())
|
|
294
|
+
conflicts=(), merged_paths=merged_paths, lane_violations=(),
|
|
295
|
+
step_id=step_id, timestamp=datetime.now(timezone.utc).isoformat())
|