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:
2026-07-07 13:27:59 +04:00
parent 879672a47d
commit 41ed6414c6
43 changed files with 1592 additions and 94 deletions
@@ -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") {
@@ -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) {
@@ -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)
}
}