Skip to content

Commit 8335455

Browse files
committed
add rule suggestion endpoint and drawer panel
1 parent 92e6b3a commit 8335455

18 files changed

Lines changed: 1164 additions & 58 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ go.work.sum
3232

3333
# Editor/IDE
3434
.claude/
35+
.codex
3536
.vscode/
3637
.cursor/
3738

frontend/src/App.css

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,84 @@ a { color: var(--brass); }
10321032
}
10331033
.copy-btn:hover { color: var(--brass); }
10341034

1035+
.rule-suggestion {
1036+
margin-top: 20px;
1037+
padding: 16px;
1038+
border: 1px solid var(--rule);
1039+
border-radius: 4px;
1040+
background: linear-gradient(135deg, rgba(212, 161, 74, 0.08), rgba(12, 15, 20, 0.55));
1041+
}
1042+
1043+
.rule-suggestion-head {
1044+
display: flex;
1045+
align-items: flex-start;
1046+
justify-content: space-between;
1047+
gap: 12px;
1048+
margin-bottom: 12px;
1049+
}
1050+
1051+
.rule-suggestion-sub {
1052+
margin-top: 4px;
1053+
color: var(--ink-mute);
1054+
font-family: var(--font-display);
1055+
font-style: italic;
1056+
font-size: 0.86rem;
1057+
}
1058+
1059+
.rule-suggestion-select {
1060+
min-width: 120px;
1061+
}
1062+
1063+
.rule-suggestion-status,
1064+
.rule-suggestion-error,
1065+
.rule-suggestion-warning,
1066+
.rule-suggestion-meta {
1067+
font-family: var(--font-mono);
1068+
font-size: 0.72rem;
1069+
}
1070+
1071+
.rule-suggestion-status {
1072+
color: var(--ink-mute);
1073+
}
1074+
1075+
.rule-suggestion-error,
1076+
.rule-suggestion-warning {
1077+
margin: 10px 0;
1078+
color: var(--block);
1079+
}
1080+
1081+
.rule-suggestion-warning {
1082+
color: var(--brass-bright);
1083+
}
1084+
1085+
.rule-suggestion-meta {
1086+
display: flex;
1087+
flex-wrap: wrap;
1088+
gap: 8px;
1089+
margin-bottom: 10px;
1090+
color: var(--ink-mute);
1091+
text-transform: uppercase;
1092+
letter-spacing: 0.1em;
1093+
}
1094+
1095+
.rule-suggestion-duplicate {
1096+
color: var(--allow);
1097+
}
1098+
1099+
.rule-suggestion-yaml {
1100+
margin: 12px 0;
1101+
padding: 12px;
1102+
border: 1px solid var(--rule-soft);
1103+
border-radius: 4px;
1104+
background: var(--bg);
1105+
color: var(--ink);
1106+
font-family: var(--font-mono);
1107+
font-size: 0.72rem;
1108+
line-height: 1.55;
1109+
white-space: pre-wrap;
1110+
word-break: break-word;
1111+
}
1112+
10351113
/* ─── policy page ──────────────────────────────────────────────── */
10361114

10371115
.policy-page {

frontend/src/EventsPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,7 @@ export default function EventsPage({
911911
</div>
912912

913913
<EventDrawer
914+
key={selected?.id ?? "empty"}
914915
event={selected}
915916
onClose={() => setSelected(null)}
916917
onApplyFilter={(key, value) => {

frontend/src/components/EventDrawer.tsx

Lines changed: 161 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
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";
44
import { actionBadge } from "../policyBadges";
5+
import { useRegisterCommands, type Command } from "../commands";
56

67
interface Props {
78
event: Event | null;
@@ -55,7 +56,136 @@ function CopyField({ label, value }: { label: string; value: string }) {
5556
);
5657
}
5758

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+
58152
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+
59189
return (
60190
<AnimatePresence>
61191
{event && (
@@ -105,32 +235,35 @@ export default function EventDrawer({ event, onClose, onApplyFilter }: Props) {
105235
<CopyField label="file" value={event.file} />
106236
<CopyField label="workdir" value={event.workdir} />
107237
<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+
/>
134267
<div
135268
className="drawer-json"
136269
dangerouslySetInnerHTML={{ __html: highlightJson(event.tool_input) }}

frontend/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ export interface RuleEntry {
2525
flags?: string[];
2626
}
2727

28+
export interface RuleSuggestion {
29+
tool: string;
30+
action: "allow" | "block" | "confirm";
31+
yaml: string;
32+
duplicate: boolean;
33+
warning?: string;
34+
}
35+
2836
export interface Rule {
2937
default_action?: string;
3038
flag_equivalents?: Record<string, Record<string, string[]>>;

internal/dashboard/helpers_test.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010
"testing/fstest"
1111

12+
"github.com/kkd16/parry/internal/policy"
1213
"github.com/kkd16/parry/internal/store"
1314
"github.com/stretchr/testify/require"
1415
)
@@ -21,10 +22,10 @@ func newTestServer(t *testing.T) *Server {
2122
t.Cleanup(func() { _ = st.Close() })
2223

2324
frontend := fstest.MapFS{
24-
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>parry</title>")},
25+
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>parry</title>")},
2526
"assets/app.js": &fstest.MapFile{Data: []byte("console.log('parry')")},
2627
}
27-
return &Server{store: st, frontend: fs.FS(frontend)}
28+
return &Server{store: st, frontend: fs.FS(frontend), policyLoader: loadTestPolicy}
2829
}
2930

3031
func seedEvents(t *testing.T, s *store.Store, events ...store.Event) {
@@ -37,6 +38,39 @@ func seedEvents(t *testing.T, s *store.Store, events ...store.Event) {
3738
}
3839
}
3940

41+
func loadTestPolicy() (*policy.Policy, error) {
42+
engine := policy.NewEngine()
43+
err := engine.LoadBytes([]byte(`
44+
version: 1
45+
mode: observe
46+
default_action: confirm
47+
check_mode_confirm: block
48+
protected_paths:
49+
- "/etc/shadow"
50+
rules:
51+
shell:
52+
default_action: confirm
53+
flag_equivalents:
54+
rm:
55+
recursive: [r, R, --recursive]
56+
force: [f, --force]
57+
allow:
58+
- binary: git
59+
positional: [status]
60+
block:
61+
- binary: rm
62+
flags: [recursive, force]
63+
file_edit:
64+
default_action: allow
65+
file_read:
66+
default_action: allow
67+
`))
68+
if err != nil {
69+
return nil, err
70+
}
71+
return engine.Policy(), nil
72+
}
73+
4074
func requireJSONArray(t *testing.T, body map[string]any, key string) []any {
4175
t.Helper()
4276
arr, ok := body[key].([]any)

0 commit comments

Comments
 (0)