feat(guardrails): steering channel + shell-in-file rule + capability-gap detector
Bundles three operator-reliability guardrails (Vikunja #28/#29/#30) plus the in-flight branch WIP they were built on top of (reasoning_content capture, operator/project profile editor, write-jail workspaceRoot fix) — the tree is interdependent (SessionOrchestrator references reasoningArtifactId from the WIP) and does not compile as separable subsets, so it lands as one commit. Guardrails: - #28 mid-stage steering: ClientMessage.SteerSession -> GlobalStreamHandler -> orchestrator.submitSteering, reusing SteeringNoteAddedEvent + existing context fold (advisory, non-authoritative; invariants #3/#7). Closes the gap where steering typed off an approval gate was silently dropped. - #29 shell-in-file guardrail: ShellInFileContentRule (core:toolintent) blocks a file_write whose content is a bare shell command (e.g. "mkdir -p ..."); FileWriteTool description now advertises auto-mkdir of parent dirs. Basename-allowlist so the extensionless case is caught; scripts/Makefiles/multiline exempt. - #30 pt1 capability-gap detector: deterministic CapabilityGapDetector maps stage intent -> implied ToolCapability, compares to granted tools, emits advisory CapabilityGapDetectedEvent in FreestyleDriver.lockAndRun. Recorded, never fails the gate and never auto-grants (invariants #3/#4/#5). Reflection rung is pt2. Verified: ./gradlew check green (whole tree).
This commit is contained in:
+3
-1
@@ -39,7 +39,9 @@ class FileWriteTool(
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
override val name: String = "file_write"
|
||||
override val description: String = "Write content to a file at the specified relative path (creates or overwrites)"
|
||||
override val description: String = "Write content to a file at the specified relative path (creates or overwrites). " +
|
||||
"Writing to a nested path auto-creates any missing parent directories — there is no separate " +
|
||||
"mkdir step. Do not write shell commands into a file's content."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
|
||||
+38
@@ -70,12 +70,44 @@ class ShellTool(
|
||||
when (val parsed = parseArgv(request.parameters["argv"])) {
|
||||
is ArgvParse.Bad -> ValidationResult.Invalid(parsed.reason)
|
||||
is ArgvParse.Ok -> when {
|
||||
// Baseline denylist runs FIRST, ahead of the allowlist — a hard floor that holds even
|
||||
// when allowedExecutables is empty (allow-all). Blocks the class of command that
|
||||
// fetches+executes remote code or prompts interactively: an unattended run has no TTY,
|
||||
// so a create-* scaffolder / npx blocks forever (or balloons the machine). The T2 gate
|
||||
// is not enough — an auto-approve loop waves these straight through.
|
||||
deniedReason(parsed.argv) != null ->
|
||||
ValidationResult.Invalid(deniedReason(parsed.argv)!!)
|
||||
allowedExecutables.isNotEmpty() && parsed.argv[0] !in allowedExecutables ->
|
||||
ValidationResult.Invalid("Executable '${parsed.argv[0]}' is not in the allowed list.")
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
}
|
||||
|
||||
// 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].
|
||||
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`.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ArgvParse {
|
||||
data class Ok(val argv: List<String>) : ArgvParse
|
||||
data class Bad(val reason: String) : ArgvParse
|
||||
@@ -179,6 +211,12 @@ class ShellTool(
|
||||
const val EMPTY_MSG = "Missing or empty 'argv' parameter. Expected a JSON array of strings."
|
||||
val SHELL_BUILTINS = setOf("cd", "export", "source", ".", "pushd", "popd", "umask", "ulimit")
|
||||
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")
|
||||
}
|
||||
|
||||
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
|
||||
|
||||
+49
@@ -180,4 +180,53 @@ class ShellToolTest {
|
||||
fun `validateRequest accepts a clean string argv`() {
|
||||
assertEquals(ValidationResult.Valid, ShellTool().validateRequest(createRequest(listOf("mkdir", "-p", "dir"))))
|
||||
}
|
||||
|
||||
@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.
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denylist blocks npx remote runner`() {
|
||||
val result = ShellTool().validateRequest(createRequest(listOf("npx", "create-react-app", "app")))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("remote code"), result.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denylist blocks create- scaffolder run directly`() {
|
||||
val result = ShellTool().validateRequest(createRequest(listOf("create-next-app", "app")))
|
||||
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)
|
||||
}
|
||||
|
||||
@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")))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertTrue((result as ValidationResult.Invalid).reason.contains("file_write"), result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user