Skip to content

Commit 05172c8

Browse files
authored
Merge branch 'main' into fix/post-tool-use-verdict
2 parents fdf532d + 649ba9b commit 05172c8

24 files changed

Lines changed: 655 additions & 78 deletions

control-plane/api/openapi.yaml

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,17 @@ paths:
111111
description: verdict
112112
content:
113113
application/json:
114-
schema: { $ref: '#/components/schemas/GateCheckResponse' }
114+
schema: { $ref: '#/components/schemas/McpPinCheckResponse' }
115+
116+
/v1/mcp/pins:
117+
get:
118+
summary: List pinned MCP server fingerprints.
119+
responses:
120+
"200":
121+
description: current pins
122+
content:
123+
application/json:
124+
schema: { $ref: '#/components/schemas/McpPinsResponse' }
115125

116126
/v1/mcp/pin/accept:
117127
post:
@@ -121,7 +131,9 @@ paths:
121131
content:
122132
application/json:
123133
schema: { $ref: '#/components/schemas/McpPinAccept' }
124-
responses: { "204": { description: pinned } }
134+
responses:
135+
"200":
136+
description: pinned
125137

126138
/v1/ledger/tail:
127139
get:
@@ -300,20 +312,44 @@ components:
300312

301313
McpPinCheck:
302314
type: object
303-
required: [server_label, fingerprint]
315+
required: [server, fingerprint]
304316
properties:
305-
server_label: { type: string }
317+
server: { type: string }
306318
fingerprint: { type: string }
307319

320+
McpPinCheckResponse:
321+
type: object
322+
required: [status, server, fingerprint]
323+
properties:
324+
status: { type: string, enum: [unknown, known, changed] }
325+
server: { type: string }
326+
fingerprint: { type: string }
327+
known_fingerprint: { type: string }
328+
308329
McpPinAccept:
309330
type: object
310-
required: [server_label, fingerprint, server_info, signature]
331+
required: [server, fingerprint]
311332
properties:
312-
server_label: { type: string }
333+
server: { type: string }
313334
fingerprint: { type: string }
314335
server_info: { type: object, additionalProperties: true }
315336
signature: { type: string }
316337

338+
McpPin:
339+
type: object
340+
required: [server, fingerprint]
341+
properties:
342+
server: { type: string }
343+
fingerprint: { type: string }
344+
345+
McpPinsResponse:
346+
type: object
347+
required: [pins]
348+
properties:
349+
pins:
350+
type: array
351+
items: { $ref: '#/components/schemas/McpPin' }
352+
317353
LedgerRoot:
318354
type: object
319355
required: [root, seq, genesis_pubkey, computed_at]

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}>

control-plane/dashboard-ui/src/hooks/usePoll.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useCallback, useEffect, useRef, useState } from "react";
22
import { apiJSON, apiSend } from "@/lib/api";
3-
import type { ModeInfo, PolicyView, RootInfo, SessionsResponse } from "@/lib/types";
3+
import type { MCPPinsResponse, ModeInfo, PolicyView, RootInfo, SessionsResponse } from "@/lib/types";
44

55
export function usePoll(fn: () => void | Promise<void>, intervalMs: number, paused = false): void {
66
const fnRef = useRef(fn);
@@ -136,3 +136,26 @@ export function useSessions(active: boolean): {
136136

137137
return { data, error, refresh };
138138
}
139+
140+
export function useMCPPins(active: boolean): {
141+
data: MCPPinsResponse | null;
142+
error: string | null;
143+
refresh: () => Promise<void>;
144+
} {
145+
const [data, setData] = useState<MCPPinsResponse | null>(null);
146+
const [error, setError] = useState<string | null>(null);
147+
148+
const refresh = useCallback(async () => {
149+
try {
150+
const pins = await apiJSON<MCPPinsResponse>("/v1/mcp/pins");
151+
setData(pins);
152+
setError(null);
153+
} catch (e) {
154+
setError(e instanceof Error ? e.message : String(e));
155+
}
156+
}, []);
157+
158+
usePoll(refresh, 5000, !active);
159+
160+
return { data, error, refresh };
161+
}
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: 12 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
@@ -55,6 +58,15 @@ export interface SessionsResponse {
5558
sessions: SessionRow[];
5659
}
5760

61+
export interface MCPPinRow {
62+
server: string;
63+
fingerprint: string;
64+
}
65+
66+
export interface MCPPinsResponse {
67+
pins: MCPPinRow[];
68+
}
69+
5870
export interface RootInfo {
5971
root: string;
6072
seq: number;

control-plane/dashboard-ui/src/routeTree.gen.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { Route as rootRouteImport } from './routes/__root'
1212
import { Route as SessionsRouteImport } from './routes/sessions'
1313
import { Route as RulesRouteImport } from './routes/rules'
14+
import { Route as McpRouteImport } from './routes/mcp'
1415
import { Route as EventsRouteImport } from './routes/events'
1516
import { Route as IndexRouteImport } from './routes/index'
1617

@@ -24,6 +25,11 @@ const RulesRoute = RulesRouteImport.update({
2425
path: '/rules',
2526
getParentRoute: () => rootRouteImport,
2627
} as any)
28+
const McpRoute = McpRouteImport.update({
29+
id: '/mcp',
30+
path: '/mcp',
31+
getParentRoute: () => rootRouteImport,
32+
} as any)
2733
const EventsRoute = EventsRouteImport.update({
2834
id: '/events',
2935
path: '/events',
@@ -38,33 +44,37 @@ const IndexRoute = IndexRouteImport.update({
3844
export interface FileRoutesByFullPath {
3945
'/': typeof IndexRoute
4046
'/events': typeof EventsRoute
47+
'/mcp': typeof McpRoute
4148
'/rules': typeof RulesRoute
4249
'/sessions': typeof SessionsRoute
4350
}
4451
export interface FileRoutesByTo {
4552
'/': typeof IndexRoute
4653
'/events': typeof EventsRoute
54+
'/mcp': typeof McpRoute
4755
'/rules': typeof RulesRoute
4856
'/sessions': typeof SessionsRoute
4957
}
5058
export interface FileRoutesById {
5159
__root__: typeof rootRouteImport
5260
'/': typeof IndexRoute
5361
'/events': typeof EventsRoute
62+
'/mcp': typeof McpRoute
5463
'/rules': typeof RulesRoute
5564
'/sessions': typeof SessionsRoute
5665
}
5766
export interface FileRouteTypes {
5867
fileRoutesByFullPath: FileRoutesByFullPath
59-
fullPaths: '/' | '/events' | '/rules' | '/sessions'
68+
fullPaths: '/' | '/events' | '/mcp' | '/rules' | '/sessions'
6069
fileRoutesByTo: FileRoutesByTo
61-
to: '/' | '/events' | '/rules' | '/sessions'
62-
id: '__root__' | '/' | '/events' | '/rules' | '/sessions'
70+
to: '/' | '/events' | '/mcp' | '/rules' | '/sessions'
71+
id: '__root__' | '/' | '/events' | '/mcp' | '/rules' | '/sessions'
6372
fileRoutesById: FileRoutesById
6473
}
6574
export interface RootRouteChildren {
6675
IndexRoute: typeof IndexRoute
6776
EventsRoute: typeof EventsRoute
77+
McpRoute: typeof McpRoute
6878
RulesRoute: typeof RulesRoute
6979
SessionsRoute: typeof SessionsRoute
7080
}
@@ -85,6 +95,13 @@ declare module '@tanstack/react-router' {
8595
preLoaderRoute: typeof RulesRouteImport
8696
parentRoute: typeof rootRouteImport
8797
}
98+
'/mcp': {
99+
id: '/mcp'
100+
path: '/mcp'
101+
fullPath: '/mcp'
102+
preLoaderRoute: typeof McpRouteImport
103+
parentRoute: typeof rootRouteImport
104+
}
88105
'/events': {
89106
id: '/events'
90107
path: '/events'
@@ -105,6 +122,7 @@ declare module '@tanstack/react-router' {
105122
const rootRouteChildren: RootRouteChildren = {
106123
IndexRoute: IndexRoute,
107124
EventsRoute: EventsRoute,
125+
McpRoute: McpRoute,
108126
RulesRoute: RulesRoute,
109127
SessionsRoute: SessionsRoute,
110128
}

0 commit comments

Comments
 (0)