shraga 0.1.111 → 0.1.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/defaults/mcps/README.md +6 -3
- package/defaults/skills/mcp-server.md +12 -5
- package/defaults/skills/platform.md +4 -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 +112 -4
- package/src/server/data-sync.ts +55 -6
- package/src/server/directives.ts +10 -5
- package/src/server/engine/claude-code.ts +190 -32
- package/src/server/engine/claude-resume.ts +205 -0
- package/src/server/engine/types.ts +10 -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 +51 -9
- package/src/server/shraga-config.ts +3 -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-BNAh4GUs.js +0 -1949
- package/dist/client/assets/index-DIMte_k6.css +0 -10
|
@@ -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; }
|
package/src/server/sessions.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
|
|
|
5
5
|
import { DATA_DIR, dataPath } from './paths.ts';
|
|
6
6
|
import type { Directives } from './directives.ts';
|
|
7
7
|
import type { ClaudeAccountRef } from './claude-account.ts';
|
|
8
|
+
import { findClaudeTranscript, type ClaudeResumeState } from './engine/claude-resume.ts';
|
|
8
9
|
|
|
9
10
|
const SESSIONS_PATH = dataPath('sessions.json');
|
|
10
11
|
|
|
@@ -45,7 +46,11 @@ export interface SessionMeta {
|
|
|
45
46
|
/** Claude login the last turn ran on (claude-code engine, subscription auth only). `personal` = the
|
|
46
47
|
* requester's own routed login (claude-account.ts), false = the box's shared login. Never a token. */
|
|
47
48
|
lastAccount?: ClaudeAccountRef;
|
|
49
|
+
/** claude-code engine SDK-resume mapping (opt-in flag) — see engine/claude-resume.ts. */
|
|
50
|
+
claudeResume?: ClaudeResumeState;
|
|
48
51
|
forkedFrom?: string;
|
|
52
|
+
/** Security taint (SECURITY_ENFORCE): rank of the lowest-ranked principal that ever contributed. Only lowers. */
|
|
53
|
+
floorRank?: number;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
// ── Index cache ──────────────────────────────────────────────────────────────
|
|
@@ -219,6 +224,7 @@ export function upsertSession(sessionId: string, firstPrompt: string, user: { ui
|
|
|
219
224
|
lastModified: now,
|
|
220
225
|
...(scope ? { scope } : {}),
|
|
221
226
|
...(kind ? { kind } : {}),
|
|
227
|
+
...floorField(sessionId),
|
|
222
228
|
});
|
|
223
229
|
}
|
|
224
230
|
|
|
@@ -383,6 +389,47 @@ export function setSessionModel(sessionId: string, model: string, engine?: strin
|
|
|
383
389
|
}
|
|
384
390
|
}
|
|
385
391
|
|
|
392
|
+
/** Store (or clear, with undefined) the claude-code resume mapping. `patch` merges into the existing one. */
|
|
393
|
+
export function setClaudeResume(sessionId: string, state: ClaudeResumeState | undefined, patch?: Partial<ClaudeResumeState>): void {
|
|
394
|
+
const sessions = loadIndex();
|
|
395
|
+
const s = sessions.find((x) => x.sessionId === sessionId);
|
|
396
|
+
if (!s) return;
|
|
397
|
+
if (patch) { if (!s.claudeResume) return; s.claudeResume = { ...s.claudeResume, ...patch }; }
|
|
398
|
+
else if (state) s.claudeResume = state;
|
|
399
|
+
else if (s.claudeResume) delete s.claudeResume;
|
|
400
|
+
else return;
|
|
401
|
+
saveIndex(sessions);
|
|
402
|
+
}
|
|
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
|
+
|
|
386
433
|
export function getSessionsByScheduleId(scheduleId: string): SessionMeta[] {
|
|
387
434
|
return loadIndex()
|
|
388
435
|
.filter((s) => s.scheduleId === scheduleId)
|
|
@@ -405,6 +452,7 @@ export function createScheduledSession(sessionId: string, scheduleId: string, ti
|
|
|
405
452
|
scheduleId,
|
|
406
453
|
scheduleRunAt: now,
|
|
407
454
|
scheduleRunStatus: 'running',
|
|
455
|
+
...floorField(sessionId),
|
|
408
456
|
});
|
|
409
457
|
saveIndex(sessions);
|
|
410
458
|
}
|
|
@@ -528,16 +576,8 @@ export function getSessionAbortController(sessionId: string): AbortController |
|
|
|
528
576
|
return globalSessionLocks.get(sessionId)?.abortController;
|
|
529
577
|
}
|
|
530
578
|
|
|
531
|
-
const CLAUDE_PROJECTS_DIR = path.join(homedir(), '.claude', 'projects');
|
|
532
|
-
|
|
533
579
|
function findSessionFile(sessionId: string): string | null {
|
|
534
|
-
|
|
535
|
-
for (const dir of readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true })) {
|
|
536
|
-
if (!dir.isDirectory()) continue;
|
|
537
|
-
const file = path.join(CLAUDE_PROJECTS_DIR, dir.name, `${sessionId}.jsonl`);
|
|
538
|
-
if (existsSync(file)) return file;
|
|
539
|
-
}
|
|
540
|
-
return null;
|
|
580
|
+
return findClaudeTranscript(sessionId, path.join(homedir(), '.claude'));
|
|
541
581
|
}
|
|
542
582
|
|
|
543
583
|
// ── Our own conversation store ───────────────────────────────────────────────
|
|
@@ -703,6 +743,8 @@ export function forkSession(sourceId: string, user: { uid: string; email: string
|
|
|
703
743
|
lastModified: now,
|
|
704
744
|
forkedFrom: sourceId,
|
|
705
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)! } : {}),
|
|
706
748
|
});
|
|
707
749
|
saveIndex(sessions);
|
|
708
750
|
|
|
@@ -52,6 +52,9 @@ export interface AgentSettings {
|
|
|
52
52
|
skillDiscovery?: boolean;
|
|
53
53
|
thinking?: 'adaptive' | 'enabled' | 'disabled';
|
|
54
54
|
effort?: 'low' | 'medium' | 'high' | 'max';
|
|
55
|
+
/** claude-code engine: resume the SDK session across turns instead of re-sending the history
|
|
56
|
+
* (prompt-cache savings). Default off; a session's `[resume:on|off]` directive wins. */
|
|
57
|
+
sdkResume?: boolean;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export interface ShragaConfig {
|
package/src/server/slack/bot.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
225
|
-
let cleanRoot =
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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(
|
|
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.
|
|
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';
|