SPB Git

Repository Public

page.tsx 10.9 KB 185 lines tsx

1'use client';2import { useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import { Plus, Trash2 } from 'lucide-react';5import { api, errorMessage } from '@/lib/api';6import { qk, usePolicyRules, useProjects, type PolicyRule } from '@/lib/queries';7import { toast } from '@/stores/toasts';8import { useNow } from '@/hooks/use-viewport';9import { fmtRelative } from '@/lib/format';10import { Badge, type Tone } from '@/components/ui/badge';11import { Button, IconButton } from '@/components/ui/button';12import { ConfirmDialog, Dialog } from '@/components/ui/dialog';13import { Field, Input } from '@/components/ui/input';14import { NONE, Select } from '@/components/ui/select';15import { EmptyState, ErrorState, Section, Skeleton } from '@/components/ui/misc';16import { IllusPolicy } from '@/components/ui/illustrations';1718const DECISION: Record<PolicyRule['decision'], { label: string; tone: Tone; hint: string }> = {19  deny: { label: 'Deny', tone: 'error', hint: 'The call is refused; the agent is told and may adapt.' },20  ask: { label: 'Ask', tone: 'info', hint: 'The run is suspended until you approve or deny.' },21  allow: { label: 'Allow', tone: 'success', hint: 'The call runs without asking.' },22};23const TOOLS = ['shell', 'git_push', 'git_commit', 'write_file', 'edit_file', 'read_file', 'search', 'list_dir', 'git_status', 'git_diff', '*'];24const DEFAULTS: { risk: string; decision: PolicyRule['decision']; note: string }[] = [25  { risk: 'Read', decision: 'allow', note: 'reading, listing and searching inside the workspace' },26  { risk: 'Workspace write', decision: 'allow', note: 'edits inside the sandbox, on the mission branch' },27  { risk: 'Network', decision: 'ask', note: 'shell commands when the mission has network access' },28  { risk: 'External effect', decision: 'ask', note: 'e.g. git push' },29];3031function matcherText(match: Record<string, unknown>): string {32  const e = Object.entries(match);33  if (e.length === 0) return 'any call';34  return e.map(([k, v]) => `${k === 'commandPrefix' ? 'command starts with' : k === 'pathPrefix' ? 'path starts with' : k} ${typeof v === 'string' ? `“${v}”` : JSON.stringify(v)}`).join(' and ');35}3637function CreateRule({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {38  const qc = useQueryClient();39  const projects = useProjects().data ?? [];40  const [decision, setDecision] = useState<PolicyRule['decision']>('ask');41  const [tool, setTool] = useState('shell');42  const [matchKey, setMatchKey] = useState<'none' | 'commandPrefix' | 'pathPrefix'>('none');43  const [matchValue, setMatchValue] = useState('');44  const [projectId, setProjectId] = useState(NONE);45  const [note, setNote] = useState('');46  const [busy, setBusy] = useState(false);47  const [error, setError] = useState<string | null>(null);48  const valid = tool.trim().length > 0 && (matchKey === 'none' || matchValue.trim().length > 0);49  const submit = async () => {50    setBusy(true);51    setError(null);52    try {53      await api.post('/policy/rules', { projectId: projectId === NONE ? null : projectId, decision, tool: tool.trim(), match: matchKey === 'none' ? {} : { [matchKey]: matchValue.trim() }, note: note.trim() });54      await qc.invalidateQueries({ queryKey: qk.policy });55      toast.success('Rule created');56      onOpenChange(false);57      setMatchValue('');58      setNote('');59    } catch (e) {60      setError(errorMessage(e));61    } finally {62      setBusy(false);63    }64  };65  return (66    <Dialog67      open={open}68      onOpenChange={onOpenChange}69      title="New policy rule"70      description="Rules are evaluated by the engine, never by the model. Order: deny, then ask, then allow; project rules before global ones."71      footer={<><Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button><Button variant="primary" loading={busy} disabled={!valid} onClick={submit} data-testid="rule-submit">Create rule</Button></>}72    >73      <div className="flex flex-col gap-4" data-testid="rule-form">74        <div role="radiogroup" aria-label="Decision" className="grid grid-cols-3 gap-2">75          {(['deny', 'ask', 'allow'] as const).map((d) => (76            <button key={d} type="button" role="radio" aria-checked={decision === d} onClick={() => setDecision(d)} data-testid={`rule-decision-${d}`} className={`flex h-10 items-center justify-center rounded-[12px] border text-[13px] font-semibold transition-colors pointer-coarse:h-12 ${decision === d ? 'border-accent bg-accent/[0.07] text-text ring-[3px] ring-accent/15' : 'border-line-2 text-text-2 hover:border-line-3'}`}>77              {DECISION[d].label}78            </button>79          ))}80        </div>81        <p className="-mt-2 text-xs text-muted">{DECISION[decision].hint}</p>82        <Field label="Tool" hint="Exact tool name; * matches every tool.">83          <Input value={tool} onChange={(e) => setTool(e.target.value)} list="aos-tools" maxLength={80} className="font-mono" data-testid="rule-tool" />84        </Field>85        <datalist id="aos-tools">{TOOLS.map((t) => <option key={t} value={t} />)}</datalist>86        <div className="grid gap-3 sm:grid-cols-[180px_1fr]">87          <Field label="Matcher">88            <Select value={matchKey} onValueChange={(v) => setMatchKey(v as typeof matchKey)} label="Matcher" options={[{ value: 'none', label: 'Any call' }, { value: 'commandPrefix', label: 'Command starts with' }, { value: 'pathPrefix', label: 'Path starts with' }]} />89          </Field>90          <Field label="Value">91            <Input value={matchValue} onChange={(e) => setMatchValue(e.target.value)} disabled={matchKey === 'none'} placeholder={matchKey === 'commandPrefix' ? 'npm test' : matchKey === 'pathPrefix' ? 'src/' : '—'} className="font-mono" data-testid="rule-match-value" />92          </Field>93        </div>94        <Field label="Scope">95          <Select value={projectId} onValueChange={setProjectId} label="Scope" options={[{ value: NONE, label: 'Global — every project' }, ...projects.map((p) => ({ value: p.id, label: `Project: ${p.name}` }))]} />96        </Field>97        <Field label="Note" hint="Optional. Why this rule exists.">98          <Input value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} data-testid="rule-note" />99        </Field>100        {error && <p role="alert" className="text-xs text-error">{error}</p>}101      </div>102    </Dialog>103  );104}105106export default function PolicyPage() {107  const q = usePolicyRules();108  const qc = useQueryClient();109  const projects = useProjects().data ?? [];110  const now = useNow(60_000);111  const [creating, setCreating] = useState(false);112  const [deleting, setDeleting] = useState<PolicyRule | null>(null);113  const rules = [...(q.data ?? [])].sort((a, b) => ['deny', 'ask', 'allow'].indexOf(a.decision) - ['deny', 'ask', 'allow'].indexOf(b.decision) || b.createdAt.localeCompare(a.createdAt));114  return (115    <div className="h-full overflow-y-auto" data-testid="policy-page">116      <div className="mx-auto flex w-full max-w-4xl flex-col gap-5 px-4 pb-32 pt-1 md:px-8 md:pb-12 md:pt-6">117        <div className="flex items-end gap-3">118          <div className="min-w-0 flex-1">119            <h1 className="hidden text-xl font-bold tracking-tight md:block">Policy</h1>120            <p className="text-[13px] leading-relaxed text-text-2">What agents may do without asking. Hard limits — protected paths, the sandbox, tools outside a mission&rsquo;s allowlist — always win and cannot be relaxed here.</p>121          </div>122          <Button variant="brand" className="h-8 shrink-0 rounded-full px-3.5 font-semibold pointer-coarse:h-12 pointer-coarse:px-4" onClick={() => setCreating(true)} data-testid="rule-new"><Plus className="size-4" strokeWidth={2.6} /> New rule</Button>123        </div>124        <Section title="Your rules" aside={q.data ? `${rules.length} rule${rules.length === 1 ? '' : 's'}` : undefined}>125          {q.isError ? (126            <ErrorState error={q.error} onRetry={() => q.refetch()} />127          ) : q.isPending ? (128            <div className="flex flex-col gap-2 p-4"><Skeleton className="h-10" /><Skeleton className="h-10" /></div>129          ) : rules.length === 0 ? (130            <EmptyState illustration={<IllusPolicy />} title="No rule yet" description="Only the defaults below apply. Rules are also created when you choose “Approve and remember” on a request." />131          ) : (132            <ul className="divide-y divide-line" data-testid="rules-list">133              {rules.map((r) => (134                <li key={r.id} className="flex items-center gap-3 px-4 py-3" data-testid="rule-row" data-decision={r.decision}>135                  <Badge tone={DECISION[r.decision].tone} className="w-14 justify-center">{DECISION[r.decision].label}</Badge>136                  <div className="min-w-0 flex-1">137                    <p className="flex flex-wrap items-baseline gap-x-2 text-[13px]">138                      <code className="font-mono font-semibold text-text">{r.tool}</code>139                      <span className="text-text-2">{matcherText(r.match)}</span>140                    </p>141                    <p className="tnum mt-0.5 truncate text-xs text-muted">142                      {r.projectId ? `Project: ${projects.find((p) => p.id === r.projectId)?.name ?? 'unknown project'}` : 'Global'} · {fmtRelative(r.createdAt, now)}{r.note ? ` · ${r.note}` : ''}143                    </p>144                  </div>145                  <IconButton label={`Delete rule for ${r.tool}`} onClick={() => setDeleting(r)} data-testid="rule-delete"><Trash2 className="size-4" /></IconButton>146                </li>147              ))}148            </ul>149          )}150        </Section>151        <Section title="Defaults by risk" aside="apply when no rule matches">152          <ul className="divide-y divide-line">153            {DEFAULTS.map((d) => (154              <li key={d.risk} className="flex items-center gap-3 px-4 py-2.5">155                <Badge tone={DECISION[d.decision].tone} className="w-14 justify-center">{DECISION[d.decision].label}</Badge>156                <p className="min-w-0 flex-1 text-[13px]"><span className="font-medium text-text">{d.risk}</span> <span className="text-text-2">— {d.note}</span></p>157              </li>158            ))}159          </ul>160        </Section>161      </div>162      <CreateRule open={creating} onOpenChange={setCreating} />163      <ConfirmDialog164        open={!!deleting}165        onOpenChange={(o) => !o && setDeleting(null)}166        title="Delete this rule?"167        description={deleting ? <>The <span className="font-medium">{deleting.decision}</span> rule for <code className="font-mono">{deleting.tool}</code> ({matcherText(deleting.match)}) stops applying immediately; the defaults take over.</> : ''}168        confirmLabel="Delete rule"169        danger170        onConfirm={async () => {171          if (!deleting) return;172          try {173            await api.delete(`/policy/rules/${deleting.id}`);174            await qc.invalidateQueries({ queryKey: qk.policy });175            toast.success('Rule deleted');176          } catch (e) {177            toast.error('Could not delete the rule', errorMessage(e));178            throw e;179          }180        }}181      />182    </div>183  );184}185
Files main
Menu

Theme