agentic-dev-loop 1.0.0a17__tar.gz → 1.0.0a19__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.0a17/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a19}/PKG-INFO +40 -19
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/README.md +39 -18
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/__init__.py +2 -2
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/config.py +183 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/agents.py +8 -3
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/dryrun.py +3 -1
- agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/egress_policy.py +120 -0
- agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/egress_proxy.py +247 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/graph.py +7 -2
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/isolation.py +325 -17
- agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/mounts.py +63 -0
- agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/presets.py +61 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/registry.py +15 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/stages.py +102 -73
- agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/step_workspace.py +163 -0
- agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/sync_back.py +198 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19/src/agentic_dev_loop.egg-info}/PKG-INFO +40 -19
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/SOURCES.txt +12 -0
- agentic_dev_loop-1.0.0a19/tests/test_egress_proxy.py +128 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_hostboundary_integration.py +38 -17
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_isolation.py +339 -29
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_isolation_integration.py +63 -1
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_registry.py +22 -1
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_stages.py +40 -4
- agentic_dev_loop-1.0.0a19/tests/test_step_workspace.py +113 -0
- agentic_dev_loop-1.0.0a19/tests/test_sync_back.py +139 -0
- agentic_dev_loop-1.0.0a19/tests/test_unit5_config.py +173 -0
- agentic_dev_loop-1.0.0a19/tests/test_unit5_pbt.py +74 -0
- agentic_dev_loop-1.0.0a19/tests/test_unit5_pure.py +196 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_e2e_fixtures.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_viz.py +0 -0
{agentic_dev_loop-1.0.0a17/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a19}/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.0a19
|
|
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
|
|
@@ -72,7 +72,7 @@ flowchart LR
|
|
|
72
72
|
| Feature | What it gives you |
|
|
73
73
|
| --- | --- |
|
|
74
74
|
| **Stage-gated agent loop** | Agents do the work, test stages verify it, and the pipeline only advances when each stage passes its gates - looping back with full context when one fails. |
|
|
75
|
-
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** - authors opt specific stages in](#per-stage-networking), no
|
|
75
|
+
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** - authors opt specific stages in](#per-stage-networking), writes only to declared `mounts:`, and no access to `.git` (steps run against a fresh export). No sandbox available → the run stops instead of degrading. |
|
|
76
76
|
| **Crash-safe resume** | Interrupt a run at any time and pick it up later - finished stages stay finished, and you resume from exactly where it stopped. |
|
|
77
77
|
| **Live graph visualisation** | Follow every run in the browser: stage status, pass/fail verdicts, retry cycles, and live console output as it happens. |
|
|
78
78
|
| **Extensible** | Plug in your own actions, tools, and document loaders to adapt the loop to your projects. |
|
|
@@ -93,16 +93,17 @@ LLM timeout (agent runs are long).
|
|
|
93
93
|
| LLM API key | The env var named by your pipeline's `api_key_env` (e.g. `OPENAI_API_KEY`) must be set - a missing key fails fast, naming it. |
|
|
94
94
|
|
|
95
95
|
```bash
|
|
96
|
-
pip install "agentic-dev-loop==1.0.
|
|
96
|
+
pip install "agentic-dev-loop==1.0.0a17"
|
|
97
97
|
agentic-dev-loop --version
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
The canonical consumer install is **pipx**, which puts the engine's venv
|
|
101
|
-
*outside* the workspace - the sandbox
|
|
102
|
-
out-of-workspace install is unmounted and never
|
|
101
|
+
*outside* the workspace - the sandbox mounts only the step's declared `mounts:`
|
|
102
|
+
from a fresh export, so an out-of-workspace install is unmounted and never
|
|
103
|
+
agent-writable:
|
|
103
104
|
|
|
104
105
|
```bash
|
|
105
|
-
pipx install "agentic-dev-loop==1.0.
|
|
106
|
+
pipx install "agentic-dev-loop==1.0.0a17"
|
|
106
107
|
```
|
|
107
108
|
|
|
108
109
|
If you install with `uv` shortly after a release, pass `--refresh` - PyPI
|
|
@@ -111,7 +112,7 @@ reuses a pre-upload snapshot of the index for up to 10 minutes, reporting
|
|
|
111
112
|
"no version of agentic-dev-loop==X.Y.Z" for a version that is already live:
|
|
112
113
|
|
|
113
114
|
```bash
|
|
114
|
-
uv pip install --refresh "agentic-dev-loop==1.0.
|
|
115
|
+
uv pip install --refresh "agentic-dev-loop==1.0.0a17"
|
|
115
116
|
```
|
|
116
117
|
|
|
117
118
|
On the first run the engine pulls the **digest-pinned** dev image
|
|
@@ -211,7 +212,8 @@ steps:
|
|
|
211
212
|
| `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call). `commit` is the sole host-side built-in action; consumer actions are sandbox-executed, command-shaped scripts (see [Host boundary](#host-boundary-security-model)). |
|
|
212
213
|
| `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
|
|
213
214
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
214
|
-
| `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true`
|
|
215
|
+
| `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` lets a command/agent step reach the hosts its `egress:` allowlist names (default off) - see [Per-stage networking](#per-stage-networking). |
|
|
216
|
+
| `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). |
|
|
215
217
|
| `secrets:` (top-level) / step `secrets:` | Declares secret **refs only - never values**; each step opts in to the refs it needs. Resolved host-side and injected into that step's exec env only. See [Secrets](#secrets--refs-only-never-values). |
|
|
216
218
|
| `isolation:` (optional) | Configures the sandbox - image override, resource caps, `allow_git_mutations`, `seccomp_profile`. It can never disable it. See [browser sandbox](docs/browser-sandbox.md). |
|
|
217
219
|
| `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` - artifacts and checkpoints live outside the sandbox mount. |
|
|
@@ -262,26 +264,45 @@ steps:
|
|
|
262
264
|
|
|
263
265
|
The sandbox is network-off by default. A step that legitimately needs egress -
|
|
264
266
|
a QA step that curls a service it booted, an agent that must hit an API or
|
|
265
|
-
fetch package metadata mid-conversation - declares it explicitly
|
|
267
|
+
fetch package metadata mid-conversation - declares it explicitly, and names
|
|
268
|
+
exactly what it may reach:
|
|
266
269
|
|
|
267
270
|
```yaml
|
|
268
271
|
steps:
|
|
269
272
|
- name: fetch-docs
|
|
270
273
|
command: curl -fsSL https://example.com/spec.json -o spec.json
|
|
271
|
-
|
|
274
|
+
mounts: [{ path: ".", mode: rw }] # the cwd + a place to write spec.json
|
|
275
|
+
network: true # egress for this step only
|
|
276
|
+
egress: { targets: [example.com] } # ...and only these hosts
|
|
277
|
+
|
|
278
|
+
- name: restore
|
|
279
|
+
command: .venv-sandbox/bin/pip install -e ".[dev]"
|
|
280
|
+
mounts: [{ path: ".", mode: rw }]
|
|
281
|
+
network: true
|
|
282
|
+
egress: { presets: [package-registries] } # pypi/npm/NuGet/cargo + VCS
|
|
272
283
|
```
|
|
273
284
|
|
|
274
285
|
- **Default off.** Every step runs with `--network none` unless it declares
|
|
275
|
-
`network: true`.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
286
|
+
`network: true`.
|
|
287
|
+
- **A networked step that writes output must also declare a writable mount.**
|
|
288
|
+
`mounts:` is strict-by-default: with no `mounts:` a step mounts nothing, so
|
|
289
|
+
a command like `curl ... -o spec.json` has no writable cwd to write into.
|
|
290
|
+
Declare `mounts: [{ path: ".", mode: rw }]` (or a narrower rw path) for any
|
|
291
|
+
networked step that needs to produce files.
|
|
292
|
+
- **`network: true` is now scoped by the `egress:` allowlist.** A networked
|
|
293
|
+
step's traffic is forced through an engine-run per-policy egress proxy
|
|
294
|
+
(injected via `HTTP_PROXY`/`HTTPS_PROXY` and reached through the
|
|
295
|
+
`host.docker.internal` host-gateway); a request to an undeclared host is
|
|
296
|
+
denied and logged as an enforcement event. The engine never adds an
|
|
297
|
+
undeclared target. `egress: { class: browser }` is the explicit
|
|
298
|
+
broad-but-logged class (every connection logged, nothing filtered); command
|
|
299
|
+
and agent steps default to a strict allowlist.
|
|
279
300
|
- **Command, agent, and sandbox-action steps** may declare it. The host-side
|
|
280
301
|
built-in `commit` action never takes it (refused once the registry is known).
|
|
281
|
-
- **Author opt-in is the control.** A networked step
|
|
282
|
-
|
|
283
|
-
for stages that need it (QA, integration). The
|
|
284
|
-
in [docs/setup.md](docs/setup.md).
|
|
302
|
+
- **Author opt-in is the control.** A networked step grants its agent egress
|
|
303
|
+
to exactly the declared hosts for that stage; the default stays network-off
|
|
304
|
+
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
305
|
+
residual risks are documented in [docs/setup.md](docs/setup.md).
|
|
285
306
|
|
|
286
307
|
## Dependency policy (locked by default)
|
|
287
308
|
|
|
@@ -407,7 +428,7 @@ the loop is building.
|
|
|
407
428
|
```bash
|
|
408
429
|
git clone git@github.com:lukedawilson/agentic-dev-loop.git
|
|
409
430
|
cd agentic-dev-loop
|
|
410
|
-
pipx install "agentic-dev-loop==1.0.
|
|
431
|
+
pipx install "agentic-dev-loop==1.0.0a17" # engine venv OUTSIDE the workspace (self-check)
|
|
411
432
|
export OPENAI_API_KEY=... # set your LLM key in the host env (no .env in the repo)
|
|
412
433
|
python -m pytest tests/ -q # fast suite (in-repo .venv is for tests only)
|
|
413
434
|
python -m pytest tests/ -q -m "not docker" # skip engine-dependent tests
|
|
@@ -30,7 +30,7 @@ flowchart LR
|
|
|
30
30
|
| Feature | What it gives you |
|
|
31
31
|
| --- | --- |
|
|
32
32
|
| **Stage-gated agent loop** | Agents do the work, test stages verify it, and the pipeline only advances when each stage passes its gates - looping back with full context when one fails. |
|
|
33
|
-
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** - authors opt specific stages in](#per-stage-networking), no
|
|
33
|
+
| **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** - authors opt specific stages in](#per-stage-networking), writes only to declared `mounts:`, and no access to `.git` (steps run against a fresh export). No sandbox available → the run stops instead of degrading. |
|
|
34
34
|
| **Crash-safe resume** | Interrupt a run at any time and pick it up later - finished stages stay finished, and you resume from exactly where it stopped. |
|
|
35
35
|
| **Live graph visualisation** | Follow every run in the browser: stage status, pass/fail verdicts, retry cycles, and live console output as it happens. |
|
|
36
36
|
| **Extensible** | Plug in your own actions, tools, and document loaders to adapt the loop to your projects. |
|
|
@@ -51,16 +51,17 @@ LLM timeout (agent runs are long).
|
|
|
51
51
|
| LLM API key | The env var named by your pipeline's `api_key_env` (e.g. `OPENAI_API_KEY`) must be set - a missing key fails fast, naming it. |
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
pip install "agentic-dev-loop==1.0.
|
|
54
|
+
pip install "agentic-dev-loop==1.0.0a17"
|
|
55
55
|
agentic-dev-loop --version
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
The canonical consumer install is **pipx**, which puts the engine's venv
|
|
59
|
-
*outside* the workspace - the sandbox
|
|
60
|
-
out-of-workspace install is unmounted and never
|
|
59
|
+
*outside* the workspace - the sandbox mounts only the step's declared `mounts:`
|
|
60
|
+
from a fresh export, so an out-of-workspace install is unmounted and never
|
|
61
|
+
agent-writable:
|
|
61
62
|
|
|
62
63
|
```bash
|
|
63
|
-
pipx install "agentic-dev-loop==1.0.
|
|
64
|
+
pipx install "agentic-dev-loop==1.0.0a17"
|
|
64
65
|
```
|
|
65
66
|
|
|
66
67
|
If you install with `uv` shortly after a release, pass `--refresh` - PyPI
|
|
@@ -69,7 +70,7 @@ reuses a pre-upload snapshot of the index for up to 10 minutes, reporting
|
|
|
69
70
|
"no version of agentic-dev-loop==X.Y.Z" for a version that is already live:
|
|
70
71
|
|
|
71
72
|
```bash
|
|
72
|
-
uv pip install --refresh "agentic-dev-loop==1.0.
|
|
73
|
+
uv pip install --refresh "agentic-dev-loop==1.0.0a17"
|
|
73
74
|
```
|
|
74
75
|
|
|
75
76
|
On the first run the engine pulls the **digest-pinned** dev image
|
|
@@ -169,7 +170,8 @@ steps:
|
|
|
169
170
|
| `agent:` / `command:` / `action:` | Exactly one per step. Agents live in `agents_dir` as `<name>.agent.md`; commands run sandboxed (mechanical, never an LLM call). `commit` is the sole host-side built-in action; consumer actions are sandbox-executed, command-shaped scripts (see [Host boundary](#host-boundary-security-model)). |
|
|
170
171
|
| `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
|
|
171
172
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
172
|
-
| `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true`
|
|
173
|
+
| `env:` / `setup:` / `network:` | `env:` declares step vars (fixed safe base + declared vars only); `setup:` provisions the sandbox (command steps, network ON, stamp-cached); `network: true` lets a command/agent step reach the hosts its `egress:` allowlist names (default off) - see [Per-stage networking](#per-stage-networking). |
|
|
174
|
+
| `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). |
|
|
173
175
|
| `secrets:` (top-level) / step `secrets:` | Declares secret **refs only - never values**; each step opts in to the refs it needs. Resolved host-side and injected into that step's exec env only. See [Secrets](#secrets--refs-only-never-values). |
|
|
174
176
|
| `isolation:` (optional) | Configures the sandbox - image override, resource caps, `allow_git_mutations`, `seccomp_profile`. It can never disable it. See [browser sandbox](docs/browser-sandbox.md). |
|
|
175
177
|
| `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` - artifacts and checkpoints live outside the sandbox mount. |
|
|
@@ -220,26 +222,45 @@ steps:
|
|
|
220
222
|
|
|
221
223
|
The sandbox is network-off by default. A step that legitimately needs egress -
|
|
222
224
|
a QA step that curls a service it booted, an agent that must hit an API or
|
|
223
|
-
fetch package metadata mid-conversation - declares it explicitly
|
|
225
|
+
fetch package metadata mid-conversation - declares it explicitly, and names
|
|
226
|
+
exactly what it may reach:
|
|
224
227
|
|
|
225
228
|
```yaml
|
|
226
229
|
steps:
|
|
227
230
|
- name: fetch-docs
|
|
228
231
|
command: curl -fsSL https://example.com/spec.json -o spec.json
|
|
229
|
-
|
|
232
|
+
mounts: [{ path: ".", mode: rw }] # the cwd + a place to write spec.json
|
|
233
|
+
network: true # egress for this step only
|
|
234
|
+
egress: { targets: [example.com] } # ...and only these hosts
|
|
235
|
+
|
|
236
|
+
- name: restore
|
|
237
|
+
command: .venv-sandbox/bin/pip install -e ".[dev]"
|
|
238
|
+
mounts: [{ path: ".", mode: rw }]
|
|
239
|
+
network: true
|
|
240
|
+
egress: { presets: [package-registries] } # pypi/npm/NuGet/cargo + VCS
|
|
230
241
|
```
|
|
231
242
|
|
|
232
243
|
- **Default off.** Every step runs with `--network none` unless it declares
|
|
233
|
-
`network: true`.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
244
|
+
`network: true`.
|
|
245
|
+
- **A networked step that writes output must also declare a writable mount.**
|
|
246
|
+
`mounts:` is strict-by-default: with no `mounts:` a step mounts nothing, so
|
|
247
|
+
a command like `curl ... -o spec.json` has no writable cwd to write into.
|
|
248
|
+
Declare `mounts: [{ path: ".", mode: rw }]` (or a narrower rw path) for any
|
|
249
|
+
networked step that needs to produce files.
|
|
250
|
+
- **`network: true` is now scoped by the `egress:` allowlist.** A networked
|
|
251
|
+
step's traffic is forced through an engine-run per-policy egress proxy
|
|
252
|
+
(injected via `HTTP_PROXY`/`HTTPS_PROXY` and reached through the
|
|
253
|
+
`host.docker.internal` host-gateway); a request to an undeclared host is
|
|
254
|
+
denied and logged as an enforcement event. The engine never adds an
|
|
255
|
+
undeclared target. `egress: { class: browser }` is the explicit
|
|
256
|
+
broad-but-logged class (every connection logged, nothing filtered); command
|
|
257
|
+
and agent steps default to a strict allowlist.
|
|
237
258
|
- **Command, agent, and sandbox-action steps** may declare it. The host-side
|
|
238
259
|
built-in `commit` action never takes it (refused once the registry is known).
|
|
239
|
-
- **Author opt-in is the control.** A networked step
|
|
240
|
-
|
|
241
|
-
for stages that need it (QA, integration). The
|
|
242
|
-
in [docs/setup.md](docs/setup.md).
|
|
260
|
+
- **Author opt-in is the control.** A networked step grants its agent egress
|
|
261
|
+
to exactly the declared hosts for that stage; the default stays network-off
|
|
262
|
+
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
263
|
+
residual risks are documented in [docs/setup.md](docs/setup.md).
|
|
243
264
|
|
|
244
265
|
## Dependency policy (locked by default)
|
|
245
266
|
|
|
@@ -365,7 +386,7 @@ the loop is building.
|
|
|
365
386
|
```bash
|
|
366
387
|
git clone git@github.com:lukedawilson/agentic-dev-loop.git
|
|
367
388
|
cd agentic-dev-loop
|
|
368
|
-
pipx install "agentic-dev-loop==1.0.
|
|
389
|
+
pipx install "agentic-dev-loop==1.0.0a17" # engine venv OUTSIDE the workspace (self-check)
|
|
369
390
|
export OPENAI_API_KEY=... # set your LLM key in the host env (no .env in the repo)
|
|
370
391
|
python -m pytest tests/ -q # fast suite (in-repo .venv is for tests only)
|
|
371
392
|
python -m pytest tests/ -q -m "not docker" # skip engine-dependent tests
|
|
@@ -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.0a19"
|
|
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"
|
{agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/__init__.py
RENAMED
|
@@ -6,13 +6,13 @@ test fails if this package imports engine/viz or heavy deps.
|
|
|
6
6
|
|
|
7
7
|
from .config import (PipelineConfig, StepConfig, LlmConfig, IsolationConfig,
|
|
8
8
|
VerdictSpec, Route, route_target, load_config,
|
|
9
|
-
default_state_dir, TERMINALS)
|
|
9
|
+
default_state_dir, TERMINALS, MountSpec, EgressSpec)
|
|
10
10
|
from .results import Result
|
|
11
11
|
from .verdict import Verdict, VerdictError, parse_verdict, RETRY
|
|
12
12
|
|
|
13
13
|
__all__ = [
|
|
14
14
|
"PipelineConfig", "StepConfig", "LlmConfig", "IsolationConfig",
|
|
15
15
|
"VerdictSpec", "Route", "route_target", "load_config",
|
|
16
|
-
"default_state_dir", "TERMINALS",
|
|
16
|
+
"default_state_dir", "TERMINALS", "MountSpec", "EgressSpec",
|
|
17
17
|
"Result", "Verdict", "VerdictError", "parse_verdict", "RETRY",
|
|
18
18
|
]
|
|
@@ -12,6 +12,99 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
|
|
12
12
|
|
|
13
13
|
TERMINALS = frozenset({"success", "failure"})
|
|
14
14
|
KNOWN_SECRET_PROVIDERS = frozenset({"env"})
|
|
15
|
+
MOUNT_MODES = frozenset({"rw", "ro"})
|
|
16
|
+
# Host-only dirs a mount path may never name (022): they are masked in
|
|
17
|
+
# the sandbox with a read-only tmpfs, so declaring them would be a decoy.
|
|
18
|
+
HOST_ONLY_MOUNT_DIRS = frozenset({".venv"})
|
|
19
|
+
# The egress preset names the engine ships (BR-9). Config validates the
|
|
20
|
+
# name here; the target data lives in engine/presets.py (preset data,
|
|
21
|
+
# never mechanism - D-2).
|
|
22
|
+
KNOWN_EGRESS_PRESETS = frozenset({"package-registries"})
|
|
23
|
+
EGRESS_CLASSES = frozenset({"allowlist", "browser"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _validate_mount_path(path: str) -> str:
|
|
27
|
+
"""Validate one repo-relative mount path (BR-1.3).
|
|
28
|
+
|
|
29
|
+
Rejects (loudly) an empty path, an absolute path, ``..`` traversal,
|
|
30
|
+
any ``.git`` component, and any host-only dir (``.venv``). The path
|
|
31
|
+
is normalised to forward-slash form and returned.
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(path, str) or not path.strip():
|
|
34
|
+
raise ValueError("mount path must be a non-empty string")
|
|
35
|
+
p = path.strip()
|
|
36
|
+
if p.startswith(("/", "\\")) or (len(p) > 1 and p[1] == ":"):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"mount path {path!r} must be repo-relative (no absolute paths)")
|
|
39
|
+
parts = [seg for seg in p.replace("\\", "/").split("/")
|
|
40
|
+
if seg not in ("", ".")]
|
|
41
|
+
for seg in parts:
|
|
42
|
+
if seg == "..":
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"mount path {path!r} escapes the repo (no '..' traversal)")
|
|
45
|
+
if seg == ".git":
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"mount path {path!r} must never name '.git'")
|
|
48
|
+
if seg in HOST_ONLY_MOUNT_DIRS:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"mount path {path!r} names the host-only dir {seg!r}")
|
|
51
|
+
return "/".join(parts) if parts else "."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_egress_target(spec: str) -> tuple[str, int | None]:
|
|
55
|
+
"""Parse ``host``, ``host:port``, or ``scheme://host[:port]`` into
|
|
56
|
+
``(host, port)``. Malformed entries that can never match fail loud
|
|
57
|
+
(BR-6.3)."""
|
|
58
|
+
value = spec.strip()
|
|
59
|
+
if "://" in value:
|
|
60
|
+
value = value.split("://", 1)[1]
|
|
61
|
+
if "/" in value or value == "":
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"malformed egress target {spec!r}: expected host or host:port")
|
|
64
|
+
# IPv6 literal: [::1]:443
|
|
65
|
+
if value.startswith("["):
|
|
66
|
+
end = value.find("]")
|
|
67
|
+
if end == -1:
|
|
68
|
+
raise ValueError(f"malformed egress target {spec!r}: bad IPv6 literal")
|
|
69
|
+
host = value[1:end]
|
|
70
|
+
rest = value[end + 1:]
|
|
71
|
+
if rest.startswith(":"):
|
|
72
|
+
return host, parse_egress_port(spec, rest[1:])
|
|
73
|
+
if rest:
|
|
74
|
+
raise ValueError(f"malformed egress target {spec!r}")
|
|
75
|
+
return host, None
|
|
76
|
+
if value.count(":") == 1:
|
|
77
|
+
host, port = value.rsplit(":", 1)
|
|
78
|
+
return host, parse_egress_port(spec, port)
|
|
79
|
+
if value.count(":") > 1:
|
|
80
|
+
raise ValueError(f"malformed egress target {spec!r}: ambiguous port")
|
|
81
|
+
return value, None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_egress_port(spec: str, port: str) -> int | None:
|
|
85
|
+
"""Validate a target port: numeric and within 1-65535 (BR-6.3)."""
|
|
86
|
+
if port == "":
|
|
87
|
+
raise ValueError(f"malformed egress target {spec!r}: empty port")
|
|
88
|
+
if not port.isdigit():
|
|
89
|
+
raise ValueError(f"malformed egress target {spec!r}: port {port!r} not numeric")
|
|
90
|
+
n = int(port)
|
|
91
|
+
if not 1 <= n <= 65535:
|
|
92
|
+
raise ValueError(f"malformed egress target {spec!r}: port {n} out of range")
|
|
93
|
+
return n
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_egress_target(target: str) -> str:
|
|
97
|
+
"""Validate and canonicalise one egress target (BR-6.3/BR-8.4): non-empty,
|
|
98
|
+
scheme stripped, host or host:port shape, port 1-65535. Malformed entries
|
|
99
|
+
fail loud at load, so ``--dry-run`` is a true load-and-validate pass."""
|
|
100
|
+
if not isinstance(target, str) or not target.strip():
|
|
101
|
+
raise ValueError("egress target must be a non-empty string")
|
|
102
|
+
host, port = parse_egress_target(target)
|
|
103
|
+
if port is None:
|
|
104
|
+
return host
|
|
105
|
+
if ":" in host: # IPv6 literal needs brackets before a port
|
|
106
|
+
return f"[{host}]:{port}"
|
|
107
|
+
return f"{host}:{port}"
|
|
15
108
|
|
|
16
109
|
|
|
17
110
|
class VerdictSpec(BaseModel):
|
|
@@ -113,6 +206,70 @@ class IsolationConfig(BaseModel):
|
|
|
113
206
|
return v
|
|
114
207
|
|
|
115
208
|
|
|
209
|
+
class MountSpec(BaseModel):
|
|
210
|
+
"""One declared mount entry (unit-5): a repo-relative path plus its
|
|
211
|
+
mode. ``ro`` is the default (BR-1.2: only declared outputs and caches
|
|
212
|
+
are ``rw``); validation at construction rejects traversal, ``.git``,
|
|
213
|
+
and host-only dirs (BR-1.3)."""
|
|
214
|
+
model_config = ConfigDict(extra="forbid")
|
|
215
|
+
|
|
216
|
+
path: str
|
|
217
|
+
mode: str = "ro"
|
|
218
|
+
|
|
219
|
+
@field_validator("path")
|
|
220
|
+
@classmethod
|
|
221
|
+
def _valid_path(cls, v: str) -> str:
|
|
222
|
+
return _validate_mount_path(v)
|
|
223
|
+
|
|
224
|
+
@field_validator("mode")
|
|
225
|
+
@classmethod
|
|
226
|
+
def _valid_mode(cls, v: str) -> str:
|
|
227
|
+
if v not in MOUNT_MODES:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"mount mode must be 'rw' or 'ro', got {v!r}")
|
|
230
|
+
return v
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class EgressSpec(BaseModel):
|
|
234
|
+
"""One declared egress policy (unit-5): an explicit class plus preset
|
|
235
|
+
references and/or explicit targets. ``class: browser`` is the explicit
|
|
236
|
+
broad-but-logged browser class (BR-7); the default is a strict
|
|
237
|
+
allowlist (BR-8). Unknown preset names fail at load (BR-8.4)."""
|
|
238
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
239
|
+
|
|
240
|
+
class_: str | None = Field(default=None, alias="class")
|
|
241
|
+
presets: list[str] = []
|
|
242
|
+
targets: list[str] = []
|
|
243
|
+
|
|
244
|
+
@field_validator("presets", "targets", mode="before")
|
|
245
|
+
@classmethod
|
|
246
|
+
def _coerce_list(cls, v: Any) -> Any:
|
|
247
|
+
return [v] if isinstance(v, str) else v
|
|
248
|
+
|
|
249
|
+
@field_validator("class_")
|
|
250
|
+
@classmethod
|
|
251
|
+
def _known_class(cls, v: str | None) -> str | None:
|
|
252
|
+
if v is not None and v not in EGRESS_CLASSES:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"egress class must be 'allowlist' or 'browser', got {v!r}")
|
|
255
|
+
return v
|
|
256
|
+
|
|
257
|
+
@field_validator("presets")
|
|
258
|
+
@classmethod
|
|
259
|
+
def _known_presets(cls, v: list[str]) -> list[str]:
|
|
260
|
+
for name in v:
|
|
261
|
+
if name not in KNOWN_EGRESS_PRESETS:
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f"unknown egress preset {name!r} (known: "
|
|
264
|
+
f"{sorted(KNOWN_EGRESS_PRESETS)})")
|
|
265
|
+
return v
|
|
266
|
+
|
|
267
|
+
@field_validator("targets")
|
|
268
|
+
@classmethod
|
|
269
|
+
def _valid_targets(cls, v: list[str]) -> list[str]:
|
|
270
|
+
return [_validate_egress_target(t) for t in v]
|
|
271
|
+
|
|
272
|
+
|
|
116
273
|
class StepConfig(BaseModel):
|
|
117
274
|
"""One pipeline step. Its type IS the key it carries: exactly one of
|
|
118
275
|
`agent`, `action`, or `command` - empty values do not count, anything
|
|
@@ -133,6 +290,12 @@ class StepConfig(BaseModel):
|
|
|
133
290
|
env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
|
|
134
291
|
setup: str | None = None # BR-13: command steps only
|
|
135
292
|
network: bool = False # per-stage egress (command/agent + sandbox actions; host-side actions reject at registry time)
|
|
293
|
+
# unit-5: per-step mount granularity + egress policy. Empty mounts =
|
|
294
|
+
# strict-by-default (BR-1.1); egress None = strict empty allowlist
|
|
295
|
+
# (BR-8.3). Host-side built-in actions are refused these at registry
|
|
296
|
+
# time (the schema cannot tell host-side from sandbox actions).
|
|
297
|
+
mounts: list[MountSpec] = []
|
|
298
|
+
egress: EgressSpec | None = None
|
|
136
299
|
secrets: list[str] = [] # declared ref names; action steps reject
|
|
137
300
|
on_pass: RouteTarget | None = None
|
|
138
301
|
on_fail: RouteTarget | None = None
|
|
@@ -281,6 +444,26 @@ class PipelineConfig(BaseModel):
|
|
|
281
444
|
"enforces, or set 'deps_policy: open'")
|
|
282
445
|
return self
|
|
283
446
|
|
|
447
|
+
@model_validator(mode="after")
|
|
448
|
+
def _mounts_avoid_state_dir(self) -> "PipelineConfig":
|
|
449
|
+
"""BR-1.3: the host-only run-state dir is never a mount path.
|
|
450
|
+
A relative ``state_dir`` (e.g. ``.pr``) lives inside the
|
|
451
|
+
workspace and must be refused as a mount; the default relocated
|
|
452
|
+
state_dir is absolute/outside the workspace, so relative mount
|
|
453
|
+
paths can never collide with it.
|
|
454
|
+
"""
|
|
455
|
+
sd = self.state_dir.strip().rstrip("/\\")
|
|
456
|
+
if not sd or sd in ("/", "."):
|
|
457
|
+
return self
|
|
458
|
+
for step in self.steps:
|
|
459
|
+
for m in step.mounts:
|
|
460
|
+
mp = m.path.rstrip("/")
|
|
461
|
+
if mp == sd or mp.startswith(sd + "/"):
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"step {step.name!r}: mount path {m.path!r} names "
|
|
464
|
+
f"the host-only state_dir {self.state_dir!r}")
|
|
465
|
+
return self
|
|
466
|
+
|
|
284
467
|
@model_validator(mode="after")
|
|
285
468
|
def _check_graph(self) -> "PipelineConfig":
|
|
286
469
|
names = [s.name for s in self.steps]
|
{agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/agents.py
RENAMED
|
@@ -65,13 +65,15 @@ class SandboxTerminalExecutor(ToolExecutor):
|
|
|
65
65
|
def __init__(self, sandbox: "Sandbox", *, workspace: str = "",
|
|
66
66
|
env: dict[str, str] | None = None,
|
|
67
67
|
pipeline: str | None = None, step: str | None = None,
|
|
68
|
-
network: bool = False):
|
|
68
|
+
network: bool = False, mount_set=None, egress_policy=None):
|
|
69
69
|
self._sandbox = sandbox
|
|
70
70
|
self._workspace = workspace
|
|
71
71
|
self._env = env or {}
|
|
72
72
|
self._pipeline = pipeline
|
|
73
73
|
self._step = step
|
|
74
74
|
self._network = network
|
|
75
|
+
self._mount_set = mount_set
|
|
76
|
+
self._egress_policy = egress_policy
|
|
75
77
|
|
|
76
78
|
def __call__(self, action, conversation=None):
|
|
77
79
|
from openhands.tools.terminal import TerminalObservation
|
|
@@ -92,7 +94,8 @@ class SandboxTerminalExecutor(ToolExecutor):
|
|
|
92
94
|
request = SandboxExecutionRequest(
|
|
93
95
|
argv=command_argv(action.command), cwd=self._workspace,
|
|
94
96
|
env=self._env, timeout=action.timeout,
|
|
95
|
-
pipeline=self._pipeline, step=self._step, network=self._network
|
|
97
|
+
pipeline=self._pipeline, step=self._step, network=self._network,
|
|
98
|
+
mount_set=self._mount_set, egress_policy=self._egress_policy)
|
|
96
99
|
result = self._sandbox.run(request)
|
|
97
100
|
output = result.stdout + result.stderr
|
|
98
101
|
return TerminalObservation.from_text(
|
|
@@ -111,6 +114,7 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
|
|
|
111
114
|
workspace: str = "", pipeline: str | None = None,
|
|
112
115
|
step: str | None = None,
|
|
113
116
|
network: bool = False,
|
|
117
|
+
mount_set=None, egress_policy=None,
|
|
114
118
|
secrets: dict[str, str] | None = None) -> None:
|
|
115
119
|
"""Per-run re-registration of the `terminal` name (KD-4): a callable
|
|
116
120
|
factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
|
|
@@ -127,6 +131,7 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
|
|
|
127
131
|
conv_state,
|
|
128
132
|
executor=SandboxTerminalExecutor(
|
|
129
133
|
sandbox, workspace=workspace, env=resolved_env,
|
|
130
|
-
pipeline=pipeline, step=step, network=network
|
|
134
|
+
pipeline=pipeline, step=step, network=network,
|
|
135
|
+
mount_set=mount_set, egress_policy=egress_policy))
|
|
131
136
|
|
|
132
137
|
register_tool("terminal", factory)
|
{agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/dryrun.py
RENAMED
|
@@ -18,7 +18,7 @@ from .loaders import resolve_docs
|
|
|
18
18
|
from .isolation import (isolation_state_dir, resolve_effective_profile,
|
|
19
19
|
resolve_image_ref, resolved_seccomp_profile)
|
|
20
20
|
from .registry import (Registry, default_registry, load_extensions,
|
|
21
|
-
validate_action_network)
|
|
21
|
+
validate_action_mounts_egress, validate_action_network)
|
|
22
22
|
from .secrets import scan_env_files
|
|
23
23
|
|
|
24
24
|
|
|
@@ -46,6 +46,8 @@ def _validate_actions(cfg: PipelineConfig, registry: Registry) -> None:
|
|
|
46
46
|
f"step {step.name!r}: unknown action {step.action!r} "
|
|
47
47
|
f"(registered: {sorted(known)})")
|
|
48
48
|
validate_action_network(step.name, step.action, step.network, registry)
|
|
49
|
+
validate_action_mounts_egress(step.name, step.action, step.mounts,
|
|
50
|
+
step.egress, registry)
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
def _resolved_image(cfg: PipelineConfig) -> tuple[str, str, float]:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Egress policy assembly and target matching (unit-5).
|
|
2
|
+
|
|
3
|
+
The pure half of the egress model: ``assemble_egress_policy`` expands preset
|
|
4
|
+
references into a resolved ``EgressPolicy`` and classifies its class;
|
|
5
|
+
``match_target`` decides one connection (BR-6). The proxy (egress_proxy.py) is
|
|
6
|
+
the enforcement point that calls these; both are Docker-free and portable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from agentic_dev_loop.core.config import (
|
|
14
|
+
EgressSpec, parse_egress_port, parse_egress_target,
|
|
15
|
+
)
|
|
16
|
+
from .presets import expand_presets
|
|
17
|
+
|
|
18
|
+
ALLOWLIST = "allowlist"
|
|
19
|
+
# The explicit broad-but-logged browser class (BR-7): no target filtering,
|
|
20
|
+
# every connection logged host-side.
|
|
21
|
+
BROWSER = "browser"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TargetEntry:
|
|
26
|
+
"""One resolved allowlist target (BR-6.1): a domain-shaped host with an
|
|
27
|
+
optional port constraint. ``host`` may also be a stable IP literal (the
|
|
28
|
+
Q2 exception); it is never resolved to an IP at assembly."""
|
|
29
|
+
|
|
30
|
+
host: str
|
|
31
|
+
port: int | None = None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_spec(cls, spec: str) -> "TargetEntry":
|
|
35
|
+
host, port = parse_egress_target(spec)
|
|
36
|
+
return cls(host=host, port=port)
|
|
37
|
+
|
|
38
|
+
def to_spec(self) -> str:
|
|
39
|
+
if self.port is None:
|
|
40
|
+
return self.host
|
|
41
|
+
return f"{self.host}:{self.port}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class EgressPolicy:
|
|
46
|
+
"""The resolved egress policy: an expanded target set plus its class.
|
|
47
|
+
``browser`` (broad-but-logged) allows everything; ``allowlist`` denies
|
|
48
|
+
everything not named (BR-6.2)."""
|
|
49
|
+
|
|
50
|
+
targets: tuple[TargetEntry, ...] = ()
|
|
51
|
+
policy_class: str = ALLOWLIST
|
|
52
|
+
|
|
53
|
+
def to_spec(self) -> EgressSpec:
|
|
54
|
+
cls = None if self.policy_class == ALLOWLIST else BROWSER
|
|
55
|
+
return EgressSpec(
|
|
56
|
+
class_=cls,
|
|
57
|
+
presets=[],
|
|
58
|
+
targets=[t.to_spec() for t in self.targets],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def assemble_egress_policy(egress: EgressSpec | None) -> EgressPolicy:
|
|
63
|
+
"""Resolve the step's egress declaration into a policy (Q9/BR-7/BR-8).
|
|
64
|
+
|
|
65
|
+
``None`` yields a strict empty allowlist (deny all - the engine never
|
|
66
|
+
adds an undeclared target, BR-8.3). ``class: browser`` yields the explicit
|
|
67
|
+
broad-but-logged class with no target filtering. Anything else expands
|
|
68
|
+
presets then explicit targets, deterministically, and classifies strict.
|
|
69
|
+
"""
|
|
70
|
+
if egress is None:
|
|
71
|
+
return EgressPolicy()
|
|
72
|
+
cls = egress.class_ or ALLOWLIST
|
|
73
|
+
if cls == BROWSER:
|
|
74
|
+
return EgressPolicy(policy_class=BROWSER)
|
|
75
|
+
entries: list[TargetEntry] = []
|
|
76
|
+
if egress.presets:
|
|
77
|
+
for spec in expand_presets(egress.presets):
|
|
78
|
+
entries.append(TargetEntry.from_spec(spec))
|
|
79
|
+
for spec in egress.targets:
|
|
80
|
+
entries.append(TargetEntry.from_spec(spec))
|
|
81
|
+
return EgressPolicy(targets=tuple(entries), policy_class=ALLOWLIST)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _split_host_port(hostname: str) -> tuple[str, int | None]:
|
|
85
|
+
"""Split a CONNECT target into (host, port). Bare hostnames carry no port."""
|
|
86
|
+
value = hostname.strip()
|
|
87
|
+
if value.startswith("["):
|
|
88
|
+
end = value.find("]")
|
|
89
|
+
if end == -1:
|
|
90
|
+
return value, None
|
|
91
|
+
host = value[1:end]
|
|
92
|
+
rest = value[end + 1:]
|
|
93
|
+
if rest.startswith(":"):
|
|
94
|
+
return host, parse_egress_port(hostname, rest[1:])
|
|
95
|
+
return host, None
|
|
96
|
+
if value.count(":") == 1:
|
|
97
|
+
host, port = value.rsplit(":", 1)
|
|
98
|
+
if port.isdigit():
|
|
99
|
+
return host, int(port)
|
|
100
|
+
return value, None
|
|
101
|
+
return value, None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def match_target(hostname: str, policy: EgressPolicy) -> bool:
|
|
105
|
+
"""Deny-by-default hostname match against the expanded allowlist (BR-6).
|
|
106
|
+
|
|
107
|
+
The browser class allows everything (BR-7). An allowlist entry matches on
|
|
108
|
+
exact hostname; an entry carrying a port also constrains the connection
|
|
109
|
+
port, taken from the ``host:port`` CONNECT-target form when present.
|
|
110
|
+
"""
|
|
111
|
+
if policy.policy_class == BROWSER:
|
|
112
|
+
return True
|
|
113
|
+
host, port = _split_host_port(hostname)
|
|
114
|
+
for entry in policy.targets:
|
|
115
|
+
if entry.host != host:
|
|
116
|
+
continue
|
|
117
|
+
if entry.port is not None and entry.port != port:
|
|
118
|
+
continue
|
|
119
|
+
return True
|
|
120
|
+
return False
|