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.
Files changed (59) hide show
  1. package/README.md +4 -1
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/skills/mcp-server.md +12 -5
  4. package/defaults/skills/platform.md +4 -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 +112 -4
  29. package/src/server/data-sync.ts +55 -6
  30. package/src/server/directives.ts +10 -5
  31. package/src/server/engine/claude-code.ts +190 -32
  32. package/src/server/engine/claude-resume.ts +205 -0
  33. package/src/server/engine/types.ts +10 -0
  34. package/src/server/hooks.ts +19 -0
  35. package/src/server/mcp-oauth.ts +24 -5
  36. package/src/server/mcp-server.ts +55 -25
  37. package/src/server/modules/routes.ts +2 -6
  38. package/src/server/notify-owners.ts +5 -17
  39. package/src/server/owners.ts +14 -0
  40. package/src/server/scheduler/builtins.ts +3 -1
  41. package/src/server/scheduler/runner.ts +3 -0
  42. package/src/server/security/audit.ts +498 -0
  43. package/src/server/security/enforce.ts +306 -0
  44. package/src/server/security/escalate.ts +194 -0
  45. package/src/server/security/guard.ts +329 -0
  46. package/src/server/security/owner-only.ts +15 -0
  47. package/src/server/security/owner-routes.ts +43 -0
  48. package/src/server/security/policy.ts +413 -0
  49. package/src/server/security/principal.ts +80 -0
  50. package/src/server/security/revocation.ts +50 -0
  51. package/src/server/security/runtime.ts +174 -0
  52. package/src/server/sessions.ts +51 -9
  53. package/src/server/shraga-config.ts +3 -0
  54. package/src/server/slack/bot.ts +44 -11
  55. package/src/server/slack/context-cache.ts +40 -7
  56. package/src/server/webhook-lane/feature.ts +17 -6
  57. package/src/shared/models.ts +11 -0
  58. package/dist/client/assets/index-BNAh4GUs.js +0 -1949
  59. package/dist/client/assets/index-DIMte_k6.css +0 -10
@@ -3,7 +3,8 @@ import { summarizeText } from './summarize.ts';
3
3
  import { dataSync } from './data-sync.ts';
4
4
  import type { McpConfig } from './mcp.ts';
5
5
  import type { ClaudeAccountRef } from './claude-account.ts';
6
- import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, type ConvMessage, type ConvBlock } from './sessions.ts';
6
+ import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, setClaudeResume, getSessionFloor, lowerSessionFloor, type ConvMessage, type ConvBlock } from './sessions.ts';
7
+ import { enforcing, TurnGuard } from './security/enforce.ts';
7
8
  import { createTurnAccumulator, type TurnStreamHooks } from './turn-stream.ts';
8
9
  import {
9
10
  resolveDefaultSkillsContent,
@@ -25,6 +26,8 @@ import { getUserContextBlock } from './user-context.ts';
25
26
  import { collectTurnContext } from './turn-context.ts';
26
27
  import { DATA_DIR, dataPath } from './paths.ts';
27
28
  import * as contacts from './contacts.ts';
29
+ import type { Principal } from './security/principal.ts';
30
+ import { security, resolvePrincipal } from './security/runtime.ts';
28
31
  import { resolveAndGetEngine, ModelUnavailableError } from './engine/index.ts';
29
32
 
30
33
  const CONFIG_PATH = dataPath('agent-config.json');
@@ -197,7 +200,10 @@ function applyCompactMarkers(conv: ConvMessage[]): ConvMessage[] {
197
200
 
198
201
  export interface AttachmentMeta { url: string; name: string; mimeType: string; path: string }
199
202
 
200
- export async function* streamChat(opts: {
203
+ export interface StreamChatOpts {
204
+ /** Who this turn runs for. Required so every channel is enumerated by the compiler. Shadow mode:
205
+ * resolved + audited (turn.start/turn.end), not yet enforced. */
206
+ principal: Principal;
201
207
  prompt: string;
202
208
  attachments?: AttachmentMeta[];
203
209
  images?: string[];
@@ -217,7 +223,85 @@ export async function* streamChat(opts: {
217
223
  * where an add-on's contributor reads its own keys. */
218
224
  turnHints?: Record<string, unknown>;
219
225
  context?: Record<string, string>;
220
- }): AsyncGenerator<WsEvent> {
226
+ }
227
+
228
+ /** Run one agent turn. Security wrapper: resolves the principal's role/profile and audits turn.start / turn.end.
229
+ * SHADOW (SECURITY_ENFORCE off): the turn is unchanged; auditing can never alter or break it.
230
+ * ENFORCE: the session floor is lowered to the caller's rank (taint), the turn runs under a TurnGuard whose
231
+ * effective profile the engine applies, and it FAILS CLOSED — no runtime, a resolution error, or an effective
232
+ * profile with `outbound: false` (e.g. `none`) refuses the turn with an `error` event. */
233
+ export async function* streamChat(opts: StreamChatOpts): AsyncGenerator<WsEvent> {
234
+ const sec = security();
235
+ const enforce = enforcing();
236
+ if (!sec) {
237
+ if (!enforce) { yield* runTurn(opts); return; }
238
+ console.error('[security] SECURITY_ENFORCE is on but the security runtime is not initialized — refusing the turn');
239
+ yield { type: 'error', message: 'Security enforcement is on but not initialized, so this turn was not run.' };
240
+ return;
241
+ }
242
+ const { principal, sessionId } = opts;
243
+ let role: string | undefined;
244
+ let guard: TurnGuard | undefined;
245
+ let refusal: string | undefined;
246
+ try {
247
+ const d = sec.decide(principal, sessionId);
248
+ role = d.role;
249
+ let enforced: Record<string, unknown> = {};
250
+ if (enforce) {
251
+ // A broken policy fails closed at resolve time already; it must not ALSO permanently taint every session it touches.
252
+ if (sessionId && sec.policy.valid) lowerSessionFloor(sessionId, d.rank);
253
+ guard = new TurnGuard({ runtime: sec, principal, sessionId, role: d.role, rank: d.rank, floorOf: getSessionFloor });
254
+ const eff = guard.current();
255
+ if (!eff.profile.outbound) refusal = `This conversation runs as role "${eff.role}", which is not permitted to run the agent.`;
256
+ enforced = { enforced: true, effectiveRole: eff.role, effectiveProfile: eff.profileName, floor: sessionId ? getSessionFloor(sessionId) : undefined };
257
+ }
258
+ sec.record({ type: 'turn.start', principal: principal.id, role, sessionId, meta: { kind: principal.kind, lane: principal.attrs.lane, source: opts.context?.source, profile: d.profile, rank: d.rank, wouldDeny: d.wouldDeny, ...enforced } });
259
+ } catch (e: any) {
260
+ console.error('[security] turn.start audit failed:', e.message);
261
+ if (enforce) refusal = 'The security check for this turn failed, so it was not run.';
262
+ }
263
+ const started = Date.now();
264
+ let outcome = 'closed';
265
+ try {
266
+ if (enforce && (refusal || !guard)) {
267
+ outcome = 'denied';
268
+ console.warn(`[security] Refused turn for ${principal.id} session=${sessionId ?? 'new'}: ${refusal}`);
269
+ yield { type: 'error', message: refusal ?? 'The security check for this turn failed, so it was not run.' };
270
+ return;
271
+ }
272
+ for await (const ev of runTurn(opts, guard)) {
273
+ if (ev.type === 'done') outcome = 'done';
274
+ else if (ev.type === 'error') outcome = 'error';
275
+ yield ev;
276
+ }
277
+ if (outcome === 'closed') outcome = 'done';
278
+ } catch (e) {
279
+ outcome = 'threw';
280
+ throw e;
281
+ } finally {
282
+ try { sec.record({ type: 'turn.end', principal: principal.id, role, sessionId, reason: outcome, meta: { ms: Date.now() - started } }); }
283
+ catch (e: any) { console.error('[security] turn.end audit failed:', e.message); }
284
+ }
285
+ }
286
+
287
+ /** SECURITY_ENFORCE: `principal`'s rank under the live policy. Undefined in shadow mode, without a runtime, or on an
288
+ * invalid (fail-closed) policy — callers then apply no rank-based filtering. */
289
+ export function enforcedRank(principal: Principal): number | undefined {
290
+ const sec = security();
291
+ return enforcing() && sec?.policy.valid ? resolvePrincipal(sec.policy, principal).rank : undefined;
292
+ }
293
+
294
+ /** SECURITY_ENFORCE: input from `principal` entered `sessionId` outside a turn it runs (e.g. another human's Slack
295
+ * thread message) — lower the session floor to that principal's rank. No-op in shadow mode, without a runtime, or
296
+ * on an invalid (fail-closed) policy. Returns the new floor, or undefined when nothing was done. */
297
+ export async function taintSession(sessionId: string, principal: Principal | (() => Promise<Principal>)): Promise<number | undefined> {
298
+ const sec = security();
299
+ if (!enforcing() || !sec?.policy.valid) return undefined;
300
+ const p = typeof principal === 'function' ? await principal() : principal;
301
+ return lowerSessionFloor(sessionId, resolvePrincipal(sec.policy, p).rank);
302
+ }
303
+
304
+ async function* runTurn(opts: StreamChatOpts, guard?: TurnGuard): AsyncGenerator<WsEvent> {
221
305
  const config = getAgentConfig();
222
306
  const { prompt: cleanPrompt, directives: parsed, unresolvedModel } = parseDirectives(opts.prompt);
223
307
 
@@ -324,7 +408,10 @@ export async function* streamChat(opts: {
324
408
  const stickyNames = opts.sessionId ? getSession(opts.sessionId)?.triggeredSkills ?? [] : [];
325
409
  const newTriggerNames = discoveryEnabled ? matchTriggeredSkillNames(effectivePrompt, opts.context) : [];
326
410
  const triggeredNames = [...new Set([...stickyNames, ...newTriggerNames])];
327
- const triggeredSkills = skillInjectionBlocks(triggeredNames);
411
+ // Per-name blocks so an engine that resumes across turns can send only newly triggered ones. Joined
412
+ // exactly as skillInjectionBlocks(triggeredNames) joins them, so contextBlock is byte-identical.
413
+ const triggeredSkillBlocks = triggeredNames.map((n) => [n, skillInjectionBlocks([n])] as const).filter(([, b]) => b);
414
+ const triggeredSkills = triggeredSkillBlocks.map(([, b]) => b).join('\n');
328
415
 
329
416
  // Per-skill turn budget. Resolved HERE, once every invoked skill is known (the slash command and
330
417
  // the triggered/sticky set), and GAP-FILLING only: an inline `[turns:N]` or a session-pinned
@@ -347,6 +434,11 @@ export async function* streamChat(opts: {
347
434
  const teamRoster = contacts.formatRoster();
348
435
  const userContextBlock = getUserContextBlock(contact);
349
436
  const contextBlock = [userBlock, userContextBlock, teamRoster, defaultSkills, triggeredSkills, skillIndex, mcpSkills, workspaceTree].filter(Boolean).join('\n');
437
+ const contextSections: Record<string, string> = Object.fromEntries(Object.entries({
438
+ user: userBlock, userContext: userContextBlock, roster: teamRoster, defaultSkills,
439
+ ...Object.fromEntries(triggeredSkillBlocks.map(([n, b]) => [`skill:${n}`, b])),
440
+ skillIndex, mcpSkills, workspace: workspaceTree,
441
+ }).filter(([, v]) => v));
350
442
 
351
443
  // Load conversation for the engine
352
444
  const sessionId = opts.sessionId ?? crypto.randomUUID();
@@ -387,12 +479,27 @@ export async function* streamChat(opts: {
387
479
  yield { type: 'error', message };
388
480
  return;
389
481
  }
482
+ // An engine that doesn't apply the profile can only run a turn that needs no restriction.
483
+ if (guard && !engine.enforcesProfile) {
484
+ const { profile, role } = guard.current();
485
+ if (!(profile.tools.includes('*') && profile.mcps.includes('*') && profile.env.includes('*'))) {
486
+ const message = `Engine "${engine.name}" does not enforce security profiles, so it cannot run this conversation's role "${role}".`;
487
+ console.error(`[security] ${message} (user=${opts.uid} session=${sessionId})`);
488
+ yield { type: 'error', message };
489
+ return;
490
+ }
491
+ }
390
492
  console.log(`[stream] engine=${engine.name} user=${opts.uid} session=${sessionId}`);
493
+ // Another engine's turn never reaches the claude-code transcript, so a stored SDK-resume mapping is
494
+ // stale from here on — mark it; the claude-code engine then falls back to a fresh query (engine-switch).
495
+ const resumeState = getSession(sessionId)?.claudeResume;
496
+ if (engine.name !== 'claude-code' && resumeState && !resumeState.interruptedBy) setClaudeResume(sessionId, undefined, { interruptedBy: engine.name });
391
497
 
392
498
  yield* engine.stream({
393
499
  prompt: turnPrompt,
394
500
  conversation,
395
501
  contextBlock,
502
+ contextSections,
396
503
  attachments: opts.attachments,
397
504
  images: opts.images,
398
505
  sessionId,
@@ -408,6 +515,7 @@ export async function* streamChat(opts: {
408
515
  turnHints: opts.turnHints,
409
516
  conversationReset: opts.conversationReset,
410
517
  context: opts.context,
518
+ ...(guard ? { security: guard } : {}),
411
519
  directives,
412
520
  config,
413
521
  });
@@ -4,9 +4,13 @@ import path from 'node:path';
4
4
  import { DATA_DIR } from './paths.ts';
5
5
  import { notifyOwners } from './notify-owners.ts';
6
6
  import { runTextQuery } from './sdk-utils.ts';
7
+ import { GENESIS_HASH, readAuditHead } from './security/audit.ts';
7
8
 
8
9
  const TAG = '[data-sync]';
9
10
  const DEPLOYMENT_ID_FILE = '.deployment-id';
11
+ /** Tracked, append-only, single-writer (security/audit.ts): committed on every flush, never pulled over, never stashed. */
12
+ const AUDIT_DIR = 'audit';
13
+ const NOT_AUDIT = ['--', '.', `:(exclude)${AUDIT_DIR}`];
10
14
 
11
15
  /** How long the LLM commit-message call may take before we fall back (ms). */
12
16
  const COMMIT_MSG_TIMEOUT_MS = 60_000;
@@ -357,11 +361,32 @@ export class DataSync {
357
361
  return;
358
362
  }
359
363
 
360
- // Stash dirty + untracked files before merging (untracked can block merge if remote adds same paths)
361
- const dirty = !!(await this.git('status', '--porcelain')).trim();
364
+ // The audit log is append-only with ONE writer, this instance. A remote commit touching audit/ would rewrite the local
365
+ // chain (and under `chattr +a` git can't even apply it) — refuse the whole pull and alert.
366
+ let incomingAudit: string;
367
+ try {
368
+ incomingAudit = (await this.git('diff', '--name-only', `HEAD...origin/${this.options.branch}`, '--', AUDIT_DIR)).trim();
369
+ } catch (err) {
370
+ console.warn(`${TAG} Audit pull check failed, skipping pull:`, (err as Error).message);
371
+ return;
372
+ }
373
+ if (incomingAudit) {
374
+ console.error(`${TAG} 🚫 Remote commits change the audit log — pull refused:\n${incomingAudit}`);
375
+ await this.alertOnce('audit-pull', incomingAudit,
376
+ `🚫 Data sync refused a pull: remote commits change the audit log, which only this instance writes.\n\n${incomingAudit}\n\n` +
377
+ `Inspect: \`cd data && git log origin/${this.options.branch} -- ${AUDIT_DIR}\``,
378
+ ).catch(err => console.warn(`${TAG} Audit pull notify failed:`, (err as Error).message));
379
+ return;
380
+ }
381
+ this.clearAlert('audit-pull');
382
+
383
+ // Stash dirty + untracked files before merging (untracked can block merge if remote adds same paths) — except audit/:
384
+ // stashing removes the live log from disk while the server appends to it (lost lines, forked chain) and fails under
385
+ // `chattr +a`. The merge can't touch audit/ (checked above), so it stays dirty in place.
386
+ const dirty = !!(await this.git('status', '--porcelain', ...NOT_AUDIT)).trim();
362
387
  if (dirty) {
363
388
  try {
364
- await this.git('stash', 'push', '--include-untracked', '-m', 'data-sync: pre-pull stash');
389
+ await this.git('stash', 'push', '--include-untracked', '-m', 'data-sync: pre-pull stash', ...NOT_AUDIT);
365
390
  } catch (err) {
366
391
  console.warn(`${TAG} Stash failed, skipping pull:`, (err as Error).message);
367
392
  return;
@@ -441,12 +466,19 @@ export class DataSync {
441
466
  }
442
467
  }
443
468
 
469
+ // The audit log rides along with every sync commit — the data repo is its offsite copy — and the commit message
470
+ // anchors its head. Head read BEFORE staging: the committed chain always contains that hash.
471
+ const auditHead = this.auditHead();
472
+ if (existsSync(path.join(DATA_DIR, AUDIT_DIR))) {
473
+ await this.git('add', '--', AUDIT_DIR).catch(err => console.warn(`${TAG} Staging audit log failed:`, (err as Error).message));
474
+ }
475
+
444
476
  const status = await this.git('status', '--porcelain');
445
477
  if (!status.trim()) return;
446
478
 
447
479
  if (await this.guardMassDeletions('flush')) return;
448
480
  const msg = await this.generateCommitMessage(files);
449
- await this.git('commit', '-m', msg).catch(() => {});
481
+ await this.git('commit', '-m', auditHead ? `${msg}\n\naudit-head: ${auditHead}` : msg).catch(() => {});
450
482
  const ahead = await this.git('rev-list', '--count', `origin/${this.options.branch}..HEAD`).catch(() => '0');
451
483
  if (parseInt(ahead.trim()) === 0) return;
452
484
  await this.git('push', 'origin', this.options.branch).catch(async (err) => {
@@ -468,11 +500,23 @@ export class DataSync {
468
500
  }
469
501
  }
470
502
 
503
+ /** Audit chain head for the commit anchor; undefined when there's no record yet or it can't be read (logged). */
504
+ private auditHead(): string | undefined {
505
+ try {
506
+ const head = readAuditHead(path.join(DATA_DIR, AUDIT_DIR));
507
+ return head === GENESIS_HASH ? undefined : head;
508
+ } catch (err) {
509
+ console.warn(`${TAG} Audit head unreadable, committing without anchor:`, (err as Error).message);
510
+ return undefined;
511
+ }
512
+ }
513
+
471
514
  private async generateCommitMessage(files: string[]): Promise<string> {
472
515
  const fallback = fallbackCommitMessage(files);
473
516
  try {
474
- const diff = await this.git('diff', '--cached', '--stat').catch(() => '');
475
- const diffContent = await this.git('diff', '--cached', '--no-color', '-U2').catch(() => '');
517
+ // The audit log's appended lines are not "behavioral config" and would crowd the real diff out of the prompt.
518
+ const diff = await this.git('diff', '--cached', '--stat', ...NOT_AUDIT).catch(() => '');
519
+ const diffContent = await this.git('diff', '--cached', '--no-color', '-U2', ...NOT_AUDIT).catch(() => '');
476
520
  if (!diffContent.trim()) return fallback;
477
521
  const truncated = diffContent.slice(0, 3000);
478
522
  // Bounded: a hung `claude` subprocess used to wedge flush() forever (it holds the push latch).
@@ -761,7 +805,12 @@ export class DataSync {
761
805
  'uploads/', 'repos/', '.tmp/', 'schedules.json.bak', 'git-log.json',
762
806
  '.internal-token', 'comms-log.jsonl', 'sessions/', 'unread/', '.DS_Store',
763
807
  'scheduler/', '.mcp-catalog.json',
808
+ 'api-keys.json.bak', // pre-hashing plaintext keys (api-keys.ts migration) — never commit
764
809
  'workspace/users/*/.claude/', // per-user Claude logins (claude-account.ts) — credentials, never commit
810
+ // Live security state of the ACTIVE instance, which is its single writer — a pull must never overwrite it:
811
+ // blocks.json (guard auto-blocks); policy.json + .migrated (the Owner Console writes them; a pulled change trips
812
+ // the policy provenance/tamper check). Blue-green shares one DATA_DIR on one host, so nothing needs to cross hosts.
813
+ 'security/',
765
814
  ];
766
815
 
767
816
  /** Write or refresh .gitignore, appending any canonical entries it's missing (idempotent). */
@@ -4,6 +4,8 @@ export interface Directives {
4
4
  thinking?: 'adaptive' | 'enabled' | 'disabled';
5
5
  effort?: 'low' | 'medium' | 'high' | 'max';
6
6
  engine?: string;
7
+ /** claude-code SDK session resume for this conversation (overrides agent-config `sdkResume`). */
8
+ resume?: boolean;
7
9
  }
8
10
 
9
11
  export interface ParsedPrompt {
@@ -16,10 +18,8 @@ export interface ParsedPrompt {
16
18
  unresolvedModel?: string;
17
19
  }
18
20
 
19
- /** Model used when neither directives nor config specify one. Always passed
20
- * explicitly to the SDK — the CLI's own default silently drifts (it picked
21
- * Opus 4.7), which burns rate limits and budget. */
22
- 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';
23
23
 
24
24
  // Canonical model aliases + label. Vendored, pure, dependency-free (src/server/model-aliases.ts).
25
25
  // Re-exported here so the rest of shraga keeps importing model helpers from one place.
@@ -28,7 +28,7 @@ import { MODEL_ALIASES } from './model-aliases.ts';
28
28
 
29
29
  const DIRECTIVE_RE = /^\s*\[([^\]]*)\]\s*([\s\S]*)/;
30
30
 
31
- const DIRECTIVE_KEYS = ['model', 'turns', 'thinking', 'think', 'effort', 'engine'];
31
+ const DIRECTIVE_KEYS = ['model', 'turns', 'thinking', 'think', 'effort', 'engine', 'resume'];
32
32
 
33
33
  /** MODEL_ALIASES only covers the bare Anthropic shorthands. A `provider/model` id
34
34
  * (`cursor/composer-2.5`, `openai/gpt-5.6`) is already concrete — gating it on the alias table
@@ -179,6 +179,11 @@ function applyDirective(d: Directives, key: string, val: string): string | undef
179
179
  case 'engine':
180
180
  d.engine = val;
181
181
  break;
182
+ case 'resume':
183
+ if (['on', 'true', '1'].includes(val)) d.resume = true;
184
+ else if (['off', 'false', '0'].includes(val)) d.resume = false;
185
+ else console.warn(`[directives] Invalid resume value: "${val}"`);
186
+ break;
182
187
  default:
183
188
  console.warn(`[directives] Unknown directive key: "${key}"`);
184
189
  }