Skip to content

Commit e7f41c3

Browse files
Mossakaclaude
andcommitted
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>
1 parent e8ba234 commit e7f41c3

3 files changed

Lines changed: 466 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 17 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

@@ -911,6 +912,12 @@ program
911912
'--allow-domains-file <path>',
912913
'Path to file with allowed domains (one per line, supports # comments)'
913914
)
915+
.option(
916+
'--ruleset-file <path>',
917+
'YAML rule file for domain allowlisting (repeatable). Schema: version: 1, rules: [{domain, subdomains}]',
918+
(value: string, previous: string[] = []) => [...previous, value],
919+
[]
920+
)
914921
.option(
915922
'--block-domains <domains>',
916923
'Comma-separated blocked domains (overrides allow list). Supports wildcards.'
@@ -1140,6 +1147,16 @@ program
11401147
}
11411148
}
11421149

1150+
// Merge domains from --ruleset-file YAML files
1151+
if (options.rulesetFile && Array.isArray(options.rulesetFile) && options.rulesetFile.length > 0) {
1152+
try {
1153+
allowedDomains = loadAndMergeDomains(options.rulesetFile, allowedDomains);
1154+
} catch (error) {
1155+
logger.error(`Failed to load ruleset file: ${error instanceof Error ? error.message : error}`);
1156+
process.exit(1);
1157+
}
1158+
}
1159+
11431160
// Log when no domains are specified (all network access will be blocked)
11441161
if (allowedDomains.length === 0) {
11451162
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)