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