shraga 0.1.112 → 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 (57) hide show
  1. package/README.md +2 -0
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/skills/mcp-server.md +12 -5
  4. package/defaults/skills/platform.md +1 -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 +98 -3
  29. package/src/server/data-sync.ts +55 -6
  30. package/src/server/directives.ts +2 -4
  31. package/src/server/engine/claude-code.ts +44 -14
  32. package/src/server/engine/types.ts +7 -0
  33. package/src/server/hooks.ts +19 -0
  34. package/src/server/mcp-oauth.ts +24 -5
  35. package/src/server/mcp-server.ts +55 -25
  36. package/src/server/modules/routes.ts +2 -6
  37. package/src/server/notify-owners.ts +5 -17
  38. package/src/server/owners.ts +14 -0
  39. package/src/server/scheduler/builtins.ts +3 -1
  40. package/src/server/scheduler/runner.ts +3 -0
  41. package/src/server/security/audit.ts +498 -0
  42. package/src/server/security/enforce.ts +306 -0
  43. package/src/server/security/escalate.ts +194 -0
  44. package/src/server/security/guard.ts +329 -0
  45. package/src/server/security/owner-only.ts +15 -0
  46. package/src/server/security/owner-routes.ts +43 -0
  47. package/src/server/security/policy.ts +413 -0
  48. package/src/server/security/principal.ts +80 -0
  49. package/src/server/security/revocation.ts +50 -0
  50. package/src/server/security/runtime.ts +174 -0
  51. package/src/server/sessions.ts +35 -0
  52. package/src/server/slack/bot.ts +44 -11
  53. package/src/server/slack/context-cache.ts +40 -7
  54. package/src/server/webhook-lane/feature.ts +17 -6
  55. package/src/shared/models.ts +11 -0
  56. package/dist/client/assets/index-DIMte_k6.css +0 -10
  57. package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
@@ -51,8 +51,13 @@ import { startEventDispatcher } from './events/dispatcher.ts';
51
51
  import { seedOperators } from './contacts.ts';
52
52
  import { dataSync } from './data-sync.ts';
53
53
  import { mountMcpServer } from './mcp-server.ts';
54
+ import { fromInternal, type Principal } from './security/principal.ts';
55
+ import { initSecurity, security, admitTurn, requestIp } from './security/runtime.ts';
56
+ import { writeDenial } from './security/guard.ts';
57
+ import { requireOwner } from './security/owner-only.ts';
58
+ import { flushEscalations } from './security/escalate.ts';
54
59
  import { lookupIdempotent, rememberIdempotent } from './idempotency.ts';
55
- import { createApiKey, deleteApiKey, listApiKeys } from './api-keys.ts';
60
+ import { apiKeyRouter } from './api-key-routes.ts';
56
61
  import { addUnread, markRead as markUnread, getUnreads } from './unread.ts';
57
62
 
58
63
  import { loadShragaConfig, getPublicOrigin } from './shraga-config.ts';
@@ -66,6 +71,7 @@ import { claudeAccountDir } from './claude-account.ts';
66
71
  import { ClaudeLogin, ClaudeLoginError } from './claude-login.ts';
67
72
  import { getAll as getAllContacts } from './contacts.ts';
68
73
  import { artifactsRouter } from './artifacts/artifacts.routes.ts';
74
+ import { ownerRouter } from './security/owner-routes.ts';
69
75
  import { handleArtifactToolUse } from './artifacts/artifacts.handler.ts';
70
76
  import { registerEngine } from './engine/index.ts';
71
77
  import { subscribeEvent } from './events/bus.ts';
@@ -114,6 +120,8 @@ export async function bootServer(__reg: BootRegistrations = {}): Promise<ServerH
114
120
  const PASSIVE_FLAG = process.env.SHRAGA_PASSIVE ?? process.env.UNCLAW_PASSIVE;
115
121
  const PASSIVE = PASSIVE_FLAG === '1' || PASSIVE_FLAG === 'true';
116
122
  if (PASSIVE) console.log('[server] PASSIVE mode — schedulers, consumers and background writers disabled');
123
+ // Flipped by activateConsumers() (blue-green promotion). Declared up here: initSecurity reads it synchronously.
124
+ let activated = !PASSIVE;
117
125
 
118
126
  // Local-only prep (git config, gitignore, untracking). The NETWORK half — fetch/merge — is
119
127
  // deliberately NOT awaited here; it runs via dataSync.syncOnBoot() AFTER listen(). See syncOnBoot().
@@ -125,6 +133,9 @@ function bootDataSync(): void {
125
133
  dataSync.syncOnBoot().catch(err => console.error('[data-sync] boot sync error:', (err as Error).message));
126
134
  }
127
135
  await loadShragaConfig();
136
+ // Process-wide policy + audit (shadow mode: decide + audit, never deny). Audit writes only on the
137
+ // active instance — a PASSIVE standby shares DATA_DIR. `activated` is declared above.
138
+ initSecurity({ isActive: () => !PASSIVE || activated });
128
139
  // Programmatic engines register through the same seam an overlay uses — BEFORE initEngines() so
129
140
  // getAvailableEngines() includes them and a directive can resolve to one immediately.
130
141
  for (const e of __reg.engines ?? []) registerEngine(e);
@@ -175,7 +186,18 @@ app.use(express.urlencoded({
175
186
  // drown real request logs. Errors and everything else still logs unconditionally.
176
187
  const QUIET_POLL_RE = /\/(cwd|ptys|sessions|workspace\/(pty-owners|layout))(\?|$)/;
177
188
 
189
+ /** Package version, read once. Served as the `X-Shraga-Version` identity header on EVERY response
190
+ * (see the middleware below) — a response WITHOUT it proves the responder is not this server, which
191
+ * is what lets the client tell "shraga 404'd" apart from "something else owns this port". */
192
+ const SHRAGA_VERSION: string = (() => {
193
+ try {
194
+ return JSON.parse(readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8')).version ?? 'unknown';
195
+ } catch { return 'unknown'; }
196
+ })();
197
+
178
198
  app.use((req, _res, next) => {
199
+ // Set BEFORE any route runs, so it rides on error responses (404/401/5xx) too — not just successes.
200
+ _res.setHeader('X-Shraga-Version', SHRAGA_VERSION);
179
201
  const start = Date.now();
180
202
  const orig = _res.end.bind(_res);
181
203
  (_res as any).end = (...args: any[]) => {
@@ -193,10 +215,7 @@ const SERVER_BUILD_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}
193
215
  // ── REST routes ───────────────────────────────────────────────────────────────
194
216
 
195
217
  app.get('/api/version', (_req, res) => {
196
- try {
197
- const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8'));
198
- res.json({ version: pkg.version });
199
- } catch { res.json({ version: 'unknown' }); }
218
+ res.json({ version: SHRAGA_VERSION });
200
219
  });
201
220
 
202
221
  // ── Self-upgrade (owner only) ────────────────────────────────────────────────
@@ -426,7 +445,8 @@ app.get('/api/mcps', requireAuth, (req, res) => {
426
445
  res.json(entries);
427
446
  });
428
447
 
429
- app.put('/api/mcps', requireAuth, (req, res) => {
448
+ // Owner-only: an MCP entry can carry a stdio `command` the server spawns.
449
+ app.put('/api/mcps', requireAuth, requireOwner('Only an owner can change MCP servers'), (req, res) => {
430
450
  const user = (req as any).user;
431
451
  const globalNames = new Set(Object.keys(getGlobalMcpConfig()));
432
452
  const incoming = req.body as McpConfig;
@@ -440,14 +460,15 @@ app.put('/api/mcps', requireAuth, (req, res) => {
440
460
  res.json({ ok: true });
441
461
  });
442
462
 
443
- app.get('/api/config', requireAuth, (_req, res) => {
463
+ app.get('/api/config', requireAuth, (req, res) => {
444
464
  // `claudeAuthSource` is derived server state (not persisted config) — the spread always overrides
445
465
  // any stale value, so it can never round-trip into agent-config.json even if a client echoes it back.
446
- res.json({ ...getAgentConfig(), claudeAuthSource: getClaudeAuthSource() });
466
+ // `isOwner` is the same kind of derived field: the client hides owner-only controls on it (PUT strips it).
467
+ res.json({ ...getAgentConfig(), isOwner: !!(req as any).user?.isOwner, claudeAuthSource: getClaudeAuthSource() });
447
468
  });
448
469
 
449
- app.put('/api/config', requireAuth, (req, res) => {
450
- const { claudeAuthSource: _drop, ...config } = (req.body ?? {}) as AgentConfig & { claudeAuthSource?: string };
470
+ app.put('/api/config', requireAuth, requireOwner('Only an owner can change the agent config'), (req, res) => {
471
+ const { claudeAuthSource: _drop, isOwner: _dropOwner, ...config } = (req.body ?? {}) as AgentConfig & { claudeAuthSource?: string; isOwner?: boolean };
451
472
  saveAgentConfig(config);
452
473
  res.json({ ok: true });
453
474
  });
@@ -472,7 +493,7 @@ app.get('/api/skills/:name', requireAuth, (req, res) => {
472
493
  res.json(skill);
473
494
  });
474
495
 
475
- app.put('/api/skills/:name', requireAuth, (req, res) => {
496
+ app.put('/api/skills/:name', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
476
497
  try {
477
498
  const { content } = req.body as { content: string };
478
499
  saveSkill(String(req.params.name), content ?? '');
@@ -480,14 +501,14 @@ app.put('/api/skills/:name', requireAuth, (req, res) => {
480
501
  } catch (e: any) { res.status(400).json({ error: e.message }); }
481
502
  });
482
503
 
483
- app.delete('/api/skills/:name', requireAuth, (req, res) => {
504
+ app.delete('/api/skills/:name', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
484
505
  try {
485
506
  deleteSkill(String(req.params.name));
486
507
  res.json({ ok: true });
487
508
  } catch (e: any) { res.status(400).json({ error: e.message }); }
488
509
  });
489
510
 
490
- app.post('/api/skills/:name/duplicate', requireAuth, (req, res) => {
511
+ app.post('/api/skills/:name/duplicate', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
491
512
  try {
492
513
  const { newName } = req.body as { newName: string };
493
514
  const skill = duplicateSkill(String(req.params.name), newName);
@@ -495,7 +516,7 @@ app.post('/api/skills/:name/duplicate', requireAuth, (req, res) => {
495
516
  } catch (e: any) { res.status(400).json({ error: e.message }); }
496
517
  });
497
518
 
498
- app.post('/api/skills/:name/rename', requireAuth, (req, res) => {
519
+ app.post('/api/skills/:name/rename', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
499
520
  try {
500
521
  const { newName } = req.body as { newName: string };
501
522
  renameSkill(String(req.params.name), newName);
@@ -507,7 +528,7 @@ app.get('/api/skills-defaults', requireAuth, (_req, res) => {
507
528
  res.json(getDefaultSkills());
508
529
  });
509
530
 
510
- app.put('/api/skills-defaults', requireAuth, (req, res) => {
531
+ app.put('/api/skills-defaults', requireAuth, requireOwner('Only an owner can change skills'), (req, res) => {
511
532
  setDefaultSkills(req.body);
512
533
  res.json({ ok: true });
513
534
  });
@@ -669,6 +690,7 @@ async function runChatTurn(
669
690
  userName?: string;
670
691
  abortController?: AbortController;
671
692
  context?: Record<string, string>;
693
+ principal: Principal;
672
694
  },
673
695
  hooks?: { onEvent?: (ev: WsEvent) => void },
674
696
  ): Promise<RunChatTurnResult> {
@@ -695,6 +717,7 @@ async function runChatTurn(
695
717
 
696
718
  try {
697
719
  const blocks = await consumeStream(streamChat({
720
+ principal: opts.principal,
698
721
  prompt,
699
722
  sessionId: sid,
700
723
  uid,
@@ -748,17 +771,26 @@ app.post('/api/chat', requireAuth, async (req, res) => {
748
771
  if (existing) return void res.json({ sessionId: existing, status: 'duplicate' });
749
772
  }
750
773
 
774
+ // Guard before any spend: blocklist → rate → concurrency (shadow unless SECURITY_ENFORCE).
775
+ const admission = admitTurn(user.principal, { ip: requestIp(req), channel: 'api' });
776
+ if (!admission.ok) return void writeDenial(res, admission);
777
+
751
778
  const sid = reqSid || `api-${crypto.randomUUID()}`;
752
779
  if (idemKey) rememberIdempotent(user.uid, idemKey, sid);
753
780
  const apiAbortController = new AbortController();
754
- const run = () => runChatTurn({
755
- prompt,
756
- sessionId: sid,
757
- uid: user.uid,
758
- userEmail: user.email,
759
- abortController: apiAbortController,
760
- context: { source: 'api', user: user.email },
761
- });
781
+ const run = async () => {
782
+ try {
783
+ return await runChatTurn({
784
+ prompt,
785
+ sessionId: sid,
786
+ uid: user.uid,
787
+ userEmail: user.email,
788
+ principal: user.principal,
789
+ abortController: apiAbortController,
790
+ context: { source: 'api', user: user.email },
791
+ });
792
+ } finally { admission.release(); }
793
+ };
762
794
 
763
795
  if (sync) {
764
796
  const result = await run();
@@ -768,6 +800,7 @@ app.post('/api/chat', requireAuth, async (req, res) => {
768
800
  } else {
769
801
  // Reject a duplicate before responding 'accepted' (lock is acquired inside run()).
770
802
  if (reqSid && isSessionLocked(sid)) {
803
+ admission.release();
771
804
  return void res.status(409).json({ error: 'Session is already processing a request' });
772
805
  }
773
806
  res.json({ sessionId: sid, status: 'accepted' });
@@ -842,6 +875,7 @@ app.get('/api/data-sync/log', requireAuth, async (_req, res) => {
842
875
  });
843
876
 
844
877
  app.use(artifactsRouter);
878
+ app.use(ownerRouter);
845
879
 
846
880
  // Runtime feature flags for the client (env-gated, never persisted to agent-config.json).
847
881
  // ── Auth mode + local login (PUBLIC — no requireAuth) ────────────────────────
@@ -904,22 +938,7 @@ app.post('/api/push/unregister', requireAuth, (req, res) => {
904
938
  });
905
939
 
906
940
  // ── API Keys ──────────────────────────────────────────────────────────────────
907
- app.get('/api/api-keys', requireAuth, (req, res) => {
908
- res.json({ keys: listApiKeys() });
909
- });
910
- app.post('/api/api-keys', requireAuth, (req, res) => {
911
- const user = (req as any).user as import('./auth.ts').AuthUser;
912
- const { label } = req.body as { label?: string };
913
- const key = createApiKey(user.uid, user.email, label || 'Unnamed');
914
- res.json(key);
915
- });
916
- app.delete('/api/api-keys/:id', requireAuth, (req: express.Request<{ id: string }>, res) => {
917
- const user = (req as any).user as import('./auth.ts').AuthUser;
918
- const ok = deleteApiKey(req.params.id, user.uid, user.isOwner);
919
- if (ok === 'not_found') return void res.status(404).json({ error: 'Key not found' });
920
- if (ok === 'forbidden') return void res.status(403).json({ error: 'Cannot delete another user\'s key' });
921
- res.json({ ok: true });
922
- });
941
+ app.use(apiKeyRouter({ base: '/api/api-keys', gate: [requireAuth] }));
923
942
 
924
943
  // ── MCP Server ────────────────────────────────────────────────────────────────
925
944
  mountMcpServer(app, { runChatTurn });
@@ -953,6 +972,10 @@ const wss = new WebSocketServer({ noServer: true });
953
972
  interface WsSession {
954
973
  uid: string;
955
974
  email: string;
975
+ /** Set on ws auth; undefined until then. */
976
+ principal?: Principal;
977
+ /** Client IP from the upgrade request (TRUSTED_PROXIES-aware). */
978
+ ip?: string;
956
979
  busySessions: Set<string>;
957
980
  autoApprove: boolean;
958
981
  abortControllers: Map<string, AbortController>;
@@ -1200,7 +1223,7 @@ function proxySidecarWebSocket(req: import('node:http').IncomingMessage, socket:
1200
1223
  server.on('upgrade', (req, socket, head) => {
1201
1224
  if (req.url === '/ws') {
1202
1225
  wss.handleUpgrade(req, socket as any, head, (ws) => {
1203
- const session: WsSession = { uid: '', email: '', busySessions: new Set(), autoApprove: false, abortControllers: new Map(), pendingPermissions: new Map(), pendingQuestions: new Map(), steerPending: new Map(), lastSessionId: null, viewingSessionId: null, focused: true };
1226
+ const session: WsSession = { uid: '', email: '', ip: requestIp(req), busySessions: new Set(), autoApprove: false, abortControllers: new Map(), pendingPermissions: new Map(), pendingQuestions: new Map(), steerPending: new Map(), lastSessionId: null, viewingSessionId: null, focused: true };
1204
1227
  handleConnection(ws, session);
1205
1228
  });
1206
1229
  } else {
@@ -1325,7 +1348,7 @@ initPolls({
1325
1348
  // NOTHING until it ended — and an add-on engine's subagent events were dropped outright — so a
1326
1349
  // turn that dispatched a worker was indistinguishable from one that only claimed to.
1327
1350
  return await consumeStream(
1328
- streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }),
1351
+ streamChat({ principal: fromInternal({ uid, email: userEmail, lane: 'wake' }), prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }),
1329
1352
  undefined,
1330
1353
  {
1331
1354
  maxResultChars: STREAMED_RESULT_MAX,
@@ -1381,10 +1404,10 @@ registerSpaCatchAll(app, distPath);
1381
1404
  // ── Runtime promotion (blue-green flip) ──────────────────────────────────────
1382
1405
  // A passive instance can be promoted to active once traffic has been flipped to it:
1383
1406
  // starts every consumer/writer that passive boot skipped. One-way; idempotent-guarded.
1384
- let activated = !PASSIVE;
1385
1407
  async function activateConsumers() {
1386
1408
  activated = true;
1387
1409
  console.log('[server] ACTIVATING — starting consumers and background writers');
1410
+ security()?.activate(); // passive Policy never wrote; now re-load from disk as trusted (migrates if still missing)
1388
1411
  await dataSync.init();
1389
1412
  bootDataSync();
1390
1413
  syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
@@ -1550,6 +1573,7 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
1550
1573
  try {
1551
1574
  let eventCount = 0;
1552
1575
  for await (const event of streamChat({
1576
+ principal: session.principal ?? fromInternal({ uid: session.uid, email: session.email, lane: 'ws-unresolved' }),
1553
1577
  prompt: promptText,
1554
1578
  attachments,
1555
1579
  sessionId: sid,
@@ -1748,6 +1772,8 @@ function handleConnection(ws: WebSocket, session: WsSession) {
1748
1772
  const user = await verifyBearer(msg.token); // pluggable (local|firebase), not firebase-only
1749
1773
  session.uid = user.uid;
1750
1774
  session.email = user.email;
1775
+ session.principal = user.principal;
1776
+ security()?.authAllow(user.principal, 'ws:bearer');
1751
1777
  session.autoApprove = getAutoApprove(user.uid);
1752
1778
  console.log(`[ws] Authenticated: ${user.email} (${user.uid}) autoApprove=${session.autoApprove}`);
1753
1779
  send(ws, { type: 'auth_ok', uid: user.uid, email: user.email, buildId: SERVER_BUILD_ID });
@@ -1763,6 +1789,7 @@ function handleConnection(ws: WebSocket, session: WsSession) {
1763
1789
  }
1764
1790
  }
1765
1791
  } catch (err: any) {
1792
+ security()?.authDeny('ws:bearer', 'invalid-bearer');
1766
1793
  console.error(`[ws] Auth failed:`, err.message);
1767
1794
  send(ws, { type: 'auth_error', message: err.message });
1768
1795
  ws.close();
@@ -1836,12 +1863,21 @@ function handleConnection(ws: WebSocket, session: WsSession) {
1836
1863
  session.steerPending.set(steerSid, steerText);
1837
1864
  ac.abort();
1838
1865
  } else {
1839
- ac.abort();
1840
- appendMessage(steerSid, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: steerText }], channel: 'web', senderName: session.email.split('@')[0] });
1841
- console.log(`[ws] External steer takeover for ${steerSid.slice(0, 8)}`);
1842
- session.busySessions.add(steerSid);
1843
- session.lastSessionId = steerSid;
1844
- runStream(ws, session, steerSid, steerText, undefined, getMcpConfig(session.uid), true);
1866
+ // A takeover starts a NEW turn on this connection — same guard as a normal WS turn, checked BEFORE the abort
1867
+ // so a denied steer leaves the running turn alone.
1868
+ const admission = admitTurn(session.principal ?? fromInternal({ uid: session.uid, email: session.email, lane: 'ws-unresolved' }), { ip: session.ip, channel: 'ws' });
1869
+ if (!admission.ok) {
1870
+ return send(ws, { type: 'error', sessionId: steerSid, reason: admission.reason, retryAfter: admission.retryAfter,
1871
+ message: admission.status === 403 ? 'Access blocked' : `Too many requests — try again in ${admission.retryAfter ?? 60}s` });
1872
+ }
1873
+ try {
1874
+ ac.abort();
1875
+ appendMessage(steerSid, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: steerText }], channel: 'web', senderName: session.email.split('@')[0] });
1876
+ console.log(`[ws] External steer takeover for ${steerSid.slice(0, 8)}`);
1877
+ session.busySessions.add(steerSid);
1878
+ session.lastSessionId = steerSid;
1879
+ await runStream(ws, session, steerSid, steerText, undefined, getMcpConfig(session.uid), true);
1880
+ } finally { admission.release(); }
1845
1881
  }
1846
1882
  return;
1847
1883
  }
@@ -1851,68 +1887,76 @@ function handleConnection(ws: WebSocket, session: WsSession) {
1851
1887
  let sid = msg.sessionId || crypto.randomUUID();
1852
1888
  const wantsFork = typeof msg.truncateAt === 'number' && msg.truncateAt >= 0 && (session.busySessions.has(sid) || isSessionLocked(sid));
1853
1889
  if (!wantsFork && (session.busySessions.has(sid) || isSessionLocked(sid))) return send(ws, { type: 'error', message: 'Already processing a request', sessionId: sid });
1854
- if (!wantsFork) session.busySessions.add(sid);
1855
- const mcpServers = getMcpConfig(session.uid);
1856
- // Opaque per-send bag from the client's send-options slot. The core forwards it verbatim to the
1857
- // turn-context/engine seams and interprets no add-on key (a plain object only — never an array/primitive).
1858
- const turnHints = msg.turnHints && typeof msg.turnHints === 'object' && !Array.isArray(msg.turnHints)
1859
- ? (msg.turnHints as Record<string, unknown>) : undefined;
1860
- // Ephemeral user turn (generic hint): a synthetic, add-on-originated opener (e.g. a voice greeting).
1861
- // Run it, but DON'T persist it as a user message — the reply is the real turn, not a user turn.
1862
- const ephemeralUser = turnHints?.ephemeralUser === true;
1863
- const promptText = msg.text ?? '';
1864
- session.lastSessionId = sid;
1865
- session.viewingSessionId = sid;
1866
- session.focused = true;
1867
- const isNew = !msg.sessionId;
1868
- if (isNew) {
1869
- upsertSession(sid, promptText, { uid: session.uid, email: session.email });
1870
- send(ws, { type: 'session_id', sessionId: sid });
1871
- // Immediate prompt-derived title so the tab renames off "New Chat" now; the LLM title refines it later.
1872
- const t0 = getSession(sid)?.title;
1873
- if (t0) send(ws, { type: 'session_title_updated', sessionId: sid, title: t0 });
1874
- console.log(`[ws] New session ${sid.slice(0, 8)} for ${session.email}`);
1890
+ // Guard before any spend: blocklist → rate → concurrency (shadow unless SECURITY_ENFORCE).
1891
+ const admission = admitTurn(session.principal ?? fromInternal({ uid: session.uid, email: session.email, lane: 'ws-unresolved' }), { ip: session.ip, channel: 'ws' });
1892
+ if (!admission.ok) {
1893
+ return send(ws, { type: 'error', sessionId: sid, reason: admission.reason, retryAfter: admission.retryAfter,
1894
+ message: admission.status === 403 ? 'Access blocked' : `Too many requests — try again in ${admission.retryAfter ?? 60}s` });
1875
1895
  }
1896
+ try {
1897
+ if (!wantsFork) session.busySessions.add(sid);
1898
+ const mcpServers = getMcpConfig(session.uid);
1899
+ // Opaque per-send bag from the client's send-options slot. The core forwards it verbatim to the
1900
+ // turn-context/engine seams and interprets no add-on key (a plain object only — never an array/primitive).
1901
+ const turnHints = msg.turnHints && typeof msg.turnHints === 'object' && !Array.isArray(msg.turnHints)
1902
+ ? (msg.turnHints as Record<string, unknown>) : undefined;
1903
+ // Ephemeral user turn (generic hint): a synthetic, add-on-originated opener (e.g. a voice greeting).
1904
+ // Run it, but DON'T persist it as a user message — the reply is the real turn, not a user turn.
1905
+ const ephemeralUser = turnHints?.ephemeralUser === true;
1906
+ const promptText = msg.text ?? '';
1907
+ session.lastSessionId = sid;
1908
+ session.viewingSessionId = sid;
1909
+ session.focused = true;
1910
+ const isNew = !msg.sessionId;
1911
+ if (isNew) {
1912
+ upsertSession(sid, promptText, { uid: session.uid, email: session.email });
1913
+ send(ws, { type: 'session_id', sessionId: sid });
1914
+ // Immediate prompt-derived title so the tab renames off "New Chat" now; the LLM title refines it later.
1915
+ const t0 = getSession(sid)?.title;
1916
+ if (t0) send(ws, { type: 'session_title_updated', sessionId: sid, title: t0 });
1917
+ console.log(`[ws] New session ${sid.slice(0, 8)} for ${session.email}`);
1918
+ }
1876
1919
 
1877
- console.log(`[ws] Message from ${session.email}: "${promptText.slice(0, 100)}" session=${sid.slice(0, 8)}`);
1920
+ console.log(`[ws] Message from ${session.email}: "${promptText.slice(0, 100)}" session=${sid.slice(0, 8)}`);
1878
1921
 
1879
- // Truncate or fork conversation if replaying/editing a previous message
1880
- if (typeof msg.truncateAt === 'number' && msg.truncateAt >= 0) {
1881
- if (wantsFork) {
1882
- // Session is busy — fork instead of destructive truncate to avoid race conditions
1883
- let forkedId: string | null = null;
1884
- if (msg.truncateAt > 0) {
1885
- // forkSession truncateAtIndex is inclusive (slices to index+1), truncateAt is message count to keep
1886
- forkedId = forkSession(sid, { uid: session.uid, email: session.email, name: session.email.split('@')[0] }, msg.truncateAt - 1);
1887
- }
1888
- if (!forkedId) {
1889
- // truncateAt=0 (restart from scratch) or forkSession failed — create a fresh session
1890
- forkedId = crypto.randomUUID();
1891
- upsertSession(forkedId, promptText, { uid: session.uid, email: session.email });
1922
+ // Truncate or fork conversation if replaying/editing a previous message
1923
+ if (typeof msg.truncateAt === 'number' && msg.truncateAt >= 0) {
1924
+ if (wantsFork) {
1925
+ // Session is busy — fork instead of destructive truncate to avoid race conditions
1926
+ let forkedId: string | null = null;
1927
+ if (msg.truncateAt > 0) {
1928
+ // forkSession truncateAtIndex is inclusive (slices to index+1), truncateAt is message count to keep
1929
+ forkedId = forkSession(sid, { uid: session.uid, email: session.email, name: session.email.split('@')[0] }, msg.truncateAt - 1);
1930
+ }
1931
+ if (!forkedId) {
1932
+ // truncateAt=0 (restart from scratch) or forkSession failed — create a fresh session
1933
+ forkedId = crypto.randomUUID();
1934
+ upsertSession(forkedId, promptText, { uid: session.uid, email: session.email });
1935
+ }
1936
+ console.log(`[ws] Forked busy session ${sid.slice(0, 8)} → ${forkedId.slice(0, 8)} (truncateAt=${msg.truncateAt})`);
1937
+ session.busySessions.add(forkedId);
1938
+ sid = forkedId;
1939
+ session.lastSessionId = forkedId;
1940
+ send(ws, { type: 'forked', sourceSessionId: msg.sessionId, sessionId: forkedId });
1941
+ } else {
1942
+ const existing = loadConversation(sid);
1943
+ saveConversation(sid, existing.slice(0, msg.truncateAt));
1944
+ console.log(`[ws] Truncated conversation ${sid.slice(0, 8)} to ${msg.truncateAt} messages`);
1892
1945
  }
1893
- console.log(`[ws] Forked busy session ${sid.slice(0, 8)} → ${forkedId.slice(0, 8)} (truncateAt=${msg.truncateAt})`);
1894
- session.busySessions.add(forkedId);
1895
- sid = forkedId;
1896
- session.lastSessionId = forkedId;
1897
- send(ws, { type: 'forked', sourceSessionId: msg.sessionId, sessionId: forkedId });
1898
- } else {
1899
- const existing = loadConversation(sid);
1900
- saveConversation(sid, existing.slice(0, msg.truncateAt));
1901
- console.log(`[ws] Truncated conversation ${sid.slice(0, 8)} to ${msg.truncateAt} messages`);
1902
1946
  }
1903
- }
1904
1947
 
1905
- // Save user message to disk immediately
1906
- const attachments: AttachmentMeta[] = msg.attachments ?? [];
1907
- const attBlocks: ConvBlock[] = attachments.map((a: any) =>
1908
- a.mimeType.startsWith('image/')
1909
- ? { type: 'image' as const, src: a.url }
1910
- : { type: 'file' as const, src: a.url, name: a.name, mimeType: a.mimeType }
1911
- );
1912
- if (!ephemeralUser) appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [...attBlocks, { type: 'text', text: promptText }], channel: 'web', senderName: session.email.split('@')[0] });
1913
-
1914
- const wasReset = typeof msg.truncateAt === 'number' && msg.truncateAt >= 0;
1915
- await runStream(ws, session, sid, promptText, attachments, mcpServers, false, wasReset, turnHints);
1948
+ // Save user message to disk immediately
1949
+ const attachments: AttachmentMeta[] = msg.attachments ?? [];
1950
+ const attBlocks: ConvBlock[] = attachments.map((a: any) =>
1951
+ a.mimeType.startsWith('image/')
1952
+ ? { type: 'image' as const, src: a.url }
1953
+ : { type: 'file' as const, src: a.url, name: a.name, mimeType: a.mimeType }
1954
+ );
1955
+ if (!ephemeralUser) appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [...attBlocks, { type: 'text', text: promptText }], channel: 'web', senderName: session.email.split('@')[0] });
1956
+
1957
+ const wasReset = typeof msg.truncateAt === 'number' && msg.truncateAt >= 0;
1958
+ await runStream(ws, session, sid, promptText, attachments, mcpServers, false, wasReset, turnHints);
1959
+ } finally { admission.release(); }
1916
1960
  }
1917
1961
 
1918
1962
  if (msg.type === 'interruption_marker') {
@@ -2000,6 +2044,8 @@ async function retryWebSession(session: SessionMeta, prompt: string) {
2000
2044
  ];
2001
2045
  registerLivePartial(sid, collectPartial);
2002
2046
  for await (const ev of streamChat({
2047
+ // Recovery of an interrupted web turn: no live human, the session's owner marked internal.
2048
+ principal: fromInternal({ uid: session.uid, email: session.userEmail, lane: 'web-retry' }),
2003
2049
  prompt,
2004
2050
  sessionId: sid,
2005
2051
  uid: session.uid,
@@ -2235,6 +2281,14 @@ async function gracefulShutdown(signal: string, opts: { exit?: boolean } = {}) {
2235
2281
 
2236
2282
  console.log(`[server] drain complete — ${getActiveLockCount()} stream(s) still active, closing`);
2237
2283
 
2284
+ // Batched escalation digests live only in memory: hand them to the owners before exiting. Best-effort, bounded.
2285
+ let flushTimer: ReturnType<typeof setTimeout> | undefined;
2286
+ await Promise.race([
2287
+ flushEscalations().then((n) => { if (n) console.log(`[server] flushed ${n} pending escalation digest(s)`); }),
2288
+ new Promise<void>((r) => { flushTimer = setTimeout(() => { console.warn('[server] escalation flush timed out'); r(); }, 3000); }),
2289
+ ]).catch((e) => console.error('[server] escalation flush failed:', e?.message ?? e));
2290
+ clearTimeout(flushTimer);
2291
+
2238
2292
  for (const [ws, session] of activeConnections) {
2239
2293
  for (const ac of session.abortControllers.values()) ac.abort();
2240
2294
  ws.close();
@@ -3,7 +3,8 @@ import { summarizeText } from './summarize.ts';
3
3
  import { dataSync } from './data-sync.ts';
4
4
  import type { McpConfig } from './mcp.ts';
5
5
  import type { ClaudeAccountRef } from './claude-account.ts';
6
- import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, setClaudeResume, type ConvMessage, type ConvBlock } from './sessions.ts';
6
+ import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, setClaudeResume, getSessionFloor, lowerSessionFloor, type ConvMessage, type ConvBlock } from './sessions.ts';
7
+ import { enforcing, TurnGuard } from './security/enforce.ts';
7
8
  import { createTurnAccumulator, type TurnStreamHooks } from './turn-stream.ts';
8
9
  import {
9
10
  resolveDefaultSkillsContent,
@@ -25,6 +26,8 @@ import { getUserContextBlock } from './user-context.ts';
25
26
  import { collectTurnContext } from './turn-context.ts';
26
27
  import { DATA_DIR, dataPath } from './paths.ts';
27
28
  import * as contacts from './contacts.ts';
29
+ import type { Principal } from './security/principal.ts';
30
+ import { security, resolvePrincipal } from './security/runtime.ts';
28
31
  import { resolveAndGetEngine, ModelUnavailableError } from './engine/index.ts';
29
32
 
30
33
  const CONFIG_PATH = dataPath('agent-config.json');
@@ -197,7 +200,10 @@ function applyCompactMarkers(conv: ConvMessage[]): ConvMessage[] {
197
200
 
198
201
  export interface AttachmentMeta { url: string; name: string; mimeType: string; path: string }
199
202
 
200
- export async function* streamChat(opts: {
203
+ export interface StreamChatOpts {
204
+ /** Who this turn runs for. Required so every channel is enumerated by the compiler. Shadow mode:
205
+ * resolved + audited (turn.start/turn.end), not yet enforced. */
206
+ principal: Principal;
201
207
  prompt: string;
202
208
  attachments?: AttachmentMeta[];
203
209
  images?: string[];
@@ -217,7 +223,85 @@ export async function* streamChat(opts: {
217
223
  * where an add-on's contributor reads its own keys. */
218
224
  turnHints?: Record<string, unknown>;
219
225
  context?: Record<string, string>;
220
- }): AsyncGenerator<WsEvent> {
226
+ }
227
+
228
+ /** Run one agent turn. Security wrapper: resolves the principal's role/profile and audits turn.start / turn.end.
229
+ * SHADOW (SECURITY_ENFORCE off): the turn is unchanged; auditing can never alter or break it.
230
+ * ENFORCE: the session floor is lowered to the caller's rank (taint), the turn runs under a TurnGuard whose
231
+ * effective profile the engine applies, and it FAILS CLOSED — no runtime, a resolution error, or an effective
232
+ * profile with `outbound: false` (e.g. `none`) refuses the turn with an `error` event. */
233
+ export async function* streamChat(opts: StreamChatOpts): AsyncGenerator<WsEvent> {
234
+ const sec = security();
235
+ const enforce = enforcing();
236
+ if (!sec) {
237
+ if (!enforce) { yield* runTurn(opts); return; }
238
+ console.error('[security] SECURITY_ENFORCE is on but the security runtime is not initialized — refusing the turn');
239
+ yield { type: 'error', message: 'Security enforcement is on but not initialized, so this turn was not run.' };
240
+ return;
241
+ }
242
+ const { principal, sessionId } = opts;
243
+ let role: string | undefined;
244
+ let guard: TurnGuard | undefined;
245
+ let refusal: string | undefined;
246
+ try {
247
+ const d = sec.decide(principal, sessionId);
248
+ role = d.role;
249
+ let enforced: Record<string, unknown> = {};
250
+ if (enforce) {
251
+ // A broken policy fails closed at resolve time already; it must not ALSO permanently taint every session it touches.
252
+ if (sessionId && sec.policy.valid) lowerSessionFloor(sessionId, d.rank);
253
+ guard = new TurnGuard({ runtime: sec, principal, sessionId, role: d.role, rank: d.rank, floorOf: getSessionFloor });
254
+ const eff = guard.current();
255
+ if (!eff.profile.outbound) refusal = `This conversation runs as role "${eff.role}", which is not permitted to run the agent.`;
256
+ enforced = { enforced: true, effectiveRole: eff.role, effectiveProfile: eff.profileName, floor: sessionId ? getSessionFloor(sessionId) : undefined };
257
+ }
258
+ sec.record({ type: 'turn.start', principal: principal.id, role, sessionId, meta: { kind: principal.kind, lane: principal.attrs.lane, source: opts.context?.source, profile: d.profile, rank: d.rank, wouldDeny: d.wouldDeny, ...enforced } });
259
+ } catch (e: any) {
260
+ console.error('[security] turn.start audit failed:', e.message);
261
+ if (enforce) refusal = 'The security check for this turn failed, so it was not run.';
262
+ }
263
+ const started = Date.now();
264
+ let outcome = 'closed';
265
+ try {
266
+ if (enforce && (refusal || !guard)) {
267
+ outcome = 'denied';
268
+ console.warn(`[security] Refused turn for ${principal.id} session=${sessionId ?? 'new'}: ${refusal}`);
269
+ yield { type: 'error', message: refusal ?? 'The security check for this turn failed, so it was not run.' };
270
+ return;
271
+ }
272
+ for await (const ev of runTurn(opts, guard)) {
273
+ if (ev.type === 'done') outcome = 'done';
274
+ else if (ev.type === 'error') outcome = 'error';
275
+ yield ev;
276
+ }
277
+ if (outcome === 'closed') outcome = 'done';
278
+ } catch (e) {
279
+ outcome = 'threw';
280
+ throw e;
281
+ } finally {
282
+ try { sec.record({ type: 'turn.end', principal: principal.id, role, sessionId, reason: outcome, meta: { ms: Date.now() - started } }); }
283
+ catch (e: any) { console.error('[security] turn.end audit failed:', e.message); }
284
+ }
285
+ }
286
+
287
+ /** SECURITY_ENFORCE: `principal`'s rank under the live policy. Undefined in shadow mode, without a runtime, or on an
288
+ * invalid (fail-closed) policy — callers then apply no rank-based filtering. */
289
+ export function enforcedRank(principal: Principal): number | undefined {
290
+ const sec = security();
291
+ return enforcing() && sec?.policy.valid ? resolvePrincipal(sec.policy, principal).rank : undefined;
292
+ }
293
+
294
+ /** SECURITY_ENFORCE: input from `principal` entered `sessionId` outside a turn it runs (e.g. another human's Slack
295
+ * thread message) — lower the session floor to that principal's rank. No-op in shadow mode, without a runtime, or
296
+ * on an invalid (fail-closed) policy. Returns the new floor, or undefined when nothing was done. */
297
+ export async function taintSession(sessionId: string, principal: Principal | (() => Promise<Principal>)): Promise<number | undefined> {
298
+ const sec = security();
299
+ if (!enforcing() || !sec?.policy.valid) return undefined;
300
+ const p = typeof principal === 'function' ? await principal() : principal;
301
+ return lowerSessionFloor(sessionId, resolvePrincipal(sec.policy, p).rank);
302
+ }
303
+
304
+ async function* runTurn(opts: StreamChatOpts, guard?: TurnGuard): AsyncGenerator<WsEvent> {
221
305
  const config = getAgentConfig();
222
306
  const { prompt: cleanPrompt, directives: parsed, unresolvedModel } = parseDirectives(opts.prompt);
223
307
 
@@ -395,6 +479,16 @@ export async function* streamChat(opts: {
395
479
  yield { type: 'error', message };
396
480
  return;
397
481
  }
482
+ // An engine that doesn't apply the profile can only run a turn that needs no restriction.
483
+ if (guard && !engine.enforcesProfile) {
484
+ const { profile, role } = guard.current();
485
+ if (!(profile.tools.includes('*') && profile.mcps.includes('*') && profile.env.includes('*'))) {
486
+ const message = `Engine "${engine.name}" does not enforce security profiles, so it cannot run this conversation's role "${role}".`;
487
+ console.error(`[security] ${message} (user=${opts.uid} session=${sessionId})`);
488
+ yield { type: 'error', message };
489
+ return;
490
+ }
491
+ }
398
492
  console.log(`[stream] engine=${engine.name} user=${opts.uid} session=${sessionId}`);
399
493
  // Another engine's turn never reaches the claude-code transcript, so a stored SDK-resume mapping is
400
494
  // stale from here on — mark it; the claude-code engine then falls back to a fresh query (engine-switch).
@@ -421,6 +515,7 @@ export async function* streamChat(opts: {
421
515
  turnHints: opts.turnHints,
422
516
  conversationReset: opts.conversationReset,
423
517
  context: opts.context,
518
+ ...(guard ? { security: guard } : {}),
424
519
  directives,
425
520
  config,
426
521
  });