feat(#552): surface tool.output_risk WS event as security chip (#561)

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-12 13:21:07 +03:00
1 parent 3052db7d65
commit 168d27dcca
5 files changed
+156

No files matched your search

@@ -90,6 +90,18 @@ object EventParser {
WsEvent.ToolComplete(name, payload, sessionId)
}
"tool.output_risk" -> {
val toolId = payload?.get("tool_id") as? String ?: ""
val name = payload?.get("name") as? String ?: ""
val risk = (payload?.get("risk") as? String)?.lowercase() ?: "low"
val redacted = payload?.get("redacted") as? Boolean ?: false
@Suppress("UNCHECKED_CAST")
val findings = (payload?.get("findings") as? List<*>)?.filterIsInstance<String>() ?: emptyList()
WsEvent.ToolOutputRisk(toolId, name, risk, findings, redacted, sessionId)
}
"clarify.request" -> {
// Gateway sends "question"/"choices" — fall back to "text"/"options" for any
// older client or test that still uses the legacy field names. (Issue #206)
@@ -62,6 +62,23 @@ sealed class WsEvent {
val sessionId: String? = null,
) : WsEvent()
/**
* Backend flagged tool output as having potential risk (secrets, PII).
* Emitted alongside tool.progress — carries risk level, findings, and
* whether content was redacted.
*
* Events: `tool.output_risk`
* Payload: `{ tool_id, name, risk, findings[], redacted }`
*/
data class ToolOutputRisk(
val toolId: String = "",
val name: String = "",
val risk: String = "low",
val findings: List<String> = emptyList(),
val redacted: Boolean = false,
val sessionId: String? = null,
) : WsEvent()
// ── Interactive ──────────────────────────────────────────────────────
data class ClarifyRequest(
@@ -85,6 +85,7 @@ import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.theme.AssistantBubble
import com.m57.hermescontrol.theme.AssistantBubbleLight
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.SystemMessageColor
@@ -1893,6 +1894,12 @@ private fun ToolBubble(
// ── Header row: icon + tool name ──
HeaderRow(message, config, contentColor)
// ── Security risk chip (tool.output_risk) ──
val riskData = message.toolOutputRiskData
if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) {
SecurityRiskChip(riskData, contentColor)
}
// ── Summary line (always visible when collapsed) ──
if (!expanded && parsed?.summaryText != null) {
Text(
@@ -2069,6 +2076,56 @@ private fun HeaderRow(
}
}
/**
* Security risk chip for [tool.output_risk] events.
*
* Shows a compact ⚠ badge when the backend flagged tool output as risky.
* Renders in the tool card between the header row and the summary line.
*/
@Composable
private fun SecurityRiskChip(
riskData: ToolOutputRiskData,
contentColor: Color,
modifier: Modifier = Modifier,
) {
val statusColors = LocalHermesStatusColors.current
val (chipColor, label) =
when {
riskData.risk == "high" -> statusColors.error to "Risky output"
riskData.risk == "medium" -> statusColors.warning to "Caution"
else -> statusColors.warning to "Redacted"
}
Row(
modifier =
modifier
.padding(top = 4.dp, start = 22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "⚠",
style = MaterialTheme.typography.labelSmall,
color = chipColor,
)
Text(
text = label,
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Medium,
),
color = chipColor,
)
if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) {
Text(
text = "· redacted",
style = MaterialTheme.typography.labelSmall,
color = chipColor.copy(alpha = 0.7f),
)
}
}
}
@Composable
private fun CopyButton(
visible: Boolean,
@@ -14,6 +14,17 @@ data class ApprovalInfo(
val patternKeys: List<String>?,
)
/**
* Risk metadata from the backend's [tool.output_risk] WS event.
* Attached to the tool [ChatMessage] so the UI can render a security chip.
* Transient — not persisted to SQLite.
*/
data class ToolOutputRiskData(
val risk: String, // "low" | "medium" | "high"
val findings: List<String>,
val redacted: Boolean,
)
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val role: MessageRole,
@@ -26,6 +37,12 @@ data class ChatMessage(
val approvalInfo: ApprovalInfo? = null,
/** Files attached to this message — shown inline in the bubble. */
val attachments: List<Attachment>? = null,
/**
* Risk metadata from [tool.output_risk] WS event.
* When risk is "medium"/"high" or redacted is true, the UI shows a ⚠ chip.
* Transient — not persisted to SQLite.
*/
val toolOutputRiskData: ToolOutputRiskData? = null,
)
enum class MessageRole {
@@ -32,6 +32,7 @@ object ChatWsEventReducer {
is WsEvent.MessageDone -> event.sessionId
is WsEvent.ToolStart -> event.sessionId
is WsEvent.ToolComplete -> event.sessionId
is WsEvent.ToolOutputRisk -> event.sessionId
is WsEvent.ClarifyRequest -> event.sessionId
else -> null
}
@@ -67,6 +68,8 @@ object ChatWsEventReducer {
is WsEvent.ToolComplete -> onToolComplete(state, streamingState, event)
is WsEvent.ToolOutputRisk -> onToolOutputRisk(state, streamingState, event)
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
@@ -350,6 +353,56 @@ object ChatWsEventReducer {
)
}
// ── ToolOutputRisk ────────────────────────────────────────────────
/**
* Attaches [ToolOutputRiskData] to the matching tool message.
*
* Finds the last RUNNING tool message with matching [WsEvent.ToolOutputRisk.name].
* If no RUNNING message exists (tool already completed), falls back to the last
* COMPLETED tool with that name. This covers the case where the risk event arrives
* after tool.complete.
*/
private fun onToolOutputRisk(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ToolOutputRisk,
): ReducerResult {
val riskData =
ToolOutputRiskData(
risk = event.risk,
findings = event.findings,
redacted = event.redacted,
)
val messages = state.messages.toMutableList()
// Prefer RUNNING tool, fall back to COMPLETED
var toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx < 0) {
toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.COMPLETED
}
}
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(toolOutputRiskData = riskData)
// No persist — risk data is transient (not stored in SQLite)
return ReducerResult(
state = state.copy(messages = messages),
streamingState = streamingState,
)
}
// ── ClarifyRequest ────────────────────────────────────────────────
private fun onClarifyRequest(