shraga 0.1.112 → 0.1.114

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 (73) hide show
  1. package/README.md +10 -1
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/scripts/agent-once.ts +2 -0
  4. package/defaults/security/policy.example.json +27 -0
  5. package/defaults/skills/mcp-server.md +15 -14
  6. package/defaults/skills/platform.md +3 -3
  7. package/defaults/system-prompt.md +1 -1
  8. package/dist/client/assets/index-CFxKc8dH.css +10 -0
  9. package/dist/client/assets/index-DwmlZAEF.js +2009 -0
  10. package/dist/client/index.html +2 -2
  11. package/package.json +3 -2
  12. package/src/client/App.tsx +33 -8
  13. package/src/client/components/BackendStatusBanner.tsx +62 -0
  14. package/src/client/components/ConfigPanel.tsx +83 -15
  15. package/src/client/components/ConversationHeader.tsx +5 -1
  16. package/src/client/components/McpManager.tsx +26 -9
  17. package/src/client/components/Sidebar.tsx +16 -1
  18. package/src/client/components/SkillsManager.tsx +48 -27
  19. package/src/client/components/owner/ApiKeysTab.tsx +87 -0
  20. package/src/client/components/owner/AuditTab.tsx +110 -0
  21. package/src/client/components/owner/BindingsTab.tsx +110 -0
  22. package/src/client/components/owner/BlocklistTab.tsx +96 -0
  23. package/src/client/components/owner/OwnerConsole.tsx +87 -0
  24. package/src/client/components/owner/PrincipalsTab.tsx +55 -0
  25. package/src/client/components/owner/RolesTab.tsx +132 -0
  26. package/src/client/components/owner/shared.tsx +84 -0
  27. package/src/client/hooks/useAuth.ts +11 -2
  28. package/src/client/hooks/useIsOwner.ts +24 -0
  29. package/src/client/hooks/useModules.ts +5 -1
  30. package/src/client/hooks/useOwner.ts +20 -0
  31. package/src/client/lib/api.ts +23 -7
  32. package/src/client/lib/backendHealth.ts +230 -0
  33. package/src/client/lib/debug.ts +48 -0
  34. package/src/client/lib/sessionApi.ts +24 -8
  35. package/src/client/lib/ws.ts +21 -13
  36. package/src/index.ts +10 -2
  37. package/src/scripts/harden-audit.sh +55 -0
  38. package/src/server/agent-config.ts +34 -0
  39. package/src/server/api-key-routes.ts +66 -0
  40. package/src/server/api-keys.ts +181 -43
  41. package/src/server/auth.ts +113 -47
  42. package/src/server/boot.ts +161 -104
  43. package/src/server/claude.ts +102 -26
  44. package/src/server/data-sync.ts +56 -6
  45. package/src/server/directives.ts +2 -4
  46. package/src/server/engine/claude-code.ts +78 -17
  47. package/src/server/engine/types.ts +7 -0
  48. package/src/server/hooks.ts +19 -0
  49. package/src/server/mcp-oauth.ts +24 -5
  50. package/src/server/mcp-server.ts +55 -25
  51. package/src/server/modules/routes.ts +2 -6
  52. package/src/server/notify-owners.ts +5 -17
  53. package/src/server/owners.ts +14 -0
  54. package/src/server/scheduler/builtins.ts +3 -1
  55. package/src/server/scheduler/runner.ts +3 -0
  56. package/src/server/security/audit.ts +505 -0
  57. package/src/server/security/enforce.ts +343 -0
  58. package/src/server/security/escalate.ts +194 -0
  59. package/src/server/security/guard.ts +337 -0
  60. package/src/server/security/owner-only.ts +34 -0
  61. package/src/server/security/owner-routes.ts +285 -0
  62. package/src/server/security/policy.ts +416 -0
  63. package/src/server/security/principal.ts +80 -0
  64. package/src/server/security/revocation.ts +50 -0
  65. package/src/server/security/runtime.ts +226 -0
  66. package/src/server/sessions.ts +38 -0
  67. package/src/server/shraga-config.ts +13 -0
  68. package/src/server/slack/bot.ts +44 -11
  69. package/src/server/slack/context-cache.ts +40 -7
  70. package/src/server/webhook-lane/feature.ts +17 -6
  71. package/src/shared/models.ts +11 -0
  72. package/dist/client/assets/index-DIMte_k6.css +0 -10
  73. package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
@@ -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, setClaudeResume, 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,35 +26,18 @@ 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
- const CONFIG_PATH = dataPath('agent-config.json');
31
-
32
33
  // ── Agent config (shared across users) ──────────────────────────────────────
34
+ // The READ path lives in agent-config.ts (a leaf the security runtime can import without pulling this
35
+ // module's graph); re-exported here so existing importers keep using claude.ts.
33
36
 
34
37
  export type { AgentSettings as AgentConfig } from './shraga-config.ts';
35
38
  import type { AgentSettings as AgentConfig } from './shraga-config.ts';
36
-
37
- const DEFAULT_CONFIG: AgentConfig = {
38
- /** ToolSearch loads deferred MCP tools; without it, permission prompts / tool graph can block Meta Ads tools. */
39
- allowedTools: ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'],
40
- permissionMode: 'acceptEdits',
41
- maxTurns: 15,
42
- // Defaults are what a fresh self-hosted install runs before anyone touches the UI, so they favour
43
- // cost/latency over ceiling. Both are overridable per-deployment via agent-config.json and per-send
44
- // via directives — an operator who wants a bigger model sets it once; every operator pays for a default.
45
- model: 'claude-sonnet-5',
46
- effort: 'low',
47
- };
48
-
49
- export function getAgentConfig(): AgentConfig {
50
- // agent-config.json (UI-writable, git-tracked) is the single source of truth for agent settings.
51
- let config = { ...DEFAULT_CONFIG };
52
- if (existsSync(CONFIG_PATH)) {
53
- try { Object.assign(config, JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))); } catch (e) { console.warn('[claude] failed to parse agent-config.json:', e); }
54
- }
55
- return config;
56
- }
39
+ import { CONFIG_PATH, getAgentConfig } from './agent-config.ts';
40
+ export { getAgentConfig };
57
41
 
58
42
  /**
59
43
  * Which credentials the claude-code SDK will resolve — process-global, derived from env at call time
@@ -197,7 +181,10 @@ function applyCompactMarkers(conv: ConvMessage[]): ConvMessage[] {
197
181
 
198
182
  export interface AttachmentMeta { url: string; name: string; mimeType: string; path: string }
199
183
 
200
- export async function* streamChat(opts: {
184
+ export interface StreamChatOpts {
185
+ /** Who this turn runs for. Required so every channel is enumerated by the compiler. Shadow mode:
186
+ * resolved + audited (turn.start/turn.end), not yet enforced. */
187
+ principal: Principal;
201
188
  prompt: string;
202
189
  attachments?: AttachmentMeta[];
203
190
  images?: string[];
@@ -217,7 +204,85 @@ export async function* streamChat(opts: {
217
204
  * where an add-on's contributor reads its own keys. */
218
205
  turnHints?: Record<string, unknown>;
219
206
  context?: Record<string, string>;
220
- }): AsyncGenerator<WsEvent> {
207
+ }
208
+
209
+ /** Run one agent turn. Security wrapper: resolves the principal's role/profile and audits turn.start / turn.end.
210
+ * SHADOW (SECURITY_ENFORCE off): the turn is unchanged; auditing can never alter or break it.
211
+ * ENFORCE: the session floor is lowered to the caller's rank (taint), the turn runs under a TurnGuard whose
212
+ * effective profile the engine applies, and it FAILS CLOSED — no runtime, a resolution error, or an effective
213
+ * profile with `outbound: false` (e.g. `none`) refuses the turn with an `error` event. */
214
+ export async function* streamChat(opts: StreamChatOpts): AsyncGenerator<WsEvent> {
215
+ const sec = security();
216
+ const enforce = enforcing();
217
+ if (!sec) {
218
+ if (!enforce) { yield* runTurn(opts); return; }
219
+ console.error('[security] SECURITY_ENFORCE is on but the security runtime is not initialized — refusing the turn');
220
+ yield { type: 'error', message: 'Security enforcement is on but not initialized, so this turn was not run.' };
221
+ return;
222
+ }
223
+ const { principal, sessionId } = opts;
224
+ let role: string | undefined;
225
+ let guard: TurnGuard | undefined;
226
+ let refusal: string | undefined;
227
+ try {
228
+ const d = sec.decide(principal, sessionId);
229
+ role = d.role;
230
+ let enforced: Record<string, unknown> = {};
231
+ if (enforce) {
232
+ // A broken policy fails closed at resolve time already; it must not ALSO permanently taint every session it touches.
233
+ if (sessionId && sec.policy.valid) lowerSessionFloor(sessionId, d.rank);
234
+ guard = new TurnGuard({ runtime: sec, principal, sessionId, role: d.role, rank: d.rank, floorOf: getSessionFloor });
235
+ const eff = guard.current();
236
+ if (!eff.profile.outbound) refusal = `This conversation runs as role "${eff.role}", which is not permitted to run the agent.`;
237
+ enforced = { enforced: true, effectiveRole: eff.role, effectiveProfile: eff.profileName, floor: sessionId ? getSessionFloor(sessionId) : undefined };
238
+ }
239
+ 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 } });
240
+ } catch (e: any) {
241
+ console.error('[security] turn.start audit failed:', e.message);
242
+ if (enforce) refusal = 'The security check for this turn failed, so it was not run.';
243
+ }
244
+ const started = Date.now();
245
+ let outcome = 'closed';
246
+ try {
247
+ if (enforce && (refusal || !guard)) {
248
+ outcome = 'denied';
249
+ console.warn(`[security] Refused turn for ${principal.id} session=${sessionId ?? 'new'}: ${refusal}`);
250
+ yield { type: 'error', message: refusal ?? 'The security check for this turn failed, so it was not run.' };
251
+ return;
252
+ }
253
+ for await (const ev of runTurn(opts, guard)) {
254
+ if (ev.type === 'done') outcome = 'done';
255
+ else if (ev.type === 'error') outcome = 'error';
256
+ yield ev;
257
+ }
258
+ if (outcome === 'closed') outcome = 'done';
259
+ } catch (e) {
260
+ outcome = 'threw';
261
+ throw e;
262
+ } finally {
263
+ try { sec.record({ type: 'turn.end', principal: principal.id, role, sessionId, reason: outcome, meta: { ms: Date.now() - started } }); }
264
+ catch (e: any) { console.error('[security] turn.end audit failed:', e.message); }
265
+ }
266
+ }
267
+
268
+ /** SECURITY_ENFORCE: `principal`'s rank under the live policy. Undefined in shadow mode, without a runtime, or on an
269
+ * invalid (fail-closed) policy — callers then apply no rank-based filtering. */
270
+ export function enforcedRank(principal: Principal): number | undefined {
271
+ const sec = security();
272
+ return enforcing() && sec?.policy.valid ? resolvePrincipal(sec.policy, principal).rank : undefined;
273
+ }
274
+
275
+ /** SECURITY_ENFORCE: input from `principal` entered `sessionId` outside a turn it runs (e.g. another human's Slack
276
+ * thread message) — lower the session floor to that principal's rank. No-op in shadow mode, without a runtime, or
277
+ * on an invalid (fail-closed) policy. Returns the new floor, or undefined when nothing was done. */
278
+ export async function taintSession(sessionId: string, principal: Principal | (() => Promise<Principal>)): Promise<number | undefined> {
279
+ const sec = security();
280
+ if (!enforcing() || !sec?.policy.valid) return undefined;
281
+ const p = typeof principal === 'function' ? await principal() : principal;
282
+ return lowerSessionFloor(sessionId, resolvePrincipal(sec.policy, p).rank);
283
+ }
284
+
285
+ async function* runTurn(opts: StreamChatOpts, guard?: TurnGuard): AsyncGenerator<WsEvent> {
221
286
  const config = getAgentConfig();
222
287
  const { prompt: cleanPrompt, directives: parsed, unresolvedModel } = parseDirectives(opts.prompt);
223
288
 
@@ -395,6 +460,16 @@ export async function* streamChat(opts: {
395
460
  yield { type: 'error', message };
396
461
  return;
397
462
  }
463
+ // An engine that doesn't apply the profile can only run a turn that needs no restriction.
464
+ if (guard && !engine.enforcesProfile) {
465
+ const { profile, role } = guard.current();
466
+ if (!(profile.tools.includes('*') && profile.mcps.includes('*') && profile.env.includes('*'))) {
467
+ const message = `Engine "${engine.name}" does not enforce security profiles, so it cannot run this conversation's role "${role}".`;
468
+ console.error(`[security] ${message} (user=${opts.uid} session=${sessionId})`);
469
+ yield { type: 'error', message };
470
+ return;
471
+ }
472
+ }
398
473
  console.log(`[stream] engine=${engine.name} user=${opts.uid} session=${sessionId}`);
399
474
  // Another engine's turn never reaches the claude-code transcript, so a stored SDK-resume mapping is
400
475
  // stale from here on — mark it; the claude-code engine then falls back to a fresh query (engine-switch).
@@ -421,6 +496,7 @@ export async function* streamChat(opts: {
421
496
  turnHints: opts.turnHints,
422
497
  conversationReset: opts.conversationReset,
423
498
  context: opts.context,
499
+ ...(guard ? { security: guard } : {}),
424
500
  directives,
425
501
  config,
426
502
  });
@@ -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,13 @@ 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/',
814
+ 'quarantine/', // untrusted inbound content held for operator review — never synced
765
815
  ];
766
816
 
767
817
  /** Write or refresh .gitignore, appending any canonical entries it's missing (idempotent). */
@@ -18,10 +18,8 @@ export interface ParsedPrompt {
18
18
  unresolvedModel?: string;
19
19
  }
20
20
 
21
- /** Model used when neither directives nor config specify one. Always passed
22
- * explicitly to the SDK — the CLI's own default silently drifts (it picked
23
- * Opus 4.7), which burns rate limits and budget. */
24
- export const DEFAULT_MODEL = 'claude-sonnet-5';
21
+ // The default model lives in src/shared/models.ts so the client header names the same one.
22
+ export { DEFAULT_MODEL } from '../shared/models';
25
23
 
26
24
  // Canonical model aliases + label. Vendored, pure, dependency-free (src/server/model-aliases.ts).
27
25
  // Re-exported here so the rest of shraga keeps importing model helpers from one place.
@@ -23,6 +23,8 @@ import { APP_ROOT } from '../paths.ts';
23
23
  import { writeMcpConfigFile } from './mcp-config-file.ts';
24
24
  import { claudeUsageFor } from '../claude-usage.ts';
25
25
  import { claudeAccountDir, applyClaudeAccount, claudeAccountRef, type ClaudeAccountRef } from '../claude-account.ts';
26
+ import { buildAgentEnv, builtinTools, filterMcpServers, allowsEscalate, allowsInternalToken, SENSITIVE_PATH_PATTERNS } from '../security/enforce.ts';
27
+ import { escalateMcpServer } from '../security/escalate.ts';
26
28
  const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
27
29
  const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
28
30
  const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
@@ -32,10 +34,6 @@ const HISTORY_LIMIT = 50;
32
34
 
33
35
  const NO_INTERACTIVE_ANSWER = 'No interactive channel is available to answer right now. Use your best judgement to proceed, and surface these options to the user in your reply so they can redirect if needed.';
34
36
 
35
- const SENSITIVE_PATTERNS = [
36
- /\.env($|\.)/i, /secrets?\//i, /credentials/i, /\.pem$/i, /\.key$/i,
37
- /service.account.*\.json/i, /\/\.claude\/credentials/i,
38
- ];
39
37
  const SENSITIVE_BASH_PATTERNS = [
40
38
  /\.env\b/i, /\bprintenv\b/i, /\b(env|set)\s*\|/i, /\bsecrets?\//i,
41
39
  /credentials/i, /service.account/i, /\.(pem|key)\b/i,
@@ -63,7 +61,7 @@ type DenyResult = { behavior: 'deny'; message: string };
63
61
  function checkSensitiveAccess(toolName: string, input: Record<string, unknown>): DenyResult | null {
64
62
  const filePath = (input.file_path ?? input.path ?? '') as string;
65
63
  if ((toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') && filePath) {
66
- if (SENSITIVE_PATTERNS.some(p => p.test(filePath))) {
64
+ if (SENSITIVE_PATH_PATTERNS.some(p => p.test(filePath))) {
67
65
  console.log(`[security] Blocked ${toolName} on sensitive file: ${filePath}`);
68
66
  return { behavior: 'deny', message: 'Access to sensitive files (.env, secrets, credentials) is blocked.' };
69
67
  }
@@ -229,6 +227,10 @@ async function* buildAttachmentPrompt(text: string, attachments: { path: string;
229
227
  yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
230
228
  }
231
229
 
230
+ async function* buildTextPrompt(text: string, sessionId: string): AsyncIterable<any> {
231
+ yield { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: sessionId };
232
+ }
233
+
232
234
  async function* buildLegacyImagePrompt(text: string, images: string[], sessionId: string): AsyncIterable<any> {
233
235
  const content: any[] = images.map((dataUrl) => {
234
236
  const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
@@ -242,6 +244,7 @@ async function* buildLegacyImagePrompt(text: string, images: string[], sessionId
242
244
 
243
245
  export class ClaudeCodeEngine implements AgentEngine {
244
246
  readonly name = 'claude-code';
247
+ readonly enforcesProfile = true;
245
248
 
246
249
  getModels(): EngineModel[] {
247
250
  return [
@@ -329,8 +332,11 @@ export class ClaudeCodeEngine implements AgentEngine {
329
332
  const fullPrompt = plan.prompt;
330
333
  const permMode = opts.onPermissionRequest ? 'default' : (config.permissionMode ?? 'acceptEdits');
331
334
 
332
- const sdkEnv: Record<string, string> = {};
333
- for (const [k, v] of Object.entries(process.env)) {
335
+ // SECURITY_ENFORCE: the effective profile at spawn decides env/tools/MCPs; the per-call gate re-reads it (enforce.ts).
336
+ const guard = opts.security;
337
+ const eff = guard?.current();
338
+ const sdkEnv: Record<string, string> = eff ? buildAgentEnv(process.env, eff.profile) : {};
339
+ if (!eff) for (const [k, v] of Object.entries(process.env)) {
334
340
  if (v !== undefined) sdkEnv[k] = v;
335
341
  }
336
342
  // Injected for the agent's own tools/scripts. Each is written under both the canonical
@@ -366,12 +372,18 @@ export class ClaudeCodeEngine implements AgentEngine {
366
372
  let initModel: string | undefined;
367
373
  sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');
368
374
 
375
+ if (eff && !allowsInternalToken(eff.profile)) delete sdkEnv.INTERNAL_API_TOKEN;
376
+
369
377
  const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
370
- const allowedTools = baseAllowed.includes('ToolSearch') ? baseAllowed : [...baseAllowed, 'ToolSearch'];
378
+ const withToolSearch = baseAllowed.includes('ToolSearch') ? baseAllowed : [...baseAllowed, 'ToolSearch'];
379
+ // Enforced: built-in tool AVAILABILITY is the profile's (tools outside it are not in the model's context), and
380
+ // auto-approval never widens it.
381
+ const available = eff ? builtinTools(eff.profile) : 'all';
382
+ const allowedTools = available === 'all' ? withToolSearch : withToolSearch.filter((t) => available.includes(t));
371
383
  const maxTurns = directives.turns ?? config.maxTurns ?? 50;
372
384
 
373
385
  const options: Record<string, unknown> = {
374
- tools: { type: 'preset', preset: 'claude_code' },
386
+ tools: available === 'all' ? { type: 'preset', preset: 'claude_code' } : available,
375
387
  env: sdkEnv,
376
388
  allowedTools,
377
389
  cwd,
@@ -391,15 +403,24 @@ export class ClaudeCodeEngine implements AgentEngine {
391
403
  skills: listSkills(),
392
404
  disallowedTools: ['Skill'],
393
405
  agents: loadAgents(),
394
- hooks: buildHooks({ exemptBashCommand: opts.foregroundBashCommand }),
406
+ // Enforced: the profile gate runs FIRST on every tool call — hooks fire even for auto-approved `allowedTools`,
407
+ // which never reach canUseTool.
408
+ hooks: ((h) => (guard ? { ...h, PreToolUse: [{ hooks: [guard.hook()] }, ...(h.PreToolUse ?? [])] } : h))(buildHooks({ exemptBashCommand: opts.foregroundBashCommand })),
395
409
  };
396
410
 
397
411
  const userHandler = opts.onPermissionRequest;
398
412
  const destructiveHandler = opts.onDestructiveApproval;
399
413
  const questionHandler = opts.onUserQuestion;
400
414
  options['canUseTool'] = async (toolName: string, input: Record<string, unknown>) => {
401
- const denied = checkSensitiveAccess(toolName, input);
415
+ // Enforced: TurnGuard owns the file-path deny (tightened list); the legacy path list stays flag-OFF behavior only.
416
+ const denied = guard && toolName !== 'Bash' ? null : checkSensitiveAccess(toolName, input);
402
417
  if (denied) return denied;
418
+ // Enforced: the profile gate (re-reads the session floor) runs before ANY handler below, so an
419
+ // `onPermissionRequest: allow` call site can never override a profile deny.
420
+ if (guard) {
421
+ const gate = guard.check(toolName, input, cwd);
422
+ if (!gate.allow) return { behavior: 'deny' as const, message: gate.message };
423
+ }
403
424
  if (toolName === 'AskUserQuestion') {
404
425
  const questions = (input.questions ?? []) as AskQuestion[];
405
426
  const answers = questionHandler
@@ -479,12 +500,25 @@ export class ClaudeCodeEngine implements AgentEngine {
479
500
  // server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
480
501
  // writeMcpConfigFile. Setting both would re-add the argv copy, so it's one or the other.
481
502
  let mcpConfigFile: { path: string; cleanup: () => void } | undefined;
482
- if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) {
483
- mcpConfigFile = writeMcpConfigFile(opts.mcpServers);
503
+ // Enforced: servers outside profile.mcps never reach the file, so the CLI never starts them.
504
+ const mcpServers = eff ? filterMcpServers(opts.mcpServers, eff.profile) : opts.mcpServers;
505
+ if (mcpServers && Object.keys(mcpServers).length > 0) {
506
+ mcpConfigFile = writeMcpConfigFile(mcpServers);
484
507
  options['extraArgs'] = { ...(options['extraArgs'] as Record<string, string> | undefined), 'mcp-config': mcpConfigFile.path };
485
508
  }
509
+ // `escalate` is an in-process SDK server (no credentials): `options.mcpServers` hands the CLI only its name.
510
+ if (guard && eff && allowsEscalate(eff.profile)) {
511
+ const { principal } = guard.options;
512
+ const lastUser = opts.conversation.findLast((m) => m.role === 'user');
513
+ const excerpt = lastUser?.blocks.map((b) => (b.type === 'text' ? b.text : '')).filter(Boolean).join('\n') || opts.prompt;
514
+ const server = escalateMcpServer({
515
+ principal, role: () => guard.current().role, sessionId: opts.sessionId, excerpt,
516
+ channel: [principal.kind, principal.attrs.lane, opts.context?.source].filter(Boolean).join(':'),
517
+ });
518
+ options['mcpServers'] = { [server.name]: server };
519
+ }
486
520
 
487
- const mcpNames = opts.mcpServers ? Object.keys(opts.mcpServers) : [];
521
+ const mcpNames = mcpServers ? Object.keys(mcpServers) : [];
488
522
  const activeModel = (options['model'] as string) || 'default';
489
523
  const directivesTag = Object.keys(directives).length ? ` directives=${JSON.stringify(directives)}` : '';
490
524
  console.log(`[claude] Starting query user=${opts.uid} session=${opts.sessionId ?? 'new'} model=${activeModel} perms=${config.permissionMode} mcps=[${mcpNames.join(',')}]${directivesTag} cwd=${cwd}`);
@@ -494,11 +528,22 @@ export class ClaudeCodeEngine implements AgentEngine {
494
528
  const sessionKey = opts.sessionId ?? crypto.randomUUID();
495
529
  const hasAttachments = opts.attachments && opts.attachments.length > 0;
496
530
  const hasLegacyImages = opts.images && opts.images.length > 0;
497
- const prompt = hasAttachments
531
+ const source = hasAttachments
498
532
  ? buildAttachmentPrompt(fullPrompt, opts.attachments!, sessionKey)
499
533
  : hasLegacyImages
500
534
  ? buildLegacyImagePrompt(fullPrompt, opts.images!, sessionKey)
501
- : fullPrompt;
535
+ : buildTextPrompt(fullPrompt, sessionKey);
536
+ // Streaming input, held open until the turn is over. A string prompt runs the CLI one-shot: it exits after
537
+ // the first result and kills `run_in_background` jobs ~5s later. Open, the job keeps running and its
538
+ // task_notification gets the model a follow-up turn, as interactive Claude Code does. The uuid is echoed on
539
+ // the result that answers THIS prompt (`user_message_uuids`), telling it apart from a resumed session's stale one.
540
+ const promptUuid = crypto.randomUUID();
541
+ let closeInput!: () => void;
542
+ const inputClosed = new Promise<void>((r) => { closeInput = r; });
543
+ const prompt = (async function* () {
544
+ for await (const msg of source) yield { ...msg, uuid: promptUuid };
545
+ await inputClosed;
546
+ })();
502
547
 
503
548
  const q = query({ prompt, options: options as any });
504
549
  // The CLI writes this prompt to the transcript before any output: from here a cancel can't un-deliver it.
@@ -516,6 +561,9 @@ export class ClaudeCodeEngine implements AgentEngine {
516
561
  const iter = q[Symbol.asyncIterator]();
517
562
  let pendingNext: Promise<IteratorResult<any>> | null = null;
518
563
  let waitingForBg = false;
564
+ // Has the result answering THIS prompt arrived? Later results are the model's follow-ups on bg-task completions.
565
+ let answered = false;
566
+ let turnBreak = false;
519
567
  let bgTimer: Promise<'__bgtimeout'> | null = null;
520
568
  let bgTimerHandle: ReturnType<typeof setTimeout> | null = null;
521
569
  let bgHeartbeat: ReturnType<typeof setInterval> | null = null;
@@ -592,7 +640,8 @@ export class ClaudeCodeEngine implements AgentEngine {
592
640
  if (m.type === 'system' && m.subtype === 'task_notification') {
593
641
  outstandingTasks.delete(m.task_id);
594
642
  console.log(`[claude] Background task ${m.status}: ${m.task_id} (${outstandingTasks.size} pending) (${elapsed()})`);
595
- if (outstandingTasks.size === 0) { waitingForBg = false; clearBgTimer(); }
643
+ // Not the end of the turn: the CLI now runs a follow-up turn on the result, whose own result ends it
644
+ // (still bounded by the bg timer).
596
645
  continue;
597
646
  }
598
647
 
@@ -602,6 +651,15 @@ export class ClaudeCodeEngine implements AgentEngine {
602
651
  if (m.type === 'rate_limit_event') { claudeUsageFor(accountDir).observeRateLimit(m.rate_limit_info); continue; }
603
652
 
604
653
  if (m.type === 'result') {
654
+ // A resumed session first replays a pending task_notification as its own zero-turn result, before
655
+ // our prompt runs — ending on it drops the user's message. Skip any result that is not ours until
656
+ // ours arrives (older CLIs don't echo the uuid: then only a zero-turn result counts as stale).
657
+ const echoed = m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : undefined);
658
+ if (!answered && !m.is_error && (echoed ? !echoed.includes(promptUuid) : !m.num_turns)) {
659
+ console.log(`[claude] Skipping result of an earlier turn: turns=${m.num_turns ?? 0} result_index=${m.result_index ?? '?'} (${elapsed()})`);
660
+ continue;
661
+ }
662
+ answered = true;
605
663
  lastSessionId = m.session_id || lastSessionId;
606
664
  const sdkTurns = m.num_turns ?? 0;
607
665
  const raw = m.subtype ?? 'unknown';
@@ -617,6 +675,7 @@ export class ClaudeCodeEngine implements AgentEngine {
617
675
  bgHeartbeat = setInterval(() => console.log(`[claude] Still holding for ${outstandingTasks.size} bg task(s): ${[...outstandingTasks].join(',')} (${elapsed()})`), BG_HEARTBEAT_MS);
618
676
  bgHeartbeat.unref?.();
619
677
  }
678
+ turnBreak = textDeltaCount > 0;
620
679
  continue;
621
680
  }
622
681
  // The SDK reports `subtype: 'success'` even when the API call failed (e.g. an org spend
@@ -657,6 +716,7 @@ export class ClaudeCodeEngine implements AgentEngine {
657
716
  if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
658
717
  textDeltaCount++;
659
718
  sawOutput = true;
719
+ if (turnBreak) { turnBreak = false; yield { type: 'text_delta', text: '\n\n' }; }
660
720
  yield { type: 'text_delta', text: event.delta.text };
661
721
  }
662
722
  if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
@@ -772,6 +832,7 @@ export class ClaudeCodeEngine implements AgentEngine {
772
832
  return;
773
833
  } finally {
774
834
  clearBgTimer();
835
+ closeInput();
775
836
  // The CLI has read the file by now (it loads MCP config at startup); holding it any longer just
776
837
  // widens the window in which the credentials sit on disk.
777
838
  mcpConfigFile?.cleanup();
@@ -3,6 +3,7 @@ import type { WsEvent, AttachmentMeta, PermissionHandler, QuestionHandler } from
3
3
  import type { ConvMessage } from '../sessions.ts';
4
4
  import type { Directives } from '../directives.ts';
5
5
  import type { AgentSettings } from '../shraga-config.ts';
6
+ import type { TurnGuard } from '../security/enforce.ts';
6
7
 
7
8
  export interface EngineStreamOpts {
8
9
  /** The user's effective prompt (after directive stripping, slash command expansion, skill/workspace mentions) */
@@ -35,6 +36,9 @@ export interface EngineStreamOpts {
35
36
  /** True when the conversation was truncated (user replayed/edited a message) — engines with cached state should reset. */
36
37
  conversationReset?: boolean;
37
38
  context?: Record<string, string>;
39
+ /** SECURITY_ENFORCE only: the turn's security context. The engine derives tools/MCP/env from its effective
40
+ * profile and gates every tool call through it. Absent (flag off) ⇒ today's behavior, untouched. */
41
+ security?: TurnGuard;
38
42
 
39
43
  directives: Directives;
40
44
  config: AgentSettings;
@@ -42,6 +46,9 @@ export interface EngineStreamOpts {
42
46
 
43
47
  export interface AgentEngine {
44
48
  readonly name: string;
49
+ /** True if the engine applies `EngineStreamOpts.security` (profile tools/MCP/env + per-call gate). Under
50
+ * SECURITY_ENFORCE an engine without it may only run turns whose effective profile is unrestricted. */
51
+ readonly enforcesProfile?: boolean;
45
52
  stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent>;
46
53
  /** Return model options for the UI picker (only called when engine is available) */
47
54
  getModels(): EngineModel[];
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { HookCallback, HookCallbackMatcher, HookEvent, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
9
9
  import { rewriteSlackMentions } from './slack/mention-rewrite.ts';
10
+ import { PROTECTED_DATA_MESSAGE, writesProtectedData } from './security/enforce.ts';
10
11
 
11
12
  /** Patterns that indicate a long-running script the model should background. */
12
13
  const LONG_RUNNING_PATTERNS = [
@@ -134,6 +135,23 @@ const guardFirebaseReads: HookCallback = async (input) => {
134
135
  };
135
136
  };
136
137
 
138
+ /** Tamper protection: no agent file tool writes server-owned data (audit, conversations, sessions, security, keys,
139
+ * MCP config) — any profile, SECURITY_ENFORCE on or off. A hook, not canUseTool: the SDK auto-approves allowed/
140
+ * accept-edits tools without calling canUseTool. See security/enforce.ts PROTECTED_DATA_WRITE. */
141
+ const denyProtectedDataWrites: HookCallback = async (input) => {
142
+ if (input.hook_event_name !== 'PreToolUse') return {};
143
+ const { tool_name, tool_input, cwd } = input as PreToolUseHookInput;
144
+ if (!writesProtectedData(tool_name, (tool_input ?? {}) as Record<string, unknown>, cwd || undefined)) return {};
145
+ console.log(`[hooks] Denied ${tool_name} on protected data path`);
146
+ return {
147
+ hookSpecificOutput: {
148
+ hookEventName: 'PreToolUse' as const,
149
+ permissionDecision: 'deny' as const,
150
+ permissionDecisionReason: PROTECTED_DATA_MESSAGE,
151
+ },
152
+ };
153
+ };
154
+
137
155
  /** Build the hooks map to pass into SDK query() options.
138
156
  * `exemptBashCommand` is the one command the turn was explicitly told to run in the foreground
139
157
  * (a `bash` schedule's command) — see forceBackgroundForScripts. */
@@ -143,6 +161,7 @@ export function buildHooks(opts?: { exemptBashCommand?: string }): Partial<Recor
143
161
  { matcher: 'Bash', hooks: [forceBackgroundForScripts(opts?.exemptBashCommand)] },
144
162
  { matcher: 'mcp__mcp-slack-use__post_slack_.*', hooks: [resolveSlackMentions] },
145
163
  { matcher: 'mcp__mcp-firebase-(?:prod|lab)__get_db.*', hooks: [guardFirebaseReads] },
164
+ { matcher: 'Write|Edit|MultiEdit|NotebookEdit', hooks: [denyProtectedDataWrites] },
146
165
  ],
147
166
  };
148
167
  }