* 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
298 lines
7.8 KiB
TypeScript
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}`;
|
|
}
|
|
}
|