Repository Public
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’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