Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-18
Original file line number Diff line number Diff line change
@@ -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/<your-name>/<branch-slug>`; 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/<your-name>/<branch-slug>`. 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/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/<your-name>/<branch-slug> --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`).
73 changes: 67 additions & 6 deletions src/output/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -758,6 +818,7 @@ module.exports = {
detectRecoverableAutoFinishConflict,
summarizeAutoFinishDetail,
printAutoFinishSummary,
tokenizeCommand,
resolveCompressCommand,
compressBlock,
printCompressible,
Expand Down
46 changes: 46 additions & 0 deletions test/output.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
Loading