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.
- package/README.md +10 -1
- package/defaults/mcps/README.md +6 -3
- package/defaults/scripts/agent-once.ts +2 -0
- package/defaults/security/policy.example.json +27 -0
- package/defaults/skills/mcp-server.md +15 -14
- package/defaults/skills/platform.md +3 -3
- package/defaults/system-prompt.md +1 -1
- package/dist/client/assets/index-CFxKc8dH.css +10 -0
- package/dist/client/assets/index-DwmlZAEF.js +2009 -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 +83 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/Sidebar.tsx +16 -1
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/components/owner/ApiKeysTab.tsx +87 -0
- package/src/client/components/owner/AuditTab.tsx +110 -0
- package/src/client/components/owner/BindingsTab.tsx +110 -0
- package/src/client/components/owner/BlocklistTab.tsx +96 -0
- package/src/client/components/owner/OwnerConsole.tsx +87 -0
- package/src/client/components/owner/PrincipalsTab.tsx +55 -0
- package/src/client/components/owner/RolesTab.tsx +132 -0
- package/src/client/components/owner/shared.tsx +84 -0
- 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/hooks/useOwner.ts +20 -0
- package/src/client/lib/api.ts +23 -7
- 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/index.ts +10 -2
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/agent-config.ts +34 -0
- package/src/server/api-key-routes.ts +66 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +161 -104
- package/src/server/claude.ts +102 -26
- package/src/server/data-sync.ts +56 -6
- package/src/server/directives.ts +2 -4
- package/src/server/engine/claude-code.ts +78 -17
- package/src/server/engine/types.ts +7 -0
- package/src/server/hooks.ts +19 -0
- package/src/server/mcp-oauth.ts +24 -5
- package/src/server/mcp-server.ts +55 -25
- package/src/server/modules/routes.ts +2 -6
- package/src/server/notify-owners.ts +5 -17
- package/src/server/owners.ts +14 -0
- package/src/server/scheduler/builtins.ts +3 -1
- package/src/server/scheduler/runner.ts +3 -0
- package/src/server/security/audit.ts +505 -0
- package/src/server/security/enforce.ts +343 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +337 -0
- package/src/server/security/owner-only.ts +34 -0
- package/src/server/security/owner-routes.ts +285 -0
- package/src/server/security/policy.ts +416 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +226 -0
- package/src/server/sessions.ts +38 -0
- package/src/server/shraga-config.ts +13 -0
- package/src/server/slack/bot.ts +44 -11
- package/src/server/slack/context-cache.ts +40 -7
- package/src/server/webhook-lane/feature.ts +17 -6
- package/src/shared/models.ts +11 -0
- package/dist/client/assets/index-DIMte_k6.css +0 -10
- package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
package/src/server/claude.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
});
|
package/src/server/data-sync.ts
CHANGED
|
@@ -4,9 +4,13 @@ import path from 'node:path';
|
|
|
4
4
|
import { DATA_DIR } from './paths.ts';
|
|
5
5
|
import { notifyOwners } from './notify-owners.ts';
|
|
6
6
|
import { runTextQuery } from './sdk-utils.ts';
|
|
7
|
+
import { GENESIS_HASH, readAuditHead } from './security/audit.ts';
|
|
7
8
|
|
|
8
9
|
const TAG = '[data-sync]';
|
|
9
10
|
const DEPLOYMENT_ID_FILE = '.deployment-id';
|
|
11
|
+
/** Tracked, append-only, single-writer (security/audit.ts): committed on every flush, never pulled over, never stashed. */
|
|
12
|
+
const AUDIT_DIR = 'audit';
|
|
13
|
+
const NOT_AUDIT = ['--', '.', `:(exclude)${AUDIT_DIR}`];
|
|
10
14
|
|
|
11
15
|
/** How long the LLM commit-message call may take before we fall back (ms). */
|
|
12
16
|
const COMMIT_MSG_TIMEOUT_MS = 60_000;
|
|
@@ -357,11 +361,32 @@ export class DataSync {
|
|
|
357
361
|
return;
|
|
358
362
|
}
|
|
359
363
|
|
|
360
|
-
//
|
|
361
|
-
|
|
364
|
+
// The audit log is append-only with ONE writer, this instance. A remote commit touching audit/ would rewrite the local
|
|
365
|
+
// chain (and under `chattr +a` git can't even apply it) — refuse the whole pull and alert.
|
|
366
|
+
let incomingAudit: string;
|
|
367
|
+
try {
|
|
368
|
+
incomingAudit = (await this.git('diff', '--name-only', `HEAD...origin/${this.options.branch}`, '--', AUDIT_DIR)).trim();
|
|
369
|
+
} catch (err) {
|
|
370
|
+
console.warn(`${TAG} Audit pull check failed, skipping pull:`, (err as Error).message);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (incomingAudit) {
|
|
374
|
+
console.error(`${TAG} 🚫 Remote commits change the audit log — pull refused:\n${incomingAudit}`);
|
|
375
|
+
await this.alertOnce('audit-pull', incomingAudit,
|
|
376
|
+
`🚫 Data sync refused a pull: remote commits change the audit log, which only this instance writes.\n\n${incomingAudit}\n\n` +
|
|
377
|
+
`Inspect: \`cd data && git log origin/${this.options.branch} -- ${AUDIT_DIR}\``,
|
|
378
|
+
).catch(err => console.warn(`${TAG} Audit pull notify failed:`, (err as Error).message));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
this.clearAlert('audit-pull');
|
|
382
|
+
|
|
383
|
+
// Stash dirty + untracked files before merging (untracked can block merge if remote adds same paths) — except audit/:
|
|
384
|
+
// stashing removes the live log from disk while the server appends to it (lost lines, forked chain) and fails under
|
|
385
|
+
// `chattr +a`. The merge can't touch audit/ (checked above), so it stays dirty in place.
|
|
386
|
+
const dirty = !!(await this.git('status', '--porcelain', ...NOT_AUDIT)).trim();
|
|
362
387
|
if (dirty) {
|
|
363
388
|
try {
|
|
364
|
-
await this.git('stash', 'push', '--include-untracked', '-m', 'data-sync: pre-pull stash');
|
|
389
|
+
await this.git('stash', 'push', '--include-untracked', '-m', 'data-sync: pre-pull stash', ...NOT_AUDIT);
|
|
365
390
|
} catch (err) {
|
|
366
391
|
console.warn(`${TAG} Stash failed, skipping pull:`, (err as Error).message);
|
|
367
392
|
return;
|
|
@@ -441,12 +466,19 @@ export class DataSync {
|
|
|
441
466
|
}
|
|
442
467
|
}
|
|
443
468
|
|
|
469
|
+
// The audit log rides along with every sync commit — the data repo is its offsite copy — and the commit message
|
|
470
|
+
// anchors its head. Head read BEFORE staging: the committed chain always contains that hash.
|
|
471
|
+
const auditHead = this.auditHead();
|
|
472
|
+
if (existsSync(path.join(DATA_DIR, AUDIT_DIR))) {
|
|
473
|
+
await this.git('add', '--', AUDIT_DIR).catch(err => console.warn(`${TAG} Staging audit log failed:`, (err as Error).message));
|
|
474
|
+
}
|
|
475
|
+
|
|
444
476
|
const status = await this.git('status', '--porcelain');
|
|
445
477
|
if (!status.trim()) return;
|
|
446
478
|
|
|
447
479
|
if (await this.guardMassDeletions('flush')) return;
|
|
448
480
|
const msg = await this.generateCommitMessage(files);
|
|
449
|
-
await this.git('commit', '-m', msg).catch(() => {});
|
|
481
|
+
await this.git('commit', '-m', auditHead ? `${msg}\n\naudit-head: ${auditHead}` : msg).catch(() => {});
|
|
450
482
|
const ahead = await this.git('rev-list', '--count', `origin/${this.options.branch}..HEAD`).catch(() => '0');
|
|
451
483
|
if (parseInt(ahead.trim()) === 0) return;
|
|
452
484
|
await this.git('push', 'origin', this.options.branch).catch(async (err) => {
|
|
@@ -468,11 +500,23 @@ export class DataSync {
|
|
|
468
500
|
}
|
|
469
501
|
}
|
|
470
502
|
|
|
503
|
+
/** Audit chain head for the commit anchor; undefined when there's no record yet or it can't be read (logged). */
|
|
504
|
+
private auditHead(): string | undefined {
|
|
505
|
+
try {
|
|
506
|
+
const head = readAuditHead(path.join(DATA_DIR, AUDIT_DIR));
|
|
507
|
+
return head === GENESIS_HASH ? undefined : head;
|
|
508
|
+
} catch (err) {
|
|
509
|
+
console.warn(`${TAG} Audit head unreadable, committing without anchor:`, (err as Error).message);
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
471
514
|
private async generateCommitMessage(files: string[]): Promise<string> {
|
|
472
515
|
const fallback = fallbackCommitMessage(files);
|
|
473
516
|
try {
|
|
474
|
-
|
|
475
|
-
const
|
|
517
|
+
// The audit log's appended lines are not "behavioral config" and would crowd the real diff out of the prompt.
|
|
518
|
+
const diff = await this.git('diff', '--cached', '--stat', ...NOT_AUDIT).catch(() => '');
|
|
519
|
+
const diffContent = await this.git('diff', '--cached', '--no-color', '-U2', ...NOT_AUDIT).catch(() => '');
|
|
476
520
|
if (!diffContent.trim()) return fallback;
|
|
477
521
|
const truncated = diffContent.slice(0, 3000);
|
|
478
522
|
// Bounded: a hung `claude` subprocess used to wedge flush() forever (it holds the push latch).
|
|
@@ -761,7 +805,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). */
|
package/src/server/directives.ts
CHANGED
|
@@ -18,10 +18,8 @@ export interface ParsedPrompt {
|
|
|
18
18
|
unresolvedModel?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* Opus 4.7), which burns rate limits and budget. */
|
|
24
|
-
export const DEFAULT_MODEL = 'claude-sonnet-5';
|
|
21
|
+
// The default model lives in src/shared/models.ts so the client header names the same one.
|
|
22
|
+
export { DEFAULT_MODEL } from '../shared/models';
|
|
25
23
|
|
|
26
24
|
// Canonical model aliases + label. Vendored, pure, dependency-free (src/server/model-aliases.ts).
|
|
27
25
|
// Re-exported here so the rest of shraga keeps importing model helpers from one place.
|
|
@@ -23,6 +23,8 @@ import { APP_ROOT } from '../paths.ts';
|
|
|
23
23
|
import { writeMcpConfigFile } from './mcp-config-file.ts';
|
|
24
24
|
import { claudeUsageFor } from '../claude-usage.ts';
|
|
25
25
|
import { claudeAccountDir, applyClaudeAccount, claudeAccountRef, type ClaudeAccountRef } from '../claude-account.ts';
|
|
26
|
+
import { buildAgentEnv, builtinTools, filterMcpServers, allowsEscalate, allowsInternalToken, SENSITIVE_PATH_PATTERNS } from '../security/enforce.ts';
|
|
27
|
+
import { escalateMcpServer } from '../security/escalate.ts';
|
|
26
28
|
const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
|
|
27
29
|
const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
|
|
28
30
|
const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
|
|
@@ -32,10 +34,6 @@ const HISTORY_LIMIT = 50;
|
|
|
32
34
|
|
|
33
35
|
const NO_INTERACTIVE_ANSWER = 'No interactive channel is available to answer right now. Use your best judgement to proceed, and surface these options to the user in your reply so they can redirect if needed.';
|
|
34
36
|
|
|
35
|
-
const SENSITIVE_PATTERNS = [
|
|
36
|
-
/\.env($|\.)/i, /secrets?\//i, /credentials/i, /\.pem$/i, /\.key$/i,
|
|
37
|
-
/service.account.*\.json/i, /\/\.claude\/credentials/i,
|
|
38
|
-
];
|
|
39
37
|
const SENSITIVE_BASH_PATTERNS = [
|
|
40
38
|
/\.env\b/i, /\bprintenv\b/i, /\b(env|set)\s*\|/i, /\bsecrets?\//i,
|
|
41
39
|
/credentials/i, /service.account/i, /\.(pem|key)\b/i,
|
|
@@ -63,7 +61,7 @@ type DenyResult = { behavior: 'deny'; message: string };
|
|
|
63
61
|
function checkSensitiveAccess(toolName: string, input: Record<string, unknown>): DenyResult | null {
|
|
64
62
|
const filePath = (input.file_path ?? input.path ?? '') as string;
|
|
65
63
|
if ((toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') && filePath) {
|
|
66
|
-
if (
|
|
64
|
+
if (SENSITIVE_PATH_PATTERNS.some(p => p.test(filePath))) {
|
|
67
65
|
console.log(`[security] Blocked ${toolName} on sensitive file: ${filePath}`);
|
|
68
66
|
return { behavior: 'deny', message: 'Access to sensitive files (.env, secrets, credentials) is blocked.' };
|
|
69
67
|
}
|
|
@@ -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
|
-
|
|
333
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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[];
|
package/src/server/hooks.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { HookCallback, HookCallbackMatcher, HookEvent, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
|
9
9
|
import { rewriteSlackMentions } from './slack/mention-rewrite.ts';
|
|
10
|
+
import { PROTECTED_DATA_MESSAGE, writesProtectedData } from './security/enforce.ts';
|
|
10
11
|
|
|
11
12
|
/** Patterns that indicate a long-running script the model should background. */
|
|
12
13
|
const LONG_RUNNING_PATTERNS = [
|
|
@@ -134,6 +135,23 @@ const guardFirebaseReads: HookCallback = async (input) => {
|
|
|
134
135
|
};
|
|
135
136
|
};
|
|
136
137
|
|
|
138
|
+
/** Tamper protection: no agent file tool writes server-owned data (audit, conversations, sessions, security, keys,
|
|
139
|
+
* MCP config) — any profile, SECURITY_ENFORCE on or off. A hook, not canUseTool: the SDK auto-approves allowed/
|
|
140
|
+
* accept-edits tools without calling canUseTool. See security/enforce.ts PROTECTED_DATA_WRITE. */
|
|
141
|
+
const denyProtectedDataWrites: HookCallback = async (input) => {
|
|
142
|
+
if (input.hook_event_name !== 'PreToolUse') return {};
|
|
143
|
+
const { tool_name, tool_input, cwd } = input as PreToolUseHookInput;
|
|
144
|
+
if (!writesProtectedData(tool_name, (tool_input ?? {}) as Record<string, unknown>, cwd || undefined)) return {};
|
|
145
|
+
console.log(`[hooks] Denied ${tool_name} on protected data path`);
|
|
146
|
+
return {
|
|
147
|
+
hookSpecificOutput: {
|
|
148
|
+
hookEventName: 'PreToolUse' as const,
|
|
149
|
+
permissionDecision: 'deny' as const,
|
|
150
|
+
permissionDecisionReason: PROTECTED_DATA_MESSAGE,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
137
155
|
/** Build the hooks map to pass into SDK query() options.
|
|
138
156
|
* `exemptBashCommand` is the one command the turn was explicitly told to run in the foreground
|
|
139
157
|
* (a `bash` schedule's command) — see forceBackgroundForScripts. */
|
|
@@ -143,6 +161,7 @@ export function buildHooks(opts?: { exemptBashCommand?: string }): Partial<Recor
|
|
|
143
161
|
{ matcher: 'Bash', hooks: [forceBackgroundForScripts(opts?.exemptBashCommand)] },
|
|
144
162
|
{ matcher: 'mcp__mcp-slack-use__post_slack_.*', hooks: [resolveSlackMentions] },
|
|
145
163
|
{ matcher: 'mcp__mcp-firebase-(?:prod|lab)__get_db.*', hooks: [guardFirebaseReads] },
|
|
164
|
+
{ matcher: 'Write|Edit|MultiEdit|NotebookEdit', hooks: [denyProtectedDataWrites] },
|
|
146
165
|
],
|
|
147
166
|
};
|
|
148
167
|
}
|