From 850907a987bf48595602e96c92488d4e43aab4fc Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 9 Oct 2026 19:02:56 +0200 Subject: [PATCH] NK-WP-0048: {param:env} format spec; check follow-through/rollback templates Answers ops-warden's observation on the literal KEYCAPE__CLIENT_SECRET. The type adopts the spec once ops-warden's planner renders it too. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 469893@bnt-lap001 Assistant-Session: 49296f15-2171-4137-b972-2443bab95edb --- .../tests/test_oidc_browser_relying_party.py | 25 ++++++++++++++++++ .../security_case_validator.py | 26 ++++++++++++++++++- workplans/NK-WP-0048-security-case-types.md | 11 ++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py b/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py index 1903346..a7a333c 100644 --- a/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py +++ b/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py @@ -171,3 +171,28 @@ def test_forgejo_reference_case_is_accepted_and_matches_the_example(): assert {b["role"] for b in case["spec"]["acceptance"]["by"]} == {"owner", "founder"} assert msgs(v.validate_case(case, TYPE, require_implemented=False)) == [] assert msgs(v.validate_case(case, as_implemented())) == [] + + +def test_env_format_spec_renders_the_keycape_env_name(): + # {client_id:env} gives the name keycape-client.py derives (env_name): upper + # case, '-' as '_'. The type adopts it once ops-warden's planner renders it + # too (ops-warden message 06303d1d); until then it keeps the literal form. + t = as_implemented() + for item in t["spec"]["follow_through"] + t["spec"]["rollback"]: + for key in ("entry_point", "action"): + if key in item: + item[key] = item[key].replace("KEYCAPE__CLIENT_SECRET", "KEYCAPE_{client_id:env}_CLIENT_SECRET") + assert msgs(v.validate_type(t)) == [] + issues, r = v.render_case(with_params(FORGEJO, client_id="my-forge"), t, DECLS) + assert msgs(issues) == [] + steps = {s["id"]: s for s in r["follow_through"]} + assert "env KEYCAPE_MY_FORGE_CLIENT_SECRET from sso/keycape-my-forge-client" in steps["bind-env"]["entry_point"] + assert "<" not in steps["bind-env"]["entry_point"] + undo = [s for s in r["rollback"] if s["reverses"] == "bind-env"] + assert "KEYCAPE_MY_FORGE_CLIENT_SECRET" in undo[0]["action"] + + +def test_unknown_format_spec_is_rejected(): + t = copy.deepcopy(TYPE) + t["spec"]["rollback"][0]["action"] += " {client_id:shout}" + assert any("unknown format spec" in m for m in msgs(v.validate_type(t))) diff --git a/tools/security-case-types/security_case_validator.py b/tools/security-case-types/security_case_validator.py index a461a42..bd95f3d 100644 --- a/tools/security-case-types/security_case_validator.py +++ b/tools/security-case-types/security_case_validator.py @@ -79,6 +79,15 @@ def placeholders(text: str) -> set[str]: return {f[1] for f in string.Formatter().parse(text) if f[1]} +#: Format specs a template may apply to a parameter. ``env`` renders an +#: environment-variable fragment: upper case, ``-`` as ``_`` ({client_id:env}). +FORMAT_SPECS = {"", "env"} + + +def format_specs(text: str) -> set[str]: + return {f[2] or "" for f in string.Formatter().parse(text) if f[1]} + + def _walk_strings(value: Any, path: str): if isinstance(value, str): yield path, value @@ -146,6 +155,9 @@ def validate_type(data: dict[str, Any], type_dir: Path | None = None) -> list[Is _validate_materialization(spec["materialization"], params, issues) _validate_verification(spec["verification"], params, issues) _validate_rollback(spec["rollback"], step_ids, spec["materialization"], issues) + for section in ("follow_through", "rollback"): + if isinstance(spec[section], list): + _validate_templates(spec[section], f"type.spec.{section}", params, issues) _validate_acceptance(spec["acceptance"], issues) for section in ("materialization", "follow_through", "verification", "rollback"): for i, item in enumerate(spec[section] if isinstance(spec[section], list) else []): @@ -241,6 +253,8 @@ def _validate_templates(items: Any, path: str, params: dict[str, dict], issues: for name in placeholders(text): if name not in params: issues.append(err(spath, f"template uses unknown parameter {{{name}}}")) + for spec in sorted(format_specs(text) - FORMAT_SPECS): + issues.append(err(spath, f"template uses unknown format spec {spec!r}")) def _validate_materialization(items: Any, params: dict[str, dict], issues: list[Issue]) -> None: @@ -446,6 +460,15 @@ def _applicable(items: list[dict], values: dict[str, Any]) -> list[dict]: return [{k: v for k, v in i.items() if k != "when"} for i in items if holds(i.get("when"), values)] +class _Param(str): + """A string parameter value that also renders the ``env`` format spec.""" + + def __format__(self, spec: str) -> str: + if spec == "env": + return self.upper().replace("-", "_") + return str.__format__(self, spec) + + class _Blank(dict): """Template values where an optional parameter a case leaves out renders empty.""" @@ -455,7 +478,8 @@ class _Blank(dict): def _fill(value: Any, values: dict[str, Any]) -> Any: if isinstance(value, str): - return value.format_map(_Blank({k: (",".join(map(str, v)) if isinstance(v, list) else v) + return value.format_map(_Blank({k: _Param(",".join(map(str, v)) if isinstance(v, list) else v) + if isinstance(v, (str, list)) else v for k, v in values.items()})) if isinstance(value, dict): return {k: _fill(v, values) for k, v in value.items()} diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index 5bfa810..1ec716f 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -362,6 +362,17 @@ the pod: `ensure_service_groups` and `revoke_service_groups` present, `/readyz` deploy/identity-provisioner provisioner=forgejo.coulomb.social/coulomb/identity-provisioner@sha256:ffacd5d790a5b7800e0535f3aa95b7de52c7a68e4f575b9192c3e1622fa672c2`. The pin in `sso-mfa/k8s/identity-provisioner/deployment.yaml` follows. +2026-10-09: ops-warden closed WARDEN-WP-0044-T01 and shipped `warden case plan` +(T02, message `06303d1d`); the accepted Forgejo case is refused until ops-mason +materializes it, and a materialized copy plans cleanly once +`keycape-client-secret-generate` exists. Its observation that `bind-env` carries +a literal `KEYCAPE__CLIENT_SECRET`: the validator now renders a +`{param:env}` format spec (upper case, `-` as `_`, as `keycape-client.py` +derives the name) and rejects unknown specs, and follow-through and rollback +text is now checked for unknown placeholders too (patch-level, canon section 6). +The type keeps the literal until ops-warden's planner (`text.format`) renders the +spec as well; asked of ops-warden. 53 tests pass. + ```task id: NK-WP-0048-T07 status: wait