diff --git a/solstone/think/talents.py b/solstone/think/talents.py index fdac75115..71bd7d341 100644 --- a/solstone/think/talents.py +++ b/solstone/think/talents.py @@ -566,6 +566,16 @@ def prepare_config(request: dict) -> dict: talent_path = Path(config["path"]) if config.get("path") else None sources = config.get("sources", {}) talent_cwd = config.get("cwd") + # Capture the security-relevant fields from the talent definition BEFORE the + # request merge. access_tier selects tool capability; type steers provider/ + # model resolution and the local-lane runtime promise. A request may not + # override either (same as cwd). Pin on PRESENCE, not just value: + # access_tier is populated only for cogitate talents (absent otherwise), so + # a request that introduces access_tier on a talent that declares none is + # itself the conflict to reject. + definition_has_access_tier = "access_tier" in config + definition_access_tier = config.get("access_tier") + definition_type = config.get("type") # Merge request values (request overrides talent defaults) config.update({k: v for k, v in request.items() if v is not None}) @@ -576,6 +586,23 @@ def prepare_config(request: dict) -> dict: f"({talent_cwd!r} != {request_cwd!r})" ) + request_access_tier = request.get("access_tier") + if request_access_tier is not None and ( + not definition_has_access_tier + or request_access_tier != definition_access_tier + ): + raise ValueError( + f"Request overrides 'access_tier' for talent '{name}' are not allowed " + f"({definition_access_tier!r} != {request_access_tier!r})" + ) + + request_type = request.get("type") + if request_type is not None and request_type != definition_type: + raise ValueError( + f"Request overrides 'type' for talent '{name}' are not allowed " + f"({definition_type!r} != {request_type!r})" + ) + cwd_value = config.get("cwd") if cwd_value == "journal": try: diff --git a/tests/test_prepare_config_pin.py b/tests/test_prepare_config_pin.py new file mode 100644 index 000000000..caeca6931 --- /dev/null +++ b/tests/test_prepare_config_pin.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""prepare_config pins security-relevant fields against request override. + +access_tier selects tool capability and type steers provider/model +resolution; neither may be overridden by a request body (the same rule the +cwd guard already enforces). The `partner` talent is type=cogitate with +access_tier=synthesis, so a request tier of "normal" is a privilege +escalation that must be refused. +""" + +import pytest + +from solstone.think.talents import prepare_config + + +def test_request_cannot_override_access_tier(): + with pytest.raises(ValueError, match="access_tier"): + prepare_config({"name": "partner", "access_tier": "normal"}) + + +def test_request_cannot_override_type(): + with pytest.raises(ValueError, match="type"): + prepare_config({"name": "partner", "type": "generate"}) + + +def test_matching_access_tier_is_a_noop(): + config = prepare_config({"name": "partner", "access_tier": "synthesis"}) + assert config["access_tier"] == "synthesis" + + +def test_no_override_leaves_definition_values(): + config = prepare_config({"name": "partner"}) + assert config["access_tier"] == "synthesis" + assert config["type"] == "cogitate"