fix: strip frontmatter with the same leniency it is parsed with

extract_frontmatter tolerates CRLF line endings, a leading UTF-8 BOM, and blank lines before the opening ---, but strip_frontmatter required the opener to be exactly "---\n" at byte 0. So a command or agent-type file saved with CRLF (common on Windows, or any core.autocrlf checkout) parsed its name and description fine and got discovered, yet strip_frontmatter returned the whole file. The raw --- block then leaked into the rendered slash-command prompt or the subagent system prompt as if it were content. strip_frontmatter now scans line by line and compares trimmed lines, the same way extract_frontmatter does, so whenever one finds a block the other removes exactly that block.

paydii committed Jul 11, 2026 at 15:10 UTC 07fd7e5a63dc003db17a946b3fe281d1b1896b57
1 file changed +48 -17
src/frontmatter.rs
+48 -17
@@ -94,28 +94,35 @@ pub fn unquote(value: &str) -> String {
94 /// Return the content after the frontmatter block, or the whole input if
95 /// there is no frontmatter.
96 pub fn strip_frontmatter(contents: &str) -> &str {
97 + // Mirror `extract_frontmatter`'s leniency exactly: tolerate a UTF-8 BOM,
98 + // leading blank lines before the opener, and CRLF line endings (compare
99 + // trimmed lines). If the two disagree, a file whose frontmatter parses
100 + // still leaks that raw block into the body — see the round-trip test.
101 let trimmed = contents.trim_start_matches('\u{feff}');
98 - let after_opener = match trimmed.strip_prefix("---") {
99 - Some(rest) => match rest.strip_prefix('\n') {
100 - Some(rest) => rest,
101 - None => return contents,
102 - },
103 - None => return contents,
104 - };
105 - // Find the closing `---` line.
106 - let mut search = after_opener;
107 - let mut offset = 0;
102 + let mut rest = trimmed;
103 loop {
109 - let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
110 - let (line, after) = search.split_at(end);
111 - if line.trim() == "---" {
112 - return &after_opener[offset + end..];
104 + let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
105 + let (line, after) = rest.split_at(line_end);
106 + if line.trim().is_empty() {
107 + rest = after;
108 + continue;
109 }
114 - if after.is_empty() {
110 + if line.trim() != "---" {
111 return contents;
112 }
117 - offset += end;
118 - search = after;
113 + // `after` begins just past the opening `---` line; find the closing one.
114 + let mut search = after;
115 + loop {
116 + let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
117 + let (l, a) = search.split_at(end);
118 + if l.trim() == "---" {
119 + return a;
120 + }
121 + if a.is_empty() {
122 + return contents;
123 + }
124 + search = a;
125 + }
126 }
127 }
128
@@ -166,4 +173,28 @@ mod tests {
173 fn strip_frontmatter_passes_through_without_block() {
174 assert_eq!(strip_frontmatter("just body"), "just body");
175 }
176 +
177 + #[test]
178 + fn extract_and_strip_agree_on_crlf_and_leading_blanks() {
179 + // Whenever extract_frontmatter finds a block, strip_frontmatter must
180 + // remove exactly that block and never leak it into the body. These
181 + // inputs (CRLF endings, a leading blank line, a BOM) parse fine in
182 + // extract but used to defeat strip's byte-exact opener check.
183 + for md in [
184 + "---\r\nname: foo\r\ndescription: bar\r\n---\r\n\r\nBody.\r\n",
185 + "\n---\nname: foo\ndescription: bar\n---\n\nBody.\n",
186 + "\u{feff}---\nname: foo\ndescription: bar\n---\n\nBody.\n",
187 + ] {
188 + assert!(
189 + extract_frontmatter(md).is_some(),
190 + "extract should find frontmatter in {md:?}"
191 + );
192 + let body = strip_frontmatter(md);
193 + assert!(
194 + !body.contains("name: foo"),
195 + "frontmatter leaked into body for {md:?}: {body:?}"
196 + );
197 + assert_eq!(body.trim(), "Body.");
198 + }
199 + }
200 }