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
@@ -0,0 +1,174 @@
1
+ // Security runtime: the ONE process-wide Policy + Audit, and the per-turn decision.
2
+ //
3
+ // SHADOW MODE: decide() computes and audits; nothing here denies. Callers never branch on it yet.
4
+ // Uninitialized (tests, embedders that never call initSecurity) ⇒ security() is undefined and every
5
+ // call site no-ops — the audit trail is additive, never a precondition for a turn or a request.
6
+ //
7
+ // Writes are gated on `isActive` (a PASSIVE standby shares DATA_DIR and must not append to data/audit, nor let the
8
+ // Policy migrate/save/mark or judge tamper; `activate()` re-loads the policy on promotion), and noisy events are
9
+ // deduped per key per window, so a busy client isn't one line per request.
10
+ import { OWNER_ROLE, Policy, type PolicyOptions, type Resolved, type TamperReason } from './policy.ts';
11
+ import { Audit, type AuditEvent, type AuditOptions } from './audit.ts';
12
+ import { Guard, type GuardOptions, type TurnAdmission } from './guard.ts';
13
+ import { fromAuthUser, type Principal } from './principal.ts';
14
+
15
+ /** principal → role. An API key acts for its creator, re-resolved per call (so an OWNERS removal applies at once):
16
+ * - no `attrs.role` (a delegated login credential) ⇒ the creator's role, owner included;
17
+ * - `attrs.role` set ⇒ min(creator's role, that role, the highest role below owner) — never above the creator, never owner. */
18
+ export function resolvePrincipal(policy: Policy, p: Principal): Resolved {
19
+ if (p.kind !== 'apikey') return policy.resolve(p);
20
+ const creator = policy.resolve(fromAuthUser({ uid: String(p.attrs.uid ?? ''), email: p.email }));
21
+ if (typeof p.attrs.role !== 'string') return creator;
22
+ const r = creator.role === OWNER_ROLE ? policy.belowOwner() : creator;
23
+ const cap = policy.effective(Infinity, p.attrs.role); // the named role (unknown ⇒ the policy default)
24
+ return cap.rank < r.rank ? cap : r;
25
+ }
26
+
27
+ export interface Decision {
28
+ role: string;
29
+ rank: number;
30
+ profile: string;
31
+ /** The profile would NOT allow today's behavior (every tool, every MCP, full env, outbound). */
32
+ wouldDeny: boolean;
33
+ }
34
+
35
+ export class SecurityRuntimeOptions {
36
+ policy: Partial<PolicyOptions> = {};
37
+ audit: Partial<AuditOptions> = {};
38
+ guard: Partial<GuardOptions> = {};
39
+ /** Audit writes (and tamper notices) only while this is true. */
40
+ isActive: () => boolean = () => true;
41
+ /** A deduped event with the same key is written at most once per window. */
42
+ dedupeMs: number = 60_000;
43
+ clock: () => number = Date.now;
44
+ /** Owner notice. Default: notify-owners (lazy import — keeps this module free of the event bus). */
45
+ notify: (text: string) => void = (text) => {
46
+ import('../notify-owners.ts')
47
+ .then(m => m.notifyOwners('security', text))
48
+ .catch((e: any) => console.error('[security] owner notice failed:', e?.message ?? e));
49
+ };
50
+ log: Pick<Console, 'info' | 'warn' | 'error'> = console;
51
+ }
52
+
53
+ const MAX_DEDUPE_KEYS = 5000;
54
+
55
+ export class SecurityRuntime {
56
+ public options: SecurityRuntimeOptions;
57
+ public readonly policy: Policy;
58
+ public readonly audit: Audit;
59
+ public readonly guard: Guard;
60
+ private seen = new Map<string, number>();
61
+
62
+ public constructor(options?: Partial<SecurityRuntimeOptions>) {
63
+ this.options = { ...new SecurityRuntimeOptions(), ...options };
64
+ const { policy, audit, guard, log, clock } = this.options;
65
+ this.audit = new Audit({
66
+ log, ...audit,
67
+ // Standby instances share DATA_DIR: only the active one alerts (false = ask again after promotion).
68
+ onAnomaly: (info) => {
69
+ if (audit.onAnomaly?.(info) === false || !this.active()) return false;
70
+ this.options.notify(`Audit log entry ${info.file} in ${info.dir} is a ${info.kind}, not a regular file — possibly planted to divert or disable auditing. Appends to that month are refused until root removes it (chattr -a the audit dir, rm the entry, re-run harden-audit.sh).`);
71
+ },
72
+ });
73
+ this.policy = new Policy({ log, isActive: () => this.active(), ...policy, onTamper: (info) => { policy.onTamper?.(info); this.onTamper(info); } });
74
+ this.guard = new Guard({
75
+ log, clock, isActive: () => this.active(),
76
+ audit: (e, k) => this.record(e, k),
77
+ blocklist: (p) => this.policy.blocked(p, this.options.clock() / 1000),
78
+ ...guard,
79
+ });
80
+ }
81
+
82
+ /** Passive → active promotion (after `isActive` turns true): re-load the policy and auto-blocks from disk with boot trust. */
83
+ public activate(): void {
84
+ this.options.log.info('[security] activated — reloading policy from disk');
85
+ this.policy.activate();
86
+ this.guard.activate();
87
+ }
88
+
89
+ /** Guard a turn start: principal → rank/rate → blocklist, rate, concurrency. Call BEFORE any LLM spend and
90
+ * `release()` when the turn ends (try/finally). Shadow unless SECURITY_ENFORCE=true. */
91
+ public admitTurn(principal: Principal, ctx: { ip?: string; channel?: string } = {}): TurnAdmission {
92
+ const r = resolvePrincipal(this.policy, principal); // same resolution as decide(): an API key's role cap applies to guard rank/rate
93
+ return this.guard.admit({ principal, ...ctx, rank: r.rank, rate: r.profile.rate });
94
+ }
95
+
96
+ private active(): boolean {
97
+ try { return this.options.isActive(); }
98
+ catch (e: any) { this.options.log.error(`[security] isActive threw — not writing audit: ${e.message}`); return false; }
99
+ }
100
+
101
+ /** Append (active instance only). With `dedupeKey`, at most once per `dedupeMs`. Never throws. */
102
+ public record(event: AuditEvent, dedupeKey?: string): void {
103
+ if (!this.active()) return;
104
+ if (dedupeKey) {
105
+ const now = this.options.clock(), last = this.seen.get(dedupeKey);
106
+ if (last !== undefined && now - last < this.options.dedupeMs) return;
107
+ if (this.seen.size >= MAX_DEDUPE_KEYS) {
108
+ for (const [k, t] of this.seen) if (now - t >= this.options.dedupeMs) this.seen.delete(k);
109
+ if (this.seen.size >= MAX_DEDUPE_KEYS) this.seen.clear();
110
+ }
111
+ this.seen.set(dedupeKey, now);
112
+ }
113
+ this.audit.append(event);
114
+ }
115
+
116
+ /** principal → role/profile, audited as `role.resolve` (deduped per principal+role per window). */
117
+ public decide(principal: Principal, sessionId?: string): Decision {
118
+ const r = resolvePrincipal(this.policy, principal);
119
+ const p = r.profile;
120
+ const wouldDeny = !(p.tools.includes('*') && p.mcps.includes('*') && p.env.includes('*') && p.outbound);
121
+ this.record({
122
+ type: 'role.resolve', principal: principal.id, role: r.role, sessionId,
123
+ meta: { kind: principal.kind, verified: principal.verified, profile: r.profileName, rank: r.rank, policyValid: this.policy.valid },
124
+ }, `role.resolve|${principal.id}|${r.role}`);
125
+ return { role: r.role, rank: r.rank, profile: r.profileName, wouldDeny };
126
+ }
127
+
128
+ /** `via` = the auth branch (e.g. `http:apikey`, `mcp:oauth`, `ws:bearer`). */
129
+ public authAllow(principal: Principal, via: string): void {
130
+ this.record({ type: 'auth.allow', principal: principal.id, target: via, meta: { kind: principal.kind } }, `auth.allow|${principal.id}|${via}`);
131
+ }
132
+
133
+ public authDeny(via: string, reason: string, ip?: string): void {
134
+ this.record({ type: 'auth.deny', target: via, reason, meta: ip ? { ip } : undefined }, `auth.deny|${via}|${reason}|${ip ?? ''}`);
135
+ }
136
+
137
+ private onTamper(info: { path: string; reason: TamperReason; expected: string | null; actual: string | null }): void {
138
+ if (!this.active()) return;
139
+ this.audit.append({ type: 'policy.tamper', target: info.path, reason: info.reason, meta: { expected: info.expected, actual: info.actual } });
140
+ this.options.notify(`Security policy file changed outside the server (${info.reason}): ${info.path}. The last good policy is still in force.`);
141
+ }
142
+
143
+ public close(): void { this.policy.close(); }
144
+ }
145
+
146
+ let current: SecurityRuntime | undefined;
147
+
148
+ /** Construct the process-wide runtime (replaces a previous one). Call once at boot, after config load. */
149
+ export function initSecurity(options?: Partial<SecurityRuntimeOptions>): SecurityRuntime {
150
+ current?.close();
151
+ current = new SecurityRuntime(options);
152
+ return current;
153
+ }
154
+
155
+ /** The process-wide runtime, or undefined when never initialized (call sites then skip auditing). */
156
+ export function security(): SecurityRuntime | undefined { return current; }
157
+
158
+ /**
159
+ * Turn-start gate for the core lanes AND add-ons (e.g. a Gmail lane): `admitTurn(principal, { ip, channel })`.
160
+ * `{ ok: false, status, retryAfter?, reason }` only when enforcing; on ok, call `release()` when the turn ends.
161
+ * No runtime, or a guard bug ⇒ admitted (logged): the guard limits spend, it is not the auth layer.
162
+ */
163
+ export function admitTurn(principal: Principal, ctx?: { ip?: string; channel?: string }): TurnAdmission {
164
+ try { return current ? current.admitTurn(principal, ctx) : { ok: true, release: () => {} }; }
165
+ catch (e: any) { console.error(`[security] admitTurn threw — admitting: ${e?.message ?? e}`); return { ok: true, release: () => {} }; }
166
+ }
167
+
168
+ /** Client IP of a request/upgrade, honoring TRUSTED_PROXIES. */
169
+ export function requestIp(req: Parameters<Guard['ipOf']>[0]): string | undefined {
170
+ return current?.guard.ipOf(req);
171
+ }
172
+
173
+ /** Test-only: drop the process-wide runtime. */
174
+ export function __resetSecurityForTest(): void { current?.close(); current = undefined; }
@@ -49,6 +49,8 @@ export interface SessionMeta {
49
49
  /** claude-code engine SDK-resume mapping (opt-in flag) — see engine/claude-resume.ts. */
50
50
  claudeResume?: ClaudeResumeState;
51
51
  forkedFrom?: string;
52
+ /** Security taint (SECURITY_ENFORCE): rank of the lowest-ranked principal that ever contributed. Only lowers. */
53
+ floorRank?: number;
52
54
  }
53
55
 
54
56
  // ── Index cache ──────────────────────────────────────────────────────────────
@@ -222,6 +224,7 @@ export function upsertSession(sessionId: string, firstPrompt: string, user: { ui
222
224
  lastModified: now,
223
225
  ...(scope ? { scope } : {}),
224
226
  ...(kind ? { kind } : {}),
227
+ ...floorField(sessionId),
225
228
  });
226
229
  }
227
230
 
@@ -398,6 +401,35 @@ export function setClaudeResume(sessionId: string, state: ClaudeResumeState | un
398
401
  saveIndex(sessions);
399
402
  }
400
403
 
404
+ // ── Security taint floor (SECURITY_ENFORCE) ─────────────────────────────────
405
+ // The engine's tool gate re-reads the floor on EVERY tool call, so it's served from this Map (write-through to
406
+ // `floorRank` in the index; single-writer process, see the index cache note). A floor only ever lowers.
407
+ const floors = new Map<string, number>();
408
+
409
+ /** The session's taint floor, or undefined when none was recorded. */
410
+ export function getSessionFloor(sessionId: string): number | undefined {
411
+ const cached = floors.get(sessionId);
412
+ if (cached !== undefined) return cached;
413
+ const f = loadIndex().find((s) => s.sessionId === sessionId)?.floorRank;
414
+ if (f !== undefined) floors.set(sessionId, f);
415
+ return f;
416
+ }
417
+
418
+ /** Lower the floor to min(floor, rank) — the first contributor sets it. Returns the new floor. Never raises it.
419
+ * Works before the index record exists (some channels create it after streaming): creation picks it up. */
420
+ export function lowerSessionFloor(sessionId: string, rank: number): number {
421
+ const prev = getSessionFloor(sessionId);
422
+ const next = prev === undefined ? rank : Math.min(prev, rank);
423
+ floors.set(sessionId, next);
424
+ const sessions = loadIndex();
425
+ const s = sessions.find((x) => x.sessionId === sessionId);
426
+ if (s && s.floorRank !== next) { s.floorRank = next; saveIndex(sessions); }
427
+ return next;
428
+ }
429
+
430
+ /** A pending floor for a record being created now. */
431
+ const floorField = (sessionId: string) => (floors.has(sessionId) ? { floorRank: floors.get(sessionId)! } : {});
432
+
401
433
  export function getSessionsByScheduleId(scheduleId: string): SessionMeta[] {
402
434
  return loadIndex()
403
435
  .filter((s) => s.scheduleId === scheduleId)
@@ -420,6 +452,7 @@ export function createScheduledSession(sessionId: string, scheduleId: string, ti
420
452
  scheduleId,
421
453
  scheduleRunAt: now,
422
454
  scheduleRunStatus: 'running',
455
+ ...floorField(sessionId),
423
456
  });
424
457
  saveIndex(sessions);
425
458
  }
@@ -710,6 +743,8 @@ export function forkSession(sourceId: string, user: { uid: string; email: string
710
743
  lastModified: now,
711
744
  forkedFrom: sourceId,
712
745
  ...(source.directives ? { directives: source.directives } : {}),
746
+ // The fork copies the source's (possibly tainted) content, so it keeps its floor.
747
+ ...(getSessionFloor(sourceId) !== undefined ? { floorRank: getSessionFloor(sourceId)! } : {}),
713
748
  });
714
749
  saveIndex(sessions);
715
750
 
@@ -6,7 +6,7 @@
6
6
  import crypto from 'node:crypto';
7
7
  import { mkdirSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
- import { streamChat, type AttachmentMeta, type WsEvent } from '../claude.ts';
9
+ import { streamChat, taintSession, enforcedRank, type AttachmentMeta, type WsEvent } from '../claude.ts';
10
10
  import { handleArtifactToolUse } from '../artifacts/artifacts.handler.ts';
11
11
  import { getMcpConfig } from '../mcp.ts';
12
12
  import { dataPath } from '../paths.ts';
@@ -14,14 +14,16 @@ import { appendMessage, setSlackContext, setRunStatus, setVisibleTo, writePartia
14
14
  import { injectFile } from '../file-inject.ts';
15
15
  import { createTurnAccumulator } from '../turn-stream.ts';
16
16
  import {
17
- postMessage, addReaction, removeReaction, getBotUserId, getAgentUserId, getThreadMessages, getMessage,
17
+ postMessage, addReaction, removeReaction, getBotUserId, getAgentUserId, getThreadMessages,
18
18
  getChannelName, getUserName, getUserProfile, resolveUserMentions, isSupportedFile, SUPPORTED_FILE_MIMES,
19
19
  downloadSlackFileBuffer,
20
20
  } from './api.ts';
21
21
  import { pipeAgentReply, type AgentEvent, type IngressMessage } from 'mcp-slack-use/src/ingress.ts';
22
22
  import { makeSlackQuestionHandler } from './questions.ts';
23
23
  import * as contacts from '../contacts.ts';
24
- import { getChannelContext, invalidateChannelContext } from './context-cache.ts';
24
+ import { fromInternal, fromSlack, type Principal } from '../security/principal.ts';
25
+ import { admitTurn } from '../security/runtime.ts';
26
+ import { getChannelContext, invalidateChannelContext, trustedMessages, type AuthorTrust } from './context-cache.ts';
25
27
  import { noteSlackSeen } from '../downtime.ts';
26
28
  import { getOrCreateSession, registerThreadAlias, setLastMessageTs, setUseUserToken, findSlackSessionBySessionId, getProactiveOrigin, hasSessionForThread, isSlackBotPlaceholderEmail } from './sessions.ts';
27
29
 
@@ -42,6 +44,14 @@ export function setBroadcast(fn: Broadcast): void { broadcastFn = fn; }
42
44
 
43
45
  const mdText = (b: ConvBlock): b is { type: 'text'; text: string } => b.type === 'text';
44
46
 
47
+ /** SECURITY_ENFORCE: channel context / thread history the invoker may see — only authors ranked ≥ the invoker's rank.
48
+ * Undefined in shadow mode (everything is ingested, as before). A failed profile lookup rejects → message excluded. */
49
+ function authorTrust(invoker: Principal): AuthorTrust | undefined {
50
+ const minRank = enforcedRank(invoker);
51
+ if (minRank === undefined) return undefined;
52
+ return { minRank, rankOf: async (u) => enforcedRank(fromSlack(u, { email: (await getUserProfile(u)).email })) };
53
+ }
54
+
45
55
  // ── Semantic gate ─────────────────────────────────────────────────────────────
46
56
  // Protocol-level routing (bot echoes, DM identity, dedupe) is the ingress's job. This is the app-side
47
57
  // half: agent-channel summon rules + threads the agent already owns. Referenced app session state
@@ -163,6 +173,9 @@ export async function* runAgentTurn(msg: IngressMessage): AsyncGenerator<AgentEv
163
173
  contact = contacts.upsert({ slackId: msg.user, email: profile.email || undefined, name: profile.name || undefined });
164
174
  }
165
175
  const sessionUser = contact?.emails.length ? { uid: contact.id, email: contact.emails[0], name: contact.name } : undefined;
176
+ // The real human sender; a message with no Slack user falls back to the bot identity, marked internal.
177
+ const principal = msg.user ? fromSlack(msg.user, { email: contact?.emails[0] }) : fromInternal({ uid: SLACK_UID, lane: 'slack' });
178
+ const trust = authorTrust(principal);
166
179
 
167
180
  let isNew: boolean;
168
181
  let sessionId: string;
@@ -175,7 +188,7 @@ export async function* runAgentTurn(msg: IngressMessage): AsyncGenerator<AgentEv
175
188
  const ctxType = isDM ? 'dm' as const : isMention ? 'mention' as const : 'channel' as const;
176
189
  const [channelName, channelCtx] = await Promise.all([
177
190
  isDM ? null : getChannelName(channel).catch(err => { console.error('[slack-bot] getChannelName failed:', err.message); return null; }),
178
- isDM ? null : getChannelContext(channel).catch(err => { console.error('[slack-bot] getChannelContext failed:', err.message); return null; }),
191
+ isDM ? null : getChannelContext(channel, trust).catch(err => { console.error('[slack-bot] getChannelContext failed:', err.message); return null; }),
179
192
  ]);
180
193
  const userName = contact?.name || null;
181
194
  const resolvedChannel = isDM ? undefined : (channelName || undefined);
@@ -202,9 +215,11 @@ export async function* runAgentTurn(msg: IngressMessage): AsyncGenerator<AgentEv
202
215
  const seen = new Set(getSession(sessionId)?.seenSlackTs ?? []);
203
216
  const seedOnly = !isNew && seen.size === 0;
204
217
  const processedTs: string[] = [];
218
+ const fresh = seedOnly ? [] : threadMsgs.filter((m) => m.ts !== msg.ts && !(m.ts && seen.has(m.ts)));
219
+ const ingest = new Set(trust ? await trustedMessages(fresh, [botId, agentUserId], trust) : fresh);
205
220
  for (const m of threadMsgs) {
206
221
  if (m.ts) processedTs.push(m.ts);
207
- if (m.ts === msg.ts || (m.ts && seen.has(m.ts)) || seedOnly) continue;
222
+ if (!ingest.has(m)) continue;
208
223
  const isBot = m.bot_id || m.user === botId || (agentUserId && m.user === agentUserId);
209
224
  const role = isBot ? 'assistant' : 'user';
210
225
  let msgText = (m.text || '').replace(new RegExp(`<@${botId}>\\s*`, 'g'), '').trim();
@@ -217,12 +232,13 @@ export async function* runAgentTurn(msg: IngressMessage): AsyncGenerator<AgentEv
217
232
  }
218
233
  recordSeenSlackTs(sessionId, [...processedTs, msg.ts]);
219
234
  } else if (isNew) {
220
- const rootText = await getMessage(channel, msg.rawThreadTs, useUserToken).catch(err => {
235
+ // conversations.replies' first message is the root; keep its author (getMessage returns only the text).
236
+ const root = await getThreadMessages(channel, msg.rawThreadTs, useUserToken).then((ms) => ms[0] ?? null, (err) => {
221
237
  console.error(`[slack] Failed to fetch root message for ${channel}:${msg.rawThreadTs}:`, err?.message || err);
222
238
  return null;
223
239
  });
224
- if (rootText) {
225
- let cleanRoot = rootText.replace(new RegExp(`<@${botId}>\\s*`, 'g'), '').trim();
240
+ if (root?.text && (!trust || (await trustedMessages([root], [botId, agentUserId], trust)).length)) {
241
+ let cleanRoot = root.text.replace(new RegExp(`<@${botId}>\\s*`, 'g'), '').trim();
226
242
  if (agentUserId) cleanRoot = cleanRoot.replace(new RegExp(`<@${agentUserId}>\\s*`, 'g'), '').trim();
227
243
  const resolved = await resolveUserMentions(cleanRoot);
228
244
  if (resolved) appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'context', label: 'Thread root message', text: resolved }], channel: 'slack' });
@@ -275,6 +291,9 @@ export async function* runAgentTurn(msg: IngressMessage): AsyncGenerator<AgentEv
275
291
  ];
276
292
  const senderName = contact?.name || contact?.emails[0]?.split('@')[0] || undefined;
277
293
  appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: userBlocks, channel: 'slack', senderName });
294
+ // Taint at the append, not only at turn start: if the session is busy below, no turn runs for this message but the
295
+ // next turn still reads it (SECURITY_ENFORCE only).
296
+ await taintSession(sessionId, principal);
278
297
  setLastMessageTs(channel, threadTs, userMessageTs);
279
298
  if (useUserToken) setUseUserToken(channel, threadTs, true);
280
299
 
@@ -286,20 +305,31 @@ export async function* runAgentTurn(msg: IngressMessage): AsyncGenerator<AgentEv
286
305
  if (msg.rawThreadTs) triggerContext.thread = msg.rawThreadTs;
287
306
  if (contact?.emails[0]) triggerContext.user = contact.emails[0];
288
307
 
308
+ // Guard before any spend: blocklist → rate → concurrency (shadow unless SECURITY_ENFORCE).
309
+ const admission = admitTurn(principal, { channel: 'slack' });
310
+ if (!admission.ok) {
311
+ console.warn(`[slack-bot] Turn refused for ${principal.id}: ${admission.reason}`);
312
+ await postMessage(channel, `⏳ _Not now — ${admission.status === 403 ? 'access is blocked' : `too many requests, try again in ${admission.retryAfter ?? 60}s`}._`, threadTs, useUserToken);
313
+ msg.sessionId = undefined; // nothing to bookkeep — no reply produced
314
+ return;
315
+ }
316
+
289
317
  const abortController = new AbortController();
290
318
  if (!acquireSessionLock(sessionId, 'slack', abortController)) {
319
+ admission.release();
291
320
  console.warn(`[slack-bot] Session ${sessionId.slice(0, 8)} already locked, queuing in Slack thread`);
292
321
  await postMessage(channel, '⏳ _Session is busy — please wait for the current task to finish._', threadTs, useUserToken);
293
322
  msg.sessionId = undefined; // nothing to bookkeep — no reply produced
294
323
  return;
295
324
  }
296
- setRunStatus(sessionId, 'running', 'slack');
297
- broadcastFn({ type: 'session_messages_changed', sessionId });
298
- broadcastFn({ type: 'session_busy', sessionId, busy: true });
299
325
 
300
326
  try {
327
+ setRunStatus(sessionId, 'running', 'slack');
328
+ broadcastFn({ type: 'session_messages_changed', sessionId });
329
+ broadcastFn({ type: 'session_busy', sessionId, busy: true });
301
330
  yield* pumpStream(
302
331
  streamChat({
332
+ principal,
303
333
  prompt: resolvedText,
304
334
  attachments: attachments.length ? attachments : undefined,
305
335
  sessionId,
@@ -316,6 +346,7 @@ export async function* runAgentTurn(msg: IngressMessage): AsyncGenerator<AgentEv
316
346
  { partial: true, artifacts: true },
317
347
  );
318
348
  } finally {
349
+ admission.release();
319
350
  if (releaseSessionLock(sessionId, abortController)) {
320
351
  setRunStatus(sessionId, 'idle');
321
352
  broadcastFn({ type: 'session_busy', sessionId, busy: false });
@@ -360,6 +391,8 @@ export async function retrySlackSession(session: SessionMeta, prompt: string): P
360
391
  { channel, threadTs, useUserToken, transform: undefined, finalTransform: resolveUserMentions },
361
392
  pumpStream(
362
393
  streamChat({
394
+ // Recovery has no live human (the Slack sender id isn't persisted): the session's owner, marked internal.
395
+ principal: fromInternal({ uid: session.uid || SLACK_UID, email: isSlackBotPlaceholderEmail(session.userEmail) ? undefined : session.userEmail, lane: 'slack-retry' }),
363
396
  prompt,
364
397
  sessionId: session.sessionId,
365
398
  uid: SLACK_UID,
@@ -9,15 +9,48 @@ interface CacheEntry {
9
9
  const TTL_MS = 5 * 60 * 1000;
10
10
  const cache = new Map<string, CacheEntry>();
11
11
 
12
- export async function getChannelContext(channel: string): Promise<string | null> {
13
- const cached = cache.get(channel);
14
- if (cached && cached.expiresAt > Date.now()) return cached.summary;
12
+ type SlackMessage = { user?: string; bot_id?: string; text?: string };
13
+ /** SECURITY_ENFORCE ingestion floor: only content by authors ranked ≥ `minRank` (the invoking principal's) is kept. */
14
+ export interface AuthorTrust {
15
+ minRank: number;
16
+ /** Rank of a Slack user. Rejects/undefined = unresolvable: the message is excluded (never lowers the floor). */
17
+ rankOf: (user: string) => Promise<number | undefined>;
18
+ }
15
19
 
16
- const messages = await getChannelHistory(channel, 20).catch(() => []);
17
- if (!messages.length) return null;
20
+ /** Keep our own bot/agent output and messages whose human author ranks ≥ `minRank`. Bot/webhook posts and unresolvable
21
+ * authors are dropped, not tainted: untrusted content never reaches the model, so the invoker keeps their tools. */
22
+ export async function trustedMessages<M extends SlackMessage>(messages: M[], ownIds: (string | null | undefined)[], trust: AuthorTrust): Promise<M[]> {
23
+ const own = new Set(ownIds.filter(Boolean));
24
+ const ranks = new Map<string, Promise<number | undefined>>();
25
+ const rank = (u: string) => ranks.get(u) ?? ranks.set(u, trust.rankOf(u).catch((err: any) => {
26
+ console.error(`[slack-context] rank lookup failed for ${u} — excluding their messages:`, err?.message ?? err);
27
+ return undefined;
28
+ })).get(u)!;
29
+ const keep = await Promise.all(messages.map(async (m) => {
30
+ if (m.user && own.has(m.user)) return true;
31
+ if (m.bot_id || !m.user) return false;
32
+ const r = await rank(m.user);
33
+ return r !== undefined && r >= trust.minRank;
34
+ }));
35
+ const kept = messages.filter((_, i) => keep[i]);
36
+ if (kept.length < messages.length && /(^|,)\s*(\*|SlackContext)\s*(,|$)/.test(process.env.DEBUG ?? '')) {
37
+ console.log(`[slack-context] dropped ${messages.length - kept.length}/${messages.length} messages (author rank < ${trust.minRank}, bot, or unresolved)`);
38
+ }
39
+ return kept;
40
+ }
18
41
 
42
+ /** Channel summary of the last 20 messages; with `trust`, summarized from trusted messages only (cached per floor). */
43
+ export async function getChannelContext(channel: string, trust?: AuthorTrust): Promise<string | null> {
44
+ const key = trust ? `${channel}|${trust.minRank}` : channel;
45
+ const cached = cache.get(key);
46
+ if (cached && cached.expiresAt > Date.now()) return cached.summary;
47
+
48
+ const history = await getChannelHistory(channel, 20).catch(() => []);
19
49
  const botId = await getBotUserId();
20
50
  const agentUid = await getAgentUserId();
51
+ const messages = trust ? await trustedMessages(history, [botId, agentUid], trust) : history;
52
+ if (!messages.length) return null;
53
+
21
54
  const lines: string[] = [];
22
55
  for (const msg of messages) {
23
56
  const isBot = !!msg.bot_id || msg.user === botId || (agentUid && msg.user === agentUid);
@@ -33,10 +66,10 @@ export async function getChannelContext(channel: string): Promise<string | null>
33
66
  );
34
67
  if (!summary) return null;
35
68
 
36
- cache.set(channel, { summary, expiresAt: Date.now() + TTL_MS });
69
+ cache.set(key, { summary, expiresAt: Date.now() + TTL_MS });
37
70
  return summary;
38
71
  }
39
72
 
40
73
  export function invalidateChannelContext(channel: string): void {
41
- cache.delete(channel);
74
+ for (const key of cache.keys()) if (key === channel || key.startsWith(`${channel}|`)) cache.delete(key);
42
75
  }
@@ -45,7 +45,10 @@ import {
45
45
  appendMessage, upsertSession, setRunStatus, acquireSessionLock, releaseSessionLock,
46
46
  type ConvBlock,
47
47
  } from '../sessions.ts';
48
- import { validateApiKey } from '../api-keys.ts';
48
+ import { apiKeyPrincipal, validateApiKey } from '../api-keys.ts';
49
+ import type { Principal } from '../security/principal.ts';
50
+ import { admitTurn, requestIp } from '../security/runtime.ts';
51
+ import { writeDenial } from '../security/guard.ts';
49
52
  import { WebhookStreamer, type WebhookTarget } from './streamer.ts';
50
53
 
51
54
  /** One receiver connection, as learned from a turn. Handed to `onTurnAccepted` so an add-on can
@@ -107,9 +110,11 @@ export async function runWebhookTurn(args: {
107
110
  callback: WebhookTarget; convId: string; msgId: string; sessionId: string; prompt: string;
108
111
  uid: string; userEmail: string; sendSegments: boolean;
109
112
  channel: string; source: string;
113
+ /** Who the turn runs for — the API key holder for the built-in ingress. */
114
+ principal: Principal;
110
115
  streamer?: WebhookLaneOptions['streamer'];
111
116
  }): Promise<void> {
112
- const { callback, convId, msgId, sessionId, prompt, uid, userEmail, sendSegments, channel, source } = args;
117
+ const { callback, convId, msgId, sessionId, prompt, uid, userEmail, sendSegments, channel, source, principal } = args;
113
118
  const streamer = new WebhookStreamer({ callback, convId, msgId, sendSegments, ...(args.streamer ?? {}) });
114
119
  const abortController = new AbortController();
115
120
 
@@ -130,7 +135,7 @@ export async function runWebhookTurn(args: {
130
135
  let text = '';
131
136
  try {
132
137
  for await (const ev of streamChat({
133
- prompt, sessionId, uid, userEmail,
138
+ principal, prompt, sessionId, uid, userEmail,
134
139
  mcpServers: getMcpConfig(uid),
135
140
  abortController,
136
141
  context: { source, user: userEmail },
@@ -203,6 +208,11 @@ export function createWebhookLaneFeature(opts: WebhookLaneOptions): ServerFeatur
203
208
  if (u.protocol !== 'https:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') throw new Error('https required');
204
209
  } catch { return void res.status(400).json({ error: 'callback.url must be a valid HTTPS URL' }); }
205
210
 
211
+ // Guard before any spend: blocklist → rate → concurrency (shadow unless SECURITY_ENFORCE).
212
+ const principal = apiKeyPrincipal(caller); // carries the key's role cap, so guard rank and the turn's role agree
213
+ const admission = admitTurn(principal, { ip: requestIp(req), channel });
214
+ if (!admission.ok) return void writeDenial(res, admission);
215
+
206
216
  const cb: WebhookTarget = { url: callback.url, secret: callback.secret, connId };
207
217
  try {
208
218
  opts.onTurnAccepted?.({ ...cb, convId, at: Date.now(), uid: caller.uid, email: caller.email });
@@ -212,13 +222,14 @@ export function createWebhookLaneFeature(opts: WebhookLaneOptions): ServerFeatur
212
222
 
213
223
  // ACCEPT, then run. The answer arrives on the callback, so holding this response open would
214
224
  // only give the caller's trigger a socket to time out on.
215
- res.json({ status: 'accepted', sessionId: sessionId || convId });
225
+ try { res.json({ status: 'accepted', sessionId: sessionId || convId }); }
226
+ catch (err) { admission.release(); throw err; }
216
227
  void runWebhookTurn({
217
228
  callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
218
- uid: caller.uid, userEmail: caller.email,
229
+ uid: caller.uid, userEmail: caller.email, principal,
219
230
  sendSegments: Array.isArray(accepts) && accepts.includes('segments'),
220
231
  channel, source, streamer: opts.streamer,
221
- });
232
+ }).finally(admission.release);
222
233
  });
223
234
 
224
235
  console.log(`[${opts.name}] turn ingress mounted at POST ${opts.route}`);
@@ -0,0 +1,11 @@
1
+ /** Model used when neither directives nor config specify one. Always passed
2
+ * explicitly to the SDK — the CLI's own default silently drifts (it picked
3
+ * Opus 4.7), which burns rate limits and budget.
4
+ *
5
+ * Shared because the UI must name the SAME default the server will actually run:
6
+ * a second literal in the client drifts out of date and makes the header report a
7
+ * model no turn would use. */
8
+ export const DEFAULT_MODEL = 'claude-sonnet-5';
9
+
10
+ /** Default for the `cursor` engine, named here for the same reason. */
11
+ export const DEFAULT_CURSOR_MODEL = 'cursor/composer-2.5';