fix(tui,viewer): show the safety verdict for every blocked tool

Only the terminal renderers learned about the `blocked` status. `apply_patch`
and `repeat_request` are also refused by the safety runtime — `apply_patch`
unconditionally in observe mode, `repeat_request` unconditionally in guarded —
and both rendered as though nothing had happened. A blocked patch was
byte-identical to one that was applied.

Extract the verdict line into `safetyBlockLine` in the Go renderers and a
`SafetyBlock` component in the viewer, then call it from the patch and
repeat-request renderers as well. The viewer's terminal renderer was reading
the envelope's `error`, which is a fixed string, so it now reads
`safety.reason` like the TUI already did.

Go tests assert a blocked patch no longer matches an applied one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
oyasumi
2026-08-08 00:56:18 +00:00
co-authored by Claude Opus 5
parent c5fca380ef
commit 58d8a9b7c4
11 changed files with 221 additions and 136 deletions
@@ -134,6 +134,9 @@ func renderApplyPatch(args map[string]any, result any, status string) string {
}
renderPatchOperation(&b, op)
}
if status == "blocked" {
b.WriteString("\n " + safetyBlockLine(result))
}
if status == "failed" {
if s, ok := result.(string); ok && strings.TrimSpace(s) != "" {
b.WriteString("\n " + Col(Red).Render(strings.TrimSpace(s)))
@@ -286,6 +286,10 @@ func renderRepeatRequest(args map[string]any, result any, status string) string
} else if mods, ok := args["modifications"].(string); ok && mods != "" {
b.WriteString(Dim().Italic(true).Render("\n " + ptrunc(mods, 200)))
}
if status == "blocked" {
b.WriteString("\n " + safetyBlockLine(result))
return b.String()
}
if status == "completed" {
if m, ok := resultMapOf(result); ok {
success, hasSuccess := m["success"].(bool)
@@ -135,3 +135,18 @@ func CollapseTool(full, name string, expanded bool) (string, bool) {
hint := Dim().Italic(true).Render(fmt.Sprintf(" … +%d line%s — click to expand", hidden, plural))
return preview + "\n" + hint, true
}
// safetyBlockLine renders the safety verdict for a tool call the safety runtime
// refused. Every renderer that shows a result must call it: without it a blocked
// call is indistinguishable from one that ran.
func safetyBlockLine(result any) string {
reason := "Action blocked by safety policy"
if envelope, ok := result.(map[string]any); ok {
if safety, ok := envelope["safety"].(map[string]any); ok {
if value := StringValue(safety["reason"]); value != "" {
reason = value
}
}
}
return Col(AmberY).Render("■ Blocked: " + reason)
}
@@ -259,3 +259,37 @@ func TestCollapseToolOnlyOutputHeavyTools(t *testing.T) {
t.Fatal("respond_to_user must never collapse")
}
}
func TestBlockedApplyPatchIsDistinguishableFromApplied(t *testing.T) {
blocked := map[string]any{
"success": false,
"status": "blocked",
"error": "Action blocked by safety policy",
"safety": map[string]any{
"reason": "apply_patch mutates state and is blocked in observe mode.",
},
}
args := map[string]any{"patch": "*** Update File: src/app.py\n-import os\n+import sys"}
out := Tool(tool("apply_patch", args, blocked, "blocked"))
applied := Tool(tool("apply_patch", args, map[string]any{"success": true}, "completed"))
if out == applied {
t.Fatal("a blocked patch renders identically to one that was applied")
}
requireContains(t, out, "Blocked", "blocked in observe mode")
}
func TestBlockedRepeatRequestShowsTheReason(t *testing.T) {
blocked := map[string]any{
"success": false,
"status": "blocked",
"safety": map[string]any{
"reason": "repeat_request is blocked in guarded mode until the final effective method",
},
}
out := Tool(tool("repeat_request", map[string]any{"request_id": "7"}, blocked, "blocked"))
requireContains(t, out, "Blocked", "guarded mode")
}
@@ -155,15 +155,7 @@ func renderTerminal(prompt string, promptColor lipgloss.Color, command string, r
b.WriteString(Dim().Render(" " + meta))
}
if status == "blocked" {
reason := "Action blocked by safety policy"
if envelope, ok := result.(map[string]any); ok {
if safety, ok := envelope["safety"].(map[string]any); ok {
if value := StringValue(safety["reason"]); value != "" {
reason = value
}
}
}
b.WriteString("\n" + Col(AmberY).Render("■ Blocked: "+reason))
b.WriteString("\n" + safetyBlockLine(result))
return b.String()
}
if result != nil {
@@ -2,6 +2,7 @@
import type { ToolRendererProps } from "@/types/events";
import { shortPath } from "./utils";
import SafetyBlock from "./SafetyBlock";
const DIFF_PREVIEW_LINES = 30;
@@ -107,6 +108,7 @@ export default function ApplyPatchRenderer({ args, result, status }: ToolRendere
{status === "failed" && typeof result === "string" && result.trim() && (
<div className="text-red-400/70 text-[13px] mt-1">{result.trim()}</div>
)}
<SafetyBlock status={status} result={result} />
</div>
);
}
@@ -119,6 +121,7 @@ export default function ApplyPatchRenderer({ args, result, status }: ToolRendere
{status === "failed" && typeof result === "string" && result.trim() && (
<div className="text-red-400/70 text-[13px]">{result.trim()}</div>
)}
<SafetyBlock status={status} result={result} />
</div>
);
}
@@ -2,6 +2,7 @@
import type { ToolRendererProps } from "@/types/events";
import { CodeBlock } from "./ToolCard";
import SafetyBlock from "./SafetyBlock";
const MAX_LINE_LENGTH = 200;
@@ -161,7 +162,7 @@ function SendRequest({ args, result }: ToolRendererProps) {
);
}
function RepeatRequest({ args, result }: ToolRendererProps) {
function RepeatRequest({ args, result, status }: ToolRendererProps) {
const requestId = args.request_id as number | undefined;
const modifications = args.modifications as Record<string, unknown> | undefined;
const res = result as Record<string, unknown> | null;
@@ -193,6 +194,7 @@ function RepeatRequest({ args, result }: ToolRendererProps) {
{resBody && (
<CodeBlock className="text-[#666]">{limitBody(resBody, 5)}</CodeBlock>
)}
<SafetyBlock status={status} result={result} />
</div>
);
}
@@ -0,0 +1,27 @@
import type { ToolRendererProps } from "../../../types/events";
/**
* The safety verdict for a tool call the safety runtime refused.
*
* Every renderer that shows a result must render this: without it a blocked call is
* indistinguishable from one that ran. The envelope's `error` is a fixed string, so the
* reason has to come from `safety.reason`.
*/
export default function SafetyBlock({ status, result }: Pick<ToolRendererProps, "status" | "result">) {
if (status !== "blocked") return null;
const envelope = result as Record<string, unknown> | null;
const safety =
envelope && typeof envelope === "object" ? (envelope.safety as Record<string, unknown> | undefined) : undefined;
const reason =
safety && typeof safety.reason === "string" && safety.reason.trim()
? safety.reason.trim()
: "Action blocked by safety policy";
return (
<div className="flex items-start gap-1.5 text-amber-400/80 text-[13px] mt-1">
<span className="shrink-0"></span>
<span>{reason}</span>
</div>
);
}
@@ -111,6 +111,11 @@ export default function TerminalRenderer({ toolName, args, result }: ToolRendere
exitCode = typeof res.exit_code === "number" ? res.exit_code : null;
const s = typeof res.status === "string" ? res.status : "";
if (s === "running" || s === "command still running") content = null;
// `error` is a fixed string for a safety block; the reason lives under `safety`.
const safety = res.safety as Record<string, unknown> | undefined;
if (safety && typeof safety.reason === "string" && safety.reason.trim()) {
error = safety.reason.trim();
}
} else if (typeof res === "string") {
content = res;
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-DCEAfGzO.js"></script>
<script type="module" crossorigin src="./assets/index-r-g5v3FU.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Bl0WqVdc.css">
</head>
<body>