diff --git a/openspec/changes/agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43/.openspec.yaml b/openspec/changes/agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43/.openspec.yaml new file mode 100644 index 0000000..95ae5a2 --- /dev/null +++ b/openspec/changes/agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-18 diff --git a/openspec/changes/agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43/notes.md b/openspec/changes/agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43/notes.md new file mode 100644 index 0000000..188c278 --- /dev/null +++ b/openspec/changes/agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43/notes.md @@ -0,0 +1,19 @@ +# agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43 (minimal / T1) + +Branch: `agent/claude/harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43` + +Replace the naive whitespace split in `resolveCompressCommand` (GUARDEX_COMPRESS_CMD, +src/output/index.js) with a shell-quote-aware `tokenizeCommand` so values like +`sh -c "tr a-z A-Z"` parse correctly; malformed input (unterminated quote / dangling +backslash) returns null so the caller falls back to plain output. Follow-up to PR #649. + +## Handoff + +- Handoff: change=`agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-harden-guardex-compress-cmd-shell-quote-2026-06-18-11-43/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/output/index.js b/src/output/index.js index f35226f..dc1994f 100644 --- a/src/output/index.js +++ b/src/output/index.js @@ -659,17 +659,77 @@ function printAutoFinishSummary(summary, options = {}) { const COMPRESS_MIN_CHARS = 400; -// resolveCompressCommand parses GUARDEX_COMPRESS_CMD into an argv array (split -// on whitespace, run with shell:false so no shell interpolation). Returns null -// when unset/empty so callers fall straight through to plain output. Commands -// needing args with embedded spaces should be wrapped in a small script. +// tokenizeCommand splits a command string into an argv array, honoring single +// quotes, double quotes (with \" and \\ escapes), and backslash escaping, so a +// value like sh -c "tr a-z A-Z" parses to ['sh','-c','tr a-z A-Z'] instead of +// being naively split on whitespace. Returns null on an unterminated quote +// (malformed) so callers fall back to plain output rather than run a half-parsed +// command. No shell-metacharacter handling (globs, $vars, pipes): tokens are +// passed to spawnSync with shell:false as literal argv. +function tokenizeCommand(raw) { + const text = String(raw); + const tokens = []; + let current = ''; + let started = false; + let quote = null; // "'" or '"' while inside a quoted run + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (quote === "'") { + if (ch === "'") quote = null; + else current += ch; + continue; + } + if (quote === '"') { + if (ch === '"') quote = null; + else if (ch === '\\' && (text[i + 1] === '"' || text[i + 1] === '\\')) { + current += text[i + 1]; + i += 1; + started = true; + } else current += ch; + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + started = true; + continue; + } + if (ch === '\\') { + if (i + 1 >= text.length) return null; // dangling backslash -> malformed + current += text[i + 1]; + i += 1; + started = true; + continue; + } + if (/\s/.test(ch)) { + if (started) { + tokens.push(current); + current = ''; + started = false; + } + continue; + } + current += ch; + started = true; + } + if (quote) return null; // unterminated quote -> malformed + if (started) tokens.push(current); + return tokens; +} + +// resolveCompressCommand parses GUARDEX_COMPRESS_CMD into an argv array via +// tokenizeCommand (shell-quote aware), run with shell:false so no shell +// interpolation. Returns null when unset/empty/malformed so callers fall +// straight through to plain output. function resolveCompressCommand(env = process.env) { const raw = String(env.GUARDEX_COMPRESS_CMD || '').trim(); if (!raw) { return null; } - const argv = raw.split(/\s+/).filter(Boolean); - return argv.length > 0 ? argv : null; + const argv = tokenizeCommand(raw); + if (!argv || argv.length === 0) { + return null; + } + return argv; } // looksMachineReadable keeps JSON / structured payloads (status --json, lock @@ -758,6 +818,7 @@ module.exports = { detectRecoverableAutoFinishConflict, summarizeAutoFinishDetail, printAutoFinishSummary, + tokenizeCommand, resolveCompressCommand, compressBlock, printCompressible, diff --git a/test/output.test.js b/test/output.test.js index 9168133..b641364 100644 --- a/test/output.test.js +++ b/test/output.test.js @@ -5,6 +5,7 @@ const { printAutoFinishSummary, compressBlock, resolveCompressCommand, + tokenizeCommand, } = require('../src/output'); // A large lowercase block so the `tr a-z A-Z` stub visibly transforms it and it @@ -134,3 +135,48 @@ test('compressBlock respects the terse-mode gate (skips when verbose)', () => { } } }); + +test('tokenizeCommand honors double and single quotes with embedded spaces', () => { + assert.deepEqual(tokenizeCommand('sh -c "tr a-z A-Z"'), ['sh', '-c', 'tr a-z A-Z']); + assert.deepEqual(tokenizeCommand("sh -c 'a b c'"), ['sh', '-c', 'a b c']); + assert.deepEqual(tokenizeCommand(' tr a-z A-Z '), ['tr', 'a-z', 'A-Z']); + assert.deepEqual(tokenizeCommand('a "b c" d'), ['a', 'b c', 'd']); +}); + +test('tokenizeCommand returns null on malformed input (unterminated quote or dangling backslash)', () => { + assert.equal(tokenizeCommand('sh -c "oops no close'), null); + assert.equal(tokenizeCommand("it's"), null); + assert.equal(tokenizeCommand('foo\\'), null); +}); + +test('resolveCompressCommand parses a shell-quoted command into argv', () => { + assert.deepEqual( + resolveCompressCommand({ GUARDEX_COMPRESS_CMD: 'sh -c "tr a-z A-Z"' }), + ['sh', '-c', 'tr a-z A-Z'], + ); + // malformed (unterminated quote) -> null so the caller falls back to plain output + assert.equal(resolveCompressCommand({ GUARDEX_COMPRESS_CMD: 'sh -c "broken' }), null); +}); + +test('compressBlock runs a shell-quoted compressor command end to end', () => { + const out = compressBlock(BIG_BLOCK, { + env: { GUARDEX_COMPRESS_CMD: 'sh -c "tr a-z A-Z"' }, + force: true, + }); + assert.equal(out, BIG_BLOCK.toUpperCase()); +}); + +test('compressBlock falls back to the original when the compressor times out (discards partial stdout)', () => { + const out = compressBlock(BIG_BLOCK, { + env: { + // emits partial stdout, then sleeps well past the timeout so it is always + // killed mid-run (long sleep removes any chance the child exits early). + GUARDEX_COMPRESS_CMD: 'sh -c "printf PARTIAL; sleep 30"', + GUARDEX_COMPRESS_TIMEOUT_MS: '200', + }, + force: true, + }); + // Whether killed before or after the printf, the partial/empty stdout is + // discarded and the original block is returned. + assert.equal(out, BIG_BLOCK); +});