From afc7479e70ccda0d4d34c279902eab2445bcff94 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 3 Jul 2026 13:39:06 -0600 Subject: [PATCH] fix(talents): pin access_tier/type against request override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepare_config merged request values over the talent definition, and while cwd was guarded, access_tier and type were not — a request body could raise a talent's access_tier (which selects tool capability) or change its type (which steers provider/model resolution), escalating privileges past the talent definition. Pin both from the definition, mirroring the cwd guard: a request value that conflicts raises. Pin on presence, not just value — access_tier is populated only for cogitate talents, so a request that introduces access_tier on a talent that declares none is itself rejected (and no None key is injected). Latent today (no shipped caller sets these), but the request arrives as a JSON body and remote dispatch paths exist. Co-Authored-By: Claude Opus 4.8 --- solstone/think/talents.py | 27 ++++++++++++++++++++++++ tests/test_prepare_config_pin.py | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 tests/test_prepare_config_pin.py 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" -- 2.51.2