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.

Files changed (98) hide show
  1. {agentic_dev_loop-1.0.0a34/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a36}/PKG-INFO +2 -2
  2. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/README.md +1 -1
  3. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/agent_error_guide.md +1 -0
  5. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/egress_policy.py +16 -6
  6. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/egress_proxy.py +88 -11
  7. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/isolation.py +232 -43
  8. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/sync_back.py +14 -3
  9. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36/src/agentic_dev_loop.egg-info}/PKG-INFO +2 -2
  10. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_agent_error_guide.py +6 -3
  11. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_egress_proxy.py +160 -0
  12. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_isolation.py +179 -32
  13. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_isolation_integration.py +4 -4
  14. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_isolation_pbt.py +4 -3
  15. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_sync_back.py +46 -0
  16. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/LICENSE +0 -0
  17. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/setup.cfg +0 -0
  18. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/__init__.py +0 -0
  19. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/__init__.py +0 -0
  20. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/config.py +0 -0
  21. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/paths.py +0 -0
  22. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/results.py +0 -0
  23. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/core/verdict.py +0 -0
  24. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/__init__.py +0 -0
  25. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/actions.py +0 -0
  26. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  27. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/agents.py +0 -0
  28. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  29. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/cli.py +0 -0
  30. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/deps.py +0 -0
  31. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  32. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  33. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  34. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  35. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/graph.py +0 -0
  36. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  37. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/llm.py +0 -0
  38. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/loaders.py +0 -0
  39. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/mounts.py +0 -0
  40. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/outputs.py +0 -0
  41. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/presets.py +0 -0
  42. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/proc.py +0 -0
  43. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/registry.py +0 -0
  44. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  45. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  46. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/secrets.py +0 -0
  47. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/stages.py +0 -0
  48. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  49. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/py.typed +0 -0
  50. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/viz/__init__.py +0 -0
  51. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/viz/__main__.py +0 -0
  52. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop/viz/server.py +0 -0
  53. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  54. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  55. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  56. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  57. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  58. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_028_declared_caches.py +0 -0
  59. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_029_merge_on_conflict.py +0 -0
  60. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_032_stop_when_green.py +0 -0
  61. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_033_brief_step.py +0 -0
  62. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_035_finalize_nudge.py +0 -0
  63. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  64. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_actions.py +0 -0
  65. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_agents.py +0 -0
  66. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_aidlc.py +0 -0
  67. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_cli.py +0 -0
  68. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_config.py +0 -0
  69. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_deps.py +0 -0
  70. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_dry_run.py +0 -0
  71. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_e2e_fixtures.py +0 -0
  72. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_graph.py +0 -0
  73. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_hostboundary.py +0 -0
  74. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_hostboundary_integration.py +0 -0
  75. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_llm.py +0 -0
  76. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_loaders.py +0 -0
  77. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_module_contract.py +0 -0
  78. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_outputs.py +0 -0
  79. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_outputs_pbt.py +0 -0
  80. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_proc.py +0 -0
  81. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_publish.py +0 -0
  82. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_qa_prompt.py +0 -0
  83. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_registry.py +0 -0
  84. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_repo_ascii.py +0 -0
  85. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_results.py +0 -0
  86. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_sdk_compat.py +0 -0
  87. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_secrets.py +0 -0
  88. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_secrets_integration.py +0 -0
  89. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_secrets_pipeline.py +0 -0
  90. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_source_resolution.py +0 -0
  91. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_stages.py +0 -0
  92. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_step_workspace.py +0 -0
  93. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_trigger_script.py +0 -0
  94. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_unit5_config.py +0 -0
  95. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_unit5_pbt.py +0 -0
  96. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_unit5_pure.py +0 -0
  97. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/tests/test_verdict.py +0 -0
  98. {agentic_dev_loop-1.0.0a34 → agentic_dev_loop-1.0.0a36}/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.0a34
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/039). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
245
+ | `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/039). Optional agent-step value: literal text, or a path whose file contents are read at load time, appended verbatim to the agent prompt at context assembly (after the prompt, before the task/artifacts). A value that looks like a path but names no file is a validation error. Agent steps only. |
203
+ | `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.0a34"
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. |
@@ -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, parse_egress_port(hostname, rest[1:])
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)
@@ -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
- from .egress_policy import EgressPolicy, match_target
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
- self.events.append(EnforcementEvent(
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())