Class: SchwabMCP::Redactor

Inherits:
Object
  • Object
show all
Defined in:
lib/schwab_mcp/redactor.rb

Constant Summary collapse

ACCOUNT_NUMBER_PATTERN =
/\b\d{8,9}\b/
HASH_VALUE_PATTERN =
/\b[A-Za-z0-9]{20,}\b/
BEARER_TOKEN_PATTERN =
/Bearer\s+[A-Za-z0-9\.\-_]+/i
ACCOUNT_FIELDS =
%w[
  accountNumber
  accountId
  account_number
  account_id
  hashValue
  hash_value
].freeze
NON_SENSITIVE_FIELDS =
%w[
  cusip
  orderId
  order_id
  legId
  leg_id
  strikePrice
  strike_price
  quantity
  daysToExpiration
  days_to_expiration
  expirationDate
  expiration_date
  price
  netChange
  net_change
  mismarkedQuantity
  mismarked_quantity
].freeze
REDACTED_PLACEHOLDER =
'[REDACTED]'
REDACTED_ACCOUNT_PLACEHOLDER =
'[REDACTED_ACCOUNT]'
REDACTED_HASH_PLACEHOLDER =
'[REDACTED_HASH]'
REDACTED_TOKEN_PLACEHOLDER =
'[REDACTED_TOKEN]'

Class Method Summary collapse

Class Method Details

.redact(data) ⇒ Object

Main entry point for redacting any data structure

Parameters:

  • data (Object)

    Data to redact (Hash, Array, String, or other)

Returns:

  • (Object)

    Redacted copy of the data



46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/schwab_mcp/redactor.rb', line 46

def redact(data)
  case data
  when Hash
    redact_hash(data)
  when Array
    redact_array(data)
  when String
    redact_string(data)
  else
    data
  end
end

.redact_api_response(response_body) ⇒ String

Redact a JSON response from Schwab API

Parameters:

  • response_body (String)

    Raw response body from API

Returns:

  • (String)

    Pretty-formatted redacted JSON



62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/schwab_mcp/redactor.rb', line 62

def redact_api_response(response_body)
  return response_body unless response_body.is_a?(String)

  begin
    parsed = JSON.parse(response_body)
    redacted = redact(parsed)
    JSON.pretty_generate(redacted)
  rescue JSON::ParserError
    # If parsing fails, redact as string
    redact_string(response_body)
  end
end

.redact_formatted_text(text) ⇒ String

Redact formatted text that might contain sensitive data

Parameters:

  • text (String)

    Formatted text to redact

Returns:

  • (String)

    Redacted text



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/schwab_mcp/redactor.rb', line 78

def redact_formatted_text(text)
  return text unless text.is_a?(String)
  redacted = text.dup
  redacted.gsub!(BEARER_TOKEN_PATTERN, "Bearer #{REDACTED_TOKEN_PLACEHOLDER}")

  # Redact account numbers in specific text patterns
  redacted.gsub!(/Account\s+ID:\s*\d{8,9}/i, "Account ID: #{REDACTED_ACCOUNT_PLACEHOLDER}")
  redacted.gsub!(/Account\s+Number:\s*\d{8,9}/i, "Account Number: #{REDACTED_ACCOUNT_PLACEHOLDER}")
  redacted.gsub!(/Account:\s*\d{8,9}/i, "Account: #{REDACTED_ACCOUNT_PLACEHOLDER}")

  # Redact account numbers in log patterns like "account_number: 123456789"
  redacted.gsub!(/account[_\s]*number[_\s]*[:\=]\s*\d{8,9}/i, "account_number: #{REDACTED_ACCOUNT_PLACEHOLDER}")
  redacted.gsub!(/account[_\s]*id[_\s]*[:\=]\s*\d{8,9}/i, "account_id: #{REDACTED_ACCOUNT_PLACEHOLDER}")

  # Redact long hashes (40+ hex chars) in URLs/logs (e.g., Schwab account hashes)
  # Example: /accounts/4996EA061B4878E8D0B9063DF74925E5688F475BE00AF6A0A41E1FC4A2510CA0/
  redacted.gsub!(/\b[0-9a-fA-F]{40,}\b/, REDACTED_HASH_PLACEHOLDER)

  redacted
end

.redact_log_message(message) ⇒ String

Redact a log message that might contain sensitive data

Parameters:

  • message (String)

    Log message to redact

Returns:

  • (String)

    Redacted log message



102
103
104
105
106
# File 'lib/schwab_mcp/redactor.rb', line 102

def redact_log_message(message)
  return message unless message.is_a?(String)

  redact_formatted_text(message)
end

.redact_mcp_response(response) ⇒ Hash

Redact an MCP tool response before sending to client

Parameters:

  • response (Hash)

    MCP tool response

Returns:

  • (Hash)

    Redacted response



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/schwab_mcp/redactor.rb', line 111

def redact_mcp_response(response)
  return response unless response.is_a?(Hash)

  redacted = redact(response)

  # Also redact any content in the response content field
  if redacted.dig("content")
    case redacted["content"]
    when String
      redacted["content"] = redact_formatted_text(redacted["content"])
    when Array
      redacted["content"] = redacted["content"].map do |item|
        if item.is_a?(Hash) && item["text"]
          item["text"] = redact_formatted_text(item["text"])
        end
        item
      end
    end
  end

  redacted
end