agentic-dev-loop 1.0.0a8__tar.gz → 1.0.0a9__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 (64) hide show
  1. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/PKG-INFO +42 -3
  2. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/README.md +41 -2
  3. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/core/config.py +59 -0
  5. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/agents.py +7 -3
  6. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/dryrun.py +2 -0
  7. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/graph.py +10 -3
  8. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/isolation.py +10 -2
  9. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/outputs.py +4 -1
  10. agentic_dev_loop-1.0.0a9/src/agentic_dev_loop/engine/secrets.py +118 -0
  11. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/stages.py +19 -7
  12. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop.egg-info/PKG-INFO +42 -3
  13. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop.egg-info/SOURCES.txt +4 -0
  14. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_e2e_fixtures.py +5 -1
  15. agentic_dev_loop-1.0.0a9/tests/test_secrets.py +221 -0
  16. agentic_dev_loop-1.0.0a9/tests/test_secrets_integration.py +67 -0
  17. agentic_dev_loop-1.0.0a9/tests/test_secrets_pipeline.py +106 -0
  18. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_viz.py +2 -1
  19. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/LICENSE +0 -0
  20. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/setup.cfg +0 -0
  21. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/__init__.py +0 -0
  22. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/core/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/core/results.py +0 -0
  24. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/core/verdict.py +0 -0
  25. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/__init__.py +0 -0
  26. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/actions.py +0 -0
  27. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/cli.py +0 -0
  28. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  29. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  30. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/llm.py +0 -0
  31. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/loaders.py +0 -0
  32. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/proc.py +0 -0
  33. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/engine/registry.py +0 -0
  34. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/py.typed +0 -0
  35. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/viz/__init__.py +0 -0
  36. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/viz/__main__.py +0 -0
  37. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop/viz/server.py +0 -0
  38. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  39. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  40. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  41. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  42. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_actions.py +0 -0
  43. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_agents.py +0 -0
  44. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_aidlc.py +0 -0
  45. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_cli.py +0 -0
  46. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_config.py +0 -0
  47. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_dry_run.py +0 -0
  48. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_graph.py +0 -0
  49. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_isolation.py +0 -0
  50. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_isolation_integration.py +0 -0
  51. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_isolation_pbt.py +0 -0
  52. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_llm.py +0 -0
  53. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_loaders.py +0 -0
  54. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_module_contract.py +0 -0
  55. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_outputs.py +0 -0
  56. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_outputs_pbt.py +0 -0
  57. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_proc.py +0 -0
  58. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_publish.py +0 -0
  59. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_registry.py +0 -0
  60. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_results.py +0 -0
  61. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_sdk_compat.py +0 -0
  62. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_source_resolution.py +0 -0
  63. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_stages.py +0 -0
  64. {agentic_dev_loop-1.0.0a8 → agentic_dev_loop-1.0.0a9}/tests/test_verdict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a8
3
+ Version: 1.0.0a9
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
@@ -192,9 +192,49 @@ steps:
192
192
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
193
193
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
194
194
  | `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). |
195
+ | `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). |
195
196
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
196
197
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
197
198
 
199
+ ### Secrets (`secrets:`) — refs only, never values
200
+
201
+ Secret values never belong in the pipeline yaml, the workspace, or `state_dir`.
202
+ Declare the refs at the top level and opt each step into the refs it needs:
203
+
204
+ ```yaml
205
+ secrets: # refs only — never values
206
+ kinde-client-secret: # ref name (arbitrary)
207
+ from: env # provider name
208
+ key: KINDIE_CLIENT_SECRET # provider-specific locator
209
+ r2-keys:
210
+ from: env
211
+ key: YOSK_R2_KEYS
212
+
213
+ steps:
214
+ - name: qa
215
+ agent: qa
216
+ secrets: [kinde-client-secret, r2-keys]
217
+ ```
218
+
219
+ - The engine resolves every declared ref **host-side at run start** (the `env`
220
+ provider reads the named host environment variable). A missing variable, an
221
+ unknown provider, or a ref a step lists without declaring it fails fast —
222
+ naming the ref, the step, and the provider error. No silent fallback.
223
+ - A resolved secret is injected into a step's exec env **only when that step
224
+ lists the ref** — the same path step `env:` uses, no new channel. The secret
225
+ never reaches the provision container, `state_dir`, or any other step.
226
+ - Prefer a mechanical `command` step for secret-consuming work (a script runs
227
+ the app with creds) so the LLM and the secret never meet; an `agent` step
228
+ holds a secret only when its job genuinely requires it.
229
+ - **`.env*` ban.** At run start the workspace root is scanned for `.env*`
230
+ files. Any hit fails fast, naming the files, with the remedy text. The
231
+ convention fixtures `.env.example`, `.env.template`, and `.env.sample` are
232
+ whitelisted. This applies regardless of `network:` flags.
233
+ - **Output scrubbing.** Resolved secret values are scrubbed from every
234
+ engine-written output — captured step output, retry notes, artifacts, and
235
+ `state_dir` files. (Hygiene: the declaring step's agent has already seen
236
+ the value in its own env.)
237
+
198
238
  ### Per-stage networking
199
239
 
200
240
  The sandbox is network-off by default. A step that legitimately needs egress —
@@ -226,8 +266,7 @@ steps:
226
266
  git clone git@github.com:lukedawilson/agentic-dev-loop.git
227
267
  cd agentic-dev-loop
228
268
  ./setup-venv.sh # idempotent .venv setup (never touches bare pip)
229
- # create .env with your LLM key (loaded by trigger-agentic-dev-loop.sh, never committed):
230
- # echo 'OPENAI_API_KEY=...' > .env
269
+ export OPENAI_API_KEY=... # set your LLM key in the host env (no .env in the repo)
231
270
  python -m pytest tests/ -q # fast suite
232
271
  python -m pytest tests/ -q -m "not docker" # skip engine-dependent tests
233
272
  ./trigger-agentic-dev-loop.sh --dry-run # run the shipped pipeline against this repo
@@ -153,9 +153,49 @@ steps:
153
153
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
154
154
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
155
155
  | `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). |
156
+ | `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). |
156
157
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
157
158
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
158
159
 
160
+ ### Secrets (`secrets:`) — refs only, never values
161
+
162
+ Secret values never belong in the pipeline yaml, the workspace, or `state_dir`.
163
+ Declare the refs at the top level and opt each step into the refs it needs:
164
+
165
+ ```yaml
166
+ secrets: # refs only — never values
167
+ kinde-client-secret: # ref name (arbitrary)
168
+ from: env # provider name
169
+ key: KINDIE_CLIENT_SECRET # provider-specific locator
170
+ r2-keys:
171
+ from: env
172
+ key: YOSK_R2_KEYS
173
+
174
+ steps:
175
+ - name: qa
176
+ agent: qa
177
+ secrets: [kinde-client-secret, r2-keys]
178
+ ```
179
+
180
+ - The engine resolves every declared ref **host-side at run start** (the `env`
181
+ provider reads the named host environment variable). A missing variable, an
182
+ unknown provider, or a ref a step lists without declaring it fails fast —
183
+ naming the ref, the step, and the provider error. No silent fallback.
184
+ - A resolved secret is injected into a step's exec env **only when that step
185
+ lists the ref** — the same path step `env:` uses, no new channel. The secret
186
+ never reaches the provision container, `state_dir`, or any other step.
187
+ - Prefer a mechanical `command` step for secret-consuming work (a script runs
188
+ the app with creds) so the LLM and the secret never meet; an `agent` step
189
+ holds a secret only when its job genuinely requires it.
190
+ - **`.env*` ban.** At run start the workspace root is scanned for `.env*`
191
+ files. Any hit fails fast, naming the files, with the remedy text. The
192
+ convention fixtures `.env.example`, `.env.template`, and `.env.sample` are
193
+ whitelisted. This applies regardless of `network:` flags.
194
+ - **Output scrubbing.** Resolved secret values are scrubbed from every
195
+ engine-written output — captured step output, retry notes, artifacts, and
196
+ `state_dir` files. (Hygiene: the declaring step's agent has already seen
197
+ the value in its own env.)
198
+
159
199
  ### Per-stage networking
160
200
 
161
201
  The sandbox is network-off by default. A step that legitimately needs egress —
@@ -187,8 +227,7 @@ steps:
187
227
  git clone git@github.com:lukedawilson/agentic-dev-loop.git
188
228
  cd agentic-dev-loop
189
229
  ./setup-venv.sh # idempotent .venv setup (never touches bare pip)
190
- # create .env with your LLM key (loaded by trigger-agentic-dev-loop.sh, never committed):
191
- # echo 'OPENAI_API_KEY=...' > .env
230
+ export OPENAI_API_KEY=... # set your LLM key in the host env (no .env in the repo)
192
231
  python -m pytest tests/ -q # fast suite
193
232
  python -m pytest tests/ -q -m "not docker" # skip engine-dependent tests
194
233
  ./trigger-agentic-dev-loop.sh --dry-run # run the shipped pipeline against this repo
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a8"
7
+ version = "1.0.0a9"
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"
@@ -11,6 +11,7 @@ import yaml
11
11
  from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
12
12
 
13
13
  TERMINALS = frozenset({"success", "failure"})
14
+ KNOWN_SECRET_PROVIDERS = frozenset({"env"})
14
15
 
15
16
 
16
17
  class VerdictSpec(BaseModel):
@@ -36,6 +37,34 @@ def route_target(route: RouteTarget | None) -> str | None:
36
37
  return route.goto if isinstance(route, Route) else route
37
38
 
38
39
 
40
+ class SecretSpec(BaseModel):
41
+ """One ``secrets:`` ref — the locator only, never a value. ``from`` names
42
+ the provider (``env`` first); ``key`` is that provider's locator (for
43
+ ``env``, the host environment variable to read and the step env name to
44
+ inject). Unknown keys fail at load (extra="forbid")."""
45
+
46
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
47
+
48
+ from_: str = Field(alias="from")
49
+ key: str
50
+
51
+ @field_validator("from_")
52
+ @classmethod
53
+ def _known_provider(cls, v: str) -> str:
54
+ if v not in KNOWN_SECRET_PROVIDERS:
55
+ raise ValueError(
56
+ f"unknown secret provider {v!r} (known: "
57
+ f"{sorted(KNOWN_SECRET_PROVIDERS)})")
58
+ return v
59
+
60
+ @field_validator("key")
61
+ @classmethod
62
+ def _nonempty_key(cls, v: str) -> str:
63
+ if not v.strip():
64
+ raise ValueError("secret key must be a non-empty string")
65
+ return v
66
+
67
+
39
68
  class IsolationConfig(BaseModel):
40
69
  """Optional `isolation:` section — configures the always-on sandbox (BR-2).
41
70
  Absence means opinionated defaults, never unsandboxed behaviour. Unknown
@@ -86,6 +115,7 @@ class StepConfig(BaseModel):
86
115
  env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
87
116
  setup: str | None = None # BR-13: command steps only
88
117
  network: bool = False # per-stage egress (command + agent steps; action steps reject)
118
+ secrets: list[str] = [] # declared ref names; action steps reject
89
119
  on_pass: RouteTarget | None = None
90
120
  on_fail: RouteTarget | None = None
91
121
 
@@ -148,6 +178,11 @@ class StepConfig(BaseModel):
148
178
  f"step {self.name!r}: 'network' is only allowed on 'agent' or "
149
179
  "'command' steps"
150
180
  )
181
+ if self.secrets and self.action is not None:
182
+ raise ValueError(
183
+ f"step {self.name!r}: 'secrets' is only allowed on 'agent' or "
184
+ "'command' steps"
185
+ )
151
186
  return self
152
187
 
153
188
 
@@ -170,10 +205,28 @@ class PipelineConfig(BaseModel):
170
205
  llm: LlmConfig
171
206
  isolation: IsolationConfig | None = None # None = always-on defaults
172
207
  state_dir: str = ".pr" # default relocated to ~/.agentic-dev-loop/runs/<hash>/ at load
208
+ secrets: dict[str, SecretSpec] = {} # ref name -> locator; values never appear here
173
209
  max_attempts: int = 3
174
210
  agents_dir: str
175
211
  steps: list[StepConfig] = Field(min_length=1)
176
212
 
213
+ @field_validator("secrets", mode="before")
214
+ @classmethod
215
+ def _reject_value_shaped_refs(cls, v: Any) -> Any:
216
+ if v is None:
217
+ return {}
218
+ if not isinstance(v, dict):
219
+ raise ValueError(
220
+ "'secrets' must be a mapping of ref name to "
221
+ "{from: ..., key: ...}")
222
+ for name, spec in v.items():
223
+ if not isinstance(spec, dict):
224
+ raise ValueError(
225
+ f"secret ref {name!r} must be a mapping "
226
+ "{from: ..., key: ...}, not a literal value — secret "
227
+ "values never belong in the pipeline yaml")
228
+ return v
229
+
177
230
  @model_validator(mode="after")
178
231
  def _check_graph(self) -> "PipelineConfig":
179
232
  names = [s.name for s in self.steps]
@@ -191,6 +244,12 @@ class PipelineConfig(BaseModel):
191
244
  raise ValueError(
192
245
  f"step {step.name!r}: unknown route target {target!r}"
193
246
  )
247
+ for step in self.steps:
248
+ for ref in step.secrets:
249
+ if ref not in self.secrets:
250
+ raise ValueError(
251
+ f"step {step.name!r}: secret ref {ref!r} is not "
252
+ "declared in the top-level 'secrets:' section")
194
253
  return self
195
254
 
196
255
 
@@ -110,13 +110,17 @@ class SandboxTerminalExecutor(ToolExecutor):
110
110
  def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None] | None = None,
111
111
  workspace: str = "", pipeline: str | None = None,
112
112
  step: str | None = None,
113
- network: bool = False) -> None:
113
+ network: bool = False,
114
+ secrets: dict[str, str] | None = None) -> None:
114
115
  """Per-run re-registration of the `terminal` name (KD-4): a callable
115
116
  factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
116
- Pipelines are sequential, so re-registering per agent step is safe."""
117
+ Pipelines are sequential, so re-registering per agent step is safe.
118
+
119
+ ``secrets`` (019) is the per-step resolved secret env, injected into the
120
+ terminal's exec env only for steps that declared the refs."""
117
121
  from openhands.sdk.tool import register_tool
118
122
 
119
- resolved_env = resolve_sandbox_env(env)
123
+ resolved_env = resolve_sandbox_env(env, secrets=secrets)
120
124
 
121
125
  def factory(conv_state, **params):
122
126
  return TerminalTool.create(
@@ -17,6 +17,7 @@ from .loaders import resolve_docs
17
17
  from .isolation import (isolation_state_dir, resolve_effective_profile,
18
18
  resolve_image_ref)
19
19
  from .registry import Registry, default_registry, load_extensions
20
+ from .secrets import scan_env_files
20
21
 
21
22
 
22
23
  def _validate_agents(cfg: PipelineConfig, agents_dir: Path) -> None:
@@ -113,6 +114,7 @@ def dry_run(cfg_path, args, registry: Registry | None = None) -> int:
113
114
  cfg_path = Path(cfg_path)
114
115
  cfg = load_config(cfg_path)
115
116
  base = cfg_path.resolve().parent
117
+ scan_env_files(Path.cwd())
116
118
 
117
119
  if registry is None:
118
120
  registry = default_registry()
@@ -22,6 +22,7 @@ from agentic_dev_loop.core.config import load_config
22
22
  from .isolation import startup_sandbox
23
23
  from .llm import build_llm
24
24
  from .loaders import resolve_docs
25
+ from .secrets import resolve_secrets, scan_env_files
25
26
  from .registry import Registry, default_registry, load_extensions
26
27
  from .stages import (Runtime, build_context, clear_attempt_artifacts,
27
28
  make_action_node, make_agent_node, make_bump_node,
@@ -107,6 +108,11 @@ def run_pipeline(cfg_path, args, *, viz_bus=None,
107
108
  base = cfg_path.resolve().parent
108
109
  max_attempts = getattr(args, "max_attempts", None) or cfg.max_attempts
109
110
 
111
+ # 019: refuse secret-bearing workspace files and resolve declared secret
112
+ # refs host-side BEFORE any sandbox/LLM work — fail fast, loudly.
113
+ scan_env_files(Path.cwd())
114
+ resolved_secrets = resolve_secrets(cfg)
115
+
110
116
  if registry is None:
111
117
  registry = default_registry()
112
118
  load_extensions(cfg.extensions, registry)
@@ -122,16 +128,17 @@ def run_pipeline(cfg_path, args, *, viz_bus=None,
122
128
  refresh=getattr(args, "refresh", False))
123
129
  try:
124
130
  return _run_pipeline(cfg, base, registry, state_dir, max_attempts,
125
- args, viz_bus, sandbox)
131
+ args, viz_bus, sandbox, resolved_secrets)
126
132
  finally:
127
133
  sandbox.close()
128
134
 
129
135
 
130
136
  def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
131
- sandbox) -> tuple[bool, int]:
137
+ sandbox, resolved_secrets) -> tuple[bool, int]:
132
138
  runtime = Runtime(cfg=cfg, registry=registry, llm=build_llm(cfg.llm),
133
139
  agents_dir=base / cfg.agents_dir, state_dir=state_dir,
134
- workspace=Path.cwd(), sandbox=sandbox)
140
+ workspace=Path.cwd(), sandbox=sandbox,
141
+ secrets=resolved_secrets)
135
142
  state_dir.mkdir(parents=True, exist_ok=True)
136
143
 
137
144
  resume = getattr(args, "resume", None)
@@ -183,11 +183,17 @@ def resolve_effective_profile(isolation: IsolationConfig | None) -> EffectivePro
183
183
 
184
184
 
185
185
  def resolve_sandbox_env(step_env: dict[str, str | None] | None, *,
186
- host_env: Mapping[str, str] | None = None) -> dict[str, str]:
186
+ host_env: Mapping[str, str] | None = None,
187
+ secrets: Mapping[str, str] | None = None) -> dict[str, str]:
187
188
  """Fixed safe base (PATH/HOME/LANG/LC_ALL/TERM) + the step's declared
188
189
  ``env:`` mapping only. A declared value of ``None`` inherits the host
189
190
  value; the global ``env_allowlist`` is never consulted and the LLM key is
190
- never auto-added (NFR-3.1 / BR-9)."""
191
+ never auto-added (NFR-3.1 / BR-9).
192
+
193
+ ``secrets`` (019) carries the per-step resolved secret env — injected last,
194
+ so a declared ref is authoritative over any same-named ``env:`` entry. It
195
+ arrives only from ``step_secret_env``, which maps only the refs the step
196
+ declared, so no secret ever reaches an undeclaring step."""
191
197
  source = os.environ if host_env is None else host_env
192
198
  env = dict(SANDBOX_ENV_BASE)
193
199
  for name, value in (step_env or {}).items():
@@ -196,6 +202,8 @@ def resolve_sandbox_env(step_env: dict[str, str | None] | None, *,
196
202
  env[name] = source[name]
197
203
  else:
198
204
  env[name] = value
205
+ for name, value in (secrets or {}).items():
206
+ env[name] = value
199
207
  return env
200
208
 
201
209
 
@@ -16,6 +16,7 @@ from pathlib import Path
16
16
  from openhands.sdk.conversation.response_utils import get_agent_final_response
17
17
 
18
18
  from agentic_dev_loop.core.results import Result
19
+ from .secrets import redact_values
19
20
 
20
21
 
21
22
  class CaptureError(enum.Enum):
@@ -32,11 +33,13 @@ class CapturedOutput:
32
33
 
33
34
  @staticmethod
34
35
  def from_agent_result(
35
- conv, artifact: str, state_dir: Path
36
+ conv, artifact: str, state_dir: Path,
37
+ redactions: tuple[str, ...] = ()
36
38
  ) -> "Result[CapturedOutput, CaptureError]":
37
39
  text = _final_agent_text(conv)
38
40
  if not text.strip():
39
41
  return Result.err(CaptureError.EMPTY_RESPONSE)
42
+ text = redact_values(text, redactions)
40
43
  return Result.ok(CapturedOutput(raw_text=text, artifact_path=state_dir / artifact))
41
44
 
42
45
  def write(self) -> Result[None, CaptureError]:
@@ -0,0 +1,118 @@
1
+ """Host-side secret store (unit 019): provider registry, run-start resolution,
2
+ the ``.env*`` ban, and output scrubbing.
3
+
4
+ Secrets are declared in the pipeline yaml as refs only — never values. At run
5
+ start the engine resolves every declared ref host-side (``env`` provider
6
+ first) and injects each value into a declaring step's exec env only. The
7
+ ``.env*`` ban keeps secret-bearing files out of the workspace, and the
8
+ scrubber keeps resolved values out of every engine-written output path.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Iterable, Mapping
15
+ from pathlib import Path
16
+ from typing import Protocol
17
+
18
+ from agentic_dev_loop.core.config import PipelineConfig, StepConfig
19
+
20
+ REDACTED = "[REDACTED]"
21
+ ENV_FILE_WHITELIST = frozenset({".env.example", ".env.template", ".env.sample"})
22
+
23
+
24
+ class SecretResolutionError(RuntimeError):
25
+ """A loud fail-fast secret failure naming the ref, step(s), and provider."""
26
+
27
+
28
+ class SecretProvider(Protocol):
29
+ """A provider resolves locator keys to values, host-side, once per run."""
30
+
31
+ name: str
32
+
33
+ def resolve(self, keys: list[str]) -> dict[str, str]:
34
+ """Resolve each locator key; a missing key raises loudly."""
35
+ ...
36
+
37
+
38
+ class EnvProvider:
39
+ """Resolve secrets from host environment variables (the shipped default).
40
+ ``key`` is both the host variable to read and the step env name to inject."""
41
+
42
+ name = "env"
43
+
44
+ def __init__(self, environ: Mapping[str, str] | None = None):
45
+ self._environ = os.environ if environ is None else environ
46
+
47
+ def resolve(self, keys: list[str]) -> dict[str, str]:
48
+ missing = sorted(k for k in keys if k not in self._environ)
49
+ if missing:
50
+ raise SecretResolutionError(
51
+ "environment variable(s) not set: " + ", ".join(missing))
52
+ return {k: self._environ[k] for k in keys}
53
+
54
+
55
+ PROVIDERS: dict[str, SecretProvider] = {"env": EnvProvider()}
56
+
57
+
58
+ def resolve_secrets(cfg: PipelineConfig,
59
+ environ: Mapping[str, str] | None = None) -> dict[str, str]:
60
+ """Resolve every declared ref host-side once (run start). Returns
61
+ ``{ref_name: value}``. An unresolvable ref or unknown provider fails fast,
62
+ naming the ref, the declaring step(s), and the provider error."""
63
+ if environ is not None:
64
+ providers = {"env": EnvProvider(environ)}
65
+ else:
66
+ providers = PROVIDERS
67
+ resolved: dict[str, str] = {}
68
+ for ref, spec in cfg.secrets.items():
69
+ provider = providers.get(spec.from_)
70
+ if provider is None:
71
+ raise SecretResolutionError(
72
+ f"secret ref {ref!r}: unknown provider {spec.from_!r}")
73
+ try:
74
+ resolved[ref] = provider.resolve([spec.key])[spec.key]
75
+ except SecretResolutionError as e:
76
+ steps = [s.name for s in cfg.steps if ref in s.secrets]
77
+ raise SecretResolutionError(
78
+ f"secret ref {ref!r} (provider {spec.from_!r}, key {spec.key!r})"
79
+ f" used by step(s) {steps or ['<none>']}: {e}") from e
80
+ return resolved
81
+
82
+
83
+ def step_secret_env(cfg: PipelineConfig, step: StepConfig,
84
+ resolved: Mapping[str, str]) -> dict[str, str]:
85
+ """The ``{env_name: value}`` mapping to inject for one step: only the refs
86
+ it declares, keyed by each ref's ``key`` (the env name the step reads)."""
87
+ return {cfg.secrets[ref].key: resolved[ref] for ref in step.secrets}
88
+
89
+
90
+ def redact_values(text: str, values: Iterable[str]) -> str:
91
+ """Replace every resolved secret value in ``text`` with ``[REDACTED]``.
92
+ Longest-first so a value that is a substring of another is fully removed."""
93
+ if not text:
94
+ return text
95
+ secrets = sorted({v for v in values if v}, key=len, reverse=True)
96
+ for value in secrets:
97
+ text = text.replace(value, REDACTED)
98
+ return text
99
+
100
+
101
+ def scan_env_files(workspace: Path | str) -> None:
102
+ """The ``.env*`` ban: any ``.env*`` file at the workspace root — other than
103
+ the convention fixtures (``.env.example`` / ``.env.template`` /
104
+ ``.env.sample``) — fails fast, naming the files and the remedy. Applies
105
+ regardless of ``network:`` flags: the workspace must simply not hold
106
+ secrets."""
107
+ root = Path(workspace)
108
+ hits = sorted(
109
+ p.name for p in root.glob(".env*")
110
+ if p.is_file() and p.name not in ENV_FILE_WHITELIST
111
+ )
112
+ if hits:
113
+ raise SecretResolutionError(
114
+ "secret-bearing file(s) found in the workspace root: "
115
+ + ", ".join(hits)
116
+ + " — move these values into `secrets:` refs / host env and "
117
+ "delete the files (`.env.example`, `.env.template`, `.env.sample` "
118
+ "are allowed fixtures)")
@@ -23,7 +23,7 @@ import os
23
23
  import subprocess
24
24
  import sys
25
25
  import traceback
26
- from dataclasses import dataclass
26
+ from dataclasses import dataclass, field
27
27
  from pathlib import Path
28
28
  from typing import TYPE_CHECKING
29
29
 
@@ -34,6 +34,7 @@ from .agents import load_agent, register_sandbox_terminal
34
34
  from agentic_dev_loop.core.config import TERMINALS, PipelineConfig, Route, StepConfig, route_target
35
35
  from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
36
36
  from .outputs import CapturedOutput
37
+ from .secrets import redact_values, step_secret_env
37
38
  from agentic_dev_loop.core.verdict import Verdict
38
39
  from .proc import (child_env, host_shell_execution, kill_process_tree,
39
40
  needs_shell, split_command)
@@ -60,6 +61,7 @@ class Runtime:
60
61
  state_dir: Path
61
62
  workspace: Path
62
63
  sandbox: Sandbox | None = None
64
+ secrets: dict[str, str] = field(default_factory=dict)
63
65
 
64
66
 
65
67
  def prompt_dir(runtime: Runtime) -> str:
@@ -112,7 +114,9 @@ def run_agent(agent_name: str, message: str, runtime: Runtime,
112
114
  workspace=str(runtime.workspace),
113
115
  pipeline=runtime.cfg.name,
114
116
  step=step.name if step is not None else None,
115
- network=step.network if step is not None else False)
117
+ network=step.network if step is not None else False,
118
+ secrets=(step_secret_env(runtime.cfg, step, runtime.secrets)
119
+ if step is not None else None))
116
120
  agent = load_agent(runtime.agents_dir, agent_name, runtime.llm)
117
121
  conv = Conversation(agent=agent, workspace=str(runtime.workspace),
118
122
  max_iteration_per_run=max_iterations)
@@ -123,7 +127,9 @@ def run_agent(agent_name: str, message: str, runtime: Runtime,
123
127
 
124
128
  if step is not None and (step.artifact is not None or step.produces):
125
129
  artifact = step.artifact if step.artifact is not None else step.produces[0]
126
- captured = CapturedOutput.from_agent_result(conv, artifact, runtime.state_dir)
130
+ captured = CapturedOutput.from_agent_result(
131
+ conv, artifact, runtime.state_dir,
132
+ redactions=tuple(runtime.secrets.values()))
127
133
  if captured.is_err():
128
134
  return AgentResult(ok=True, truncated=truncated,
129
135
  capture_error=captured.error.value)
@@ -135,7 +141,7 @@ def run_agent(agent_name: str, message: str, runtime: Runtime,
135
141
  response=captured.unwrap().raw_text)
136
142
  return AgentResult(ok=True, truncated=truncated)
137
143
  except Exception:
138
- err = traceback.format_exc()
144
+ err = redact_values(traceback.format_exc(), runtime.secrets.values())
139
145
  print(err[-4000:], file=sys.stderr, flush=True)
140
146
  runtime.state_dir.mkdir(parents=True, exist_ok=True)
141
147
  error_path = runtime.state_dir / LAST_ERROR
@@ -338,7 +344,8 @@ def make_command_node(runtime: Runtime, step: StepConfig):
338
344
  timeout = step.timeout if step.timeout is not None else DEFAULT_COMMAND_TIMEOUT
339
345
  sandbox = runtime.sandbox
340
346
  if sandbox is not None:
341
- env = resolve_sandbox_env(step.env)
347
+ env = resolve_sandbox_env(
348
+ step.env, secrets=step_secret_env(runtime.cfg, step, runtime.secrets))
342
349
  else:
343
350
  env = child_env(runtime.cfg.env_allowlist, runtime.cfg.llm.api_key_env)
344
351
 
@@ -352,9 +359,12 @@ def make_command_node(runtime: Runtime, step: StepConfig):
352
359
  sandbox=sandbox, pipeline=runtime.cfg.name, step=step.name,
353
360
  network=step.network)
354
361
  except Exception:
355
- print(traceback.format_exc()[-4000:], file=sys.stderr, flush=True)
362
+ print(redact_values(traceback.format_exc(),
363
+ runtime.secrets.values())[-4000:],
364
+ file=sys.stderr, flush=True)
356
365
  return {"failed": True, "failed_step": step.name}
357
366
  if output:
367
+ output = redact_values(output, runtime.secrets.values())
358
368
  print(output, end="" if output.endswith("\n") else "\n", flush=True)
359
369
  merged = {**state["step_verdicts"],
360
370
  step.name: "pass" if exit_code == 0 else "fail"}
@@ -383,7 +393,9 @@ def make_action_node(runtime: Runtime, step: StepConfig):
383
393
  try:
384
394
  return fn(state, step.params) or {}
385
395
  except Exception:
386
- print(traceback.format_exc()[-4000:], file=sys.stderr, flush=True)
396
+ print(redact_values(traceback.format_exc(),
397
+ runtime.secrets.values())[-4000:],
398
+ file=sys.stderr, flush=True)
387
399
  return {"failed": True, "failed_step": step.name}
388
400
 
389
401
  return node
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a8
3
+ Version: 1.0.0a9
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
@@ -192,9 +192,49 @@ steps:
192
192
  | `produces:` / `artifact:` + `verdicts:` | Hygiene-tracked outputs vs verdict-parsed routing. The **last** `VERDICT:` line wins; stale artifacts are deleted at each attempt start. |
193
193
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
194
194
  | `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). |
195
+ | `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). |
195
196
  | `isolation:` (optional) | Configures the sandbox — image override, resource caps, `allow_git_mutations`. It can never disable it. |
196
197
  | `state_dir` | Defaults to `~/.agentic-dev-loop/runs/<pipeline-hash>/` — artifacts and checkpoints live outside the sandbox mount. |
197
198
 
199
+ ### Secrets (`secrets:`) — refs only, never values
200
+
201
+ Secret values never belong in the pipeline yaml, the workspace, or `state_dir`.
202
+ Declare the refs at the top level and opt each step into the refs it needs:
203
+
204
+ ```yaml
205
+ secrets: # refs only — never values
206
+ kinde-client-secret: # ref name (arbitrary)
207
+ from: env # provider name
208
+ key: KINDIE_CLIENT_SECRET # provider-specific locator
209
+ r2-keys:
210
+ from: env
211
+ key: YOSK_R2_KEYS
212
+
213
+ steps:
214
+ - name: qa
215
+ agent: qa
216
+ secrets: [kinde-client-secret, r2-keys]
217
+ ```
218
+
219
+ - The engine resolves every declared ref **host-side at run start** (the `env`
220
+ provider reads the named host environment variable). A missing variable, an
221
+ unknown provider, or a ref a step lists without declaring it fails fast —
222
+ naming the ref, the step, and the provider error. No silent fallback.
223
+ - A resolved secret is injected into a step's exec env **only when that step
224
+ lists the ref** — the same path step `env:` uses, no new channel. The secret
225
+ never reaches the provision container, `state_dir`, or any other step.
226
+ - Prefer a mechanical `command` step for secret-consuming work (a script runs
227
+ the app with creds) so the LLM and the secret never meet; an `agent` step
228
+ holds a secret only when its job genuinely requires it.
229
+ - **`.env*` ban.** At run start the workspace root is scanned for `.env*`
230
+ files. Any hit fails fast, naming the files, with the remedy text. The
231
+ convention fixtures `.env.example`, `.env.template`, and `.env.sample` are
232
+ whitelisted. This applies regardless of `network:` flags.
233
+ - **Output scrubbing.** Resolved secret values are scrubbed from every
234
+ engine-written output — captured step output, retry notes, artifacts, and
235
+ `state_dir` files. (Hygiene: the declaring step's agent has already seen
236
+ the value in its own env.)
237
+
198
238
  ### Per-stage networking
199
239
 
200
240
  The sandbox is network-off by default. A step that legitimately needs egress —
@@ -226,8 +266,7 @@ steps:
226
266
  git clone git@github.com:lukedawilson/agentic-dev-loop.git
227
267
  cd agentic-dev-loop
228
268
  ./setup-venv.sh # idempotent .venv setup (never touches bare pip)
229
- # create .env with your LLM key (loaded by trigger-agentic-dev-loop.sh, never committed):
230
- # echo 'OPENAI_API_KEY=...' > .env
269
+ export OPENAI_API_KEY=... # set your LLM key in the host env (no .env in the repo)
231
270
  python -m pytest tests/ -q # fast suite
232
271
  python -m pytest tests/ -q -m "not docker" # skip engine-dependent tests
233
272
  ./trigger-agentic-dev-loop.sh --dry-run # run the shipped pipeline against this repo