diff --git a/MeetingAssistant.Tests/MeetingNoteStoreTests.cs b/MeetingAssistant.Tests/MeetingNoteStoreTests.cs index ed4ae50..b25b699 100644 --- a/MeetingAssistant.Tests/MeetingNoteStoreTests.cs +++ b/MeetingAssistant.Tests/MeetingNoteStoreTests.cs @@ -135,6 +135,72 @@ public sealed class MeetingNoteStoreTests Assert.Equal("User notes.", loaded.UserNotes); } + [Fact] + public async Task StoreEscapesApostrophePrefixedAttendeesBeforeWritingFrontmatter() + { + var (store, saved) = await SaveNoteAsync( + title: "Escaped Attendees", + attendees: ["'Ada Lovelace"], + projects: [], + userNotes: "Discuss attendee import."); + var content = await File.ReadAllTextAsync(saved.Path); + var loaded = await store.ReadAsync(saved.Path, CancellationToken.None); + + Assert.Contains("- \"'Ada Lovelace\"", content); + Assert.Equal(["'Ada Lovelace"], loaded.Frontmatter.Attendees); + Assert.Equal("Discuss attendee import.", loaded.UserNotes); + } + + [Theory] + [InlineData("Ada # platform lead")] + [InlineData("- Ada Lovelace")] + [InlineData("? Ada Lovelace")] + [InlineData("{Ada: Platform}")] + [InlineData("*Ada")] + [InlineData("&Ada")] + [InlineData("!Ada")] + [InlineData("| Ada")] + [InlineData("> Ada")] + [InlineData("@Ada")] + [InlineData("`Ada")] + [InlineData("true")] + [InlineData("null")] + [InlineData("2026-07-08")] + [InlineData("Ada \"The Architect\" Lovelace")] + [InlineData("C:\\People\\Ada")] + public async Task StoreEscapesYamlSensitiveAttendeesBeforeWritingFrontmatter(string attendee) + { + var (store, saved) = await SaveNoteAsync( + title: "Escaped Attendees", + attendees: [attendee], + projects: [], + userNotes: "Discuss attendee import."); + var loaded = await store.ReadAsync(saved.Path, CancellationToken.None); + + Assert.Equal([attendee], loaded.Frontmatter.Attendees); + Assert.Equal("Discuss attendee import.", loaded.UserNotes); + } + + [Theory] + [InlineData("Planning # Q3")] + [InlineData("- Planning")] + [InlineData("{Planning: Q3}")] + [InlineData("true")] + [InlineData("2026-07-08")] + public async Task StoreEscapesYamlSensitiveScalarAndProjectFrontmatterValues(string value) + { + var (store, saved) = await SaveNoteAsync( + title: value, + attendees: [], + projects: [value], + userNotes: "Discuss YAML escaping."); + var loaded = await store.ReadAsync(saved.Path, CancellationToken.None); + + Assert.Equal(value, loaded.Frontmatter.Title); + Assert.Equal([value], loaded.Frontmatter.Projects); + Assert.Equal("Discuss YAML escaping.", loaded.UserNotes); + } + [Fact] public void ActionLinkEscapesSummaryFileName() { @@ -147,6 +213,26 @@ public sealed class MeetingNoteStoreTests link); } + private static async Task<(MarkdownMeetingNoteStore Store, MeetingNote Saved)> SaveNoteAsync( + string title, + IReadOnlyList attendees, + IReadOnlyList projects, + string userNotes) + { + var (vaultRoot, store) = CreateStore(); + var note = MeetingNoteTemplate.Create( + title: title, + attendees: attendees, + projects: projects, + transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"), + assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"), + summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"), + userNotes: userNotes); + + var saved = await store.SaveAsync(note, CancellationToken.None); + return (store, saved); + } + private static (string VaultRoot, MarkdownMeetingNoteStore Store) CreateStore() { var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); diff --git a/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs b/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs index 35c9342..a0f6ba0 100644 --- a/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs +++ b/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Options; +using System.Globalization; +using System.Text; using YamlDotNet.Core; using YamlDotNet.Serialization; @@ -113,7 +115,24 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore private static string EscapeQuoted(string value) { - return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; + var escaped = new StringBuilder(value.Length + 2); + escaped.Append('"'); + foreach (var character in value) + { + escaped.Append(character switch + { + '\\' => "\\\\", + '"' => "\\\"", + '\r' => "\\r", + '\n' => "\\n", + '\t' => "\\t", + < ' ' => "\\x" + ((int)character).ToString("X2", CultureInfo.InvariantCulture), + _ => character.ToString() + }); + } + + escaped.Append('"'); + return escaped.ToString(); } private static string EscapeNullableDateTime(DateTimeOffset? value) @@ -135,7 +154,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore return "\"\""; } - if (value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal)) + if (RequiresQuotedScalar(value)) { return EscapeQuoted(value); } @@ -143,6 +162,49 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore return value; } + private static bool RequiresQuotedScalar(string value) + { + if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) + { + return true; + } + + if (value.Contains(':', StringComparison.Ordinal) || + value.Contains('\'', StringComparison.Ordinal) || + value.Contains(" #", StringComparison.Ordinal) || + value.Any(char.IsControl)) + { + return true; + } + + if (IsYamlIndicator(value[0])) + { + return true; + } + + if (IsYamlCoreSchemaKeyword(value)) + { + return true; + } + + return DateTimeOffset.TryParse(value, out _) || + double.TryParse(value, CultureInfo.InvariantCulture, out _); + } + + private static bool IsYamlIndicator(char character) + { + return character is '-' or '?' or ':' or ',' or '[' or ']' or '{' or '}' or + '#' or '&' or '*' or '!' or '|' or '>' or '\'' or '"' or '%' or '@' or '`'; + } + + private static bool IsYamlCoreSchemaKeyword(string value) + { + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "null", StringComparison.OrdinalIgnoreCase) || + value == "~"; + } + private sealed class MeetingNoteYaml { [YamlMember(Alias = "title")] diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index a6d194f..6328cb9 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -44,6 +44,8 @@ The meeting note frontmatter SHALL link to the transcript, assistant context, an Generated artifact notes SHALL link only to the other notes from the same run and SHALL omit the frontmatter property that would reference themselves. +Meeting Assistant SHALL escape generated meeting-note frontmatter string values after metadata enrichment and workflow rules have been applied, immediately before writing the final markdown file. + #### Scenario: Meeting note links to generated artifacts - **WHEN** Meeting Assistant creates a meeting note - **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations @@ -59,6 +61,12 @@ Generated artifact notes SHALL link only to the other notes from the same run an - **THEN** the artifact frontmatter links to the other run notes - **AND** the artifact frontmatter omits the property for the artifact's own note type +#### Scenario: Generated frontmatter remains parseable after attendee transforms +- **GIVEN** meeting metadata or workflow rules produce an attendee name that contains a single quote +- **WHEN** Meeting Assistant writes the final meeting note +- **THEN** the attendee value is escaped in frontmatter +- **AND** the meeting note frontmatter remains parseable when read back + ### Requirement: Meeting notes preserve user-authored content Meeting Assistant SHALL preserve user-authored meeting notes and include them as input when generating summaries, decisions, and next steps. @@ -354,4 +362,3 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru - **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom - **WHEN** a new user or assistant message is appended - **THEN** the conversation scrolls to the bottom of the newly rendered message content -