agentic-dev-loop 1.0.0a34__tar.gz → 1.0.0a36__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.0a34/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a36}/PKG-INFO +2 -2
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/README.md +1 -1
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/agent_error_guide.md +1 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/egress_policy.py +16 -6
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/egress_proxy.py +88 -11
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/isolation.py +232 -43
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/sync_back.py +14 -3
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36/src/agentic_dev_loop.egg-info}/PKG-INFO +2 -2
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_agent_error_guide.py +6 -3
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_egress_proxy.py +160 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_isolation.py +179 -32
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_isolation_integration.py +4 -4
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_isolation_pbt.py +4 -3
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_sync_back.py +46 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/config.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/paths.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/allowlist.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/forbidden.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/presets.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/stages.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_028_declared_caches.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_029_merge_on_conflict.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_032_stop_when_green.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_033_brief_step.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_035_finalize_nudge.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_e2e_fixtures.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_unit5_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_viz.py +0 -0
{agentic_dev_loop-1.0.0a34/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a36}/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.0a36
|
|
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,7 +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/
|
|
245
|
+
| `custom_prompt:` | Planned (planning-docs/040). 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. |
|
|
246
246
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
247
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). |
|
|
248
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). |
|
|
@@ -200,7 +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/
|
|
203
|
+
| `custom_prompt:` | Planned (planning-docs/040). 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. |
|
|
204
204
|
| `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
|
|
205
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). |
|
|
206
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). |
|
|
@@ -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.0a36"
|
|
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"
|
|
@@ -4,3 +4,4 @@
|
|
|
4
4
|
| AE-SBX-010 | The sandbox could not create the execution container for the command, so the command did not run. | The command is not the problem: retry once; if it persists, mention the code in your final response and stop. |
|
|
5
5
|
| AE-SBX-016 | The loop refused to sync an edit because the real repo changed underneath on a path this workspace also touches; the file in this workspace is stale and its edits will not be applied. | The command is not the problem: retry once, do not edit or work around the named path, and mention the code in your final response if it persists. |
|
|
6
6
|
| AE-SBX-018 | The network egress proxy for this step stopped, so the sandbox refused the command rather than run without enforcement. | The command is not the problem: do not retry it, mention the code in your final response, and stop. |
|
|
7
|
+
| AE-SBX-020 | The sandbox could not start the network egress proxy for this step, so the command did not run. | The command is not the problem: retry once; if it persists, mention the code in your final response and stop. |
|
{agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/egress_policy.py
RENAMED
|
@@ -10,11 +10,6 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from dataclasses import dataclass
|
|
12
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
13
|
ALLOWLIST = "allowlist"
|
|
19
14
|
# The explicit broad-but-logged browser class (BR-7): no target filtering,
|
|
20
15
|
# every connection logged host-side.
|
|
@@ -32,6 +27,7 @@ class TargetEntry:
|
|
|
32
27
|
|
|
33
28
|
@classmethod
|
|
34
29
|
def from_spec(cls, spec: str) -> "TargetEntry":
|
|
30
|
+
from agentic_dev_loop.core.config import parse_egress_target
|
|
35
31
|
host, port = parse_egress_target(spec)
|
|
36
32
|
return cls(host=host, port=port)
|
|
37
33
|
|
|
@@ -51,6 +47,7 @@ class EgressPolicy:
|
|
|
51
47
|
policy_class: str = ALLOWLIST
|
|
52
48
|
|
|
53
49
|
def to_spec(self) -> EgressSpec:
|
|
50
|
+
from agentic_dev_loop.core.config import EgressSpec
|
|
54
51
|
cls = None if self.policy_class == ALLOWLIST else BROWSER
|
|
55
52
|
return EgressSpec(
|
|
56
53
|
class_=cls,
|
|
@@ -72,6 +69,7 @@ def assemble_egress_policy(egress: EgressSpec | None) -> EgressPolicy:
|
|
|
72
69
|
cls = egress.class_ or ALLOWLIST
|
|
73
70
|
if cls == BROWSER:
|
|
74
71
|
return EgressPolicy(policy_class=BROWSER)
|
|
72
|
+
from .presets import expand_presets
|
|
75
73
|
entries: list[TargetEntry] = []
|
|
76
74
|
if egress.presets:
|
|
77
75
|
for spec in expand_presets(egress.presets):
|
|
@@ -81,6 +79,18 @@ def assemble_egress_policy(egress: EgressSpec | None) -> EgressPolicy:
|
|
|
81
79
|
return EgressPolicy(targets=tuple(entries), policy_class=ALLOWLIST)
|
|
82
80
|
|
|
83
81
|
|
|
82
|
+
def _parse_port(spec: str, port: str) -> int | None:
|
|
83
|
+
"""Validate a target port (BR-6.3), standalone-safe (no engine package)."""
|
|
84
|
+
if port == "":
|
|
85
|
+
raise ValueError(f"malformed egress target {spec!r}: empty port")
|
|
86
|
+
if not port.isdigit():
|
|
87
|
+
raise ValueError(f"malformed egress target {spec!r}: port {port!r} not numeric")
|
|
88
|
+
n = int(port)
|
|
89
|
+
if not 1 <= n <= 65535:
|
|
90
|
+
raise ValueError(f"malformed egress target {spec!r}: port {n} out of range")
|
|
91
|
+
return n
|
|
92
|
+
|
|
93
|
+
|
|
84
94
|
def _split_host_port(hostname: str) -> tuple[str, int | None]:
|
|
85
95
|
"""Split a CONNECT target into (host, port). Bare hostnames carry no port."""
|
|
86
96
|
value = hostname.strip()
|
|
@@ -91,7 +101,7 @@ def _split_host_port(hostname: str) -> tuple[str, int | None]:
|
|
|
91
101
|
host = value[1:end]
|
|
92
102
|
rest = value[end + 1:]
|
|
93
103
|
if rest.startswith(":"):
|
|
94
|
-
return host,
|
|
104
|
+
return host, _parse_port(hostname, rest[1:])
|
|
95
105
|
return host, None
|
|
96
106
|
if value.count(":") == 1:
|
|
97
107
|
host, port = value.rsplit(":", 1)
|
{agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/egress_proxy.py
RENAMED
|
@@ -8,13 +8,20 @@ request hostname (CONNECT target) per connection against the expanded policy
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import argparse
|
|
11
12
|
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
12
15
|
import threading
|
|
13
16
|
from dataclasses import dataclass, field
|
|
14
17
|
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
15
19
|
from urllib.parse import urlsplit
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
if __package__:
|
|
22
|
+
from .egress_policy import EgressPolicy, TargetEntry, match_target
|
|
23
|
+
else: # standalone container-mode entrypoint (run as a script)
|
|
24
|
+
from egress_policy import EgressPolicy, TargetEntry, match_target
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
@dataclass(frozen=True)
|
|
@@ -33,6 +40,8 @@ class EgressProxy:
|
|
|
33
40
|
|
|
34
41
|
policy: EgressPolicy
|
|
35
42
|
events: list[EnforcementEvent] = field(default_factory=list)
|
|
43
|
+
dial_rewrites: dict[str, str] = field(default_factory=dict)
|
|
44
|
+
event_file: Path | None = None
|
|
36
45
|
|
|
37
46
|
def __post_init__(self) -> None:
|
|
38
47
|
self._server: asyncio.AbstractServer | None = None
|
|
@@ -138,7 +147,7 @@ class EgressProxy:
|
|
|
138
147
|
# upstream answers with a decode alert - the restore step's SSL
|
|
139
148
|
# TLSV1_ALERT_DECODE_ERROR).
|
|
140
149
|
await _drain_headers(reader)
|
|
141
|
-
conn = await _open(host, port)
|
|
150
|
+
conn = await self._open(host, port)
|
|
142
151
|
if conn is None:
|
|
143
152
|
_write(writer, b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
|
|
144
153
|
return
|
|
@@ -159,7 +168,7 @@ class EgressProxy:
|
|
|
159
168
|
_write(writer, b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
|
|
160
169
|
return
|
|
161
170
|
self._record(host, True)
|
|
162
|
-
conn = await _open(host, port)
|
|
171
|
+
conn = await self._open(host, port)
|
|
163
172
|
if conn is None:
|
|
164
173
|
_write(writer, b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
|
|
165
174
|
return
|
|
@@ -174,9 +183,23 @@ class EgressProxy:
|
|
|
174
183
|
await _relay_both(reader, writer, upstream_reader, upstream_writer)
|
|
175
184
|
|
|
176
185
|
def _record(self, hostname: str, allowed: bool) -> None:
|
|
177
|
-
|
|
186
|
+
event = EnforcementEvent(
|
|
178
187
|
hostname=hostname, allowed=allowed,
|
|
179
|
-
timestamp=datetime.now(timezone.utc).isoformat())
|
|
188
|
+
timestamp=datetime.now(timezone.utc).isoformat())
|
|
189
|
+
self.events.append(event)
|
|
190
|
+
if self.event_file is not None:
|
|
191
|
+
_append_event_file(self.event_file, event)
|
|
192
|
+
|
|
193
|
+
async def _open(self, host: str, port: int):
|
|
194
|
+
"""Dial the upstream, applying the configured dial rewrite (D8).
|
|
195
|
+
``match_target`` already ran against the original hostname, and the
|
|
196
|
+
request/Host header keep that hostname; only the upstream dial address
|
|
197
|
+
is rewritten."""
|
|
198
|
+
dial_host = self.dial_rewrites.get(host, host)
|
|
199
|
+
try:
|
|
200
|
+
return await asyncio.open_connection(dial_host, port)
|
|
201
|
+
except OSError:
|
|
202
|
+
return None
|
|
180
203
|
|
|
181
204
|
|
|
182
205
|
def _parse_request_line(line: bytes) -> tuple[str, str, str]:
|
|
@@ -207,12 +230,6 @@ def _write(writer: asyncio.StreamWriter, data: bytes) -> None:
|
|
|
207
230
|
writer.write(data)
|
|
208
231
|
|
|
209
232
|
|
|
210
|
-
async def _open(host: str, port: int):
|
|
211
|
-
try:
|
|
212
|
-
return await asyncio.open_connection(host, port)
|
|
213
|
-
except OSError:
|
|
214
|
-
return None
|
|
215
|
-
|
|
216
233
|
|
|
217
234
|
async def _forward_headers(reader, upstream, host: str) -> None:
|
|
218
235
|
saw_host = False
|
|
@@ -262,3 +279,63 @@ async def _relay(src, dst) -> None:
|
|
|
262
279
|
dst.write_eof()
|
|
263
280
|
except Exception:
|
|
264
281
|
pass
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _append_event_file(path: Path, event: EnforcementEvent) -> None:
|
|
286
|
+
"""Append one enforcement event to the mounted JSONL file (best-effort:
|
|
287
|
+
a broken events file must never break enforcement)."""
|
|
288
|
+
record = {
|
|
289
|
+
"hostname": event.hostname,
|
|
290
|
+
"allowed": event.allowed,
|
|
291
|
+
"timestamp": event.timestamp,
|
|
292
|
+
}
|
|
293
|
+
try:
|
|
294
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
295
|
+
fh.write(json.dumps(record, sort_keys=True) + "\n")
|
|
296
|
+
except OSError:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def policy_to_json(policy: EgressPolicy) -> str:
|
|
301
|
+
"""Serialize a resolved policy for the container-mode entrypoint."""
|
|
302
|
+
return json.dumps({
|
|
303
|
+
"class": policy.policy_class,
|
|
304
|
+
"targets": [{"host": t.host, "port": t.port} for t in policy.targets],
|
|
305
|
+
}, sort_keys=True)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def policy_from_json(text: str) -> EgressPolicy:
|
|
309
|
+
"""Rebuild a resolved policy from the entrypoint's ``--policy`` JSON."""
|
|
310
|
+
data = json.loads(text)
|
|
311
|
+
return EgressPolicy(
|
|
312
|
+
targets=tuple(
|
|
313
|
+
TargetEntry(host=t["host"], port=t.get("port"))
|
|
314
|
+
for t in data.get("targets", [])),
|
|
315
|
+
policy_class=data.get("class", "allowlist"),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def main(argv: list[str] | None = None) -> int:
|
|
320
|
+
"""Container-mode entrypoint: serve the policy on the internal network
|
|
321
|
+
and append enforcement events to a mounted file until SIGTERM."""
|
|
322
|
+
parser = argparse.ArgumentParser(description="agentic-dev-loop egress proxy")
|
|
323
|
+
parser.add_argument("--policy", required=True)
|
|
324
|
+
parser.add_argument("--events", required=True)
|
|
325
|
+
parser.add_argument("--dial-rewrites", default="{}")
|
|
326
|
+
parser.add_argument("--host", default="0.0.0.0")
|
|
327
|
+
parser.add_argument("--port", type=int, default=0)
|
|
328
|
+
args = parser.parse_args(argv)
|
|
329
|
+
|
|
330
|
+
proxy = EgressProxy(
|
|
331
|
+
policy=policy_from_json(args.policy),
|
|
332
|
+
dial_rewrites=json.loads(args.dial_rewrites),
|
|
333
|
+
event_file=Path(args.events),
|
|
334
|
+
)
|
|
335
|
+
proxy.start(host=args.host, port=args.port)
|
|
336
|
+
threading.Event().wait()
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
sys.exit(main())
|