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.

Files changed (86) hide show
  1. {agentic_dev_loop-1.0.0a17/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a19}/PKG-INFO +40 -19
  2. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/README.md +39 -18
  3. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/__init__.py +2 -2
  5. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/config.py +183 -0
  6. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/agents.py +8 -3
  7. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/dryrun.py +3 -1
  8. agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/egress_policy.py +120 -0
  9. agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/egress_proxy.py +247 -0
  10. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/graph.py +7 -2
  11. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/isolation.py +325 -17
  12. agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/mounts.py +63 -0
  13. agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/presets.py +61 -0
  14. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/registry.py +15 -0
  15. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/stages.py +102 -73
  16. agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/step_workspace.py +163 -0
  17. agentic_dev_loop-1.0.0a19/src/agentic_dev_loop/engine/sync_back.py +198 -0
  18. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19/src/agentic_dev_loop.egg-info}/PKG-INFO +40 -19
  19. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/SOURCES.txt +12 -0
  20. agentic_dev_loop-1.0.0a19/tests/test_egress_proxy.py +128 -0
  21. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_hostboundary_integration.py +38 -17
  22. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_isolation.py +339 -29
  23. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_isolation_integration.py +63 -1
  24. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_registry.py +22 -1
  25. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_stages.py +40 -4
  26. agentic_dev_loop-1.0.0a19/tests/test_step_workspace.py +113 -0
  27. agentic_dev_loop-1.0.0a19/tests/test_sync_back.py +139 -0
  28. agentic_dev_loop-1.0.0a19/tests/test_unit5_config.py +173 -0
  29. agentic_dev_loop-1.0.0a19/tests/test_unit5_pbt.py +74 -0
  30. agentic_dev_loop-1.0.0a19/tests/test_unit5_pure.py +196 -0
  31. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/LICENSE +0 -0
  32. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/setup.cfg +0 -0
  33. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/__init__.py +0 -0
  34. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/results.py +0 -0
  35. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/core/verdict.py +0 -0
  36. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/__init__.py +0 -0
  37. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/actions.py +0 -0
  38. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/cli.py +0 -0
  39. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/deps.py +0 -0
  40. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  41. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  42. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  43. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/llm.py +0 -0
  44. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/loaders.py +0 -0
  45. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/outputs.py +0 -0
  46. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/proc.py +0 -0
  47. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  48. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  49. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/engine/secrets.py +0 -0
  50. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/py.typed +0 -0
  51. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/viz/__init__.py +0 -0
  52. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/viz/__main__.py +0 -0
  53. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop/viz/server.py +0 -0
  54. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  55. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  56. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  57. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  58. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_actions.py +0 -0
  59. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_agents.py +0 -0
  60. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_aidlc.py +0 -0
  61. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_cli.py +0 -0
  62. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_config.py +0 -0
  63. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_deps.py +0 -0
  64. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_dry_run.py +0 -0
  65. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_e2e_fixtures.py +0 -0
  66. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_graph.py +0 -0
  67. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_hostboundary.py +0 -0
  68. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_isolation_pbt.py +0 -0
  69. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_llm.py +0 -0
  70. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_loaders.py +0 -0
  71. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_module_contract.py +0 -0
  72. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_outputs.py +0 -0
  73. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_outputs_pbt.py +0 -0
  74. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_proc.py +0 -0
  75. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_publish.py +0 -0
  76. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_qa_prompt.py +0 -0
  77. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_repo_ascii.py +0 -0
  78. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_results.py +0 -0
  79. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_sdk_compat.py +0 -0
  80. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_secrets.py +0 -0
  81. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_secrets_integration.py +0 -0
  82. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_secrets_pipeline.py +0 -0
  83. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_source_resolution.py +0 -0
  84. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_trigger_script.py +0 -0
  85. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_verdict.py +0 -0
  86. {agentic_dev_loop-1.0.0a17 → agentic_dev_loop-1.0.0a19}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a17
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 writes outside your workspace. No sandbox available → the run stops instead of degrading. |
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.0a9"
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 mount set is exactly `{workspace}`, so an
102
- out-of-workspace install is unmounted and never agent-writable:
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.0a9"
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.0a9"
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` grants egress to a command or agent step (default off) - see [Per-stage networking](#per-stage-networking). |
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
- network: true # egress for this step only
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`. A networked step runs in a second, otherwise-identical
276
- sandbox container (`--read-only`, `--cap-drop ALL`, single workspace mount)
277
- on the Docker bridge network - the default network-none container is always
278
- created too, so no infrastructure work happens mid-run.
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 gives its agent raw
282
- egress for that stage; the default stays network-off everywhere. Reserve it
283
- for stages that need it (QA, integration). The residual risk is documented
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.0a9" # engine venv OUTSIDE the workspace (self-check)
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 writes outside your workspace. No sandbox available → the run stops instead of degrading. |
33
+ | **Kernel-level sandboxing** | Agent work and command stages run isolated from your machine: [no network access **by default** - authors opt specific stages in](#per-stage-networking), 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.0a9"
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 mount set is exactly `{workspace}`, so an
60
- out-of-workspace install is unmounted and never agent-writable:
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.0a9"
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.0a9"
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` grants egress to a command or agent step (default off) - see [Per-stage networking](#per-stage-networking). |
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
- network: true # egress for this step only
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`. A networked step runs in a second, otherwise-identical
234
- sandbox container (`--read-only`, `--cap-drop ALL`, single workspace mount)
235
- on the Docker bridge network - the default network-none container is always
236
- created too, so no infrastructure work happens mid-run.
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 gives its agent raw
240
- egress for that stage; the default stays network-off everywhere. Reserve it
241
- for stages that need it (QA, integration). The residual risk is documented
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.0a9" # engine venv OUTSIDE the workspace (self-check)
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.0a17"
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"
@@ -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]
@@ -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)
@@ -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