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.
Files changed (59) hide show
  1. package/README.md +4 -1
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/skills/mcp-server.md +12 -5
  4. package/defaults/skills/platform.md +4 -1
  5. package/dist/client/assets/index-DIDtPQb-.css +10 -0
  6. package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
  7. package/dist/client/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/client/App.tsx +33 -8
  10. package/src/client/components/BackendStatusBanner.tsx +62 -0
  11. package/src/client/components/ConfigPanel.tsx +61 -15
  12. package/src/client/components/ConversationHeader.tsx +5 -1
  13. package/src/client/components/McpManager.tsx +26 -9
  14. package/src/client/components/SkillsManager.tsx +48 -27
  15. package/src/client/hooks/useAuth.ts +11 -2
  16. package/src/client/hooks/useIsOwner.ts +24 -0
  17. package/src/client/hooks/useModules.ts +5 -1
  18. package/src/client/lib/api.ts +21 -5
  19. package/src/client/lib/backendHealth.ts +230 -0
  20. package/src/client/lib/debug.ts +48 -0
  21. package/src/client/lib/sessionApi.ts +24 -8
  22. package/src/client/lib/ws.ts +21 -13
  23. package/src/scripts/harden-audit.sh +55 -0
  24. package/src/server/api-key-routes.ts +64 -0
  25. package/src/server/api-keys.ts +181 -43
  26. package/src/server/auth.ts +113 -47
  27. package/src/server/boot.ts +158 -104
  28. package/src/server/claude.ts +112 -4
  29. package/src/server/data-sync.ts +55 -6
  30. package/src/server/directives.ts +10 -5
  31. package/src/server/engine/claude-code.ts +190 -32
  32. package/src/server/engine/claude-resume.ts +205 -0
  33. package/src/server/engine/types.ts +10 -0
  34. package/src/server/hooks.ts +19 -0
  35. package/src/server/mcp-oauth.ts +24 -5
  36. package/src/server/mcp-server.ts +55 -25
  37. package/src/server/modules/routes.ts +2 -6
  38. package/src/server/notify-owners.ts +5 -17
  39. package/src/server/owners.ts +14 -0
  40. package/src/server/scheduler/builtins.ts +3 -1
  41. package/src/server/scheduler/runner.ts +3 -0
  42. package/src/server/security/audit.ts +498 -0
  43. package/src/server/security/enforce.ts +306 -0
  44. package/src/server/security/escalate.ts +194 -0
  45. package/src/server/security/guard.ts +329 -0
  46. package/src/server/security/owner-only.ts +15 -0
  47. package/src/server/security/owner-routes.ts +43 -0
  48. package/src/server/security/policy.ts +413 -0
  49. package/src/server/security/principal.ts +80 -0
  50. package/src/server/security/revocation.ts +50 -0
  51. package/src/server/security/runtime.ts +174 -0
  52. package/src/server/sessions.ts +51 -9
  53. package/src/server/shraga-config.ts +3 -0
  54. package/src/server/slack/bot.ts +44 -11
  55. package/src/server/slack/context-cache.ts +40 -7
  56. package/src/server/webhook-lane/feature.ts +17 -6
  57. package/src/shared/models.ts +11 -0
  58. package/dist/client/assets/index-BNAh4GUs.js +0 -1949
  59. package/dist/client/assets/index-DIMte_k6.css +0 -10
@@ -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
  }
@@ -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` — the same seam that handles Firebase today and email-password
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
- const ACCESS_TTL = 3600; // 1h
20
- const REFRESH_TTL = 60 * 60 * 24 * 30; // 30d
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',
@@ -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
- // Per-request caller identity, set by the /mcp Express handler before forwarding to MCP tool handlers.
43
- // Safe because Node is single-threaded and the handler awaits the full MCP response.
44
- let currentCaller: { uid: string; email: string } | null = null;
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 = currentCaller || { uid: 'agent-internal', email: 'agent@internal' };
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
- const result = await deps.runChatTurn(turn, { onEvent: makeProgressEmitter(captureMcpProgress()) });
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 authed = false;
417
- let caller: { uid: string; email: string } | null = null;
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
- authed = internalToken.length === secret.length &&
421
- timingSafeEqual(Buffer.from(internalToken), Buffer.from(secret));
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 (!authed && authHeader?.startsWith('uck_')) {
424
- caller = validateApiKey(authHeader);
425
- authed = !!caller;
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 (!authed && authHeader?.startsWith('mcp_')) {
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 }; authed = true; }
456
+ if (id && id.kind === 'access') { caller = { uid: id.uid, email: id.email, principal: fromAuthUser(id) }; via = 'mcp:oauth'; }
430
457
  }
431
- if (!authed) {
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
- currentCaller = caller;
466
+ security()?.authAllow(caller.principal, via);
467
+ return mcpCallerStore.run({ ...caller, ip: requestIp(req) }, () => bridge(req, res));
468
+ });
439
469
 
440
- // Bridge Express Request → Web API Request → MCPAdapter.httpHandler → Express Response.
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. currentCaller stays set until the pipe completes (the tool
458
- // handler reads it inside the stream). Otherwise buffer the single JSON response.
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
- function ownerOnly(req: Request, res: Response): boolean {
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
- /** The OWNERS env list, lowercased. THE definition of "an owner of this deployment" — every
14
- * medium joins on it, each through whatever identity it happens to hold. */
15
- export function ownerEmails(): string[] {
16
- return (process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
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
- export const SYSTEM_UID = '__system__';
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,