feat(recovery,context): tier-2 intent-holder arbiter + remaining-delta pinning + surfaced signal events
- recovery: two-tier repair ladder — owner then arbiter (recovery stage recast
as intent-holder, holds initial intent, reconciles cross-file contract disputes)
with independent INTENT_ROUTE_BUDGET; RecoveryRoutingTest coverage
- context: remaining-delta checklist pinning (RemainingDelta{Pinning,Entry}Test)
- surface write-only events (session name, quality signals) into stats/browse
- parseToolArguments lenient-Json fallback for bare-key drift
- tools: ChildProcess seam
This commit is contained in:
+46
-27
@@ -14,6 +14,7 @@ import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import com.correx.core.tools.process.ChildProcess
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
@@ -83,28 +84,20 @@ class ShellTool(
|
||||
}
|
||||
}
|
||||
|
||||
// Return a rejection reason if argv is a fetch-and-execute-remote-code or interactive-scaffolder
|
||||
// invocation, else null. Matched by shape, not just argv[0], because the danger for package
|
||||
// managers is the *subcommand* (`npm install` is fine; `npm create`/`npm init` scaffolds and
|
||||
// prompts). Deterministic, allowlist-independent — see [validateRequest].
|
||||
// Return a rejection reason if argv fetches and runs an arbitrary *remote* package in one shot
|
||||
// (npx/bunx/pnpx), else null. That's remote code execution — a distinct threat we still deny.
|
||||
// Package-manager scaffolders (`npm create`, `npm init`, `npm install`) are NO LONGER blocked:
|
||||
// the hang they used to guard against (stdin prompt with no TTY) is now closed at the process
|
||||
// factory — every child gets stdin from /dev/null and a non-interactive env overlay
|
||||
// ([ChildProcess]). Matched by shape, not just argv[0], because a runner can hide inside a shell
|
||||
// command line run via `sh -c` (`cd web && npx create-foo`), where argv[0] is `cd`.
|
||||
private fun deniedReason(argv: List<String>): String? {
|
||||
// Scan every token, not just argv[0]: a scaffolder can hide inside a shell command line that
|
||||
// runs via `sh -c` (`cd web && npm create vite`), where argv[0] is `cd`.
|
||||
// Scan every token, not just argv[0]: a runner can hide behind a shell builtin at argv[0].
|
||||
val progs = argv.map { it.substringAfterLast('/') }
|
||||
val redirect = "Scaffold by writing files directly with file_write (package.json, configs, " +
|
||||
"sources) — do not shell out to a network installer; it needs a TTY and will hang unattended."
|
||||
val runner = progs.firstOrNull { it in REMOTE_EXEC_RUNNERS }
|
||||
val scaffolder = progs.firstOrNull { it.startsWith("create-") }
|
||||
val pmIdx = progs.indexOfFirst { it in PACKAGE_MANAGERS }
|
||||
return when {
|
||||
runner != null ->
|
||||
"'$runner' fetches and runs remote code with no TTY, which hangs an unattended run. $redirect"
|
||||
scaffolder != null ->
|
||||
"'$scaffolder' is an interactive project scaffolder that prompts on stdin. $redirect"
|
||||
pmIdx >= 0 && progs.getOrNull(pmIdx + 1) in SCAFFOLD_SUBCOMMANDS ->
|
||||
"'${progs[pmIdx]} ${progs[pmIdx + 1]}' scaffolds a project interactively " +
|
||||
"(prompts on stdin). $redirect"
|
||||
else -> null
|
||||
return progs.firstOrNull { it in REMOTE_EXEC_RUNNERS }?.let { runner ->
|
||||
"'$runner' downloads and runs an arbitrary remote package in one shot (remote code " +
|
||||
"execution). Use the package manager's own scaffold command instead — e.g. " +
|
||||
"`npm create vite@latest <dir> -- --template react-ts` — or write the files with file_write."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +163,7 @@ class ShellTool(
|
||||
// shell API. A still-unrunnable program returns RECOVERABLE (model adapts, not fatal).
|
||||
val command = if (looksLikeShellCommand(argv)) listOf("sh", "-c", argv.joinToString(" ")) else argv
|
||||
val process = runCatching {
|
||||
ProcessBuilder(command).apply { workingDir?.let { directory(it.toFile()) } }.start()
|
||||
ChildProcess.builder(command, workingDir?.toFile()).start()
|
||||
}.getOrElse {
|
||||
return@let ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
@@ -213,10 +206,29 @@ class ShellTool(
|
||||
val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1")
|
||||
// Runners that download and execute an arbitrary remote package in one shot.
|
||||
val REMOTE_EXEC_RUNNERS = setOf("npx", "bunx", "pnpx")
|
||||
val PACKAGE_MANAGERS = setOf("npm", "yarn", "pnpm", "bun")
|
||||
// Package-manager subcommands that generate a project by pulling+running a remote scaffolder
|
||||
// and prompting on stdin (npm create vite, yarn create next-app, pnpm dlx, …).
|
||||
val SCAFFOLD_SUBCOMMANDS = setOf("create", "init", "dlx")
|
||||
}
|
||||
|
||||
// Combines a failed process's stdout and stderr into one labelled, head/tail-bounded block for
|
||||
// the model's failure feedback. Labels each stream (only when non-blank) so the model can tell
|
||||
// compiler diagnostics on stdout from runner errors on stderr; the same HEAD/TAIL window as the
|
||||
// success-path compressor keeps a large build log from flooding the next context window.
|
||||
private fun boundFailureOutput(stdout: String, stderr: String): String {
|
||||
val sections = buildList {
|
||||
if (stdout.isNotBlank()) add("stdout:\n${stdout.trimEnd()}")
|
||||
if (stderr.isNotBlank()) add("stderr:\n${stderr.trimEnd()}")
|
||||
}
|
||||
return boundLines(sections.joinToString("\n\n"))
|
||||
}
|
||||
|
||||
// Keeps the first HEAD_LINES and last TAIL_LINES of a multi-line block, dropping the middle with
|
||||
// an elision marker — build errors cluster at both ends (first failures, final summary), so a
|
||||
// head+tail window preserves the actionable lines while bounding size.
|
||||
private fun boundLines(text: String): String {
|
||||
val lines = text.lines()
|
||||
if (lines.size <= HEAD_LINES + TAIL_LINES) return text
|
||||
val omitted = lines.size - HEAD_LINES - TAIL_LINES
|
||||
return (lines.take(HEAD_LINES) + "… ($omitted lines omitted) …" + lines.takeLast(TAIL_LINES))
|
||||
.joinToString("\n")
|
||||
}
|
||||
|
||||
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
|
||||
@@ -230,11 +242,18 @@ class ShellTool(
|
||||
val metadata = if (stderr.isNotBlank()) mapOf("stderr" to stderr) else emptyMap()
|
||||
if (exitCode != 0) {
|
||||
// A non-zero exit is normal command behaviour (missing path, no grep match, git
|
||||
// --exit-code, test, diff). Surface it recoverably so the model sees the code + stderr
|
||||
// --exit-code, test, diff). Surface it recoverably so the model sees the code + output
|
||||
// and adapts, rather than aborting the stage on the first failed probe.
|
||||
//
|
||||
// BOTH streams, not just stderr: compilers and test runners (tsc, vite, jest, pytest…)
|
||||
// print their diagnostics to STDOUT, so a stderr-only failure message returns a bare
|
||||
// "exited with code 2" and the model fixes blind — it can see the build failed but not
|
||||
// which file/line. Combine stdout+stderr, head/tail-bounded so a large build log can't
|
||||
// flood the next context window.
|
||||
val detail = boundFailureOutput(stdout, stderr)
|
||||
ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "Process exited with code $exitCode${if (stderr.isNotBlank()) ": $stderr" else ""}",
|
||||
reason = "Process exited with code $exitCode${if (detail.isNotBlank()) ":\n$detail" else ""}",
|
||||
recoverable = true,
|
||||
)
|
||||
} else {
|
||||
|
||||
+32
-25
@@ -102,6 +102,24 @@ class ShellToolTest {
|
||||
assertTrue(failure.recoverable, "non-zero exit must be recoverable so the model can adapt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute surfaces stdout in the failure reason for a non-zero exit`(): Unit = runBlocking {
|
||||
// Compilers/test runners (tsc, vite, jest) print diagnostics to STDOUT and exit non-zero.
|
||||
// The failure reason must carry that output, else the model sees only "exited with code 2"
|
||||
// and fixes blind. Regression for session 6f9a9f08 recovery loop.
|
||||
val tool = ShellTool()
|
||||
val request = createRequest(listOf("sh", "-c", "echo 'hooks.ts(57,3): error TS1005'; exit 2"))
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
val failure = result as ToolResult.Failure
|
||||
assertTrue(failure.reason.contains("exited with code 2"))
|
||||
assertTrue(
|
||||
failure.reason.contains("TS1005"),
|
||||
"stdout diagnostics must reach the model's failure feedback, got: ${failure.reason}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Failure on timeout`(): Unit = runBlocking {
|
||||
// Using sleep command to simulate timeout
|
||||
@@ -182,15 +200,12 @@ class ShellToolTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denylist blocks npm create even in allow-all mode`() {
|
||||
// Repro: gemma ran `npm create vite@latest` — a network scaffolder that prompts on stdin.
|
||||
// Empty allowedExecutables = allow-all, yet this must still be rejected.
|
||||
fun `denylist allows npm create — hang closed at the process factory`() {
|
||||
// `npm create vite@latest` used to be blocked as an interactive scaffolder. The stdin-prompt
|
||||
// hang is now closed at ChildProcess (stdin=/dev/null + non-interactive env), so the idiomatic
|
||||
// scaffold path is allowed again. Empty allowedExecutables = allow-all.
|
||||
val tool = ShellTool(allowedExecutables = emptySet())
|
||||
val result = tool.validateRequest(createRequest(listOf("npm", "create", "vite")))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
val reason = (result as ValidationResult.Invalid).reason
|
||||
assertTrue(reason.contains("npm create"), reason)
|
||||
assertTrue(reason.contains("file_write"), reason)
|
||||
assertEquals(ValidationResult.Valid, tool.validateRequest(createRequest(listOf("npm", "create", "vite"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -201,32 +216,24 @@ class ShellToolTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denylist blocks create- scaffolder run directly`() {
|
||||
val result = ShellTool().validateRequest(createRequest(listOf("create-next-app", "app")))
|
||||
fun `denylist catches remote runner hidden in a shell command line`() {
|
||||
// argv[0] is `cd`, but `npx` rides along and would run via sh -c.
|
||||
val result = ShellTool().validateRequest(createRequest(listOf("cd", "web", "&&", "npx", "create-foo")))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("scaffolder"), result.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denylist catches scaffolder hidden in a shell command line`() {
|
||||
// argv[0] is `cd`, but `npm create` rides along and would run via sh -c.
|
||||
val result = ShellTool().validateRequest(createRequest(listOf("cd", "web", "&&", "npm", "create", "vite")))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("npm create"), result.reason)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("remote code"), result.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denylist allows npm install`() {
|
||||
// The subcommand is what's dangerous — `npm install` on existing files is fine.
|
||||
assertEquals(ValidationResult.Valid, ShellTool().validateRequest(createRequest(listOf("npm", "install"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denylist takes precedence over the allowlist`() {
|
||||
// Even if npm is explicitly allowed, `npm create` stays blocked.
|
||||
val tool = ShellTool(allowedExecutables = setOf("npm"))
|
||||
val result = tool.validateRequest(createRequest(listOf("npm", "create", "vite")))
|
||||
fun `denylist takes precedence over the allowlist for remote runners`() {
|
||||
// Even if npx is explicitly allowed, remote-exec stays blocked.
|
||||
val tool = ShellTool(allowedExecutables = setOf("npx"))
|
||||
val result = tool.validateRequest(createRequest(listOf("npx", "create-react-app", "app")))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("file_write"), result.reason)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("remote code"), result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user