diff --git a/README.md b/README.md index 16aa4e8..5e680bc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ assignment / channel / activity ## Profiles +Catalog YAML rejects duplicate mapping keys, including collisions introduced by +merge keys. Use one explicit value per key; later entries cannot override earlier +credential routes, limits, or rein capabilities. Errors identify the file and +line without printing the duplicate key or value. + List and validate every committed catalog profile, including its independently declared runtime readiness: diff --git a/src/glas_harness/profiles.py b/src/glas_harness/profiles.py index d8c0ba1..daa2b1c 100644 --- a/src/glas_harness/profiles.py +++ b/src/glas_harness/profiles.py @@ -62,9 +62,28 @@ def _default_data_dir(kind: str) -> Path: return _source_root() / ("profiles" if kind == "profiles" else "registry/reins") +class _UniqueKeyLoader(yaml.SafeLoader): + """Reject overwrites, including collisions introduced by YAML merges.""" + + def construct_mapping(self, node, deep=False): + mapping = super().construct_mapping(node, deep=deep) + seen = set() + for key_node, _ in node.value: + key = self.construct_object(key_node, deep=deep) + if key in seen: + mark = key_node.start_mark + # Key names and source snippets can themselves contain secrets. + raise yaml.YAMLError( + f"duplicate YAML key at line {mark.line + 1}, " + f"column {mark.column + 1}" + ) + seen.add(key) + return mapping + + def _read_yaml(path: Path) -> dict[str, Any]: try: - data = yaml.safe_load(path.read_text()) + data = yaml.load(path.read_text(), Loader=_UniqueKeyLoader) except (OSError, yaml.YAMLError) as exc: raise ProfileError(f"cannot read {path}: {exc}") from exc if not isinstance(data, dict): diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 356d845..d3c6957 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -226,3 +226,45 @@ def test_incompatible_profile_or_rein_is_rejected( with pytest.raises(IncompatibleProfileError, match=match): ProfileCatalog(profiles, reins).resolve("harness.test@1.0.0") + + +@pytest.mark.parametrize("kind", ["profile", "rein"]) +@pytest.mark.parametrize("nested", [False, True]) +def test_duplicate_catalog_keys_are_rejected(tmp_path, kind, nested): + if kind == "profile": + content = _profile() + content += ("metadata:\n route: first\n route: second\n" if nested + else "credential_route_refs: [first]\ncredential_route_refs: [second]\n") + else: + content = _rein() + content = (content.replace("session_style: unattended", + "session_style: interactive\n session_style: unattended") + if nested else content + "status: disabled\n") + _write(tmp_path, "duplicate.yaml", content) + catalog = ProfileCatalog(profile_dir=tmp_path, rein_dir=tmp_path) + with pytest.raises(ProfileError, match="duplicate YAML key"): + catalog.profiles() if kind == "profile" else catalog.reins() + + +def test_yaml_merge_cannot_override_catalog_values(tmp_path): + _write(tmp_path, "profile.yaml", _profile(extra='''metadata: + defaults: &defaults + runtime: pinned + selected: + <<: *defaults + runtime: different +''')) + with pytest.raises(ProfileError, match="duplicate YAML key"): + ProfileCatalog(profile_dir=tmp_path).profiles() + + +def test_nonoverlapping_yaml_merge_remains_supported(tmp_path): + _write(tmp_path, "profile.yaml", _profile(extra='''metadata: + defaults: &defaults + runtime: pinned + selected: + <<: *defaults + host: local +''')) + profile = ProfileCatalog(profile_dir=tmp_path).profiles()[("harness.test", "1.0.0")] + assert profile.metadata["selected"] == {"runtime": "pinned", "host": "local"} diff --git a/workplans/ADHOC-2026-09-06.md b/workplans/ADHOC-2026-09-06.md index 7e18ce5..ed26b64 100644 --- a/workplans/ADHOC-2026-09-06.md +++ b/workplans/ADHOC-2026-09-06.md @@ -52,3 +52,18 @@ the fix and passes afterwards. Validation: full suite 104 passed; profile catalog validation passed with existing readiness unchanged. Reviewed credential owner update and refreshed the proposed policy reference in docs/anthropic-workload-key.md. + +## Reject ambiguous catalog YAML + +```task +id: ADHOC-2026-09-06-T03 +status: done +priority: medium +``` + +Follow-up repository review found that YAML silently overwrote duplicate keys +before profile validation. Both profile and rein descriptor loading now reject +duplicate keys at every mapping depth, including merge collisions. Disjoint +merges remain supported. Four regression cases reproduced silent acceptance +before the change. Full suite: 110 passed; committed catalog validation passes. +No residuals from this bounded fix; GLAS-WP-0012 retains the live-proof work. diff --git a/workplans/GLAS-WP-0012-first-local-profile-production-proof.md b/workplans/GLAS-WP-0012-first-local-profile-production-proof.md index 49bec91..1b55176 100644 --- a/workplans/GLAS-WP-0012-first-local-profile-production-proof.md +++ b/workplans/GLAS-WP-0012-first-local-profile-production-proof.md @@ -234,3 +234,18 @@ by configuration, per-lane approval and positive/negative verification. State Hub decisions are not a substitute for the durable authorization object. SECRETS-WP-0009-T03 and this plan's T02 remain waiting; runtime pinning and combined real proof remain required. No readiness change or real-key read. + +## 2026-09-06 authorization contract and policy publication + +Reviewed owner commits `7b4b9e3` and `083bee7` and FLEX-WP-0021. Gate-house +GH-DEC-2026-005 resolved the contract: secrets-engine now validates the +approval-claim and decision envelope separately and removed the incorrect +State Hub authority requirement. Flex-auth published +`secrets-engine.catalog-lane.lifecycle` v1; T01/T02 are done. + +Remaining owner gates are real decision/digest verification (FLEX-WP-0021-T03), +consumer service deployment and handoff (T04/T05), approval-engine deployment, +production service identity and verified lane activation. Publication is not +deployment. T02 stays waiting, and runtime pinning and combined acceptance +remain required. No new owner inbox message or activation evidence was found +in this review.