From b306df961848c1c45ee9657f50c3f15f26c62f48 Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:05:01 -0400 Subject: [PATCH] fix(security): keep UUIDs intact through Sanitizer phone redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskFailure#initialize runs message and context through Security::Sanitizer. The loose phone pattern matched any UUID whose 2nd and 3rd groups are all digits (~4% of SecureRandom.uuid values), turning e.g. 73fb9a6e-9973-4748-87aa-… into 73fb9a6e-[REDACTED_PHONE]-87aa-… and corrupting dependency_id / task ids. This is the random red on the Ruby 3.2.4 CI job for #40, #50, #51 and #53 (plan_orchestrator_spec 'skips transitively through the graph'). Mask UUID spans with placeholders before applying PII patterns and restore them after; add a regression spec with a digit-heavy UUID. Co-Authored-By: Claude Fable 5.1 --- lib/agentic/security/sanitizer.rb | 35 ++++++++++++++++++++++++- spec/agentic/security/sanitizer_spec.rb | 12 +++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/agentic/security/sanitizer.rb b/lib/agentic/security/sanitizer.rb index 9b040cd..b8971ff 100644 --- a/lib/agentic/security/sanitizer.rb +++ b/lib/agentic/security/sanitizer.rb @@ -104,6 +104,15 @@ class Sanitizer :connection_string, :email, :file_path, :phone ].freeze + # Spans that must survive sanitization untouched. A UUID whose middle + # groups happen to be all digits (e.g. 73fb9a6e-9973-4748-87aa-…) matches + # the loose phone pattern and would otherwise come out as + # 73fb9a6e-[REDACTED_PHONE]-87aa-…, corrupting task/dependency ids that + # flow through TaskFailure#context and error messages. + PROTECTED_SPANS = [ + /\b\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\b/ # UUID + ].freeze + attr_reader :security_level, :custom_patterns, :replacements def initialize(security_level: SECURITY_LEVEL_STANDARD, custom_patterns: {}, replacements: {}) @@ -397,7 +406,7 @@ def sanitize_string(content, context) cache_key = "#{content.hash}_#{context}" return @performance_cache[cache_key] if @performance_cache[cache_key] - sanitized = content.dup + sanitized, protected_spans = mask_protected_spans(content) patterns_for(context).each do |pattern_type, patterns| replacement = @replacements[pattern_type] || "[REDACTED]" @@ -407,6 +416,8 @@ def sanitize_string(content, context) end end + sanitized = restore_protected_spans(sanitized, protected_spans) + # Cache result for performance (limit cache size) if @performance_cache.size < 1000 @performance_cache[cache_key] = sanitized @@ -415,6 +426,28 @@ def sanitize_string(content, context) sanitized end + # Swap protected spans (UUIDs) for placeholders that no PII pattern can + # match, so a later restore puts the originals back verbatim. + def mask_protected_spans(content) + spans = [] + masked = content.dup + + PROTECTED_SPANS.each do |pattern| + masked = masked.gsub(pattern) do |span| + spans << span + "\u{E000}uuid#{spans.size - 1}\u{E001}" + end + end + + [masked, spans] + end + + def restore_protected_spans(content, spans) + return content if spans.empty? + + content.gsub(/\u{E000}uuid(\d+)\u{E001}/) { spans[Regexp.last_match(1).to_i] } + end + # Sanitize hash content recursively def sanitize_hash(hash, context) return {} if hash.nil? diff --git a/spec/agentic/security/sanitizer_spec.rb b/spec/agentic/security/sanitizer_spec.rb index 829029d..c5fa1e8 100644 --- a/spec/agentic/security/sanitizer_spec.rb +++ b/spec/agentic/security/sanitizer_spec.rb @@ -78,6 +78,18 @@ end context "phone numbers" do + it "leaves UUIDs intact even when their digit groups look like a phone number" do + # 2nd + 3rd groups all-digit: matched the loose phone pattern before the guard + uuid = "73fb9a6e-9973-4748-87aa-3aeaa0c29af2" + text = "Dependency #{uuid} failed; call 555-123-4567" + sanitized = sanitizer.sanitize(text) + + expect(sanitized).to include(uuid) + expect(sanitized).to include("[REDACTED_PHONE]") + expect(sanitized).not_to include("555-123-4567") + expect(sanitizer.sanitize({dependency_id: uuid})[:dependency_id]).to eq(uuid) + end + it "sanitizes phone numbers" do text_with_phone = "Call me at 555-123-4567 or (555) 987-6543" sanitized = sanitizer.sanitize(text_with_phone)