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.
This commit is contained in:
Patrick Buckley
2026-06-26 19:33:03 -07:00
parent a318265946
commit 2c1ec9c230
3 changed files with 22 additions and 4 deletions
+11
View File
@@ -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(("",))
+7 -1
View File
@@ -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,
+4 -3
View File
@@ -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.