shraga 0.1.111 → 0.1.113
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 +4 -1
- package/defaults/mcps/README.md +6 -3
- package/defaults/skills/mcp-server.md +12 -5
- package/defaults/skills/platform.md +4 -1
- package/dist/client/assets/index-DIDtPQb-.css +10 -0
- package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/client/App.tsx +33 -8
- package/src/client/components/BackendStatusBanner.tsx +62 -0
- package/src/client/components/ConfigPanel.tsx +61 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/hooks/useAuth.ts +11 -2
- package/src/client/hooks/useIsOwner.ts +24 -0
- package/src/client/hooks/useModules.ts +5 -1
- package/src/client/lib/api.ts +21 -5
- package/src/client/lib/backendHealth.ts +230 -0
- package/src/client/lib/debug.ts +48 -0
- package/src/client/lib/sessionApi.ts +24 -8
- package/src/client/lib/ws.ts +21 -13
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/api-key-routes.ts +64 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +158 -104
- package/src/server/claude.ts +112 -4
- package/src/server/data-sync.ts +55 -6
- package/src/server/directives.ts +10 -5
- package/src/server/engine/claude-code.ts +190 -32
- package/src/server/engine/claude-resume.ts +205 -0
- package/src/server/engine/types.ts +10 -0
- package/src/server/hooks.ts +19 -0
- package/src/server/mcp-oauth.ts +24 -5
- package/src/server/mcp-server.ts +55 -25
- package/src/server/modules/routes.ts +2 -6
- package/src/server/notify-owners.ts +5 -17
- package/src/server/owners.ts +14 -0
- package/src/server/scheduler/builtins.ts +3 -1
- package/src/server/scheduler/runner.ts +3 -0
- package/src/server/security/audit.ts +498 -0
- package/src/server/security/enforce.ts +306 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +329 -0
- package/src/server/security/owner-only.ts +15 -0
- package/src/server/security/owner-routes.ts +43 -0
- package/src/server/security/policy.ts +413 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +174 -0
- package/src/server/sessions.ts +51 -9
- package/src/server/shraga-config.ts +3 -0
- package/src/server/slack/bot.ts +44 -11
- package/src/server/slack/context-cache.ts +40 -7
- package/src/server/webhook-lane/feature.ts +17 -6
- package/src/shared/models.ts +11 -0
- package/dist/client/assets/index-BNAh4GUs.js +0 -1949
- package/dist/client/assets/index-DIMte_k6.css +0 -10
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
/** blocklist → auto-blocks → rate. Consumes one token from each applicable bucket only when all pass. */
|
|
164
|
+
public check(input: GuardInput): GuardVerdict {
|
|
165
|
+
const denial = this.evaluate(input);
|
|
166
|
+
if (!denial) return { ok: true };
|
|
167
|
+
return this.options.enforce() ? denial : { ok: true, wouldDeny: denial };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Concurrency slot for rank < ceilingBelowRank. Returns an idempotent release, or null when the ceiling is reached. */
|
|
171
|
+
public acquireTurn(rank: number): (() => void) | null {
|
|
172
|
+
if (rank >= this.limits.ceilingBelowRank) return () => {};
|
|
173
|
+
if (this.activeTurns >= this.limits.maxConcurrentTurns) return null;
|
|
174
|
+
this.activeTurns++;
|
|
175
|
+
let done = false;
|
|
176
|
+
return () => { if (!done) { done = true; this.activeTurns--; } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Run `fn` holding a turn slot; the slot is released however `fn` exits. Throws when the ceiling is reached. */
|
|
180
|
+
public async withTurn<T>(rank: number, fn: () => T | Promise<T>): Promise<T> {
|
|
181
|
+
const release = this.acquireTurn(rank);
|
|
182
|
+
if (!release) throw new Error('guard: concurrent turn ceiling reached');
|
|
183
|
+
try { return await fn(); } finally { release(); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** check + acquireTurn, shadow-aware. On ok the caller MUST call `release` when the turn ends (try/finally). */
|
|
187
|
+
public admit(input: GuardInput): TurnAdmission {
|
|
188
|
+
const v = this.check(input);
|
|
189
|
+
if (!v.ok) return v;
|
|
190
|
+
const release = this.acquireTurn(input.rank);
|
|
191
|
+
if (release) return v.wouldDeny ? { ok: true, release, wouldDeny: v.wouldDeny } : { ok: true, release };
|
|
192
|
+
const denial: GuardDenial = { ok: false, status: 429, retryAfter: 5, reason: 'turn-ceiling' };
|
|
193
|
+
const enforced = this.options.enforce();
|
|
194
|
+
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');
|
|
195
|
+
return enforced ? denial : { ok: true, release: () => {}, wouldDeny: v.wouldDeny ?? denial };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Passive → active promotion: merge disk state (the previous active's blocks) and persist. */
|
|
199
|
+
public activate(): void {
|
|
200
|
+
const mem = [...this.blocks.map];
|
|
201
|
+
this.load();
|
|
202
|
+
for (const [k, b] of mem) { const d = this.blocks.get(k); if (!d || d.until < b.until) this.blocks.set(k, b); }
|
|
203
|
+
this.save();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── internals ─────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
private evaluate(input: GuardInput): GuardDenial | undefined {
|
|
209
|
+
const { principal, channel, rank } = input;
|
|
210
|
+
const now = this.options.clock(), enforced = this.options.enforce();
|
|
211
|
+
const ip = input.ip && !isLoopback(input.ip) ? input.ip : undefined;
|
|
212
|
+
const pKey = `principal:${principal.id}`, ipKey = ip ? `ip:${ip}` : undefined;
|
|
213
|
+
const meta = (extra: Record<string, unknown>) => ({ ...(ip ? { ip } : {}), ...(channel ? { channel } : {}), rank, enforced, ...extra });
|
|
214
|
+
|
|
215
|
+
const manual = this.safe(() => this.options.blocklist(principal), undefined);
|
|
216
|
+
if (manual) {
|
|
217
|
+
this.audit({ type: 'guard.block', principal: principal.id, reason: manual.reason ?? 'blocklist', meta: meta({ source: 'policy' }) }, `guard.block|policy|${principal.id}`);
|
|
218
|
+
const retryAfter = manual.until ? Math.max(1, Math.ceil(manual.until - now / 1000)) : undefined;
|
|
219
|
+
return { ok: false, status: 403, reason: 'blocked', ...(retryAfter ? { retryAfter } : {}) };
|
|
220
|
+
}
|
|
221
|
+
// 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.
|
|
222
|
+
const exempt = rank >= this.limits.autoBlockExemptRank;
|
|
223
|
+
for (const key of [pKey, exempt ? undefined : ipKey]) {
|
|
224
|
+
const b = key ? this.activeBlock(key, now) : undefined;
|
|
225
|
+
if (!b) continue;
|
|
226
|
+
this.audit({ type: 'guard.block', principal: principal.id, target: key, reason: b.reason, meta: meta({ source: 'auto' }) }, `guard.block|auto|${key}`);
|
|
227
|
+
return { ok: false, status: 403, reason: 'blocked', retryAfter: Math.max(1, Math.ceil((b.until - now) / 1000)) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const wants: Array<[key: string, rate: string, blockable: boolean]> = [[pKey, input.rate, !exempt]];
|
|
231
|
+
if (ipKey && !exempt) wants.push([ipKey, this.limits.ip, true]);
|
|
232
|
+
const chRate = channel ? this.limits.channel[channel] : undefined;
|
|
233
|
+
if (chRate !== undefined) wants.push([`channel:${channel}`, chRate, false]);
|
|
234
|
+
|
|
235
|
+
const pending: Array<[string, Bucket]> = [];
|
|
236
|
+
for (const [key, rate, blockable] of wants) {
|
|
237
|
+
const r = parseRate(rate);
|
|
238
|
+
if (!r || r.cap === 0) {
|
|
239
|
+
// Policy denial ("0" = no access) or a bad rate string — not an abuse signal, so no auto-block.
|
|
240
|
+
if (!r) this.options.log.error(`[guard] invalid rate "${rate}" for ${key} — denying`);
|
|
241
|
+
this.audit({ type: 'guard.limit', principal: principal.id, target: key, reason: r ? 'rate-zero' : 'rate-invalid', meta: meta({ rate }) }, `guard.limit|${key}|zero`);
|
|
242
|
+
return { ok: false, status: 403, reason: r ? 'rate-zero' : 'rate-invalid' };
|
|
243
|
+
}
|
|
244
|
+
const prev = this.buckets.get(key);
|
|
245
|
+
const b: Bucket = prev && prev.rate === rate
|
|
246
|
+
? { 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
|
|
247
|
+
: { rate, at: now, tokens: r.cap };
|
|
248
|
+
if (b.tokens < 1) {
|
|
249
|
+
const retryAfter = Math.max(1, Math.ceil(((1 - b.tokens) * r.periodMs) / r.cap / 1000));
|
|
250
|
+
this.buckets.set(key, b);
|
|
251
|
+
this.audit({ type: 'guard.limit', principal: principal.id, target: key, reason: 'rate', meta: meta({ rate, retryAfter }) }, `guard.limit|${key}`);
|
|
252
|
+
if (blockable) this.recordHit(key, principal, now, meta({}));
|
|
253
|
+
return { ok: false, status: 429, retryAfter, reason: 'rate' };
|
|
254
|
+
}
|
|
255
|
+
pending.push([key, b]);
|
|
256
|
+
}
|
|
257
|
+
for (const [key, b] of pending) { b.tokens -= 1; this.buckets.set(key, b); }
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private recordHit(key: string, principal: Principal, now: number, meta: Record<string, unknown>): void {
|
|
262
|
+
const { blockAfter, blockWindowMs, blockTtlMs } = this.limits;
|
|
263
|
+
const window = (this.hits.get(key) ?? []).filter(h => now - h.t < blockWindowMs);
|
|
264
|
+
window.push({ t: now, p: principal.kind === 'anonymous' ? undefined : principal.id });
|
|
265
|
+
if (window.length < blockAfter) return void this.hits.set(key, window.slice(-blockAfter));
|
|
266
|
+
// An IP whose hits come from >1 authenticated principal is shared (NAT/proxy): throttle, never block it.
|
|
267
|
+
if (key.startsWith('ip:') && new Set(window.map(h => h.p).filter(Boolean)).size > 1) return void this.hits.set(key, window.slice(-blockAfter));
|
|
268
|
+
this.hits.map.delete(key);
|
|
269
|
+
const principalId = principal.id;
|
|
270
|
+
const rec: BlockRecord = { until: now + blockTtlMs, reason: `auto: ${window.length} limit hits in ${Math.round(blockWindowMs / 1000)}s`, at: now };
|
|
271
|
+
this.blocks.set(key, rec);
|
|
272
|
+
this.options.log.warn(`[guard] auto-blocked ${key} until ${new Date(rec.until).toISOString()} (${rec.reason})`);
|
|
273
|
+
this.audit({ type: 'guard.block', principal: principalId, target: key, reason: rec.reason, meta: { ...meta, source: 'auto', created: true, until: rec.until } });
|
|
274
|
+
this.save();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private activeBlock(key: string, now: number): BlockRecord | undefined {
|
|
278
|
+
const b = this.blocks.map.get(key);
|
|
279
|
+
if (!b) return undefined;
|
|
280
|
+
if (b.until > now) return b;
|
|
281
|
+
this.blocks.map.delete(key);
|
|
282
|
+
this.save();
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private audit(event: AuditEvent, dedupeKey?: string): void {
|
|
287
|
+
this.safe(() => this.options.audit(event, dedupeKey), undefined);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private safe<T>(fn: () => T, fallback: T): T {
|
|
291
|
+
try { return fn(); } catch (e: any) { this.options.log.error(`[guard] hook threw: ${e?.message ?? e}`); return fallback; }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private active(): boolean { return this.safe(() => this.options.isActive(), false); }
|
|
295
|
+
|
|
296
|
+
/** Read blocks.json, dropping expired/malformed entries (persisting the prune when active). Never throws. */
|
|
297
|
+
private load(): void {
|
|
298
|
+
const p = this.options.blocksPath, now = this.options.clock();
|
|
299
|
+
if (!existsSync(p)) return;
|
|
300
|
+
let pruned = 0;
|
|
301
|
+
try {
|
|
302
|
+
const raw = JSON.parse(readFileSync(p, 'utf8'))?.blocks ?? {};
|
|
303
|
+
for (const [key, b] of Object.entries<any>(raw)) {
|
|
304
|
+
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 });
|
|
305
|
+
else pruned++;
|
|
306
|
+
}
|
|
307
|
+
} catch (e: any) { this.options.log.error(`[guard] ${p} unreadable — starting with no auto-blocks: ${e.message}`); return; }
|
|
308
|
+
if (pruned) { this.options.log.info(`[guard] pruned ${pruned} expired block(s) from ${p}`); this.save(); }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Atomic write of the active blocks. Active instance only. Never throws. */
|
|
312
|
+
private save(): void {
|
|
313
|
+
if (!this.active()) return;
|
|
314
|
+
const p = this.options.blocksPath, now = this.options.clock();
|
|
315
|
+
const blocks = Object.fromEntries([...this.blocks.map].filter(([, b]) => b.until > now));
|
|
316
|
+
try {
|
|
317
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
318
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
319
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, blocks }, null, 2) + '\n', { mode: 0o600 });
|
|
320
|
+
renameSync(tmp, p);
|
|
321
|
+
} catch (e: any) { this.options.log.error(`[guard] persisting ${p} failed: ${e.message}`); }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Write a denial to an HTTP response (status + Retry-After). */
|
|
326
|
+
export function writeDenial(res: { status(code: number): { json(body: unknown): unknown }; setHeader(k: string, v: string): unknown }, d: GuardDenial): void {
|
|
327
|
+
if (d.retryAfter) res.setHeader('Retry-After', String(d.retryAfter));
|
|
328
|
+
res.status(d.status).json({ error: d.status === 403 ? 'Forbidden' : 'Too many requests', reason: d.reason, ...(d.retryAfter ? { retryAfter: d.retryAfter } : {}) });
|
|
329
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
|
|
5
|
+
/** True if the caller is an owner; otherwise responds 403 with `message` and returns false. */
|
|
6
|
+
export function ownerOnly(req: Request, res: Response, message = 'Only an owner can do this'): boolean {
|
|
7
|
+
if ((req as any).user?.isOwner) return true;
|
|
8
|
+
res.status(403).json({ error: message });
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Middleware form of ownerOnly. */
|
|
13
|
+
export function requireOwner(message?: string): RequestHandler {
|
|
14
|
+
return (req, res, next) => { if (ownerOnly(req, res, message)) next(); };
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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. The Owner Console UI (step 7) is the consumer.
|
|
3
|
+
import { Router, type Request, type Response } from 'express';
|
|
4
|
+
import { requireAuth, type AuthUser } from '../auth.ts';
|
|
5
|
+
import { apiKeyRouter } from '../api-key-routes.ts';
|
|
6
|
+
import { requireOwner } from './owner-only.ts';
|
|
7
|
+
import { revocablePrincipalId, revokeTokens } from './revocation.ts';
|
|
8
|
+
|
|
9
|
+
const gate = [requireAuth, requireOwner()];
|
|
10
|
+
/** The raw INTERNAL_API_TOKEN's principal (auth.ts) — no issued-at, so tokensValidAfter can never reject it. */
|
|
11
|
+
const LEGACY_INTERNAL_ID = 'internal:agent-internal';
|
|
12
|
+
const userOf = (req: Request) => (req as any).user as AuthUser;
|
|
13
|
+
|
|
14
|
+
const fail = (res: Response, e: any, status = 400) => {
|
|
15
|
+
console.warn(`[owner-routes] ${e?.message ?? e}`);
|
|
16
|
+
res.status(status).json({ error: e?.message ?? String(e) });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const ownerRouter = Router();
|
|
20
|
+
|
|
21
|
+
/** Invalidate every token issued to a principal until now. Body: { principalId: "user:<email|uid>" | "internal:<uid>" }. */
|
|
22
|
+
ownerRouter.post('/api/owner/tokens/revoke', ...gate, (req, res) => {
|
|
23
|
+
const { principalId } = (req.body ?? {}) as { principalId?: unknown };
|
|
24
|
+
const id = typeof principalId === 'string' ? revocablePrincipalId(principalId) : null;
|
|
25
|
+
if (!id) {
|
|
26
|
+
return void res.status(400).json({
|
|
27
|
+
error: 'principalId must be "user:<email|uid>" or "internal:<uid>" (the token kinds revocation checks). '
|
|
28
|
+
+ 'To revoke an API key, DELETE /api/owner/api-keys/:id.',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (id === LEGACY_INTERNAL_ID) {
|
|
32
|
+
return void res.status(400).json({
|
|
33
|
+
error: `${LEGACY_INTERNAL_ID} is the legacy shared internal token, which carries no issue time — it can only be `
|
|
34
|
+
+ 'rotated by changing INTERNAL_API_TOKEN and restarting.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const validAfter = revokeTokens(id, userOf(req).principal.id);
|
|
39
|
+
res.json({ ok: true, principalId: id, validAfter });
|
|
40
|
+
} catch (e: any) { fail(res, e, 409); }
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
ownerRouter.use(apiKeyRouter({ base: '/api/owner/api-keys', gate, asOwner: true }));
|