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

410 lines17,154 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1/**
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
Fast pages, required checks on the branch, self-hosted runners, honest incidents19import { formatCap, tri, workflowDomainLine } from "../lib/guardrails";
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API20import { formatCost } from "./agents";
21import { Button, ErrorText, Input, 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 saving: boolean;
101 saved: boolean;
102 error: string | null | undefined;
103}) {
104 const own: GuardrailSettings = (level === "project" ? view.project : view.workspace) ?? {};
105 // What this level inherits, and what it is called.
106 const base: Guardrails = level === "project" ? view.inherited : view.defaults;
107 const parent = level === "project" ? "the workspace" : "g1t's default";
108 const [registryMode, setRegistryMode] = useState(own.registries ? "custom" : "inherit");
109 const shownRegistries = own.registries ?? base.registries;
110 const inheritedDomains = level === "project" ? view.inherited.domains : [];
111 const inheritedDeny = level === "project" ? view.inherited.deny : [];
Fast pages, required checks on the branch, self-hosted runners, honest incidents112 const inheritedWorkflowDomains = level === "project" ? (view.inherited.workflowDomains ?? []) : [];
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API113 return (
114 <Form method="post" className="max-w-4xl space-y-8">
115 <fieldset disabled={!editable} className="min-w-0 space-y-8">
116 <Section
117 title="Network"
118 about={
119 <>
120 Which hosts a sandbox may reach. Requests anywhere else are refused at the sandbox's edge, and
121 each refused host shows on the run as a step.
122 </>
123 }
124 >
125 <TriSelect
126 name="restrictNetwork"
127 value={own.restrictNetwork}
128 inherited={base.restrictNetwork}
129 parent={parent}
130 labels={["Restricted", "Open"]}
131 title="Only allowed hosts"
132 >
133 Restricted, a sandbox reaches g1t, the registries below and the domains you list, over HTTP and
134 HTTPS only. Open, it reaches the whole internet.
135 </TriSelect>
136 <div className="rounded-xl border border-line bg-surface p-4">
137 <p className="text-sm font-medium">Always allowed</p>
138 <p className="mt-1 text-sm text-muted">
139 g1t's own hosts, for cloning, pushing, reporting and the model: <Hosts hosts={view.g1tHosts} />
140 </p>
141 </div>
142 <div className="rounded-xl border border-line bg-surface p-4">
143 <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
144 <div className="min-w-0 grow">
145 <p className="text-sm font-medium">Package registries</p>
146 <p className="mt-1 text-sm text-muted">Where installs fetch dependencies from.</p>
147 </div>
148 <Select name="registries" value={registryMode} onValueChange={setRegistryMode}>
149 <SelectTrigger size="sm" aria-label="Package registries" className="w-full shrink-0 sm:w-auto sm:min-w-44">
150 <SelectValue />
151 </SelectTrigger>
152 <SelectContent align="end">
153 <SelectItem value="inherit">As {parent}</SelectItem>
154 <SelectItem value="custom">Choose</SelectItem>
155 </SelectContent>
156 </Select>
157 </div>
158 <div className="mt-4 grid gap-3 sm:grid-cols-2">
159 {view.registries.map((registry) => (
160 <CheckboxOption
161 key={`${registry.id}-${registryMode}`}
162 name={`registry:${registry.id}`}
163 disabled={registryMode !== "custom"}
164 defaultChecked={(registryMode === "custom" ? shownRegistries : base.registries).includes(registry.id)}
165 label={registry.name}
166 description={registry.hosts.join(", ")}
167 />
168 ))}
169 </div>
170 </div>
171 <div className="rounded-xl border border-line bg-surface p-4">
172 <label htmlFor="guardrail-domains" className="text-sm font-medium">
173 Allowed domains
174 </label>
175 <p className="mt-1 text-sm text-muted">
176 One per line: <span className="font-mono text-xs">api.stripe.com</span>, or{" "}
177 <span className="font-mono text-xs">*.example.com</span> for its subdomains.
178 {level === "project" && " These add to the workspace's."}
179 </p>
180 <Textarea
181 id="guardrail-domains"
182 name="domains"
183 className="mt-3 font-mono text-xs"
184 rows={4}
185 defaultValue={(own.domains ?? []).join("\n")}
186 placeholder="api.stripe.com"
187 />
188 {inheritedDomains.length > 0 && (
189 <p className="mt-2 text-xs text-faint">
190 From the workspace: <Hosts hosts={inheritedDomains} />
191 </p>
192 )}
193 </div>
Fast pages, required checks on the branch, self-hosted runners, honest incidents194 <div className="rounded-xl border border-line bg-surface p-4">
195 <label htmlFor="guardrail-workflow-domains" className="text-sm font-medium">
196 Workflow-only domains
197 </label>
198 <p className="mt-1 text-sm text-muted">
199 Hosts that only workflow jobs may reach, never agents, such as the API a deploy uploads to. One
200 per line: the domain, then the workflows and the environments it is for, each comma-separated;
201 leave either out for any. Only jobs of runs that are not pull requests from forks get them.
202 {level === "project" && " These add to the workspace's."}
203 </p>
204 <Textarea
205 id="guardrail-workflow-domains"
206 name="workflowDomains"
207 className="mt-3 font-mono text-xs"
208 rows={3}
209 defaultValue={(own.workflowDomains ?? []).map(workflowDomainLine).join("\n")}
210 placeholder="api.cloudflare.com | deploy.yml | production"
211 />
212 {inheritedWorkflowDomains.length > 0 && (
213 <p className="mt-2 text-xs text-faint">
214 From the workspace:{" "}
215 <span className="font-mono break-words">{inheritedWorkflowDomains.map(workflowDomainLine).join("; ")}</span>
216 </p>
217 )}
218 </div>
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API219 </Section>
220
221 <Section
222 title="Commands"
223 about={
224 <>
225 What the agent's harness refuses to run. A refused command is not run; the agent is told why, and
226 the run shows it as a step.
227 </>
228 }
229 >
230 {view.rules.map((rule) => (
231 <TriSelect
232 key={rule.id}
233 name={`rule:${rule.id}`}
234 value={own.rules?.[rule.id]}
235 inherited={base.rules[rule.id] ?? true}
236 parent={parent}
237 labels={["On", "Off"]}
238 title={rule.title}
239 >
240 {rule.about}
241 </TriSelect>
242 ))}
243 <div className="rounded-xl border border-line bg-surface p-4">
244 <label htmlFor="guardrail-deny" className="text-sm font-medium">
245 Also refuse
246 </label>
247 <p className="mt-1 text-sm text-muted">
248 One rule per line, as <span className="font-mono text-xs">Bash(terraform apply:*)</span>,{" "}
249 <span className="font-mono text-xs">Edit(//etc/**)</span> or{" "}
250 <span className="font-mono text-xs">WebFetch</span>. Plain text is the start of a shell command.
251 {level === "project" && " These add to the workspace's."}
252 </p>
253 <Textarea
254 id="guardrail-deny"
255 name="deny"
256 className="mt-3 font-mono text-xs"
257 rows={4}
258 defaultValue={(own.deny ?? []).join("\n")}
259 placeholder="Bash(terraform apply:*)"
260 />
261 {inheritedDeny.length > 0 && (
262 <p className="mt-2 text-xs text-faint">
263 From the workspace: <span className="font-mono">{inheritedDeny.join(", ")}</span>
264 </p>
265 )}
266 </div>
267 </Section>
268
269 <Section
270 title="Caps"
271 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."
272 >
273 <div className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 sm:flex-row sm:items-start">
274 <div className="min-w-0 grow">
275 <label htmlFor="guardrail-budget" className="text-sm font-medium">
276 Cost per run, in US dollars
277 </label>
278 <p className="mt-1 text-sm text-muted">
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily279 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.
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API280 </p>
281 </div>
282 <Input
283 id="guardrail-budget"
284 name="budgetUsd"
285 inputMode="decimal"
286 className="w-full shrink-0 sm:w-32"
287 defaultValue={own.budgetUsd == null ? "" : String(own.budgetUsd)}
288 placeholder={base.budgetUsd == null ? "0" : base.budgetUsd.toFixed(2)}
289 />
290 </div>
291 <div className="rounded-xl border border-line bg-surface p-4">
292 <p className="text-sm font-medium">Time per run, in minutes</p>
293 <p className="mt-1 text-sm text-muted">Empty: as {parent}, shown faded.</p>
294 <div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
295 {RUN_KINDS.map((kind) => (
296 <label key={kind} className="flex flex-col gap-1 text-xs text-muted">
297 {RUN_KIND_LABEL[kind]}
298 <Input
299 name={`minutes:${kind}`}
300 inputMode="numeric"
301 defaultValue={own.minutes?.[kind] == null ? "" : String(own.minutes[kind])}
302 placeholder={String(base.minutes[kind] ?? "")}
303 />
304 </label>
305 ))}
306 </div>
307 </div>
308 </Section>
309 </fieldset>
310
311 {editable ? (
312 <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">
313 <Button type="submit" disabled={saving}>
314 {saving ? "Saving…" : "Save guardrails"}
315 </Button>
316 {saved && <span className="text-sm text-muted">Saved. Runs that start from now on get these.</span>}
317 <ErrorText>{error}</ErrorText>
318 {own.updatedBy && own.updatedAt && !saved && !error && (
319 <span className="text-xs text-faint">
320 Last changed by <span className="font-mono">{own.updatedBy}</span> <TimeAgo at={own.updatedAt} />
321 </span>
322 )}
323 </div>
324 ) : (
325 <p className="text-sm text-muted">
326 {level === "workspace"
327 ? "Only the workspace's owners can change its defaults."
328 : "Only members of the workspace can change a project's guardrails."}
329 </p>
330 )}
331 </Form>
332 );
333}
334
335/** Minutes since `from`, ticking while the run goes on. */
336function useMinutes(from: string | null, to: string | null): number | null {
337 const [now, setNow] = useState(() => Date.now());
338 useEffect(() => {
339 if (!from || to) return;
340 const timer = setInterval(() => setNow(Date.now()), 5000);
341 return () => clearInterval(timer);
342 }, [from, to]);
343 if (!from) return null;
344 return ((to ? new Date(to).getTime() : now) - new Date(from).getTime()) / 60000;
345}
346
347function Meter({ share, tone }: { share: number | null; tone: string }) {
348 if (share == null) return null;
349 return (
350 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-line" aria-hidden>
351 <span className={`block h-full rounded-full ${tone}`} style={{ width: `${Math.round(share * 100)}%` }} />
352 </span>
353 );
354}
355
356/** A run's caps: spent and elapsed against them, and whether one stopped it. */
357export function RunCaps({ run, member }: { run: AgentRun; member: boolean }) {
358 const active = isActiveRun(run.status);
359 const minutes = useMinutes(run.startedAt ?? (active ? run.createdAt : null), run.finishedAt);
360 if (run.timeCapMinutes == null && run.budgetUsd == null && !run.halted) return null;
361 const cap = run.timeCapMinutes ?? null;
362 const timeShare = cap && minutes != null ? Math.min(1, minutes / cap) : null;
363 const spent = formatCost(run.costUsd);
364 const budget = run.budgetUsd ?? null;
365 const costShare = budget && run.costUsd != null ? Math.min(1, run.costUsd / budget) : null;
366 return (
367 <section className="mt-6 rounded-xl border border-line bg-surface p-4">
368 <div className="flex items-center gap-2 text-sm font-medium">
369 <ShieldCheck size={15} className="text-accent" />
370 Guardrails
371 {run.halted && (
372 <span className="ml-auto rounded-full border border-warn/40 bg-warn/10 px-2 py-0.5 text-xs font-normal text-warn">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look373 {run.halted === "abuse"
374 ? "Stopped: unusual CPU use"
375 : `Stopped at its ${run.halted === "budget" ? "cost" : "time"} cap`}
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API376 </span>
377 )}
378 </div>
379 <div className="mt-3 grid gap-4 text-sm sm:grid-cols-2">
380 {cap != null && (
381 <div>
382 <p className="flex items-center gap-1.5 text-muted">
383 <Clock size={13} />
384 <span className="tabular-nums text-fg" suppressHydrationWarning>
385 {minutes == null ? "—" : `${Math.floor(minutes)}m`}
386 </span>
387 of {cap}m
388 </p>
389 <Meter share={timeShare} tone={timeShare != null && timeShare > 0.85 ? "bg-warn" : "bg-accent"} />
390 </div>
391 )}
392 {member && (
393 <div>
394 <p className="flex items-center gap-1.5 text-muted">
395 <Coins size={13} />
396 {spent ? <span className="tabular-nums text-fg">{spent}</span> : <span>Spend</span>}
397 of {formatCap(budget)}
398 </p>
399 <Meter share={costShare} tone={costShare != null && costShare > 0.85 ? "bg-warn" : "bg-accent"} />
400 {active && !spent && budget != null && (
401 <p className="mt-1.5 text-xs text-faint">
402 The agent stops itself at the cap. What it spent is reported when the run ends.
403 </p>
404 )}
405 </div>
406 )}
407 </div>
408 </section>
409 );
410}