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
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user