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
+20 -3
View File
@@ -119,6 +119,10 @@ func (m Model) renderOverlay(base string) string {
return m.center(m.artifactsModal())
case OverlayConfig:
return m.center(m.configModal())
case OverlayProjectProfile:
return m.center(m.projectProfileModal())
case OverlayOperatorProfile:
return m.center(m.operatorProfileModal())
case OverlayStats:
return m.center(m.statsModal())
case OverlayIdeas:
@@ -160,7 +164,11 @@ func (m Model) approvalBandHeight() int {
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
rows = m.approvalContentRows(s.Pending)
if n := len(s.Pending.Rationale); n > 0 {
rat = n + 1 // rationale lines + trailing blank
textW := m.width - 4 - 2 // box borders/padding + rationale marker
for _, r := range s.Pending.Rationale {
rat += len(wrapLine(r, textW))
}
rat++ // trailing blank
}
}
steer := 0
@@ -219,7 +227,10 @@ func (m Model) renderApprovalBand(h int) string {
innerH := h - 2
ratBlock := 0
if len(a.Rationale) > 0 {
ratBlock = len(a.Rationale) + 1 // rationale lines + trailing blank
for _, r := range a.Rationale {
ratBlock += len(wrapLine(r, textW-2))
}
ratBlock++ // trailing blank
}
steerBlock := 0
if m.steering || m.steerBuffer != "" {
@@ -233,7 +244,13 @@ func (m Model) renderApprovalBand(h int) string {
body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "")
for _, r := range a.Rationale {
marker := lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render("▲ ")
body = append(body, marker+t.span(truncate(r, textW-2), t.P.Dim))
for i, ln := range wrapLine(r, textW-2) {
prefix := marker
if i > 0 {
prefix = " "
}
body = append(body, prefix+t.span(ln, t.P.Dim))
}
}
if len(a.Rationale) > 0 {
body = append(body, "")