module RubyLLM::Browser::Redactor

Sanitization and bounding helpers for anything that could reach a model message, tool result, log line or error.

Page text, model output and provider metadata are untrusted. They are redacted by key name, scrubbed of known secrets, stripped of DevTools URLs, and bounded by size and depth before they leave a backend.

Constants

DEVTOOLS_URL

A DevTools WebSocket URL anywhere in a string. Full browser control, so it is always removed.

MAX_DEPTH

Maximum nesting depth kept while redacting.

MIN_LITERAL_SECRET

Secrets shorter than this are not treated as replaceable literals; replacing very short strings would corrupt ordinary text.

REDACTED

The replacement for redacted values.

SECRET_KEY

Keys whose values are always treated as secrets. Matching is case-insensitive and applies to ‘snake_case`, `kebab-case` and `camelCase` spellings.

TRUNCATED

JSON preview marker when a value is too large to return whole.

Public Instance Methods

bound(value, max_bytes:, secrets: []) click to toggle source

Redacts and then bounds value to max_bytes of JSON.

@return [Array(Object, Boolean)] the bounded value and whether it was truncated

# File lib/ruby_llm/browser/redactor.rb, line 77
def bound(value, max_bytes:, secrets: [])
  unless max_bytes.is_a?(Integer) && max_bytes.positive?
    raise InvalidInput, "max_bytes must be a positive integer"
  end
  redacted = redact(value, secrets: secrets)
  json = canonical_json(redacted)
  return [ redacted, false ] if json.bytesize <= max_bytes

  # Count encoded bytes, not characters: JSON escaping can expand a preview.
  envelope = { "truncated" => true, "preview" => "", "note" => TRUNCATED }
  return [ "", true ] if max_bytes < canonical_json(envelope).bytesize && max_bytes >= 2
  return [ 0, true ] if max_bytes == 1

  low = 0
  high = [ json.length, max_bytes ].min
  while low < high
    midpoint = (low + high + 1) / 2
    envelope["preview"] = json[0, midpoint]
    if canonical_json(envelope).bytesize <= max_bytes
      low = midpoint
    else
      high = midpoint - 1
    end
  end
  envelope["preview"] = json[0, low]
  [ envelope, true ]
end
canonical_json(value) click to toggle source

Stable JSON for digests: object keys sorted, no insignificant whitespace.

# File lib/ruby_llm/browser/redactor.rb, line 106
def canonical_json(value)
  JSON.generate(sort_keys(value))
end
digest(value) click to toggle source

SHA-256 hex digest of the canonical JSON of value.

# File lib/ruby_llm/browser/redactor.rb, line 111
def digest(value)
  Digest::SHA256.hexdigest(canonical_json(value))
end
redact(value, secrets: [], depth: 0, max_depth: MAX_DEPTH) click to toggle source

Returns a redacted, depth-bounded copy of value.

@param value [Object] @param secrets [Array<String>] exact secret values to remove from strings @param max_depth [Integer] nesting depth at which content is replaced entirely

# File lib/ruby_llm/browser/redactor.rb, line 54
def redact(value, secrets: [], depth: 0, max_depth: MAX_DEPTH)
  return REDACTED if depth > max_depth

  case value
  when Hash
    value.each_with_object({}) do |(key, nested), result|
      safe_key = scrub_string(key.to_s, secrets: secrets)
      result[safe_key] = secret_key?(key) ? REDACTED : redact(nested, secrets: secrets, depth: depth + 1, max_depth: max_depth)
    end
  when Array
    value.map { |item| redact(item, secrets: secrets, depth: depth + 1, max_depth: max_depth) }
  when String
    scrub_string(value, secrets: secrets)
  when Symbol
    scrub_string(value.to_s, secrets: secrets)
  else
    value
  end
end
scrub_string(string, secrets:) click to toggle source
# File lib/ruby_llm/browser/redactor.rb, line 148
def scrub_string(string, secrets:)
  scrubbed = string.dup.force_encoding(Encoding::UTF_8).scrub
  (secrets || []).each do |secret|
    next if secret.to_s.length < MIN_LITERAL_SECRET

    scrubbed = scrubbed.gsub(secret.to_s, REDACTED)
  end
  scrubbed.gsub(DEVTOOLS_URL, REDACTED)
end
secret_key?(key) click to toggle source

True when a key name denotes a secret value.

# File lib/ruby_llm/browser/redactor.rb, line 116
def secret_key?(key)
  SECRET_KEY.match?(key.to_s)
end
sort_keys(value) click to toggle source
# File lib/ruby_llm/browser/redactor.rb, line 135
def sort_keys(value)
  case value
  when Hash
    value.keys.map(&:to_s).sort.to_h do |key|
      [ key, sort_keys(value.key?(key) ? value[key] : value[key.to_sym]) ]
    end
  when Array
    value.map { |item| sort_keys(item) }
  else
    value
  end
end
url(value, secrets: []) click to toggle source

Removes userinfo, fragments and credential-like query values from provenance.

# File lib/ruby_llm/browser/redactor.rb, line 121
def url(value, secrets: [])
  uri = URI.parse(value.to_s)
  uri.userinfo = nil if uri.respond_to?(:userinfo=)
  uri.fragment = nil
  if uri.query
    uri.query = URI.encode_www_form(URI.decode_www_form(uri.query).map do |key, nested|
      [ key, secret_key?(key) ? REDACTED : nested ]
    end)
  end
  scrub_string(uri.to_s, secrets: secrets)
rescue URI::Error, ArgumentError
  "[REDACTED URL]"
end