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
@@ -4,9 +4,13 @@ import path from 'node:path';
4
4
  import { DATA_DIR } from './paths.ts';
5
5
  import { notifyOwners } from './notify-owners.ts';
6
6
  import { runTextQuery } from './sdk-utils.ts';
7
+ import { GENESIS_HASH, readAuditHead } from './security/audit.ts';
7
8
 
8
9
  const TAG = '[data-sync]';
9
10
  const DEPLOYMENT_ID_FILE = '.deployment-id';
11
+ /** Tracked, append-only, single-writer (security/audit.ts): committed on every flush, never pulled over, never stashed. */
12
+ const AUDIT_DIR = 'audit';
13
+ const NOT_AUDIT = ['--', '.', `:(exclude)${AUDIT_DIR}`];
10
14
 
11
15
  /** How long the LLM commit-message call may take before we fall back (ms). */
12
16
  const COMMIT_MSG_TIMEOUT_MS = 60_000;
@@ -357,11 +361,32 @@ export class DataSync {
357
361
  return;
358
362
  }
359
363
 
360
- // Stash dirty + untracked files before merging (untracked can block merge if remote adds same paths)
361
- const dirty = !!(await this.git('status', '--porcelain')).trim();
364
+ // The audit log is append-only with ONE writer, this instance. A remote commit touching audit/ would rewrite the local
365
+ // chain (and under `chattr +a` git can't even apply it) — refuse the whole pull and alert.
366
+ let incomingAudit: string;
367
+ try {
368
+ incomingAudit = (await this.git('diff', '--name-only', `HEAD...origin/${this.options.branch}`, '--', AUDIT_DIR)).trim();
369
+ } catch (err) {
370
+ console.warn(`${TAG} Audit pull check failed, skipping pull:`, (err as Error).message);
371
+ return;
372
+ }
373
+ if (incomingAudit) {
374
+ console.error(`${TAG} 🚫 Remote commits change the audit log — pull refused:\n${incomingAudit}`);
375
+ await this.alertOnce('audit-pull', incomingAudit,
376
+ `🚫 Data sync refused a pull: remote commits change the audit log, which only this instance writes.\n\n${incomingAudit}\n\n` +
377
+ `Inspect: \`cd data && git log origin/${this.options.branch} -- ${AUDIT_DIR}\``,
378
+ ).catch(err => console.warn(`${TAG} Audit pull notify failed:`, (err as Error).message));
379
+ return;
380
+ }
381
+ this.clearAlert('audit-pull');
382
+
383
+ // Stash dirty + untracked files before merging (untracked can block merge if remote adds same paths) — except audit/:
384
+ // stashing removes the live log from disk while the server appends to it (lost lines, forked chain) and fails under
385
+ // `chattr +a`. The merge can't touch audit/ (checked above), so it stays dirty in place.
386
+ const dirty = !!(await this.git('status', '--porcelain', ...NOT_AUDIT)).trim();
362
387
  if (dirty) {
363
388
  try {
364
- await this.git('stash', 'push', '--include-untracked', '-m', 'data-sync: pre-pull stash');
389
+ await this.git('stash', 'push', '--include-untracked', '-m', 'data-sync: pre-pull stash', ...NOT_AUDIT);
365
390
  } catch (err) {
366
391
  console.warn(`${TAG} Stash failed, skipping pull:`, (err as Error).message);
367
392
  return;
@@ -441,12 +466,19 @@ export class DataSync {
441
466
  }
442
467
  }
443
468
 
469
+ // The audit log rides along with every sync commit — the data repo is its offsite copy — and the commit message
470
+ // anchors its head. Head read BEFORE staging: the committed chain always contains that hash.
471
+ const auditHead = this.auditHead();
472
+ if (existsSync(path.join(DATA_DIR, AUDIT_DIR))) {
473
+ await this.git('add', '--', AUDIT_DIR).catch(err => console.warn(`${TAG} Staging audit log failed:`, (err as Error).message));
474
+ }
475
+
444
476
  const status = await this.git('status', '--porcelain');
445
477
  if (!status.trim()) return;
446
478
 
447
479
  if (await this.guardMassDeletions('flush')) return;
448
480
  const msg = await this.generateCommitMessage(files);
449
- await this.git('commit', '-m', msg).catch(() => {});
481
+ await this.git('commit', '-m', auditHead ? `${msg}\n\naudit-head: ${auditHead}` : msg).catch(() => {});
450
482
  const ahead = await this.git('rev-list', '--count', `origin/${this.options.branch}..HEAD`).catch(() => '0');
451
483
  if (parseInt(ahead.trim()) === 0) return;
452
484
  await this.git('push', 'origin', this.options.branch).catch(async (err) => {
@@ -468,11 +500,23 @@ export class DataSync {
468
500
  }
469
501
  }
470
502
 
503
+ /** Audit chain head for the commit anchor; undefined when there's no record yet or it can't be read (logged). */
504
+ private auditHead(): string | undefined {
505
+ try {
506
+ const head = readAuditHead(path.join(DATA_DIR, AUDIT_DIR));
507
+ return head === GENESIS_HASH ? undefined : head;
508
+ } catch (err) {
509
+ console.warn(`${TAG} Audit head unreadable, committing without anchor:`, (err as Error).message);
510
+ return undefined;
511
+ }
512
+ }
513
+
471
514
  private async generateCommitMessage(files: string[]): Promise<string> {
472
515
  const fallback = fallbackCommitMessage(files);
473
516
  try {
474
- const diff = await this.git('diff', '--cached', '--stat').catch(() => '');
475
- const diffContent = await this.git('diff', '--cached', '--no-color', '-U2').catch(() => '');
517
+ // The audit log's appended lines are not "behavioral config" and would crowd the real diff out of the prompt.
518
+ const diff = await this.git('diff', '--cached', '--stat', ...NOT_AUDIT).catch(() => '');
519
+ const diffContent = await this.git('diff', '--cached', '--no-color', '-U2', ...NOT_AUDIT).catch(() => '');
476
520
  if (!diffContent.trim()) return fallback;
477
521
  const truncated = diffContent.slice(0, 3000);
478
522
  // Bounded: a hung `claude` subprocess used to wedge flush() forever (it holds the push latch).
@@ -761,7 +805,12 @@ export class DataSync {
761
805
  'uploads/', 'repos/', '.tmp/', 'schedules.json.bak', 'git-log.json',
762
806
  '.internal-token', 'comms-log.jsonl', 'sessions/', 'unread/', '.DS_Store',
763
807
  'scheduler/', '.mcp-catalog.json',
808
+ 'api-keys.json.bak', // pre-hashing plaintext keys (api-keys.ts migration) — never commit
764
809
  'workspace/users/*/.claude/', // per-user Claude logins (claude-account.ts) — credentials, never commit
810
+ // Live security state of the ACTIVE instance, which is its single writer — a pull must never overwrite it:
811
+ // blocks.json (guard auto-blocks); policy.json + .migrated (the Owner Console writes them; a pulled change trips
812
+ // the policy provenance/tamper check). Blue-green shares one DATA_DIR on one host, so nothing needs to cross hosts.
813
+ 'security/',
765
814
  ];
766
815
 
767
816
  /** Write or refresh .gitignore, appending any canonical entries it's missing (idempotent). */
@@ -18,10 +18,8 @@ export interface ParsedPrompt {
18
18
  unresolvedModel?: string;
19
19
  }
20
20
 
21
- /** Model used when neither directives nor config specify one. Always passed
22
- * explicitly to the SDK — the CLI's own default silently drifts (it picked
23
- * Opus 4.7), which burns rate limits and budget. */
24
- export const DEFAULT_MODEL = 'claude-sonnet-5';
21
+ // The default model lives in src/shared/models.ts so the client header names the same one.
22
+ export { DEFAULT_MODEL } from '../shared/models';
25
23
 
26
24
  // Canonical model aliases + label. Vendored, pure, dependency-free (src/server/model-aliases.ts).
27
25
  // Re-exported here so the rest of shraga keeps importing model helpers from one place.
@@ -23,6 +23,8 @@ import { APP_ROOT } from '../paths.ts';
23
23
  import { writeMcpConfigFile } from './mcp-config-file.ts';
24
24
  import { claudeUsageFor } from '../claude-usage.ts';
25
25
  import { claudeAccountDir, applyClaudeAccount, claudeAccountRef, type ClaudeAccountRef } from '../claude-account.ts';
26
+ import { buildAgentEnv, builtinTools, filterMcpServers, allowsEscalate, allowsInternalToken, SENSITIVE_PATH_PATTERNS } from '../security/enforce.ts';
27
+ import { escalateMcpServer } from '../security/escalate.ts';
26
28
  const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
27
29
  const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
28
30
  const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
@@ -32,10 +34,6 @@ const HISTORY_LIMIT = 50;
32
34
 
33
35
  const NO_INTERACTIVE_ANSWER = 'No interactive channel is available to answer right now. Use your best judgement to proceed, and surface these options to the user in your reply so they can redirect if needed.';
34
36
 
35
- const SENSITIVE_PATTERNS = [
36
- /\.env($|\.)/i, /secrets?\//i, /credentials/i, /\.pem$/i, /\.key$/i,
37
- /service.account.*\.json/i, /\/\.claude\/credentials/i,
38
- ];
39
37
  const SENSITIVE_BASH_PATTERNS = [
40
38
  /\.env\b/i, /\bprintenv\b/i, /\b(env|set)\s*\|/i, /\bsecrets?\//i,
41
39
  /credentials/i, /service.account/i, /\.(pem|key)\b/i,
@@ -63,7 +61,7 @@ type DenyResult = { behavior: 'deny'; message: string };
63
61
  function checkSensitiveAccess(toolName: string, input: Record<string, unknown>): DenyResult | null {
64
62
  const filePath = (input.file_path ?? input.path ?? '') as string;
65
63
  if ((toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') && filePath) {
66
- if (SENSITIVE_PATTERNS.some(p => p.test(filePath))) {
64
+ if (SENSITIVE_PATH_PATTERNS.some(p => p.test(filePath))) {
67
65
  console.log(`[security] Blocked ${toolName} on sensitive file: ${filePath}`);
68
66
  return { behavior: 'deny', message: 'Access to sensitive files (.env, secrets, credentials) is blocked.' };
69
67
  }
@@ -242,6 +240,7 @@ async function* buildLegacyImagePrompt(text: string, images: string[], sessionId
242
240
 
243
241
  export class ClaudeCodeEngine implements AgentEngine {
244
242
  readonly name = 'claude-code';
243
+ readonly enforcesProfile = true;
245
244
 
246
245
  getModels(): EngineModel[] {
247
246
  return [
@@ -329,8 +328,11 @@ export class ClaudeCodeEngine implements AgentEngine {
329
328
  const fullPrompt = plan.prompt;
330
329
  const permMode = opts.onPermissionRequest ? 'default' : (config.permissionMode ?? 'acceptEdits');
331
330
 
332
- const sdkEnv: Record<string, string> = {};
333
- for (const [k, v] of Object.entries(process.env)) {
331
+ // SECURITY_ENFORCE: the effective profile at spawn decides env/tools/MCPs; the per-call gate re-reads it (enforce.ts).
332
+ const guard = opts.security;
333
+ const eff = guard?.current();
334
+ const sdkEnv: Record<string, string> = eff ? buildAgentEnv(process.env, eff.profile) : {};
335
+ if (!eff) for (const [k, v] of Object.entries(process.env)) {
334
336
  if (v !== undefined) sdkEnv[k] = v;
335
337
  }
336
338
  // Injected for the agent's own tools/scripts. Each is written under both the canonical
@@ -366,12 +368,18 @@ export class ClaudeCodeEngine implements AgentEngine {
366
368
  let initModel: string | undefined;
367
369
  sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');
368
370
 
371
+ if (eff && !allowsInternalToken(eff.profile)) delete sdkEnv.INTERNAL_API_TOKEN;
372
+
369
373
  const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
370
- const allowedTools = baseAllowed.includes('ToolSearch') ? baseAllowed : [...baseAllowed, 'ToolSearch'];
374
+ const withToolSearch = baseAllowed.includes('ToolSearch') ? baseAllowed : [...baseAllowed, 'ToolSearch'];
375
+ // Enforced: built-in tool AVAILABILITY is the profile's (tools outside it are not in the model's context), and
376
+ // auto-approval never widens it.
377
+ const available = eff ? builtinTools(eff.profile) : 'all';
378
+ const allowedTools = available === 'all' ? withToolSearch : withToolSearch.filter((t) => available.includes(t));
371
379
  const maxTurns = directives.turns ?? config.maxTurns ?? 50;
372
380
 
373
381
  const options: Record<string, unknown> = {
374
- tools: { type: 'preset', preset: 'claude_code' },
382
+ tools: available === 'all' ? { type: 'preset', preset: 'claude_code' } : available,
375
383
  env: sdkEnv,
376
384
  allowedTools,
377
385
  cwd,
@@ -391,15 +399,24 @@ export class ClaudeCodeEngine implements AgentEngine {
391
399
  skills: listSkills(),
392
400
  disallowedTools: ['Skill'],
393
401
  agents: loadAgents(),
394
- hooks: buildHooks({ exemptBashCommand: opts.foregroundBashCommand }),
402
+ // Enforced: the profile gate runs FIRST on every tool call — hooks fire even for auto-approved `allowedTools`,
403
+ // which never reach canUseTool.
404
+ hooks: ((h) => (guard ? { ...h, PreToolUse: [{ hooks: [guard.hook()] }, ...(h.PreToolUse ?? [])] } : h))(buildHooks({ exemptBashCommand: opts.foregroundBashCommand })),
395
405
  };
396
406
 
397
407
  const userHandler = opts.onPermissionRequest;
398
408
  const destructiveHandler = opts.onDestructiveApproval;
399
409
  const questionHandler = opts.onUserQuestion;
400
410
  options['canUseTool'] = async (toolName: string, input: Record<string, unknown>) => {
401
- const denied = checkSensitiveAccess(toolName, input);
411
+ // Enforced: TurnGuard owns the file-path deny (tightened list); the legacy path list stays flag-OFF behavior only.
412
+ const denied = guard && toolName !== 'Bash' ? null : checkSensitiveAccess(toolName, input);
402
413
  if (denied) return denied;
414
+ // Enforced: the profile gate (re-reads the session floor) runs before ANY handler below, so an
415
+ // `onPermissionRequest: allow` call site can never override a profile deny.
416
+ if (guard) {
417
+ const gate = guard.check(toolName, input, cwd);
418
+ if (!gate.allow) return { behavior: 'deny' as const, message: gate.message };
419
+ }
403
420
  if (toolName === 'AskUserQuestion') {
404
421
  const questions = (input.questions ?? []) as AskQuestion[];
405
422
  const answers = questionHandler
@@ -479,12 +496,25 @@ export class ClaudeCodeEngine implements AgentEngine {
479
496
  // server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
480
497
  // writeMcpConfigFile. Setting both would re-add the argv copy, so it's one or the other.
481
498
  let mcpConfigFile: { path: string; cleanup: () => void } | undefined;
482
- if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) {
483
- mcpConfigFile = writeMcpConfigFile(opts.mcpServers);
499
+ // Enforced: servers outside profile.mcps never reach the file, so the CLI never starts them.
500
+ const mcpServers = eff ? filterMcpServers(opts.mcpServers, eff.profile) : opts.mcpServers;
501
+ if (mcpServers && Object.keys(mcpServers).length > 0) {
502
+ mcpConfigFile = writeMcpConfigFile(mcpServers);
484
503
  options['extraArgs'] = { ...(options['extraArgs'] as Record<string, string> | undefined), 'mcp-config': mcpConfigFile.path };
485
504
  }
505
+ // `escalate` is an in-process SDK server (no credentials): `options.mcpServers` hands the CLI only its name.
506
+ if (guard && eff && allowsEscalate(eff.profile)) {
507
+ const { principal } = guard.options;
508
+ const lastUser = opts.conversation.findLast((m) => m.role === 'user');
509
+ const excerpt = lastUser?.blocks.map((b) => (b.type === 'text' ? b.text : '')).filter(Boolean).join('\n') || opts.prompt;
510
+ const server = escalateMcpServer({
511
+ principal, role: () => guard.current().role, sessionId: opts.sessionId, excerpt,
512
+ channel: [principal.kind, principal.attrs.lane, opts.context?.source].filter(Boolean).join(':'),
513
+ });
514
+ options['mcpServers'] = { [server.name]: server };
515
+ }
486
516
 
487
- const mcpNames = opts.mcpServers ? Object.keys(opts.mcpServers) : [];
517
+ const mcpNames = mcpServers ? Object.keys(mcpServers) : [];
488
518
  const activeModel = (options['model'] as string) || 'default';
489
519
  const directivesTag = Object.keys(directives).length ? ` directives=${JSON.stringify(directives)}` : '';
490
520
  console.log(`[claude] Starting query user=${opts.uid} session=${opts.sessionId ?? 'new'} model=${activeModel} perms=${config.permissionMode} mcps=[${mcpNames.join(',')}]${directivesTag} cwd=${cwd}`);
@@ -3,6 +3,7 @@ import type { WsEvent, AttachmentMeta, PermissionHandler, QuestionHandler } from
3
3
  import type { ConvMessage } from '../sessions.ts';
4
4
  import type { Directives } from '../directives.ts';
5
5
  import type { AgentSettings } from '../shraga-config.ts';
6
+ import type { TurnGuard } from '../security/enforce.ts';
6
7
 
7
8
  export interface EngineStreamOpts {
8
9
  /** The user's effective prompt (after directive stripping, slash command expansion, skill/workspace mentions) */
@@ -35,6 +36,9 @@ export interface EngineStreamOpts {
35
36
  /** True when the conversation was truncated (user replayed/edited a message) — engines with cached state should reset. */
36
37
  conversationReset?: boolean;
37
38
  context?: Record<string, string>;
39
+ /** SECURITY_ENFORCE only: the turn's security context. The engine derives tools/MCP/env from its effective
40
+ * profile and gates every tool call through it. Absent (flag off) ⇒ today's behavior, untouched. */
41
+ security?: TurnGuard;
38
42
 
39
43
  directives: Directives;
40
44
  config: AgentSettings;
@@ -42,6 +46,9 @@ export interface EngineStreamOpts {
42
46
 
43
47
  export interface AgentEngine {
44
48
  readonly name: string;
49
+ /** True if the engine applies `EngineStreamOpts.security` (profile tools/MCP/env + per-call gate). Under
50
+ * SECURITY_ENFORCE an engine without it may only run turns whose effective profile is unrestricted. */
51
+ readonly enforcesProfile?: boolean;
45
52
  stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent>;
46
53
  /** Return model options for the UI picker (only called when engine is available) */
47
54
  getModels(): EngineModel[];
@@ -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
+ }