KMPwright

Security

Limit what automation and agents can do with KmpwrightConfig origin rules and switches, action policies with confirmation, allowed domains, content boundaries, encrypted storage state, and the hardened in-page runtime.

When an LLM drives a browser, the page it reads is untrusted input and the actions it takes have consequences. KMPwright gives you several independent controls. Use them together.

ControlScopeSet with
Origin rulesWhich origins the main frame may navigate toKmpwrightConfig(allowedOriginRules = …)
Feature switchesWhether evaluation, DOM extraction, trace export and debug mode are available at allKmpwrightConfig(allowEval, allowDomExtraction, enableTraceExport, productionMode)
Action policyWhich kinds of actions are allowed, denied or need confirmation, per domainpage.setActionPolicy(…), page.setPolicyConfirmCallback { … }
Allowed domainsWhich hosts any request (navigation or subresource) may reachpage.setAllowedDomains(…)
Content boundariesMarking untrusted page content inside tool outputpage.boundedSnapshot(), page.boundedContent()
Encrypted storage stateSession files at reststorageState().save(path, password)

A WebView is not a sandbox. These are workflow controls for automation you run. Page content remains untrusted, filtering does not isolate the page from your app, and none of these controls replace platform security (process isolation, app permissions, server-side authorization). On Android and iOS the automated page runs inside your app's WebView, with your app's cookies and network identity.

KmpwrightConfig

Security-relevant fields of the browser-wide configuration, passed to Browser(context, config), launchBrowser(executablePath, config = config) or rememberKmpwrightController(config):

val config = KmpwrightConfig(
    allowedOriginRules = setOf("https://app.example.com", "https://*.example.com"),
    productionMode = true,
    allowEval = false,
    allowDomExtraction = false,
    enableTraceExport = false,
)
val browser = Browser(AndroidPlatformContext(activity, webView), config)

Prop

Type

Origin rules

allowedOriginRules is enforced on the main frame:

  • page.goto(url) throws KmpwrightException("Navigation origin is not allowed by KmpwrightConfig: …") before navigating (about:blank is always allowed).
  • Navigations the page starts itself (links, redirects, location changes) are cancelled in the backend's navigation hook (CDP request interception, WebKit navigation policy, Android shouldOverrideUrlLoading). A navigation that still commits to a disallowed origin is reported with ok = false.
  • HTTP reads (page.read, HttpReader) check every URL, including redirects.
  • On Android, document-start init scripts are registered only for these origins.

It does not filter subresources. Use allowed domains for that.

Action policies

An ActionPolicy decides, for each action, whether it is allowed, denied or needs confirmation. It mirrors agent-browser's --action-policy and --confirm-actions.

page.setActionPolicy(
    ActionPolicy(
        defaultAction = PolicyResult.Allow,
        deniedActions = setOf(ActionType.Evaluate, ActionType.AddScriptTag),
        confirmActions = setOf(ActionType.Fill, ActionType.Navigate),
        allowedDomains = setOf("example.com"),
    )
)

page.setPolicyConfirmCallback { action, url ->
    askUser("Allow $action on $url?")   // your UI; return true to proceed
}

Prop

Type

policy.check(action, url) evaluates rules in this order; the first match wins:

  1. If any domain list is set and the URL has no HTTP(S) host (for example about:blank or data:), deny.
  2. Host matches deniedDomains (exact or subdomain): deny.
  3. allowedDomains is non-empty and the host does not match: deny.
  4. Action in deniedActions: deny.
  5. Action in confirmActions: confirm.
  6. allowedActions non-empty: allow if listed, otherwise deny.
  7. Otherwise defaultAction.

Policy domain entries match the host and every subdomain (example.com covers api.example.com). This differs from setAllowedDomains, where a plain entry matches only that host.

What happens with each result:

ResultEffect
AllowThe action runs.
DenyThe action throws KmpwrightException("Action denied by policy: <ActionType>").
ConfirmThe confirm callback runs with (ActionType, url). true proceeds; false throws "Action denied by user confirmation". With no callback set, it throws "… requires confirmation but no confirm callback is set".

Navigations the page starts on its own (link clicks, redirects) are checked as Navigate too: Deny cancels them, and Confirm only lets through a URL you already approved with page.goto. Pass null to setPolicyConfirmCallback to remove the callback.

Action types

ActionTypeCovers
Navigategoto, reload, goBack, goForward, pushState, read, page-initiated navigations
Clickclick, dblclick, tap, check/uncheck/setChecked, dispatchEvent, mouse button and touch taps
Fillfill, clear, file inputs and file choosers
TypepressSequentially, keyboard.type, keyboard.insertText
Presspress, keyboard.press/down/up
Focusfocus, blur
SelectselectOption
Hoverhover, mouse.move
DragdragTo, mouse.drag, touchscreen.swipe
Scrollscroll, scrollToTop/Bottom, scrollIntoViewIfNeeded, mouse.wheel, touchscreen.pinch
Screenshotscreenshot (page and locator), pdf
Evaluateevaluate, waitForFunction, addInitScript/removeInitScript, accessibilityAudit, webVitals
ReadContentcontent, snapshot, cookies, storageState, metrics, waitForText
SetContentsetContent
AddScriptTag / AddStyleTagaddScriptTag / addStyleTag
SetCookie / DeleteCookieaddCookies / clearCookies
StorageWriting or clearing localStorage/sessionStorage, setStorageState
Clipboardclipboard.readText/writeText
NetworksetExtraHTTPHeaders, setHTTPCredentials, setAllowedDomains
EmulationViewport, user agent, scale, device, color scheme, geolocation, offline and network conditions

Because setAllowedDomains is itself a Network action, a policy that denies Network also stops an agent from loosening the domain filter.

Allowed domains

page.setAllowedDomains(domains) restricts the hosts the page may contact, like agent-browser's --allowed-domains. It applies to navigations and subresources (fetch, XHR, images, iframes), not just the main frame.

page.setAllowedDomains(listOf("example.com", "*.static.example.com"))
page.setAllowedDomains(emptyList())   // remove the restriction
  • example.com matches exactly that host.
  • *.example.com matches example.com and every subdomain.
  • Matching is case-insensitive and ignores a trailing dot. An empty list removes the filter.
  • Blocked requests fail as network errors; blocked navigations are cancelled.
  • The filter is remembered and applied to tabs opened from the page with page.openNewTab.

Enforcement happens below page JavaScript, but each backend has a different hook. Check page.capabilities().domainFiltering and read page.capabilities().notes:

BackenddomainFilteringHowKnown gaps
JVM Chromium (CDP)SupportedCDP Fetch interception: navigations, iframes, redirects and every HTTP(S) subresourceWebSocket handshakes are not visible to Fetch interception and are not filtered.
iOS WKWebViewSupportedNavigation policy cancels navigations (including server redirects); a WKContentRuleList blocks every subresource (fetch, XHR, images, iframes, WebSockets, redirects)None reported.
Android WebViewPartialshouldInterceptRequest fails disallowed subresources; loadUrl/shouldOverrideUrlLoading cancel navigations, including main-frame server redirectsNo hook for server redirects of subresources (an open redirect on an allowed host reaches its target), WebSocket connections, or requests made by service workers.
// Refuse to run where the filter has gaps.
page.requireCapabilities(PageCapability.DomainFiltering)

Content boundaries

When you return page text to an LLM, wrap it so the model can tell untrusted page content from your instructions. This mirrors agent-browser's --content-boundaries.

val text = page.boundedSnapshot(interactive = true)
// --- AGENT_BROWSER_PAGE_CONTENT nonce=3f9c…e1 origin=https://example.com ---
// @e1 [heading] "Example Domain" [level=1]
// @e2 [link] "Learn more"
// --- END_AGENT_BROWSER_PAGE_CONTENT nonce=3f9c…e1 ---
APIReturns
page.createContentBoundary()A ContentBoundary with a fresh 128-bit random nonce and the page URL as origin.
page.boundedSnapshot(options, boundary = null)The snapshot tree wrapped in boundary markers.
page.boundedContent(boundary = null)The page HTML wrapped in boundary markers.
boundary.wrap(text) / wrapWithSelector(selector, text)Wraps any text you extracted yourself.
boundary.isBounded(text) / unwrap(text)Checks for and extracts the bounded content.
ContentBoundary.create(origin)A boundary for content that did not come from a page.

Pass null (the default) to get a new nonce per call, or reuse one boundary across a single tool result. The nonce is what makes the markers hard to forge: page text cannot predict it. Tell the model in your system prompt that text between markers is data, not instructions.

runCommand output is not wrapped automatically. Wrap it before returning it to the model:

val boundary = page.createContentBoundary()
val result = page.runCommand(command)
return boundary.wrap(result.output)

Hardened in-page runtime

KMPwright's selector engine, actionability checks, synthetic input and snapshot ref registry run as JavaScript inside the page, which may be hostile. The runtime is built to resist a page that tries to interfere:

  • It lives in a closure published under a random, per-process property name defined as non-writable, non-enumerable and non-configurable, so a page cannot replace it or pre-empt a well-known global.
  • It captures the DOM primitives it needs at install time, so later page patches to those primitives do not affect it.
  • The ref registry is private. Changing it requires a secret that only appears in scripts KMPwright evaluates.
  • KMPwright never trusts a pre-existing global. If the slot exists but is not a sealed runtime of the expected version, the operation fails with KmpwrightException ("The page replaced or pre-defined the KMPwright runtime slot; refusing to automate this document") instead of using it.

Residual risk: once installed, a page can discover the property name and read (but not modify) the runtime. A page that learned the name could pre-define it in a later document; KMPwright then detects the tampering and refuses to automate that document.

Encrypted storage state

Storage state contains session cookies. StorageState.save therefore requires you to choose explicitly:

page.storageState().save("state.json", password = statePassword)    // encrypted
page.setStorageState(StorageState.load("state.json", password = statePassword))

page.storageState().save("state.json", password = null)             // plaintext, opt-in
  • On Android and the JVM, the file is encrypted with AES-256 (CBC) and authenticated with HMAC-SHA256, using keys derived from the password with PBKDF2-HMAC-SHA256 (210 000 iterations). iOS uses the platform implementation behind the same API.
  • load with a password never falls back to plaintext; a wrong password or a modified file throws KmpwrightException.
  • A blank password is rejected; pass null for plaintext.
  • autosaveStorageState(path, password, intervalMs) has the same explicit password rule.
  • StorageState.toString() prints only cookie counts and storage keys, not values. HttpCredentials and AuthProfile also redact passwords in toString().

Keep passwords, provider API keys and state files out of source control.

Checklist for agent workloads

  1. Set allowedOriginRules to the origins the agent needs, and call setAllowedDomains for every host the page may contact.
  2. Turn off what the agent does not need: allowEval = false in most cases. allowDomExtraction = false also disables snapshots and locator reads, so it only suits scripted automation that never reads the page.
  3. Add an ActionPolicy that denies dangerous action types and routes sensitive ones (Fill, Navigate, SetCookie) through a confirmation callback.
  4. Wrap every piece of page text you return to the model in a content boundary.
  5. requireCapabilities(PageCapability.DomainFiltering) if partial filtering is not acceptable.
  6. Encrypt storage state files.

On this page