shraga 0.1.112 → 0.1.114

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 (73) hide show
  1. package/README.md +10 -1
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/scripts/agent-once.ts +2 -0
  4. package/defaults/security/policy.example.json +27 -0
  5. package/defaults/skills/mcp-server.md +15 -14
  6. package/defaults/skills/platform.md +3 -3
  7. package/defaults/system-prompt.md +1 -1
  8. package/dist/client/assets/index-CFxKc8dH.css +10 -0
  9. package/dist/client/assets/index-DwmlZAEF.js +2009 -0
  10. package/dist/client/index.html +2 -2
  11. package/package.json +3 -2
  12. package/src/client/App.tsx +33 -8
  13. package/src/client/components/BackendStatusBanner.tsx +62 -0
  14. package/src/client/components/ConfigPanel.tsx +83 -15
  15. package/src/client/components/ConversationHeader.tsx +5 -1
  16. package/src/client/components/McpManager.tsx +26 -9
  17. package/src/client/components/Sidebar.tsx +16 -1
  18. package/src/client/components/SkillsManager.tsx +48 -27
  19. package/src/client/components/owner/ApiKeysTab.tsx +87 -0
  20. package/src/client/components/owner/AuditTab.tsx +110 -0
  21. package/src/client/components/owner/BindingsTab.tsx +110 -0
  22. package/src/client/components/owner/BlocklistTab.tsx +96 -0
  23. package/src/client/components/owner/OwnerConsole.tsx +87 -0
  24. package/src/client/components/owner/PrincipalsTab.tsx +55 -0
  25. package/src/client/components/owner/RolesTab.tsx +132 -0
  26. package/src/client/components/owner/shared.tsx +84 -0
  27. package/src/client/hooks/useAuth.ts +11 -2
  28. package/src/client/hooks/useIsOwner.ts +24 -0
  29. package/src/client/hooks/useModules.ts +5 -1
  30. package/src/client/hooks/useOwner.ts +20 -0
  31. package/src/client/lib/api.ts +23 -7
  32. package/src/client/lib/backendHealth.ts +230 -0
  33. package/src/client/lib/debug.ts +48 -0
  34. package/src/client/lib/sessionApi.ts +24 -8
  35. package/src/client/lib/ws.ts +21 -13
  36. package/src/index.ts +10 -2
  37. package/src/scripts/harden-audit.sh +55 -0
  38. package/src/server/agent-config.ts +34 -0
  39. package/src/server/api-key-routes.ts +66 -0
  40. package/src/server/api-keys.ts +181 -43
  41. package/src/server/auth.ts +113 -47
  42. package/src/server/boot.ts +161 -104
  43. package/src/server/claude.ts +102 -26
  44. package/src/server/data-sync.ts +56 -6
  45. package/src/server/directives.ts +2 -4
  46. package/src/server/engine/claude-code.ts +78 -17
  47. package/src/server/engine/types.ts +7 -0
  48. package/src/server/hooks.ts +19 -0
  49. package/src/server/mcp-oauth.ts +24 -5
  50. package/src/server/mcp-server.ts +55 -25
  51. package/src/server/modules/routes.ts +2 -6
  52. package/src/server/notify-owners.ts +5 -17
  53. package/src/server/owners.ts +14 -0
  54. package/src/server/scheduler/builtins.ts +3 -1
  55. package/src/server/scheduler/runner.ts +3 -0
  56. package/src/server/security/audit.ts +505 -0
  57. package/src/server/security/enforce.ts +343 -0
  58. package/src/server/security/escalate.ts +194 -0
  59. package/src/server/security/guard.ts +337 -0
  60. package/src/server/security/owner-only.ts +34 -0
  61. package/src/server/security/owner-routes.ts +285 -0
  62. package/src/server/security/policy.ts +416 -0
  63. package/src/server/security/principal.ts +80 -0
  64. package/src/server/security/revocation.ts +50 -0
  65. package/src/server/security/runtime.ts +226 -0
  66. package/src/server/sessions.ts +38 -0
  67. package/src/server/shraga-config.ts +13 -0
  68. package/src/server/slack/bot.ts +44 -11
  69. package/src/server/slack/context-cache.ts +40 -7
  70. package/src/server/webhook-lane/feature.ts +17 -6
  71. package/src/shared/models.ts +11 -0
  72. package/dist/client/assets/index-DIMte_k6.css +0 -10
  73. package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
@@ -0,0 +1,337 @@
1
+ // Guard: abuse limits in front of every turn — BEFORE any LLM spend.
2
+ //
3
+ // Order: blocklist (policy manual blocklist + persisted auto-blocks) → rate (principal, ip, channel buckets) → proceed.
4
+ // Plus a global ceiling on concurrent agent turns for principals below `ceilingBelowRank`.
5
+ //
6
+ // SHADOW: unless `enforce()` (SECURITY_ENFORCE=true) every verdict is `{ ok: true, wouldDeny }` — computed and audited,
7
+ // never denied. Auto-blocks are still recorded (TTL-bound) so the Console shows what enforcement would do.
8
+ //
9
+ // Memory is bounded: buckets, hit windows and blocks are LRU maps capped at `maxKeys` — a spray of distinct
10
+ // principals/IPs evicts the least-recently-used entry instead of growing the heap.
11
+ //
12
+ // Persistence: auto-blocks → data/security/blocks.json (atomic tmp+rename). Only an ACTIVE instance writes; a PASSIVE
13
+ // standby keeps blocks in memory and `activate()` re-loads from disk on promotion (same model as runtime/policy).
14
+ import { BlockList, isIP } from 'node:net';
15
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { dataPath } from '../paths.ts';
18
+ import type { AuditEvent } from './audit.ts';
19
+ import type { Principal } from './principal.ts';
20
+ import { enforcing } from './enforce.ts';
21
+
22
+ export interface GuardDenial { ok: false; status: 429 | 403; retryAfter?: number; reason: string }
23
+ export type GuardVerdict = { ok: true; wouldDeny?: GuardDenial } | GuardDenial;
24
+ export interface GuardInput { principal: Principal; ip?: string; channel?: string; rank: number; rate: string }
25
+ export type TurnAdmission = { ok: true; release: () => void; wouldDeny?: GuardDenial } | GuardDenial;
26
+ export interface BlockRecord { until: number; reason: string; at: number }
27
+
28
+ const UNITS: Record<string, number> = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
29
+
30
+ /** `"N/h"`, `"N/m"`, `"N/s"`, `"N/d"`; bare `"N"` = per hour; `"0"` = deny all. null = unparseable. */
31
+ export function parseRate(rate: string): { cap: number; periodMs: number } | null {
32
+ const m = /^(\d+)(?:\/([smhd]))?$/.exec(String(rate ?? '').trim());
33
+ return m ? { cap: Number(m[1]), periodMs: UNITS[m[2] ?? 'h'] } : null;
34
+ }
35
+
36
+ /** `TRUSTED_PROXIES` → BlockList. Comma/space separated IPs or CIDRs; unset/empty = trust none. */
37
+ export function parseTrustedProxies(spec: string | undefined, log: Pick<Console, 'warn'> = console): BlockList {
38
+ const list = new BlockList();
39
+ for (const raw of String(spec ?? '').split(/[\s,]+/).filter(Boolean)) {
40
+ const [addr, bits] = raw.split('/');
41
+ const ip = normalizeIp(addr), type = ip && isIP(ip) === 6 ? 'ipv6' : 'ipv4';
42
+ if (!ip) { log.warn(`[guard] TRUSTED_PROXIES: ignoring invalid entry "${raw}"`); continue; }
43
+ if (bits === undefined) list.addAddress(ip, type);
44
+ else if (/^\d+$/.test(bits) && Number(bits) <= (type === 'ipv6' ? 128 : 32)) list.addSubnet(ip, Number(bits), type);
45
+ else log.warn(`[guard] TRUSTED_PROXIES: ignoring invalid prefix "${raw}"`);
46
+ }
47
+ return list;
48
+ }
49
+
50
+ /** Strip `::ffff:` IPv4-mapped prefix and zone ids; undefined if not an IP. */
51
+ export function normalizeIp(v: string | undefined): string | undefined {
52
+ let s = String(v ?? '').trim();
53
+ if (s.startsWith('[') && s.includes(']')) s = s.slice(1, s.indexOf(']'));
54
+ s = s.replace(/%.*$/, '');
55
+ if (/^::ffff:\d+\.\d+\.\d+\.\d+$/i.test(s)) s = s.slice(7);
56
+ return isIP(s) ? s : undefined;
57
+ }
58
+
59
+ const isTrusted = (list: BlockList, ip: string) => list.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4');
60
+ const isLoopback = (ip: string) => /^127\./.test(ip) || ip === '::1';
61
+
62
+ /**
63
+ * Client IP: the direct peer, unless the peer is a trusted proxy — then walk `x-forwarded-for` right→left and take the
64
+ * first hop that is NOT trusted. Headers from an untrusted peer are ignored (spoofable). A malformed hop stops the walk
65
+ * at the last trusted hop. Returns undefined when nothing usable is known.
66
+ */
67
+ export function clientIp(peer: string | undefined, xff: string | string[] | undefined, trusted: BlockList): string | undefined {
68
+ let ip = normalizeIp(peer);
69
+ if (!ip || !isTrusted(trusted, ip)) return ip;
70
+ const hops = (Array.isArray(xff) ? xff.join(',') : String(xff ?? '')).split(',').map(h => h.trim()).filter(Boolean);
71
+ for (let i = hops.length - 1; i >= 0; i--) {
72
+ const hop = normalizeIp(hops[i]);
73
+ if (!hop) return ip;
74
+ ip = hop;
75
+ if (!isTrusted(trusted, hop)) return hop;
76
+ }
77
+ return ip;
78
+ }
79
+
80
+ /** Insertion-ordered Map used as an LRU: get() refreshes, set() evicts the oldest past `max`. */
81
+ class Lru<V> {
82
+ public readonly map = new Map<string, V>();
83
+ public constructor(private max: () => number) {}
84
+ public get(k: string): V | undefined {
85
+ const v = this.map.get(k);
86
+ if (v !== undefined) { this.map.delete(k); this.map.set(k, v); }
87
+ return v;
88
+ }
89
+ public set(k: string, v: V): void {
90
+ this.map.delete(k); this.map.set(k, v);
91
+ while (this.map.size > this.max()) this.map.delete(this.map.keys().next().value!);
92
+ }
93
+ public get size(): number { return this.map.size; }
94
+ }
95
+
96
+ export class GuardLimits {
97
+ /** Per client IP (only when an IP is known and not loopback). */
98
+ ip: string = '600/h';
99
+ /** Per channel name (e.g. `slack`, `api`, `ws`); channels not listed are unlimited. */
100
+ channel: Record<string, string> = {};
101
+ /** Max concurrent agent turns across principals ranked below `ceilingBelowRank`. */
102
+ maxConcurrentTurns: number = 8;
103
+ ceilingBelowRank: number = 50;
104
+ /** Auto-block a principal/IP after this many rate-limit hits within `blockWindowMs`… */
105
+ blockAfter: number = 20;
106
+ blockWindowMs: number = 10 * 60_000;
107
+ /** …for this long. */
108
+ blockTtlMs: number = 60 * 60_000;
109
+ /** Principals at/above this rank (owner, operator) are rate-limited but never auto-blocked or denied by IP-keyed state. */
110
+ autoBlockExemptRank: number = 80;
111
+ /** Cap per in-memory map (buckets, hit windows, blocks). */
112
+ maxKeys: number = 10_000;
113
+ }
114
+
115
+ export class GuardOptions {
116
+ clock: () => number = Date.now;
117
+ log: Pick<Console, 'info' | 'warn' | 'error'> = console;
118
+ limits: Partial<GuardLimits> = {};
119
+ blocksPath: string = dataPath('security', 'blocks.json');
120
+ /** Writes (blocks.json) only while true. */
121
+ isActive: () => boolean = () => true;
122
+ /** False = shadow: compute + audit, never deny. */
123
+ enforce: () => boolean = enforcing; // the ONE flag that also governs profile enforcement (enforce.ts)
124
+ /** Audit sink; `dedupeKey` = at most once per window (SecurityRuntime.record semantics). */
125
+ audit: (event: AuditEvent, dedupeKey?: string) => void = () => {};
126
+ /** Manual blocklist match (Policy.blocked). */
127
+ blocklist: (p: Principal) => { reason?: string; until: number | null } | undefined = () => undefined;
128
+ trustedProxies: BlockList = parseTrustedProxies(process.env.TRUSTED_PROXIES);
129
+ }
130
+
131
+ interface Bucket { tokens: number; at: number; rate: string }
132
+
133
+ export class Guard {
134
+ public options: GuardOptions;
135
+ public readonly limits: GuardLimits;
136
+ private buckets: Lru<Bucket>;
137
+ /** Rate-limit hit times per key, with the authenticated principal id (undefined = anonymous) for shared-IP detection. */
138
+ private hits: Lru<Array<{ t: number; p?: string }>>;
139
+ private blocks: Lru<BlockRecord>;
140
+ private activeTurns = 0;
141
+
142
+ public constructor(options?: Partial<GuardOptions>) {
143
+ this.options = { ...new GuardOptions(), ...options };
144
+ this.limits = { ...new GuardLimits(), ...this.options.limits };
145
+ const max = () => this.limits.maxKeys;
146
+ this.buckets = new Lru(max); this.hits = new Lru(max); this.blocks = new Lru(max);
147
+ this.load();
148
+ }
149
+
150
+ /** Client IP of an HTTP request / WS upgrade (see clientIp). */
151
+ public ipOf(req: { socket?: { remoteAddress?: string }; headers: Record<string, string | string[] | undefined> }): string | undefined {
152
+ return clientIp(req.socket?.remoteAddress, req.headers['x-forwarded-for'], this.options.trustedProxies);
153
+ }
154
+
155
+ public get turnsInFlight(): number { return this.activeTurns; }
156
+
157
+ /** Active auto-blocks (for the Console). */
158
+ public list(): Array<BlockRecord & { key: string }> {
159
+ const now = this.options.clock();
160
+ return [...this.blocks.map].filter(([, b]) => b.until > now).map(([key, b]) => ({ key, ...b }));
161
+ }
162
+
163
+ /** Clear an auto-block (Owner Console) and its hit window; persists. False when no such block exists. */
164
+ public unblock(key: string): boolean {
165
+ if (!this.blocks.map.delete(key)) return false;
166
+ this.hits.map.delete(key);
167
+ this.save();
168
+ return true;
169
+ }
170
+
171
+ /** blocklist → auto-blocks → rate. Consumes one token from each applicable bucket only when all pass. */
172
+ public check(input: GuardInput): GuardVerdict {
173
+ const denial = this.evaluate(input);
174
+ if (!denial) return { ok: true };
175
+ return this.options.enforce() ? denial : { ok: true, wouldDeny: denial };
176
+ }
177
+
178
+ /** Concurrency slot for rank < ceilingBelowRank. Returns an idempotent release, or null when the ceiling is reached. */
179
+ public acquireTurn(rank: number): (() => void) | null {
180
+ if (rank >= this.limits.ceilingBelowRank) return () => {};
181
+ if (this.activeTurns >= this.limits.maxConcurrentTurns) return null;
182
+ this.activeTurns++;
183
+ let done = false;
184
+ return () => { if (!done) { done = true; this.activeTurns--; } };
185
+ }
186
+
187
+ /** Run `fn` holding a turn slot; the slot is released however `fn` exits. Throws when the ceiling is reached. */
188
+ public async withTurn<T>(rank: number, fn: () => T | Promise<T>): Promise<T> {
189
+ const release = this.acquireTurn(rank);
190
+ if (!release) throw new Error('guard: concurrent turn ceiling reached');
191
+ try { return await fn(); } finally { release(); }
192
+ }
193
+
194
+ /** check + acquireTurn, shadow-aware. On ok the caller MUST call `release` when the turn ends (try/finally). */
195
+ public admit(input: GuardInput): TurnAdmission {
196
+ const v = this.check(input);
197
+ if (!v.ok) return v;
198
+ const release = this.acquireTurn(input.rank);
199
+ if (release) return v.wouldDeny ? { ok: true, release, wouldDeny: v.wouldDeny } : { ok: true, release };
200
+ const denial: GuardDenial = { ok: false, status: 429, retryAfter: 5, reason: 'turn-ceiling' };
201
+ const enforced = this.options.enforce();
202
+ this.audit({ type: 'guard.limit', principal: input.principal.id, reason: 'turn-ceiling', meta: { key: 'global:turns', max: this.limits.maxConcurrentTurns, rank: input.rank, enforced } }, 'guard.limit|global:turns');
203
+ return enforced ? denial : { ok: true, release: () => {}, wouldDeny: v.wouldDeny ?? denial };
204
+ }
205
+
206
+ /** Passive → active promotion: merge disk state (the previous active's blocks) and persist. */
207
+ public activate(): void {
208
+ const mem = [...this.blocks.map];
209
+ this.load();
210
+ for (const [k, b] of mem) { const d = this.blocks.get(k); if (!d || d.until < b.until) this.blocks.set(k, b); }
211
+ this.save();
212
+ }
213
+
214
+ // ── internals ─────────────────────────────────────────────────────────────
215
+
216
+ private evaluate(input: GuardInput): GuardDenial | undefined {
217
+ const { principal, channel, rank } = input;
218
+ const now = this.options.clock(), enforced = this.options.enforce();
219
+ const ip = input.ip && !isLoopback(input.ip) ? input.ip : undefined;
220
+ const pKey = `principal:${principal.id}`, ipKey = ip ? `ip:${ip}` : undefined;
221
+ const meta = (extra: Record<string, unknown>) => ({ ...(ip ? { ip } : {}), ...(channel ? { channel } : {}), rank, enforced, ...extra });
222
+
223
+ const manual = this.safe(() => this.options.blocklist(principal), undefined);
224
+ if (manual) {
225
+ this.audit({ type: 'guard.block', principal: principal.id, reason: manual.reason ?? 'blocklist', meta: meta({ source: 'policy' }) }, `guard.block|policy|${principal.id}`);
226
+ const retryAfter = manual.until ? Math.max(1, Math.ceil(manual.until - now / 1000)) : undefined;
227
+ return { ok: false, status: 403, reason: 'blocked', ...(retryAfter ? { retryAfter } : {}) };
228
+ }
229
+ // IP-keyed state never denies an exempt rank: a shared IP (NAT, same-host proxy) blocked by a guest must not lock out an owner/operator.
230
+ const exempt = rank >= this.limits.autoBlockExemptRank;
231
+ for (const key of [pKey, exempt ? undefined : ipKey]) {
232
+ const b = key ? this.activeBlock(key, now) : undefined;
233
+ if (!b) continue;
234
+ this.audit({ type: 'guard.block', principal: principal.id, target: key, reason: b.reason, meta: meta({ source: 'auto' }) }, `guard.block|auto|${key}`);
235
+ return { ok: false, status: 403, reason: 'blocked', retryAfter: Math.max(1, Math.ceil((b.until - now) / 1000)) };
236
+ }
237
+
238
+ const wants: Array<[key: string, rate: string, blockable: boolean]> = [[pKey, input.rate, !exempt]];
239
+ if (ipKey && !exempt) wants.push([ipKey, this.limits.ip, true]);
240
+ const chRate = channel ? this.limits.channel[channel] : undefined;
241
+ if (chRate !== undefined) wants.push([`channel:${channel}`, chRate, false]);
242
+
243
+ const pending: Array<[string, Bucket]> = [];
244
+ for (const [key, rate, blockable] of wants) {
245
+ const r = parseRate(rate);
246
+ if (!r || r.cap === 0) {
247
+ // Policy denial ("0" = no access) or a bad rate string — not an abuse signal, so no auto-block.
248
+ if (!r) this.options.log.error(`[guard] invalid rate "${rate}" for ${key} — denying`);
249
+ this.audit({ type: 'guard.limit', principal: principal.id, target: key, reason: r ? 'rate-zero' : 'rate-invalid', meta: meta({ rate }) }, `guard.limit|${key}|zero`);
250
+ return { ok: false, status: 403, reason: r ? 'rate-zero' : 'rate-invalid' };
251
+ }
252
+ const prev = this.buckets.get(key);
253
+ const b: Bucket = prev && prev.rate === rate
254
+ ? { rate, at: now, tokens: Math.min(r.cap, prev.tokens + (Math.max(0, now - prev.at) * r.cap) / r.periodMs) } // clock stepped back → no refill, never a drain
255
+ : { rate, at: now, tokens: r.cap };
256
+ if (b.tokens < 1) {
257
+ const retryAfter = Math.max(1, Math.ceil(((1 - b.tokens) * r.periodMs) / r.cap / 1000));
258
+ this.buckets.set(key, b);
259
+ this.audit({ type: 'guard.limit', principal: principal.id, target: key, reason: 'rate', meta: meta({ rate, retryAfter }) }, `guard.limit|${key}`);
260
+ if (blockable) this.recordHit(key, principal, now, meta({}));
261
+ return { ok: false, status: 429, retryAfter, reason: 'rate' };
262
+ }
263
+ pending.push([key, b]);
264
+ }
265
+ for (const [key, b] of pending) { b.tokens -= 1; this.buckets.set(key, b); }
266
+ return undefined;
267
+ }
268
+
269
+ private recordHit(key: string, principal: Principal, now: number, meta: Record<string, unknown>): void {
270
+ const { blockAfter, blockWindowMs, blockTtlMs } = this.limits;
271
+ const window = (this.hits.get(key) ?? []).filter(h => now - h.t < blockWindowMs);
272
+ window.push({ t: now, p: principal.kind === 'anonymous' ? undefined : principal.id });
273
+ if (window.length < blockAfter) return void this.hits.set(key, window.slice(-blockAfter));
274
+ // An IP whose hits come from >1 authenticated principal is shared (NAT/proxy): throttle, never block it.
275
+ if (key.startsWith('ip:') && new Set(window.map(h => h.p).filter(Boolean)).size > 1) return void this.hits.set(key, window.slice(-blockAfter));
276
+ this.hits.map.delete(key);
277
+ const principalId = principal.id;
278
+ const rec: BlockRecord = { until: now + blockTtlMs, reason: `auto: ${window.length} limit hits in ${Math.round(blockWindowMs / 1000)}s`, at: now };
279
+ this.blocks.set(key, rec);
280
+ this.options.log.warn(`[guard] auto-blocked ${key} until ${new Date(rec.until).toISOString()} (${rec.reason})`);
281
+ this.audit({ type: 'guard.block', principal: principalId, target: key, reason: rec.reason, meta: { ...meta, source: 'auto', created: true, until: rec.until } });
282
+ this.save();
283
+ }
284
+
285
+ private activeBlock(key: string, now: number): BlockRecord | undefined {
286
+ const b = this.blocks.map.get(key);
287
+ if (!b) return undefined;
288
+ if (b.until > now) return b;
289
+ this.blocks.map.delete(key);
290
+ this.save();
291
+ return undefined;
292
+ }
293
+
294
+ private audit(event: AuditEvent, dedupeKey?: string): void {
295
+ this.safe(() => this.options.audit(event, dedupeKey), undefined);
296
+ }
297
+
298
+ private safe<T>(fn: () => T, fallback: T): T {
299
+ try { return fn(); } catch (e: any) { this.options.log.error(`[guard] hook threw: ${e?.message ?? e}`); return fallback; }
300
+ }
301
+
302
+ private active(): boolean { return this.safe(() => this.options.isActive(), false); }
303
+
304
+ /** Read blocks.json, dropping expired/malformed entries (persisting the prune when active). Never throws. */
305
+ private load(): void {
306
+ const p = this.options.blocksPath, now = this.options.clock();
307
+ if (!existsSync(p)) return;
308
+ let pruned = 0;
309
+ try {
310
+ const raw = JSON.parse(readFileSync(p, 'utf8'))?.blocks ?? {};
311
+ for (const [key, b] of Object.entries<any>(raw)) {
312
+ if (typeof b?.until === 'number' && b.until > now && typeof key === 'string') this.blocks.set(key, { until: b.until, reason: String(b.reason ?? 'auto'), at: Number(b.at) || now });
313
+ else pruned++;
314
+ }
315
+ } catch (e: any) { this.options.log.error(`[guard] ${p} unreadable — starting with no auto-blocks: ${e.message}`); return; }
316
+ if (pruned) { this.options.log.info(`[guard] pruned ${pruned} expired block(s) from ${p}`); this.save(); }
317
+ }
318
+
319
+ /** Atomic write of the active blocks. Active instance only. Never throws. */
320
+ private save(): void {
321
+ if (!this.active()) return;
322
+ const p = this.options.blocksPath, now = this.options.clock();
323
+ const blocks = Object.fromEntries([...this.blocks.map].filter(([, b]) => b.until > now));
324
+ try {
325
+ mkdirSync(path.dirname(p), { recursive: true });
326
+ const tmp = `${p}.${process.pid}.tmp`;
327
+ writeFileSync(tmp, JSON.stringify({ version: 1, blocks }, null, 2) + '\n', { mode: 0o600 });
328
+ renameSync(tmp, p);
329
+ } catch (e: any) { this.options.log.error(`[guard] persisting ${p} failed: ${e.message}`); }
330
+ }
331
+ }
332
+
333
+ /** Write a denial to an HTTP response (status + Retry-After). */
334
+ export function writeDenial(res: { status(code: number): { json(body: unknown): unknown }; setHeader(k: string, v: string): unknown }, d: GuardDenial): void {
335
+ if (d.retryAfter) res.setHeader('Retry-After', String(d.retryAfter));
336
+ res.status(d.status).json({ error: d.status === 403 ? 'Forbidden' : 'Too many requests', reason: d.reason, ...(d.retryAfter ? { retryAfter: d.retryAfter } : {}) });
337
+ }
@@ -0,0 +1,34 @@
1
+ // Owner gate for admin actions. `isOwner` is set by requireAuth from the OWNERS env (owners.ts), so this
2
+ // must run AFTER requireAuth. Real enforcement (not shadow): only for system-scope mutations.
3
+ import type { Request, Response, RequestHandler } from 'express';
4
+ import { security } from './runtime.ts';
5
+
6
+ /** True if the caller is an owner; otherwise responds 403 with `message` and returns false. */
7
+ export function ownerOnly(req: Request, res: Response, message = 'Only an owner can do this'): boolean {
8
+ if ((req as any).user?.isOwner) return true;
9
+ res.status(403).json({ error: message });
10
+ return false;
11
+ }
12
+
13
+ /** 403 + `auth.deny` audit unless the caller's principal kind is one of `kinds`. */
14
+ function kindGate(kinds: string[], error: string): RequestHandler {
15
+ return (req, res, next) => {
16
+ const kind = (req as any).user?.principal?.kind ?? 'unknown';
17
+ if (kinds.includes(kind)) return next();
18
+ console.warn(`[owner-only] ${req.method} ${req.path} refused: ${kind} credential`);
19
+ security()?.authDeny(`http:${req.path}`, `non-interactive:${kind}`, req.ip);
20
+ res.status(403).json({ error });
21
+ };
22
+ }
23
+
24
+ /** Middleware form of ownerOnly, for policy/config/skills/MCP routes. The agent subprocess carries an owner-signed
25
+ * internal token (INTERNAL_API_TOKEN), so an internal principal is never owner HERE — otherwise a prompt-injected turn
26
+ * could rewrite its own policy. Owner = interactive login or an uncapped owner API key (CLI terminal). Inline
27
+ * `ownerOnly` callers (self-upgrade, schedules, modules) keep accepting the internal token. */
28
+ export function requireOwner(message?: string): RequestHandler {
29
+ const notInternal = kindGate(['user', 'apikey'], 'Owner access requires an interactive login or an owner API key — not the agent\'s internal token');
30
+ return (req, res, next) => { if (ownerOnly(req, res, message)) notInternal(req, res, next); };
31
+ }
32
+
33
+ /** Security-console writes: an interactive login only (no API key, no internal token) — same rule as minting a key. */
34
+ export const requireInteractive = (): RequestHandler => kindGate(['user'], 'This change requires an interactive login');
@@ -0,0 +1,285 @@
1
+ // Owner-only admin API (`/api/owner/*`). Each route runs requireAuth + requireOwner itself — never router-wide, so
2
+ // mounting this router can't gate unrelated requests. Consumer: the Owner Console (client/components/owner/).
3
+ // Policy writes: validated by Policy's own validatePolicy, saved via policy.save() (provenance holds), audited as
4
+ // `policy.change` with the changed paths only (never values). Every write on a PASSIVE standby → 409.
5
+ import { createHash } from 'node:crypto';
6
+ import { Router, type Request, type Response } from 'express';
7
+ import { requireAuth, type AuthUser } from '../auth.ts';
8
+ import { apiKeyRouter } from '../api-key-routes.ts';
9
+ import { apiKeyPrincipal } from '../api-keys.ts';
10
+ import { getOwners } from '../owners.ts';
11
+ import { canonical, type AuditEventType } from './audit.ts';
12
+ import { matches, validatePolicy, type BindingMatch, type PolicyFile } from './policy.ts';
13
+ import { anonymous, fromAuthUser, fromEmailSender, fromInternal, fromSlack, type Principal } from './principal.ts';
14
+ import { requireInteractive, requireOwner } from './owner-only.ts';
15
+ import { revocablePrincipalId, revokeTokens } from './revocation.ts';
16
+ import { resolvePrincipal, security, type SecurityRuntime } from './runtime.ts';
17
+
18
+ /** Reads: owner via interactive login or uncapped owner API key (never the agent's internal token). */
19
+ const gate = [requireAuth, requireOwner()];
20
+ /** Writes: interactive login only. */
21
+ const writeGate = [...gate, requireInteractive()];
22
+ /** The raw INTERNAL_API_TOKEN's principal (auth.ts) — no issued-at, so tokensValidAfter can never reject it. */
23
+ const LEGACY_INTERNAL_ID = 'internal:agent-internal';
24
+ const userOf = (req: Request) => (req as any).user as AuthUser;
25
+ /** OWNERS as the principals a block could hit: each owner's login and verified email channel. */
26
+ const ownerPrincipals = () => getOwners().flatMap(e => [fromAuthUser({ uid: e, email: e }), fromEmailSender(e, true)]);
27
+
28
+ const fail = (res: Response, e: any, status = 400) => {
29
+ console.warn(`[owner-routes] ${e?.message ?? e}`);
30
+ res.status(status).json({ error: e?.message ?? String(e) });
31
+ };
32
+
33
+ const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v);
34
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
35
+
36
+ /** Opaque version of a policy document. A PUT carrying a stale version is refused, so a Console tab opened earlier
37
+ * can't silently undo a revoke/block made meanwhile. */
38
+ export const policyVersion = (p: PolicyFile) => createHash('sha256').update(canonical(p)).digest('hex').slice(0, 16);
39
+
40
+ /** Paths that differ, two levels deep (`profiles.standard`, `bindings.2`, `default`) — names only, never values. */
41
+ export function policyDiff(a: object, b: object, max = 30): string[] {
42
+ const keys = (x: object, y: object) => [...new Set([...Object.keys(x), ...Object.keys(y)])];
43
+ const out: string[] = [];
44
+ for (const k of keys(a, b)) {
45
+ const x = (a as any)[k], y = (b as any)[k];
46
+ if (canonical(x) === canonical(y)) continue;
47
+ if (x && y && typeof x === 'object' && typeof y === 'object') {
48
+ for (const s of keys(x, y)) if (canonical(x[s]) !== canonical(y[s])) out.push(`${k}.${s}`);
49
+ } else out.push(k);
50
+ }
51
+ return out.length > max ? [...out.slice(0, max), `+${out.length - max} more`] : out;
52
+ }
53
+
54
+ /** The runtime, or an error response: 503 when uninitialized; 409 for a write on a PASSIVE standby. */
55
+ function runtimeOr(res: Response, write = false): SecurityRuntime | undefined {
56
+ const sec = security();
57
+ if (!sec) return void res.status(503).json({ error: 'Security runtime not initialized' });
58
+ if (write) {
59
+ let active = false;
60
+ try { active = sec.options.isActive(); } catch (e: any) { console.warn(`[owner-routes] isActive threw — treating as PASSIVE: ${e?.message ?? e}`); }
61
+ if (!active) return void res.status(409).json({ error: 'Read-only on a PASSIVE standby — use the active instance' });
62
+ }
63
+ return sec;
64
+ }
65
+
66
+ /** Incremental edits (blocklist) start from the current document — refused in fail-closed mode, where saving would
67
+ * replace the broken-but-fixable file with the owners-only fallback. A whole-document PUT is the fix path. */
68
+ function requireValidPolicy(res: Response, sec: SecurityRuntime): boolean {
69
+ if (sec.policy.valid) return true;
70
+ res.status(409).json({ error: 'Policy is invalid (fail-closed) — fix it with a full policy save first' });
71
+ return false;
72
+ }
73
+
74
+ /** validatePolicy → policy.save() → `policy.change` audit. Responds with the error itself; true on success. */
75
+ function commit(req: Request, res: Response, sec: SecurityRuntime, next: PolicyFile, op: string): boolean {
76
+ const errors = validatePolicy(next);
77
+ if (errors.length) {
78
+ console.warn(`[owner-routes] ${op} refused: ${errors.join('; ')}`);
79
+ res.status(400).json({ error: `Invalid policy: ${errors.join('; ')}`, errors });
80
+ return false;
81
+ }
82
+ const prev = sec.policy.current;
83
+ try { sec.policy.save(next); } catch (e: any) { fail(res, e, 500); return false; }
84
+ sec.record({ type: 'policy.change', principal: userOf(req).principal.id, target: op, meta: { changed: policyDiff(prev, sec.policy.current) } });
85
+ return true;
86
+ }
87
+
88
+ const KINDS = ['user', 'email', 'slack', 'apikey', 'internal', 'anonymous'];
89
+
90
+ /** A principal built by the SAME builders the channels use, from `{ kind, id?, email?, verified?, lane?, role?, attrs? }`.
91
+ * `role` (apikey only) = the key's role cap; for an apikey, `email` is the creator. */
92
+ export function principalFromDescriptor(d: any): Principal {
93
+ const kind = d?.kind, email = str(d?.email), id = str(d?.id);
94
+ const attrs: Record<string, unknown> = isObj(d?.attrs) ? { ...d.attrs } : {};
95
+ const lane = str(d?.lane) ?? str(attrs.lane), who = id ?? email;
96
+ if (!KINDS.includes(kind)) throw new Error(`kind must be one of ${KINDS.join(', ')}`);
97
+ if (kind !== 'anonymous' && !who) throw new Error('id or email is required');
98
+ let p: Principal;
99
+ switch (kind) {
100
+ case 'user': p = fromAuthUser({ uid: who!, email }); break;
101
+ case 'email': p = fromEmailSender(who!, d?.verified === true); break;
102
+ case 'slack': p = fromSlack(who!, { email }); break;
103
+ case 'apikey': p = apiKeyPrincipal({ id: id ?? 'console-test', uid: str(attrs.uid) ?? who!, email: email ?? '', role: str(d?.role) ?? str(attrs.role) }); break;
104
+ case 'internal': p = fromInternal({ uid: who!, email, lane }); break;
105
+ default: p = anonymous();
106
+ }
107
+ if (typeof d?.verified === 'boolean') p.verified = d.verified;
108
+ Object.assign(p.attrs, attrs, lane ? { lane } : {});
109
+ return p;
110
+ }
111
+
112
+ const DENY_TYPES = new Set<string>(['auth.deny', 'tool.deny', 'guard.block', 'guard.limit']);
113
+ /** Events whose `role` is the ACTING principal's role. Others carry a different subject's role (key.create: the key's cap). */
114
+ const OWN_ROLE_TYPES = new Set<string>(['role.resolve', 'turn.start', 'turn.end', 'tool.allow', 'tool.deny', 'escalate']);
115
+ const PRINCIPAL_SCAN_CAP = 50_000;
116
+ export interface PrincipalRow { id: string; kind: string; lastRole?: string; lastSeen: string; turns: number; denies: number }
117
+
118
+ /** Principals in the audit since `sinceMs`, most recently seen first. Denies include shadow (would-deny) verdicts. */
119
+ export function recentPrincipals(sec: SecurityRuntime, sinceMs: number, limit: number): { principals: PrincipalRow[]; truncated: boolean } {
120
+ const rows = new Map<string, PrincipalRow>();
121
+ let cursor: string | undefined, scanned = 0;
122
+ do {
123
+ const page = sec.audit.query({ from: sinceMs, limit: 1000, cursor });
124
+ for (const r of page.items) {
125
+ scanned++;
126
+ if (!r.principal) continue;
127
+ let row = rows.get(r.principal);
128
+ if (!row) rows.set(r.principal, row = { id: r.principal, kind: r.principal.split(':')[0], lastSeen: r.ts, turns: 0, denies: 0 });
129
+ if (!row.lastRole && r.role && OWN_ROLE_TYPES.has(r.type)) row.lastRole = r.role;
130
+ if (r.type === 'turn.start') row.turns++;
131
+ else if (DENY_TYPES.has(r.type)) row.denies++;
132
+ }
133
+ cursor = page.nextCursor;
134
+ } while (cursor && scanned < PRINCIPAL_SCAN_CAP);
135
+ return { principals: [...rows.values()].slice(0, limit), truncated: !!cursor };
136
+ }
137
+
138
+ const clampInt = (v: unknown, def: number, min: number, max: number) => Math.max(min, Math.min(max, Math.floor(Number(v)) || def));
139
+ const when = (v: unknown): Date | undefined => {
140
+ const s = str(v);
141
+ if (!s) return undefined;
142
+ const d = new Date(/^\d+$/.test(s) ? Number(s) : s);
143
+ if (Number.isNaN(d.getTime())) throw new Error(`invalid date "${s}"`);
144
+ return d;
145
+ };
146
+
147
+ export const ownerRouter = Router();
148
+
149
+ // ── Tokens + API keys ─────────────────────────────────────────────────────────
150
+
151
+ /** Invalidate every token issued to a principal until now. Body: { principalId: "user:<email|uid>" | "internal:<uid>" }. */
152
+ ownerRouter.post('/api/owner/tokens/revoke', ...writeGate, (req, res) => {
153
+ const { principalId } = (req.body ?? {}) as { principalId?: unknown };
154
+ const id = typeof principalId === 'string' ? revocablePrincipalId(principalId) : null;
155
+ if (!id) {
156
+ return void res.status(400).json({
157
+ error: 'principalId must be "user:<email|uid>" or "internal:<uid>" (the token kinds revocation checks). '
158
+ + 'To revoke an API key, DELETE /api/owner/api-keys/:id.',
159
+ });
160
+ }
161
+ if (id === LEGACY_INTERNAL_ID) {
162
+ return void res.status(400).json({
163
+ error: `${LEGACY_INTERNAL_ID} is the legacy shared internal token, which carries no issue time — it can only be `
164
+ + 'rotated by changing INTERNAL_API_TOKEN and restarting.',
165
+ });
166
+ }
167
+ try {
168
+ const validAfter = revokeTokens(id, userOf(req).principal.id);
169
+ res.json({ ok: true, principalId: id, validAfter });
170
+ } catch (e: any) { fail(res, e, 409); }
171
+ });
172
+
173
+ ownerRouter.use(apiKeyRouter({ base: '/api/owner/api-keys', gate, writeGate: [requireInteractive()], asOwner: true }));
174
+
175
+ // ── Policy ────────────────────────────────────────────────────────────────────
176
+
177
+ ownerRouter.get('/api/owner/policy', ...gate, (_req, res) => {
178
+ const sec = runtimeOr(res);
179
+ if (!sec) return;
180
+ const policy = sec.policy.current;
181
+ // ownerIds: a UI hint (hide Block on owner rows); the block route enforces it.
182
+ res.json({ policy, version: policyVersion(policy), valid: sec.policy.valid, ownerIds: ownerPrincipals().map(p => p.id) });
183
+ });
184
+
185
+ /** Body: { policy, version } — the whole document; `version` (from GET, required) guards against overwriting a newer
186
+ * save. `tokensValidAfter` and `blocklist` always come from the CURRENT policy — they change only via their own routes,
187
+ * so a PUT can't un-revoke or un-block. */
188
+ ownerRouter.put('/api/owner/policy', ...writeGate, (req, res) => {
189
+ const sec = runtimeOr(res, true);
190
+ if (!sec) return;
191
+ const { policy, version } = (req.body ?? {}) as { policy?: unknown; version?: unknown };
192
+ if (!isObj(policy)) return void res.status(400).json({ error: 'Body must be { policy, version }' });
193
+ const current = sec.policy.current;
194
+ if (version !== policyVersion(current)) {
195
+ return void res.status(409).json({ error: version === undefined ? 'version is required — GET the policy first' : 'The policy changed since you loaded it — reload and re-apply your edit' });
196
+ }
197
+ const next = { ...policy, tokensValidAfter: current.tokensValidAfter, blocklist: current.blocklist } as PolicyFile;
198
+ if (commit(req, res, sec, next, 'policy')) res.json({ ok: true, version: policyVersion(sec.policy.current), valid: sec.policy.valid });
199
+ });
200
+
201
+ /** Resolve a descriptor exactly as a turn would (resolvePrincipal — decide()'s path), without auditing `role.resolve`. */
202
+ ownerRouter.post('/api/owner/policy/test', ...gate, (req, res) => {
203
+ const sec = runtimeOr(res);
204
+ if (!sec) return;
205
+ let p: Principal;
206
+ try { p = principalFromDescriptor(req.body); } catch (e: any) { return fail(res, e); }
207
+ const r = resolvePrincipal(sec.policy, p);
208
+ res.json({ principal: p.id, role: r.role, rank: r.rank, profile: r.profileName, capabilities: r.profile, blocked: sec.policy.blocked(p) ?? null });
209
+ });
210
+
211
+ // ── Principals ────────────────────────────────────────────────────────────────
212
+
213
+ /** Query: days (1–90, default 7), limit (1–500, default 200). */
214
+ ownerRouter.get('/api/owner/principals', ...gate, (req, res) => {
215
+ const sec = runtimeOr(res);
216
+ if (!sec) return;
217
+ const days = clampInt(req.query.days, 7, 1, 90), limit = clampInt(req.query.limit, 200, 1, 500);
218
+ try { res.json({ days, ...recentPrincipals(sec, Date.now() - days * 86_400_000, limit) }); } catch (e: any) { fail(res, e, 500); }
219
+ });
220
+
221
+ // ── Blocklist ─────────────────────────────────────────────────────────────────
222
+
223
+ /** Manual entries (policy blocklist, `until` epoch SECONDS) + guard auto-blocks (`until` epoch MS). */
224
+ ownerRouter.get('/api/owner/blocks', ...gate, (_req, res) => {
225
+ const sec = runtimeOr(res);
226
+ if (!sec) return;
227
+ res.json({ manual: sec.policy.current.blocklist.map((b, index) => ({ index, ...b })), auto: sec.guard.list() });
228
+ });
229
+
230
+ /** Body: { match, until?: epoch seconds | null, reason? } — a manual block. */
231
+ ownerRouter.post('/api/owner/blocks', ...writeGate, (req, res) => {
232
+ const sec = runtimeOr(res, true);
233
+ if (!sec || !requireValidPolicy(res, sec)) return;
234
+ const { match, until = null, reason } = (req.body ?? {}) as { match?: unknown; until?: unknown; reason?: unknown };
235
+ const owner = isObj(match) && ownerPrincipals().find(p => matches(match as BindingMatch, p));
236
+ if (owner) return void res.status(400).json({ error: `This block would match the owner ${owner.email ?? owner.id} — owners can't be blocked (change OWNERS instead)` });
237
+ if (until !== null && (typeof until !== 'number' || until >= 1e11)) return void res.status(400).json({ error: 'until must be epoch SECONDS or null' });
238
+ if (typeof until === 'number' && until <= Date.now() / 1000) return void res.status(400).json({ error: 'until is in the past' });
239
+ const next = sec.policy.current;
240
+ next.blocklist = [...next.blocklist, { match: match as BindingMatch, until, ...(str(reason) ? { reason: str(reason) } : {}) }];
241
+ if (commit(req, res, sec, next, 'blocklist.add')) res.json({ ok: true, index: next.blocklist.length - 1 });
242
+ });
243
+
244
+ /** Body: { source: "manual", index, match } (match must still equal the entry at index) | { source: "auto", key }. */
245
+ ownerRouter.delete('/api/owner/blocks', ...writeGate, (req, res) => {
246
+ const sec = runtimeOr(res, true);
247
+ if (!sec) return;
248
+ const b = (req.body ?? {}) as { source?: unknown; index?: unknown; match?: unknown; key?: unknown };
249
+ if (b.source === 'auto') {
250
+ if (typeof b.key !== 'string' || !sec.guard.unblock(b.key)) return void res.status(404).json({ error: 'No such auto-block' });
251
+ sec.record({ type: 'policy.change', principal: userOf(req).principal.id, target: `auto-block:${b.key}`, meta: { op: 'auto-block.clear' } });
252
+ return void res.json({ ok: true });
253
+ }
254
+ if (b.source !== 'manual') return void res.status(400).json({ error: 'source must be "manual" or "auto"' });
255
+ if (!requireValidPolicy(res, sec)) return;
256
+ const next = sec.policy.current;
257
+ const entry = typeof b.index === 'number' && Number.isInteger(b.index) ? next.blocklist[b.index] : undefined;
258
+ if (!entry) return void res.status(404).json({ error: 'No such manual block' });
259
+ if (canonical(entry.match) !== canonical(b.match)) return void res.status(409).json({ error: 'The blocklist changed since you loaded it — reload' });
260
+ next.blocklist = next.blocklist.filter((_, i) => i !== b.index);
261
+ if (commit(req, res, sec, next, 'blocklist.remove')) res.json({ ok: true });
262
+ });
263
+
264
+ // The console deliberately exposes no conversation-delete route: the audit log is append-only, and removing a
265
+ // conversation is a manual, on-box operation. The `session.delete` audit type stays for those out-of-band removals.
266
+
267
+ // ── Audit ─────────────────────────────────────────────────────────────────────
268
+
269
+ /** Query: from, to (ISO or epoch ms), type (comma list), principal, limit (1–500, default 100), cursor. Newest first. */
270
+ ownerRouter.get('/api/owner/audit', ...gate, (req, res) => {
271
+ const sec = runtimeOr(res);
272
+ if (!sec) return;
273
+ try {
274
+ const type = str(req.query.type)?.split(',').map(s => s.trim()).filter(Boolean) as AuditEventType[] | undefined;
275
+ res.json(sec.audit.query({
276
+ from: when(req.query.from), to: when(req.query.to), type, principal: str(req.query.principal),
277
+ limit: clampInt(req.query.limit, 100, 1, 500), cursor: str(req.query.cursor),
278
+ }));
279
+ } catch (e: any) { fail(res, e, /cursor|date/.test(e?.message ?? '') ? 400 : 500); }
280
+ });
281
+
282
+ ownerRouter.get('/api/owner/audit/verify', ...gate, (_req, res) => {
283
+ const sec = runtimeOr(res);
284
+ if (sec) res.json(sec.audit.verify());
285
+ });