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