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.
- package/README.md +8 -1
- package/defaults/scripts/agent-once.ts +2 -0
- package/defaults/security/policy.example.json +27 -0
- package/defaults/skills/mcp-server.md +1 -7
- package/defaults/skills/platform.md +2 -2
- package/defaults/system-prompt.md +1 -1
- package/dist/client/assets/index-CFxKc8dH.css +10 -0
- package/dist/client/assets/index-CeGkHIed.js +2009 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/client/components/ConfigPanel.tsx +22 -0
- package/src/client/components/Sidebar.tsx +16 -1
- package/src/client/components/owner/ApiKeysTab.tsx +87 -0
- package/src/client/components/owner/AuditTab.tsx +110 -0
- package/src/client/components/owner/BindingsTab.tsx +110 -0
- package/src/client/components/owner/BlocklistTab.tsx +96 -0
- package/src/client/components/owner/OwnerConsole.tsx +87 -0
- package/src/client/components/owner/PrincipalsTab.tsx +55 -0
- package/src/client/components/owner/RolesTab.tsx +132 -0
- package/src/client/components/owner/shared.tsx +84 -0
- package/src/client/hooks/useOwner.ts +20 -0
- package/src/client/lib/api.ts +2 -2
- package/src/index.ts +10 -2
- package/src/server/agent-config.ts +34 -0
- package/src/server/api-key-routes.ts +5 -3
- package/src/server/boot.ts +4 -1
- package/src/server/claude.ts +4 -23
- package/src/server/data-sync.ts +1 -0
- package/src/server/engine/claude-code.ts +34 -3
- package/src/server/security/audit.ts +12 -5
- package/src/server/security/enforce.ts +43 -6
- package/src/server/security/guard.ts +8 -0
- package/src/server/security/owner-only.ts +21 -2
- package/src/server/security/owner-routes.ts +246 -4
- package/src/server/security/policy.ts +4 -1
- package/src/server/security/runtime.ts +53 -1
- package/src/server/sessions.ts +3 -0
- package/src/server/shraga-config.ts +13 -0
- package/src/server/workspace.ts +9 -0
- package/dist/client/assets/index-DIDtPQb-.css +0 -10
- 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
|
+
}
|
package/src/client/lib/api.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/server/boot.ts
CHANGED
|
@@ -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);
|
package/src/server/claude.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/server/data-sync.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
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);
|