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
package/src/server/hooks.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { HookCallback, HookCallbackMatcher, HookEvent, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
|
9
9
|
import { rewriteSlackMentions } from './slack/mention-rewrite.ts';
|
|
10
|
+
import { PROTECTED_DATA_MESSAGE, writesProtectedData } from './security/enforce.ts';
|
|
10
11
|
|
|
11
12
|
/** Patterns that indicate a long-running script the model should background. */
|
|
12
13
|
const LONG_RUNNING_PATTERNS = [
|
|
@@ -134,6 +135,23 @@ const guardFirebaseReads: HookCallback = async (input) => {
|
|
|
134
135
|
};
|
|
135
136
|
};
|
|
136
137
|
|
|
138
|
+
/** Tamper protection: no agent file tool writes server-owned data (audit, conversations, sessions, security, keys,
|
|
139
|
+
* MCP config) — any profile, SECURITY_ENFORCE on or off. A hook, not canUseTool: the SDK auto-approves allowed/
|
|
140
|
+
* accept-edits tools without calling canUseTool. See security/enforce.ts PROTECTED_DATA_WRITE. */
|
|
141
|
+
const denyProtectedDataWrites: HookCallback = async (input) => {
|
|
142
|
+
if (input.hook_event_name !== 'PreToolUse') return {};
|
|
143
|
+
const { tool_name, tool_input, cwd } = input as PreToolUseHookInput;
|
|
144
|
+
if (!writesProtectedData(tool_name, (tool_input ?? {}) as Record<string, unknown>, cwd || undefined)) return {};
|
|
145
|
+
console.log(`[hooks] Denied ${tool_name} on protected data path`);
|
|
146
|
+
return {
|
|
147
|
+
hookSpecificOutput: {
|
|
148
|
+
hookEventName: 'PreToolUse' as const,
|
|
149
|
+
permissionDecision: 'deny' as const,
|
|
150
|
+
permissionDecisionReason: PROTECTED_DATA_MESSAGE,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
137
155
|
/** Build the hooks map to pass into SDK query() options.
|
|
138
156
|
* `exemptBashCommand` is the one command the turn was explicitly told to run in the foreground
|
|
139
157
|
* (a `bash` schedule's command) — see forceBackgroundForScripts. */
|
|
@@ -143,6 +161,7 @@ export function buildHooks(opts?: { exemptBashCommand?: string }): Partial<Recor
|
|
|
143
161
|
{ matcher: 'Bash', hooks: [forceBackgroundForScripts(opts?.exemptBashCommand)] },
|
|
144
162
|
{ matcher: 'mcp__mcp-slack-use__post_slack_.*', hooks: [resolveSlackMentions] },
|
|
145
163
|
{ matcher: 'mcp__mcp-firebase-(?:prod|lab)__get_db.*', hooks: [guardFirebaseReads] },
|
|
164
|
+
{ matcher: 'Write|Edit|MultiEdit|NotebookEdit', hooks: [denyProtectedDataWrites] },
|
|
146
165
|
],
|
|
147
166
|
};
|
|
148
167
|
}
|
package/src/server/mcp-oauth.ts
CHANGED
|
@@ -6,18 +6,23 @@
|
|
|
6
6
|
* of a static `uck_` API key.
|
|
7
7
|
*
|
|
8
8
|
* Provider-agnostic by design: the only place a user proves identity is `/oauth/authorize/consent`,
|
|
9
|
-
* which is guarded by `requireAuth`
|
|
9
|
+
* which is guarded by `requireAuth` AND requires an interactive login (principal kind `user` — never an API key or
|
|
10
|
+
* internal token, which would otherwise mint themselves a login-equivalent MCP token) — the same seam that handles Firebase today and email-password
|
|
10
11
|
* later. Access/refresh tokens are stateless HMAC tokens (see auth.ts:signMcpToken). No DB:
|
|
11
12
|
* clients live in a flat JSON file, auth codes in a short-TTL in-memory map.
|
|
12
13
|
*/
|
|
13
14
|
import type { Express, Request, Response, NextFunction } from 'express';
|
|
14
15
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
15
16
|
import { randomBytes, createHash } from 'node:crypto';
|
|
16
|
-
import { requireAuth, signMcpToken, verifyMcpToken, type AuthUser } from './auth.ts';
|
|
17
|
+
import { MCP_TOKEN_TTL, requireAuth, signMcpToken, verifyMcpToken, type AuthUser } from './auth.ts';
|
|
17
18
|
import { dataPath } from './paths.ts';
|
|
19
|
+
import { security } from './security/runtime.ts';
|
|
20
|
+
import { fromAuthUser } from './security/principal.ts';
|
|
21
|
+
import { tokenRevoked } from './security/revocation.ts';
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
const
|
|
23
|
+
// Shared with auth.ts: a legacy token's implied issued-at is exp - TTL, so the TTLs must be the ones signed with.
|
|
24
|
+
const ACCESS_TTL = MCP_TOKEN_TTL.access; // 1h
|
|
25
|
+
const REFRESH_TTL = MCP_TOKEN_TTL.refresh; // 30d
|
|
21
26
|
const CODE_TTL_MS = 60_000; // 1min
|
|
22
27
|
|
|
23
28
|
const CLIENTS_PATH = () => dataPath('oauth-clients.json');
|
|
@@ -48,6 +53,8 @@ interface AuthCode {
|
|
|
48
53
|
challenge: string; // PKCE code_challenge (S256)
|
|
49
54
|
resource?: string;
|
|
50
55
|
expiresAt: number;
|
|
56
|
+
/** Epoch seconds — checked against tokensValidAfter at exchange, so a revocation also kills unexchanged codes. */
|
|
57
|
+
issuedAt: number;
|
|
51
58
|
}
|
|
52
59
|
const codes = new Map<string, AuthCode>();
|
|
53
60
|
|
|
@@ -133,6 +140,14 @@ export function registerMcpOAuthRoutes(app: Express) {
|
|
|
133
140
|
// ── Consent → issue authorization code (identity via requireAuth seam) ───────
|
|
134
141
|
app.post('/oauth/authorize/consent', oauthCors, requireAuth, (req: Request, res: Response) => {
|
|
135
142
|
const user = (req as any).user as AuthUser;
|
|
143
|
+
// Consent mints an OAuth grant AS this identity — only an interactive login may give it. An API key or internal
|
|
144
|
+
// token must not launder itself into a login-equivalent (possibly owner) MCP token.
|
|
145
|
+
const kind = user.principal?.kind ?? 'unknown';
|
|
146
|
+
if (kind !== 'user') {
|
|
147
|
+
console.warn(`[mcp-oauth] consent refused: non-interactive credential (${kind})`);
|
|
148
|
+
security()?.authDeny('oauth:consent', `non-interactive:${kind}`, req.ip);
|
|
149
|
+
return void res.status(403).json({ error: 'access_denied', error_description: 'OAuth consent requires an interactive login' });
|
|
150
|
+
}
|
|
136
151
|
const { client_id, redirect_uri, code_challenge, code_challenge_method, resource } = (req.body ?? {}) as Record<string, string>;
|
|
137
152
|
if (!client_id || !redirect_uri || !code_challenge) {
|
|
138
153
|
return void res.status(400).json({ error: 'invalid_request', error_description: 'client_id, redirect_uri, code_challenge required' });
|
|
@@ -149,7 +164,7 @@ export function registerMcpOAuthRoutes(app: Express) {
|
|
|
149
164
|
const code = randomBytes(32).toString('hex');
|
|
150
165
|
codes.set(code, {
|
|
151
166
|
uid: user.uid, email: user.email, clientId: client_id, redirectUri: redirect_uri,
|
|
152
|
-
challenge: code_challenge, resource, expiresAt: Date.now() + CODE_TTL_MS,
|
|
167
|
+
challenge: code_challenge, resource, expiresAt: Date.now() + CODE_TTL_MS, issuedAt: Math.floor(Date.now() / 1000),
|
|
153
168
|
});
|
|
154
169
|
console.log(`[mcp-oauth] issued auth code for ${user.email} → client ${client_id}`);
|
|
155
170
|
res.json({ code });
|
|
@@ -170,6 +185,10 @@ export function registerMcpOAuthRoutes(app: Express) {
|
|
|
170
185
|
if (!b.code_verifier || !pkceVerify(b.code_verifier, entry.challenge)) {
|
|
171
186
|
return void res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
|
|
172
187
|
}
|
|
188
|
+
if (tokenRevoked(fromAuthUser({ uid: entry.uid, email: entry.email }).id, entry.issuedAt)) {
|
|
189
|
+
console.warn(`[mcp-oauth] auth code for ${entry.email} predates a token revocation — refused`);
|
|
190
|
+
return void res.status(400).json({ error: 'invalid_grant', error_description: 'authorization revoked — sign in again' });
|
|
191
|
+
}
|
|
173
192
|
return void res.json({
|
|
174
193
|
access_token: signMcpToken(entry.uid, entry.email, 'access', ACCESS_TTL),
|
|
175
194
|
token_type: 'Bearer',
|
package/src/server/mcp-server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { resolve, dirname } from 'node:path';
|
|
3
3
|
import { timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
5
|
import { json } from 'itty-router';
|
|
5
6
|
import { RouterWrapper, captureMcpProgress } from 'edge.libx.js/build/main.js';
|
|
6
7
|
import type { Application, Request, Response } from 'express';
|
|
@@ -11,8 +12,10 @@ import { getAllSessions, loadConversation, isSessionLocked } from './sessions.ts
|
|
|
11
12
|
import * as scheduler from './scheduler/index.ts';
|
|
12
13
|
import { buildReport } from './downtime.ts';
|
|
13
14
|
import { getAgentConfig, MAX_TURNS_NOTICE } from './claude.ts';
|
|
14
|
-
import { validateApiKey } from './api-keys.ts';
|
|
15
|
+
import { apiKeyPrincipal, validateApiKey } from './api-keys.ts';
|
|
15
16
|
import { verifyMcpToken } from './auth.ts';
|
|
17
|
+
import { fromAuthUser, fromInternal, type Principal } from './security/principal.ts';
|
|
18
|
+
import { security, admitTurn, requestIp } from './security/runtime.ts';
|
|
16
19
|
import { makeProgressEmitter } from './mcp-progress.ts';
|
|
17
20
|
import { lookupIdempotent, rememberIdempotent } from './idempotency.ts';
|
|
18
21
|
import type { WsEvent } from './claude.ts';
|
|
@@ -31,6 +34,7 @@ export type RunChatTurn = (
|
|
|
31
34
|
userName?: string;
|
|
32
35
|
abortController?: AbortController;
|
|
33
36
|
context?: Record<string, string>;
|
|
37
|
+
principal: Principal;
|
|
34
38
|
},
|
|
35
39
|
hooks?: { onEvent?: (ev: WsEvent) => void },
|
|
36
40
|
) => Promise<RunChatTurnResult>;
|
|
@@ -39,9 +43,21 @@ export interface McpServerDeps {
|
|
|
39
43
|
runChatTurn: RunChatTurn;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
export interface McpCaller { uid: string; email: string; principal: Principal; ip?: string }
|
|
47
|
+
|
|
48
|
+
/** Identity of the legacy raw INTERNAL_API_TOKEN caller — unchanged effective identity, now an internal principal. */
|
|
49
|
+
const LEGACY_INTERNAL_CALLER: McpCaller = {
|
|
50
|
+
uid: 'agent-internal', email: 'agent@internal',
|
|
51
|
+
principal: fromInternal({ uid: 'agent-internal', email: 'agent@internal', lane: 'mcp-legacy-token' }),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Per-request caller identity. AsyncLocalStorage, not a module global: two concurrent /mcp requests
|
|
55
|
+
// (a long sync post_chat + a second caller) used to overwrite — and then null — each other's caller.
|
|
56
|
+
// The store is entered around the WHOLE /mcp handler, SSE pipe included.
|
|
57
|
+
const mcpCallerStore = new AsyncLocalStorage<McpCaller>();
|
|
58
|
+
|
|
59
|
+
/** The authenticated /mcp caller of the current request — valid anywhere in its async chain, across awaits. */
|
|
60
|
+
export function currentMcpCaller(): McpCaller | undefined { return mcpCallerStore.getStore(); }
|
|
45
61
|
|
|
46
62
|
function errMessage(e: unknown): string {
|
|
47
63
|
return e instanceof Error ? e.message : String(e);
|
|
@@ -286,7 +302,7 @@ export function createShragaMcp(deps: McpServerDeps) {
|
|
|
286
302
|
const { prompt, sessionId, sync = true, clientRequestId } = body as { prompt?: string; sessionId?: string; sync?: boolean; clientRequestId?: string };
|
|
287
303
|
if (!prompt) return json({ error: 'prompt required' }, { status: 400 });
|
|
288
304
|
|
|
289
|
-
const caller =
|
|
305
|
+
const caller = currentMcpCaller() ?? LEGACY_INTERNAL_CALLER;
|
|
290
306
|
const idemKey = clientRequestId || str(req.headers?.get?.('idempotency-key'));
|
|
291
307
|
|
|
292
308
|
// Idempotency: a retried submit with the same key reuses the session that first
|
|
@@ -299,23 +315,33 @@ export function createShragaMcp(deps: McpServerDeps) {
|
|
|
299
315
|
// Pre-allocate the session id so async callers get a real id back immediately
|
|
300
316
|
// (parity with POST /api/chat). Lets timeout-prone MCP clients recover/continue
|
|
301
317
|
// instead of double-submitting and creating duplicate sessions.
|
|
318
|
+
// Guard before any spend: blocklist → rate → concurrency (shadow unless SECURITY_ENFORCE).
|
|
319
|
+
const admission = admitTurn(caller.principal, { ip: caller.ip, channel: 'mcp' });
|
|
320
|
+
if (!admission.ok) {
|
|
321
|
+
return json({ error: admission.status === 403 ? 'Forbidden' : 'Too many requests', reason: admission.reason, retryAfter: admission.retryAfter },
|
|
322
|
+
{ status: admission.status, headers: admission.retryAfter ? { 'Retry-After': String(admission.retryAfter) } : undefined });
|
|
323
|
+
}
|
|
324
|
+
|
|
302
325
|
const sid = sessionId || `api-${crypto.randomUUID()}`;
|
|
303
326
|
if (idemKey) rememberIdempotent(caller.uid, idemKey, sid);
|
|
304
|
-
const turn = { prompt, sessionId: sid, uid: caller.uid, userEmail: caller.email, context: { source: 'mcp', user: caller.email } };
|
|
327
|
+
const turn = { prompt, sessionId: sid, uid: caller.uid, userEmail: caller.email, principal: caller.principal, context: { source: 'mcp', user: caller.email } };
|
|
305
328
|
|
|
306
329
|
if (sync === false) {
|
|
307
330
|
// Reject a duplicate before responding 'accepted' (lock is acquired inside the turn).
|
|
308
331
|
if (sessionId && isSessionLocked(sid)) {
|
|
332
|
+
admission.release();
|
|
309
333
|
return json({ error: 'Session is already processing a request' }, { status: 409 });
|
|
310
334
|
}
|
|
311
335
|
// Fire-and-forget: kick off the turn, return the real session id immediately.
|
|
312
|
-
void deps.runChatTurn(turn);
|
|
336
|
+
void deps.runChatTurn(turn).finally(admission.release);
|
|
313
337
|
return json({ sessionId: sid, status: 'accepted' });
|
|
314
338
|
}
|
|
315
339
|
|
|
316
340
|
// Capture the progress channel here (within the tools/call async context) and stream
|
|
317
341
|
// agent events as notifications/progress. No-op unless the client requested progress.
|
|
318
|
-
|
|
342
|
+
let result: Awaited<ReturnType<typeof deps.runChatTurn>>;
|
|
343
|
+
try { result = await deps.runChatTurn(turn, { onEvent: makeProgressEmitter(captureMcpProgress()) }); }
|
|
344
|
+
finally { admission.release(); }
|
|
319
345
|
if ('status' in result) return json({ error: 'Session is already processing a request' }, { status: 409 });
|
|
320
346
|
if ('error' in result) return json({ error: result.error, sessionId: result.sessionId }, { status: 500 });
|
|
321
347
|
// A turn that hit the step ceiling is a PARTIAL answer. Without this the MCP caller got the
|
|
@@ -413,31 +439,37 @@ export function mountMcpServer(app: Application, deps: McpServerDeps) {
|
|
|
413
439
|
const authHeader = req.headers.authorization?.replace('Bearer ', '');
|
|
414
440
|
const internalToken = req.headers['x-internal-token'] as string | undefined;
|
|
415
441
|
|
|
416
|
-
let
|
|
417
|
-
let
|
|
442
|
+
let caller: McpCaller | null = null;
|
|
443
|
+
let via = 'mcp';
|
|
418
444
|
if (internalToken && process.env.INTERNAL_API_TOKEN) {
|
|
419
445
|
const secret = process.env.INTERNAL_API_TOKEN;
|
|
420
|
-
|
|
421
|
-
|
|
446
|
+
if (internalToken.length === secret.length && timingSafeEqual(Buffer.from(internalToken), Buffer.from(secret))) {
|
|
447
|
+
caller = LEGACY_INTERNAL_CALLER; via = 'mcp:internal';
|
|
448
|
+
}
|
|
422
449
|
}
|
|
423
|
-
if (!
|
|
424
|
-
|
|
425
|
-
|
|
450
|
+
if (!caller && authHeader?.startsWith('uck_')) {
|
|
451
|
+
const k = validateApiKey(authHeader);
|
|
452
|
+
if (k) { caller = { uid: k.uid, email: k.email, principal: apiKeyPrincipal(k) }; via = 'mcp:apikey'; }
|
|
426
453
|
}
|
|
427
|
-
if (!
|
|
454
|
+
if (!caller && authHeader?.startsWith('mcp_')) {
|
|
428
455
|
const id = verifyMcpToken(authHeader);
|
|
429
|
-
if (id && id.kind === 'access') { caller = { uid: id.uid, email: id.email };
|
|
456
|
+
if (id && id.kind === 'access') { caller = { uid: id.uid, email: id.email, principal: fromAuthUser(id) }; via = 'mcp:oauth'; }
|
|
430
457
|
}
|
|
431
|
-
if (!
|
|
458
|
+
if (!caller) {
|
|
459
|
+
security()?.authDeny('mcp', authHeader || internalToken ? 'invalid-credential' : 'missing-credential', req.ip);
|
|
432
460
|
// Point MCP clients (claude.ai et al.) at our OAuth discovery so they can run the auth handshake.
|
|
433
461
|
const proto = (req.get('x-forwarded-proto') || req.protocol).split(',')[0];
|
|
434
462
|
const base = `${proto}://${req.get('host')}`;
|
|
435
463
|
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`);
|
|
436
464
|
return void res.status(401).json({ error: 'Unauthorized — provide API key or complete OAuth' });
|
|
437
465
|
}
|
|
438
|
-
|
|
466
|
+
security()?.authAllow(caller.principal, via);
|
|
467
|
+
return mcpCallerStore.run({ ...caller, ip: requestIp(req) }, () => bridge(req, res));
|
|
468
|
+
});
|
|
439
469
|
|
|
440
|
-
|
|
470
|
+
// Bridge Express Request → Web API Request → MCPAdapter.httpHandler → Express Response. Runs inside
|
|
471
|
+
// the caller's AsyncLocalStorage context (entered above), which the tool handlers read.
|
|
472
|
+
async function bridge(req: Request, res: Response): Promise<void> {
|
|
441
473
|
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
|
442
474
|
const webReq = new globalThis.Request(url, {
|
|
443
475
|
method: req.method,
|
|
@@ -454,8 +486,8 @@ export function mountMcpServer(app: Application, deps: McpServerDeps) {
|
|
|
454
486
|
res.status(webRes.status);
|
|
455
487
|
webRes.headers.forEach((val, key) => res.setHeader(key, val));
|
|
456
488
|
// For a Streamable-HTTP SSE response (progress streaming), pipe the body so frames flush
|
|
457
|
-
// as the agent emits them.
|
|
458
|
-
//
|
|
489
|
+
// as the agent emits them. The caller context spans the pipe (the tool handler reads it inside
|
|
490
|
+
// the stream). Otherwise buffer the single JSON response.
|
|
459
491
|
if (webRes.body && (webRes.headers.get('content-type') || '').includes('text/event-stream')) {
|
|
460
492
|
(res as any).flushHeaders?.();
|
|
461
493
|
const reader = webRes.body.getReader();
|
|
@@ -473,10 +505,8 @@ export function mountMcpServer(app: Application, deps: McpServerDeps) {
|
|
|
473
505
|
console.error('[mcp-server] handler error:', e);
|
|
474
506
|
if (!res.headersSent) res.status(500).json({ error: 'MCP handler error' });
|
|
475
507
|
else res.end();
|
|
476
|
-
} finally {
|
|
477
|
-
currentCaller = null;
|
|
478
508
|
}
|
|
479
|
-
}
|
|
509
|
+
}
|
|
480
510
|
|
|
481
511
|
console.log('[mcp-server] MCP endpoint mounted at /mcp');
|
|
482
512
|
}
|
|
@@ -3,13 +3,9 @@
|
|
|
3
3
|
import type { Express, RequestHandler, Request, Response } from 'express';
|
|
4
4
|
import { loadState, listAvailableModules, installModule, enableModule, disableModule, setModuleConfig, uninstallModule, readManifest, readModuleReadme } from './service.ts';
|
|
5
5
|
import { dataPath } from '../paths.ts';
|
|
6
|
+
import { ownerOnly as ownerGate } from '../security/owner-only.ts';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
const user = (req as any).user;
|
|
9
|
-
if (user?.isOwner) return true;
|
|
10
|
-
res.status(403).json({ error: 'Only an owner can manage modules' });
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
8
|
+
const ownerOnly = (req: Request, res: Response): boolean => ownerGate(req, res, 'Only an owner can manage modules');
|
|
13
9
|
|
|
14
10
|
export function registerModuleRoutes(app: Express, requireAuth: RequestHandler): void {
|
|
15
11
|
app.get('/api/modules', requireAuth, (_req, res) => {
|
|
@@ -10,23 +10,11 @@ import { emitEvent } from './events/bus.ts';
|
|
|
10
10
|
|
|
11
11
|
export type Owner = { name?: string; slackId: string };
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Is this email address an owner of this deployment?
|
|
20
|
-
*
|
|
21
|
-
* Exported because a medium that is not Slack cannot use `resolveOwners`: that returns Slack ids
|
|
22
|
-
* (OWNERS ∩ contacts WITH a Slack id), which is a Slack-shaped answer. A webhook-lane add-on, for
|
|
23
|
-
* instance, holds a shraga uid + the email of the API key that opened the link, so it joins on the
|
|
24
|
-
* email instead. An empty/unknown address is NOT an owner — the fail-closed direction, since the
|
|
25
|
-
* alternative is fanning a deploy report out to whoever happened to open a link. */
|
|
26
|
-
export function isOwnerEmail(email: string | undefined | null): boolean {
|
|
27
|
-
const e = String(email ?? '').trim().toLowerCase();
|
|
28
|
-
return !!e && ownerEmails().includes(e);
|
|
29
|
-
}
|
|
13
|
+
// OWNERS parsing lives in ./owners.ts (single definition). Re-exported under the historical names:
|
|
14
|
+
// `isOwnerEmail` joins media that hold an email rather than a Slack id (e.g. webhook-lane); an
|
|
15
|
+
// empty/unknown address is NOT an owner — fail-closed.
|
|
16
|
+
export { getOwners as ownerEmails, isOwnerEmail } from './owners.ts';
|
|
17
|
+
import { isOwnerEmail } from './owners.ts';
|
|
30
18
|
|
|
31
19
|
/** Owners of THIS deployment (OWNERS env ∩ contacts that have a Slack id). */
|
|
32
20
|
export async function resolveOwners(): Promise<Owner[]> {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The root of trust for "who owns this deployment": the OWNERS env var. Dependency-free on
|
|
2
|
+
// purpose — auth, notify-owners and the security policy all join on it, so it must not import any
|
|
3
|
+
// of them.
|
|
4
|
+
|
|
5
|
+
/** OWNERS="email1,email2" → lowercased, trimmed, de-duplicated list. Read per call (env may change in tests). */
|
|
6
|
+
export function getOwners(): string[] {
|
|
7
|
+
return [...new Set((process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean))];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Is this email an owner? Empty/unknown is NOT an owner (fail-closed). */
|
|
11
|
+
export function isOwnerEmail(email: string | undefined | null): boolean {
|
|
12
|
+
const e = String(email ?? '').trim().toLowerCase();
|
|
13
|
+
return !!e && getOwners().includes(e);
|
|
14
|
+
}
|
|
@@ -8,7 +8,9 @@ import type { Schedule } from './types.ts';
|
|
|
8
8
|
// which it is NOT for an npm-consumer app.
|
|
9
9
|
const SUMMARIZER_CMD = `bun run ${fileURLToPath(new URL('../../scripts/summarize-conversations.ts', import.meta.url))}`;
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// Single definition lives with the security principal (a system lane resolves to operator there).
|
|
12
|
+
import { SYSTEM_UID } from '../security/principal.ts';
|
|
13
|
+
export { SYSTEM_UID };
|
|
12
14
|
export const NIGHTLY_RECONCILE_SCHEDULE_ID = 'builtin-nightly-reconcile';
|
|
13
15
|
export const DAILY_GARDEN_SCHEDULE_ID = 'builtin-daily-garden';
|
|
14
16
|
export const HOURLY_SUMMARIZER_SCHEDULE_ID = 'builtin-conversation-summarizer';
|
|
@@ -3,6 +3,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, resolve, isAbsolute } from 'node:path';
|
|
4
4
|
import { DATA_DIR } from '../paths.ts';
|
|
5
5
|
import { streamChat, type PermissionHandler } from '../claude.ts';
|
|
6
|
+
import { fromInternal } from '../security/principal.ts';
|
|
6
7
|
import { getMcpConfig } from '../mcp.ts';
|
|
7
8
|
import { appendMessage, createScheduledSession, updateScheduledSessionStatus, setRunStatus, registerLivePartial, unregisterLivePartial, writePartial, clearPartial, acquireSessionLock, releaseSessionLock, type ConvBlock } from '../sessions.ts';
|
|
8
9
|
import type { Schedule, ScheduleRunSummary } from './types.ts';
|
|
@@ -303,6 +304,8 @@ export async function runSchedule(
|
|
|
303
304
|
toolUses.clear();
|
|
304
305
|
try {
|
|
305
306
|
for await (const ev of streamChat({
|
|
307
|
+
// No human at run time: the creator's identity, marked internal (re-resolved per run).
|
|
308
|
+
principal: fromInternal({ uid: schedule.createdBy.uid, email: schedule.createdBy.email, lane: 'scheduler' }),
|
|
306
309
|
prompt,
|
|
307
310
|
sessionId,
|
|
308
311
|
uid: schedule.createdBy.uid,
|