Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion internal/guard/app/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,12 +287,23 @@ func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, events)
writeJSON(w, http.StatusOK, dashboardDecisionRecords(events))
default:
writeError(w, http.StatusNotFound, "not found")
}
}

func dashboardDecisionRecords(records []sqlite.DecisionRecord) []sqlite.DecisionRecord {
out := make([]sqlite.DecisionRecord, len(records))
for i, record := range records {
out[i] = record
out[i].ModelVersion = ""
out[i].RiskEvent.ModelVersion = ""
out[i].RiskEvent.JudgeModel = ""
}
Comment thread
hasandemirkiran marked this conversation as resolved.
return out
}

func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
dist, err := fs.Sub(dashboardassets.FS, "dist")
if err != nil {
Expand Down
60 changes: 60 additions & 0 deletions internal/guard/app/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,66 @@ func TestProcessHookEventPreservesRiskMetadata(t *testing.T) {
}
}

func TestDashboardEventsHideModelMetadata(t *testing.T) {
store, err := sqlite.OpenStore(t.TempDir() + "/guard.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
policy := recordingPolicy{
decision: risk.RiskDecision{
Decision: risk.DecisionDeny,
Reason: "local judge denied",
ReasonCode: risk.DecisionStageJudgeDeny,
ModelVersion: "qwen3-0.6b-q4",
RiskEvent: risk.RiskEvent{
Type: risk.EventNormalToolCall,
DecisionStage: risk.DecisionStageJudgeDeny,
ModelVersion: "qwen3-0.6b-q4",
JudgeModel: "qwen3-0.6b-q4",
JudgeRiskLevel: "high",
},
},
}
server := newTestServerWithPolicy(t, store, &policy)
if _, err := server.ProcessHookEvent(context.Background(), risk.HookEvent{
SessionID: "s1",
HookEventName: "PreToolUse",
ToolName: "Bash",
ToolInput: map[string]any{"command": "curl -X POST https://admin.example/reindex"},
}); err != nil {
t.Fatal(err)
}
stored, err := store.Events(context.Background(), "s1")
if err != nil {
t.Fatal(err)
}
if len(stored) != 1 ||
stored[0].ModelVersion != "qwen3-0.6b-q4" ||
stored[0].RiskEvent.ModelVersion != "qwen3-0.6b-q4" ||
stored[0].RiskEvent.JudgeModel != "qwen3-0.6b-q4" {
t.Fatalf("stored events = %+v, want model metadata retained internally", stored)
}

recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/sessions/s1/events", nil)
server.Handler().ServeHTTP(recorder, request)

if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", recorder.Code, http.StatusOK, recorder.Body.String())
}
var response []sqlite.DecisionRecord
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatal(err)
}
if len(response) != 1 ||
response[0].ModelVersion != "" ||
response[0].RiskEvent.ModelVersion != "" ||
response[0].RiskEvent.JudgeModel != "" {
t.Fatalf("response = %+v, want model metadata hidden", response)
}
}

func TestJudgePolicyDeniesFromLocalJudge(t *testing.T) {
store, err := sqlite.OpenStore(t.TempDir() + "/guard.db")
if err != nil {
Expand Down
52 changes: 52 additions & 0 deletions internal/guard/web/assets/dist/assets/index-CMifAc1g.js

Large diffs are not rendered by default.

52 changes: 0 additions & 52 deletions internal/guard/web/assets/dist/assets/index-DBoOM-Il.js

This file was deleted.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/guard/web/assets/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kontext Guard</title>
<script type="module" crossorigin src="/assets/index-DBoOM-Il.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CleXL0pS.css">
<script type="module" crossorigin src="/assets/index-CMifAc1g.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DUTwVgWJ.css">
</head>
<body>
<div id="root"></div>
Expand Down
93 changes: 86 additions & 7 deletions web/guard-dashboard/src/dashboard/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import {
actionSummary,
dateTime,
decisionLabel,
decisionSource,
decisionTone,
Expand All @@ -18,6 +19,16 @@ import type { Event } from "./types";
export function Inspector({ event }: { event: Event }) {
const r = event.risk_event ?? {};
const tone = decisionTone[event.decision];
const timestamp = dateTime(event.created_at);
const judgeResult =
r.decision_stage === "judge_allow"
? "allow"
: r.decision_stage === "judge_deny"
? "deny"
: r.decision_stage === "judge_fail_open"
? "fail open"
: "";
const judgeLatency = formatDurationMs(r.judge_duration_ms);

return (
<div className="flex h-full flex-col bg-background">
Expand All @@ -37,9 +48,6 @@ export function Inspector({ event }: { event: Event }) {
<pre className="whitespace-pre-wrap break-words font-mono text-[15px] font-medium leading-snug tracking-tight text-foreground">
{summaryOf(event)}
</pre>
<p className="text-[13.5px] leading-relaxed text-foreground/75">
{humanReason(event)}
</p>
</div>

<dl className="grid grid-cols-[120px_1fr] gap-y-3 text-[13px]">
Expand All @@ -53,10 +61,28 @@ export function Inspector({ event }: { event: Event }) {
<Dd>
<span className="font-mono text-[12.5px]">{r.environment || "unknown"}</span>
</Dd>
{r.judge_model && (
{timestamp && (
<>
<Dt>Timestamp</Dt>
<Dd>{timestamp}</Dd>
</>
)}
{r.policy_version && (
<>
<Dt>Policy version</Dt>
<Dd>{r.policy_version}</Dd>
</>
)}
{r.policy_profile && (
<>
<Dt>Policy profile</Dt>
<Dd>{humanize(r.policy_profile)}</Dd>
</>
)}
{r.policy_rule_pack && (
<>
<Dt>Judge</Dt>
<Dd>{r.judge_model}</Dd>
<Dt>Rule pack</Dt>
<Dd>{r.policy_rule_pack}</Dd>
</>
)}
{r.policy_rule_id && (
Expand All @@ -65,8 +91,36 @@ export function Inspector({ event }: { event: Event }) {
<Dd>{r.policy_rule_id}</Dd>
</>
)}
{r.policy_rule_category && (
<>
<Dt>Rule category</Dt>
<Dd>{humanize(r.policy_rule_category)}</Dd>
</>
)}
{judgeResult && (
<>
<Dt>Judge result</Dt>
<Dd>{judgeResult}</Dd>
</>
)}
{r.judge_risk_level && (
<>
<Dt>Judge risk</Dt>
<Dd>{humanize(r.judge_risk_level)}</Dd>
</>
)}
{judgeLatency && (
<>
<Dt>Judge latency</Dt>
<Dd>{judgeLatency}</Dd>
</>
)}
</dl>
Comment thread
hasandemirkiran marked this conversation as resolved.

<Section title="Reason">
<p className="text-[13px] leading-relaxed text-foreground/80">{humanReason(event)}</p>
</Section>

<Section title="Analysis">
<p className="text-[13px] leading-relaxed text-foreground/80">
{technicalExplanation(event)}
Expand Down Expand Up @@ -95,9 +149,19 @@ export function Inspector({ event }: { event: Event }) {
</Section>
)}

{(r.policy_signals ?? []).length > 0 && (
<Section title="Policy Signals">
<div className="flex flex-wrap gap-1.5">
{(r.policy_signals ?? []).map((s) => (
<SignalChip key={s} signal={s} toneClass={tone.bg} />
))}
</div>
</Section>
)}

{event.reason_code && (
<div className="border-t pt-4 font-mono text-[10.5px] uppercase tracking-[0.2em] text-muted-foreground">
reason · <span className="text-foreground/70">{event.reason_code}</span>
decision code · <span className="text-foreground/70">{event.reason_code}</span>
</div>
)}
</div>
Expand All @@ -106,6 +170,21 @@ export function Inspector({ event }: { event: Event }) {
);
}

function SignalChip({ signal, toneClass }: { signal: string; toneClass: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 font-mono text-[11px] text-foreground/80 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]">
<span className={cn("h-1 w-1 rounded-full", toneClass)} />
{humanize(signal)}
</span>
);
}

function formatDurationMs(value?: number): string {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return "";
if (value < 1000) return `${Math.round(value)} ms`;
return `${(value / 1000).toFixed(1)} s`;
}

function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2.5">
Expand Down
5 changes: 5 additions & 0 deletions web/guard-dashboard/src/dashboard/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,15 @@ function parseRiskEvent(value: unknown): RiskEvent | undefined {
signals: stringList(value.signals),
guard_id: optionalString(value.guard_id),
confidence: optionalNumber(value.confidence),
policy_version: optionalString(value.policy_version),
policy_profile: optionalString(value.policy_profile),
policy_rule_pack: optionalString(value.policy_rule_pack),
policy_rule_id: optionalString(value.policy_rule_id),
policy_rule_category: optionalString(value.policy_rule_category),
policy_signals: stringList(value.policy_signals),
judge_runtime: optionalString(value.judge_runtime),
judge_model: optionalString(value.judge_model),
judge_duration_ms: optionalNumber(value.judge_duration_ms),
judge_failure_kind: optionalString(value.judge_failure_kind),
judge_risk_level: optionalString(value.judge_risk_level),
judge_categories: stringList(value.judge_categories),
Expand Down Expand Up @@ -131,6 +135,7 @@ function parseEvent(value: unknown): Event | undefined {
decision: parsedDecision,
reason: optionalString(value.reason),
reason_code: optionalString(value.reason_code),
created_at: optionalString(value.created_at),
risk_event: parseRiskEvent(value.risk_event),
};
}
Expand Down
16 changes: 13 additions & 3 deletions web/guard-dashboard/src/dashboard/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function humanReason(e: Event): string {
if (e.risk_event?.decision_stage === "judge_fail_open") {
return "Local judge was unavailable, so Guard allowed by fail-open policy.";
}
return e.reason || e.reason_code || "No explanation captured.";
return e.reason || (e.reason_code ? humanize(e.reason_code) : "No explanation captured.");
}

export function technicalExplanation(e: Event): string {
Expand All @@ -71,10 +71,10 @@ export function technicalExplanation(e: Event): string {
return "Not a live gate. Recorded after execution for local session history.";
}
if (r.decision_stage === "judge_allow") {
return `Deterministic policy allowed this action, then the local judge allowed it${r.judge_model ? ` using ${r.judge_model}` : ""}.`;
return "Deterministic policy allowed this action, then the local judge allowed it.";
}
if (r.decision_stage === "judge_deny") {
return `Deterministic policy allowed this action, then the local judge denied it${r.judge_model ? ` using ${r.judge_model}` : ""}.`;
return "Deterministic policy allowed this action, then the local judge denied it.";
}
if (r.decision_stage === "judge_fail_open") {
return `Deterministic policy allowed this action, but the local judge failed${r.judge_failure_kind ? ` with ${humanize(r.judge_failure_kind)}` : ""}.`;
Expand Down Expand Up @@ -115,6 +115,16 @@ export function relativeTime(value?: string): string {
return `${Math.floor(h / 24)}d ago`;
}

export function dateTime(value?: string): string {
if (!value) return "";
const ts = Date.parse(value);
if (Number.isNaN(ts)) return "";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "medium",
}).format(ts);
}

export function decisionLabel(decision: Event["decision"]): string {
if (decision === "deny") return "Would deny";
if (decision === "ask") return "Would ask";
Expand Down
Loading
Loading