Skip to content

Commit 0d66f45

Browse files
authored
Add dashboard block-next-time flow (#65)
1 parent 4522be5 commit 0d66f45

14 files changed

Lines changed: 333 additions & 63 deletions

File tree

control-plane/dashboard-ui/src/components/AddRuleModal.tsx

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515
import type { FormEvent } from "react";
1616
import { useEffect, useMemo, useState } from "react";
1717
import { apiSend } from "@/lib/api";
18+
import type { AddRuleInitialPreset, RuleType } from "@/lib/rulePresetTypes";
1819

1920
interface AddRuleModalProps {
2021
onClose: () => void;
2122
onCreated: (id: string) => void;
23+
initialPreset?: AddRuleInitialPreset;
2224
}
2325

24-
type RuleType = "bash" | "pkg-install" | "destructive" | "secret-read" | "net-egress-url" | "custom";
25-
2626
interface Preset {
2727
label: string;
2828
description: string;
@@ -110,28 +110,50 @@ const TOOL_OPTIONS = [
110110
"Task",
111111
] as const;
112112

113-
export function AddRuleModal({ onClose, onCreated }: AddRuleModalProps) {
114-
const [ruleType, setRuleType] = useState<RuleType>("destructive");
113+
export function AddRuleModal({ onClose, onCreated, initialPreset }: AddRuleModalProps) {
114+
const initialRuleType = initialPreset?.ruleType ?? "destructive";
115+
const initialBasePreset = PRESETS[initialRuleType];
116+
const [ruleType, setRuleType] = useState<RuleType>(initialRuleType);
115117
const preset = PRESETS[ruleType];
116118

117-
const [id, setId] = useState(preset.idSuggestion);
118-
const [tool, setTool] = useState(preset.tool);
119-
const [commandRegexes, setCommandRegexes] = useState("");
120-
const [pathRegexes, setPathRegexes] = useState("");
121-
const [urlRegexes, setUrlRegexes] = useState("");
122-
const [action, setAction] = useState<"deny" | "allow">(preset.action);
123-
const [mode, setMode] = useState<"inherit" | "monitor" | "enforce">("inherit");
119+
const [id, setId] = useState(initialPreset?.id ?? initialBasePreset.idSuggestion);
120+
const [tool, setTool] = useState(initialPreset?.tool ?? initialBasePreset.tool);
121+
const [commandRegexes, setCommandRegexes] = useState(initialPreset?.commandRegexes ?? "");
122+
const [pathRegexes, setPathRegexes] = useState(initialPreset?.pathRegexes ?? "");
123+
const [urlRegexes, setUrlRegexes] = useState(initialPreset?.urlRegexes ?? "");
124+
const [action, setAction] = useState<"deny" | "allow">(initialPreset?.action ?? initialBasePreset.action);
125+
const [mode, setMode] = useState<"inherit" | "monitor" | "enforce">(initialPreset?.mode ?? "inherit");
124126
const [submitting, setSubmitting] = useState(false);
125127
const [error, setError] = useState<string | null>(null);
126128

127-
// Reset id / tool / action whenever the user picks a different preset
128-
// so they always see a sane starting point for the rule type.
129+
// Re-apply caller-owned presets if the same modal instance is reused for
130+
// a different ledger event.
129131
useEffect(() => {
130-
setId(preset.idSuggestion);
131-
setTool(preset.tool);
132-
setAction(preset.action);
132+
if (!initialPreset) return;
133+
const nextRuleType = initialPreset.ruleType ?? "destructive";
134+
const nextBasePreset = PRESETS[nextRuleType];
135+
setRuleType(nextRuleType);
136+
setId(initialPreset.id ?? nextBasePreset.idSuggestion);
137+
setTool(initialPreset.tool ?? nextBasePreset.tool);
138+
setCommandRegexes(initialPreset.commandRegexes ?? "");
139+
setPathRegexes(initialPreset.pathRegexes ?? "");
140+
setUrlRegexes(initialPreset.urlRegexes ?? "");
141+
setAction(initialPreset.action ?? nextBasePreset.action);
142+
setMode(initialPreset.mode ?? "inherit");
143+
setError(null);
144+
}, [initialPreset]);
145+
146+
const onRuleTypeChange = (nextRuleType: RuleType) => {
147+
const nextPreset = PRESETS[nextRuleType];
148+
setRuleType(nextRuleType);
149+
setId(nextPreset.idSuggestion);
150+
setTool(nextPreset.tool);
151+
setCommandRegexes("");
152+
setPathRegexes("");
153+
setUrlRegexes("");
154+
setAction(nextPreset.action);
133155
setError(null);
134-
}, [ruleType, preset]);
156+
};
135157

136158
const showCommand = useMemo(
137159
() => preset.fields.includes("command") || preset.fields.includes("all"),
@@ -206,7 +228,7 @@ export function AddRuleModal({ onClose, onCreated }: AddRuleModalProps) {
206228
<select
207229
className="oal-input w-full"
208230
value={ruleType}
209-
onChange={(e) => setRuleType(e.target.value as RuleType)}
231+
onChange={(e) => onRuleTypeChange(e.target.value as RuleType)}
210232
>
211233
{(Object.keys(PRESETS) as RuleType[]).map((k) => (
212234
<option key={k} value={k}>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { AddRuleInitialPreset } from "@/lib/rulePresetTypes";
2+
import type { LedgerEntry } from "@/lib/types";
3+
4+
function stringField(input: Record<string, unknown>, keys: string[]): string | null {
5+
for (const key of keys) {
6+
const value = input[key];
7+
if (typeof value === "string" && value.trim().length > 0) {
8+
return value.trim();
9+
}
10+
}
11+
return null;
12+
}
13+
14+
function escapeRegexLiteral(value: string): string {
15+
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
16+
}
17+
18+
function exactRegex(value: string): string {
19+
return `^${escapeRegexLiteral(value)}$`;
20+
}
21+
22+
export function rulePresetFromLedgerEntry(entry: LedgerEntry): AddRuleInitialPreset | null {
23+
const input = entry.input ?? entry.tool_input;
24+
if (!input) return null;
25+
26+
const command = stringField(input, ["command"]);
27+
if (command) {
28+
return {
29+
ruleType: "bash",
30+
id: `dashboard.block-${entry.seq}`,
31+
tool: entry.tool || "Bash",
32+
commandRegexes: exactRegex(command),
33+
action: "deny",
34+
mode: "inherit",
35+
};
36+
}
37+
38+
const path = stringField(input, ["file_path", "path"]);
39+
if (path) {
40+
return {
41+
ruleType: "secret-read",
42+
id: `dashboard.block-${entry.seq}`,
43+
tool: entry.tool || "Read",
44+
pathRegexes: exactRegex(path),
45+
action: "deny",
46+
mode: "inherit",
47+
};
48+
}
49+
50+
const url = stringField(input, ["url"]);
51+
if (url) {
52+
return {
53+
ruleType: "net-egress-url",
54+
id: `dashboard.block-${entry.seq}`,
55+
tool: entry.tool || "WebFetch",
56+
urlRegexes: exactRegex(url),
57+
action: "deny",
58+
mode: "inherit",
59+
};
60+
}
61+
62+
return null;
63+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export type RuleType = "bash" | "pkg-install" | "destructive" | "secret-read" | "net-egress-url" | "custom";
2+
3+
export interface AddRuleInitialPreset {
4+
ruleType?: RuleType;
5+
id?: string;
6+
tool?: string;
7+
commandRegexes?: string;
8+
pathRegexes?: string;
9+
urlRegexes?: string;
10+
action?: "deny" | "allow";
11+
mode?: "inherit" | "monitor" | "enforce";
12+
}

control-plane/dashboard-ui/src/lib/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ export interface LedgerEntry {
22
seq: number;
33
ts: string;
44
source: string;
5+
tool?: string;
56
tool_use_id: string;
67
signer: string;
8+
input?: Record<string, unknown>;
9+
tool_input?: Record<string, unknown>;
710
rule_id?: string;
811
verdict?: string;
912
// True when the original verdict was deny but the daemon's monitor

control-plane/dashboard-ui/src/routes/events.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type React from "react";
22
import { useEffect, useMemo, useRef, useState } from "react";
33
import { createFileRoute } from "@tanstack/react-router";
4+
import { AddRuleModal } from "@/components/AddRuleModal";
45
import { useRootInfo } from "@/hooks/usePoll";
56
import { useSSELedger } from "@/hooks/useSSE";
67
import type { LedgerEntry } from "@/lib/types";
78
import { INTERNAL_SOURCES } from "@/lib/filters";
89
import { fullLocal, shortTime } from "@/lib/time";
910
import { shortHash } from "@/lib/filters";
11+
import { rulePresetFromLedgerEntry } from "@/lib/eventRulePreset";
12+
import type { AddRuleInitialPreset } from "@/lib/rulePresetTypes";
1013

1114
// verdictDisplay maps a ledger row to a (label, color-class) pair.
1215
//
@@ -49,13 +52,35 @@ function EventsTab() {
4952
const [selectedSeq, setSelectedSeq] = useState<number | null>(null);
5053
const [page, setPage] = useState(0);
5154
const [pageSize, setPageSize] = useState(50);
55+
const [contextMenu, setContextMenu] = useState<{
56+
x: number;
57+
y: number;
58+
preset: AddRuleInitialPreset;
59+
} | null>(null);
60+
const [addRulePreset, setAddRulePreset] = useState<AddRuleInitialPreset | null>(null);
5261

5362
// Reset to page 0 when filter inputs or page size change so the user
5463
// never lands on an empty page after narrowing the result set.
5564
useEffect(() => {
5665
setPage(0);
5766
}, [sourceFilter, verdictFilter, ruleFilter, showInternal, pageSize]);
5867

68+
useEffect(() => {
69+
if (!contextMenu) return;
70+
const close = () => setContextMenu(null);
71+
const onKey = (e: KeyboardEvent) => {
72+
if (e.key === "Escape") close();
73+
};
74+
window.addEventListener("click", close);
75+
window.addEventListener("scroll", close, true);
76+
window.addEventListener("keydown", onKey);
77+
return () => {
78+
window.removeEventListener("click", close);
79+
window.removeEventListener("scroll", close, true);
80+
window.removeEventListener("keydown", onKey);
81+
};
82+
}, [contextMenu]);
83+
5984
const sources = useMemo(() => {
6085
const set = new Set<string>();
6186
entries.forEach((e) => set.add(e.source));
@@ -93,6 +118,17 @@ function EventsTab() {
93118
[filtered, pageStart, pageEnd],
94119
);
95120

121+
const onRowContextMenu = (ev: React.MouseEvent, entry: LedgerEntry) => {
122+
const preset = rulePresetFromLedgerEntry(entry);
123+
if (!preset) return;
124+
ev.preventDefault();
125+
setContextMenu({
126+
x: Math.min(ev.clientX, window.innerWidth - 220),
127+
y: Math.min(ev.clientY, window.innerHeight - 64),
128+
preset,
129+
});
130+
};
131+
96132
return (
97133
<div className="space-y-4">
98134
<section className="oal-panel">
@@ -205,6 +241,7 @@ function EventsTab() {
205241
<tr
206242
key={`${e.seq}-${e.leaf_hash}`}
207243
onClick={() => setSelectedSeq(e.seq)}
244+
onContextMenu={(ev) => onRowContextMenu(ev, e)}
208245
className="cursor-pointer hover:bg-chip"
209246
>
210247
<td className="font-mono">{e.seq}</td>
@@ -294,6 +331,35 @@ function EventsTab() {
294331
onClose={() => setSelectedSeq(null)}
295332
/>
296333
)}
334+
335+
{contextMenu && (
336+
<div
337+
className="fixed z-50 min-w-[200px] rounded-md border border-border bg-panel py-1 shadow-lg"
338+
style={{ left: contextMenu.x, top: contextMenu.y }}
339+
onClick={(e) => e.stopPropagation()}
340+
role="menu"
341+
>
342+
<button
343+
type="button"
344+
className="block w-full px-3 py-2 text-left text-xs hover:bg-chip focus:bg-chip focus:outline-none"
345+
onClick={() => {
346+
setAddRulePreset(contextMenu.preset);
347+
setContextMenu(null);
348+
}}
349+
role="menuitem"
350+
>
351+
Block this next time
352+
</button>
353+
</div>
354+
)}
355+
356+
{addRulePreset && (
357+
<AddRuleModal
358+
initialPreset={addRulePreset}
359+
onClose={() => setAddRulePreset(null)}
360+
onCreated={() => setAddRulePreset(null)}
361+
/>
362+
)}
297363
</div>
298364
);
299365
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { rulePresetFromLedgerEntry } from "../src/lib/eventRulePreset";
3+
import type { LedgerEntry } from "../src/lib/types";
4+
5+
const baseEntry: LedgerEntry = {
6+
seq: 42,
7+
ts: "2026-05-06T00:00:00Z",
8+
source: "codex",
9+
tool_use_id: "toolu_123",
10+
signer: "none",
11+
payload_hash: "hash",
12+
sig: "",
13+
leaf_hash: "leaf",
14+
prev_leaf: "prev",
15+
};
16+
17+
describe("rulePresetFromLedgerEntry", () => {
18+
test("returns null when a ledger row does not include tool input", () => {
19+
expect(rulePresetFromLedgerEntry(baseEntry)).toBeNull();
20+
});
21+
22+
test("builds an exact Bash command preset from row input", () => {
23+
expect(
24+
rulePresetFromLedgerEntry({
25+
...baseEntry,
26+
tool: "Bash",
27+
input: { command: "rm -rf /tmp/demo" },
28+
}),
29+
).toEqual({
30+
ruleType: "bash",
31+
id: "dashboard.block-42",
32+
tool: "Bash",
33+
commandRegexes: "^rm -rf /tmp/demo$",
34+
action: "deny",
35+
mode: "inherit",
36+
});
37+
});
38+
39+
test("builds an exact path preset from file_path", () => {
40+
expect(
41+
rulePresetFromLedgerEntry({
42+
...baseEntry,
43+
tool: "Read",
44+
input: { file_path: "/tmp/demo/.env" },
45+
}),
46+
).toMatchObject({
47+
ruleType: "secret-read",
48+
tool: "Read",
49+
pathRegexes: "^/tmp/demo/\\.env$",
50+
});
51+
});
52+
53+
test("builds an exact URL preset from url", () => {
54+
expect(
55+
rulePresetFromLedgerEntry({
56+
...baseEntry,
57+
tool: "WebFetch",
58+
input: { url: "https://attacker.example/a?x=1" },
59+
}),
60+
).toMatchObject({
61+
ruleType: "net-egress-url",
62+
tool: "WebFetch",
63+
urlRegexes: "^https://attacker\\.example/a\\?x=1$",
64+
});
65+
});
66+
});

control-plane/internal/api/gate.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import (
1313
)
1414

1515
type gateCheckRequest struct {
16-
SessionID string `json:"session_id"`
17-
Source string `json:"source"`
18-
Tool string `json:"tool"`
19-
Input map[string]any `json:"input"`
20-
Cwd string `json:"cwd,omitempty"`
21-
Meta map[string]any `json:"meta,omitempty"`
16+
SessionID string `json:"session_id"`
17+
Source string `json:"source"`
18+
Tool string `json:"tool"`
19+
Input map[string]any `json:"input"`
20+
Cwd string `json:"cwd,omitempty"`
21+
Meta map[string]any `json:"meta,omitempty"`
2222
}
2323

2424
type gateCheckResponse struct {
@@ -100,12 +100,13 @@ func gateCheckHandler(d Deps) http.HandlerFunc {
100100
entry, err := d.Store.AppendLedger(r.Context(), storage.AppendInput{
101101
TS: time.Now().UTC(),
102102
Source: req.Source,
103-
ToolUseID: "gate.check",
104103
Tool: req.Tool,
104+
ToolUseID: "gate.check",
105105
Signer: sess.Signer,
106106
RuleID: result.RuleID,
107107
Verdict: origVerdict,
108108
MonitorMatch: result.MonitorMatch,
109+
MatcherInput: ledgerMatcherInput(req.Input),
109110
PayloadHash: payloadHash[:],
110111
Sig: nil,
111112
})

0 commit comments

Comments
 (0)