* 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
214 lines
8.3 KiB
TypeScript
214 lines
8.3 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import {
|
|
getActionCategory,
|
|
checkPolicy,
|
|
loadPolicyFile,
|
|
describeAction,
|
|
KNOWN_CATEGORIES,
|
|
type ActionPolicy,
|
|
} from './action-policy.js';
|
|
|
|
describe('action-policy', () => {
|
|
describe('getActionCategory', () => {
|
|
it('should return correct category for known actions', () => {
|
|
expect(getActionCategory('navigate')).toBe('navigate');
|
|
expect(getActionCategory('click')).toBe('click');
|
|
expect(getActionCategory('fill')).toBe('fill');
|
|
expect(getActionCategory('evaluate')).toBe('eval');
|
|
expect(getActionCategory('download')).toBe('download');
|
|
expect(getActionCategory('upload')).toBe('upload');
|
|
expect(getActionCategory('snapshot')).toBe('snapshot');
|
|
expect(getActionCategory('scroll')).toBe('scroll');
|
|
expect(getActionCategory('wait')).toBe('wait');
|
|
expect(getActionCategory('gettext')).toBe('get');
|
|
expect(getActionCategory('route')).toBe('network');
|
|
expect(getActionCategory('state_save')).toBe('state');
|
|
expect(getActionCategory('hover')).toBe('interact');
|
|
});
|
|
|
|
it('should return _internal for internal actions', () => {
|
|
expect(getActionCategory('launch')).toBe('_internal');
|
|
expect(getActionCategory('close')).toBe('_internal');
|
|
expect(getActionCategory('session')).toBe('_internal');
|
|
expect(getActionCategory('auth_save')).toBe('_internal');
|
|
expect(getActionCategory('confirm')).toBe('_internal');
|
|
});
|
|
|
|
it('should return eval for security-sensitive actions', () => {
|
|
expect(getActionCategory('setcontent')).toBe('eval');
|
|
expect(getActionCategory('expose')).toBe('eval');
|
|
expect(getActionCategory('addstyle')).toBe('eval');
|
|
});
|
|
|
|
it('should return unknown for unrecognized actions', () => {
|
|
expect(getActionCategory('nonexistent')).toBe('unknown');
|
|
expect(getActionCategory('')).toBe('unknown');
|
|
});
|
|
|
|
it('should return get for semantic locator actions', () => {
|
|
expect(getActionCategory('getbyrole')).toBe('get');
|
|
expect(getActionCategory('getbytext')).toBe('get');
|
|
expect(getActionCategory('getbylabel')).toBe('get');
|
|
});
|
|
});
|
|
|
|
describe('checkPolicy', () => {
|
|
it('should always allow internal actions regardless of policy', () => {
|
|
const denyAll: ActionPolicy = { default: 'deny' };
|
|
expect(checkPolicy('launch', denyAll, new Set())).toBe('allow');
|
|
expect(checkPolicy('close', denyAll, new Set())).toBe('allow');
|
|
expect(checkPolicy('session', denyAll, new Set())).toBe('allow');
|
|
});
|
|
|
|
it('should allow all when no policy and no confirm categories', () => {
|
|
expect(checkPolicy('navigate', null, new Set())).toBe('allow');
|
|
expect(checkPolicy('click', null, new Set())).toBe('allow');
|
|
expect(checkPolicy('evaluate', null, new Set())).toBe('allow');
|
|
});
|
|
|
|
it('should deny actions in explicit deny list', () => {
|
|
const policy: ActionPolicy = { default: 'allow', deny: ['eval', 'download'] };
|
|
expect(checkPolicy('evaluate', policy, new Set())).toBe('deny');
|
|
expect(checkPolicy('download', policy, new Set())).toBe('deny');
|
|
expect(checkPolicy('click', policy, new Set())).toBe('allow');
|
|
});
|
|
|
|
it('should allow actions in explicit allow list with deny default', () => {
|
|
const policy: ActionPolicy = { default: 'deny', allow: ['navigate', 'snapshot'] };
|
|
expect(checkPolicy('navigate', policy, new Set())).toBe('allow');
|
|
expect(checkPolicy('snapshot', policy, new Set())).toBe('allow');
|
|
expect(checkPolicy('click', policy, new Set())).toBe('deny');
|
|
});
|
|
|
|
it('should return confirm for actions in confirm categories', () => {
|
|
expect(checkPolicy('evaluate', null, new Set(['eval']))).toBe('confirm');
|
|
expect(checkPolicy('download', null, new Set(['download']))).toBe('confirm');
|
|
});
|
|
|
|
it('should deny over confirm when action is in deny list', () => {
|
|
const policy: ActionPolicy = { default: 'allow', deny: ['eval'] };
|
|
expect(checkPolicy('evaluate', policy, new Set(['eval']))).toBe('deny');
|
|
});
|
|
|
|
it('should use default policy for unknown categories', () => {
|
|
const denyPolicy: ActionPolicy = { default: 'deny' };
|
|
const allowPolicy: ActionPolicy = { default: 'allow' };
|
|
expect(checkPolicy('nonexistent', denyPolicy, new Set())).toBe('deny');
|
|
expect(checkPolicy('nonexistent', allowPolicy, new Set())).toBe('allow');
|
|
});
|
|
});
|
|
|
|
describe('loadPolicyFile', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'action-policy-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load a valid allow-default policy', () => {
|
|
const policyPath = path.join(tempDir, 'policy.json');
|
|
fs.writeFileSync(policyPath, JSON.stringify({ default: 'allow', deny: ['eval'] }));
|
|
const policy = loadPolicyFile(policyPath);
|
|
expect(policy.default).toBe('allow');
|
|
expect(policy.deny).toEqual(['eval']);
|
|
});
|
|
|
|
it('should load a valid deny-default policy', () => {
|
|
const policyPath = path.join(tempDir, 'policy.json');
|
|
fs.writeFileSync(
|
|
policyPath,
|
|
JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot'] })
|
|
);
|
|
const policy = loadPolicyFile(policyPath);
|
|
expect(policy.default).toBe('deny');
|
|
expect(policy.allow).toEqual(['navigate', 'snapshot']);
|
|
});
|
|
|
|
it('should throw on invalid default value', () => {
|
|
const policyPath = path.join(tempDir, 'policy.json');
|
|
fs.writeFileSync(policyPath, JSON.stringify({ default: 'maybe' }));
|
|
expect(() => loadPolicyFile(policyPath)).toThrow('must be "allow" or "deny"');
|
|
});
|
|
|
|
it('should throw on missing file', () => {
|
|
expect(() => loadPolicyFile(path.join(tempDir, 'missing.json'))).toThrow();
|
|
});
|
|
|
|
it('should throw on invalid JSON', () => {
|
|
const policyPath = path.join(tempDir, 'policy.json');
|
|
fs.writeFileSync(policyPath, 'not json');
|
|
expect(() => loadPolicyFile(policyPath)).toThrow();
|
|
});
|
|
|
|
it('should warn on unrecognized category names', () => {
|
|
const policyPath = path.join(tempDir, 'policy.json');
|
|
fs.writeFileSync(
|
|
policyPath,
|
|
JSON.stringify({ default: 'allow', deny: ['eval', 'typo_category'] })
|
|
);
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const policy = loadPolicyFile(policyPath);
|
|
expect(policy.default).toBe('allow');
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('unrecognized action category "typo_category"')
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('should not warn on valid category names', () => {
|
|
const policyPath = path.join(tempDir, 'policy.json');
|
|
fs.writeFileSync(
|
|
policyPath,
|
|
JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot', 'get'] })
|
|
);
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
loadPolicyFile(policyPath);
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('describeAction', () => {
|
|
it('should describe navigate actions', () => {
|
|
expect(describeAction('navigate', { url: 'https://example.com' })).toBe(
|
|
'Navigate to https://example.com'
|
|
);
|
|
});
|
|
|
|
it('should describe eval actions with truncation', () => {
|
|
const longScript = 'a'.repeat(200);
|
|
const desc = describeAction('evaluate', { script: longScript });
|
|
expect(desc).toContain('Evaluate JavaScript:');
|
|
expect(desc.length).toBeLessThan(200);
|
|
});
|
|
|
|
it('should describe click actions', () => {
|
|
expect(describeAction('click', { selector: '#btn' })).toBe('Click #btn');
|
|
});
|
|
|
|
it('should describe dblclick actions', () => {
|
|
expect(describeAction('dblclick', { selector: '#btn' })).toBe('Double-click #btn');
|
|
});
|
|
|
|
it('should describe tap actions', () => {
|
|
expect(describeAction('tap', { selector: '#btn' })).toBe('Tap #btn');
|
|
});
|
|
|
|
it('should describe fill actions', () => {
|
|
expect(describeAction('fill', { selector: '#input' })).toBe('Fill #input');
|
|
});
|
|
|
|
it('should use fallback for unknown actions', () => {
|
|
const desc = describeAction('scroll', {});
|
|
expect(desc).toContain('scroll');
|
|
});
|
|
});
|
|
});
|