flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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