Skip to content

Commit 0fd834a

Browse files
Mossakaclaude
andauthored
feat(cli): add --ruleset-file for YAML domain rule configuration (#1279)
* feat(cli): add --ruleset-file for YAML domain rule configuration Adds support for YAML rule files via --ruleset-file flag. Rules define domain allowlists with optional subdomain matching. Multiple files can be specified and are merged with --allow-domains. Fixes #136 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add hasRateLimitOptions and --ruleset-file option tests Add test coverage for hasRateLimitOptions function and --ruleset-file Commander option accumulator to restore Functions coverage metric. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add collectRulesetFile tests to fix coverage drop Extract inline accumulator into named collectRulesetFile function and add direct unit tests to restore Functions coverage metric. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b551d43 commit 0fd834a

File tree

4 files changed

+524
-1
lines changed

4 files changed

+524
-1
lines changed

src/cli.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
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';
2+
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';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -1536,6 +1536,20 @@ describe('cli', () => {
15361536
});
15371537
});
15381538

1539+
describe('hasRateLimitOptions', () => {
1540+
it('should return false when no rate limit options set', () => {
1541+
expect(hasRateLimitOptions({})).toBe(false);
1542+
});
1543+
1544+
it('should return true when rateLimitRpm is set', () => {
1545+
expect(hasRateLimitOptions({ rateLimitRpm: '30' })).toBe(true);
1546+
});
1547+
1548+
it('should return true when rateLimit is explicitly false (--no-rate-limit)', () => {
1549+
expect(hasRateLimitOptions({ rateLimit: false })).toBe(true);
1550+
});
1551+
});
1552+
15391553
describe('validateAllowHostPorts', () => {
15401554
it('should fail when --allow-host-ports is used without --enable-host-access', () => {
15411555
const result = validateAllowHostPorts('3000', undefined);
@@ -1962,6 +1976,41 @@ describe('cli', () => {
19621976
});
19631977
});
19641978

1979+
describe('collectRulesetFile', () => {
1980+
it('should accumulate multiple values into an array', () => {
1981+
let result = collectRulesetFile('a.yml');
1982+
result = collectRulesetFile('b.yml', result);
1983+
expect(result).toEqual(['a.yml', 'b.yml']);
1984+
});
1985+
1986+
it('should default to empty array when no previous values', () => {
1987+
const result = collectRulesetFile('first.yml');
1988+
expect(result).toEqual(['first.yml']);
1989+
});
1990+
1991+
it('should work with Commander option parsing', () => {
1992+
const testProgram = new Command();
1993+
testProgram
1994+
.option('--ruleset-file <path>', 'YAML rule file', collectRulesetFile, [])
1995+
.action(() => {});
1996+
1997+
testProgram.parse(['node', 'awf', '--ruleset-file', 'a.yml', '--ruleset-file', 'b.yml'], { from: 'node' });
1998+
const opts = testProgram.opts();
1999+
expect(opts.rulesetFile).toEqual(['a.yml', 'b.yml']);
2000+
});
2001+
2002+
it('should default to empty array when not provided', () => {
2003+
const testProgram = new Command();
2004+
testProgram
2005+
.option('--ruleset-file <path>', 'YAML rule file', collectRulesetFile, [])
2006+
.action(() => {});
2007+
2008+
testProgram.parse(['node', 'awf'], { from: 'node' });
2009+
const opts = testProgram.opts();
2010+
expect(opts.rulesetFile).toEqual([]);
2011+
});
2012+
});
2013+
19652014
describe('handlePredownloadAction', () => {
19662015
it('should delegate to predownloadCommand with correct options', async () => {
19672016
// Mock the predownload module that handlePredownloadAction dynamically imports

src/cli.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { runMainWorkflow } from './cli-workflow';
2323
import { redactSecrets } from './redact-secrets';
2424
import { validateDomainOrPattern } from './domain-patterns';
25+
import { loadAndMergeDomains } from './rules';
2526
import { OutputFormat } from './types';
2627
import { version } from '../package.json';
2728

@@ -457,6 +458,14 @@ export interface FlagValidationResult {
457458
* Checks if any rate limit options are set in the CLI options.
458459
* Used to warn when rate limit flags are provided without --enable-api-proxy.
459460
*/
461+
/**
462+
* Commander option accumulator for repeatable --ruleset-file flag.
463+
* Collects multiple values into an array.
464+
*/
465+
export function collectRulesetFile(value: string, previous: string[] = []): string[] {
466+
return [...previous, value];
467+
}
468+
460469
export function hasRateLimitOptions(options: {
461470
rateLimitRpm?: string;
462471
rateLimitRph?: string;
@@ -911,6 +920,12 @@ program
911920
'--allow-domains-file <path>',
912921
'Path to file with allowed domains (one per line, supports # comments)'
913922
)
923+
.option(
924+
'--ruleset-file <path>',
925+
'YAML rule file for domain allowlisting (repeatable). Schema: version: 1, rules: [{domain, subdomains}]',
926+
collectRulesetFile,
927+
[]
928+
)
914929
.option(
915930
'--block-domains <domains>',
916931
'Comma-separated blocked domains (overrides allow list). Supports wildcards.'
@@ -1147,6 +1162,16 @@ program
11471162
}
11481163
}
11491164

1165+
// Merge domains from --ruleset-file YAML files
1166+
if (options.rulesetFile && Array.isArray(options.rulesetFile) && options.rulesetFile.length > 0) {
1167+
try {
1168+
allowedDomains = loadAndMergeDomains(options.rulesetFile, allowedDomains);
1169+
} catch (error) {
1170+
logger.error(`Failed to load ruleset file: ${error instanceof Error ? error.message : error}`);
1171+
process.exit(1);
1172+
}
1173+
}
1174+
11501175
// Log when no domains are specified (all network access will be blocked)
11511176
if (allowedDomains.length === 0) {
11521177
logger.debug('No allowed domains specified - all network access will be blocked');

src/rules.test.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as os from 'os';
4+
import { loadRuleSet, mergeRuleSets, expandRule, loadAndMergeDomains, RuleSet } from './rules';
5+
6+
describe('rules', () => {
7+
let testDir: string;
8+
9+
beforeEach(() => {
10+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-rules-test-'));
11+
});
12+
13+
afterEach(() => {
14+
if (fs.existsSync(testDir)) {
15+
fs.rmSync(testDir, { recursive: true, force: true });
16+
}
17+
});
18+
19+
function writeRuleFile(name: string, content: string): string {
20+
const filePath = path.join(testDir, name);
21+
fs.writeFileSync(filePath, content);
22+
return filePath;
23+
}
24+
25+
describe('loadRuleSet', () => {
26+
it('should parse a valid YAML ruleset', () => {
27+
const filePath = writeRuleFile('rules.yml', `
28+
version: 1
29+
rules:
30+
- domain: github.com
31+
subdomains: true
32+
- domain: npmjs.org
33+
subdomains: false
34+
`);
35+
const result = loadRuleSet(filePath);
36+
expect(result.version).toBe(1);
37+
expect(result.rules).toHaveLength(2);
38+
expect(result.rules[0]).toEqual({ domain: 'github.com', subdomains: true });
39+
expect(result.rules[1]).toEqual({ domain: 'npmjs.org', subdomains: false });
40+
});
41+
42+
it('should default subdomains to true when not specified', () => {
43+
const filePath = writeRuleFile('rules.yml', `
44+
version: 1
45+
rules:
46+
- domain: github.com
47+
`);
48+
const result = loadRuleSet(filePath);
49+
expect(result.rules[0].subdomains).toBe(true);
50+
});
51+
52+
it('should throw for missing file', () => {
53+
expect(() => loadRuleSet('/nonexistent/rules.yml')).toThrow(
54+
'Ruleset file not found: /nonexistent/rules.yml'
55+
);
56+
});
57+
58+
it('should throw for invalid YAML', () => {
59+
const filePath = writeRuleFile('bad.yml', '{ invalid yaml: [}');
60+
expect(() => loadRuleSet(filePath)).toThrow('Invalid YAML');
61+
});
62+
63+
it('should throw for empty file', () => {
64+
const filePath = writeRuleFile('empty.yml', '');
65+
expect(() => loadRuleSet(filePath)).toThrow('is empty');
66+
});
67+
68+
it('should throw for missing version field', () => {
69+
const filePath = writeRuleFile('no-version.yml', `
70+
rules:
71+
- domain: github.com
72+
`);
73+
expect(() => loadRuleSet(filePath)).toThrow('missing required "version" field');
74+
});
75+
76+
it('should throw for unsupported version', () => {
77+
const filePath = writeRuleFile('bad-version.yml', `
78+
version: 2
79+
rules:
80+
- domain: github.com
81+
`);
82+
expect(() => loadRuleSet(filePath)).toThrow('Unsupported ruleset version 2');
83+
});
84+
85+
it('should throw for missing rules field', () => {
86+
const filePath = writeRuleFile('no-rules.yml', `
87+
version: 1
88+
`);
89+
expect(() => loadRuleSet(filePath)).toThrow('missing required "rules" field');
90+
});
91+
92+
it('should throw for non-array rules', () => {
93+
const filePath = writeRuleFile('bad-rules.yml', `
94+
version: 1
95+
rules: "not an array"
96+
`);
97+
expect(() => loadRuleSet(filePath)).toThrow('"rules" field in');
98+
});
99+
100+
it('should throw for rule without domain', () => {
101+
const filePath = writeRuleFile('no-domain.yml', `
102+
version: 1
103+
rules:
104+
- subdomains: true
105+
`);
106+
expect(() => loadRuleSet(filePath)).toThrow('missing required "domain" string field');
107+
});
108+
109+
it('should throw for rule with empty domain', () => {
110+
const filePath = writeRuleFile('empty-domain.yml', `
111+
version: 1
112+
rules:
113+
- domain: " "
114+
`);
115+
expect(() => loadRuleSet(filePath)).toThrow('empty "domain" field');
116+
});
117+
118+
it('should throw for non-boolean subdomains', () => {
119+
const filePath = writeRuleFile('bad-subdomains.yml', `
120+
version: 1
121+
rules:
122+
- domain: github.com
123+
subdomains: "yes"
124+
`);
125+
expect(() => loadRuleSet(filePath)).toThrow('"subdomains" must be a boolean');
126+
});
127+
128+
it('should throw for non-object rule', () => {
129+
const filePath = writeRuleFile('string-rule.yml', `
130+
version: 1
131+
rules:
132+
- "github.com"
133+
`);
134+
expect(() => loadRuleSet(filePath)).toThrow('must be an object');
135+
});
136+
137+
it('should throw for non-object top level', () => {
138+
const filePath = writeRuleFile('array.yml', `
139+
- github.com
140+
- npmjs.org
141+
`);
142+
expect(() => loadRuleSet(filePath)).toThrow('must contain a YAML object');
143+
});
144+
145+
it('should handle an empty rules array', () => {
146+
const filePath = writeRuleFile('empty-rules.yml', `
147+
version: 1
148+
rules: []
149+
`);
150+
const result = loadRuleSet(filePath);
151+
expect(result.rules).toHaveLength(0);
152+
});
153+
});
154+
155+
describe('expandRule', () => {
156+
it('should return the domain for subdomains: true', () => {
157+
expect(expandRule({ domain: 'github.com', subdomains: true })).toEqual([
158+
'github.com',
159+
]);
160+
});
161+
162+
it('should return the domain for subdomains: false', () => {
163+
expect(expandRule({ domain: 'github.com', subdomains: false })).toEqual([
164+
'github.com',
165+
]);
166+
});
167+
});
168+
169+
describe('mergeRuleSets', () => {
170+
it('should merge multiple rulesets and deduplicate', () => {
171+
const ruleSet1: RuleSet = {
172+
version: 1,
173+
rules: [
174+
{ domain: 'github.com', subdomains: true },
175+
{ domain: 'npmjs.org', subdomains: true },
176+
],
177+
};
178+
const ruleSet2: RuleSet = {
179+
version: 1,
180+
rules: [
181+
{ domain: 'github.com', subdomains: true }, // duplicate
182+
{ domain: 'pypi.org', subdomains: true },
183+
],
184+
};
185+
186+
const result = mergeRuleSets([ruleSet1, ruleSet2]);
187+
expect(result).toEqual(['github.com', 'npmjs.org', 'pypi.org']);
188+
});
189+
190+
it('should handle empty rulesets', () => {
191+
expect(mergeRuleSets([])).toEqual([]);
192+
});
193+
194+
it('should handle rulesets with empty rules', () => {
195+
const ruleSet: RuleSet = { version: 1, rules: [] };
196+
expect(mergeRuleSets([ruleSet])).toEqual([]);
197+
});
198+
});
199+
200+
describe('loadAndMergeDomains', () => {
201+
it('should merge file domains with CLI domains', () => {
202+
const filePath = writeRuleFile('rules.yml', `
203+
version: 1
204+
rules:
205+
- domain: github.com
206+
- domain: npmjs.org
207+
`);
208+
209+
const result = loadAndMergeDomains([filePath], ['api.example.com']);
210+
expect(result).toContain('api.example.com');
211+
expect(result).toContain('github.com');
212+
expect(result).toContain('npmjs.org');
213+
expect(result).toHaveLength(3);
214+
});
215+
216+
it('should deduplicate across CLI and file domains', () => {
217+
const filePath = writeRuleFile('rules.yml', `
218+
version: 1
219+
rules:
220+
- domain: github.com
221+
`);
222+
223+
const result = loadAndMergeDomains([filePath], ['github.com', 'npmjs.org']);
224+
expect(result).toEqual(['github.com', 'npmjs.org']);
225+
});
226+
227+
it('should merge multiple ruleset files', () => {
228+
const file1 = writeRuleFile('rules1.yml', `
229+
version: 1
230+
rules:
231+
- domain: github.com
232+
`);
233+
const file2 = writeRuleFile('rules2.yml', `
234+
version: 1
235+
rules:
236+
- domain: npmjs.org
237+
`);
238+
239+
const result = loadAndMergeDomains([file1, file2], []);
240+
expect(result).toEqual(['github.com', 'npmjs.org']);
241+
});
242+
243+
it('should work with no CLI domains', () => {
244+
const filePath = writeRuleFile('rules.yml', `
245+
version: 1
246+
rules:
247+
- domain: github.com
248+
`);
249+
250+
const result = loadAndMergeDomains([filePath], []);
251+
expect(result).toEqual(['github.com']);
252+
});
253+
254+
it('should work with no ruleset files', () => {
255+
const result = loadAndMergeDomains([], ['github.com']);
256+
expect(result).toEqual(['github.com']);
257+
});
258+
});
259+
});

0 commit comments

Comments
 (0)