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
58 changes: 40 additions & 18 deletions control-plane/dashboard-ui/src/components/AddRuleModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
import type { FormEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import { apiSend } from "@/lib/api";
import type { AddRuleInitialPreset, RuleType } from "@/lib/rulePresetTypes";

interface AddRuleModalProps {
onClose: () => void;
onCreated: (id: string) => void;
initialPreset?: AddRuleInitialPreset;
}

type RuleType = "bash" | "pkg-install" | "destructive" | "secret-read" | "net-egress-url" | "custom";

interface Preset {
label: string;
description: string;
Expand Down Expand Up @@ -110,28 +110,50 @@ const TOOL_OPTIONS = [
"Task",
] as const;

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

const [id, setId] = useState(preset.idSuggestion);
const [tool, setTool] = useState(preset.tool);
const [commandRegexes, setCommandRegexes] = useState("");
const [pathRegexes, setPathRegexes] = useState("");
const [urlRegexes, setUrlRegexes] = useState("");
const [action, setAction] = useState<"deny" | "allow">(preset.action);
const [mode, setMode] = useState<"inherit" | "monitor" | "enforce">("inherit");
const [id, setId] = useState(initialPreset?.id ?? initialBasePreset.idSuggestion);
const [tool, setTool] = useState(initialPreset?.tool ?? initialBasePreset.tool);
const [commandRegexes, setCommandRegexes] = useState(initialPreset?.commandRegexes ?? "");
const [pathRegexes, setPathRegexes] = useState(initialPreset?.pathRegexes ?? "");
const [urlRegexes, setUrlRegexes] = useState(initialPreset?.urlRegexes ?? "");
const [action, setAction] = useState<"deny" | "allow">(initialPreset?.action ?? initialBasePreset.action);
const [mode, setMode] = useState<"inherit" | "monitor" | "enforce">(initialPreset?.mode ?? "inherit");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

// Reset id / tool / action whenever the user picks a different preset
// so they always see a sane starting point for the rule type.
// Re-apply caller-owned presets if the same modal instance is reused for
// a different ledger event.
useEffect(() => {
setId(preset.idSuggestion);
setTool(preset.tool);
setAction(preset.action);
if (!initialPreset) return;
const nextRuleType = initialPreset.ruleType ?? "destructive";
const nextBasePreset = PRESETS[nextRuleType];
setRuleType(nextRuleType);
setId(initialPreset.id ?? nextBasePreset.idSuggestion);
setTool(initialPreset.tool ?? nextBasePreset.tool);
setCommandRegexes(initialPreset.commandRegexes ?? "");
setPathRegexes(initialPreset.pathRegexes ?? "");
setUrlRegexes(initialPreset.urlRegexes ?? "");
setAction(initialPreset.action ?? nextBasePreset.action);
setMode(initialPreset.mode ?? "inherit");
setError(null);
}, [initialPreset]);

const onRuleTypeChange = (nextRuleType: RuleType) => {
const nextPreset = PRESETS[nextRuleType];
setRuleType(nextRuleType);
setId(nextPreset.idSuggestion);
setTool(nextPreset.tool);
setCommandRegexes("");
setPathRegexes("");
setUrlRegexes("");
setAction(nextPreset.action);
setError(null);
}, [ruleType, preset]);
};

const showCommand = useMemo(
() => preset.fields.includes("command") || preset.fields.includes("all"),
Expand Down Expand Up @@ -206,7 +228,7 @@ export function AddRuleModal({ onClose, onCreated }: AddRuleModalProps) {
<select
className="oal-input w-full"
value={ruleType}
onChange={(e) => setRuleType(e.target.value as RuleType)}
onChange={(e) => onRuleTypeChange(e.target.value as RuleType)}
>
{(Object.keys(PRESETS) as RuleType[]).map((k) => (
<option key={k} value={k}>
Expand Down
63 changes: 63 additions & 0 deletions control-plane/dashboard-ui/src/lib/eventRulePreset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { AddRuleInitialPreset } from "@/lib/rulePresetTypes";
import type { LedgerEntry } from "@/lib/types";

function stringField(input: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = input[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return null;
}

function escapeRegexLiteral(value: string): string {
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
}

function exactRegex(value: string): string {
return `^${escapeRegexLiteral(value)}$`;
}

export function rulePresetFromLedgerEntry(entry: LedgerEntry): AddRuleInitialPreset | null {
const input = entry.input ?? entry.tool_input;
if (!input) return null;

const command = stringField(input, ["command"]);
if (command) {
return {
ruleType: "bash",
id: `dashboard.block-${entry.seq}`,
tool: entry.tool || "Bash",
commandRegexes: exactRegex(command),
action: "deny",
mode: "inherit",
};
}

const path = stringField(input, ["file_path", "path"]);
if (path) {
return {
ruleType: "secret-read",
id: `dashboard.block-${entry.seq}`,
tool: entry.tool || "Read",
pathRegexes: exactRegex(path),
action: "deny",
mode: "inherit",
};
}

const url = stringField(input, ["url"]);
if (url) {
return {
ruleType: "net-egress-url",
id: `dashboard.block-${entry.seq}`,
tool: entry.tool || "WebFetch",
urlRegexes: exactRegex(url),
action: "deny",
mode: "inherit",
};
}

return null;
}
12 changes: 12 additions & 0 deletions control-plane/dashboard-ui/src/lib/rulePresetTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type RuleType = "bash" | "pkg-install" | "destructive" | "secret-read" | "net-egress-url" | "custom";

export interface AddRuleInitialPreset {
ruleType?: RuleType;
id?: string;
tool?: string;
commandRegexes?: string;
pathRegexes?: string;
urlRegexes?: string;
action?: "deny" | "allow";
mode?: "inherit" | "monitor" | "enforce";
}
3 changes: 3 additions & 0 deletions control-plane/dashboard-ui/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ export interface LedgerEntry {
seq: number;
ts: string;
source: string;
tool?: string;
tool_use_id: string;
signer: string;
input?: Record<string, unknown>;
tool_input?: Record<string, unknown>;
rule_id?: string;
verdict?: string;
// True when the original verdict was deny but the daemon's monitor
Expand Down
66 changes: 66 additions & 0 deletions control-plane/dashboard-ui/src/routes/events.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { AddRuleModal } from "@/components/AddRuleModal";
import { useRootInfo } from "@/hooks/usePoll";
import { useSSELedger } from "@/hooks/useSSE";
import type { LedgerEntry } from "@/lib/types";
import { INTERNAL_SOURCES } from "@/lib/filters";
import { fullLocal, shortTime } from "@/lib/time";
import { shortHash } from "@/lib/filters";
import { rulePresetFromLedgerEntry } from "@/lib/eventRulePreset";
import type { AddRuleInitialPreset } from "@/lib/rulePresetTypes";

// verdictDisplay maps a ledger row to a (label, color-class) pair.
//
Expand Down Expand Up @@ -49,13 +52,35 @@ function EventsTab() {
const [selectedSeq, setSelectedSeq] = useState<number | null>(null);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(50);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
preset: AddRuleInitialPreset;
} | null>(null);
const [addRulePreset, setAddRulePreset] = useState<AddRuleInitialPreset | null>(null);

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

useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("click", close);
window.addEventListener("scroll", close, true);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("keydown", onKey);
};
}, [contextMenu]);

const sources = useMemo(() => {
const set = new Set<string>();
entries.forEach((e) => set.add(e.source));
Expand Down Expand Up @@ -93,6 +118,17 @@ function EventsTab() {
[filtered, pageStart, pageEnd],
);

const onRowContextMenu = (ev: React.MouseEvent, entry: LedgerEntry) => {
const preset = rulePresetFromLedgerEntry(entry);
if (!preset) return;
ev.preventDefault();
setContextMenu({
x: Math.min(ev.clientX, window.innerWidth - 220),
y: Math.min(ev.clientY, window.innerHeight - 64),
preset,
});
};

return (
<div className="space-y-4">
<section className="oal-panel">
Expand Down Expand Up @@ -205,6 +241,7 @@ function EventsTab() {
<tr
key={`${e.seq}-${e.leaf_hash}`}
onClick={() => setSelectedSeq(e.seq)}
onContextMenu={(ev) => onRowContextMenu(ev, e)}
className="cursor-pointer hover:bg-chip"
>
<td className="font-mono">{e.seq}</td>
Expand Down Expand Up @@ -294,6 +331,35 @@ function EventsTab() {
onClose={() => setSelectedSeq(null)}
/>
)}

{contextMenu && (
<div
className="fixed z-50 min-w-[200px] rounded-md border border-border bg-panel py-1 shadow-lg"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
role="menu"
>
<button
type="button"
className="block w-full px-3 py-2 text-left text-xs hover:bg-chip focus:bg-chip focus:outline-none"
onClick={() => {
setAddRulePreset(contextMenu.preset);
setContextMenu(null);
}}
role="menuitem"
>
Block this next time
</button>
</div>
)}

{addRulePreset && (
<AddRuleModal
initialPreset={addRulePreset}
onClose={() => setAddRulePreset(null)}
onCreated={() => setAddRulePreset(null)}
/>
)}
</div>
);
}
Expand Down
66 changes: 66 additions & 0 deletions control-plane/dashboard-ui/tests/eventRulePreset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test";
import { rulePresetFromLedgerEntry } from "../src/lib/eventRulePreset";
import type { LedgerEntry } from "../src/lib/types";

const baseEntry: LedgerEntry = {
seq: 42,
ts: "2026-05-06T00:00:00Z",
source: "codex",
tool_use_id: "toolu_123",
signer: "none",
payload_hash: "hash",
sig: "",
leaf_hash: "leaf",
prev_leaf: "prev",
};

describe("rulePresetFromLedgerEntry", () => {
test("returns null when a ledger row does not include tool input", () => {
expect(rulePresetFromLedgerEntry(baseEntry)).toBeNull();
});

test("builds an exact Bash command preset from row input", () => {
expect(
rulePresetFromLedgerEntry({
...baseEntry,
tool: "Bash",
input: { command: "rm -rf /tmp/demo" },
}),
).toEqual({
ruleType: "bash",
id: "dashboard.block-42",
tool: "Bash",
commandRegexes: "^rm -rf /tmp/demo$",
action: "deny",
mode: "inherit",
});
});

test("builds an exact path preset from file_path", () => {
expect(
rulePresetFromLedgerEntry({
...baseEntry,
tool: "Read",
input: { file_path: "/tmp/demo/.env" },
}),
).toMatchObject({
ruleType: "secret-read",
tool: "Read",
pathRegexes: "^/tmp/demo/\\.env$",
});
});

test("builds an exact URL preset from url", () => {
expect(
rulePresetFromLedgerEntry({
...baseEntry,
tool: "WebFetch",
input: { url: "https://attacker.example/a?x=1" },
}),
).toMatchObject({
ruleType: "net-egress-url",
tool: "WebFetch",
urlRegexes: "^https://attacker\\.example/a\\?x=1$",
});
});
});
15 changes: 8 additions & 7 deletions control-plane/internal/api/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import (
)

type gateCheckRequest struct {
SessionID string `json:"session_id"`
Source string `json:"source"`
Tool string `json:"tool"`
Input map[string]any `json:"input"`
Cwd string `json:"cwd,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
SessionID string `json:"session_id"`
Source string `json:"source"`
Tool string `json:"tool"`
Input map[string]any `json:"input"`
Cwd string `json:"cwd,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}

type gateCheckResponse struct {
Expand Down Expand Up @@ -100,12 +100,13 @@ func gateCheckHandler(d Deps) http.HandlerFunc {
entry, err := d.Store.AppendLedger(r.Context(), storage.AppendInput{
TS: time.Now().UTC(),
Source: req.Source,
ToolUseID: "gate.check",
Tool: req.Tool,
ToolUseID: "gate.check",
Signer: sess.Signer,
RuleID: result.RuleID,
Verdict: origVerdict,
MonitorMatch: result.MonitorMatch,
MatcherInput: ledgerMatcherInput(req.Input),
PayloadHash: payloadHash[:],
Sig: nil,
})
Expand Down
Loading
Loading