Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
@@ -90,6 +90,18 @@ object EventParser {
|
|||||||
WsEvent.ToolComplete(name, payload, sessionId)
|
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" -> {
|
"clarify.request" -> {
|
||||||
// Gateway sends "question"/"choices" — fall back to "text"/"options" for any
|
// Gateway sends "question"/"choices" — fall back to "text"/"options" for any
|
||||||
// older client or test that still uses the legacy field names. (Issue #206)
|
// older client or test that still uses the legacy field names. (Issue #206)
|
||||||
|
|||||||
@@ -62,6 +62,23 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : 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 ──────────────────────────────────────────────────────
|
// ── Interactive ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class ClarifyRequest(
|
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.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.theme.AssistantBubble
|
import com.m57.hermescontrol.theme.AssistantBubble
|
||||||
import com.m57.hermescontrol.theme.AssistantBubbleLight
|
import com.m57.hermescontrol.theme.AssistantBubbleLight
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.StatusGreen
|
import com.m57.hermescontrol.theme.StatusGreen
|
||||||
import com.m57.hermescontrol.theme.StatusRed
|
import com.m57.hermescontrol.theme.StatusRed
|
||||||
import com.m57.hermescontrol.theme.SystemMessageColor
|
import com.m57.hermescontrol.theme.SystemMessageColor
|
||||||
@@ -1893,6 +1894,12 @@ private fun ToolBubble(
|
|||||||
// ── Header row: icon + tool name ──
|
// ── Header row: icon + tool name ──
|
||||||
HeaderRow(message, config, contentColor)
|
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) ──
|
// ── Summary line (always visible when collapsed) ──
|
||||||
if (!expanded && parsed?.summaryText != null) {
|
if (!expanded && parsed?.summaryText != null) {
|
||||||
Text(
|
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
|
@Composable
|
||||||
private fun CopyButton(
|
private fun CopyButton(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ data class ApprovalInfo(
|
|||||||
val patternKeys: List<String>?,
|
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(
|
data class ChatMessage(
|
||||||
val id: String = UUID.randomUUID().toString(),
|
val id: String = UUID.randomUUID().toString(),
|
||||||
val role: MessageRole,
|
val role: MessageRole,
|
||||||
@@ -26,6 +37,12 @@ data class ChatMessage(
|
|||||||
val approvalInfo: ApprovalInfo? = null,
|
val approvalInfo: ApprovalInfo? = null,
|
||||||
/** Files attached to this message — shown inline in the bubble. */
|
/** Files attached to this message — shown inline in the bubble. */
|
||||||
val attachments: List<Attachment>? = null,
|
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 {
|
enum class MessageRole {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.MessageDone -> event.sessionId
|
is WsEvent.MessageDone -> event.sessionId
|
||||||
is WsEvent.ToolStart -> event.sessionId
|
is WsEvent.ToolStart -> event.sessionId
|
||||||
is WsEvent.ToolComplete -> event.sessionId
|
is WsEvent.ToolComplete -> event.sessionId
|
||||||
|
is WsEvent.ToolOutputRisk -> event.sessionId
|
||||||
is WsEvent.ClarifyRequest -> event.sessionId
|
is WsEvent.ClarifyRequest -> event.sessionId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,8 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.ToolComplete -> onToolComplete(state, streamingState, event)
|
is WsEvent.ToolComplete -> onToolComplete(state, streamingState, event)
|
||||||
|
|
||||||
|
is WsEvent.ToolOutputRisk -> onToolOutputRisk(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
|
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.RpcError -> onRpcError(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 ────────────────────────────────────────────────
|
// ── ClarifyRequest ────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun onClarifyRequest(
|
private fun onClarifyRequest(
|
||||||
|
|||||||
Reference in New Issue
Block a user