shraga 0.1.113 → 0.1.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +8 -1
  2. package/defaults/scripts/agent-once.ts +2 -0
  3. package/defaults/security/policy.example.json +27 -0
  4. package/defaults/skills/mcp-server.md +1 -7
  5. package/defaults/skills/platform.md +2 -2
  6. package/defaults/system-prompt.md +1 -1
  7. package/dist/client/assets/index-CFxKc8dH.css +10 -0
  8. package/dist/client/assets/index-CeGkHIed.js +2009 -0
  9. package/dist/client/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/client/components/ConfigPanel.tsx +22 -0
  12. package/src/client/components/Sidebar.tsx +16 -1
  13. package/src/client/components/owner/ApiKeysTab.tsx +87 -0
  14. package/src/client/components/owner/AuditTab.tsx +110 -0
  15. package/src/client/components/owner/BindingsTab.tsx +110 -0
  16. package/src/client/components/owner/BlocklistTab.tsx +96 -0
  17. package/src/client/components/owner/OwnerConsole.tsx +87 -0
  18. package/src/client/components/owner/PrincipalsTab.tsx +55 -0
  19. package/src/client/components/owner/RolesTab.tsx +132 -0
  20. package/src/client/components/owner/shared.tsx +84 -0
  21. package/src/client/hooks/useOwner.ts +20 -0
  22. package/src/client/lib/api.ts +2 -2
  23. package/src/index.ts +10 -2
  24. package/src/server/agent-config.ts +34 -0
  25. package/src/server/api-key-routes.ts +5 -3
  26. package/src/server/boot.ts +4 -1
  27. package/src/server/claude.ts +4 -23
  28. package/src/server/data-sync.ts +1 -0
  29. package/src/server/engine/claude-code.ts +34 -3
  30. package/src/server/security/audit.ts +12 -5
  31. package/src/server/security/enforce.ts +43 -6
  32. package/src/server/security/guard.ts +8 -0
  33. package/src/server/security/owner-only.ts +21 -2
  34. package/src/server/security/owner-routes.ts +246 -4
  35. package/src/server/security/policy.ts +4 -1
  36. package/src/server/security/runtime.ts +53 -1
  37. package/src/server/sessions.ts +3 -0
  38. package/src/server/shraga-config.ts +13 -0
  39. package/src/server/workspace.ts +9 -0
  40. package/dist/client/assets/index-DIDtPQb-.css +0 -10
  41. package/dist/client/assets/index-DJ0AgGIu.js +0 -1969
@@ -0,0 +1,55 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { Ban, KeyRound, RefreshCw } from 'lucide-react';
3
+ import { ErrorBox, fmtTs, iconBtn, principalActions, selectCls, tdCls, thCls, useAction, type OwnerCall } from './shared';
4
+
5
+ interface Row { id: string; kind: string; lastRole?: string; lastSeen: string; turns: number; denies: number }
6
+
7
+ export function PrincipalsTab({ call, onPolicyChange, ownerIds }: { call: OwnerCall; onPolicyChange: () => Promise<void>; ownerIds: string[] }) {
8
+ const [days, setDays] = useState(7);
9
+ const [data, setData] = useState<{ principals: Row[]; truncated: boolean } | null>(null);
10
+ const { busy, error, run } = useAction();
11
+ const actions = principalActions(call, onPolicyChange, ownerIds);
12
+ const load = useCallback(() => run('load', async () => setData(await call(`/principals?days=${days}`))), [call, days]); // eslint-disable-line react-hooks/exhaustive-deps
13
+ useEffect(() => { load(); }, [load]);
14
+
15
+ return (
16
+ <div className="space-y-2">
17
+ <div className="flex items-center gap-2">
18
+ <span className="text-xs text-muted-foreground">Seen in the audit, last</span>
19
+ <select className={selectCls} value={days} onChange={(e) => setDays(Number(e.target.value))}>
20
+ {[1, 7, 30, 90].map((d) => <option key={d} value={d}>{d} day{d > 1 ? 's' : ''}</option>)}
21
+ </select>
22
+ <button className={iconBtn} title="Refresh" onClick={load}><RefreshCw className={`w-3.5 h-3.5 ${busy === 'load' ? 'animate-spin' : ''}`} /></button>
23
+ {data?.truncated && <span className="text-[10px] text-amber-500">scan capped — older activity omitted</span>}
24
+ </div>
25
+ <ErrorBox error={error} />
26
+ <div className="border rounded-lg overflow-x-auto">
27
+ <table className="w-full text-xs">
28
+ <thead className="bg-muted/40"><tr>
29
+ <th className={thCls}>Principal</th><th className={thCls}>Kind</th><th className={thCls}>Last role</th>
30
+ <th className={thCls}>Last seen</th><th className={thCls}>Turns</th><th className={thCls} title="Includes shadow (would-deny) verdicts">Denies</th><th className={thCls} />
31
+ </tr></thead>
32
+ <tbody className="divide-y">
33
+ {data?.principals.map((p) => (
34
+ <tr key={p.id}>
35
+ <td className={`${tdCls} font-mono break-all`}>{p.id}</td>
36
+ <td className={tdCls}>{p.kind}</td>
37
+ <td className={tdCls}>{p.lastRole ?? '—'}</td>
38
+ <td className={`${tdCls} whitespace-nowrap`}>{fmtTs(p.lastSeen)}</td>
39
+ <td className={tdCls}>{p.turns}</td>
40
+ <td className={`${tdCls} ${p.denies ? 'text-destructive' : ''}`}>{p.denies}</td>
41
+ <td className={`${tdCls} whitespace-nowrap text-right`}>
42
+ {actions.canRevoke(p.id) && (
43
+ <button className={`${iconBtn} mr-2`} title="Revoke tokens" disabled={!!busy} onClick={() => run(`rv-${p.id}`, () => actions.revoke(p.id))}><KeyRound className="w-3.5 h-3.5" /></button>
44
+ )}
45
+ {actions.canBlock(p.id) &&<button className={`${iconBtn} hover:text-destructive`} title="Block" disabled={!!busy} onClick={() => run(`bl-${p.id}`, () => actions.block(p.id))}><Ban className="w-3.5 h-3.5" /></button>}
46
+ </td>
47
+ </tr>
48
+ ))}
49
+ {data && !data.principals.length && <tr><td colSpan={7} className="text-center text-muted-foreground py-6">No principals in this window.</td></tr>}
50
+ </tbody>
51
+ </table>
52
+ </div>
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,132 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Check, Loader2, Plus, Trash2, Undo2 } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { CsvInput, ErrorBox, iconBtn, inputCls, selectCls, useAction, type Policy, type PolicyDoc, type Profile, type SavePolicy } from './shared';
6
+
7
+ /** The part of the policy a PUT writes (blocklist/tokensValidAfter have their own routes; the server keeps them). */
8
+ const editable = (p: Policy) => { const { blocklist: _b, tokensValidAfter: _t, ...rest } = p; return JSON.stringify(rest); };
9
+
10
+ /** A whole-document policy draft: edit locally, Save = PUT with the version the draft started from (server validates;
11
+ * errors stay on screen). A refresh never wipes edits: a clean draft follows it; a dirty one adopts the new version only
12
+ * when the editable part is unchanged (a block/revoke meanwhile) — otherwise Save still 409s instead of overwriting. */
13
+ export function usePolicyDraft(doc: PolicyDoc, save: SavePolicy, onDirty?: (dirty: boolean) => void) {
14
+ const [base, setBase] = useState(doc);
15
+ const [draft, setDraft] = useState<Policy>(() => structuredClone(doc.policy));
16
+ const dirty = editable(draft) !== editable(base.policy);
17
+ useEffect(() => {
18
+ if (!dirty || editable(doc.policy) === editable(draft)) { setBase(doc); setDraft(structuredClone(doc.policy)); }
19
+ else if (editable(doc.policy) === editable(base.policy)) setBase(doc);
20
+ }, [doc]); // eslint-disable-line react-hooks/exhaustive-deps
21
+ useEffect(() => { onDirty?.(dirty); }, [dirty, onDirty]);
22
+ useEffect(() => () => onDirty?.(false), [onDirty]);
23
+ const action = useAction();
24
+ const saveBar = (
25
+ <div className="flex items-center gap-2 sticky -bottom-3 z-10 -mx-3 -mb-3 px-3 pt-2 pb-3 bg-background border-t">
26
+ <Button size="sm" className="h-7 text-xs" disabled={!dirty || !!action.busy} onClick={() => action.run('save', () => save(draft, base.version))}>
27
+ {action.busy ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Check className="w-3 h-3 mr-1" />} Save policy
28
+ </Button>
29
+ <Button size="sm" variant="ghost" className="h-7 text-xs" disabled={!dirty} onClick={() => { setBase(doc); setDraft(structuredClone(doc.policy)); }}>
30
+ <Undo2 className="w-3 h-3 mr-1" /> Discard
31
+ </Button>
32
+ {dirty && <span className="text-[10px] text-amber-500">unsaved changes</span>}
33
+ </div>
34
+ );
35
+ return { draft, setDraft, error: action.error, saveBar };
36
+ }
37
+
38
+ export function RolesTab({ doc, save, onDirty }: { doc: PolicyDoc; save: SavePolicy; onDirty: (dirty: boolean) => void }) {
39
+ const { draft, setDraft, error, saveBar } = usePolicyDraft(doc, save, onDirty);
40
+ const [newRole, setNewRole] = useState('');
41
+ const [newProfile, setNewProfile] = useState('');
42
+ const profileNames = Object.keys(draft.profiles);
43
+
44
+ const setRole = (name: string, patch: Partial<Policy['roles'][string]>) =>
45
+ setDraft((d) => ({ ...d, roles: { ...d.roles, [name]: { ...d.roles[name], ...patch } } }));
46
+ const setProfile = (name: string, patch: Partial<Profile>) =>
47
+ setDraft((d) => ({ ...d, profiles: { ...d.profiles, [name]: { ...d.profiles[name], ...patch } } }));
48
+ const without = <T,>(o: Record<string, T>, k: string) => Object.fromEntries(Object.entries(o).filter(([n]) => n !== k));
49
+
50
+ return (
51
+ <div className="space-y-4">
52
+ <ErrorBox error={error} />
53
+ <section className="space-y-2">
54
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Roles</h3>
55
+ <p className="text-[11px] text-muted-foreground">Owners come only from the OWNERS env; this row only picks their rank and profile.</p>
56
+ <div className="border rounded-lg divide-y">
57
+ {Object.entries(draft.roles).sort((a, b) => b[1].rank - a[1].rank).map(([name, r]) => (
58
+ <div key={name} className="flex items-center gap-2 p-2">
59
+ <span className="text-sm font-medium w-28 truncate">{name}</span>
60
+ <label className="text-[10px] text-muted-foreground">rank</label>
61
+ <Input type="number" className={`${inputCls} w-20`} value={Number.isFinite(r.rank) ? r.rank : ''} onChange={(e) => setRole(name, { rank: e.target.value === '' ? NaN : Number(e.target.value) })} />
62
+ <label className="text-[10px] text-muted-foreground">profile</label>
63
+ <select className={selectCls} value={r.profile} onChange={(e) => setRole(name, { profile: e.target.value })}>
64
+ {!profileNames.includes(r.profile) && <option value={r.profile}>{r.profile} (missing)</option>}
65
+ {profileNames.map((p) => <option key={p} value={p}>{p}</option>)}
66
+ </select>
67
+ <div className="flex-1" />
68
+ {name !== 'owner' && (
69
+ <button className={`${iconBtn} hover:text-destructive`} title="Remove role" onClick={() => setDraft((d) => ({ ...d, roles: without(d.roles, name) }))}>
70
+ <Trash2 className="w-3.5 h-3.5" />
71
+ </button>
72
+ )}
73
+ </div>
74
+ ))}
75
+ </div>
76
+ <div className="flex items-center gap-2">
77
+ <Input className={`${inputCls} w-40`} placeholder="new role name" value={newRole} onChange={(e) => setNewRole(e.target.value.trim())} />
78
+ <Button size="sm" variant="outline" className="h-7 text-xs" disabled={!newRole || newRole in draft.roles}
79
+ onClick={() => { setDraft((d) => ({ ...d, roles: { ...d.roles, [newRole]: { rank: 10, profile: profileNames.includes('none') ? 'none' : profileNames[0] } } })); setNewRole(''); }}>
80
+ <Plus className="w-3 h-3 mr-1" /> Add role
81
+ </Button>
82
+ <div className="flex-1" />
83
+ <label className="text-[10px] text-muted-foreground">default role</label>
84
+ <select className={selectCls} value={draft.default} onChange={(e) => setDraft((d) => ({ ...d, default: e.target.value }))}>
85
+ {Object.keys(draft.roles).filter((n) => n !== 'owner').map((n) => <option key={n} value={n}>{n}</option>)}
86
+ </select>
87
+ </div>
88
+ </section>
89
+
90
+ <section className="space-y-2">
91
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Profiles</h3>
92
+ {Object.entries(draft.profiles).map(([name, p]) => (
93
+ <div key={name} className="border rounded-lg p-3 space-y-2">
94
+ <div className="flex items-center gap-2">
95
+ <span className="text-sm font-medium">{name}</span>
96
+ <div className="flex-1" />
97
+ <button className={`${iconBtn} hover:text-destructive`} title="Remove profile" onClick={() => setDraft((d) => ({ ...d, profiles: without(d.profiles, name) }))}>
98
+ <Trash2 className="w-3.5 h-3.5" />
99
+ </button>
100
+ </div>
101
+ <div className="grid grid-cols-[4.5rem_1fr] gap-x-2 gap-y-1.5 items-center">
102
+ {(['tools', 'mcps', 'env'] as const).map((f) => (
103
+ <FieldRow key={f} label={f}><CsvInput value={p[f]} onChange={(v) => setProfile(name, { [f]: v })} placeholder='comma-separated, "*" = all' /></FieldRow>
104
+ ))}
105
+ <FieldRow label="rate"><Input className={`${inputCls} w-28`} value={p.rate} placeholder="120/h" onChange={(e) => setProfile(name, { rate: e.target.value.trim() })} /></FieldRow>
106
+ <FieldRow label="readScope">
107
+ <select className={`${selectCls} w-28`} value={p.readScope} onChange={(e) => setProfile(name, { readScope: e.target.value as Profile['readScope'] })}>
108
+ {['all', 'own', 'none'].map((s) => <option key={s} value={s}>{s}</option>)}
109
+ </select>
110
+ </FieldRow>
111
+ <FieldRow label="outbound">
112
+ <input type="checkbox" className="h-4 w-4 justify-self-start" checked={p.outbound} onChange={(e) => setProfile(name, { outbound: e.target.checked })} />
113
+ </FieldRow>
114
+ </div>
115
+ </div>
116
+ ))}
117
+ <div className="flex items-center gap-2">
118
+ <Input className={`${inputCls} w-40`} placeholder="new profile name" value={newProfile} onChange={(e) => setNewProfile(e.target.value.trim())} />
119
+ <Button size="sm" variant="outline" className="h-7 text-xs" disabled={!newProfile || newProfile in draft.profiles}
120
+ onClick={() => { setDraft((d) => ({ ...d, profiles: { ...d.profiles, [newProfile]: { tools: [], mcps: [], env: [], outbound: false, readScope: 'none', rate: '0' } } })); setNewProfile(''); }}>
121
+ <Plus className="w-3 h-3 mr-1" /> Add profile
122
+ </Button>
123
+ </div>
124
+ </section>
125
+ {saveBar}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
131
+ return (<><label className="text-[11px] text-muted-foreground">{label}</label>{children}</>);
132
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Input } from '@/components/ui/input';
3
+ import type { OwnerCall } from '@/hooks/useOwner';
4
+
5
+ export type { OwnerCall };
6
+ export interface Profile { tools: string[]; mcps: string[]; env: string[]; outbound: boolean; readScope: 'all' | 'own' | 'none'; rate: string }
7
+ export interface Match { kind?: string; id?: string; emailIn?: string[]; domain?: string; verified?: boolean; [k: string]: unknown }
8
+ export interface Policy {
9
+ roles: Record<string, { rank: number; profile: string }>;
10
+ profiles: Record<string, Profile>;
11
+ bindings: { match: Match; role: string }[];
12
+ default: string;
13
+ blocklist: { match: Match; until: number | null; reason?: string }[];
14
+ tokensValidAfter: Record<string, number>;
15
+ }
16
+ export interface PolicyDoc { policy: Policy; version: string; valid: boolean; ownerIds?: string[] }
17
+ /** PUT the whole policy, guarded by the version the edit started from. */
18
+ export type SavePolicy = (policy: Policy, version: string) => Promise<void>;
19
+
20
+ export const KINDS = ['user', 'email', 'slack', 'apikey', 'internal', 'anonymous'];
21
+ export const selectCls = 'h-7 rounded-md border border-input bg-background px-2 text-xs';
22
+ export const inputCls = 'h-7 text-xs';
23
+ export const thCls = 'text-left font-medium text-muted-foreground px-2 py-1 whitespace-nowrap';
24
+ export const tdCls = 'px-2 py-1 align-top';
25
+ export const iconBtn = 'text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors';
26
+
27
+ export const fmtTs = (v: string | number) => new Date(v).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
28
+ export const matchLabel = (m: Match) => Object.entries(m).map(([k, v]) => `${k}=${Array.isArray(v) ? v.join('|') : String(v)}`).join(' ');
29
+
30
+ /** One action at a time, with its error kept visible (useOwner's call already logged it). */
31
+ export function useAction() {
32
+ const [busy, setBusy] = useState<string | null>(null);
33
+ const [error, setError] = useState<string | null>(null);
34
+ const run = async (key: string, fn: () => Promise<void>) => {
35
+ if (busy) return;
36
+ setBusy(key);
37
+ setError(null);
38
+ try { await fn(); } catch (e: any) { setError(e?.message ?? String(e)); } finally { setBusy(null); }
39
+ };
40
+ return { busy, error, run };
41
+ }
42
+
43
+ /** Server error; a policy validation error ("Invalid policy: a; b") renders as a list. */
44
+ export function ErrorBox({ error }: { error: string | null }) {
45
+ if (!error) return null;
46
+ const m = /^Invalid policy: (.*)$/s.exec(error);
47
+ return (
48
+ <div className="text-xs text-destructive border border-destructive/40 rounded-md p-2">
49
+ {m ? (<><p className="font-medium">Invalid policy — not saved:</p><ul className="list-disc pl-4">{m[1].split('; ').map((e, i) => <li key={i}>{e}</li>)}</ul></>) : error}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ /** Comma-separated list input; commits on blur so typing "a, " isn't normalized away mid-edit. */
55
+ export function CsvInput({ value, onChange, placeholder }: { value: string[] | undefined; onChange: (v: string[]) => void; placeholder?: string }) {
56
+ const joined = (value ?? []).join(', ');
57
+ const [text, setText] = useState(joined);
58
+ useEffect(() => setText(joined), [joined]);
59
+ return (
60
+ <Input className={inputCls} value={text} placeholder={placeholder} onChange={(e) => setText(e.target.value)}
61
+ onBlur={() => onChange(text.split(',').map((s) => s.trim()).filter(Boolean))} />
62
+ );
63
+ }
64
+
65
+ /** Row actions shared by Principals and Audit. Only `user:`/`internal:` principals carry revocable tokens. Both actions
66
+ * change the policy document, so `onPolicyChange` refreshes its version (else the next Save would 409). */
67
+ export function principalActions(call: OwnerCall, onPolicyChange: () => Promise<void>, ownerIds: string[] = []) {
68
+ return {
69
+ canRevoke: (id: string) => /^(user|internal):/.test(id) && id !== 'internal:agent-internal',
70
+ /** UI hint only (ownerIds from GET /policy) — the server refuses a block that matches an owner. */
71
+ canBlock: (id: string) => !ownerIds.includes(id),
72
+ revoke: async (id: string) => {
73
+ if (!confirm(`Revoke every token issued to ${id}? They must sign in again.`)) return;
74
+ await call('/tokens/revoke', 'POST', { principalId: id });
75
+ await onPolicyChange();
76
+ },
77
+ block: async (id: string) => {
78
+ const reason = prompt(`Block ${id}? Reason (optional):`);
79
+ if (reason === null) return;
80
+ await call('/blocks', 'POST', { match: { id }, until: null, reason });
81
+ await onPolicyChange();
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,20 @@
1
+ import { useCallback } from 'react';
2
+ import { api } from '@/lib/api';
3
+ import { useIsOwner } from './useIsOwner';
4
+
5
+ /** `/api/owner/*` caller: throws the server's `error` (validation text included), logged before it reaches the UI. */
6
+ export type OwnerCall = <T = any>(path: string, method?: string, body?: unknown) => Promise<T>;
7
+
8
+ /** Owner Console access: `isOwner` (UI hint — the server enforces) + an authenticated caller for `/api/owner/*`. */
9
+ export function useOwner(getToken: () => Promise<string | null>) {
10
+ const isOwner = useIsOwner(getToken);
11
+ const call = useCallback<OwnerCall>(async (path, method = 'GET', body) => {
12
+ try {
13
+ return await api(`/api/owner${path}`, getToken, { method, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
14
+ } catch (e: any) {
15
+ console.warn(`[owner] ${method} ${path} failed: ${e?.message ?? e}`);
16
+ throw e;
17
+ }
18
+ }, [getToken]);
19
+ return { isOwner, call };
20
+ }
@@ -1,6 +1,6 @@
1
1
  import { reportApiFailure, reportApiResponse } from '@/lib/backendHealth';
2
2
 
3
- /** Minimal authenticated JSON fetch helper. Throws `Error("<status> <statusText>")` or the server's `error` field. */
3
+ /** Minimal authenticated JSON fetch helper. Throws `Error("<status> <statusText>")` or the server's `error` field, with `status` set. */
4
4
  export async function api<T>(
5
5
  path: string,
6
6
  getToken: () => Promise<string | null>,
@@ -23,7 +23,7 @@ export async function api<T>(
23
23
  reportApiResponse(path, res, init?.expect);
24
24
  if (!res.ok) {
25
25
  const body = await res.json().catch(() => ({}));
26
- throw new Error(body.error || `${res.status} ${res.statusText}`);
26
+ throw Object.assign(new Error(body.error || `${res.status} ${res.statusText}`), { status: res.status });
27
27
  }
28
28
  return res.json();
29
29
  }
package/src/index.ts CHANGED
@@ -25,8 +25,10 @@ import type { AgentEngine, EngineModel, EngineStreamOpts } from './server/engine
25
25
  import type { ExtRegisterFn, ExtensionContext } from './server/extensions.ts';
26
26
  import type { WebhookOptions } from './server/events/webhook.ts';
27
27
  import type { ShragaEvent, ShragaEventMap, PayloadOf } from './server/events/types.ts';
28
+ import type { PolicyOptions, PolicyFile } from './server/security/policy.ts';
28
29
 
29
30
  export type {
31
+ PolicyFile,
30
32
  ServerHandle,
31
33
  ServerFeature,
32
34
  FeatureContext,
@@ -67,6 +69,12 @@ export class ShragaOptions {
67
69
  runtimeRegistration?: boolean = false;
68
70
  /** Arbitrary extra environment to apply before boot (e.g. ANTHROPIC_API_KEY, SHRAGA_FEAT_*). */
69
71
  env?: Record<string, string>;
72
+ /** Security policy seams. `migrate` seeds `data/security/policy.json` ONCE, when it's first generated (no file, no
73
+ * `.migrated` marker): it receives the default draft (legacy whitelist already bound as operator) and returns the
74
+ * final draft. The result is validated and saved like any policy; an invalid draft or a throw fails closed (owners
75
+ * only). Never runs on a PASSIVE standby (it runs on promotion if the file is still missing). Later edits go through
76
+ * the Owner Console, not this hook. */
77
+ security?: { migrate?: PolicyOptions['migrate'] };
70
78
  }
71
79
 
72
80
  export interface ShragaInstance {
@@ -94,7 +102,7 @@ export interface ShragaInstance {
94
102
 
95
103
  class Shraga implements ShragaInstance {
96
104
  public options: ShragaOptions;
97
- private reg: Required<BootRegistrations> = { features: [], engines: [], extensions: [], eventSubs: [] };
105
+ private reg: Required<Omit<BootRegistrations, 'security'>> ={ features: [], engines: [], extensions: [], eventSubs: [] };
98
106
  private handle: ServerHandle | null = null;
99
107
  private starting: Promise<ServerHandle> | null = null;
100
108
 
@@ -150,7 +158,7 @@ class Shraga implements ShragaInstance {
150
158
  if (this.starting) return this.starting;
151
159
  this.starting = (async () => {
152
160
  const { bootServer } = await import('./server/boot.ts');
153
- this.handle = await bootServer(this.reg);
161
+ this.handle = await bootServer({ ...this.reg, security: this.options.security });
154
162
  return this.handle;
155
163
  })();
156
164
  return this.starting;
@@ -0,0 +1,34 @@
1
+ // Agent config READ path (shared across users), as its own leaf module.
2
+ //
3
+ // Why it isn't in claude.ts: the security runtime reads a flag from it (`allowUntrustedReplies`, see
4
+ // `mayReplyTo`), and claude.ts imports the runtime — plus the whole engine/skills/sessions graph. A leaf
5
+ // that only touches fs + paths keeps that dependency one-way and cheap. The WRITE path stays in claude.ts
6
+ // (it also has to tell data-sync, which pulls the Agent SDK).
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { dataPath } from './paths.ts';
9
+ import type { AgentSettings as AgentConfig } from './shraga-config.ts';
10
+
11
+ export const CONFIG_PATH = dataPath('agent-config.json');
12
+
13
+ export const DEFAULT_CONFIG: AgentConfig = {
14
+ /** ToolSearch loads deferred MCP tools; without it, permission prompts / tool graph can block Meta Ads tools. */
15
+ allowedTools: ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'],
16
+ permissionMode: 'acceptEdits',
17
+ maxTurns: 15,
18
+ // Defaults are what a fresh self-hosted install runs before anyone touches the UI, so they favour
19
+ // cost/latency over ceiling. Both are overridable per-deployment via agent-config.json and per-send
20
+ // via directives — an operator who wants a bigger model sets it once; every operator pays for a default.
21
+ model: 'claude-sonnet-5',
22
+ effort: 'low',
23
+ // `allowUntrustedReplies` is deliberately absent: undefined = off, so a fresh install never auto-replies
24
+ // to an unverified sender, and an owner has to turn it on explicitly.
25
+ };
26
+
27
+ export function getAgentConfig(): AgentConfig {
28
+ // agent-config.json (UI-writable, git-tracked) is the single source of truth for agent settings.
29
+ let config = { ...DEFAULT_CONFIG };
30
+ if (existsSync(CONFIG_PATH)) {
31
+ try { Object.assign(config, JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))); } catch (e) { console.warn('[claude] failed to parse agent-config.json:', e); }
32
+ }
33
+ return config;
34
+ }
@@ -9,6 +9,8 @@ import { security } from './security/runtime.ts';
9
9
  export class ApiKeyRoutesOptions {
10
10
  base: string = '/api/api-keys';
11
11
  gate: RequestHandler[] = [];
12
+ /** Extra gates for create/delete (after `gate`). */
13
+ writeGate: RequestHandler[] = [];
12
14
  /** Owner console: sees/deletes every key and may set `role`/`expiresAt`. Self: own keys (all when the caller is an
13
15
  * owner, as before) and label only. */
14
16
  asOwner: boolean = false;
@@ -23,7 +25,7 @@ function fail(res: Response, e: any): void {
23
25
  }
24
26
 
25
27
  export function apiKeyRouter(options?: Partial<ApiKeyRoutesOptions>): Router {
26
- const { base, gate, asOwner } = { ...new ApiKeyRoutesOptions(), ...options };
28
+ const { base, gate, writeGate, asOwner } = { ...new ApiKeyRoutesOptions(), ...options };
27
29
  const router = Router();
28
30
  const isOwner = (u: AuthUser) => asOwner || u.isOwner;
29
31
 
@@ -33,7 +35,7 @@ export function apiKeyRouter(options?: Partial<ApiKeyRoutesOptions>): Router {
33
35
  });
34
36
 
35
37
  /** Body: { label?, role?, expiresAt? (epoch ms) } — role/expiresAt honored on the owner mount only. Plaintext returned once. */
36
- router.post(base, ...gate, (req, res) => {
38
+ router.post(base, ...gate, ...writeGate, (req, res) => {
37
39
  const user = userOf(req);
38
40
  // Minting a key is minting a credential AS this identity — only an interactive login may (same rule as OAuth
39
41
  // consent). Otherwise a role-capped or expiring key could mint itself an uncapped, non-expiring one.
@@ -50,7 +52,7 @@ export function apiKeyRouter(options?: Partial<ApiKeyRoutesOptions>): Router {
50
52
  } catch (e: any) { fail(res, e); }
51
53
  });
52
54
 
53
- router.delete(`${base}/:id`, ...gate, (req: Request<{ id: string }>, res) => {
55
+ router.delete(`${base}/:id`, ...gate, ...writeGate, (req: Request<{ id: string }>, res) => {
54
56
  const user = userOf(req);
55
57
  try {
56
58
  const r = apiKeyStore().delete(req.params.id, user.uid, isOwner(user), user.principal.id);
@@ -53,6 +53,7 @@ import { dataSync } from './data-sync.ts';
53
53
  import { mountMcpServer } from './mcp-server.ts';
54
54
  import { fromInternal, type Principal } from './security/principal.ts';
55
55
  import { initSecurity, security, admitTurn, requestIp } from './security/runtime.ts';
56
+ import type { PolicyOptions } from './security/policy.ts';
56
57
  import { writeDenial } from './security/guard.ts';
57
58
  import { requireOwner } from './security/owner-only.ts';
58
59
  import { flushEscalations } from './security/escalate.ts';
@@ -89,6 +90,8 @@ export interface BootRegistrations {
89
90
  engines?: AgentEngine[];
90
91
  extensions?: ExtRegisterFn[];
91
92
  eventSubs?: Array<{ source: string; handler: (payload: any, evt: any) => void }>;
93
+ /** ShragaOptions.security — threaded into the process-wide Policy. */
94
+ security?: { migrate?: PolicyOptions['migrate'] };
92
95
  }
93
96
 
94
97
  export interface ServerHandle {
@@ -135,7 +138,7 @@ function bootDataSync(): void {
135
138
  await loadShragaConfig();
136
139
  // Process-wide policy + audit (shadow mode: decide + audit, never deny). Audit writes only on the
137
140
  // active instance — a PASSIVE standby shares DATA_DIR. `activated` is declared above.
138
- initSecurity({ isActive: () => !PASSIVE || activated });
141
+ initSecurity({ isActive: () => !PASSIVE || activated, policy: { migrate: __reg.security?.migrate } });
139
142
  // Programmatic engines register through the same seam an overlay uses — BEFORE initEngines() so
140
143
  // getAvailableEngines() includes them and a directive can resolve to one immediately.
141
144
  for (const e of __reg.engines ?? []) registerEngine(e);
@@ -30,33 +30,14 @@ import type { Principal } from './security/principal.ts';
30
30
  import { security, resolvePrincipal } from './security/runtime.ts';
31
31
  import { resolveAndGetEngine, ModelUnavailableError } from './engine/index.ts';
32
32
 
33
- const CONFIG_PATH = dataPath('agent-config.json');
34
-
35
33
  // ── Agent config (shared across users) ──────────────────────────────────────
34
+ // The READ path lives in agent-config.ts (a leaf the security runtime can import without pulling this
35
+ // module's graph); re-exported here so existing importers keep using claude.ts.
36
36
 
37
37
  export type { AgentSettings as AgentConfig } from './shraga-config.ts';
38
38
  import type { AgentSettings as AgentConfig } from './shraga-config.ts';
39
-
40
- const DEFAULT_CONFIG: AgentConfig = {
41
- /** ToolSearch loads deferred MCP tools; without it, permission prompts / tool graph can block Meta Ads tools. */
42
- allowedTools: ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'],
43
- permissionMode: 'acceptEdits',
44
- maxTurns: 15,
45
- // Defaults are what a fresh self-hosted install runs before anyone touches the UI, so they favour
46
- // cost/latency over ceiling. Both are overridable per-deployment via agent-config.json and per-send
47
- // via directives — an operator who wants a bigger model sets it once; every operator pays for a default.
48
- model: 'claude-sonnet-5',
49
- effort: 'low',
50
- };
51
-
52
- export function getAgentConfig(): AgentConfig {
53
- // agent-config.json (UI-writable, git-tracked) is the single source of truth for agent settings.
54
- let config = { ...DEFAULT_CONFIG };
55
- if (existsSync(CONFIG_PATH)) {
56
- try { Object.assign(config, JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))); } catch (e) { console.warn('[claude] failed to parse agent-config.json:', e); }
57
- }
58
- return config;
59
- }
39
+ import { CONFIG_PATH, getAgentConfig } from './agent-config.ts';
40
+ export { getAgentConfig };
60
41
 
61
42
  /**
62
43
  * Which credentials the claude-code SDK will resolve — process-global, derived from env at call time
@@ -811,6 +811,7 @@ export class DataSync {
811
811
  // blocks.json (guard auto-blocks); policy.json + .migrated (the Owner Console writes them; a pulled change trips
812
812
  // the policy provenance/tamper check). Blue-green shares one DATA_DIR on one host, so nothing needs to cross hosts.
813
813
  'security/',
814
+ 'quarantine/', // untrusted inbound content held for operator review — never synced
814
815
  ];
815
816
 
816
817
  /** Write or refresh .gitignore, appending any canonical entries it's missing (idempotent). */
@@ -227,6 +227,10 @@ async function* buildAttachmentPrompt(text: string, attachments: { path: string;
227
227
  yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
228
228
  }
229
229
 
230
+ async function* buildTextPrompt(text: string, sessionId: string): AsyncIterable<any> {
231
+ yield { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: sessionId };
232
+ }
233
+
230
234
  async function* buildLegacyImagePrompt(text: string, images: string[], sessionId: string): AsyncIterable<any> {
231
235
  const content: any[] = images.map((dataUrl) => {
232
236
  const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
@@ -524,11 +528,22 @@ export class ClaudeCodeEngine implements AgentEngine {
524
528
  const sessionKey = opts.sessionId ?? crypto.randomUUID();
525
529
  const hasAttachments = opts.attachments && opts.attachments.length > 0;
526
530
  const hasLegacyImages = opts.images && opts.images.length > 0;
527
- const prompt = hasAttachments
531
+ const source = hasAttachments
528
532
  ? buildAttachmentPrompt(fullPrompt, opts.attachments!, sessionKey)
529
533
  : hasLegacyImages
530
534
  ? buildLegacyImagePrompt(fullPrompt, opts.images!, sessionKey)
531
- : fullPrompt;
535
+ : buildTextPrompt(fullPrompt, sessionKey);
536
+ // Streaming input, held open until the turn is over. A string prompt runs the CLI one-shot: it exits after
537
+ // the first result and kills `run_in_background` jobs ~5s later. Open, the job keeps running and its
538
+ // task_notification gets the model a follow-up turn, as interactive Claude Code does. The uuid is echoed on
539
+ // the result that answers THIS prompt (`user_message_uuids`), telling it apart from a resumed session's stale one.
540
+ const promptUuid = crypto.randomUUID();
541
+ let closeInput!: () => void;
542
+ const inputClosed = new Promise<void>((r) => { closeInput = r; });
543
+ const prompt = (async function* () {
544
+ for await (const msg of source) yield { ...msg, uuid: promptUuid };
545
+ await inputClosed;
546
+ })();
532
547
 
533
548
  const q = query({ prompt, options: options as any });
534
549
  // The CLI writes this prompt to the transcript before any output: from here a cancel can't un-deliver it.
@@ -546,6 +561,9 @@ export class ClaudeCodeEngine implements AgentEngine {
546
561
  const iter = q[Symbol.asyncIterator]();
547
562
  let pendingNext: Promise<IteratorResult<any>> | null = null;
548
563
  let waitingForBg = false;
564
+ // Has the result answering THIS prompt arrived? Later results are the model's follow-ups on bg-task completions.
565
+ let answered = false;
566
+ let turnBreak = false;
549
567
  let bgTimer: Promise<'__bgtimeout'> | null = null;
550
568
  let bgTimerHandle: ReturnType<typeof setTimeout> | null = null;
551
569
  let bgHeartbeat: ReturnType<typeof setInterval> | null = null;
@@ -622,7 +640,8 @@ export class ClaudeCodeEngine implements AgentEngine {
622
640
  if (m.type === 'system' && m.subtype === 'task_notification') {
623
641
  outstandingTasks.delete(m.task_id);
624
642
  console.log(`[claude] Background task ${m.status}: ${m.task_id} (${outstandingTasks.size} pending) (${elapsed()})`);
625
- if (outstandingTasks.size === 0) { waitingForBg = false; clearBgTimer(); }
643
+ // Not the end of the turn: the CLI now runs a follow-up turn on the result, whose own result ends it
644
+ // (still bounded by the bg timer).
626
645
  continue;
627
646
  }
628
647
 
@@ -632,6 +651,15 @@ export class ClaudeCodeEngine implements AgentEngine {
632
651
  if (m.type === 'rate_limit_event') { claudeUsageFor(accountDir).observeRateLimit(m.rate_limit_info); continue; }
633
652
 
634
653
  if (m.type === 'result') {
654
+ // A resumed session first replays a pending task_notification as its own zero-turn result, before
655
+ // our prompt runs — ending on it drops the user's message. Skip any result that is not ours until
656
+ // ours arrives (older CLIs don't echo the uuid: then only a zero-turn result counts as stale).
657
+ const echoed = m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : undefined);
658
+ if (!answered && !m.is_error && (echoed ? !echoed.includes(promptUuid) : !m.num_turns)) {
659
+ console.log(`[claude] Skipping result of an earlier turn: turns=${m.num_turns ?? 0} result_index=${m.result_index ?? '?'} (${elapsed()})`);
660
+ continue;
661
+ }
662
+ answered = true;
635
663
  lastSessionId = m.session_id || lastSessionId;
636
664
  const sdkTurns = m.num_turns ?? 0;
637
665
  const raw = m.subtype ?? 'unknown';
@@ -647,6 +675,7 @@ export class ClaudeCodeEngine implements AgentEngine {
647
675
  bgHeartbeat = setInterval(() => console.log(`[claude] Still holding for ${outstandingTasks.size} bg task(s): ${[...outstandingTasks].join(',')} (${elapsed()})`), BG_HEARTBEAT_MS);
648
676
  bgHeartbeat.unref?.();
649
677
  }
678
+ turnBreak = textDeltaCount > 0;
650
679
  continue;
651
680
  }
652
681
  // The SDK reports `subtype: 'success'` even when the API call failed (e.g. an org spend
@@ -687,6 +716,7 @@ export class ClaudeCodeEngine implements AgentEngine {
687
716
  if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
688
717
  textDeltaCount++;
689
718
  sawOutput = true;
719
+ if (turnBreak) { turnBreak = false; yield { type: 'text_delta', text: '\n\n' }; }
690
720
  yield { type: 'text_delta', text: event.delta.text };
691
721
  }
692
722
  if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
@@ -802,6 +832,7 @@ export class ClaudeCodeEngine implements AgentEngine {
802
832
  return;
803
833
  } finally {
804
834
  clearBgTimer();
835
+ closeInput();
805
836
  // The CLI has read the file by now (it loads MCP config at startup); holding it any longer just
806
837
  // widens the window in which the credentials sit on disk.
807
838
  mcpConfigFile?.cleanup();
@@ -422,15 +422,21 @@ export class Audit {
422
422
  }
423
423
 
424
424
  /**
425
- * Newest first, streaming backwards over ALL month files (no month-name pruning: a skewed clock can put any ts in any
426
- * file); each record is filtered by its own ts. Cursor is opaque (file + byte offset).
427
- * Throws (after logging) if the dir or a month file can't be read — never a silently short page. Planted
428
- * (non-regular) entries are skipped, not thrown: they're alerted and verify() reports them, and throwing would let
429
- * one planted name (undeletable under +a) blind the audit viewer.
425
+ * Newest first, streaming backwards over the month files; each record is filtered by its own ts. Two skip rules:
426
+ * - Month-name floor: with `from`, regular month files named older than the month before `from`'s month are skipped
427
+ * whole (never opened). Every record in every remaining regular file (any month from that floor on, including
428
+ * future-named ones) is checked, so a back-skewed record never hides earlier in-window ones; a record whose ts
429
+ * was written into a file below the floor (clock skewed back by more than a month) is not found.
430
+ * - Planted (non-regular) month entries are skipped, not thrown: they're alerted and verify() reports them, and
431
+ * throwing would let one planted name (undeletable under +a) blind the audit viewer.
432
+ * Throws (after logging) if the dir or a regular month file can't be read — never a silently short page.
433
+ * Cursor is opaque (file + byte offset).
430
434
  */
431
435
  public query(q: AuditQuery): AuditPage {
432
436
  const limit = Math.max(1, Math.min(MAX_QUERY, Math.floor(q.limit) || 1));
433
437
  const from = q.from !== undefined ? toIso(q.from) : undefined, to = q.to !== undefined ? toIso(q.to) : undefined;
438
+ const fd = from ? new Date(from) : undefined; // skew slack: nothing older than the start of the month before `from`'s
439
+ const floor = fd ? new Date(Date.UTC(fd.getUTCFullYear(), fd.getUTCMonth() - 1, 1)).toISOString() : undefined;
434
440
  const types = q.type === undefined ? undefined : new Set<string>([q.type].flat());
435
441
  let cur: { file: string; offset: number } | undefined;
436
442
  if (q.cursor) {
@@ -442,6 +448,7 @@ export class Audit {
442
448
  let last: { file: string; start: number } | undefined;
443
449
  for (const f of this.files().reverse()) {
444
450
  if (cur && f > cur.file) continue;
451
+ if (floor && f.slice(0, 7) < floor.slice(0, 7)) break; // sorted newest-first: every remaining file is older
445
452
  try {
446
453
  for (const { line, start } of reverseLines(path.join(this.options.dir, f), cur?.file === f ? cur.offset : undefined)) {
447
454
  const r = parse(line);