g1t/apps/web/app/components/guardrails.tsx

412 lines17,376 bytesCodeBlame
1/**
2 * Guardrails: the form a workspace sets its defaults with and a project its
3 * overrides, and what a run shows of its caps.
4 */
5import { Clock, Coins, ShieldCheck } from "lucide-react";
6import { type ReactNode, useEffect, useState } from "react";
7import { Form } from "react-router";
8
9import {
10 type AgentRun,
11 type GuardrailSettings,
12 type Guardrails,
13 type GuardrailsView,
14 RUN_KINDS,
15 RUN_KIND_LABEL,
16 isActiveRun,
17} from "@g1t/contracts";
18
19import { formatCap, tri, workflowDomainLine } from "../lib/guardrails";
20import { formatCost } from "./agents";
21import { ErrorText, Input, SubmitButton, TimeAgo } from "./ui";
22import { CheckboxOption } from "./ui/checkbox";
23import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
24import { Textarea } from "./ui/textarea";
25
26function Section({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) {
27 return (
28 <section className="grid gap-x-10 gap-y-4 border-t border-line pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[16rem_1fr]">
29 <div>
30 <h2 className="font-medium">{title}</h2>
31 <div className="mt-1 text-sm text-muted">{about}</div>
32 </div>
33 <div className="min-w-0 space-y-3">{children}</div>
34 </section>
35 );
36}
37
38/** A choice of inheriting, or on or off, for one setting. */
39function TriSelect({
40 name,
41 value,
42 inherited,
43 parent,
44 labels,
45 title,
46 children,
47}: {
48 name: string;
49 value: boolean | null | undefined;
50 inherited: boolean;
51 parent: string;
52 labels: [on: string, off: string];
53 title: string;
54 children: ReactNode;
55}) {
56 return (
57 <div className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 sm:flex-row sm:items-start">
58 <div className="min-w-0 grow">
59 <p className="text-sm font-medium">{title}</p>
60 <p className="mt-1 text-sm text-muted">{children}</p>
61 </div>
62 <Select name={name} defaultValue={tri(value)}>
63 <SelectTrigger size="sm" aria-label={title} className="w-full shrink-0 sm:w-auto sm:min-w-44">
64 <SelectValue />
65 </SelectTrigger>
66 <SelectContent align="end">
67 <SelectItem value="inherit">
68 As {parent} ({(inherited ? labels[0] : labels[1]).toLowerCase()})
69 </SelectItem>
70 <SelectItem value="on">{labels[0]}</SelectItem>
71 <SelectItem value="off">{labels[1]}</SelectItem>
72 </SelectContent>
73 </Select>
74 </div>
75 );
76}
77
78/** Hosts in a compact list. */
79function Hosts({ hosts }: { hosts: string[] }) {
80 return (
81 <span className="font-mono text-xs break-words text-faint">{hosts.length ? hosts.join(", ") : "none"}</span>
82 );
83}
84
85/**
86 * One level's guardrails. `level` says which: the workspace's defaults,
87 * over g1t's, or a project's overrides, over its workspace's.
88 */
89export function GuardrailsForm({
90 view,
91 level,
92 editable,
93 saving,
94 saved,
95 error,
96}: {
97 view: GuardrailsView;
98 level: "workspace" | "project";
99 editable: boolean;
100 /** Working for a reason the Save button cannot see; it follows its own submission by itself. */
101 saving?: boolean;
102 saved: boolean;
103 error: string | null | undefined;
104}) {
105 const own: GuardrailSettings = (level === "project" ? view.project : view.workspace) ?? {};
106 // What this level inherits, and what it is called.
107 const base: Guardrails = level === "project" ? view.inherited : view.defaults;
108 const parent = level === "project" ? "the workspace" : "g1t's default";
109 const [registryMode, setRegistryMode] = useState(own.registries ? "custom" : "inherit");
110 const shownRegistries = own.registries ?? base.registries;
111 const inheritedDomains = level === "project" ? view.inherited.domains : [];
112 const inheritedDeny = level === "project" ? view.inherited.deny : [];
113 const inheritedWorkflowDomains = level === "project" ? (view.inherited.workflowDomains ?? []) : [];
114 return (
115 // Keyed to the last change, so after a save the fields show what was kept, as it was tidied.
116 <Form key={own.updatedAt ?? "unset"} method="post" className="max-w-4xl space-y-8">
117 <fieldset disabled={!editable} className="min-w-0 space-y-8">
118 <Section
119 title="Network"
120 about={
121 <>
122 Which hosts a sandbox may reach. Requests anywhere else are refused at the sandbox's edge, and
123 each refused host shows on the run as a step.
124 </>
125 }
126 >
127 <TriSelect
128 name="restrictNetwork"
129 value={own.restrictNetwork}
130 inherited={base.restrictNetwork}
131 parent={parent}
132 labels={["Restricted", "Open"]}
133 title="Only allowed hosts"
134 >
135 Restricted, a sandbox reaches g1t, the registries below and the domains you list, over HTTP and
136 HTTPS only. Open, it reaches the whole internet.
137 </TriSelect>
138 <div className="rounded-xl border border-line bg-surface p-4">
139 <p className="text-sm font-medium">Always allowed</p>
140 <p className="mt-1 text-sm text-muted">
141 g1t's own hosts, for cloning, pushing, reporting and the model: <Hosts hosts={view.g1tHosts} />
142 </p>
143 </div>
144 <div className="rounded-xl border border-line bg-surface p-4">
145 <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
146 <div className="min-w-0 grow">
147 <p className="text-sm font-medium">Package registries</p>
148 <p className="mt-1 text-sm text-muted">Where installs fetch dependencies from.</p>
149 </div>
150 <Select name="registries" value={registryMode} onValueChange={setRegistryMode}>
151 <SelectTrigger size="sm" aria-label="Package registries" className="w-full shrink-0 sm:w-auto sm:min-w-44">
152 <SelectValue />
153 </SelectTrigger>
154 <SelectContent align="end">
155 <SelectItem value="inherit">As {parent}</SelectItem>
156 <SelectItem value="custom">Choose</SelectItem>
157 </SelectContent>
158 </Select>
159 </div>
160 <div className="mt-4 grid gap-3 sm:grid-cols-2">
161 {view.registries.map((registry) => (
162 <CheckboxOption
163 key={`${registry.id}-${registryMode}`}
164 name={`registry:${registry.id}`}
165 disabled={registryMode !== "custom"}
166 defaultChecked={(registryMode === "custom" ? shownRegistries : base.registries).includes(registry.id)}
167 label={registry.name}
168 description={registry.hosts.join(", ")}
169 />
170 ))}
171 </div>
172 </div>
173 <div className="rounded-xl border border-line bg-surface p-4">
174 <label htmlFor="guardrail-domains" className="text-sm font-medium">
175 Allowed domains
176 </label>
177 <p className="mt-1 text-sm text-muted">
178 One per line: <span className="font-mono text-xs">api.stripe.com</span>, or{" "}
179 <span className="font-mono text-xs">*.example.com</span> for its subdomains.
180 {level === "project" && " These add to the workspace's."}
181 </p>
182 <Textarea
183 id="guardrail-domains"
184 name="domains"
185 className="mt-3 font-mono text-xs"
186 rows={4}
187 defaultValue={(own.domains ?? []).join("\n")}
188 placeholder="api.stripe.com"
189 />
190 {inheritedDomains.length > 0 && (
191 <p className="mt-2 text-xs text-faint">
192 From the workspace: <Hosts hosts={inheritedDomains} />
193 </p>
194 )}
195 </div>
196 <div className="rounded-xl border border-line bg-surface p-4">
197 <label htmlFor="guardrail-workflow-domains" className="text-sm font-medium">
198 Workflow-only domains
199 </label>
200 <p className="mt-1 text-sm text-muted">
201 Hosts that only workflow jobs may reach, never agents, such as the API a deploy uploads to. One
202 per line: the domain, then the workflows and the environments it is for, each comma-separated;
203 leave either out for any. Only jobs of runs that are not pull requests from forks get them.
204 {level === "project" && " These add to the workspace's."}
205 </p>
206 <Textarea
207 id="guardrail-workflow-domains"
208 name="workflowDomains"
209 className="mt-3 font-mono text-xs"
210 rows={3}
211 defaultValue={(own.workflowDomains ?? []).map(workflowDomainLine).join("\n")}
212 placeholder="api.cloudflare.com | deploy.yml | production"
213 />
214 {inheritedWorkflowDomains.length > 0 && (
215 <p className="mt-2 text-xs text-faint">
216 From the workspace:{" "}
217 <span className="font-mono break-words">{inheritedWorkflowDomains.map(workflowDomainLine).join("; ")}</span>
218 </p>
219 )}
220 </div>
221 </Section>
222
223 <Section
224 title="Commands"
225 about={
226 <>
227 What the agent's harness refuses to run. A refused command is not run; the agent is told why, and
228 the run shows it as a step.
229 </>
230 }
231 >
232 {view.rules.map((rule) => (
233 <TriSelect
234 key={rule.id}
235 name={`rule:${rule.id}`}
236 value={own.rules?.[rule.id]}
237 inherited={base.rules[rule.id] ?? true}
238 parent={parent}
239 labels={["On", "Off"]}
240 title={rule.title}
241 >
242 {rule.about}
243 </TriSelect>
244 ))}
245 <div className="rounded-xl border border-line bg-surface p-4">
246 <label htmlFor="guardrail-deny" className="text-sm font-medium">
247 Also refuse
248 </label>
249 <p className="mt-1 text-sm text-muted">
250 One rule per line, as <span className="font-mono text-xs">Bash(terraform apply:*)</span>,{" "}
251 <span className="font-mono text-xs">Edit(//etc/**)</span> or{" "}
252 <span className="font-mono text-xs">WebFetch</span>. Plain text is the start of a shell command.
253 {level === "project" && " These add to the workspace's."}
254 </p>
255 <Textarea
256 id="guardrail-deny"
257 name="deny"
258 className="mt-3 font-mono text-xs"
259 rows={4}
260 defaultValue={(own.deny ?? []).join("\n")}
261 placeholder="Bash(terraform apply:*)"
262 />
263 {inheritedDeny.length > 0 && (
264 <p className="mt-2 text-xs text-faint">
265 From the workspace: <span className="font-mono">{inheritedDeny.join(", ")}</span>
266 </p>
267 )}
268 </div>
269 </Section>
270
271 <Section
272 title="Caps"
273 about="How much one run may cost and how long it may take. A run that reaches either is stopped, and its pull request waits for you."
274 >
275 <div className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 sm:flex-row sm:items-start">
276 <div className="min-w-0 grow">
277 <label htmlFor="guardrail-budget" className="text-sm font-medium">
278 Cost per run, in US dollars
279 </label>
280 <p className="mt-1 text-sm text-muted">
281 Empty: as {parent} ({formatCap(base.budgetUsd)}). 0: no cap here. The workspace's spend cap per run, under Billing, applies as well, and a run stops at the lower of the two.
282 </p>
283 </div>
284 <Input
285 id="guardrail-budget"
286 name="budgetUsd"
287 inputMode="decimal"
288 className="w-full shrink-0 sm:w-32"
289 defaultValue={own.budgetUsd == null ? "" : String(own.budgetUsd)}
290 placeholder={base.budgetUsd == null ? "0" : base.budgetUsd.toFixed(2)}
291 />
292 </div>
293 <div className="rounded-xl border border-line bg-surface p-4">
294 <p className="text-sm font-medium">Time per run, in minutes</p>
295 <p className="mt-1 text-sm text-muted">Empty: as {parent}, shown faded.</p>
296 <div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
297 {RUN_KINDS.map((kind) => (
298 <label key={kind} className="flex flex-col gap-1 text-xs text-muted">
299 {RUN_KIND_LABEL[kind]}
300 <Input
301 name={`minutes:${kind}`}
302 inputMode="numeric"
303 defaultValue={own.minutes?.[kind] == null ? "" : String(own.minutes[kind])}
304 placeholder={String(base.minutes[kind] ?? "")}
305 />
306 </label>
307 ))}
308 </div>
309 </div>
310 </Section>
311 </fieldset>
312
313 {editable ? (
314 <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
315 <SubmitButton pending="Saving…" busy={saving}>
316 Save guardrails
317 </SubmitButton>
318 {saved && <span className="text-sm text-muted">Saved. Runs that start from now on get these.</span>}
319 <ErrorText>{error}</ErrorText>
320 {own.updatedBy && own.updatedAt && !saved && !error && (
321 <span className="text-xs text-faint">
322 Last changed by <span className="font-mono">{own.updatedBy}</span> <TimeAgo at={own.updatedAt} />
323 </span>
324 )}
325 </div>
326 ) : (
327 <p className="text-sm text-muted">
328 {level === "workspace"
329 ? "Only the workspace's owners can change its defaults."
330 : "Only members of the workspace can change a project's guardrails."}
331 </p>
332 )}
333 </Form>
334 );
335}
336
337/** Minutes since `from`, ticking while the run goes on. */
338function useMinutes(from: string | null, to: string | null): number | null {
339 const [now, setNow] = useState(() => Date.now());
340 useEffect(() => {
341 if (!from || to) return;
342 const timer = setInterval(() => setNow(Date.now()), 5000);
343 return () => clearInterval(timer);
344 }, [from, to]);
345 if (!from) return null;
346 return ((to ? new Date(to).getTime() : now) - new Date(from).getTime()) / 60000;
347}
348
349function Meter({ share, tone }: { share: number | null; tone: string }) {
350 if (share == null) return null;
351 return (
352 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-line" aria-hidden>
353 <span className={`block h-full rounded-full ${tone}`} style={{ width: `${Math.round(share * 100)}%` }} />
354 </span>
355 );
356}
357
358/** A run's caps: spent and elapsed against them, and whether one stopped it. */
359export function RunCaps({ run, member }: { run: AgentRun; member: boolean }) {
360 const active = isActiveRun(run.status);
361 const minutes = useMinutes(run.startedAt ?? (active ? run.createdAt : null), run.finishedAt);
362 if (run.timeCapMinutes == null && run.budgetUsd == null && !run.halted) return null;
363 const cap = run.timeCapMinutes ?? null;
364 const timeShare = cap && minutes != null ? Math.min(1, minutes / cap) : null;
365 const spent = formatCost(run.costUsd);
366 const budget = run.budgetUsd ?? null;
367 const costShare = budget && run.costUsd != null ? Math.min(1, run.costUsd / budget) : null;
368 return (
369 <section className="mt-6 rounded-xl border border-line bg-surface p-4">
370 <div className="flex items-center gap-2 text-sm font-medium">
371 <ShieldCheck size={15} className="text-accent" />
372 Guardrails
373 {run.halted && (
374 <span className="ml-auto rounded-full border border-warn/40 bg-warn/10 px-2 py-0.5 text-xs font-normal text-warn">
375 {run.halted === "abuse"
376 ? "Stopped: unusual CPU use"
377 : `Stopped at its ${run.halted === "budget" ? "cost" : "time"} cap`}
378 </span>
379 )}
380 </div>
381 <div className="mt-3 grid gap-4 text-sm sm:grid-cols-2">
382 {cap != null && (
383 <div>
384 <p className="flex items-center gap-1.5 text-muted">
385 <Clock size={13} />
386 <span className="tabular-nums text-fg" suppressHydrationWarning>
387 {minutes == null ? "—" : `${Math.floor(minutes)}m`}
388 </span>
389 of {cap}m
390 </p>
391 <Meter share={timeShare} tone={timeShare != null && timeShare > 0.85 ? "bg-warn" : "bg-accent"} />
392 </div>
393 )}
394 {member && (
395 <div>
396 <p className="flex items-center gap-1.5 text-muted">
397 <Coins size={13} />
398 {spent ? <span className="tabular-nums text-fg">{spent}</span> : <span>Spend</span>}
399 of {formatCap(budget)}
400 </p>
401 <Meter share={costShare} tone={costShare != null && costShare > 0.85 ? "bg-warn" : "bg-accent"} />
402 {active && !spent && budget != null && (
403 <p className="mt-1.5 text-xs text-faint">
404 The agent stops itself at the cap. What it spent is reported when the run ends.
405 </p>
406 )}
407 </div>
408 )}
409 </div>
410 </section>
411 );
412}