class RubyLLM::Browser::Policy
The host’s source, schema, action and egress policy.
Policy is composed by the host application and enforced by the Gem. It never infers permissions from model arguments or page text, and it fails closed: an unknown host, schema, action or an unqualified backend stops the operation.
Constants
- ALLOWED_SCHEMA_TYPES
- DEFAULT_ACTION_METHODS
Action methods an approved
Actmay use. There is no keypress/execute/download and no arbitrary JavaScript.- DEFAULT_FORBIDDEN_ACTIONS
Sensitive operations are refused even with a broad host approval.
- DEFAULT_MAX_SCHEMA_BYTES
- DEFAULT_MAX_SCHEMA_DEPTH
- DEFAULT_MAX_SCHEMA_PROPERTIES
- SCHEMA_KEYS
JSON Schema keys this Gem accepts inside an extraction schema.
Attributes
allowed_action_methods[R]
allowed_hosts[R]
allowed_ports[R]
allowed_schemes[R]
forbidden_action_patterns[R]
max_result_bytes[R]
max_schema_bytes[R]
max_schema_depth[R]
max_schema_properties[R]
Public Class Methods
new(allowed_hosts:, allowed_schemes: UrlGuard::DEFAULT_SCHEMES, allowed_ports: UrlGuard::DEFAULT_PORTS, verify_dns: true, resolver: nil, max_result_bytes: nil, allowed_action_methods: DEFAULT_ACTION_METHODS, forbidden_action_patterns: DEFAULT_FORBIDDEN_ACTIONS, max_schema_depth: DEFAULT_MAX_SCHEMA_DEPTH, max_schema_properties: DEFAULT_MAX_SCHEMA_PROPERTIES, max_schema_bytes: DEFAULT_MAX_SCHEMA_BYTES)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 46 def initialize(allowed_hosts:, allowed_schemes: UrlGuard::DEFAULT_SCHEMES, allowed_ports: UrlGuard::DEFAULT_PORTS, verify_dns: true, resolver: nil, max_result_bytes: nil, allowed_action_methods: DEFAULT_ACTION_METHODS, forbidden_action_patterns: DEFAULT_FORBIDDEN_ACTIONS, max_schema_depth: DEFAULT_MAX_SCHEMA_DEPTH, max_schema_properties: DEFAULT_MAX_SCHEMA_PROPERTIES, max_schema_bytes: DEFAULT_MAX_SCHEMA_BYTES) @url_guard = UrlGuard.new( allowed_hosts: allowed_hosts, allowed_schemes: allowed_schemes, allowed_ports: allowed_ports, verify_dns: verify_dns, resolver: resolver ) @allowed_hosts = @url_guard.allowed_hosts @allowed_schemes = @url_guard.allowed_schemes @allowed_ports = @url_guard.allowed_ports @max_result_bytes = positive_integer!(max_result_bytes || RubyLLM::Browser.configuration.max_result_bytes, :max_result_bytes) @allowed_action_methods = Immutable.copy(normalize_action_methods!(allowed_action_methods)) @forbidden_action_patterns = (DEFAULT_FORBIDDEN_ACTIONS + Array(forbidden_action_patterns)).uniq.map do |pattern| raise InvalidInput, "forbidden actions must be regexps" unless pattern.is_a?(Regexp) pattern end.freeze @max_schema_depth = positive_integer!(max_schema_depth, :max_schema_depth) @max_schema_properties = positive_integer!(max_schema_properties, :max_schema_properties) @max_schema_bytes = positive_integer!(max_schema_bytes, :max_schema_bytes) freeze end
Public Instance Methods
coerce_schema!(schema)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 197 def coerce_schema!(schema) case schema when Hash stringify_keys(schema) when String parse_schema_json!(schema) when Class if schema.respond_to?(:to_json_schema) return stringify_keys(schema.to_json_schema) end if schema.instance_methods.include?(:to_json_schema) return stringify_keys(schema.new.to_json_schema) end raise InvalidInput, "unsupported schema class" else if schema.respond_to?(:to_json_schema) stringify_keys(schema.to_json_schema) else raise InvalidInput, "extraction schema must be a Hash or JSON object string" end end end
digest(= Redactor.digest(to_h))
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 179 def digest = Redactor.digest(to_h) private def egress_failure_reason(capabilities) return "backend did not report capabilities" unless capabilities.is_a?(Capabilities) reasons = [] reasons << "browser egress isolation is not enforced" unless capabilities.enforced_egress? if capabilities.local_mode? && !capabilities.local_binary_pinned? reasons << "local mode requires a host-pinned server binary" end if capabilities.custom_headers_present? && !capabilities.custom_headers_approved? reasons << "STAGEHAND_CUSTOM_HEADERS is set but not host-approved" end "backend cannot enforce egress isolation: #{reasons.join("; ")}" end def coerce_schema!(schema) case schema when Hash stringify_keys(schema) when String parse_schema_json!(schema) when Class if schema.respond_to?(:to_json_schema) return stringify_keys(schema.to_json_schema) end if schema.instance_methods.include?(:to_json_schema) return stringify_keys(schema.new.to_json_schema) end raise InvalidInput, "unsupported schema class" else if schema.respond_to?(:to_json_schema) stringify_keys(schema.to_json_schema) else raise InvalidInput, "extraction schema must be a Hash or JSON object string" end end end def parse_schema_json!(string) raise InvalidInput, "extraction schema is too large" if string.bytesize > max_schema_bytes parsed = JSON.parse(string) raise InvalidInput, "extraction schema must be a JSON object" unless parsed.is_a?(Hash) stringify_keys(parsed) rescue JSON::ParserError raise InvalidInput, "extraction schema is not valid JSON", cause: nil end def validate_schema_node!(node, depth:) return unless node.is_a?(Hash) || node.is_a?(Array) if depth > max_schema_depth raise InvalidInput, "extraction schema is nested too deeply" end case node when Array node.each { |item| validate_schema_node!(item, depth: depth + 1) } when Hash node.each do |key, value| name = key.to_s unless SCHEMA_KEYS.include?(name) raise InvalidInput, "unsupported schema key #{name.inspect}" end validate_schema_type!(value) if name == "type" if name == "properties" # Values are schemas; property names themselves are arbitrary. value.each_value { |subschema| validate_schema_node!(subschema, depth: depth + 1) } if value.is_a?(Hash) elsif name == "items" || name == "additionalProperties" validate_schema_node!(value, depth: depth + 1) end end end end def validate_schema_type!(value) types = Array(value).map(&:to_s) invalid = types - ALLOWED_SCHEMA_TYPES raise InvalidInput, "unsupported schema type #{invalid.inspect}" if invalid.any? end def symbolize_action!(action) raise InvalidInput, "action must be a Hash" unless action.is_a?(Hash) action.each_with_object({}) { |(key, value), result| result[key.to_s] = value } end def stringify_keys(value) case value when Hash value.each_with_object({}) { |(key, nested), result| result[key.to_s] = stringify_keys(nested) } when Array value.map { |item| stringify_keys(item) } else value end end def normalize_action_methods!(methods) normalized = Array(methods).map { |method| method.to_s.downcase } raise InvalidInput, "allowed_action_methods must not be empty" if normalized.empty? unless (normalized - DEFAULT_ACTION_METHODS).empty? raise InvalidInput, "only supported non-script action methods are allowed" end normalized.uniq end def positive_integer!(value, name) raise InvalidInput, "#{name} must be a positive integer" unless value.is_a?(Integer) && value.positive? value end end
egress_failure_reason(capabilities)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 183 def egress_failure_reason(capabilities) return "backend did not report capabilities" unless capabilities.is_a?(Capabilities) reasons = [] reasons << "browser egress isolation is not enforced" unless capabilities.enforced_egress? if capabilities.local_mode? && !capabilities.local_binary_pinned? reasons << "local mode requires a host-pinned server binary" end if capabilities.custom_headers_present? && !capabilities.custom_headers_approved? reasons << "STAGEHAND_CUSTOM_HEADERS is set but not host-approved" end "backend cannot enforce egress isolation: #{reasons.join("; ")}" end
egress_qualified?(capabilities)
click to toggle source
True when a backend proves the egress isolation this Gem requires.
# File lib/ruby_llm/browser/policy.rb, line 152 def egress_qualified?(capabilities) return false unless capabilities.is_a?(Capabilities) return false unless capabilities.enforced_egress? return false if capabilities.local_mode? && !capabilities.local_binary_pinned? return false if capabilities.custom_headers_present? && !capabilities.custom_headers_approved? true end
normalize_action_methods!(methods)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 281 def normalize_action_methods!(methods) normalized = Array(methods).map { |method| method.to_s.downcase } raise InvalidInput, "allowed_action_methods must not be empty" if normalized.empty? unless (normalized - DEFAULT_ACTION_METHODS).empty? raise InvalidInput, "only supported non-script action methods are allowed" end normalized.uniq end
parse_schema_json!(string)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 220 def parse_schema_json!(string) raise InvalidInput, "extraction schema is too large" if string.bytesize > max_schema_bytes parsed = JSON.parse(string) raise InvalidInput, "extraction schema must be a JSON object" unless parsed.is_a?(Hash) stringify_keys(parsed) rescue JSON::ParserError raise InvalidInput, "extraction schema is not valid JSON", cause: nil end
positive_integer!(value, name)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 291 def positive_integer!(value, name) raise InvalidInput, "#{name} must be a positive integer" unless value.is_a?(Integer) && value.positive? value end
stringify_keys(value)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 270 def stringify_keys(value) case value when Hash value.each_with_object({}) { |(key, nested), result| result[key.to_s] = stringify_keys(nested) } when Array value.map { |item| stringify_keys(item) } else value end end
symbolize_action!(action)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 264 def symbolize_action!(action) raise InvalidInput, "action must be a Hash" unless action.is_a?(Hash) action.each_with_object({}) { |(key, value), result| result[key.to_s] = value } end
to_h()
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 168 def to_h { allowed_hosts: allowed_hosts, allowed_schemes: allowed_schemes, allowed_ports: allowed_ports, max_result_bytes: max_result_bytes, allowed_action_methods: allowed_action_methods, max_schema_depth: max_schema_depth } end
validate_schema_node!(node, depth:)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 231 def validate_schema_node!(node, depth:) return unless node.is_a?(Hash) || node.is_a?(Array) if depth > max_schema_depth raise InvalidInput, "extraction schema is nested too deeply" end case node when Array node.each { |item| validate_schema_node!(item, depth: depth + 1) } when Hash node.each do |key, value| name = key.to_s unless SCHEMA_KEYS.include?(name) raise InvalidInput, "unsupported schema key #{name.inspect}" end validate_schema_type!(value) if name == "type" if name == "properties" # Values are schemas; property names themselves are arbitrary. value.each_value { |subschema| validate_schema_node!(subschema, depth: depth + 1) } if value.is_a?(Hash) elsif name == "items" || name == "additionalProperties" validate_schema_node!(value, depth: depth + 1) end end end end
validate_schema_type!(value)
click to toggle source
# File lib/ruby_llm/browser/policy.rb, line 258 def validate_schema_type!(value) types = Array(value).map(&:to_s) invalid = types - ALLOWED_SCHEMA_TYPES raise InvalidInput, "unsupported schema type #{invalid.inspect}" if invalid.any? end