diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 911cf3da..8f5a5835 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -5637,7 +5637,7 @@ Anchoring on `$PSScriptRoot` binds the script to the checkout it lives in, which ## 1062. `check` validates the env value file under `--project-root` then reads the values from the current directory -> ๐Ÿ”ข **Filed 2026-08-06 โ€” not started.** Value **7/10** ยท Difficulty **2/10** ยท _quick win_. `messagefoundry check --project-root R` anchors `--config` under `R` and **hard-fails** if `R//.toml` is absent โ€” then drops `R`. `run_checks` takes no project root, so the build check re-derives the value anchor from `Path.cwd()`. The gate therefore **verifies the file under the root you supplied and reads the values from wherever your shell happens to be.** `serve` does not have this defect, in the same file, by one line. +> โœ… **SHIPPED 2026-08-06 โ€” the root is threaded through and applied the way `serve` applies it.** Value **7/10** ยท Difficulty **2/10** ยท _quick win_. `run_checks` gained a `project_root` parameter, threaded to the build check and set as a `[environments].base_dir` **CLI override** โ€” the same mechanism `serve` uses, so `load_settings`' CLI > env > file precedence puts it above a file-set `base_dir`. Left unset the resolution is unchanged and still falls back to the process directory, so `check --config config` is untouched. Two tests, asserted by the DIVERGENCE (the process directory holds its own value file with a different host); the pre-fix behaviour was reproduced directly rather than inferred โ€” values were read from the process directory while the root was the one validated. Original filing follows. `messagefoundry check --project-root R` anchors `--config` under `R` and **hard-fails** if `R//.toml` is absent โ€” then drops `R`. `run_checks` takes no project root, so the build check re-derives the value anchor from `Path.cwd()`. The gate therefore **verifies the file under the root you supplied and reads the values from wherever your shell happens to be.** `serve` does not have this defect, in the same file, by one line. **Cluster:** Configuration anchoring / gate integrity. **Priority:** P2. **Verdict:** build (small). **Severity:** would mis-decide a **required, blocking** check on a deploying site. Nothing is deployed (ยง0), so this is what a deploying site would hit on first use, not something happening today. It is also the only finding in this cluster on **product code** rather than developer tooling. diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index ae1b1ce0..8b26fcd7 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -4267,6 +4267,10 @@ def _check(args: argparse.Namespace) -> int: handler_security_allow=frozenset(args.handler_security_allow or ()), service_config=service_config, suppress_service_toml_search=args.project_root is not None, + # The root was already used to anchor --config/--service-config and to REQUIRE that + # //.toml exists; pass it on so the build check READS the values from there + # too, rather than from wherever the shell happens to be (BACKLOG #1062). + project_root=args.project_root, ) if args.json: _print_json(report.to_json(), compact=True) diff --git a/messagefoundry/checks.py b/messagefoundry/checks.py index d1d8c120..be404055 100644 --- a/messagefoundry/checks.py +++ b/messagefoundry/checks.py @@ -120,6 +120,7 @@ def run_checks( handler_security_allow: frozenset[str] = frozenset(), service_config: str | Path | None = None, suppress_service_toml_search: bool = False, + project_root: str | Path | None = None, ) -> CheckReport: """Run the gate against ``config_dir``; ``messages_dir`` enables the dry-run check when it has fixtures. Set ``run_lint=False`` to skip the advisory ruff/mypy pass. ``strict_handler_security`` @@ -133,6 +134,14 @@ def run_checks( given) suppresses the legacy upward-walk so ``check`` matches ``serve``'s resolution. With both defaulted (today's ``messagefoundry check --config config``), the upward-walk is preserved โ€” no regression. + + ``project_root`` (``--project-root``) is the anchor for ``environments/.toml``, and it exists + because the gate previously VALIDATED that file under the supplied root and then READ the values + from the process working directory (BACKLOG #1062). It is threaded to the build check and applied + the way ``serve`` applies it โ€” as a ``[environments].base_dir`` CLI override, which by + ``load_settings``' CLI > env > file precedence overrides a file-set ``base_dir`` exactly as + ``__main__.py``'s ``serve`` does. Left ``None`` the resolution is unchanged and still falls back to + the process directory, so the documented ``check --config config`` invocation is untouched. """ results = [ _check_validate(config_dir), @@ -151,6 +160,7 @@ def run_checks( config_dir, service_config=service_config, suppress_search=suppress_service_toml_search, + project_root=project_root, ), _check_reference_backend( config_dir, @@ -1234,6 +1244,7 @@ def _check_build( *, service_config: str | Path | None = None, suppress_search: bool = False, + project_root: str | Path | None = None, ) -> CheckResult: """Run the **posture-stamped** ``build_check_registry`` that ``serve``/``reload`` run, so a config ``serve`` would REFUSE โ€” most importantly a production-PHI cleartext / weakened-TLS transport hop @@ -1275,8 +1286,20 @@ def _check_build( return CheckResult( "build-check", ok=True, required=True, skipped=True, detail="no messagefoundry.toml" ) + # --project-root anchors the env VALUES, applied exactly as `serve` applies it (__main__.py, the + # `cli["environments"]["base_dir"] = args.project_root` line): as a [environments].base_dir CLI + # override, which load_settings' CLI > env > file precedence puts above a file-set base_dir. + # + # Without it the caller's root was used to VALIDATE that //.toml EXISTS and then + # discarded, while the values below were read from the PROCESS directory -- so the ADR 0092 + # posture-keyed insecure-hop refusal could be decided against a different environment's hosts and + # schemes than the one the operator named (BACKLOG #1062). Left None, resolution is unchanged and + # still falls back to the process directory, so `check --config config` is untouched. + cli: dict[str, dict[str, object]] | None = ( + {"environments": {"base_dir": str(project_root)}} if project_root is not None else None + ) try: - settings = load_settings(config_path=toml) + settings = load_settings(config_path=toml, cli=cli) except (FileNotFoundError, ValueError, ValidationError, OSError) as exc: return CheckResult( "build-check", diff --git a/tests/test_config_anchoring.py b/tests/test_config_anchoring.py index 50a71f7b..c8471ba2 100644 --- a/tests/test_config_anchoring.py +++ b/tests/test_config_anchoring.py @@ -489,3 +489,92 @@ def test_malformed_env_file_fails_cleanly_not_traceback( err = capsys.readouterr().err assert "could not read environment values" in err assert "dev.toml" in err + + +# --- AC-6 / BACKLOG #1062: the build check must READ values from the root it VALIDATED ------------- + + +def _root_with_values(root: Path, peer_host: str, *, env: str = "prod") -> Path: + """A project root carrying a config, a service toml, and one environment value file.""" + _config_dir(root, _ENV_GRAPH) + (root / "messagefoundry.toml").write_text(f'[ai]\nenvironment = "{env}"\n', encoding="utf-8") + envdir = root / "environments" + envdir.mkdir(exist_ok=True) + (envdir / f"{env}.toml").write_text(f'peer_host = "{peer_host}"\n', encoding="utf-8") + return root + + +def test_build_check_reads_values_from_the_root_not_the_cwd( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """BACKLOG #1062. ``check --project-root R`` hard-fails when ``R//.toml`` is absent, + then used to DROP R and read the values from the process directory -- so the ADR 0092 posture-keyed + insecure-hop refusal could be decided against a different environment's hosts and schemes than the + one the operator named. ``serve`` never had this: it writes the root into ``[environments].base_dir`` + before ``load_settings``. + + Asserted by the DIVERGENCE, which is the only shape that can fail: the process directory holds its + OWN value file with a different host. A test run from inside the root passes with the bug present, + because both anchors agree there -- the same masking that hid the rule 3d defect on Windows. + """ + root = _root_with_values(tmp_path / "R", "10.0.0.9") + elsewhere = _root_with_values(tmp_path / "W", "192.168.1.1") + monkeypatch.chdir(elsewhere) # the process directory is NOT the root + + seen: list[str] = [] + import messagefoundry.checks as checks_mod + import messagefoundry.config.environments as env_mod + + # `_check_build` imports this inside the function, so the patch must land on the SOURCE module. + real = env_mod.load_environment_values + + def _spy(*, base_dir: Path, **kw: object) -> object: + seen.append(str(base_dir)) + return real(base_dir=base_dir, **kw) # type: ignore[arg-type] + + monkeypatch.setattr(env_mod, "load_environment_values", _spy) + + checks_mod.run_checks( + root / "config", + run_lint=False, + service_config=str(root / "messagefoundry.toml"), + suppress_service_toml_search=True, + project_root=str(root), + ) + + assert seen, "the build check never resolved environment values -- the test proves nothing" + assert seen[0] == str(root), f"values were read from {seen[0]}, not the supplied root {root}" + assert str(elsewhere) not in seen + + +def test_without_a_root_the_build_check_still_falls_back_to_the_cwd( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The other half, and the one that makes the fix a correction rather than a widening: with no + ``--project-root`` the resolution is unchanged and still anchors on the process directory, so the + documented ``messagefoundry check --config config`` invocation is untouched.""" + here = _root_with_values(tmp_path / "here", "10.0.0.9") + monkeypatch.chdir(here) + + seen: list[str] = [] + import messagefoundry.checks as checks_mod + import messagefoundry.config.environments as env_mod + + # `_check_build` imports this inside the function, so the patch must land on the SOURCE module. + real = env_mod.load_environment_values + + def _spy(*, base_dir: Path, **kw: object) -> object: + seen.append(str(base_dir)) + return real(base_dir=base_dir, **kw) # type: ignore[arg-type] + + monkeypatch.setattr(env_mod, "load_environment_values", _spy) + + checks_mod.run_checks( + here / "config", + run_lint=False, + service_config=str(here / "messagefoundry.toml"), + suppress_service_toml_search=True, + ) + + assert seen, "the build check never resolved environment values -- the test proves nothing" + assert seen[0] == str(here)