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:
@@ -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)
|
||||
|
||||
+25
@@ -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,
|
||||
|
||||
+89
@@ -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")
|
||||
}
|
||||
}
|
||||
+85
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user