Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Command } from 'commander';
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction } from './cli';
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction } from './cli';
import { redactSecrets } from './redact-secrets';
import * as fs from 'fs';
import * as path from 'path';
Expand Down Expand Up @@ -48,14 +48,14 @@

afterEach(() => {
// Clean up the test directory
if (fs.existsSync(testDir)) {

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('should parse domains from file with one domain per line', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com\nnpmjs.org');

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -64,7 +64,7 @@

it('should parse comma-separated domains from file', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com, npmjs.org');

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -73,7 +73,7 @@

it('should handle mixed formats (lines and commas)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com, npmjs.org\nexample.com');

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -82,7 +82,7 @@

it('should skip empty lines', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n\n\napi.github.com\n\nnpmjs.org');

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -91,7 +91,7 @@

it('should skip lines with only whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n \n\t\napi.github.com');

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -100,7 +100,7 @@

it('should skip comments starting with #', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# This is a comment\ngithub.com\n# Another comment\napi.github.com');

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -109,7 +109,7 @@

it('should handle inline comments (after domain)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com # GitHub main domain\napi.github.com # API endpoint');

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -118,7 +118,7 @@

it('should handle domains with inline comments in comma-separated format', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com # GitHub domains\nnpmjs.org');

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -133,7 +133,7 @@

it('should return empty array for file with only comments and whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# Comment 1\n\n# Comment 2\n \n');

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand Down Expand Up @@ -1536,6 +1536,20 @@
});
});

describe('hasRateLimitOptions', () => {
it('should return false when no rate limit options set', () => {
expect(hasRateLimitOptions({})).toBe(false);
});

it('should return true when rateLimitRpm is set', () => {
expect(hasRateLimitOptions({ rateLimitRpm: '30' })).toBe(true);
});

it('should return true when rateLimit is explicitly false (--no-rate-limit)', () => {
expect(hasRateLimitOptions({ rateLimit: false })).toBe(true);
});
});

describe('validateAllowHostPorts', () => {
it('should fail when --allow-host-ports is used without --enable-host-access', () => {
const result = validateAllowHostPorts('3000', undefined);
Expand Down Expand Up @@ -1962,6 +1976,41 @@
});
});

describe('collectRulesetFile', () => {
it('should accumulate multiple values into an array', () => {
let result = collectRulesetFile('a.yml');
result = collectRulesetFile('b.yml', result);
expect(result).toEqual(['a.yml', 'b.yml']);
});

it('should default to empty array when no previous values', () => {
const result = collectRulesetFile('first.yml');
expect(result).toEqual(['first.yml']);
});

it('should work with Commander option parsing', () => {
const testProgram = new Command();
testProgram
.option('--ruleset-file <path>', 'YAML rule file', collectRulesetFile, [])
.action(() => {});

testProgram.parse(['node', 'awf', '--ruleset-file', 'a.yml', '--ruleset-file', 'b.yml'], { from: 'node' });
const opts = testProgram.opts();
expect(opts.rulesetFile).toEqual(['a.yml', 'b.yml']);
});

it('should default to empty array when not provided', () => {
const testProgram = new Command();
testProgram
.option('--ruleset-file <path>', 'YAML rule file', collectRulesetFile, [])
.action(() => {});

testProgram.parse(['node', 'awf'], { from: 'node' });
const opts = testProgram.opts();
expect(opts.rulesetFile).toEqual([]);
});
});

describe('handlePredownloadAction', () => {
it('should delegate to predownloadCommand with correct options', async () => {
// Mock the predownload module that handlePredownloadAction dynamically imports
Expand Down
25 changes: 25 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { runMainWorkflow } from './cli-workflow';
import { redactSecrets } from './redact-secrets';
import { validateDomainOrPattern } from './domain-patterns';
import { loadAndMergeDomains } from './rules';
import { OutputFormat } from './types';
import { version } from '../package.json';

Expand Down Expand Up @@ -457,6 +458,14 @@ export interface FlagValidationResult {
* Checks if any rate limit options are set in the CLI options.
* Used to warn when rate limit flags are provided without --enable-api-proxy.
*/
/**
* Commander option accumulator for repeatable --ruleset-file flag.
* Collects multiple values into an array.
*/
export function collectRulesetFile(value: string, previous: string[] = []): string[] {
return [...previous, value];
}

export function hasRateLimitOptions(options: {
rateLimitRpm?: string;
rateLimitRph?: string;
Expand Down Expand Up @@ -911,6 +920,12 @@ program
'--allow-domains-file <path>',
'Path to file with allowed domains (one per line, supports # comments)'
)
.option(
'--ruleset-file <path>',
'YAML rule file for domain allowlisting (repeatable). Schema: version: 1, rules: [{domain, subdomains}]',
collectRulesetFile,
[]
)
.option(
'--block-domains <domains>',
'Comma-separated blocked domains (overrides allow list). Supports wildcards.'
Expand Down Expand Up @@ -1147,6 +1162,16 @@ program
}
}

// Merge domains from --ruleset-file YAML files
if (options.rulesetFile && Array.isArray(options.rulesetFile) && options.rulesetFile.length > 0) {
try {
allowedDomains = loadAndMergeDomains(options.rulesetFile, allowedDomains);
} catch (error) {
logger.error(`Failed to load ruleset file: ${error instanceof Error ? error.message : error}`);
process.exit(1);
}
}

// Log when no domains are specified (all network access will be blocked)
if (allowedDomains.length === 0) {
logger.debug('No allowed domains specified - all network access will be blocked');
Expand Down
259 changes: 259 additions & 0 deletions src/rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { loadRuleSet, mergeRuleSets, expandRule, loadAndMergeDomains, RuleSet } from './rules';

describe('rules', () => {
let testDir: string;

beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-rules-test-'));
});

afterEach(() => {
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});

function writeRuleFile(name: string, content: string): string {
const filePath = path.join(testDir, name);
fs.writeFileSync(filePath, content);
return filePath;
}

describe('loadRuleSet', () => {
it('should parse a valid YAML ruleset', () => {
const filePath = writeRuleFile('rules.yml', `
version: 1
rules:
- domain: github.com
subdomains: true
- domain: npmjs.org
subdomains: false
`);
const result = loadRuleSet(filePath);
expect(result.version).toBe(1);
expect(result.rules).toHaveLength(2);
expect(result.rules[0]).toEqual({ domain: 'github.com', subdomains: true });
expect(result.rules[1]).toEqual({ domain: 'npmjs.org', subdomains: false });
});

it('should default subdomains to true when not specified', () => {
const filePath = writeRuleFile('rules.yml', `
version: 1
rules:
- domain: github.com
`);
const result = loadRuleSet(filePath);
expect(result.rules[0].subdomains).toBe(true);
});

it('should throw for missing file', () => {
expect(() => loadRuleSet('/nonexistent/rules.yml')).toThrow(
'Ruleset file not found: /nonexistent/rules.yml'
);
});

it('should throw for invalid YAML', () => {
const filePath = writeRuleFile('bad.yml', '{ invalid yaml: [}');
expect(() => loadRuleSet(filePath)).toThrow('Invalid YAML');
});

it('should throw for empty file', () => {
const filePath = writeRuleFile('empty.yml', '');
expect(() => loadRuleSet(filePath)).toThrow('is empty');
});

it('should throw for missing version field', () => {
const filePath = writeRuleFile('no-version.yml', `
rules:
- domain: github.com
`);
expect(() => loadRuleSet(filePath)).toThrow('missing required "version" field');
});

it('should throw for unsupported version', () => {
const filePath = writeRuleFile('bad-version.yml', `
version: 2
rules:
- domain: github.com
`);
expect(() => loadRuleSet(filePath)).toThrow('Unsupported ruleset version 2');
});

it('should throw for missing rules field', () => {
const filePath = writeRuleFile('no-rules.yml', `
version: 1
`);
expect(() => loadRuleSet(filePath)).toThrow('missing required "rules" field');
});

it('should throw for non-array rules', () => {
const filePath = writeRuleFile('bad-rules.yml', `
version: 1
rules: "not an array"
`);
expect(() => loadRuleSet(filePath)).toThrow('"rules" field in');
});

it('should throw for rule without domain', () => {
const filePath = writeRuleFile('no-domain.yml', `
version: 1
rules:
- subdomains: true
`);
expect(() => loadRuleSet(filePath)).toThrow('missing required "domain" string field');
});

it('should throw for rule with empty domain', () => {
const filePath = writeRuleFile('empty-domain.yml', `
version: 1
rules:
- domain: " "
`);
expect(() => loadRuleSet(filePath)).toThrow('empty "domain" field');
});

it('should throw for non-boolean subdomains', () => {
const filePath = writeRuleFile('bad-subdomains.yml', `
version: 1
rules:
- domain: github.com
subdomains: "yes"
`);
expect(() => loadRuleSet(filePath)).toThrow('"subdomains" must be a boolean');
});

it('should throw for non-object rule', () => {
const filePath = writeRuleFile('string-rule.yml', `
version: 1
rules:
- "github.com"
`);
expect(() => loadRuleSet(filePath)).toThrow('must be an object');
});

it('should throw for non-object top level', () => {
const filePath = writeRuleFile('array.yml', `
- github.com
- npmjs.org
`);
expect(() => loadRuleSet(filePath)).toThrow('must contain a YAML object');
});

it('should handle an empty rules array', () => {
const filePath = writeRuleFile('empty-rules.yml', `
version: 1
rules: []
`);
const result = loadRuleSet(filePath);
expect(result.rules).toHaveLength(0);
});
});

describe('expandRule', () => {
it('should return the domain for subdomains: true', () => {
expect(expandRule({ domain: 'github.com', subdomains: true })).toEqual([
'github.com',
]);
});

it('should return the domain for subdomains: false', () => {
expect(expandRule({ domain: 'github.com', subdomains: false })).toEqual([
'github.com',
]);
});
});

describe('mergeRuleSets', () => {
it('should merge multiple rulesets and deduplicate', () => {
const ruleSet1: RuleSet = {
version: 1,
rules: [
{ domain: 'github.com', subdomains: true },
{ domain: 'npmjs.org', subdomains: true },
],
};
const ruleSet2: RuleSet = {
version: 1,
rules: [
{ domain: 'github.com', subdomains: true }, // duplicate
{ domain: 'pypi.org', subdomains: true },
],
};

const result = mergeRuleSets([ruleSet1, ruleSet2]);
expect(result).toEqual(['github.com', 'npmjs.org', 'pypi.org']);
});

it('should handle empty rulesets', () => {
expect(mergeRuleSets([])).toEqual([]);
});

it('should handle rulesets with empty rules', () => {
const ruleSet: RuleSet = { version: 1, rules: [] };
expect(mergeRuleSets([ruleSet])).toEqual([]);
});
});

describe('loadAndMergeDomains', () => {
it('should merge file domains with CLI domains', () => {
const filePath = writeRuleFile('rules.yml', `
version: 1
rules:
- domain: github.com
- domain: npmjs.org
`);

const result = loadAndMergeDomains([filePath], ['api.example.com']);
expect(result).toContain('api.example.com');
expect(result).toContain('github.com');
expect(result).toContain('npmjs.org');
expect(result).toHaveLength(3);
});

it('should deduplicate across CLI and file domains', () => {
const filePath = writeRuleFile('rules.yml', `
version: 1
rules:
- domain: github.com
`);

const result = loadAndMergeDomains([filePath], ['github.com', 'npmjs.org']);
expect(result).toEqual(['github.com', 'npmjs.org']);
});

it('should merge multiple ruleset files', () => {
const file1 = writeRuleFile('rules1.yml', `
version: 1
rules:
- domain: github.com
`);
const file2 = writeRuleFile('rules2.yml', `
version: 1
rules:
- domain: npmjs.org
`);

const result = loadAndMergeDomains([file1, file2], []);
expect(result).toEqual(['github.com', 'npmjs.org']);
});

it('should work with no CLI domains', () => {
const filePath = writeRuleFile('rules.yml', `
version: 1
rules:
- domain: github.com
`);

const result = loadAndMergeDomains([filePath], []);
expect(result).toEqual(['github.com']);
});

it('should work with no ruleset files', () => {
const result = loadAndMergeDomains([], ['github.com']);
expect(result).toEqual(['github.com']);
});
});
});
Loading
Loading