Skip to content

Commit ccc2e9a

Browse files
committed
fix(wrap): bypass commander for argv parsing, give clear error on '--<cmd>'
Reproduces the user-reported bug: $ stackguard wrap --claude "test" ✓ stackguard: ok stackguard wrap: failed to spawn --claude: spawn --claude ENOENT Root cause: commander's option parser fights with pass-through args to a wrapped CLI. With allowUnknownOption(true), an invocation like 'stackguard wrap --claude "test"' (no '--' separator) gets '--claude' deposited into cmd.args[0], which the wrap code then blindly spawned. The check itself worked — only the spawn step was wrong. Fix: - Intercept 'wrap' at the top of index.ts BEFORE commander parses anything. Wrap parses its own argv tail via a new pure helper parseWrapArgs(argv) that handles --policy, --mode, --help, and the optional '--' separator. - The first non-option token starts the pass-through; everything from there forward is forwarded verbatim to the wrapped CLI, including option-looking flags like '--some-claude-flag'. - If the resolved command starts with '-', refuse it with a pointed error: "command must not start with '-' (got '--claude'). Did you forget the '--' separator? Try: stackguard wrap -- claude ..." - Both 'wrap claude "x"' and 'wrap -- claude "x"' now work; the former just for ergonomics. - The 'wrap' command is still registered with commander as a stub so it shows up in --help, but the action handler is unreachable. Adds 16 unit tests in tests/wrap.test.ts covering: clean parse, explicit separator, --policy/--mode positions, pass-through of unknown flags after the command, the bug-reproduction case, empty argv, missing --policy value, invalid --mode, and --help. Manually verified against the original failing input and against 'stackguard wrap --policy ./examples/policy.example.md -- echo hello'.
1 parent 18db369 commit ccc2e9a

3 files changed

Lines changed: 333 additions & 84 deletions

File tree

src/commands/wrap.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,111 @@ interface WrapOptions {
1313
mode?: string
1414
}
1515

16+
export interface ParsedWrapInvocation {
17+
policy?: string
18+
mode?: 'warn' | 'block'
19+
help: boolean
20+
args: string[]
21+
error?: string
22+
}
23+
24+
/**
25+
* Parse the argv tail that follows `stackguard wrap`. We do this ourselves
26+
* instead of letting commander handle it because commander's option parser
27+
* fights with pass-through arguments — `--claude` would be eaten as an
28+
* unknown option even with allowUnknownOption(true), and post-`--` args
29+
* land in inconsistent places across commander versions.
30+
*
31+
* Accepted shapes:
32+
* stackguard wrap claude "prompt"
33+
* stackguard wrap -- claude "prompt"
34+
* stackguard wrap --policy ./p.md -- claude "prompt"
35+
* stackguard wrap --mode block -- claude --some-flag "prompt"
36+
*
37+
* Returns an `error` string when the input is unusable.
38+
*/
39+
export function parseWrapArgs(argv: string[]): ParsedWrapInvocation {
40+
const result: ParsedWrapInvocation = { help: false, args: [] }
41+
let i = 0
42+
let sawSeparator = false
43+
44+
while (i < argv.length) {
45+
const a = argv[i]
46+
47+
if (sawSeparator) {
48+
result.args.push(a)
49+
i++
50+
continue
51+
}
52+
53+
if (a === '--') {
54+
sawSeparator = true
55+
i++
56+
continue
57+
}
58+
59+
if (a === '--help' || a === '-h') {
60+
result.help = true
61+
i++
62+
continue
63+
}
64+
65+
if (a === '--policy') {
66+
const next = argv[i + 1]
67+
if (!next || next.startsWith('-')) {
68+
result.error = '--policy requires a path argument'
69+
return result
70+
}
71+
result.policy = next
72+
i += 2
73+
continue
74+
}
75+
76+
if (a === '--mode') {
77+
const next = argv[i + 1]
78+
if (next !== 'warn' && next !== 'block') {
79+
result.error = "--mode must be 'warn' or 'block'"
80+
return result
81+
}
82+
result.mode = next
83+
i += 2
84+
continue
85+
}
86+
87+
// First non-option token is the start of the wrapped command. Everything
88+
// from here on is pass-through, even option-looking tokens.
89+
sawSeparator = true
90+
result.args.push(a)
91+
i++
92+
}
93+
94+
if (result.help) return result
95+
96+
if (result.args.length === 0) {
97+
result.error = 'no command specified — usage: stackguard wrap -- <command> [args...]'
98+
return result
99+
}
100+
101+
if (result.args[0].startsWith('-')) {
102+
result.error = `command must not start with '-' (got '${result.args[0]}'). Did you forget the '--' separator? Try: stackguard wrap -- ${result.args[0].replace(/^-+/, '')} ...`
103+
return result
104+
}
105+
106+
return result
107+
}
108+
109+
const WRAP_HELP = `Usage: stackguard wrap [--policy <path>] [--mode warn|block] -- <command> [args...]
110+
111+
Wrap an AI assistant CLI with stackguard checking. Everything after
112+
the '--' separator is forwarded to the wrapped command. The last
113+
non-flag argument is treated as the prompt to check.
114+
115+
Examples:
116+
stackguard wrap -- claude "add a database connection"
117+
stackguard wrap -- cursor agent "refactor this file"
118+
stackguard wrap --mode block -- claude "implement token signing"
119+
`
120+
16121
function findPromptArgIndex(args: string[]): number {
17122
for (let i = args.length - 1; i >= 0; i--) {
18123
const a = args[i]
@@ -56,15 +161,49 @@ function execChild(command: string, args: string[]): void {
56161
})
57162
}
58163

164+
/**
165+
* Top-level entry point used by index.ts. Parses argv directly, prints help
166+
* or errors as needed, and delegates to runWrap on success.
167+
*/
168+
export async function wrapEntrypoint(argv: string[]): Promise<void> {
169+
const parsed = parseWrapArgs(argv)
170+
171+
if (parsed.help) {
172+
console.log(WRAP_HELP)
173+
process.exit(0)
174+
}
175+
176+
if (parsed.error) {
177+
console.error(chalk.red(`✗ stackguard wrap: ${parsed.error}`))
178+
console.error('')
179+
console.error(WRAP_HELP)
180+
process.exit(1)
181+
}
182+
183+
await wrapCommand(parsed.args, { policy: parsed.policy, mode: parsed.mode })
184+
}
185+
59186
export async function wrapCommand(rawArgs: string[], options: WrapOptions): Promise<void> {
60187
if (rawArgs.length === 0) {
61-
console.error(chalk.red('✗ stackguard wrap: no command specified after --'))
188+
console.error(chalk.red('✗ stackguard wrap: no command specified'))
189+
console.error('')
190+
console.error(WRAP_HELP)
62191
process.exit(1)
63192
}
64193

65194
const command = rawArgs[0]
66195
const args = rawArgs.slice(1)
67196

197+
if (command.startsWith('-')) {
198+
// Defense in depth — parseWrapArgs should have caught this already.
199+
console.error(
200+
chalk.red(`✗ stackguard wrap: command must not start with '-' (got '${command}')`)
201+
)
202+
console.error('')
203+
console.error(WRAP_HELP)
204+
process.exit(1)
205+
}
206+
68207
// Identify prompt
69208
const promptIdx = findPromptArgIndex(args)
70209
let prompt: string | null = null

src/index.ts

Lines changed: 88 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -3,94 +3,99 @@ import { auditCommand } from './commands/audit.js'
33
import { checkCommand } from './commands/check.js'
44
import { initCommand } from './commands/init.js'
55
import { policyCommand } from './commands/policy.js'
6-
import { wrapCommand } from './commands/wrap.js'
6+
import { wrapEntrypoint } from './commands/wrap.js'
77

8-
const program = new Command()
8+
// Special-case `wrap` BEFORE commander parses anything. Commander's option
9+
// parser doesn't play nicely with pass-through arguments to a wrapped CLI —
10+
// unknown options like `--claude` get eaten and end up in the wrong place.
11+
// Wrap parses its own argv tail; everything else goes through commander.
12+
if (process.argv[2] === 'wrap') {
13+
wrapEntrypoint(process.argv.slice(3)).catch((err) => {
14+
console.error((err as Error).message ?? err)
15+
process.exit(1)
16+
})
17+
} else {
18+
const program = new Command()
919

10-
program
11-
.name('stackguard')
12-
.description('Pre-prompt policy enforcement for AI coding assistants')
13-
.version('0.1.0')
14-
.option('--policy <path>', 'path to policy file (overrides config)')
15-
.option('--mode <mode>', 'warn or block (overrides config)')
20+
program
21+
.name('stackguard')
22+
.description('Pre-prompt policy enforcement for AI coding assistants')
23+
.version('0.1.0')
24+
.option('--policy <path>', 'path to policy file (overrides config)')
25+
.option('--mode <mode>', 'warn or block (overrides config)')
1626

17-
program
18-
.command('init')
19-
.description('Set up stackguard.json in the current directory')
20-
.action(async () => {
21-
try {
22-
await initCommand()
23-
} catch (err) {
24-
console.error((err as Error).message)
25-
process.exit(1)
26-
}
27-
})
27+
program
28+
.command('init')
29+
.description('Set up stackguard.json in the current directory')
30+
.action(async () => {
31+
try {
32+
await initCommand()
33+
} catch (err) {
34+
console.error((err as Error).message)
35+
process.exit(1)
36+
}
37+
})
2838

29-
program
30-
.command('check <prompt>')
31-
.description('Check a prompt against the policy')
32-
.option('--policy <path>', 'override policySource from config')
33-
.option('--mode <mode>', 'override mode from config')
34-
.option('--json', 'output JSON result, no interactive UI')
35-
.option('--show-hash', 'print policy hash and exit')
36-
.action(async (prompt: string, opts: any) => {
37-
try {
38-
const merged = { ...program.opts(), ...opts }
39-
await checkCommand(prompt, merged)
40-
} catch (err) {
41-
console.error((err as Error).message)
42-
process.exit(1)
43-
}
44-
})
39+
program
40+
.command('check <prompt>')
41+
.description('Check a prompt against the policy')
42+
.option('--policy <path>', 'override policySource from config')
43+
.option('--mode <mode>', 'override mode from config')
44+
.option('--json', 'output JSON result, no interactive UI')
45+
.option('--show-hash', 'print policy hash and exit')
46+
.action(async (prompt: string, opts: any) => {
47+
try {
48+
const merged = { ...program.opts(), ...opts }
49+
await checkCommand(prompt, merged)
50+
} catch (err) {
51+
console.error((err as Error).message)
52+
process.exit(1)
53+
}
54+
})
4555

46-
program
47-
.command('wrap')
48-
.description('Wrap an AI assistant command with stackguard checking')
49-
.option('--policy <path>', 'override policySource from config')
50-
.option('--mode <mode>', 'override mode from config')
51-
.allowUnknownOption(true)
52-
.helpOption(false)
53-
.action(async (opts: any, cmd: any) => {
54-
try {
55-
// Everything after `--` is in cmd.args
56-
const args: string[] = cmd.args || []
57-
const merged = { ...program.opts(), ...opts }
58-
await wrapCommand(args, merged)
59-
} catch (err) {
60-
console.error((err as Error).message)
61-
process.exit(1)
62-
}
63-
})
56+
// Note: 'wrap' is intentionally NOT registered with commander. It's
57+
// intercepted at the top of this file because commander's option parser
58+
// mangles pass-through arguments. We register a stub command here only
59+
// so it shows up in `--help`.
60+
program
61+
.command('wrap')
62+
.description('Wrap an AI assistant command (use: stackguard wrap -- <cmd> [args...])')
63+
.allowUnknownOption(true)
64+
.helpOption(false)
65+
.action(() => {
66+
// Unreachable — handled at top of file.
67+
})
6468

65-
program
66-
.command('audit')
67-
.description('Show the override audit log')
68-
.option('--days <n>', 'only show entries within the last N days')
69-
.option('--user <name>', 'filter by user')
70-
.option('--json', 'output as JSON')
71-
.action(async (opts: any) => {
72-
try {
73-
await auditCommand(opts)
74-
} catch (err) {
75-
console.error((err as Error).message)
76-
process.exit(1)
77-
}
78-
})
69+
program
70+
.command('audit')
71+
.description('Show the override audit log')
72+
.option('--days <n>', 'only show entries within the last N days')
73+
.option('--user <name>', 'filter by user')
74+
.option('--json', 'output as JSON')
75+
.action(async (opts: any) => {
76+
try {
77+
await auditCommand(opts)
78+
} catch (err) {
79+
console.error((err as Error).message)
80+
process.exit(1)
81+
}
82+
})
7983

80-
program
81-
.command('policy <subcommand>')
82-
.description('Inspect the active policy (show | hash | source)')
83-
.action(async (subcommand: string) => {
84-
try {
85-
const merged = { ...program.opts() }
86-
await policyCommand(subcommand, merged)
87-
} catch (err) {
88-
console.error((err as Error).message)
89-
process.exit(1)
90-
}
91-
})
84+
program
85+
.command('policy <subcommand>')
86+
.description('Inspect the active policy (show | hash | source)')
87+
.action(async (subcommand: string) => {
88+
try {
89+
const merged = { ...program.opts() }
90+
await policyCommand(subcommand, merged)
91+
} catch (err) {
92+
console.error((err as Error).message)
93+
process.exit(1)
94+
}
95+
})
9296

93-
program.parseAsync(process.argv).catch((err) => {
94-
console.error(err)
95-
process.exit(1)
96-
})
97+
program.parseAsync(process.argv).catch((err) => {
98+
console.error(err)
99+
process.exit(1)
100+
})
101+
}

0 commit comments

Comments
 (0)