1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
//! Shared YAML scalar / inline-comment helpers for every `tirith` subcommand
//! that writes YAML scaffolds (`mcp policy init`, `agent policy init`, …).
//! Centralized so the YAML safety rules (incl. the DEL-escape fix) live in one
//! place rather than being copied in `cli/mcp.rs` and `cli/agent.rs`.
//!
//! Safety contract:
//! * [`safe_scalar`] returns YAML that round-trips byte-for-byte through
//! `serde_yaml` (every reserved indicator, C0 control byte, DEL, empty string,
//! and multi-byte UTF-8 quoted/escaped). The full per-character contract is
//! pinned by `cli/mcp.rs::yaml_safe_scalar_round_trips_through_yaml_parser`; a
//! smoke set runs here too.
//! * [`safe_inline_comment`] is for `#`-comment suffixes: any control byte
//! (line-breakers / ANSI escapes) renders the whole string in `Debug` form.
//!
//! Both are `pub(crate)`, not part of the public library surface.
/// Bytes that force a YAML scalar to be quoted: YAML's reserved indicator set
/// (`:#-?,[]{}&*!|>'"%@` plus backtick) and whitespace (space, tab). Control
/// bytes (`< 0x20`, `0x7f` DEL) are checked separately in [`safe_scalar`].
pub(crate) const YAML_NEEDS_QUOTING_BYTES: &[u8] = b":#-?,[]{}&*!|>'\"%@` \t";
/// repo-0442: Unicode code points that must never appear verbatim in a YAML
/// scalar or comment: C1 controls (U+0085 NEL terminates a YAML line, U+009B
/// CSI reaches the terminal), line/paragraph separators, bidi controls,
/// zero-width and BOM characters. Byte-level checks (`< 0x20`, DEL) miss all
/// of these because they are multi-byte UTF-8.
fn is_risky_unicode(ch: char) -> bool {
ch.is_control()
|| matches!(
ch,
'\u{2028}' | '\u{2029}'
| '\u{202A}'..='\u{202E}'
| '\u{2066}'..='\u{2069}'
| '\u{200B}'..='\u{200D}'
| '\u{FEFF}'
)
}
/// Escape every [`is_risky_unicode`] character left literal in an already
/// quoted/escaped string as `\u{XXXX}` (YAML 1.2 accepts the escape in
/// double-quoted scalars; in a Debug-rendered comment it stays inert ASCII).
fn escape_risky_unicode(s: &str) -> String {
if !s.chars().any(is_risky_unicode) {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if is_risky_unicode(ch) {
out.push_str(&format!("\\u{{{:04X}}}", ch as u32));
} else {
out.push(ch);
}
}
out
}
/// Render a scalar (server / tool / matcher name) for a YAML document. Returns
/// the input unmodified when safe as a bare scalar; otherwise quotes and
/// JSON-escapes it.
///
/// LOAD-BEARING for safety: scaffolds carry names from arbitrary config files,
/// and a name with `:` / `#` / a newline / an ANSI escape would otherwise split
/// the key, comment out the value, break the document, or reach the terminal on
/// `cat`. The quoted/escaped form is unambiguous.
/// repo-0234: `true` when a bare emission would be parsed by YAML as
/// null/bool/number instead of the string it is.
fn yaml_implicit_nonstring(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
if matches!(
lower.as_str(),
"null"
| "~"
| "true"
| "false"
| "yes"
| "no"
| "on"
| "off"
| ".nan"
| ".inf"
| "-.inf"
| "+.inf"
) {
return true;
}
// Integer / float spellings.
if s.parse::<i64>().is_ok() || s.parse::<f64>().is_ok() {
return true;
}
false
}
pub(crate) fn safe_scalar(s: &str) -> String {
// Empty must be quoted — bare empty is invalid YAML.
if s.is_empty() {
return "\"\"".to_string();
}
// repo-0234: a bare scalar that YAML would parse as null/bool/number does
// NOT round-trip as a string — quote those spellings.
if yaml_implicit_nonstring(s) {
return serde_json::to_string(s)
.map(|json| escape_risky_unicode(&json))
.unwrap_or_else(|_| format!("\"{}\"", s.escape_debug()));
}
// Bare-safe iff every byte is printable ASCII non-special. Control bytes are
// checked separately so a future indicator change can't drop the guards.
let needs_quoting = s
.bytes()
.any(|b| YAML_NEEDS_QUOTING_BYTES.contains(&b) || b < 0x20 || b == 0x7f)
|| s.chars().any(is_risky_unicode);
if !needs_quoting {
return s.to_string();
}
// JSON escaping (a subset of YAML's double-quoted form) handles every C0
// byte. Post-process DEL: JSON leaves it literal, but YAML 1.2 §5.7 rejects
// a literal DEL in a quoted scalar; replace with``
// (pinned by `yaml_safe_scalar_round_trips_del` in `cli/mcp.rs`).
serde_json::to_string(s)
.map(|json| escape_risky_unicode(&json.replace('\u{7f}', "\\u007F")))
.unwrap_or_else(|_| format!("\"{}\"", s.escape_debug()))
}
/// Render a string for an inline `#`-comment suffix. The risks are line-breakers
/// (`\n`, `\r`) and ANSI escapes, so any control byte triggers `Debug` rendering
/// (printable bytes only).
pub(crate) fn safe_inline_comment(s: &str) -> String {
// No control bytes → as-is; otherwise debug-escape.
if s.bytes().any(|b| b < 0x20 || b == 0x7f) || s.chars().any(is_risky_unicode) {
// Debug-escape handles C0/DEL; the Unicode pass catches what it leaves
// literal (C1, U+2028/2029, bidi, zero-width).
escape_risky_unicode(&format!("{s:?}"))
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn risky_unicode_is_escaped_in_scalars_and_comments() {
// repo-0442: C1 NEL/CSI, line separators, bidi and zero-width must
// never survive verbatim into a YAML scaffold.
for ch in [
'\u{85}', '\u{9b}', '\u{2028}', '\u{2029}', '\u{202E}', '\u{200B}', '\u{FEFF}',
] {
let raw = format!("server{ch}name");
let scalar = safe_scalar(&raw);
assert!(
scalar.starts_with('"'),
"{raw:?} must be quoted: {scalar:?}"
);
assert!(!scalar.contains(ch), "{raw:?} leaked verbatim: {scalar:?}");
let comment = safe_inline_comment(&raw);
assert!(
!comment.contains(ch),
"{raw:?} leaked into comment: {comment:?}"
);
}
// A bare safe name is unchanged (note: '-' is a YAML indicator byte
// and intentionally forces quoting, so use a bare-alphanumeric name).
assert_eq!(safe_scalar("plainserver"), "plainserver");
}
// Full round-trip behavior is pinned by the call-site modules (`cli/mcp.rs`,
// `cli/agent.rs`); these are a load-bearing smoke subset.
#[test]
fn safe_scalar_empty_becomes_quoted() {
assert_eq!(safe_scalar(""), "\"\"");
}
#[test]
fn safe_scalar_plain_identifier_is_bare() {
assert_eq!(safe_scalar("abc"), "abc");
assert_eq!(safe_scalar("v1_2_3"), "v1_2_3");
}
#[test]
fn safe_scalar_quotes_yaml_indicator_byte() {
for &b in YAML_NEEDS_QUOTING_BYTES {
let s = format!("a{}b", b as char);
let out = safe_scalar(&s);
assert!(
out.starts_with('"') && out.ends_with('"'),
"byte 0x{b:02x} ({:?}) must force quoting: got {out:?}",
b as char,
);
}
}
#[test]
fn safe_scalar_quotes_control_bytes() {
// C0 control + DEL.
for b in 0u8..0x20 {
let s = format!("a{}b", b as char);
assert!(safe_scalar(&s).starts_with('"'));
}
assert!(safe_scalar("a\x7fb").starts_with('"'));
}
#[test]
fn safe_scalar_escapes_del_for_yaml_roundtrip() {
// DEL must be escaped, not a raw byte (YAML 1.2 §5.7 disallows a raw DEL
// in a quoted scalar). Escaped to``.
let scalar = safe_scalar("\x7f");
assert!(
!scalar.contains('\u{7f}'),
"raw DEL must not appear: {scalar:?}"
);
assert!(
scalar.contains("\\u007F"),
"DEL must be escaped: {scalar:?}"
);
// And the round-trip through serde_yaml recovers the original.
let doc = format!("k: {scalar}\n");
let parsed: serde_yaml::Value = serde_yaml::from_str(&doc).expect("DEL round-trip parses");
assert_eq!(parsed.get("k").and_then(|v| v.as_str()), Some("\x7f"));
}
#[test]
fn safe_inline_comment_passes_safe_strings_unchanged() {
assert_eq!(safe_inline_comment("/etc/foo.json"), "/etc/foo.json");
assert_eq!(safe_inline_comment(".mcp.json"), ".mcp.json");
}
#[test]
fn safe_inline_comment_escapes_control_bytes() {
let out = safe_inline_comment("evil\nname");
// Debug form quotes the entire string and escapes the newline.
assert!(out.starts_with('"') && out.ends_with('"'), "got {out:?}");
assert!(out.contains("\\n"));
}
}