From 168d27dcca888473f2493e10739afc2cab05d948 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:21:07 +0300 Subject: [PATCH] feat(#552): surface tool.output_risk WS event as security chip (#561) Co-authored-by: M57 --- .../m57/hermescontrol/data/ws/EventParser.kt | 12 ++++ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 17 ++++++ .../m57/hermescontrol/ui/chat/ChatBubble.kt | 57 +++++++++++++++++++ .../m57/hermescontrol/ui/chat/ChatMessage.kt | 17 ++++++ .../ui/chat/ChatWsEventReducer.kt | 53 +++++++++++++++++ 5 files changed, 156 insertions(+) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 04cc135c..dc92bd73 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -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() ?: 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) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 698ff2dd..1abea4c1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -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 = emptyList(), + val redacted: Boolean = false, + val sessionId: String? = null, + ) : WsEvent() + // ── Interactive ────────────────────────────────────────────────────── data class ClarifyRequest( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index e4f16812..6c6db7bc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 98c3ef5c..22ee2e5c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -14,6 +14,17 @@ data class ApprovalInfo( val patternKeys: List?, ) +/** + * 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, + 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? = 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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 671e1f1e..c97e99e6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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(