From 2c1ec9c2307ee0853ac10fae3ff44fb924f68c62 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 26 Jun 2026 19:33:03 -0700 Subject: [PATCH] fix(fence): guard detection_pattern against an empty tag set Address PR review feedback: - detection_pattern(()) with an empty tag set compiled to an overly-broad regex (the empty alternation matches any [start ...]/[end ...] run), which would turn the forgery scanner into a false-positive generator. Reject an empty or all-empty tag set up front. Not reachable from the sole caller today, but it is a public, security-relevant helper. - Clarify build_operator_instruction_declaration's docstring: the trusted region is delimited by both the start and end markers (each carrying the nonce), not just the opening marker. --- tests/test_fence.py | 11 +++++++++++ turnstone/core/fence.py | 8 +++++++- turnstone/prompts/__init__.py | 7 ++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/test_fence.py b/tests/test_fence.py index 9fd76237..119a1b9f 100644 --- a/tests/test_fence.py +++ b/tests/test_fence.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from turnstone.core import fence @@ -154,3 +156,12 @@ class TestDetectionPattern: assert pat.search("x [ end tool_output_abcd] y") # leading whitespace assert pat.search("x [end tool_output_abcd] y") # run after keyword assert pat.search("x [start system-reminder] y") # bare, run after keyword + + def test_empty_tag_set_rejected(self) -> None: + # An empty (or all-empty) tag set would compile to an overly-broad regex + # matching any "[start …]"/"[end …]" run — reject it rather than turn the + # forgery scanner into a false-positive generator. + with pytest.raises(ValueError): + fence.detection_pattern(()) + with pytest.raises(ValueError): + fence.detection_pattern(("",)) diff --git a/turnstone/core/fence.py b/turnstone/core/fence.py index 06afd7c4..59b511d2 100644 --- a/turnstone/core/fence.py +++ b/turnstone/core/fence.py @@ -141,8 +141,14 @@ def detection_pattern(tags: Iterable[str]) -> re.Pattern[str]: can tell a leaked exact-nonce marker from a bare or wrong-nonce forgery. Only the tag prefix is anchored, so a marker is caught whether or not its hex matches a real nonce. + + Raises ``ValueError`` on an empty tag set: an empty alternation would compile + to ``(?:)`` and match *any* ``[start …]`` / ``[end …]`` run, turning the + forgery scanner into a false-positive generator. """ - alt = "|".join(re.escape(t) for t in tags) + alt = "|".join(re.escape(t) for t in tags if t) + if not alt: + raise ValueError("detection_pattern requires at least one non-empty tag") return re.compile( rf"\[\s*(?:{_OPEN_KW}|{_CLOSE_KW})\s+(?:{alt})(_[0-9a-f]+)?", re.IGNORECASE, diff --git a/turnstone/prompts/__init__.py b/turnstone/prompts/__init__.py index a3bb6064..0d2a1fc2 100644 --- a/turnstone/prompts/__init__.py +++ b/turnstone/prompts/__init__.py @@ -92,9 +92,10 @@ def build_operator_instruction_declaration(nonce: str) -> str: Declares the per-session *nonce* as the sole trusted ``system-reminder`` marker so the fold (:func:`turnstone.core.fence.wrap`) can rely on it: the - model trusts only ``[start system-reminder_{nonce}]`` blocks and treats every - other ``system-reminder``-style marker (e.g. one forged in tool output, - files, or web pages) as untrusted data. Emitted only when the model uses + model trusts only the region delimited by ``[start system-reminder_{nonce}]`` + … ``[end system-reminder_{nonce}]`` (both boundaries carry the nonce) and + treats every other ``system-reminder``-style marker (e.g. one forged in tool + output, files, or web pages) as untrusted data. Emitted only when the model uses the fold path — the native mid-conversation-system path (claude-opus-4-8, claude-fable-5) delivers operator turns as real ``{"role":"system"}`` messages with no fence, so no marker appears.