Skip to content

Commit 15c4bbd

Browse files
CopilotMossaka
andauthored
Add --allow-domains-file flag for file-based domain whitelisting (#48)
* Initial plan * Add --allow-domains-file flag to load domains from file Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * Add documentation and example file for --allow-domains-file flag Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com>
1 parent 3358b27 commit 15c4bbd

File tree

4 files changed

+252
-4
lines changed

4 files changed

+252
-4
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Domains automatically match all subdomains:
6161
sudo awf --allow-domains github.com -- curl https://api.github.com # ✓ works
6262
```
6363

64+
### Using Command-Line Flag
65+
6466
Common domain lists:
6567

6668
```bash
@@ -71,6 +73,43 @@ Common domain lists:
7173
--allow-domains github.com,arxiv.org,example.com
7274
```
7375

76+
### Using a Domains File
77+
78+
You can also specify domains in a file using `--allow-domains-file`:
79+
80+
```bash
81+
# Create a domains file (see examples/domains.txt)
82+
cat > allowed-domains.txt << 'EOF'
83+
# GitHub domains
84+
github.com
85+
api.github.com
86+
87+
# NPM registry
88+
npmjs.org, registry.npmjs.org
89+
90+
# Example with inline comment
91+
example.com # Example domain
92+
EOF
93+
94+
# Use the domains file
95+
sudo awf --allow-domains-file allowed-domains.txt -- curl https://api.github.com
96+
```
97+
98+
**File format:**
99+
- One domain per line or comma-separated
100+
- Comments start with `#` (full line or inline)
101+
- Empty lines are ignored
102+
- Whitespace is trimmed
103+
104+
**Combining both methods:**
105+
```bash
106+
# You can use both flags together - domains are merged
107+
sudo awf \
108+
--allow-domains github.com \
109+
--allow-domains-file my-domains.txt \
110+
-- curl https://api.github.com
111+
```
112+
74113

75114
## Security Considerations
76115

examples/domains.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Example domains file for awf (Agentic Workflow Firewall)
2+
#
3+
# This file demonstrates the supported formats for specifying allowed domains.
4+
# You can use:
5+
# - One domain per line
6+
# - Comma-separated domains on the same line
7+
# - Comments starting with # (full line or inline)
8+
# - Empty lines (they will be ignored)
9+
10+
# GitHub domains
11+
github.com
12+
api.github.com
13+
raw.githubusercontent.com
14+
15+
# Google services (comma-separated)
16+
googleapis.com, google.com
17+
18+
# NPM registry
19+
registry.npmjs.org # NPM package registry
20+
21+
# Example domain
22+
example.com

src/cli.test.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
import { parseEnvironmentVariables, parseDomains, escapeShellArg, joinShellArgs, parseVolumeMounts } from './cli';
2+
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts } from './cli';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -38,6 +38,127 @@ describe('cli', () => {
3838
});
3939
});
4040

41+
describe('domain file parsing', () => {
42+
let testDir: string;
43+
44+
beforeEach(() => {
45+
// Create a temporary directory for testing
46+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-'));
47+
});
48+
49+
afterEach(() => {
50+
// Clean up the test directory
51+
if (fs.existsSync(testDir)) {
52+
fs.rmSync(testDir, { recursive: true, force: true });
53+
}
54+
});
55+
56+
it('should parse domains from file with one domain per line', () => {
57+
const filePath = path.join(testDir, 'domains.txt');
58+
fs.writeFileSync(filePath, 'github.com\napi.github.com\nnpmjs.org');
59+
60+
const result = parseDomainsFile(filePath);
61+
62+
expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']);
63+
});
64+
65+
it('should parse comma-separated domains from file', () => {
66+
const filePath = path.join(testDir, 'domains.txt');
67+
fs.writeFileSync(filePath, 'github.com, api.github.com, npmjs.org');
68+
69+
const result = parseDomainsFile(filePath);
70+
71+
expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']);
72+
});
73+
74+
it('should handle mixed formats (lines and commas)', () => {
75+
const filePath = path.join(testDir, 'domains.txt');
76+
fs.writeFileSync(filePath, 'github.com\napi.github.com, npmjs.org\nexample.com');
77+
78+
const result = parseDomainsFile(filePath);
79+
80+
expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org', 'example.com']);
81+
});
82+
83+
it('should skip empty lines', () => {
84+
const filePath = path.join(testDir, 'domains.txt');
85+
fs.writeFileSync(filePath, 'github.com\n\n\napi.github.com\n\nnpmjs.org');
86+
87+
const result = parseDomainsFile(filePath);
88+
89+
expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']);
90+
});
91+
92+
it('should skip lines with only whitespace', () => {
93+
const filePath = path.join(testDir, 'domains.txt');
94+
fs.writeFileSync(filePath, 'github.com\n \n\t\napi.github.com');
95+
96+
const result = parseDomainsFile(filePath);
97+
98+
expect(result).toEqual(['github.com', 'api.github.com']);
99+
});
100+
101+
it('should skip comments starting with #', () => {
102+
const filePath = path.join(testDir, 'domains.txt');
103+
fs.writeFileSync(filePath, '# This is a comment\ngithub.com\n# Another comment\napi.github.com');
104+
105+
const result = parseDomainsFile(filePath);
106+
107+
expect(result).toEqual(['github.com', 'api.github.com']);
108+
});
109+
110+
it('should handle inline comments (after domain)', () => {
111+
const filePath = path.join(testDir, 'domains.txt');
112+
fs.writeFileSync(filePath, 'github.com # GitHub main domain\napi.github.com # API endpoint');
113+
114+
const result = parseDomainsFile(filePath);
115+
116+
expect(result).toEqual(['github.com', 'api.github.com']);
117+
});
118+
119+
it('should handle domains with inline comments in comma-separated format', () => {
120+
const filePath = path.join(testDir, 'domains.txt');
121+
fs.writeFileSync(filePath, 'github.com, api.github.com # GitHub domains\nnpmjs.org');
122+
123+
const result = parseDomainsFile(filePath);
124+
125+
expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']);
126+
});
127+
128+
it('should throw error if file does not exist', () => {
129+
const nonExistentPath = path.join(testDir, 'nonexistent.txt');
130+
131+
expect(() => parseDomainsFile(nonExistentPath)).toThrow('Domains file not found');
132+
});
133+
134+
it('should return empty array for file with only comments and whitespace', () => {
135+
const filePath = path.join(testDir, 'domains.txt');
136+
fs.writeFileSync(filePath, '# Comment 1\n\n# Comment 2\n \n');
137+
138+
const result = parseDomainsFile(filePath);
139+
140+
expect(result).toEqual([]);
141+
});
142+
143+
it('should handle file with Windows line endings (CRLF)', () => {
144+
const filePath = path.join(testDir, 'domains.txt');
145+
fs.writeFileSync(filePath, 'github.com\r\napi.github.com\r\nnpmjs.org');
146+
147+
const result = parseDomainsFile(filePath);
148+
149+
expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']);
150+
});
151+
152+
it('should trim whitespace from each domain', () => {
153+
const filePath = path.join(testDir, 'domains.txt');
154+
fs.writeFileSync(filePath, ' github.com \n api.github.com \n npmjs.org ');
155+
156+
const result = parseDomainsFile(filePath);
157+
158+
expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']);
159+
});
160+
});
161+
41162
describe('environment variable parsing', () => {
42163
it('should parse KEY=VALUE format correctly', () => {
43164
const envVars = ['GITHUB_TOKEN=abc123', 'API_KEY=xyz789'];

src/cli.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Command } from 'commander';
44
import * as path from 'path';
55
import * as os from 'os';
6+
import * as fs from 'fs';
67
import { WrapperConfig, LogLevel } from './types';
78
import { logger } from './logger';
89
import {
@@ -32,6 +33,46 @@ export function parseDomains(input: string): string[] {
3233
.filter(d => d.length > 0);
3334
}
3435

36+
/**
37+
* Parses domains from a file, supporting both line-separated and comma-separated formats
38+
* @param filePath - Path to file containing domains (one per line or comma-separated)
39+
* @returns Array of trimmed domain strings with empty entries and comments filtered out
40+
* @throws Error if file doesn't exist or can't be read
41+
*/
42+
export function parseDomainsFile(filePath: string): string[] {
43+
if (!fs.existsSync(filePath)) {
44+
throw new Error(`Domains file not found: ${filePath}`);
45+
}
46+
47+
const content = fs.readFileSync(filePath, 'utf-8');
48+
const domains: string[] = [];
49+
50+
// Split by lines first
51+
const lines = content.split('\n');
52+
53+
for (const line of lines) {
54+
// Remove comments (anything after #)
55+
const withoutComment = line.split('#')[0].trim();
56+
57+
// Skip empty lines
58+
if (withoutComment.length === 0) {
59+
continue;
60+
}
61+
62+
// Check if line contains commas (comma-separated format)
63+
if (withoutComment.includes(',')) {
64+
// Parse as comma-separated domains
65+
const commaSeparated = parseDomains(withoutComment);
66+
domains.push(...commaSeparated);
67+
} else {
68+
// Single domain per line
69+
domains.push(withoutComment);
70+
}
71+
}
72+
73+
return domains;
74+
}
75+
3576
/**
3677
* Escapes a shell argument by wrapping it in single quotes and escaping any single quotes within it
3778
* @param arg - Argument to escape
@@ -204,10 +245,14 @@ program
204245
.name('awf')
205246
.description('Network firewall for agentic workflows with domain whitelisting')
206247
.version('0.1.0')
207-
.requiredOption(
248+
.option(
208249
'--allow-domains <domains>',
209250
'Comma-separated list of allowed domains (e.g., github.com,api.github.com)'
210251
)
252+
.option(
253+
'--allow-domains-file <path>',
254+
'Path to file containing allowed domains (one per line or comma-separated, supports # comments)'
255+
)
211256
.option(
212257
'--log-level <level>',
213258
'Log level: debug, info, warn, error',
@@ -279,13 +324,34 @@ program
279324

280325
logger.setLevel(logLevel);
281326

282-
const allowedDomains = parseDomains(options.allowDomains);
327+
// Parse domains from both --allow-domains flag and --allow-domains-file
328+
let allowedDomains: string[] = [];
329+
330+
// Parse domains from command-line flag if provided
331+
if (options.allowDomains) {
332+
allowedDomains = parseDomains(options.allowDomains);
333+
}
283334

335+
// Parse domains from file if provided
336+
if (options.allowDomainsFile) {
337+
try {
338+
const fileDomainsArray = parseDomainsFile(options.allowDomainsFile);
339+
allowedDomains.push(...fileDomainsArray);
340+
} catch (error) {
341+
logger.error(`Failed to read domains file: ${error instanceof Error ? error.message : error}`);
342+
process.exit(1);
343+
}
344+
}
345+
346+
// Ensure at least one domain is specified
284347
if (allowedDomains.length === 0) {
285-
logger.error('At least one domain must be specified with --allow-domains');
348+
logger.error('At least one domain must be specified with --allow-domains or --allow-domains-file');
286349
process.exit(1);
287350
}
288351

352+
// Remove duplicates (in case domains appear in both sources)
353+
allowedDomains = [...new Set(allowedDomains)];
354+
289355
// Parse additional environment variables from --env flags
290356
let additionalEnv: Record<string, string> = {};
291357
if (options.env && Array.isArray(options.env)) {

0 commit comments

Comments
 (0)