Permissions-Policy Builder — Ruby source
Build a Permissions-Policy header interactively. Control which browser features (camera, microphone, geolocation, etc.) your site can use.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# Permissions-Policy — pure header builder / parser. Zero deps.
#
# Language: Ruby (3.1+, standard library only)
# Source: CosmoDev polyglot showcase port of the Permissions Policy tool,
# ported from src/lib/permissions-policy.ts (the canonical
# TypeScript implementation).
# License: display source — part of CosmoDev's polyglot tool pages.
#
# The Permissions-Policy header is a comma-separated list of directives:
# Permissions-Policy: geolocation=(self), camera=(), microphone=*, usb=(https://a.example)
# Each directive maps a browser feature to an allowlist. An empty allowlist
# `()` disables the feature outright; `*` allows it everywhere; `self` limits
# it to the page's own origin; anything else is a space-separated origin list.
#
# A policy is modeled here as a Hash of feature -> allowlist array:
# { 'geolocation' => ['self'], 'camera' => [], 'usb' => ['https://a.example'] }
# - [] => camera=() (disabled)
# - ['*'] => microphone=* (every origin)
# - ['self'] => geolocation=(self)
# - [origins...] => usb=(https://a.example https://b.example)
# Features absent from the map are absent from the header (browser default).
module PermissionsPolicy
FeatureInfo = Struct.new(:name, :description, :privacy_impact,
:default_browser_behavior, keyword_init: true)
ValidationResult = Struct.new(:valid, :errors, keyword_init: true)
FEATURES = [
# --- high privacy impact --------------------------------------------------
FeatureInfo.new(name: 'camera', description: 'Access the device camera for photos / video calls.', privacy_impact: 'high', default_browser_behavior: 'Same-origin only; prompts the user.'),
FeatureInfo.new(name: 'microphone', description: 'Capture audio from the device microphone.', privacy_impact: 'high', default_browser_behavior: 'Same-origin only; prompts the user.'),
FeatureInfo.new(name: 'geolocation', description: 'Read the precise GPS location of the visitor.', privacy_impact: 'high', default_browser_behavior: 'Same-origin only; prompts the user.'),
FeatureInfo.new(name: 'display-capture', description: 'Screen / window sharing via getDisplayMedia.', privacy_impact: 'high', default_browser_behavior: 'Same-origin only; prompts the user.'),
FeatureInfo.new(name: 'idle-detection', description: 'Detects when the user is away from the device — reveals usage patterns.', privacy_impact: 'high', default_browser_behavior: 'Disabled; prompts the user.'),
FeatureInfo.new(name: 'serial', description: 'Talk to serial devices (Arduinos, POS terminals) over a physical port.', privacy_impact: 'high', default_browser_behavior: 'Disabled; prompts the user.'),
FeatureInfo.new(name: 'usb', description: 'WebUSB — direct access to connected USB devices.', privacy_impact: 'high', default_browser_behavior: 'Disabled; prompts the user.'),
FeatureInfo.new(name: 'hid', description: 'Human Interface Devices — raw access to unusual keyboards, gamepads, sensors.', privacy_impact: 'high', default_browser_behavior: 'Disabled; prompts the user.'),
FeatureInfo.new(name: 'xr-spatial-tracking', description: 'Tracks head / hand position in WebXR sessions.', privacy_impact: 'high', default_browser_behavior: 'Same-origin only; prompts the user.'),
# --- medium privacy impact ------------------------------------------------
FeatureInfo.new(name: 'accelerometer', description: 'Device motion sensor — can fingerprint and infer behaviour.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'ambient-light-sensor', description: 'Reads ambient light level around the device.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'battery', description: 'Battery Status API — a classic fingerprinting vector.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'gyroscope', description: 'Device orientation sensor — fingerprinting and behaviour inference.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'magnetometer', description: "Compass readings — can leak details of the user's surroundings.", privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'keyboard-map', description: 'Reads the physical keyboard layout — a small but real fingerprint.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'gamepad', description: 'Enumerates connected controllers and their button state.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'midi', description: 'Web MIDI — access to attached music hardware.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only; prompts the user.'),
FeatureInfo.new(name: 'payment', description: 'Payment Request API — can expose stored payment handles.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'publickey-credentials-get', description: 'WebAuthn credential requests.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'screen-wake-lock', description: 'Keeps the screen awake — drains battery and signals intent.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'speaker-selection', description: 'Enumerates and switches audio output devices.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'web-share', description: 'Invokes the OS share sheet with chosen content.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'encrypted-media', description: 'DRM (EME) — playback identity can be correlated.', privacy_impact: 'medium', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'document-domain', description: 'Let frames relax the same-origin policy via document.domain.', privacy_impact: 'medium', default_browser_behavior: 'Allowed in same-origin pages; deprecated.'),
# --- low privacy impact ---------------------------------------------------
FeatureInfo.new(name: 'autoplay', description: 'Autoplay media with/without sound — not a data leak, an annoyance knob.', privacy_impact: 'low', default_browser_behavior: 'Muted autoplay allowed; audible blocked.'),
FeatureInfo.new(name: 'cross-origin-isolated', description: 'COOP/COEP isolation for SharedArrayBuffer — hardens the page.', privacy_impact: 'low', default_browser_behavior: 'Not isolated.'),
FeatureInfo.new(name: 'fullscreen', description: 'Element.requestFullscreen().', privacy_impact: 'low', default_browser_behavior: 'Same-origin only.'),
FeatureInfo.new(name: 'navigation-override', description: 'Lets a frame intercept its own top-level navigations.', privacy_impact: 'low', default_browser_behavior: 'Disabled.'),
FeatureInfo.new(name: 'picture-in-picture', description: 'Floating always-on-top video window.', privacy_impact: 'low', default_browser_behavior: 'Same-origin only.')
].freeze
FEATURE_NAME_RE = /\A[a-z][a-z0-9-]*\z/
ORIGIN_RE = /\A(https?:\/\/|https?:)[\w.-]+(:\d+)?\z/i
class << self
# Assemble a Permissions-Policy header value from a feature map.
def build_permissions_policy(features)
features.keys.sort.map do |name|
"#{name}=#{format_allowlist(features[name])}"
end.join(', ')
end
# Parse a Permissions-Policy header value back into a feature map.
# Accepts an optional `Permissions-Policy:` prefix. Returns nil when the
# syntax is malformed (bad directive, missing allowlist, bad token).
def parse_permissions_policy(header)
cleaned = header.strip.sub(/\Apermissions-policy\s*:\s*/i, '')
return nil if cleaned.empty?
map = {}
cleaned.split(',').each do |raw_directive|
directive = raw_directive.strip
return nil if directive.empty?
eq = directive.index('=')
return nil if eq.nil? || eq <= 0
name = directive[0...eq].strip
value = directive[(eq + 1)..].strip
return nil unless name.match?(FEATURE_NAME_RE)
if value == '*' || value == 'self'
map[name] = [value]
next
end
return nil unless value.start_with?('(') && value.end_with?(')')
inner = value[1..-2].strip
if inner.empty?
map[name] = []
next
end
tokens = inner.split(/\s+/).map { |t| t.sub(/\A"(.*)"\z/, '\1') }
tokens.each do |token|
return nil if token != 'self' && token != '*' && !token.match?(ORIGIN_RE)
end
map[name] = tokens
end
map
end
# Syntax-check a header the same way parse_permissions_policy does, with
# messages.
def validate_permissions_policy(header)
errors = []
cleaned = header.strip.sub(/\Apermissions-policy\s*:\s*/i, '')
if cleaned.empty?
return ValidationResult.new(valid: false, errors: ['Header is empty.'])
end
cleaned.split(',').each_with_index do |raw_directive, i|
directive = raw_directive.strip
where = "Directive #{i + 1}"
if directive.empty?
errors << "#{where}: empty (stray comma?)."
next
end
eq = directive.index('=')
if eq.nil? || eq <= 0
errors << "#{where}: expected `feature=allowlist`, got `#{directive}`."
next
end
name = directive[0...eq].strip
value = directive[(eq + 1)..].strip
unless name.match?(FEATURE_NAME_RE)
errors << "#{where}: `#{name}` is not a valid feature name."
next
end
next if value == '*' || value == 'self'
unless value.start_with?('(') && value.end_with?(')')
errors << "#{where}: `#{name}` allowlist must be `*`, `self`, or " \
"`(...)` — got `#{value}`."
next
end
inner = value[1..-2].strip
next if inner.empty? # () = disabled, valid
inner.split(/\s+/).each do |raw|
token = raw.sub(/\A"(.*)"\z/, '\1')
if token != 'self' && token != '*' && !token.match?(ORIGIN_RE)
errors << "#{where}: `#{name}` has an invalid allowlist token `#{raw}`."
end
end
end
ValidationResult.new(valid: errors.empty?, errors: errors)
end
# Privacy score 0-100 for a policy: how much it locks down the catalog.
# Each feature is weighted by privacy impact (high 3, medium 2, low 1).
# Disabled `()` earns full credit, `self` or an origin list half, `*` or
# "absent from the policy" none (the browser default stays in force).
def privacy_score(features)
weight = { 'high' => 3, 'medium' => 2, 'low' => 1 }
earned = 0.0
possible = 0.0
FEATURES.each do |feature|
w = weight[feature.privacy_impact]
possible += w
allowlist = features[feature.name]
if allowlist && allowlist.empty?
earned += w # () disabled
elsif allowlist && !(allowlist.length == 1 && allowlist[0] == '*')
earned += w / 2.0 # self / origins
end
end
((earned / possible) * 100).round
end
private
def format_allowlist(tokens)
return '*' if tokens.length == 1 && tokens[0] == '*'
"(#{tokens.join(' ')})"
end
end
end
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →