Skip to content

Commit 4b1d778

Browse files
CopilotMossaka
andauthored
Require -- separator for passing command arguments (#42)
* Initial plan * Add support for -- separator to pass command arguments Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * Add proper shell argument escaping to preserve argument boundaries Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * Address code review comments: improve regex escaping and use ES6 imports Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * Remove legacy quoted syntax, require -- separator for all commands 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 c0ffe8e commit 4b1d778

File tree

3 files changed

+125
-9
lines changed

3 files changed

+125
-9
lines changed

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,28 @@ sudo awf --help
3737
# Simple HTTP request
3838
sudo awf \
3939
--allow-domains github.com,api.github.com \
40-
'curl https://api.github.com'
40+
-- curl https://api.github.com
4141

4242
# With GitHub Copilot CLI
4343
sudo -E awf \
4444
--allow-domains github.com,api.github.com,googleapis.com \
45-
'copilot --prompt "List my repositories"'
45+
-- copilot --prompt "List my repositories"
4646

4747
# Docker-in-Docker (spawned containers inherit firewall)
4848
sudo awf \
4949
--allow-domains api.github.com,registry-1.docker.io,auth.docker.io \
50-
'docker run --rm curlimages/curl -fsS https://api.github.com/zen'
50+
-- docker run --rm curlimages/curl -fsS https://api.github.com/zen
5151
```
5252

53+
**Note:** Always use the `--` separator to pass commands and arguments. This ensures proper argument handling and avoids shell escaping issues.
54+
5355
## Domain Whitelisting
5456

5557
Domains automatically match all subdomains:
5658

5759
```bash
5860
# github.com matches api.github.com, raw.githubusercontent.com, etc.
59-
sudo awf --allow-domains github.com "curl https://api.github.com" # ✓ works
61+
sudo awf --allow-domains github.com -- curl https://api.github.com # ✓ works
6062
```
6163

6264
Common domain lists:

src/cli.test.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Command } from 'commander';
2-
import { parseEnvironmentVariables } from './cli';
2+
import { parseEnvironmentVariables, parseDomains, escapeShellArg, joinShellArgs } from './cli';
33
import { redactSecrets } from './redact-secrets';
4-
import { parseDomains } from './cli';
54

65
describe('cli', () => {
76
describe('domain parsing', () => {
@@ -212,7 +211,7 @@ describe('cli', () => {
212211
)
213212
.option('--log-level <level>', 'Log level: debug, info, warn, error', 'info')
214213
.option('--keep-containers', 'Keep containers running after command exits', false)
215-
.argument('<command>', 'Copilot command to execute');
214+
.argument('[args...]', 'Command and arguments to execute');
216215

217216
expect(program.name()).toBe('awf');
218217
expect(program.description()).toBe('Network firewall for agentic workflows with domain whitelisting');
@@ -238,6 +237,86 @@ describe('cli', () => {
238237
});
239238
});
240239

240+
describe('argument parsing with variadic args', () => {
241+
it('should handle multiple arguments after -- separator', () => {
242+
const program = new Command();
243+
let capturedArgs: string[] = [];
244+
245+
program
246+
.argument('[args...]', 'Command and arguments')
247+
.action((args: string[]) => {
248+
capturedArgs = args;
249+
});
250+
251+
program.parse(['node', 'awf', '--', 'curl', 'https://api.github.com']);
252+
253+
expect(capturedArgs).toEqual(['curl', 'https://api.github.com']);
254+
});
255+
256+
it('should handle arguments with flags after -- separator', () => {
257+
const program = new Command();
258+
let capturedArgs: string[] = [];
259+
260+
program
261+
.argument('[args...]', 'Command and arguments')
262+
.action((args: string[]) => {
263+
capturedArgs = args;
264+
});
265+
266+
program.parse(['node', 'awf', '--', 'curl', '-H', 'Authorization: Bearer token', 'https://api.github.com']);
267+
268+
expect(capturedArgs).toEqual(['curl', '-H', 'Authorization: Bearer token', 'https://api.github.com']);
269+
});
270+
271+
it('should handle complex command with multiple flags', () => {
272+
const program = new Command();
273+
let capturedArgs: string[] = [];
274+
275+
program
276+
.argument('[args...]', 'Command and arguments')
277+
.action((args: string[]) => {
278+
capturedArgs = args;
279+
});
280+
281+
program.parse(['node', 'awf', '--', 'npx', '@github/copilot', '--prompt', 'hello world', '--log-level', 'debug']);
282+
283+
expect(capturedArgs).toEqual(['npx', '@github/copilot', '--prompt', 'hello world', '--log-level', 'debug']);
284+
});
285+
});
286+
287+
describe('shell argument escaping', () => {
288+
it('should not escape simple arguments', () => {
289+
expect(escapeShellArg('curl')).toBe('curl');
290+
expect(escapeShellArg('https://api.github.com')).toBe('https://api.github.com');
291+
expect(escapeShellArg('/usr/bin/node')).toBe('/usr/bin/node');
292+
expect(escapeShellArg('--log-level=debug')).toBe('--log-level=debug');
293+
});
294+
295+
it('should escape arguments with spaces', () => {
296+
expect(escapeShellArg('hello world')).toBe("'hello world'");
297+
expect(escapeShellArg('Authorization: Bearer token')).toBe("'Authorization: Bearer token'");
298+
});
299+
300+
it('should escape arguments with special characters', () => {
301+
expect(escapeShellArg('test$var')).toBe("'test$var'");
302+
expect(escapeShellArg('test`cmd`')).toBe("'test`cmd`'");
303+
expect(escapeShellArg('test;echo')).toBe("'test;echo'");
304+
});
305+
306+
it('should escape single quotes in arguments', () => {
307+
expect(escapeShellArg("it's")).toBe("'it'\\''s'");
308+
expect(escapeShellArg("don't")).toBe("'don'\\''t'");
309+
});
310+
311+
it('should join multiple arguments with proper escaping', () => {
312+
expect(joinShellArgs(['curl', 'https://api.github.com'])).toBe('curl https://api.github.com');
313+
expect(joinShellArgs(['curl', '-H', 'Authorization: Bearer token', 'https://api.github.com']))
314+
.toBe("curl -H 'Authorization: Bearer token' https://api.github.com");
315+
expect(joinShellArgs(['echo', 'hello world', 'test']))
316+
.toBe("echo 'hello world' test");
317+
});
318+
});
319+
241320
describe('work directory generation', () => {
242321
it('should generate unique work directories', () => {
243322
const dir1 = `/tmp/awf-${Date.now()}`;

src/cli.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,32 @@ export function parseDomains(input: string): string[] {
3232
.filter(d => d.length > 0);
3333
}
3434

35+
/**
36+
* Escapes a shell argument by wrapping it in single quotes and escaping any single quotes within it
37+
* @param arg - Argument to escape
38+
* @returns Escaped argument safe for shell execution
39+
*/
40+
export function escapeShellArg(arg: string): string {
41+
// If the argument doesn't contain special characters, return as-is
42+
// Character class includes: letters, digits, underscore, dash, dot (literal), slash, equals, colon
43+
if (/^[a-zA-Z0-9_\-\./=:]+$/.test(arg)) {
44+
return arg;
45+
}
46+
// Otherwise, wrap in single quotes and escape any single quotes inside
47+
// The pattern '\\'' works by: ending the single-quoted string ('),
48+
// adding an escaped single quote (\'), then starting a new single-quoted string (')
49+
return `'${arg.replace(/'/g, "'\\''")}'`;
50+
}
51+
52+
/**
53+
* Joins an array of shell arguments into a single command string, properly escaping each argument
54+
* @param args - Array of arguments
55+
* @returns Command string with properly escaped arguments
56+
*/
57+
export function joinShellArgs(args: string[]): string {
58+
return args.map(escapeShellArg).join(' ');
59+
}
60+
3561
/**
3662
* Result of parsing environment variables
3763
*/
@@ -116,8 +142,17 @@ program
116142
'Pass all host environment variables to container (excludes system vars like PATH, DOCKER_HOST)',
117143
false
118144
)
119-
.argument('<command>', 'Copilot command to execute (wrap in quotes)')
120-
.action(async (copilotCommand: string, options) => {
145+
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
146+
.action(async (args: string[], options) => {
147+
// Require -- separator for passing command arguments
148+
if (args.length === 0) {
149+
console.error('Error: No command specified. Use -- to separate command from options.');
150+
console.error('Example: awf --allow-domains github.com -- curl https://api.github.com');
151+
process.exit(1);
152+
}
153+
154+
// Join arguments with proper shell escaping to preserve argument boundaries
155+
const copilotCommand = joinShellArgs(args);
121156
// Parse and validate options
122157
const logLevel = options.logLevel as LogLevel;
123158
if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) {

0 commit comments

Comments
 (0)