Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion lib/agentic/security/sanitizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {})
Expand Down Expand Up @@ -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]"
Expand All @@ -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
Expand All @@ -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?
Expand Down
12 changes: 12 additions & 0 deletions spec/agentic/security/sanitizer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading