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
@@ -0,0 +1,306 @@
1
+ // Enforcement: apply the resolved profile at the ONE choke point, the engine's spawn config + per-call tool gate.
2
+ //
3
+ // Flag `SECURITY_ENFORCE` (env, read per call, default OFF). OFF ⇒ shadow mode: nothing here is consulted and the
4
+ // spawn config is exactly today's (engine-security.test.ts snapshots it). ON ⇒ streamChat builds a TurnGuard and
5
+ // the engine derives from its effective profile:
6
+ // - built-in tool AVAILABILITY (`tools`), not regex denies; MCP servers filtered BEFORE the config file is written;
7
+ // - the subprocess env from an allowlist, never minus-nothing `process.env` — and `env:["*"]` still drops the
8
+ // server's own secrets (SERVER_SECRET_ENV);
9
+ // - a gate on EVERY tool call (PreToolUse hook + canUseTool) that re-reads the session floor, so a lower-rank
10
+ // message landing mid-turn lowers the running turn. The hook is load-bearing: the SDK auto-approves
11
+ // `allowedTools` WITHOUT calling canUseTool, whereas PreToolUse hooks fire for every call (hooks.ts relies on it).
12
+ // - secret files: a GUARANTEE only for restricted profiles (no Bash, no Grep, path-checked file tools). For full
13
+ // profiles (owner/operator) it is BEST-EFFORT: they have Bash, which reads any file, and the engine's legacy
14
+ // SENSITIVE_BASH_PATTERNS sit in canUseTool, which auto-approved Bash never reaches. Per-role OS isolation is out of
15
+ // scope; server-owned data is write-protected for file tools in every profile and the audit log at the OS level
16
+ // (see Protected data).
17
+ import type { HookCallback, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
18
+ import { realpathSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import path from 'node:path';
21
+ import type { McpConfig } from '../mcp.ts';
22
+ import { DATA_DIR } from '../paths.ts';
23
+ import type { Principal } from './principal.ts';
24
+ import type { Profile, Resolved } from './policy.ts';
25
+ import type { SecurityRuntime } from './runtime.ts';
26
+
27
+ export function enforcing(): boolean {
28
+ const v = process.env.SECURITY_ENFORCE?.trim().toLowerCase();
29
+ return v === 'true' || v === '1';
30
+ }
31
+
32
+ // ── Tools / MCP ──────────────────────────────────────────────────────────────
33
+
34
+ /** In-process MCP server carrying `escalate` (security/escalate.ts); the model sees `mcp__security__escalate`. */
35
+ export const ESCALATE_SERVER = 'security';
36
+ export const ESCALATE_TOOL = 'escalate';
37
+ export const ESCALATE_TOOL_ID = `mcp__${ESCALATE_SERVER}__${ESCALATE_TOOL}`;
38
+
39
+ /** `escalate` is opt-in by name (reply-only). `*` profiles don't get it: they can act, not just ask. */
40
+ export const allowsEscalate = (p: Pick<Profile, 'tools'>) => p.tools.includes(ESCALATE_TOOL);
41
+ export const allowsMcpServer = (p: Pick<Profile, 'mcps'>, server: string) => p.mcps.includes('*') || p.mcps.includes(server);
42
+
43
+ /** Never available to a restricted (non-`*`) profile, even when listed: Bash runs anything, and Grep reads the
44
+ * contents of every file it opens, so neither can be gated per file (see Secret paths). */
45
+ const FULL_ONLY_TOOLS = new Set(['Bash', 'Grep']);
46
+
47
+ /** Built-in tools the profile makes available: `'all'` for `*`, else its names minus escalate/MCP ids/full-only tools. */
48
+ export function builtinTools(p: Pick<Profile, 'tools' | 'mcps'>): 'all' | string[] {
49
+ if (p.tools.includes('*')) return 'all';
50
+ const names = p.tools.filter(t => t !== ESCALATE_TOOL && !t.startsWith('mcp__') && !FULL_ONLY_TOOLS.has(t));
51
+ // ToolSearch only loads deferred MCP tool schemas — needed exactly when any MCP tool is reachable.
52
+ if ((p.mcps.length || allowsEscalate(p)) && !names.includes('ToolSearch')) names.push('ToolSearch');
53
+ return names;
54
+ }
55
+
56
+ /** May this tool (built-in name or `mcp__<server>__<tool>`) run under the profile? */
57
+ export function profileAllowsTool(p: Pick<Profile, 'tools' | 'mcps'>, tool: string): boolean {
58
+ if (tool.startsWith('mcp__')) {
59
+ const server = tool.slice(5).split('__')[0];
60
+ if (server === ESCALATE_SERVER) return allowsEscalate(p);
61
+ return allowsMcpServer(p, server) || p.tools.includes(tool);
62
+ }
63
+ const b = builtinTools(p);
64
+ return b === 'all' || b.includes(tool);
65
+ }
66
+
67
+ export function filterMcpServers(servers: McpConfig | undefined, p: Pick<Profile, 'mcps'>): McpConfig {
68
+ return Object.fromEntries(Object.entries(servers ?? {}).filter(([name]) => allowsMcpServer(p, name))) as McpConfig;
69
+ }
70
+
71
+ // ── Env ──────────────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * The server's OWN secrets — never in an agent subprocess, whatever the profile says (`env:["*"]` included). The
75
+ * agent does its job with integration credentials (Slack/GitHub tokens, MCP server env); it never needs what signs
76
+ * sessions, verifies webhooks or defines who owns the deployment.
77
+ * - OWNERS: root of trust. INTERNAL_API_TOKEN: raw internal secret (the engine injects a SCOPED token instead).
78
+ * - Signing/verification + OAuth client secrets: SLACK_SIGNING_SECRET, SLACK_CLIENT_SECRET, DATA_SYNC_WEBHOOK_SECRET
79
+ * and any `*_CLIENT_SECRET` / `*_SIGNING_SECRET` / `*_WEBHOOK_SECRET` / `*_JWT_SECRET` / `*_SESSION_SECRET` /
80
+ * `*_AUTH_SECRET` / `*_OAUTH_SECRET` / `*_COOKIE_SECRET` (downstream overlays follow the same naming).
81
+ * - Firebase admin creds: FIREBASE_SERVICE_ACCOUNT_JSON[_<ENV>] (mcp.ts hands them to the Firebase MCP via its
82
+ * config file, not via the agent's env), GOOGLE_APPLICATION_CREDENTIALS.
83
+ * - MCP OAuth / local-auth signing secrets live in data/ files, not env; the file gate below covers them.
84
+ */
85
+ const SECRET_ENV_EXACT = new Set(['OWNERS', 'INTERNAL_API_TOKEN', 'DATA_SYNC_WEBHOOK_SECRET', 'GOOGLE_APPLICATION_CREDENTIALS']);
86
+ const SECRET_ENV_RE = /^FIREBASE_SERVICE_ACCOUNT_JSON(?:_[A-Z0-9]+)?$|(?:^|_)(?:CLIENT|SIGNING|WEBHOOK|JWT|SESSION|AUTH|OAUTH|COOKIE)_SECRET$/;
87
+ export const isServerSecretEnv = (key: string) => SECRET_ENV_EXACT.has(key) || SECRET_ENV_RE.test(key);
88
+
89
+ /** What any agent process needs to run at all: OS basics, network/TLS, and the CLI's own model credentials/config. */
90
+ const BASELINE_ENV = new Set([
91
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LANGUAGE', 'TERM', 'TZ',
92
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
93
+ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'CLAUDE_CONFIG_DIR', 'DEBUG_CLAUDE_AGENT_SDK',
94
+ 'BASH_DEFAULT_TIMEOUT_MS', 'BASH_MAX_TIMEOUT_MS', 'ENABLE_CLAUDEAI_MCP_SERVERS',
95
+ ]);
96
+ const BASELINE_PREFIX = ['LC_', 'CLAUDE_CODE_'];
97
+ const isBaselineEnv = (k: string) => BASELINE_ENV.has(k) || BASELINE_PREFIX.some(p => k.startsWith(p));
98
+ /** Profile env entry: exact name, or `PREFIX_*`. */
99
+ const listed = (entries: string[], k: string) => entries.some(e => e === k || (e.length > 1 && e.endsWith('*') && k.startsWith(e.slice(0, -1))));
100
+
101
+ /** Subprocess env from the allowlist: baseline + the profile's names (`*` = all), minus server secrets, always. */
102
+ export function buildAgentEnv(source: Record<string, string | undefined>, p: Pick<Profile, 'env'>): Record<string, string> {
103
+ const all = p.env.includes('*');
104
+ const out: Record<string, string> = {};
105
+ for (const [k, v] of Object.entries(source)) {
106
+ if (v === undefined || isServerSecretEnv(k)) continue;
107
+ if (all || isBaselineEnv(k) || listed(p.env, k)) out[k] = v;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /** Only `*` env profiles get the scoped INTERNAL_API_TOKEN (it authenticates HTTP calls back as the user). */
113
+ export const allowsInternalToken = (p: Pick<Profile, 'env'>) => p.env.includes('*') || p.env.includes('INTERNAL_API_TOKEN');
114
+
115
+ // ── Secret paths ─────────────────────────────────────────────────────────────
116
+ // What each profile class is promised:
117
+ // - RESTRICTED (tools without `*`): a guarantee. No Bash/Grep (FULL_ONLY_TOOLS); every file tool's path is matched
118
+ // literally and by realpath; nothing under /proc or /sys; Glob only inside the workspace root, and it lists names only
119
+ // (contents stay behind the Read gate).
120
+ // - FULL (owner/operator): best-effort. Bash is auto-approved before canUseTool, so no path deny here (nor the engine's
121
+ // SENSITIVE_BASH_PATTERNS) can stop `cat .env`; the file-tool deny stops accidents. Plan step 8 is the OS-level fix.
122
+ // The SDK skips canUseTool for auto-approved Read/Glob too, so TurnGuard.check (PreToolUse hook) is the gate that holds.
123
+ //
124
+ // Two lists. SENSITIVE_PATH_PATTERNS is the pre-enforcement list, used VERBATIM by the engine's canUseTool when the flag
125
+ // is OFF (snapshot-tested). SECRET_PATH_PATTERNS is the enforcement list: real secret files only, so owners can still
126
+ // work on `.env.example`, `claude-credentials.ts`, `docs/secrets/` or `*.env.ts`.
127
+ export const SENSITIVE_PATH_PATTERNS: readonly RegExp[] = [
128
+ /\.env($|\.)/i, /secrets?\//i, /credentials/i, /\.pem$/i, /\.key$/i,
129
+ /service.account.*\.json/i, /\/\.claude\/credentials/i,
130
+ ];
131
+ const SERVER_SECRET_NAMES = ['.internal-token', '.mcp-oauth-secret', '.local-auth-secret', 'api-keys.json', 'oauth-clients.json', 'users.json']
132
+ .map(n => n.replace(/[.]/g, '\\.')).join('|');
133
+ export const SECRET_PATH_PATTERNS: readonly RegExp[] = [
134
+ /(?:^|\/)\.env(?:\.(?!(?:example|sample|template)$)[^/]+)?$/i, // .env, .env.local — not .env.example/.sample/.template
135
+ /(?:^|\/)\.credentials\.json$/i, // Claude CLI OAuth: ~/.claude/, workspace/users/*/.claude/
136
+ /\.(?:pem|key)$/i,
137
+ /(?:^|\/)[^/]*service[-_.]?account[^/]*\.json$/i,
138
+ new RegExp(`(?:^|/)(?:${SERVER_SECRET_NAMES})$`, 'i'),
139
+ /(?:^|\/)shraga-mcp-[^/]*(?:\/|$)/i, // engine/mcp-config-file.ts mkdtemp dir: another turn's MCP servers + their env
140
+ /^\/proc\/.+\/environ$/, // any process/task env, e.g. /proc/1/task/1/environ
141
+ ];
142
+ /** Restricted profiles: all kernel pseudo-files (environ, cmdline, mem, …), not just environ. */
143
+ const SYSTEM_PATH_RE = /^\/(?:proc|sys)(?:\/|$)/;
144
+ /** Bash (full profiles only): server credential file names as command words. Best-effort, see above. */
145
+ const SECRET_FILE_CMD_RE = new RegExp(`(?:^|[\\s/'"=])(?:${SERVER_SECRET_NAMES})(?:$|[\\s'";|&)])`, 'i');
146
+ const GLOB_CHAR = /[*?[\]{}]/;
147
+
148
+ /** The literal path, its absolute form, and its realpath (deepest existing ancestor resolved, the rest re-appended). */
149
+ function pathForms(p: string, cwd: string): string[] {
150
+ const abs = path.resolve(cwd, p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p);
151
+ const forms = [p, abs];
152
+ for (let head = abs, tail = ''; ;) {
153
+ try { forms.push(path.join(realpathSync(head), tail)); break; } catch { /* not there yet: resolve the parent */ }
154
+ const parent = path.dirname(head);
155
+ if (parent === head) break;
156
+ tail = path.join(path.basename(head), tail);
157
+ head = parent;
158
+ }
159
+ return forms;
160
+ }
161
+
162
+ /** A glob's search base: the pattern joined to its root, cut at the first wildcard segment. */
163
+ function globBase(pattern: string, root: string | undefined): { joined: string; prefix: string; rest: string } {
164
+ const joined = root && !path.isAbsolute(pattern) ? path.join(root, pattern) : pattern;
165
+ const segs = joined.split('/');
166
+ const i = segs.findIndex(s => GLOB_CHAR.test(s));
167
+ if (i < 0) return { joined, prefix: joined, rest: '' };
168
+ return { joined, prefix: segs.slice(0, i).join('/') || (joined.startsWith('/') ? '/' : '.'), rest: segs.slice(i).join('/') };
169
+ }
170
+
171
+ /** Glob forms: literal, joined, realpath'd base + rest, and a "de-wildcarded" copy of each so `**\/.env*` reads as the
172
+ * `.env` it targets. A wildcard at a segment START becomes `x` (`*.env.ts` is `x.env.ts`, not the dotfile);
173
+ * elsewhere it is dropped; `[ab]` becomes its first char. */
174
+ function globForms(pattern: string, root: string | undefined, cwd: string): string[] {
175
+ const { joined, prefix, rest } = globBase(pattern, root);
176
+ const forms = rest ? [pattern, joined, ...pathForms(prefix, cwd).map(f => path.join(f, rest))] : pathForms(joined, cwd);
177
+ const plain = (f: string) => f.replace(/\[!?([^\]])[^\]]*\]/g, '$1').replace(/(^|\/)[*?]+/g, '$1x').replace(/[*?]+/g, '');
178
+ return [...forms, ...forms.map(plain)];
179
+ }
180
+
181
+ const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined);
182
+
183
+ /** Would this call read/write/list a secret path? File tools match literal + realpath; Glob/Grep match their search root
184
+ * and filename pattern (Grep's `pattern` is a content regex, not a path). `restricted` adds /proc and /sys. Bash: server
185
+ * credential file names only — best-effort, a full profile's Bash can always reach a file some other way. */
186
+ export function touchesSecretPath(tool: string, input: Record<string, unknown>, cwd: string = process.cwd(), restricted = false): boolean {
187
+ if (tool === 'Bash') return SECRET_FILE_CMD_RE.test(String(input.command ?? ''));
188
+ const secret = (forms: string[]) => forms.some(f => SECRET_PATH_PATTERNS.some(re => re.test(f)) || (restricted && SYSTEM_PATH_RE.test(f)));
189
+ const root = str(input.path);
190
+ const pattern = tool === 'Glob' ? str(input.pattern) : tool === 'Grep' ? str(input.glob) : undefined;
191
+ if (pattern && secret(globForms(pattern, root, cwd))) return true;
192
+ return [str(input.file_path), str(input.notebook_path), root].some(p => p !== undefined && secret(pathForms(p, cwd)));
193
+ }
194
+
195
+ // ── Protected data (tamper protection) ───────────────────────────────────────
196
+ // Server-owned state under DATA_DIR that NO agent file tool may write or edit: every profile, owner included, and
197
+ // whatever SECURITY_ENFORCE says (hooks.ts applies it always; TurnGuard also audits it when enforcing). The server
198
+ // writes these with fs calls, never agent tools, so it is unaffected. Reads are not covered: they follow the profile
199
+ // (secret files stay denied by SECRET_PATH_PATTERNS). Bash is not covered (best-effort, see Secret paths) — for the
200
+ // audit log the guarantee is OS-level `chattr +a` (src/scripts/harden-audit.sh) plus the data-sync offsite copy.
201
+ /** DATA_DIR-relative; a trailing `/` protects the whole directory. */
202
+ export const PROTECTED_DATA_WRITE: readonly string[] = [
203
+ 'audit/', 'conversations/', 'sessions/', 'sessions.json', 'security/', 'api-keys.json', 'api-keys.json.bak',
204
+ 'oauth-clients.json', 'mcps/', '.internal-token', '.mcp-oauth-secret', '.local-auth-secret', 'users.json',
205
+ // data-sync's own repo: .git/config (core.fsmonitor, hooks) runs code on its next git call; .gitignore untracks state.
206
+ '.git/', '.gitignore',
207
+ ];
208
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
209
+ export const PROTECTED_DATA_MESSAGE = 'Audit logs, conversations, sessions, security policy, keys and MCP config are server-owned and cannot be modified by agent tools.';
210
+
211
+ /** Would this file tool write a protected data path? Target and data dir both matched in absolute and realpath form. */
212
+ export function writesProtectedData(tool: string, input: Record<string, unknown>, cwd: string = process.cwd(), dataDir: string = DATA_DIR): boolean {
213
+ const target = WRITE_TOOLS.has(tool) ? str(input.file_path) ?? str(input.notebook_path) : undefined;
214
+ if (!target) return false;
215
+ const roots = [...new Set(pathForms(dataDir, cwd).slice(1))];
216
+ return pathForms(target, cwd).slice(1).some(f => roots.some(root => {
217
+ const rel = path.relative(root, f);
218
+ if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false;
219
+ const r = rel.split(path.sep).join('/').toLowerCase(); // case-insensitive filesystems (macOS) alias Audit/ to audit/
220
+ return PROTECTED_DATA_WRITE.some(e => (e.endsWith('/') ? r === e.slice(0, -1) || r.startsWith(e) : r === e));
221
+ }));
222
+ }
223
+
224
+ /** Restricted profiles: does this Glob search outside the workspace root (`cwd`)? Its base is realpath-checked; any
225
+ * `..` segment in the pattern counts as outside. */
226
+ export function globOutsideWorkspace(input: Record<string, unknown>, cwd: string = process.cwd()): boolean {
227
+ const pattern = str(input.pattern) ?? '';
228
+ if (pattern.split('/').includes('..')) return true;
229
+ const real = (p: string) => pathForms(p, cwd).at(-1)!;
230
+ const ws = real(cwd);
231
+ const base = real(globBase(pattern, str(input.path) ?? cwd).prefix);
232
+ return base !== ws && !base.startsWith(ws.endsWith(path.sep) ? ws : ws + path.sep);
233
+ }
234
+
235
+ // ── Turn guard ───────────────────────────────────────────────────────────────
236
+
237
+ export type GateResult = { allow: true } | { allow: false; message: string };
238
+
239
+ export class TurnGuardOptions {
240
+ runtime!: SecurityRuntime;
241
+ principal!: Principal;
242
+ sessionId?: string;
243
+ /** The caller's own role/rank at turn start (SecurityRuntime.decide → resolvePrincipal, the single resolution path). */
244
+ role!: string;
245
+ rank!: number;
246
+ /** Session floor lookup (sessions.getSessionFloor — a Map read). Undefined = no floor recorded. */
247
+ floorOf: (sessionId: string) => number | undefined = () => undefined;
248
+ log: Pick<Console, 'log' | 'error'> = console;
249
+ }
250
+
251
+ /** One turn's security context, handed to the engine. */
252
+ export class TurnGuard {
253
+ public options: TurnGuardOptions;
254
+
255
+ public constructor(options: Partial<TurnGuardOptions> & Pick<TurnGuardOptions, 'runtime' | 'principal' | 'role' | 'rank'>) {
256
+ this.options = { ...new TurnGuardOptions(), ...options };
257
+ }
258
+
259
+ /** Effective role right now: policy.effective(session floor, caller role). Re-read on every call. */
260
+ public current(): Resolved {
261
+ const { runtime, sessionId, floorOf, role, rank } = this.options;
262
+ const floor = sessionId ? floorOf(sessionId) : undefined;
263
+ return runtime.policy.effective(floor ?? rank, role);
264
+ }
265
+
266
+ /** Gate one tool call. Audits tool.allow / tool.deny (deduped per session+tool+role per window). Never throws.
267
+ * `cwd` is the workspace root: it resolves relative paths and bounds a restricted Glob (the hook passes the CLI's). */
268
+ public check(tool: string, input: Record<string, unknown> = {}, cwd?: string): GateResult {
269
+ const { runtime, principal, sessionId, log } = this.options;
270
+ let eff: Resolved;
271
+ try { eff = this.current(); } catch (e: any) {
272
+ log.error(`[security] effective-role lookup failed — denying ${tool}: ${e.message}`);
273
+ return { allow: false, message: 'Tool use is unavailable right now (security check failed).' };
274
+ }
275
+ const restricted = !eff.profile.tools.includes('*');
276
+ const reason = writesProtectedData(tool, input, cwd) ? 'protected-path'
277
+ : touchesSecretPath(tool, input, cwd, restricted) ? 'secret-path'
278
+ : !profileAllowsTool(eff.profile, tool) ? 'profile'
279
+ : restricted && tool === 'Glob' && globOutsideWorkspace(input, cwd) ? 'outside-workspace' : undefined;
280
+ const base = { principal: principal.id, role: eff.role, sessionId, target: tool };
281
+ try {
282
+ runtime.record(!reason
283
+ ? { type: 'tool.allow', ...base }
284
+ : { type: 'tool.deny', ...base, reason, meta: { profile: eff.profileName, rank: eff.rank } },
285
+ `tool.${reason ? 'deny' : 'allow'}|${sessionId ?? ''}|${tool}|${eff.role}`);
286
+ } catch (e: any) { log.error(`[security] tool audit failed: ${e.message}`); }
287
+ if (!reason) return { allow: true };
288
+ log.log(`[security] Denied ${tool} for ${principal.id} (role=${eff.role} profile=${eff.profileName} ${reason}) session=${sessionId ?? 'new'}`);
289
+ if (reason === 'protected-path') return { allow: false, message: PROTECTED_DATA_MESSAGE };
290
+ if (reason === 'secret-path') return { allow: false, message: 'Credential and secret files are not accessible.' };
291
+ if (reason === 'outside-workspace') return { allow: false, message: `Glob is limited to the workspace for role "${eff.role}".` };
292
+ const hint = allowsEscalate(eff.profile) ? ` Use the ${ESCALATE_TOOL} tool to hand this request to an owner.` : '';
293
+ return { allow: false, message: `Tool ${tool} is not available to role "${eff.role}" in this session.${hint}` };
294
+ }
295
+
296
+ /** PreToolUse hook: the gate on every call, including auto-approved (`allowedTools`) tools. */
297
+ public hook(): HookCallback {
298
+ return async (input) => {
299
+ if (input.hook_event_name !== 'PreToolUse') return {};
300
+ const { tool_name, tool_input } = input as PreToolUseHookInput;
301
+ const r = this.check(tool_name, (tool_input ?? {}) as Record<string, unknown>, (input as PreToolUseHookInput).cwd || undefined);
302
+ if (r.allow) return {};
303
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, permissionDecisionReason: r.message } };
304
+ };
305
+ }
306
+ }
@@ -0,0 +1,194 @@
1
+ // `escalate` — the one tool a reply-only principal gets: hand the request to a human owner, who opens the session
2
+ // link and continues AS THEMSELVES (a new owner-owned turn). The escalated session never upgrades.
3
+ //
4
+ // Anti alert-fatigue: per-principal cooldown with digest batching. The first escalation in a window is sent at once
5
+ // and opens a `cooldownMs` window; further ones in that window are collected and sent as ONE digest when it ends
6
+ // (which opens the next window, so a sustained flood yields at most one notice per window). Principals are cheap to
7
+ // rotate (a new From address each time), so a GLOBAL cap bounds immediate notices across all of them: beyond
8
+ // `globalMax` per `globalWindowMs`, everything goes into one global digest. Every digest lists at most `maxPending`.
9
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
10
+ import { z } from 'zod/v4';
11
+ import type { Principal } from './principal.ts';
12
+ import type { AuditEvent } from './audit.ts';
13
+ import { security } from './runtime.ts';
14
+ import { getSessionUrl } from '../shraga-config.ts';
15
+ import { ESCALATE_SERVER, ESCALATE_TOOL } from './enforce.ts';
16
+
17
+ export interface EscalationRequest {
18
+ principal: Principal;
19
+ /** Effective role at escalation time. */
20
+ role: string;
21
+ sessionId?: string;
22
+ /** Channel/lane, e.g. `slack`, `email via gmail`, `internal:scheduler`. */
23
+ channel: string;
24
+ /** The model's summary (untrusted text). */
25
+ summary: string;
26
+ /** The raw request that triggered it (untrusted text). */
27
+ excerpt?: string;
28
+ }
29
+ /** `sent` = the owner notice was handed off; `batched` = queued for a digest; `dropped` = over the digest cap (counted). */
30
+ export type EscalationStatus = 'sent' | 'batched' | 'dropped';
31
+
32
+ export class EscalationsOptions {
33
+ cooldownMs: number = 15 * 60_000;
34
+ /** At most this many IMMEDIATE notices per `globalWindowMs`, across all principals. */
35
+ globalMax: number = 5;
36
+ globalWindowMs: number = 15 * 60_000;
37
+ excerptChars: number = 500;
38
+ summaryChars: number = 500;
39
+ /** Max escalations listed in one digest (per principal, and the global one); beyond it they are counted, not kept. */
40
+ maxPending: number = 20;
41
+ /** Deliver a notice; a rejection means it was NOT sent. */
42
+ notify: (text: string) => void | Promise<void> = (text) => import('../notify-owners.ts').then(m => m.notifyOwners('security', text));
43
+ record: (ev: AuditEvent) => void = (ev) => security()?.record(ev);
44
+ sessionUrl: (sessionId?: string) => string | undefined = getSessionUrl;
45
+ setTimer: (fn: () => void, ms: number) => { cancel(): void } = (fn, ms) => {
46
+ const t = setTimeout(fn, ms); (t as { unref?: () => void }).unref?.();
47
+ return { cancel: () => clearTimeout(t) };
48
+ };
49
+ }
50
+
51
+ /** `senders`: every principal held in the window, listed or dropped (the digest header counts them). */
52
+ interface Window { pending: EscalationRequest[]; dropped: number; senders: Set<string>; timer: { cancel(): void } }
53
+ interface GlobalWindow extends Window { sent: number }
54
+
55
+ const clip = (s: string | undefined, n: number) => { const t = (s ?? '').trim(); return t.length > n ? `${t.slice(0, n)}…` : t; };
56
+ /** Untrusted text into a Slack notice: `&<>` escaped (no `<!channel>`, `<@U…>`, `<url|label>` markup), no bare broadcasts. */
57
+ const slack = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/@(channel|here|everyone)\b/gi, '@\u200b$1');
58
+ const NEWLINE = /\r\n|[\r\n\u2028\u2029]/;
59
+ /** A single-line field: newlines collapsed, so it can't forge `*Session:*`-style lines. */
60
+ const oneLine = (s: string) => slack(s).split(NEWLINE).map(l => l.trim()).filter(Boolean).join(' ');
61
+ /** A multi-line field: every line quoted, so nothing in it reads as one of the notice's own lines. */
62
+ const quote = (s: string) => slack(s).split(NEWLINE).map(l => `> ${l}`).join('\n');
63
+
64
+ export class Escalations {
65
+ public options: EscalationsOptions;
66
+ private windows = new Map<string, Window>();
67
+ private global?: GlobalWindow;
68
+
69
+ public constructor(options?: Partial<EscalationsOptions>) {
70
+ this.options = { ...new EscalationsOptions(), ...options };
71
+ }
72
+
73
+ public async escalate(req: EscalationRequest): Promise<EscalationStatus> {
74
+ const w = this.windows.get(req.principal.id);
75
+ let status: EscalationStatus;
76
+ if (w) status = this.hold(w, req);
77
+ else if ((this.global?.sent ?? 0) >= this.options.globalMax) status = this.hold(this.globalWindow(), req);
78
+ else {
79
+ const g = this.globalWindow();
80
+ g.sent++;
81
+ this.open(req.principal.id);
82
+ // `sent` only once the notice was handed off; a failed one frees its global slot and waits in the digest instead.
83
+ if (await this.send([req], 0)) status = 'sent';
84
+ else { g.sent--; status = this.hold(this.windows.get(req.principal.id) ?? this.globalWindow(), req); }
85
+ }
86
+ this.audit(req, status);
87
+ return status;
88
+ }
89
+
90
+ /** Shutdown/test: send every pending digest now (awaiting delivery) and clear all windows. Returns digests sent. */
91
+ public async flushAll(): Promise<number> {
92
+ const jobs: Promise<boolean>[] = [];
93
+ for (const [key, w] of [...this.windows]) { w.timer.cancel(); jobs.push(this.flush(key, false)); }
94
+ if (this.global) { this.global.timer.cancel(); jobs.push(this.flushGlobal()); }
95
+ return (await Promise.all(jobs)).filter(Boolean).length;
96
+ }
97
+
98
+ private hold(w: Window, req: EscalationRequest): EscalationStatus {
99
+ w.senders.add(req.principal.id);
100
+ if (w.pending.length < this.options.maxPending) { w.pending.push(req); return 'batched'; }
101
+ w.dropped++;
102
+ return 'dropped';
103
+ }
104
+
105
+ private globalWindow(): GlobalWindow {
106
+ return (this.global ??= { pending: [], dropped: 0, senders: new Set(), sent: 0, timer: this.options.setTimer(() => void this.flushGlobal(), this.options.globalWindowMs) });
107
+ }
108
+
109
+ private open(key: string): void {
110
+ const timer = this.options.setTimer(() => void this.flush(key, true), this.options.cooldownMs);
111
+ this.windows.set(key, { pending: [], dropped: 0, senders: new Set(), timer });
112
+ }
113
+
114
+ private async flush(key: string, reopen: boolean): Promise<boolean> {
115
+ const w = this.windows.get(key);
116
+ this.windows.delete(key);
117
+ if (!w || (!w.pending.length && !w.dropped)) return false;
118
+ if (reopen) this.open(key);
119
+ return w.pending.length ? this.send(w.pending, w.dropped) : false;
120
+ }
121
+
122
+ private async flushGlobal(): Promise<boolean> {
123
+ const g = this.global;
124
+ this.global = undefined;
125
+ return g?.pending.length ? this.send(g.pending, g.dropped, g.senders.size) : false;
126
+ }
127
+
128
+ /** `senders` set = a global digest (lists who sent each item). */
129
+ private async send(reqs: EscalationRequest[], dropped: number, senders?: number): Promise<boolean> {
130
+ try { await this.options.notify(this.format(reqs, dropped, senders)); return true; }
131
+ catch (e: any) { console.error(`[escalate] owner notice failed: ${e?.message ?? e}`); return false; }
132
+ }
133
+
134
+ private format(reqs: EscalationRequest[], dropped: number, senders?: number): string {
135
+ const { excerptChars, summaryChars, sessionUrl, cooldownMs, globalWindowMs } = this.options;
136
+ const global = senders !== undefined;
137
+ const who = (r: EscalationRequest) => `${oneLine(r.principal.id)} (role ${oneLine(r.role)})`;
138
+ const item = (r: EscalationRequest) => {
139
+ const link = sessionUrl(r.sessionId);
140
+ const lines = global ? [`*From:* ${who(r)}`] : [];
141
+ lines.push(`*Channel:* ${oneLine(r.channel)}`, `*Session:* ${link ?? (r.sessionId ? oneLine(r.sessionId) : '(none)')}`, '*Summary:*', quote(clip(r.summary, summaryChars)));
142
+ if (r.excerpt?.trim()) lines.push('*Original message:*', quote(clip(r.excerpt, excerptChars)));
143
+ return lines.join('\n');
144
+ };
145
+ const footer = 'Open the session and continue as yourself — the escalated session itself keeps its restricted role.';
146
+ const more = dropped ? ` (+${dropped} more not shown)` : '';
147
+ const first = reqs[0];
148
+ if (!global && reqs.length === 1 && !dropped) return `:rotating_light: Escalation from ${who(first)}\n${item(first)}\n\n${footer}`;
149
+ const head = global
150
+ ? `${reqs.length + dropped} escalations from ${senders} sender${senders === 1 ? '' : 's'} while immediate notices were capped (${this.options.globalMax} per ${Math.round(globalWindowMs / 60_000)} min)`
151
+ : `${reqs.length} escalations from ${who(first)} in the last ${Math.round(cooldownMs / 60_000)} min`;
152
+ return `:rotating_light: ${head}${more}\n\n${reqs.map(item).join('\n\n')}\n\n${footer}`;
153
+ }
154
+
155
+ private audit(req: EscalationRequest, status: EscalationStatus): void {
156
+ try {
157
+ this.options.record({ type: 'escalate', principal: req.principal.id, role: req.role, sessionId: req.sessionId, target: req.channel, reason: status });
158
+ } catch (e: any) { console.error(`[escalate] audit failed: ${e?.message ?? e}`); }
159
+ }
160
+ }
161
+
162
+ let current: Escalations | undefined;
163
+ /** Process-wide instance (cooldowns must be shared across turns). */
164
+ export function escalations(): Escalations { return (current ??= new Escalations()); }
165
+ /** Test-only. */
166
+ export function __setEscalationsForTest(e: Escalations | undefined): void { current = e; }
167
+ /** Shutdown: deliver batched digests (they live in memory only). No-op when nothing ever escalated. */
168
+ export function flushEscalations(): Promise<number> { return current ? current.flushAll() : Promise.resolve(0); }
169
+
170
+ /** What the model tells the sender: a delivery claim only when the notice was handed off; a digest promise only when the
171
+ * request is listed in one. A dropped request is only COUNTED in the digest ("+N more not shown"). */
172
+ export const escalateReply = (status: EscalationStatus) => status === 'sent'
173
+ ? 'Escalated: the owners have been notified.'
174
+ : status === 'batched'
175
+ ? 'Queued for the owners: this request will reach them in their next digest. Do not tell the sender they were already notified.'
176
+ : 'Owners are receiving many requests right now: this one is counted in their next summary, but its details are not included. Do not tell the sender they were notified or that the owners have their request; suggest they try again later if it is urgent.';
177
+
178
+ /** In-process MCP server exposing `escalate` for one turn. */
179
+ export function escalateMcpServer(ctx: { principal: Principal; role: () => string; sessionId?: string; channel: string; excerpt?: string }) {
180
+ return createSdkMcpServer({
181
+ name: ESCALATE_SERVER,
182
+ version: '1.0.0',
183
+ tools: [tool(
184
+ ESCALATE_TOOL,
185
+ 'Hand this request to a human owner when it needs something you are not permitted to do here (tools, data, actions). ' +
186
+ 'Owners get your summary and the original message (immediately, or in a digest when busy); they follow up themselves. Call it at most once per request, then tell the sender a human will follow up.',
187
+ { summary: z.string().min(1).max(2000).describe('One or two sentences: what the sender wants and why it needs a human.') },
188
+ async ({ summary }) => {
189
+ const status = await escalations().escalate({ principal: ctx.principal, role: ctx.role(), sessionId: ctx.sessionId, channel: ctx.channel, summary, excerpt: ctx.excerpt });
190
+ return { content: [{ type: 'text' as const, text: escalateReply(status) }] };
191
+ },
192
+ )],
193
+ });
194
+ }