agentic-dev-loop 1.0.0a32__tar.gz → 1.0.0a35__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.0a32/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a35}/PKG-INFO +12 -1
  2. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/README.md +11 -0
  3. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/core/config.py +21 -0
  5. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/core/paths.py +22 -0
  6. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/agents.py +22 -5
  7. agentic_dev_loop-1.0.0a35/src/agentic_dev_loop/engine/allowlist.py +66 -0
  8. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/extensions/aidlc.py +6 -0
  9. agentic_dev_loop-1.0.0a35/src/agentic_dev_loop/engine/forbidden.py +91 -0
  10. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/graph.py +4 -1
  11. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/isolation.py +20 -3
  12. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/stages.py +45 -6
  13. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/sync_back.py +38 -7
  14. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35/src/agentic_dev_loop.egg-info}/PKG-INFO +12 -1
  15. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop.egg-info/SOURCES.txt +3 -0
  16. agentic_dev_loop-1.0.0a35/tests/test_036_banned_commands_and_lane_boundary.py +273 -0
  17. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_e2e_fixtures.py +4 -0
  18. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_isolation.py +32 -0
  19. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_sync_back.py +46 -0
  20. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/LICENSE +0 -0
  21. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/setup.cfg +0 -0
  22. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/core/__init__.py +0 -0
  24. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/core/results.py +0 -0
  25. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/core/verdict.py +0 -0
  26. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/__init__.py +0 -0
  27. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/actions.py +0 -0
  28. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  29. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  30. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/cli.py +0 -0
  31. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/deps.py +0 -0
  32. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  33. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  34. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  35. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  36. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  37. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/llm.py +0 -0
  38. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/loaders.py +0 -0
  39. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/mounts.py +0 -0
  40. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/outputs.py +0 -0
  41. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/presets.py +0 -0
  42. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/proc.py +0 -0
  43. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/registry.py +0 -0
  44. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  45. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  46. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/secrets.py +0 -0
  47. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  48. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/py.typed +0 -0
  49. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/viz/__init__.py +0 -0
  50. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/viz/__main__.py +0 -0
  51. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop/viz/server.py +0 -0
  52. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  53. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  54. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  55. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  56. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_028_declared_caches.py +0 -0
  57. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_029_merge_on_conflict.py +0 -0
  58. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_032_stop_when_green.py +0 -0
  59. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_033_brief_step.py +0 -0
  60. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_035_finalize_nudge.py +0 -0
  61. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_actions.py +0 -0
  62. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_agent_error_guide.py +0 -0
  63. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_agents.py +0 -0
  64. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_aidlc.py +0 -0
  65. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_cli.py +0 -0
  66. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_config.py +0 -0
  67. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_deps.py +0 -0
  68. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_dry_run.py +0 -0
  69. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_egress_proxy.py +0 -0
  70. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_graph.py +0 -0
  71. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_hostboundary.py +0 -0
  72. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_hostboundary_integration.py +0 -0
  73. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_isolation_integration.py +0 -0
  74. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_isolation_pbt.py +0 -0
  75. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_llm.py +0 -0
  76. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_loaders.py +0 -0
  77. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_module_contract.py +0 -0
  78. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_outputs.py +0 -0
  79. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_outputs_pbt.py +0 -0
  80. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_proc.py +0 -0
  81. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_publish.py +0 -0
  82. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_qa_prompt.py +0 -0
  83. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_registry.py +0 -0
  84. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_repo_ascii.py +0 -0
  85. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_results.py +0 -0
  86. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_sdk_compat.py +0 -0
  87. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_secrets.py +0 -0
  88. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_secrets_integration.py +0 -0
  89. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_secrets_pipeline.py +0 -0
  90. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_source_resolution.py +0 -0
  91. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_stages.py +0 -0
  92. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_step_workspace.py +0 -0
  93. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_trigger_script.py +0 -0
  94. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_unit5_config.py +0 -0
  95. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_unit5_pbt.py +0 -0
  96. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_unit5_pure.py +0 -0
  97. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/tests/test_verdict.py +0 -0
  98. {agentic_dev_loop-1.0.0a32 → agentic_dev_loop-1.0.0a35}/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.0a32
3
+ Version: 1.0.0a35
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,6 +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
246
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
246
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). |
247
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). |
@@ -341,6 +342,16 @@ steps:
341
342
  to exactly the declared hosts for that stage; the default stays network-off
342
343
  everywhere. Reserve it for stages that need it (QA, integration). The
343
344
  residual risks are documented in [docs/setup.md](docs/setup.md).
345
+ - **Host-service reachability (planned - planning-docs/037, 038).** A host
346
+ service reached at `host.docker.internal` through the proxy fails today:
347
+ the policy admits it, but the proxy dials from the host, where that
348
+ container-only name does not resolve (a 502 for the client). 037 rewrites
349
+ `host.docker.internal` to host loopback at the proxy dial site, so signed
350
+ host-service traffic (an S3 SDK against a local MinIO, say) reaches the
351
+ host through the enforced, logged path. 038 then replaces the
352
+ host-gateway model with a per-run internal network: sandboxes get no host
353
+ route, and a containerized proxy on a second host-access network is the
354
+ only egress path.
344
355
 
345
356
  ### Sync-back conflicts (host edits during a run)
346
357
 
@@ -200,6 +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
204
  | `on_pass:` / `on_fail:` | A step name, terminal `success`/`failure`, or `{goto: X, retry: true}`. No loops happen unless you write them. |
204
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). |
205
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). |
@@ -299,6 +300,16 @@ steps:
299
300
  to exactly the declared hosts for that stage; the default stays network-off
300
301
  everywhere. Reserve it for stages that need it (QA, integration). The
301
302
  residual risks are documented in [docs/setup.md](docs/setup.md).
303
+ - **Host-service reachability (planned - planning-docs/037, 038).** A host
304
+ service reached at `host.docker.internal` through the proxy fails today:
305
+ the policy admits it, but the proxy dials from the host, where that
306
+ container-only name does not resolve (a 502 for the client). 037 rewrites
307
+ `host.docker.internal` to host loopback at the proxy dial site, so signed
308
+ host-service traffic (an S3 SDK against a local MinIO, say) reaches the
309
+ host through the enforced, logged path. 038 then replaces the
310
+ host-gateway model with a per-run internal network: sandboxes get no host
311
+ route, and a containerized proxy on a second host-access network is the
312
+ only egress path.
302
313
 
303
314
  ### Sync-back conflicts (host edits during a run)
304
315
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a32"
7
+ version = "1.0.0a35"
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"
@@ -391,6 +391,7 @@ class StepConfig(BaseModel):
391
391
  brief_inputs: BriefInputsSpec | None = None # agent steps only (033)
392
392
  env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
393
393
  setup: str | None = None # BR-13: command steps only
394
+ forbidden_commands: list[str] = [] # banned-command regex deny-list (036 D1)
394
395
  network: bool = False # per-stage egress (command/agent + sandbox actions; host-side actions reject at registry time)
395
396
  # unit-5: per-step mount granularity + egress policy. Empty mounts =
396
397
  # strict-by-default (BR-1.1); egress None = strict empty allowlist
@@ -420,6 +421,26 @@ class StepConfig(BaseModel):
420
421
  def _coerce_produces(cls, v: Any) -> Any:
421
422
  return [v] if isinstance(v, str) else v
422
423
 
424
+ @field_validator("forbidden_commands", mode="before")
425
+ @classmethod
426
+ def _coerce_forbidden_commands(cls, v: Any) -> Any:
427
+ return [v] if isinstance(v, str) else v
428
+
429
+ @field_validator("forbidden_commands")
430
+ @classmethod
431
+ def _valid_forbidden_commands(cls, v: list[str]) -> list[str]:
432
+ for pattern in v:
433
+ if not isinstance(pattern, str) or not pattern.strip():
434
+ raise ValueError(
435
+ "forbidden_commands entries must be non-empty strings")
436
+ try:
437
+ re.compile(pattern)
438
+ except re.error as e:
439
+ raise ValueError(
440
+ f"forbidden_commands entry {pattern!r} is not a valid "
441
+ f"regex: {e}") from e
442
+ return v
443
+
423
444
  @model_validator(mode="after")
424
445
  def _check_shape(self) -> "StepConfig":
425
446
  for key in ("agent", "action", "command"):
@@ -67,3 +67,25 @@ def dir_excluded(dir_rel: str, excludes: tuple[str, ...] | list[str]) -> bool:
67
67
  if parts[:len(spec_parts)] == spec_parts:
68
68
  return True
69
69
  return False
70
+
71
+
72
+ def path_allowed(rel: str, allowlist: tuple[str, ...] | list[str]) -> bool:
73
+ """True when a repo-relative posix path matches an allowed path/glob.
74
+
75
+ Mirrors ``path_excluded`` semantics: literal entries match by component
76
+ prefix (the dir and everything under it); wildcard entries match by
77
+ ``fnmatch.fnmatchcase`` on the full posix relpath. A ``.`` (or blank)
78
+ entry allows the whole tree.
79
+ """
80
+ parts = relative_parts(rel)
81
+ for spec in allowlist:
82
+ if _has_wildcards(spec):
83
+ if fnmatch.fnmatchcase(rel, spec):
84
+ return True
85
+ else:
86
+ spec_parts = relative_parts(spec)
87
+ if not spec_parts:
88
+ return True
89
+ if parts[:len(spec_parts)] == spec_parts:
90
+ return True
91
+ return False
@@ -15,6 +15,7 @@ from openhands.tools.file_editor import FileEditorTool # noqa: F401 - registers
15
15
  from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 - registers "task_tracker"
16
16
  from openhands.tools.terminal import TerminalTool # noqa: F401 - registers "terminal"
17
17
 
18
+ from .forbidden import ForbiddenRule, match_forbidden_command
18
19
  from .isolation import (SandboxError, SandboxExecutionRequest, command_argv,
19
20
  resolve_sandbox_env)
20
21
 
@@ -68,7 +69,9 @@ class SandboxTerminalExecutor(ToolExecutor):
68
69
  pipeline: str | None = None, step: str | None = None,
69
70
  network: bool = False, mount_set=None, egress_policy=None,
70
71
  terminal_timeout: float | None = None,
71
- on_terminal_result=None):
72
+ on_terminal_result=None,
73
+ forbidden_commands: tuple[ForbiddenRule, ...] | None = None,
74
+ allowlist: tuple[str, ...] | None = None):
72
75
  self._sandbox = sandbox
73
76
  self._workspace = workspace
74
77
  self._env = env or {}
@@ -79,6 +82,8 @@ class SandboxTerminalExecutor(ToolExecutor):
79
82
  self._egress_policy = egress_policy
80
83
  self._terminal_timeout = terminal_timeout
81
84
  self._on_terminal_result = on_terminal_result
85
+ self._forbidden_commands = forbidden_commands
86
+ self._allowlist = allowlist
82
87
 
83
88
  def __call__(self, action, conversation=None):
84
89
  from openhands.tools.terminal import TerminalObservation
@@ -99,11 +104,19 @@ class SandboxTerminalExecutor(ToolExecutor):
99
104
  timeout = action.timeout
100
105
  if timeout is None and self._terminal_timeout is not None:
101
106
  timeout = self._terminal_timeout
107
+ argv = command_argv(action.command)
108
+ forbidden = match_forbidden_command(argv, self._forbidden_commands)
109
+ if forbidden is not None:
110
+ return TerminalObservation.from_text(
111
+ f"[forbidden: {forbidden}]", is_error=True,
112
+ command=action.command, exit_code=None, timeout=False,
113
+ metadata=CmdOutputMetadata(exit_code=-1))
102
114
  request = SandboxExecutionRequest(
103
- argv=command_argv(action.command), cwd=self._workspace,
115
+ argv=argv, cwd=self._workspace,
104
116
  env=self._env, timeout=timeout,
105
117
  pipeline=self._pipeline, step=self._step, network=self._network,
106
- mount_set=self._mount_set, egress_policy=self._egress_policy)
118
+ mount_set=self._mount_set, egress_policy=self._egress_policy,
119
+ allowlist=self._allowlist)
107
120
  try:
108
121
  result = self._sandbox.run(request)
109
122
  except SandboxError as exc:
@@ -142,7 +155,9 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
142
155
  mount_set=None, egress_policy=None,
143
156
  terminal_timeout: float | None = None,
144
157
  on_terminal_result=None,
145
- secrets: dict[str, str] | None = None) -> None:
158
+ secrets: dict[str, str] | None = None,
159
+ forbidden_commands: tuple[ForbiddenRule, ...] | None = None,
160
+ allowlist: tuple[str, ...] | None = None) -> None:
146
161
  """Per-run re-registration of the `terminal` name (KD-4): a callable
147
162
  factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
148
163
  Pipelines are sequential, so re-registering per agent step is safe.
@@ -161,6 +176,8 @@ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None]
161
176
  pipeline=pipeline, step=step, network=network,
162
177
  mount_set=mount_set, egress_policy=egress_policy,
163
178
  terminal_timeout=terminal_timeout,
164
- on_terminal_result=on_terminal_result))
179
+ on_terminal_result=on_terminal_result,
180
+ forbidden_commands=forbidden_commands,
181
+ allowlist=allowlist))
165
182
 
166
183
  register_tool("terminal", factory)
@@ -0,0 +1,66 @@
1
+ """Engine-lane allowlist (036 D2 v1).
2
+
3
+ The active AI-DLC unit directory may carry ``engine-allowlist.yaml`` - a YAML
4
+ list of repo-relative allowed paths/globs. Absent = unrestricted (backward
5
+ compatible). Present = sync-back asserts every merged/synced path matches an
6
+ entry and fails the step otherwise. v2 (read-only export root with rw
7
+ bind-mounts for the allowlisted paths) is deferred.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ from agentic_dev_loop.core.config import _validate_declared_path
17
+ from .loaders import select_loader
18
+
19
+ ALLOWLIST_FILENAME = "engine-allowlist.yaml"
20
+
21
+
22
+ def load_engine_allowlist(path: str | Path) -> list[str]:
23
+ """Parse and validate an ``engine-allowlist.yaml`` file.
24
+
25
+ The file is a plain YAML list of repo-relative path/glob strings (the
26
+ same declared-path vocabulary as ``sync_excludes:``). An empty file is a
27
+ valid, deliberately empty allowlist (nothing matches, so every synced
28
+ path violates the lane). Missing files are handled by the caller as the
29
+ unrestricted case, not here.
30
+ """
31
+ path = Path(path)
32
+ if not path.is_file():
33
+ raise FileNotFoundError(f"engine allowlist file not found: {path}")
34
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
35
+ if data is None:
36
+ return []
37
+ if not isinstance(data, list):
38
+ raise ValueError(
39
+ f"{ALLOWLIST_FILENAME} must be a YAML list of paths/globs, "
40
+ f"got {type(data).__name__}")
41
+ return [_validate_declared_path(entry, allow_wildcards=True)
42
+ for entry in data]
43
+
44
+
45
+ def resolve_engine_allowlist(registry, args) -> list[str] | None:
46
+ """Resolve the active unit's allowlist, or None when unrestricted.
47
+
48
+ Only AI-DLC unit loaders expose a unit directory; other loaders (plan,
49
+ none) have no lane boundary. The file lives at
50
+ ``<unit_dir>/engine-allowlist.yaml``.
51
+ """
52
+ loader = select_loader(registry, args)
53
+ if loader is None:
54
+ return None
55
+ unit_dir = getattr(loader, "unit_dir", None)
56
+ if not callable(unit_dir):
57
+ return None
58
+ try:
59
+ path = Path(unit_dir(args)) / ALLOWLIST_FILENAME
60
+ except (FileNotFoundError, RuntimeError):
61
+ # No active AI-DLC unit can be determined (e.g. a resumed run with no
62
+ # aidlc-state file) -> no lane boundary, exactly like an absent file.
63
+ return None
64
+ if not path.is_file():
65
+ return None
66
+ return load_engine_allowlist(path)
@@ -138,6 +138,12 @@ class AidlcDocumentLoader:
138
138
  _, sources = resolve_unit_docs_with_sources(self.docs_dir, unit)
139
139
  return sources
140
140
 
141
+ def unit_dir(self, args: argparse.Namespace) -> Path:
142
+ """The active unit directory (036): where the engine lane boundary
143
+ looks for ``engine-allowlist.yaml``."""
144
+ unit = getattr(args, "aidlc_unit", None) or get_current_unit(self.docs_dir)
145
+ return Path(self.docs_dir) / "construction" / unit
146
+
141
147
 
142
148
  class AidlcExtension:
143
149
  name = "ai-dlc"
@@ -0,0 +1,91 @@
1
+ """Banned-command deny-list (036 D1).
2
+
3
+ The sandbox terminal enforces a deny-list before any shell spawn: a command
4
+ whose argv matches a step's ``forbidden_commands:`` regex (merged with the
5
+ engine defaults) returns a structured observation instead of reaching the
6
+ sandbox. Command steps are unaffected - they are declared infrastructure and
7
+ already fail fast on their exit code.
8
+
9
+ The default list carries human-readable labels; custom entries use their
10
+ regex text as the label. ``dotnet restore`` and ``dotnet add`` are network
11
+ gated: denied only while the step's ``network:`` flag is off.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ForbiddenRule:
22
+ label: str
23
+ regex: re.Pattern[str]
24
+
25
+
26
+ _DEFAULT_PATTERNS: tuple[tuple[str, str], ...] = (
27
+ ("git commit", r"\bgit\s+commit\b"),
28
+ ("git push", r"\bgit\s+push\b"),
29
+ ("git rebase", r"\bgit\s+rebase\b"),
30
+ ("git checkout", r"\bgit\s+checkout\b"),
31
+ ("git reset", r"\bgit\s+reset\b"),
32
+ ("git tag", r"\bgit\s+tag\b"),
33
+ ("npm install", r"\bnpm\s+install\b"),
34
+ ("npm ci", r"\bnpm\s+ci\b"),
35
+ ("pip install", r"\bpip\s+install\b"),
36
+ ("uv add", r"\buv\s+add\b"),
37
+ ("pip-compile", r"\bpip-compile\b"),
38
+ ("dotnet add package", r"\bdotnet\s+add\s+package\b"),
39
+ ("npx playwright", r"\bnpx\s+playwright\b"),
40
+ ("playwright", r"\bplaywright\b"),
41
+ ("docker", r"\bdocker\b"),
42
+ )
43
+
44
+ _NETWORK_GATED_PATTERNS: tuple[tuple[str, str], ...] = (
45
+ ("dotnet restore", r"\bdotnet\s+restore\b"),
46
+ ("dotnet add", r"\bdotnet\s+add\b"),
47
+ )
48
+
49
+
50
+ def _compile(label: str, pattern: str) -> ForbiddenRule:
51
+ return ForbiddenRule(label=label, regex=re.compile(pattern, re.IGNORECASE))
52
+
53
+
54
+ DEFAULT_FORBIDDEN_RULES: tuple[ForbiddenRule, ...] = tuple(
55
+ _compile(label, pattern) for label, pattern in _DEFAULT_PATTERNS)
56
+
57
+ NETWORK_GATED_FORBIDDEN_RULES: tuple[ForbiddenRule, ...] = tuple(
58
+ _compile(label, pattern) for label, pattern in _NETWORK_GATED_PATTERNS)
59
+
60
+
61
+ def resolve_forbidden_rules(custom_patterns: list[str] | tuple[str, ...] | None,
62
+ network: bool) -> tuple[ForbiddenRule, ...]:
63
+ """Merge the engine defaults with a step's custom regex list.
64
+
65
+ Custom entries are appended after the defaults, so the first match wins
66
+ on overlap. Network-gated defaults are included only while ``network`` is
67
+ off; there is no disable escape hatch for the engine defaults.
68
+ """
69
+ rules = list(DEFAULT_FORBIDDEN_RULES)
70
+ if not network:
71
+ rules.extend(NETWORK_GATED_FORBIDDEN_RULES)
72
+ for pattern in custom_patterns or ():
73
+ rules.append(_compile(pattern, pattern))
74
+ return tuple(rules)
75
+
76
+
77
+ def match_forbidden_command(argv: list[str],
78
+ rules: tuple[ForbiddenRule, ...] | None) -> str | None:
79
+ """Return the label of the first matching rule for ``argv``, else None.
80
+
81
+ Match is a regex search over the space-joined argv, so shell-wrapped
82
+ commands (``/bin/sh -c "git commit ..."``) still match. Empty argv never
83
+ matches.
84
+ """
85
+ if not argv:
86
+ return None
87
+ text = " ".join(argv)
88
+ for rule in rules or ():
89
+ if rule.regex.search(text):
90
+ return rule.label
91
+ return None
@@ -18,6 +18,7 @@ from langgraph.graph import END, START, StateGraph
18
18
 
19
19
  from agentic_dev_loop import viz
20
20
  from .actions import git_worktree_clean, kill_listeners_on_port
21
+ from .allowlist import resolve_engine_allowlist
21
22
  from agentic_dev_loop.core.config import load_config
22
23
  from .hostboundary import (check_orchestrator_install,
23
24
  check_tooling_integrity, tooling_rel_paths)
@@ -170,11 +171,13 @@ def run_pipeline(cfg_path, args, *, viz_bus=None,
170
171
  def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
171
172
  sandbox, resolved_secrets, tooling_paths) -> tuple[bool, int]:
172
173
  export_root = getattr(sandbox, "export_root", None)
174
+ engine_allowlist = resolve_engine_allowlist(registry, args)
173
175
  runtime = Runtime(cfg=cfg, registry=registry, llm=build_llm(cfg.llm),
174
176
  agents_dir=base / cfg.agents_dir, state_dir=state_dir,
175
177
  workspace=Path.cwd(), sandbox=sandbox,
176
178
  sandbox_cwd=Path(export_root) if export_root else None,
177
- secrets=resolved_secrets)
179
+ secrets=resolved_secrets,
180
+ engine_allowlist=engine_allowlist)
178
181
  state_dir.mkdir(parents=True, exist_ok=True)
179
182
 
180
183
  resume = getattr(args, "resume", None)
@@ -121,6 +121,10 @@ class SandboxExecutionRequest:
121
121
  # Host-side audit metadata (BR-11) - never sent into the container.
122
122
  pipeline: str | None = None
123
123
  step: str | None = None
124
+ # 036 lane boundary (v1): the unit allowlist every synced path must match.
125
+ # None = unrestricted (no engine-allowlist.yaml); an empty tuple = present
126
+ # but empty file (deny all synced paths).
127
+ allowlist: tuple[str, ...] | None = None
124
128
 
125
129
  def __post_init__(self) -> None:
126
130
  if not self.argv:
@@ -1071,7 +1075,7 @@ class DockerBackend(Sandbox):
1071
1075
  result = self._exec(request, prof.name)
1072
1076
  report: SyncBackReport | None = None
1073
1077
  if mount_set.rw_paths() and self._export_root is not None:
1074
- report = self._sync_back(mount_set)
1078
+ report = self._sync_back(mount_set, request.allowlist)
1075
1079
  if report is not None and (report.synced_paths or report.merged_paths):
1076
1080
  result = SandboxExecutionResult(
1077
1081
  exit_code=result.exit_code, stdout=result.stdout,
@@ -1169,7 +1173,8 @@ class DockerBackend(Sandbox):
1169
1173
  export_path.parent.mkdir(parents=True, exist_ok=True)
1170
1174
  shutil.copy2(host_path, export_path, follow_symlinks=False)
1171
1175
 
1172
- def _sync_back(self, mount_set: MountSet) -> SyncBackReport | None:
1176
+ def _sync_back(self, mount_set: MountSet,
1177
+ allowlist: tuple[str, ...] | None = None) -> SyncBackReport | None:
1173
1178
  """Sync declared-rw export changes back to the real repo (BR-4/F1),
1174
1179
  git-aware, host-side, with the sandbox step already complete.
1175
1180
 
@@ -1184,7 +1189,18 @@ class DockerBackend(Sandbox):
1184
1189
  self._export_root, self._workspace, list(mount_set.rw_paths()),
1185
1190
  baseline=self._baseline, step_id=None,
1186
1191
  cache_paths=self._cache_paths, excludes=self._sync_excludes,
1187
- merge_on_conflict=self._merge_on_conflict)
1192
+ merge_on_conflict=self._merge_on_conflict, allowlist=allowlist)
1193
+
1194
+ # 036 lane boundary (v1): fail the step before any copy/delete/merge
1195
+ # so an out-of-lane write is never promoted into the real repo.
1196
+ if report.lane_violations:
1197
+ _announce(
1198
+ "lane violation: "
1199
+ + ", ".join(report.lane_violations)
1200
+ + " outside engine-allowlist.yaml")
1201
+ raise SandboxError(
1202
+ "engine lane violation: synced/merged paths outside the unit "
1203
+ "engine-allowlist.yaml: " + ", ".join(report.lane_violations))
1188
1204
 
1189
1205
  # 029 step 2: union-merge declared merge paths first, so a mixed
1190
1206
  # merge+hard conflict still converges the append-style logs before
@@ -1234,6 +1250,7 @@ class DockerBackend(Sandbox):
1234
1250
  "event": "sync_back",
1235
1251
  "synced_paths": list(report.synced_paths),
1236
1252
  "merged_paths": list(report.merged_paths),
1253
+ "host_added_paths": list(report.host_added_paths),
1237
1254
  })
1238
1255
  return report
1239
1256
 
@@ -38,7 +38,9 @@ from agentic_dev_loop.core.config import TERMINALS, PipelineConfig, Route, StepC
38
38
  from agentic_dev_loop.core.paths import _has_wildcards
39
39
  from .deps import extension_message, parse_missing_dependencies, policy_injection
40
40
  from .egress_policy import assemble_egress_policy
41
- from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
41
+ from .forbidden import resolve_forbidden_rules
42
+ from .isolation import (SandboxError, SandboxExecutionRequest, command_argv,
43
+ resolve_sandbox_env)
42
44
  from .mounts import resolve_mount_set
43
45
  from .outputs import CapturedOutput
44
46
  from .secrets import redact_values, step_secret_env
@@ -171,6 +173,9 @@ class Runtime:
171
173
  # the real workspace (no export materialised - fake/legacy paths).
172
174
  sandbox_cwd: Path | None = None
173
175
  secrets: dict[str, str] = field(default_factory=dict)
176
+ # 036 lane boundary (v1): the active unit's engine-allowlist.yaml paths;
177
+ # None = unrestricted (no allowlist file).
178
+ engine_allowlist: list[str] | None = None
174
179
 
175
180
  def run_agent(self, agent_name: str, message: str,
176
181
  max_iterations: int = DEFAULT_MAX_ITERATIONS,
@@ -200,6 +205,13 @@ class Runtime:
200
205
  cwd, mount_set, egress_policy = _step_sandbox(self, step)
201
206
  else:
202
207
  cwd, mount_set, egress_policy = (self.workspace, None, None)
208
+ forbidden_commands = (
209
+ resolve_forbidden_rules(step.forbidden_commands,
210
+ step.network)
211
+ if step is not None else None)
212
+ allowlist = (
213
+ tuple(self.engine_allowlist)
214
+ if self.engine_allowlist is not None else None)
203
215
  register_sandbox_terminal(
204
216
  self.sandbox,
205
217
  env=step.env if step is not None else None,
@@ -214,7 +226,9 @@ class Runtime:
214
226
  on_terminal_result=(controller.on_terminal_result
215
227
  if controller is not None else None),
216
228
  secrets=(step_secret_env(self.cfg, step, self.secrets)
217
- if step is not None else None))
229
+ if step is not None else None),
230
+ forbidden_commands=forbidden_commands,
231
+ allowlist=allowlist)
218
232
  agent = load_agent(self.agents_dir, agent_name, self.llm)
219
233
  callbacks = []
220
234
  if controller is not None:
@@ -364,7 +378,8 @@ def _run_command(command: str, workspace: Path, timeout: float,
364
378
  network: bool = False,
365
379
  mount_set=None,
366
380
  egress_policy=None,
367
- cwd: Path | None = None) -> tuple[int | None, str, bool]:
381
+ cwd: Path | None = None,
382
+ allowlist: tuple[str, ...] | None = None) -> tuple[int | None, str, bool]:
368
383
  """Run a command step in the workspace, capturing combined stdout/stderr.
369
384
 
370
385
  With a ``Sandbox`` the command routes through the isolation boundary: git
@@ -381,7 +396,8 @@ def _run_command(command: str, workspace: Path, timeout: float,
381
396
  request = SandboxExecutionRequest(
382
397
  argv=command_argv(command), cwd=str(cwd or workspace), env=env or {},
383
398
  timeout=timeout, pipeline=pipeline, step=step, network=network,
384
- mount_set=mount_set, egress_policy=egress_policy)
399
+ mount_set=mount_set, egress_policy=egress_policy,
400
+ allowlist=allowlist)
385
401
  result = sandbox.run(request)
386
402
  return result.exit_code, result.stdout + result.stderr, result.timed_out
387
403
  if needs_shell(command):
@@ -822,6 +838,8 @@ def make_command_node(runtime: Runtime, step: StepConfig):
822
838
  timeout = step.timeout if step.timeout is not None else DEFAULT_COMMAND_TIMEOUT
823
839
  sandbox = runtime.sandbox
824
840
  cwd, mount_set, egress_policy = _step_sandbox(runtime, step)
841
+ allowlist = (tuple(runtime.engine_allowlist)
842
+ if runtime.engine_allowlist is not None else None)
825
843
  if sandbox is not None:
826
844
  env = resolve_sandbox_env(
827
845
  step.env, secrets=step_secret_env(runtime.cfg, step, runtime.secrets))
@@ -837,7 +855,16 @@ def make_command_node(runtime: Runtime, step: StepConfig):
837
855
  step.command, runtime.workspace, timeout, env,
838
856
  sandbox=sandbox, pipeline=runtime.cfg.name, step=step.name,
839
857
  network=step.network, mount_set=mount_set,
840
- egress_policy=egress_policy, cwd=cwd)
858
+ egress_policy=egress_policy, cwd=cwd, allowlist=allowlist)
859
+ except SandboxError:
860
+ err = redact_values(traceback.format_exc(),
861
+ runtime.secrets.values())[-4000:]
862
+ print(err, file=sys.stderr, flush=True)
863
+ runtime.state_dir.mkdir(parents=True, exist_ok=True)
864
+ (runtime.state_dir / LAST_ERROR).write_text(
865
+ f"# Command step failed\n\n```\n{err}\n```\n",
866
+ encoding="utf-8")
867
+ return {"failed": True, "failed_step": step.name}
841
868
  except Exception:
842
869
  print(redact_values(traceback.format_exc(),
843
870
  runtime.secrets.values())[-4000:],
@@ -903,6 +930,8 @@ def make_action_node(runtime: Runtime, step: StepConfig):
903
930
  timeout = DEFAULT_COMMAND_TIMEOUT
904
931
  env = resolve_sandbox_env(step.env)
905
932
  cwd, mount_set, egress_policy = _step_sandbox(runtime, step)
933
+ allowlist = (tuple(runtime.engine_allowlist)
934
+ if runtime.engine_allowlist is not None else None)
906
935
 
907
936
  def node(state: dict) -> dict:
908
937
  attempt = state["attempt"]
@@ -919,7 +948,17 @@ def make_action_node(runtime: Runtime, step: StepConfig):
919
948
  exit_code, output, timed_out = _run_command(
920
949
  command, runtime.workspace, timeout, env, sandbox=runtime.sandbox,
921
950
  pipeline=runtime.cfg.name, step=step.name, network=step.network,
922
- mount_set=mount_set, egress_policy=egress_policy, cwd=cwd)
951
+ mount_set=mount_set, egress_policy=egress_policy, cwd=cwd,
952
+ allowlist=allowlist)
953
+ except SandboxError:
954
+ err = redact_values(traceback.format_exc(),
955
+ runtime.secrets.values())[-4000:]
956
+ print(err, file=sys.stderr, flush=True)
957
+ runtime.state_dir.mkdir(parents=True, exist_ok=True)
958
+ (runtime.state_dir / LAST_ERROR).write_text(
959
+ f"# Action step failed\n\n```\n{err}\n```\n",
960
+ encoding="utf-8")
961
+ return {"failed": True, "failed_step": step.name}
923
962
  except Exception:
924
963
  print(redact_values(traceback.format_exc(),
925
964
  runtime.secrets.values())[-4000:],