mirror of
https://github.com/usestrix/strix.git
synced 2026-08-25 04:12:37 +02:00
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:
@@ -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 {
|
||||
|
||||
+3
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
+5
@@ -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;
|
||||
}
|
||||
|
||||
+125
-125
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user