From ffa89373f40bbe0d34859fd8ae66802f973de257 Mon Sep 17 00:00:00 2001 From: David Hyrule Date: Mon, 15 Jun 2026 21:12:09 +0200 Subject: [PATCH] feat(daemon): per-repo allowed write paths (default stays docs-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operations-lane daemon hardcoded allowed_paths=("docs",), so the live loop could only ever modify docs/. To let the loop attempt scoped code work (e.g. the VPS launch-proof wedge) it needs a way to widen write paths for specific repos without abandoning the conservative default. - DaemonConfig.allowed_paths_by_repo: per-repo override keyed by sibling checkout name; any repo not listed falls back to allowed_paths (docs-only). - daemon --allow REPO=PATH_PREFIX (repeatable) wires it via the existing _parse_repo_paths helper. - daemon_once resolves the effective allowed paths for the picked issue's repo. Backward compatible: no --allow → docs-only everywhere, exactly as before. All safety rails unchanged (draft PR only, human merge, gates, eval corpus). Validation: ruff clean, mypy --strict clean, 175 passed, evals 15/15. Co-Authored-By: Claude Opus 4.8 --- src/hyrule_engineering_loop/cli.py | 10 ++++++++ src/hyrule_engineering_loop/daemon.py | 10 ++++++-- tests/test_phase24_daemon.py | 37 +++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/hyrule_engineering_loop/cli.py b/src/hyrule_engineering_loop/cli.py index d4228b1..30aec4e 100644 --- a/src/hyrule_engineering_loop/cli.py +++ b/src/hyrule_engineering_loop/cli.py @@ -475,6 +475,10 @@ def daemon_command(args: argparse.Namespace) -> int: memory_dir=args.memory_dir, max_runs_per_day=args.max_runs_per_day, max_cost_usd_per_day=args.max_cost_usd_per_day, + allowed_paths_by_repo={ + repo: tuple(prefixes) + for repo, prefixes in _parse_repo_paths(args.allow, option="--allow").items() + }, ) report = daemon_once(config, client=GhCli()) print(json.dumps(report.as_dict(), indent=2, sort_keys=True)) @@ -828,6 +832,12 @@ def build_parser() -> argparse.ArgumentParser: daemon_parser.add_argument( "--max-cost-usd-per-day", type=float, default=DaemonConfig.max_cost_usd_per_day ) + daemon_parser.add_argument( + "--allow", + action="append", + metavar="REPO=PATH_PREFIX", + help="widen allowed write paths for a repo (default: docs only). Repeatable.", + ) daemon_parser.set_defaults(func=daemon_command) intake_parser = subparsers.add_parser("intake", help="signal mining and triage inbox") diff --git a/src/hyrule_engineering_loop/daemon.py b/src/hyrule_engineering_loop/daemon.py index ea829ce..cc3482e 100644 --- a/src/hyrule_engineering_loop/daemon.py +++ b/src/hyrule_engineering_loop/daemon.py @@ -90,6 +90,10 @@ class DaemonConfig: state_dir: Path = Path(".engineering-loop-state/daemon") memory_dir: str | None = None allowed_paths: tuple[str, ...] = ("docs",) + # Per-repo override of allowed_paths, keyed by sibling checkout name + # (see repo_name_for_issue). Falls back to allowed_paths (docs-only) for any + # repo not listed, so the daemon stays docs-only unless explicitly widened. + allowed_paths_by_repo: dict[str, tuple[str, ...]] = field(default_factory=dict) remote: str = "origin" max_runs_per_day: int = 2 max_cost_usd_per_day: float = 10.0 @@ -399,14 +403,16 @@ def daemon_once( ) runner = feature_runner or run_feature_intake + repo_name = repo_name_for_issue(item) + effective_allowed_paths = list(config.allowed_paths_by_repo.get(repo_name, config.allowed_paths)) result = runner( change_id=change_id, change_class=change_class, workspace_root=config.workspace_root, output_root=output_root, - repo_name=repo_name_for_issue(item), + repo_name=repo_name, request_path=request_path, - allowed_paths=list(config.allowed_paths), + allowed_paths=effective_allowed_paths, source_files=["README.md"], memory_dir=config.memory_dir, backend_budget={ diff --git a/tests/test_phase24_daemon.py b/tests/test_phase24_daemon.py index bca355a..8ad4e4f 100644 --- a/tests/test_phase24_daemon.py +++ b/tests/test_phase24_daemon.py @@ -201,6 +201,43 @@ def test_daemon_defaults_to_core_repos_and_low_and_slow_budget() -> None: assert config.max_runs_per_day == 2 assert config.max_cost_usd_per_day == 10.0 assert config.allowed_paths == ("docs",) + assert config.allowed_paths_by_repo == {} + + +def _capture_allowed_paths(tmp_path: Path, config_kwargs: dict[str, Any], repo: str = "AS215932/hyrule-cloud") -> dict[str, Any]: + captured: dict[str, Any] = {} + + def runner(**kwargs: Any) -> dict[str, Any]: + captured.update(kwargs) + return {"final_state": {}, "state_path": str(tmp_path / "state.json")} + + config = DaemonConfig(repos=(repo,), state_dir=tmp_path / "state", output_root=tmp_path / "runs", **config_kwargs) + gh = FakeGh({"issue list": _approved_issue_json(1, repo=repo, labels=["loop:approved"]), "issue view": json.dumps({"body": "x"})}) + daemon_once(config, client=gh, feature_runner=runner) + return captured + + +def test_daemon_allowed_paths_default_is_docs_only(tmp_path: Path) -> None: + captured = _capture_allowed_paths(tmp_path, {}) + assert captured["repo_name"] == "hyrule-cloud" + assert captured["allowed_paths"] == ["docs"] + + +def test_daemon_allowed_paths_per_repo_override(tmp_path: Path) -> None: + captured = _capture_allowed_paths( + tmp_path, {"allowed_paths_by_repo": {"hyrule-cloud": ("hyrule_cloud", "tests", "docs")}} + ) + assert captured["allowed_paths"] == ["hyrule_cloud", "tests", "docs"] + + +def test_daemon_allowed_paths_unlisted_repo_falls_back_to_docs(tmp_path: Path) -> None: + # An override for one repo must not widen a different repo. + captured = _capture_allowed_paths( + tmp_path, + {"allowed_paths_by_repo": {"hyrule-web": ("hyrule_web",)}}, + repo="AS215932/hyrule-cloud", + ) + assert captured["allowed_paths"] == ["docs"] def test_repo_name_for_issue_maps_core_repo_checkout_names() -> None: