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
@@ -68,6 +68,7 @@ import com.correx.core.toolintent.rules.NetworkHostRule
import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.toolintent.rules.ReferenceExistsRule
import com.correx.core.toolintent.rules.ShellInFileContentRule
import com.correx.core.toolintent.rules.StaleWriteRule
import com.correx.core.toolintent.rules.WriteScopeRule
import com.correx.apps.server.freestyle.FreestyleDriver
@@ -200,15 +201,20 @@ fun main() {
?.let { Path.of(it) }
?: toolsConfig.sandboxRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: Paths.get(System.getProperty("user.home"), ".config", "correx", "sandbox")
val workingDir = System.getenv("CORREX_WORKING_DIR")
val explicitWorkingDir = System.getenv("CORREX_WORKING_DIR")
?.let { Path.of(it) }
?: toolsConfig.workingDir.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: Path.of("").toAbsolutePath()
val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet()
val workspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT")
val explicitWorkspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT")
?.let { Path.of(it) }
?: toolsConfig.workspaceRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: workingDir
// workingDir and workspaceRoot must resolve to the same tree by default — the tool-call
// assessor's containment rules (PathContainmentRule, ManifestContainmentRule) only ever see
// workspaceRoot, while FileWriteTool resolves relative paths against workingDir. If only one
// is configured, each falls back to the other before falling back to process CWD, so the
// assessor's containment check and the actual write always share one resolved root.
val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet()
val workspaceRoot = explicitWorkspaceRoot ?: explicitWorkingDir ?: Path.of("").toAbsolutePath()
val workingDir = explicitWorkingDir ?: workspaceRoot
// One shared HTTP client backs both the default and per-workspace registries' research tools
// (web_search/web_fetch). Built only when research is enabled, so the static path stays offline.
val researchToolConfig = com.correx.infrastructure.tools.ResearchToolConfig(
@@ -274,6 +280,7 @@ fun main() {
PathContainmentRule(),
ReadBeforeWriteRule(),
ReferenceExistsRule(),
ShellInFileContentRule(),
WriteScopeRule(),
StaleWriteRule(),
ManifestContainmentRule(),
@@ -510,6 +517,7 @@ fun main() {
config = defaultOrchestrationConfig,
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
requestPlanApproval = { sid, planJson -> orchestrator.requestPlanApproval(sid, planJson) },
toolCapabilities = toolRegistry.all().associate { it.name to it.requiredCapabilities },
)
// observability-spec §4: continuous health watch. Seed the monitor's last-status from the
// recorded system-session events so a restart doesn't re-emit a degraded already in the log.
@@ -340,6 +340,7 @@ private suspend fun mapInferenceCompleted(
outputSummary = this,
responseText = this,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
totalTokens = p.tokensUsed.totalTokens,
sequence = event.sequence,
sessionSequence = sessionSequence,
)
@@ -181,7 +181,7 @@ class SessionEventBridge(
ToolDecl(name = tool.name, tier = tool.tier.level)
}
}
StageToolDecl(stageId = stageId.value, tools = tools)
StageToolDecl(stageId = stageId.value, tools = tools, tokenBudget = stageConfig.tokenBudget)
}
send(ServerMessage.StageToolManifest(
sessionId = sessionId,
@@ -1,16 +1,20 @@
package com.correx.apps.server.freestyle
import com.correx.core.events.events.CapabilityGapDetectedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.PlanLintCompletedEvent
import com.correx.core.tools.contract.ToolCapability
import com.correx.infrastructure.workflow.CapabilityGapDetector
import com.correx.infrastructure.workflow.PlanLintResult
import com.correx.infrastructure.workflow.PlanLinter
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.transitions.graph.WorkflowGraph
@@ -26,6 +30,7 @@ import java.util.UUID
* [runPhase2] is a functional seam so the driver can be tested without the full
* [DefaultSessionOrchestrator] constructor chain.
*/
@Suppress("LongParameterList")
class FreestyleDriver(
private val eventStore: EventStore,
private val compiler: ExecutionPlanCompiler,
@@ -33,6 +38,10 @@ class FreestyleDriver(
private val config: OrchestrationConfig,
private val runPhase2: suspend (SessionId, WorkflowGraph, OrchestrationConfig) -> WorkflowResult,
private val requestPlanApproval: suspend (SessionId, String) -> Boolean = { _, _ -> true },
// Tool name -> the capabilities that tool grants (Tool.requiredCapabilities), used only by the
// capability-gap detector below. Empty = detector finds nothing (preserves callers/tests that
// don't care about it) rather than depending on a live ToolRegistry here.
private val toolCapabilities: Map<String, Set<ToolCapability>> = emptyMap(),
) {
suspend fun lockAndRun(sessionId: SessionId) {
val json = planContent(sessionId) ?: run {
@@ -59,6 +68,16 @@ class FreestyleDriver(
emitRejected(sessionId, "plan failed lint: $summary", "lint")
return
}
// Capability-gap detector (Vikunja #30 part 1): advisory only — recorded, never blocking.
// A gap does not fail the gate and does not grant the missing tool (invariants #3/#4/#5); a
// future reflection rung (part 2) is what will act on these events.
CapabilityGapDetector.detect(graph, toolCapabilities).forEach { gap ->
log.info(
"[FreestyleDriver] capability gap session={} stage={} implied={} evidence='{}'",
sessionId.value, gap.stageId, gap.impliedCapability, gap.phrase,
)
emitCapabilityGap(sessionId, gap.stageId, gap.impliedCapability.name, gap.phrase)
}
val approved = requestPlanApproval(sessionId, json)
if (!approved) {
log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value)
@@ -121,6 +140,33 @@ class FreestyleDriver(
)
}
private suspend fun emitCapabilityGap(
sessionId: SessionId,
stageId: String,
impliedCapability: String,
evidence: String,
) {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = CapabilityGapDetectedEvent(
sessionId = sessionId,
stageId = StageId(stageId),
impliedCapability = impliedCapability,
evidence = evidence,
timestampMs = Clock.System.now().toEpochMilliseconds(),
),
),
)
}
private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
eventStore.append(
NewEvent(
@@ -86,6 +86,32 @@ sealed class ClientMessage {
@Serializable
data class UpdateConfig(val patch: Map<String, String>) : ClientMessage()
/** Operator request for the ProjectProfile bound to [sessionId]'s workspace. */
@Serializable
data class GetProjectProfile(val sessionId: SessionId) : ClientMessage()
/** Operator request to overwrite the ProjectProfile at [sessionId]'s workspace root. */
@Serializable
data class UpdateProjectProfile(
val sessionId: SessionId,
val about: String,
val conventions: List<String>,
val commands: Map<String, String>,
) : ClientMessage()
/** Operator request for the current OperatorProfile (home-scoped, not session-bound). */
@Serializable
data object GetOperatorProfile : ClientMessage()
/** Operator request to overwrite the OperatorProfile. */
@Serializable
data class UpdateOperatorProfile(
val about: String,
val approvalMode: String,
val preferredModels: List<String>,
val conventions: List<String>,
) : ClientMessage()
@Serializable
data class ApprovalResponse(
val requestId: ApprovalRequestId,
@@ -102,6 +128,18 @@ sealed class ClientMessage {
val answers: List<ClarificationAnswer>,
) : ClientMessage()
/**
* Free-form operator steering for a session that is mid-stage, independent of any approval
* gate. Unlike [ApprovalResponse.steeringNote] (only deliverable while parked on an approval)
* this can be sent at any time; the orchestrator records it and folds it into the stage's next
* context turn as advisory input (never a state mutation — see SessionOrchestrator.submitSteering).
*/
@Serializable
data class SteerSession(
val sessionId: SessionId,
val text: String,
) : ClientMessage()
@Serializable
data class CreateGrant(
val sessionId: SessionId,
@@ -57,6 +57,7 @@ enum class PauseReason {
data class StageToolDecl(
val stageId: String,
val tools: List<ToolDecl>,
val tokenBudget: Int? = null,
)
@Serializable
@@ -189,6 +189,7 @@ sealed interface ServerMessage {
val outputSummary: String,
val responseText: String = "",
val occurredAt: Long,
val totalTokens: Int? = null,
override val sequence: Long,
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
@@ -546,6 +547,33 @@ sealed interface ServerMessage {
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/** The ProjectProfile bound to a session's workspace (reply to GetProjectProfile / UpdateProjectProfile). */
@Serializable
@SerialName("project_profile.snapshot")
data class ProjectProfileSnapshot(
val sessionId: SessionId,
val about: String,
val conventions: List<String>,
val commands: Map<String, String>,
val error: String? = null,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/** The operator's OperatorProfile (reply to GetOperatorProfile / UpdateOperatorProfile). */
@Serializable
@SerialName("operator_profile.snapshot")
data class OperatorProfileSnapshot(
val about: String,
val approvalMode: String,
val preferredModels: List<String>,
val conventions: List<String>,
val proposedAdaptation: String? = null,
val error: String? = null,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
// -- Router --
@Serializable
@@ -32,6 +32,11 @@ import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.config.OperatorProfile
import com.correx.core.config.OperatorProfileWriter
import com.correx.core.config.ProfileLoader
import com.correx.core.config.ProfilePreferences
import com.correx.core.config.ProjectProfile
import com.correx.core.config.ProjectProfileLoader
import com.correx.core.config.ProjectProfileWriter
import com.correx.core.config.withConvention
@@ -56,6 +61,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.util.UUID
private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java)
@@ -244,9 +250,14 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks())
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
is ClientMessage.GetProjectProfile -> handleGetProjectProfile(msg, sendFrame)
is ClientMessage.UpdateProjectProfile -> handleUpdateProjectProfile(msg, sendFrame)
is ClientMessage.GetOperatorProfile -> handleGetOperatorProfile(sendFrame)
is ClientMessage.UpdateOperatorProfile -> handleUpdateOperatorProfile(msg, sendFrame)
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
is ClientMessage.SteerSession -> handleSteerSession(msg)
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame)
is ClientMessage.ListGrants -> sendFrame(queries.listGrants())
@@ -318,6 +329,16 @@ class GlobalStreamHandler(private val module: ServerModule) {
}
}
// Free-form mid-stage steering, independent of any approval gate — see
// DefaultSessionOrchestrator.submitSteering and ClientMessage.SteerSession.
private suspend fun handleSteerSession(msg: ClientMessage.SteerSession) {
runCatching {
module.orchestrator.submitSteering(msg.sessionId, msg.text)
}.onFailure {
log.error("submitSteering failed for session={}: {}", msg.sessionId.value, it.message, it)
}
}
// Tombstone an idea, landing the discard in the session that captured it (so all of one idea's
// events stay co-located), then reply with the refreshed board. A missing idea is a no-op.
private suspend fun handleDiscardIdea(
@@ -397,6 +418,131 @@ class GlobalStreamHandler(private val module: ServerModule) {
.lastOrNull()
?.workspaceRoot
private suspend fun handleGetProjectProfile(
msg: ClientMessage.GetProjectProfile,
sendFrame: suspend (ServerMessage) -> Unit,
) {
val workspaceRoot = workspaceRootOf(msg.sessionId)
if (workspaceRoot == null) {
sendFrame(
ServerMessage.ProjectProfileSnapshot(
sessionId = msg.sessionId,
about = "",
conventions = emptyList(),
commands = emptyMap(),
error = "session has no bound workspace",
),
)
return
}
val profile = withContext(Dispatchers.IO) { ProjectProfileLoader.load(workspaceRoot) }
sendFrame(
ServerMessage.ProjectProfileSnapshot(
sessionId = msg.sessionId,
about = profile.about,
conventions = profile.conventions,
commands = profile.commands,
),
)
}
private suspend fun handleUpdateProjectProfile(
msg: ClientMessage.UpdateProjectProfile,
sendFrame: suspend (ServerMessage) -> Unit,
) {
val workspaceRoot = workspaceRootOf(msg.sessionId)
if (workspaceRoot == null) {
sendFrame(
ServerMessage.ProjectProfileSnapshot(
sessionId = msg.sessionId,
about = "",
conventions = emptyList(),
commands = emptyMap(),
error = "session has no bound workspace",
),
)
return
}
val profile = ProjectProfile(about = msg.about, conventions = msg.conventions, commands = msg.commands)
runCatching {
withContext(Dispatchers.IO) { ProjectProfileWriter.write(workspaceRoot, profile) }
}.onFailure {
log.error("updateProjectProfile failed for session={}: {}", msg.sessionId.value, it.message, it)
sendFrame(
ServerMessage.ProjectProfileSnapshot(
sessionId = msg.sessionId,
about = profile.about,
conventions = profile.conventions,
commands = profile.commands,
error = "failed to save: ${it.message}",
),
)
return
}
sendFrame(
ServerMessage.ProjectProfileSnapshot(
sessionId = msg.sessionId,
about = profile.about,
conventions = profile.conventions,
commands = profile.commands,
),
)
}
private suspend fun handleGetOperatorProfile(sendFrame: suspend (ServerMessage) -> Unit) {
val profile = withContext(Dispatchers.IO) { ProfileLoader.load() }
val proposedPath = ProfileLoader.profilePath().resolveSibling("profile.proposed.toml")
val proposed = withContext(Dispatchers.IO) {
runCatching { if (Files.exists(proposedPath)) Files.readString(proposedPath) else null }.getOrNull()
}
sendFrame(
ServerMessage.OperatorProfileSnapshot(
about = profile.about,
approvalMode = profile.preferences.approvalMode,
preferredModels = profile.preferences.preferredModels,
conventions = profile.preferences.conventions,
proposedAdaptation = proposed,
),
)
}
private suspend fun handleUpdateOperatorProfile(
msg: ClientMessage.UpdateOperatorProfile,
sendFrame: suspend (ServerMessage) -> Unit,
) {
val profile = OperatorProfile(
about = msg.about,
preferences = ProfilePreferences(
approvalMode = msg.approvalMode,
preferredModels = msg.preferredModels,
conventions = msg.conventions,
),
)
runCatching {
withContext(Dispatchers.IO) { OperatorProfileWriter.write(profile) }
}.onFailure {
log.error("updateOperatorProfile failed: {}", it.message, it)
sendFrame(
ServerMessage.OperatorProfileSnapshot(
about = profile.about,
approvalMode = profile.preferences.approvalMode,
preferredModels = profile.preferences.preferredModels,
conventions = profile.preferences.conventions,
error = "failed to save: ${it.message}",
),
)
return
}
sendFrame(
ServerMessage.OperatorProfileSnapshot(
about = profile.about,
approvalMode = profile.preferences.approvalMode,
preferredModels = profile.preferences.preferredModels,
conventions = profile.preferences.conventions,
),
)
}
private fun errorResponse(message: String) = ServerMessage.ProtocolError(message)
private fun encodeError(message: String): Frame.Text =
@@ -260,7 +260,8 @@ class DomainEventMapperTest {
val result = domainEventToServerMessage(event, store, sessionSequence = 0L)
assertEquals(
ServerMessage.InferenceCompleted(
sessionId, stageId, responseText, responseText, occurredAt, event.sequence, 0L,
sessionId, stageId, responseText, responseText, occurredAt,
totalTokens = 15, sequence = event.sequence, sessionSequence = 0L,
),
result,
)
@@ -281,7 +282,10 @@ class DomainEventMapperTest {
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals(
ServerMessage.InferenceCompleted(sessionId, stageId, "", "", occurredAt, event.sequence, 0L),
ServerMessage.InferenceCompleted(
sessionId, stageId, "", "", occurredAt,
totalTokens = 15, sequence = event.sequence, sessionSequence = 0L,
),
result,
)
}