|
1 | 1 | import { motion, AnimatePresence } from "motion/react"; |
2 | | -import { useState } from "react"; |
3 | | -import type { Event } from "../types"; |
| 2 | +import { useEffect, useMemo, useState } from "react"; |
| 3 | +import type { Event, RuleSuggestion } from "../types"; |
4 | 4 | import { actionBadge } from "../policyBadges"; |
| 5 | +import { useRegisterCommands, type Command } from "../commands"; |
5 | 6 |
|
6 | 7 | interface Props { |
7 | 8 | event: Event | null; |
@@ -55,7 +56,136 @@ function CopyField({ label, value }: { label: string; value: string }) { |
55 | 56 | ); |
56 | 57 | } |
57 | 58 |
|
| 59 | +type SuggestAction = "allow" | "confirm" | "block"; |
| 60 | + |
| 61 | +function initialSuggestAction(event: Event | null): SuggestAction { |
| 62 | + if (event?.action === "allow" || event?.action === "confirm" || event?.action === "block") { |
| 63 | + return event.action; |
| 64 | + } |
| 65 | + return "confirm"; |
| 66 | +} |
| 67 | + |
| 68 | +function RuleSuggestionPanel({ |
| 69 | + event, |
| 70 | + targetAction, |
| 71 | + setTargetAction, |
| 72 | +}: { |
| 73 | + event: Event; |
| 74 | + targetAction: SuggestAction; |
| 75 | + setTargetAction: (action: SuggestAction) => void; |
| 76 | +}) { |
| 77 | + const [suggestion, setSuggestion] = useState<RuleSuggestion | null>(null); |
| 78 | + const [error, setError] = useState<{ action: SuggestAction; message: string } | null>(null); |
| 79 | + const [copied, setCopied] = useState(false); |
| 80 | + |
| 81 | + useEffect(() => { |
| 82 | + const ctrl = new AbortController(); |
| 83 | + const params = new URLSearchParams({ |
| 84 | + event_id: String(event.id), |
| 85 | + action: targetAction, |
| 86 | + }); |
| 87 | + fetch(`/api/rule-suggestion?${params}`, { signal: ctrl.signal }) |
| 88 | + .then(async (res) => { |
| 89 | + if (!res.ok) throw new Error((await res.text()) || res.statusText); |
| 90 | + return res.json() as Promise<RuleSuggestion>; |
| 91 | + }) |
| 92 | + .then((data) => { |
| 93 | + setSuggestion(data); |
| 94 | + setError(null); |
| 95 | + }) |
| 96 | + .catch((e) => { |
| 97 | + if (e instanceof DOMException && e.name === "AbortError") return; |
| 98 | + setError({ action: targetAction, message: e instanceof Error ? e.message : "unknown error" }); |
| 99 | + }); |
| 100 | + return () => ctrl.abort(); |
| 101 | + }, [event.id, targetAction]); |
| 102 | + |
| 103 | + return ( |
| 104 | + <section className="rule-suggestion"> |
| 105 | + <div className="rule-suggestion-head"> |
| 106 | + <div> |
| 107 | + <div className="drawer-field-label">suggest rule</div> |
| 108 | + <div className="rule-suggestion-sub">copy YAML into policy.yaml</div> |
| 109 | + </div> |
| 110 | + <select |
| 111 | + className="input rule-suggestion-select" |
| 112 | + value={targetAction} |
| 113 | + onChange={(e) => { |
| 114 | + setCopied(false); |
| 115 | + setTargetAction(e.target.value as SuggestAction); |
| 116 | + }} |
| 117 | + > |
| 118 | + <option value="allow">allow</option> |
| 119 | + <option value="confirm">confirm</option> |
| 120 | + <option value="block">block</option> |
| 121 | + </select> |
| 122 | + </div> |
| 123 | + |
| 124 | + {(!suggestion || suggestion.action !== targetAction) && error?.action !== targetAction && ( |
| 125 | + <div className="rule-suggestion-status">building suggestion…</div> |
| 126 | + )} |
| 127 | + {error?.action === targetAction && <div className="rule-suggestion-error">{error.message}</div>} |
| 128 | + {suggestion && suggestion.action === targetAction && ( |
| 129 | + <> |
| 130 | + <div className="rule-suggestion-meta"> |
| 131 | + <span>{suggestion.tool}</span> |
| 132 | + {suggestion.duplicate && <span className="rule-suggestion-duplicate">already covered</span>} |
| 133 | + </div> |
| 134 | + {suggestion.warning && <div className="rule-suggestion-warning">{suggestion.warning}</div>} |
| 135 | + <pre className="rule-suggestion-yaml">{suggestion.yaml}</pre> |
| 136 | + <button |
| 137 | + className="btn" |
| 138 | + onClick={() => { |
| 139 | + void navigator.clipboard.writeText(suggestion.yaml); |
| 140 | + setCopied(true); |
| 141 | + setTimeout(() => setCopied(false), 1200); |
| 142 | + }} |
| 143 | + > |
| 144 | + {copied ? "copied yaml" : "copy yaml"} |
| 145 | + </button> |
| 146 | + </> |
| 147 | + )} |
| 148 | + </section> |
| 149 | + ); |
| 150 | +} |
| 151 | + |
58 | 152 | export default function EventDrawer({ event, onClose, onApplyFilter }: Props) { |
| 153 | + const [targetAction, setTargetAction] = useState<SuggestAction>(() => initialSuggestAction(event)); |
| 154 | + |
| 155 | + const commands = useMemo<Command[]>( |
| 156 | + () => |
| 157 | + event |
| 158 | + ? [ |
| 159 | + { |
| 160 | + id: "event.suggest.allow", |
| 161 | + group: "Event", |
| 162 | + label: "Suggest allow rule", |
| 163 | + perform: () => { |
| 164 | + setTargetAction("allow"); |
| 165 | + }, |
| 166 | + }, |
| 167 | + { |
| 168 | + id: "event.suggest.confirm", |
| 169 | + group: "Event", |
| 170 | + label: "Suggest confirm rule", |
| 171 | + perform: () => { |
| 172 | + setTargetAction("confirm"); |
| 173 | + }, |
| 174 | + }, |
| 175 | + { |
| 176 | + id: "event.suggest.block", |
| 177 | + group: "Event", |
| 178 | + label: "Suggest block rule", |
| 179 | + perform: () => { |
| 180 | + setTargetAction("block"); |
| 181 | + }, |
| 182 | + }, |
| 183 | + ] |
| 184 | + : [], |
| 185 | + [event], |
| 186 | + ); |
| 187 | + useRegisterCommands(commands, [commands]); |
| 188 | + |
59 | 189 | return ( |
60 | 190 | <AnimatePresence> |
61 | 191 | {event && ( |
@@ -105,32 +235,35 @@ export default function EventDrawer({ event, onClose, onApplyFilter }: Props) { |
105 | 235 | <CopyField label="file" value={event.file} /> |
106 | 236 | <CopyField label="workdir" value={event.workdir} /> |
107 | 237 | <CopyField label="session" value={event.session} /> |
108 | | - {onApplyFilter && ( |
109 | | - <div className="drawer-actions"> |
110 | | - {event.binary && ( |
111 | | - <button |
112 | | - className="btn" |
113 | | - onClick={() => { |
114 | | - onApplyFilter("binary", event.binary); |
115 | | - onClose(); |
116 | | - }} |
117 | | - > |
118 | | - events for {event.binary} |
119 | | - </button> |
120 | | - )} |
121 | | - {event.workdir && ( |
122 | | - <button |
123 | | - className="btn" |
124 | | - onClick={() => { |
125 | | - onApplyFilter("workdir", event.workdir); |
126 | | - onClose(); |
127 | | - }} |
128 | | - > |
129 | | - events in this directory |
130 | | - </button> |
131 | | - )} |
132 | | - </div> |
133 | | - )} |
| 238 | + <div className="drawer-actions"> |
| 239 | + {onApplyFilter && event.binary && ( |
| 240 | + <button |
| 241 | + className="btn" |
| 242 | + onClick={() => { |
| 243 | + onApplyFilter("binary", event.binary); |
| 244 | + onClose(); |
| 245 | + }} |
| 246 | + > |
| 247 | + events for {event.binary} |
| 248 | + </button> |
| 249 | + )} |
| 250 | + {onApplyFilter && event.workdir && ( |
| 251 | + <button |
| 252 | + className="btn" |
| 253 | + onClick={() => { |
| 254 | + onApplyFilter("workdir", event.workdir); |
| 255 | + onClose(); |
| 256 | + }} |
| 257 | + > |
| 258 | + events in this directory |
| 259 | + </button> |
| 260 | + )} |
| 261 | + </div> |
| 262 | + <RuleSuggestionPanel |
| 263 | + event={event} |
| 264 | + targetAction={targetAction} |
| 265 | + setTargetAction={setTargetAction} |
| 266 | + /> |
134 | 267 | <div |
135 | 268 | className="drawer-json" |
136 | 269 | dangerouslySetInnerHTML={{ __html: highlightJson(event.tool_input) }} |
|
0 commit comments