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
@@ -0,0 +1,89 @@
package com.correx.core.toolintent.rules
import com.correx.core.events.events.ToolCallObservation
import com.correx.core.events.risk.RiskAction
import com.correx.core.toolintent.ToolCallAssessment
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
/**
* Shell-command-as-file-content gate. Dispatches on FILE_WRITE: a model lacking a shell tool has
* been observed "faking" one by writing a file whose entire content is a shell command (e.g. a file
* named `tasks` containing only `mkdir -p frontend/src/components/tasks`) instead of learning that
* file_write already creates parent directories. The heuristic is deliberately narrow to avoid
* flagging legitimate single-line files: it only fires when the content is a single short line that
* *starts* with a directory/file-management shell verb, AND the write target's extension is one that
* would never legitimately hold a bare shell command (source/doc/data files) — shell scripts
* (`.sh`/`.bash`/etc.), known build-tool files by basename (e.g. `Makefile`, `Dockerfile`), and any
* multi-line content are exempt.
*/
class ShellInFileContentRule : ToolCallRule {
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
ToolCapability.FILE_WRITE in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
val pathString = input.request.parameters["path"] as? String
val content = input.request.parameters["content"] as? String
if (pathString == null || content == null) {
return ToolCallAssessment()
}
val trimmed = content.trim()
val isSingleLine = trimmed.isNotBlank() && !trimmed.contains('\n')
val leadingVerb = SHELL_VERBS.firstOrNull { verb ->
trimmed.startsWith(verb) && (trimmed.length == verb.length || trimmed[verb.length].isWhitespace())
}
val fileName = pathString.substringAfterLast('/')
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "")
val isExemptTarget = extension.lowercase() in EXEMPT_EXTENSIONS ||
fileName.lowercase() in EXEMPT_BASENAMES
val looksLikeShellCommand = isSingleLine && leadingVerb != null && !isExemptTarget
val observations = listOf(
ToolCallObservation(
ruleCode = RULE_CODE,
facts = mapOf(
"path" to pathString,
"singleLine" to isSingleLine.toString(),
"leadingVerb" to (leadingVerb ?: ""),
"extension" to extension,
"flagged" to looksLikeShellCommand.toString(),
),
),
)
if (!looksLikeShellCommand) {
return ToolCallAssessment(observations = observations)
}
val issue = ValidationIssue(
code = RULE_CODE,
message = "[$RULE_CODE] file_write content looks like a shell command " +
"('${trimmed.take(SNIPPET_LENGTH)}'), not file content. To create a directory, just " +
"write your file at the nested path — parents are auto-created. Do not write shell " +
"commands into files.",
severity = ValidationSeverity.ERROR,
)
return ToolCallAssessment(
issues = listOf(issue),
observations = observations,
disposition = maxAction(RiskAction.PROCEED, RiskAction.BLOCK),
)
}
private companion object {
const val RULE_CODE = "SHELL_IN_FILE"
const val SNIPPET_LENGTH = 60
val SHELL_VERBS = listOf("mkdir", "rm", "cp", "mv", "touch", "cat", "echo", "chmod", "ls")
val EXEMPT_EXTENSIONS = setOf("sh", "bash", "zsh", "ps1", "bat", "cmd")
val EXEMPT_BASENAMES = setOf("makefile", "dockerfile", "vagrantfile", "rakefile", "gemfile", "procfile")
}
}
@@ -0,0 +1,85 @@
package com.correx.core.toolintent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.toolintent.rules.ShellInFileContentRule
import com.correx.core.tools.contract.ToolCapability
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ShellInFileContentRuleTest {
private val workspace = Path.of("/work/project")
private val rule = ShellInFileContentRule()
private class FakeProbe : WorldProbe {
override fun exists(path: Path) = true
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
}
private fun input(pathArg: String, content: String) = ToolCallAssessmentInput(
request = ToolRequest(
ToolInvocationId("i"),
SessionId("s"),
StageId("st"),
"file_write",
mapOf("path" to pathArg, "content" to content),
),
capabilities = setOf(ToolCapability.FILE_WRITE),
workspace = WorkspacePolicy(workspace, emptyList()),
probe = FakeProbe(),
)
@Test
fun `applies only to FILE_WRITE`() {
assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_WRITE)))
assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ)))
}
@Test
fun `a source file whose content is a bare mkdir command is blocked`() {
val r = rule.assess(input("frontend/src/components/tasks", "mkdir -p frontend/src/components/tasks"))
assertEquals(RiskAction.BLOCK, r.disposition)
assertEquals("SHELL_IN_FILE", r.issues.single().code)
assertTrue(r.issues.single().message.contains("parents are auto-created"))
}
@Test
fun `a real tsx file proceeds`() {
val content = "export function Tasks() {\n return <div>tasks</div>;\n}\n"
val r = rule.assess(input("frontend/src/components/tasks.tsx", content))
assertEquals(RiskAction.PROCEED, r.disposition)
assertTrue(r.issues.isEmpty())
}
@Test
fun `a real shell script starting with a shell verb is exempt`() {
val r = rule.assess(input("scripts/setup.sh", "mkdir -p build\ncp foo build/"))
assertEquals(RiskAction.PROCEED, r.disposition)
}
@Test
fun `a single-line shell script is exempt by extension`() {
val r = rule.assess(input("scripts/mk.sh", "mkdir -p build"))
assertEquals(RiskAction.PROCEED, r.disposition)
}
@Test
fun `a README with a multi-line mkdir code block proceeds`() {
val content = "# Setup\n\n```sh\nmkdir -p build\n```\n"
val r = rule.assess(input("README.md", content))
assertEquals(RiskAction.PROCEED, r.disposition)
}
@Test
fun `an extensionless Makefile is exempt`() {
val r = rule.assess(input("Makefile", "mkdir -p build"))
assertEquals(RiskAction.PROCEED, r.disposition)
}
}