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
@@ -21,4 +21,13 @@ data class JsonSchemaProperty(
// array is a misread brief, not a valid artifact).
val minItems: Int = 0,
val items: JsonSchemaProperty? = null,
// For `type == "object"` (either a nested property or an array's `items`): the object's
// own shape. Lets a schema describe structured collections — e.g. the discovery stage's
// `questions: array of { prompt, options, header, ... }`. Without this the parser rejects
// any nested object schema ("unknown key 'properties'"), silently dropping the kind.
// JSON-schema convention: nested objects allow extra keys by default (additionalProperties=true),
// unlike the top-level [JsonSchema] which defaults to strict.
val properties: Map<String, JsonSchemaProperty> = emptyMap(),
val required: List<String> = emptyList(),
val additionalProperties: Boolean = true,
)
@@ -0,0 +1,48 @@
package com.correx.core.config
import java.nio.file.Files
/**
* Serializes an [OperatorProfile] back to TOML text that [ProfileLoader] reads identically,
* mirroring [ProjectProfileWriter]'s regenerate-the-file, skip-empty-sections approach.
*/
object OperatorProfileWriter {
/** Renders [profile] as TOML. String values escape `\` and `"`; empty sections are skipped. */
fun serialize(profile: OperatorProfile): String {
val b = StringBuilder()
if (profile.about.isNotBlank()) {
b.append("about = ").append(str(profile.about)).append('\n')
}
val prefs = profile.preferences
val hasPrefs = prefs.approvalMode.isNotBlank() || prefs.preferredModels.isNotEmpty() || prefs.conventions.isNotEmpty()
if (hasPrefs) {
if (b.isNotEmpty()) b.append('\n')
b.append("[preferences]\n")
if (prefs.approvalMode.isNotBlank()) {
b.append("approval_mode = ").append(str(prefs.approvalMode)).append('\n')
}
if (prefs.preferredModels.isNotEmpty()) {
b.append("preferred_models = ").append(list(prefs.preferredModels)).append('\n')
}
if (prefs.conventions.isNotEmpty()) {
b.append("conventions = ").append(list(prefs.conventions)).append('\n')
}
}
return b.toString()
}
/** Writes [profile] to `<homeDir>/.config/correx/profile.toml`, creating parent dirs if missing. */
fun write(profile: OperatorProfile, homeDir: String = System.getProperty("user.home")) {
val path = ProfileLoader.profilePath(homeDir)
Files.createDirectories(path.parent)
Files.writeString(path, serialize(profile))
}
private fun str(v: String): String = "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
private fun list(v: List<String>): String = "[" + v.joinToString(", ") { str(it) } + "]"
}
@@ -52,3 +52,24 @@ data class PlanCompileCheckedEvent(
/** Compiler error when [passed] is false; null on success. Kept bounded. */
val error: String? = null,
) : EventPayload
/**
* Capability-gap detector finding (Vikunja #30 part 1): a stage's declared intent implies a tool
* capability ([impliedCapability]) it was not granted, per the deterministic verb map in
* [com.correx.infrastructure.workflow.CapabilityGapDetector]. [evidence] is the triggering phrase
* found in the stage brief.
*
* Advisory only — recorded alongside the plan-compile gate's other findings but does NOT fail the
* gate and does NOT grant the tool itself (invariants #3 LLM-proposes/core-decides, #4
* policy-absolute, #5 tiers-declared-not-inferred). A future reflection rung (part 2, an LLM
* "are you sure?" pass) consumes these events; this ticket stops at detection + recording.
*/
@Serializable
@SerialName("CapabilityGapDetected")
data class CapabilityGapDetectedEvent(
val sessionId: SessionId,
val stageId: StageId,
val impliedCapability: String,
val evidence: String,
val timestampMs: Long,
) : EventPayload
@@ -30,6 +30,7 @@ data class InferenceCompletedEvent(
val tokensUsed: TokenUsage,
val latencyMs: Long,
val responseArtifactId: ArtifactId,
val reasoningArtifactId: ArtifactId? = null,
) : EventPayload
@Serializable
@@ -34,6 +34,7 @@ import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.PlanCompileCheckedEvent
import com.correx.core.events.events.CapabilityGapDetectedEvent
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.HealthDegradedEvent
@@ -171,6 +172,7 @@ val eventModule = SerializersModule {
subclass(ExecutionPlanLockedEvent::class)
subclass(ExecutionPlanRejectedEvent::class)
subclass(PlanCompileCheckedEvent::class)
subclass(CapabilityGapDetectedEvent::class)
subclass(ReviewFindingsRaisedEvent::class)
subclass(JournalCompactedEvent::class)
subclass(HealthDegradedEvent::class)
@@ -20,6 +20,7 @@ import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.SalvageDecision
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.orchestration.OrchestrationState
@@ -469,6 +470,30 @@ class DefaultSessionOrchestrator(
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
}
/**
* Records free-form operator steering for a session that is mid-stage — independent of any
* approval gate. Unlike [submitApprovalDecision]/[submitClarification] this never completes a
* pending deferred: the running stage is not paused waiting on it. It only appends a
* [com.correx.core.events.events.SteeringNoteAddedEvent], which [buildSteeringNoteEntries] folds
* into context as an advisory `steeringNote` entry on the *next* context turn (invariants #3/#7 —
* advisory only, never a state mutation). Reuses the existing SteeringNoteAddedEvent/fold rather
* than a parallel event type, since that mechanism already implements exactly this semantics
* (see TalkieFacade's STEERING chat mode, which emits the same event).
*/
suspend fun submitSteering(sessionId: SessionId, text: String) {
if (text.isBlank()) return
val currentStageId = runCatching { orchestrationRepository.getState(sessionId).currentStageId }
.getOrNull()
emit(
sessionId,
SteeringNoteAddedEvent(
sessionId = sessionId,
content = text,
stageId = currentStageId,
),
)
}
private suspend fun executeMove(
ctx: EnrichedExecutionContext,
decision: TransitionDecision.Move,
@@ -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)
}
}
@@ -50,23 +50,49 @@ object JsonSchemaValidator {
private fun checkProperty(key: String, property: JsonSchemaProperty, element: JsonElement): List<String> {
val typeError = checkType(property.type, element)
val array = element as? JsonArray
return when {
typeError != null -> listOf("property '$key': $typeError")
property.type != "array" || array == null -> emptyList()
else -> buildList {
if (array.size < property.minItems) {
add("property '$key': expected at least ${property.minItems} item(s), got ${array.size}")
}
property.items?.let { itemSchema ->
array.forEachIndexed { i, el ->
checkType(itemSchema.type, el)?.let { add("property '$key'[$i]: $it") }
}
property.type == "array" -> checkArray(key, property, element as JsonArray)
property.type == "object" && property.properties.isNotEmpty() ->
validateProperty(property, element as JsonObject).map { "property '$key': $it" }
else -> emptyList()
}
}
private fun checkArray(key: String, property: JsonSchemaProperty, array: JsonArray): List<String> = buildList {
if (array.size < property.minItems) {
add("property '$key': expected at least ${property.minItems} item(s), got ${array.size}")
}
property.items?.let { itemSchema ->
array.forEachIndexed { i, el ->
val itemTypeError = checkType(itemSchema.type, el)
when {
itemTypeError != null -> add("property '$key'[$i]: $itemTypeError")
itemSchema.type == "object" && itemSchema.properties.isNotEmpty() ->
validateProperty(itemSchema, el as JsonObject).forEach { add("property '$key'[$i]: $it") }
}
}
}
}
// Validate a nested object against a [JsonSchemaProperty] carrying its own shape — mirrors
// [validateObject] but keyed off the property's fields (they diverge only in defaults).
private fun validateProperty(property: JsonSchemaProperty, value: JsonObject): List<String> {
val errors = mutableListOf<String>()
property.required.filterNot { it in value }.forEach { errors += "missing required property '$it'" }
for ((key, element) in value) {
val nested = property.properties[key]
if (nested == null) {
if (!property.additionalProperties && property.properties.isNotEmpty()) {
errors += "unknown property '$key'"
}
} else {
errors += checkProperty(key, nested, element)
}
}
return errors
}
private fun checkType(type: String, value: JsonElement): String? {
val ok = when (type) {
"string" -> value is JsonPrimitive && value.isString