4 Commits

Author SHA1 Message Date
Patrick Buckley 2c1ec9c230 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.
2026-06-26 19:37:01 -07:00
Patrick Buckley a318265946 fix(fence): bracket trust-fence markers instead of angle-bracket XML
Swap the trust-fence marker shape from <tag_nonce>...</tag_nonce> to
[start tag_nonce]...[end tag_nonce] for both the operator fold (system-reminder)
and the output-guard judge (tool_output). Angle-bracket markup pushed some local
models out of distribution and toward emitting their own turn-structure tokens:
chat templates built around rigid <...>-style structural tokens derail once a
few folded reminders accumulate. The start/end keywords carry no slash (no </ or
[/ closing-tag shape) and read as ordinary text.

Single-source the shape in fence.py (_OPEN_KW/_CLOSE_KW + detection_pattern) so
wrap, neutralize, the forgery/leak detector, and both trust declarations track
one definition. The nonce still rides both boundaries (unforgeable close); the
leak-vs-forgery split and the forge-in / break-out defang are preserved. The
fold is wire-only, so there is no migration; the legacy persisted-envelope
readers keep the old shape.

Add regression tests pinning each trust declaration to fence.wrap's emission so
a future keyword change fails loudly instead of silently desyncing the anchors.
2026-06-26 19:37:01 -07:00
Patrick Buckley 2d64569cf8 fix(fence): close defang/detection whitespace gap; test host-escaping
Phase-1 review follow-ups:
- neutralize() now tolerates whitespace between '<' and the slash ('< /tag',
  '<  /tag'), matching output_guard's detection regex so a marker can no longer
  be detected-but-not-defanged (a leaked-nonce break-out gap).
- Add direct tests for the sec-1 forge-in defence: _neutralize_host defangs a
  forged <system-reminder_{nonce}> in both string- and list-content untrusted
  hosts before the real fence is appended, and the host is defanged exactly once
  so consecutive folds don't corrupt the first appended fence.
2026-06-04 11:03:13 -07:00
Patrick Buckley bb9e50c714 feat(fence): unify operator + judge trust fences on one primitive
Both the operator fold and the output-guard judge wrap spans in nonce-delimited
fences, but the two had drifted: the operator path minted a 32-bit nonce reused
per session with no body escaping, while the judge used a 64-bit per-call nonce
plus closing-tag escaping. Extract the shared mechanism (mint/neutralise/wrap)
into turnstone/core/fence.py and put both callers on it so they cannot diverge
again.

- Operator fold (sec-1): 64-bit nonce; fence.wrap neutralises the operator
  body's close marker, and _fold_system_turns neutralises the untrusted host
  turn's <system-reminder> markers once before the first fold, so a leaked or
  guessed per-session nonce still cannot forge a trusted block. Per-session +
  cached declaration kept (the declaration pins the exact value, so per-turn
  rotation would bust the prompt cache). Marker is now <system-reminder_{nonce}>.
- Judge: refactored onto fence (behaviour-preserving; still per-call).
- Forgery detection: output_guard scans tool output for trust-fence markers —
  an exact session-nonce match is HIGH (operator_marker_leak: the token has
  leaked and is being replayed), any other marker LOW (operator_marker_forgery).

Removes mint_envelope_nonce / wrap_system_context (folded into fence.wrap).
2026-06-04 11:03:13 -07:00