diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt index 09f14107..4b0b8b0e 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt @@ -167,19 +167,24 @@ class ShellTool( // argv[0] is the program name. A single element carrying whitespace is the weak-model "whole // command line in one string" collapse (["npm create vite@latest frontend"]) — split it into // tokens so the denylist, allowlist and executor all see a real argv, instead of rejecting it and - // looping the stage on a call the model reliably re-emits. A JSON-escape mangle collapses - // differently — it leaves quotes/commas in argv[0] (["npm\",\"-v"]) — and stays rejected: there is - // no runnable program to recover. (Splitting on whitespace loses quoting of args with spaces — - // same ceiling as the `sh -c` join below; fine for agent shell use, not a general shell API.) + // looping the stage on a call the model reliably re-emits. Weak models also leak the JSON array + // separators into the individual tokens — trailing commas (["npm,","-v"]) or wrapping escaped- + // quotes (["npm\",","\"-v\""]) — which the model re-emits identically until the stage loop breaks. + // Strip stray leading/trailing quote/comma from each token so those recover too. (Internal commas, + // --foo=a,b, are kept; a single token that is itself the whole collapsed array — "npm\",\"-v\"," — + // still has interior quotes/commas and stays rejected: there is no runnable program to recover. + // Splitting on whitespace loses quoting of args with spaces — same ceiling as the `sh -c` join + // below; fine for agent shell use, not a general shell API.) private fun checkExecutable(raw: List): ArgvParse { - val argv = if (raw.size == 1 && raw[0].any { it.isWhitespace() } && - raw[0].none { it == '"' || it == ',' } + val stripped = raw.map { it.trim().trim { c -> c == '"' || c == ',' } }.filter { it.isNotEmpty() } + val argv = if (stripped.size == 1 && stripped[0].any { it.isWhitespace() } && + stripped[0].none { it == '"' || it == ',' } ) { - raw[0].trim().split(WHITESPACE_RE) + stripped[0].trim().split(WHITESPACE_RE) } else { - raw + stripped } - return if (argv[0].any { it == '"' || it == ',' || it.isWhitespace() }) { + return if (argv.isEmpty() || argv[0].any { it == '"' || it == ',' || it.isWhitespace() }) { ArgvParse.Bad( "argv[0] `${argv[0]}` is not a valid executable name — it looks like a collapsed array. " + "Emit each token as a separate string element, e.g. [\"npm\", \"-v\"].", diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt index 57d1fb00..efaec0ee 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt @@ -204,6 +204,17 @@ class ShellToolTest { assertTrue((result as ValidationResult.Invalid).reason.contains("collapsed array"), result.reason) } + @Test + fun `validateRequest recovers per-token separator leak into argv`() { + // Repro: gemma re-emitted `npm -v` as ["npm,","-v"] (trailing comma) and ["npm\",","\"-v\""] + // (wrapping escaped-quotes) six times → stage_loop_break → FAILED. Strip the stray quote/comma + // per token so the call runs instead of looping the stage to death. + listOf(listOf("npm,", "-v"), listOf("npm\",", "\"-v\"")).forEach { argv -> + val result = ShellTool().validateRequest(rawArgvRequest(argv)) + assertTrue(result is ValidationResult.Valid, "$argv: ${(result as? ValidationResult.Invalid)?.reason}") + } + } + @Test fun `validateRequest splits a collapsed single-string command line into tokens`() { // Repro: weak model emitted ["npm create vite@latest frontend"] — the whole command line as