Files
chrome-use/src/action-policy.ts
T
Chris Tate bc1e917e87 add security hardening features (#543)
* add security hardening features

- Add authentication vault (`auth save/login/list/show/delete`) so credentials are stored locally and never exposed to the LLM (fixes Snyk W007)
- Add `--content-boundaries` flag to wrap page-sourced output in structural markers, helping LLMs distinguish tool output from untrusted page content (fixes Snyk W011)
- Add `--allowed-domains` flag to restrict browser navigation to trusted domains
- Add `--action-policy` for static allow/deny gating of action categories, with opt-in `--confirm-actions`/`--confirm-interactive` for orchestrator or human-in-the-loop confirmation
- Add `--max-output` flag to truncate large page outputs, preventing context flooding
- New docs page at /security, updated README, SKILL.md, CLI help text, and templates

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* docs
2026-02-25 15:33:20 -06:00

298 lines
7.8 KiB
TypeScript

import { readFileSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
export interface ActionPolicy {
default: 'allow' | 'deny';
allow?: string[];
deny?: string[];
}
export type PolicyDecision = 'allow' | 'deny' | 'confirm';
const ACTION_CATEGORIES: Record<string, string> = {
navigate: 'navigate',
back: 'navigate',
forward: 'navigate',
reload: 'navigate',
tab_new: 'navigate',
click: 'click',
dblclick: 'click',
tap: 'click',
fill: 'fill',
type: 'fill',
// The `keyboard` action is a compound command that dispatches to sub-actions
// (type, inserttext, press, down, up). Its primary use is text input, so it
// maps to 'fill'. The interact-like sub-actions (press, down, up) are less
// common and don't have separate top-level action names in the protocol.
keyboard: 'fill',
inserttext: 'fill',
select: 'fill',
multiselect: 'fill',
check: 'fill',
uncheck: 'fill',
clear: 'fill',
selectall: 'fill',
setvalue: 'fill',
download: 'download',
waitfordownload: 'download',
upload: 'upload',
evaluate: 'eval',
evalhandle: 'eval',
addscript: 'eval',
addinitscript: 'eval',
snapshot: 'snapshot',
screenshot: 'snapshot',
pdf: 'snapshot',
diff_snapshot: 'snapshot',
diff_screenshot: 'snapshot',
diff_url: 'snapshot',
scroll: 'scroll',
scrollintoview: 'scroll',
wait: 'wait',
waitforurl: 'wait',
waitforloadstate: 'wait',
waitforfunction: 'wait',
gettext: 'get',
content: 'get',
innerhtml: 'get',
innertext: 'get',
inputvalue: 'get',
url: 'get',
title: 'get',
getattribute: 'get',
count: 'get',
boundingbox: 'get',
styles: 'get',
isvisible: 'get',
isenabled: 'get',
ischecked: 'get',
responsebody: 'get',
route: 'network',
unroute: 'network',
requests: 'network',
state_save: 'state',
state_load: 'state',
cookies_set: 'state',
storage_set: 'state',
credentials: 'state',
hover: 'interact',
focus: 'interact',
drag: 'interact',
press: 'interact',
keydown: 'interact',
keyup: 'interact',
mousemove: 'interact',
mousedown: 'interact',
mouseup: 'interact',
wheel: 'interact',
dispatch: 'interact',
// These are always allowed (internal/meta operations)
launch: '_internal',
close: '_internal',
tab_list: '_internal',
tab_switch: '_internal',
tab_close: '_internal',
window_new: '_internal',
frame: '_internal',
mainframe: '_internal',
dialog: '_internal',
session: '_internal',
console: '_internal',
errors: '_internal',
cookies_get: '_internal',
cookies_clear: '_internal',
storage_get: '_internal',
storage_clear: '_internal',
state_list: '_internal',
state_show: '_internal',
state_clear: '_internal',
state_clean: '_internal',
state_rename: '_internal',
highlight: '_internal',
bringtofront: '_internal',
trace_start: '_internal',
trace_stop: '_internal',
har_start: '_internal',
har_stop: '_internal',
video_start: '_internal',
video_stop: '_internal',
recording_start: '_internal',
recording_stop: '_internal',
recording_restart: '_internal',
profiler_start: '_internal',
profiler_stop: '_internal',
clipboard: '_internal',
viewport: '_internal',
useragent: '_internal',
device: '_internal',
geolocation: '_internal',
permissions: '_internal',
emulatemedia: '_internal',
offline: '_internal',
headers: '_internal',
addstyle: 'eval',
expose: 'eval',
timezone: '_internal',
locale: '_internal',
pause: '_internal',
setcontent: 'eval',
screencast_start: '_internal',
screencast_stop: '_internal',
input_mouse: '_internal',
input_keyboard: '_internal',
input_touch: '_internal',
auth_save: '_internal',
auth_login: '_internal',
auth_list: '_internal',
auth_delete: '_internal',
auth_show: '_internal',
confirm: '_internal',
deny: '_internal',
// Find/semantic locator actions (read-only element resolution)
getbyrole: 'get',
getbytext: 'get',
getbylabel: 'get',
getbyplaceholder: 'get',
getbyalttext: 'get',
getbytitle: 'get',
getbytestid: 'get',
nth: 'get',
};
// User-facing categories used in policy files. '_internal' is excluded because
// internal actions always bypass policy. 'unknown' is intentionally not a value
// in ACTION_CATEGORIES -- it is only the fallback return of getActionCategory()
// for unrecognized actions. If a user puts "unknown" in a policy file,
// loadPolicyFile will warn about it as unrecognized, which is correct.
export const KNOWN_CATEGORIES = new Set(
Object.values(ACTION_CATEGORIES).filter((c) => c !== '_internal')
);
export function getActionCategory(action: string): string {
return ACTION_CATEGORIES[action] ?? 'unknown';
}
export function loadPolicyFile(policyPath: string): ActionPolicy {
const resolved = resolve(policyPath);
const content = readFileSync(resolved, 'utf-8');
const policy = JSON.parse(content) as ActionPolicy;
if (policy.default !== 'allow' && policy.default !== 'deny') {
throw new Error(
`Invalid action policy: "default" must be "allow" or "deny", got "${policy.default}"`
);
}
for (const list of [policy.allow, policy.deny]) {
if (!list) continue;
for (const category of list) {
if (!KNOWN_CATEGORIES.has(category)) {
console.warn(
`[agent-browser] Warning: unrecognized action category "${category}" in policy file. ` +
`Known categories: ${[...KNOWN_CATEGORIES].sort().join(', ')}`
);
}
}
}
return policy;
}
let cachedPolicyPath: string | null = null;
let cachedPolicyMtimeMs = 0;
let cachedPolicy: ActionPolicy | null = null;
const RELOAD_CHECK_INTERVAL_MS = 5_000;
let lastCheckMs = 0;
export function initPolicyReloader(policyPath: string, policy: ActionPolicy): void {
cachedPolicyPath = resolve(policyPath);
cachedPolicyMtimeMs = statSync(cachedPolicyPath).mtimeMs;
cachedPolicy = policy;
}
export function reloadPolicyIfChanged(): ActionPolicy | null {
if (!cachedPolicyPath) return cachedPolicy;
const now = Date.now();
if (now - lastCheckMs < RELOAD_CHECK_INTERVAL_MS) return cachedPolicy;
lastCheckMs = now;
try {
const currentMtime = statSync(cachedPolicyPath).mtimeMs;
if (currentMtime !== cachedPolicyMtimeMs) {
cachedPolicy = loadPolicyFile(cachedPolicyPath);
cachedPolicyMtimeMs = currentMtime;
}
} catch {
// File may have been removed; keep using cached policy
}
return cachedPolicy;
}
export function checkPolicy(
action: string,
policy: ActionPolicy | null,
confirmCategories: Set<string>
): PolicyDecision {
const category = getActionCategory(action);
// Internal actions are always allowed
if (category === '_internal') return 'allow';
// Explicit deny takes precedence over confirmation
if (policy?.deny?.includes(category)) return 'deny';
// Check if this category requires confirmation
if (confirmCategories.has(category)) return 'confirm';
if (!policy) return 'allow';
// Explicit allow list
if (policy.allow?.includes(category)) return 'allow';
return policy.default;
}
export function describeAction(action: string, command: Record<string, unknown>): string {
const category = getActionCategory(action);
switch (action) {
case 'navigate':
return `Navigate to ${command.url}`;
case 'evaluate':
case 'evalhandle':
return `Evaluate JavaScript: ${String(command.script ?? '').slice(0, 80)}`;
case 'fill':
return `Fill ${command.selector}`;
case 'type':
return `Type into ${command.selector}`;
case 'click':
return `Click ${command.selector}`;
case 'dblclick':
return `Double-click ${command.selector}`;
case 'tap':
return `Tap ${command.selector}`;
case 'download':
return `Download via ${command.selector} to ${command.path}`;
case 'upload':
return `Upload files to ${command.selector}`;
default:
return `${category}: ${action}`;
}
}