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
+62 -26
View File
@@ -64,6 +64,8 @@ const (
OverlayGrants
OverlayGrantScope
OverlayHelp
OverlayProjectProfile
OverlayOperatorProfile
)
// RouterEntry is one line in a session's conversation transcript.
@@ -109,6 +111,13 @@ type ManifestTool struct {
Tier int
}
// ProfileField is one editable row in the project/operator profile editors — a flat
// key/value text field, mirroring the config editor's STRING-field editing model.
type ProfileField struct {
Key string
Value string
}
// Approval is a pending approval gate for a session.
type Approval struct {
RequestID string
@@ -179,18 +188,20 @@ type Proposal struct {
// Session is the UI's view of one server session.
type Session struct {
ID string
Status string
WorkflowID string
Name string
LastEventAt int64
CurrentStage string
WorkspaceRoot string // bound cwd, from session.workspace_bound
LastOutput string
LastResponse string
Tools []ToolRecord
ToolsByStage map[string][]ManifestTool
Events []EventEntry
ID string
Status string
WorkflowID string
Name string
LastEventAt int64
CurrentStage string
WorkspaceRoot string // bound cwd, from session.workspace_bound
LastOutput string
LastResponse string
Tools []ToolRecord
ToolsByStage map[string][]ManifestTool
TokenBudgetByStage map[string]int // stage -> ceiling, from stage.tool_manifest
StageTokensUsed int // tokens used by the most recent inference in CurrentStage
Events []EventEntry
// Pending is the *current* approval gate shown in the band. It always mirrors
// PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can
// keep reading a single pointer while multiple gates queue up behind it.
@@ -301,6 +312,29 @@ type Model struct {
configRestart []string // keys from the last save that need a restart
configLoading bool
// project profile editor (OverlayProjectProfile) — populated by project_profile.snapshot;
// mirrors the config editor's field-list/staged-edit pattern. fields[0] is "about",
// fields[1] is "conventions" (semicolon-joined), the rest are one row per command.
projectProfileFields []ProfileField
projectProfileIndex int
projectProfileStaged map[string]string
projectProfileEditing bool
projectProfileEditBuf string
projectProfileError string
projectProfileLoading bool
// operator profile editor (OverlayOperatorProfile) — populated by operator_profile.snapshot.
// fields are "about", "approvalMode", "preferredModels" (comma-joined), "conventions"
// (semicolon-joined). proposedAdaptation is read-only (from ProfileAdaptationService).
operatorProfileFields []ProfileField
operatorProfileIndex int
operatorProfileStaged map[string]string
operatorProfileEditing bool
operatorProfileEditBuf string
operatorProfileError string
operatorProfileLoading bool
operatorProfileProposed string
// session stats (OverlayStats) — populated by the session.stats reply
stats *protocol.StatsDto
statsFor string // sessionId the current stats belong to
@@ -413,20 +447,22 @@ type Model struct {
// NewModel builds the initial idle state.
func NewModel(client *ws.Client) Model {
return Model{
client: client,
theme: NewTheme(SoftBlue),
wfIndex: -1,
inputMode: ModeRouter,
historyIndex: -1,
routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{},
chatMode: ChatModeChat,
providerType: "LOCAL",
snapshotPhase: true,
eventStripShown: true,
transcriptSel: -1,
sbHidden: loadStatusbarHidden(),
actionsHidden: loadPrefs().InlineActionsHidden,
client: client,
theme: NewTheme(SoftBlue),
wfIndex: -1,
inputMode: ModeRouter,
historyIndex: -1,
routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{},
projectProfileStaged: map[string]string{},
operatorProfileStaged: map[string]string{},
chatMode: ChatModeChat,
providerType: "LOCAL",
snapshotPhase: true,
eventStripShown: true,
transcriptSel: -1,
sbHidden: loadStatusbarHidden(),
actionsHidden: loadPrefs().InlineActionsHidden,
}
}