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
+26 -1
View File
@@ -151,6 +151,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if s := m.session(msg.SessionID); s != nil {
s.CurrentStage = msg.StageID
s.Tools = nil
s.StageTokensUsed = 0
s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID)
}
case protocol.TypeStageCompleted:
@@ -175,6 +176,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if msg.Response != "" {
s.LastResponse = msg.Response
}
if msg.TotalTokens != nil {
s.StageTokensUsed = *msg.TotalTokens
}
s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID)
}
case protocol.TypeInferenceTimeout:
@@ -292,15 +296,36 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
case protocol.TypeStageManifest:
if s := m.session(msg.SessionID); s != nil {
byStage := map[string][]ManifestTool{}
budgetByStage := map[string]int{}
for _, st := range msg.Stages {
tools := make([]ManifestTool, 0, len(st.Tools))
for _, td := range st.Tools {
tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier})
}
byStage[st.StageID] = tools
if st.TokenBudget != nil {
budgetByStage[st.StageID] = *st.TokenBudget
}
}
s.ToolsByStage = byStage
s.TokenBudgetByStage = budgetByStage
}
case protocol.TypeProjectProfile:
errMsg := ""
if msg.ConfigError != nil {
errMsg = *msg.ConfigError
}
m.applyProjectProfileSnapshot(msg.About, msg.Conventions, msg.Commands, errMsg)
case protocol.TypeOperatorProfile:
errMsg := ""
if msg.ConfigError != nil {
errMsg = *msg.ConfigError
}
proposed := ""
if msg.ProposedAdaptation != nil {
proposed = *msg.ProposedAdaptation
}
m.applyOperatorProfileSnapshot(msg.About, msg.ApprovalMode, msg.PreferredModels, msg.Conventions, proposed, errMsg)
case protocol.TypeProviderStatus:
m.currentModel = msg.ProviderID
if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") {
@@ -411,7 +436,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
protocol.TypeIdeaList, protocol.TypeHealthChecks,
protocol.TypeFileList, protocol.TypeGrantList:
protocol.TypeFileList, protocol.TypeGrantList, protocol.TypeOperatorProfile:
return ""
default:
return msg.SessionID