shraga 0.1.115 → 0.1.117

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.
@@ -13,7 +13,7 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
16
- <script type="module" crossorigin src="/assets/index-CeGkHIed.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-Bke9ulHK.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-CFxKc8dH.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.115",
3
+ "version": "0.1.117",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -31,7 +31,11 @@ import {
31
31
  BookOpen,
32
32
  Blocks,
33
33
  SquarePen,
34
+ ShieldCheck,
34
35
  } from 'lucide-react';
36
+ import { useOwner } from '@/hooks/useOwner';
37
+ import { api } from '@/lib/api';
38
+ import { OwnerConsole } from '@/components/owner/OwnerConsole';
35
39
  import { useDarkMode } from '@/hooks/useDarkMode';
36
40
  import { useUnread } from '@/hooks/useUnread';
37
41
  import { ToastStack } from '@/components/Toast';
@@ -68,6 +72,7 @@ function AppInner() {
68
72
  const slots = useSlots();
69
73
  const { dark, toggle: toggleDark } = useDarkMode();
70
74
  const { user, token, getToken, loading, mode, needsSetup, loginLocal, registerLocal, logout } = useAuth();
75
+ const { isOwner, call: ownerCall } = useOwner(getToken);
71
76
  const [sidebarOpen, _setSidebarOpen] = useState(() => localStorage.getItem('shraga:sidebarOpen') !== 'false');
72
77
  const setSidebarOpen = useCallback((v: boolean | ((prev: boolean) => boolean)) => {
73
78
  _setSidebarOpen((prev) => {
@@ -229,6 +234,12 @@ function AppInner() {
229
234
  const { socket, connectionStatus, authError } = useAgentSocket(token, getToken, handleGlobalEvent);
230
235
 
231
236
  const { unreads, toasts, markRead, dismissToast, addToast, handleUnreadEvent } = useUnread(socket, activeSessionId);
237
+ // Owner Console → open any user's session: the meta fetch confirms access (owner view-only bypass) before selecting.
238
+ const openOwnerSession = useCallback(async (id: string) => {
239
+ await api(`/api/sessions/${encodeURIComponent(id)}/meta`, getToken);
240
+ openSession(id);
241
+ markRead(id);
242
+ }, [getToken, openSession, markRead]);
232
243
  unreadEventRef.current = handleUnreadEvent;
233
244
  markReadRef.current = markRead;
234
245
 
@@ -425,6 +436,17 @@ function AppInner() {
425
436
  {dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
426
437
  </Button>
427
438
  {slots.headerActions?.()}
439
+ {isOwner && (
440
+ <OwnerConsole
441
+ call={ownerCall}
442
+ onOpenSession={openOwnerSession}
443
+ trigger={
444
+ <Button variant="ghost" size="icon" className="h-8 w-8" title="Owner Console">
445
+ <ShieldCheck className="w-4 h-4" />
446
+ </Button>
447
+ }
448
+ />
449
+ )}
428
450
  <McpManager getToken={getToken} />
429
451
  <SkillsManager getToken={getToken} onSkillsChange={setSkills} />
430
452
  <ModulesManager getToken={getToken} />
@@ -454,6 +476,17 @@ function AppInner() {
454
476
  style={{ top: mobileMenuPos.top, right: mobileMenuPos.right }}
455
477
  >
456
478
  {slots.headerActions?.()}
479
+ {isOwner && (
480
+ <OwnerConsole
481
+ call={ownerCall}
482
+ onOpenSession={openOwnerSession}
483
+ trigger={
484
+ <button className="flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-sm hover:bg-accent">
485
+ <ShieldCheck className="w-3.5 h-3.5" /> Owner Console
486
+ </button>
487
+ }
488
+ />
489
+ )}
457
490
  <McpManager
458
491
  getToken={getToken}
459
492
  trigger={
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
- import { Plus, MessageSquare, Hash, AtSign, MessageCircle, ShieldCheck } from 'lucide-react';
2
+ import { Plus, MessageSquare, Hash, AtSign, MessageCircle } from 'lucide-react';
3
3
  import { Button } from './ui/button';
4
4
  import { ScrollArea } from './ui/scroll-area';
5
5
  import { cn } from '@/lib/utils';
@@ -10,9 +10,6 @@ import type { UnreadSession } from '@/hooks/useUnread';
10
10
  import type { AgentSocket } from '@/lib/ws';
11
11
  import { useSessionList, type SessionRow as Session, type ChatsFilter } from '@/hooks/useSessionList';
12
12
  import { CLIENT_BUILD_VERSION } from '@/lib/build-version';
13
- import { useOwner } from '@/hooks/useOwner';
14
- import { api } from '@/lib/api';
15
- import { OwnerConsole } from './owner/OwnerConsole';
16
13
 
17
14
  interface Props {
18
15
  getToken: () => Promise<string | null>;
@@ -78,7 +75,6 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
78
75
  const [filter, setFilter] = useState<ChatsFilter>(() => (localStorage.getItem(FILTER_KEY) as ChatsFilter) || 'mine');
79
76
  const [unreadOnly, setUnreadOnly] = useState(() => localStorage.getItem(UNREAD_FILTER_KEY) === 'true');
80
77
  const [version, setVersion] = useState<string>('');
81
- const { isOwner, call: ownerCall } = useOwner(getToken);
82
78
  const activeRef = useRef<HTMLButtonElement | null>(null);
83
79
 
84
80
  function changeFilter(f: ChatsFilter) {
@@ -246,17 +242,6 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
246
242
  </ScrollArea>
247
243
 
248
244
  <div className="px-4 py-2 flex flex-col items-center gap-1">
249
- {isOwner && (
250
- <OwnerConsole
251
- call={ownerCall}
252
- onOpenSession={async (id) => { await api(`/api/sessions/${encodeURIComponent(id)}/meta`, getToken); onSelect(id); }}
253
- trigger={
254
- <button className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors" title="Roles, bindings, keys, blocklist, audit">
255
- <ShieldCheck className="w-3.5 h-3.5" /> Owner Console
256
- </button>
257
- }
258
- />
259
- )}
260
245
  <MachineStats socket={socket ?? null} getToken={getToken} />
261
246
  {slots.sidebarExtras?.()}
262
247
  {version && (
@@ -31,7 +31,7 @@ import { dataPath } from './paths.ts';
31
31
  import { apiKeyPrincipal, validateApiKey } from './api-keys.ts';
32
32
  import { isOwnerEmail } from './owners.ts';
33
33
  import { fromAuthUser, fromInternal, type Principal } from './security/principal.ts';
34
- import { security } from './security/runtime.ts';
34
+ import { loginAllowed, security } from './security/runtime.ts';
35
35
  import { firebaseIssuedAt, tokenRevoked } from './security/revocation.ts';
36
36
 
37
37
  // Issued-at (`iat`) + revocation: every token format below carries `iat` for NEW tokens, in a shape the pre-iat
@@ -154,17 +154,6 @@ export function verifyMcpToken(token: string): { uid: string; email: string; kin
154
154
  return { kind, uid, email };
155
155
  }
156
156
 
157
- const WHITELIST_PATH = dataPath('whitelist.json');
158
-
159
- function loadWhitelist(): string[] {
160
- if (!existsSync(WHITELIST_PATH)) return [];
161
- try {
162
- return JSON.parse(readFileSync(WHITELIST_PATH, 'utf-8'));
163
- } catch {
164
- return [];
165
- }
166
- }
167
-
168
157
  export interface AuthUser {
169
158
  uid: string;
170
159
  email: string;
@@ -195,12 +184,11 @@ export async function verifyToken(token: string): Promise<AuthUser> {
195
184
  if (msg.includes('aud')) throw new Error(`Token audience mismatch (expected ${projectId}). Please sign out and sign in again.`);
196
185
  throw new Error(msg);
197
186
  }
198
- const whitelist = loadWhitelist();
199
- if (whitelist.length > 0 && !whitelist.includes(payload.email)) {
200
- throw new Error('User not in whitelist');
201
- }
202
187
  const uid = payload.user_id || payload.sub;
203
188
  const principal = fromAuthUser({ uid, email: payload.email });
189
+ // Login gate = policy bindings (member rank+) or OWNERS; fails closed. The message keeps the word 'whitelist':
190
+ // the client (App.tsx, useAgentSocket, lib/ws) keys its permanent "not allowed" handling on it.
191
+ if (!loginAllowed(principal)) throw new Error('User not in whitelist — ask an owner to add a binding (Owner Console → Bindings)');
204
192
  // auth_time = sign-in time; the hourly-refreshed iat would outlive a revocation.
205
193
  if (tokenRevoked(principal.id, firebaseIssuedAt(payload))) throw new Error('Token revoked — sign in again');
206
194
  return authUser(uid, payload.email, principal);
@@ -72,6 +72,7 @@ import { claudeAccountDir } from './claude-account.ts';
72
72
  import { ClaudeLogin, ClaudeLoginError } from './claude-login.ts';
73
73
  import { getAll as getAllContacts } from './contacts.ts';
74
74
  import { artifactsRouter } from './artifacts/artifacts.routes.ts';
75
+ import { setSharedFileHeaders } from './share-file.ts';
75
76
  import { ownerRouter } from './security/owner-routes.ts';
76
77
  import { handleArtifactToolUse } from './artifacts/artifacts.handler.ts';
77
78
  import { registerEngine } from './engine/index.ts';
@@ -853,7 +854,7 @@ app.get('/api/workspace/raw', requireAuth, (req, res) => {
853
854
  res.sendFile(resolved);
854
855
  });
855
856
 
856
- app.use('/uploads/shared', express.static(dataPath('uploads/shared'), { dotfiles: 'deny', index: false }));
857
+ app.use('/uploads/shared', express.static(dataPath('uploads/shared'), { dotfiles: 'deny', index: false, setHeaders: setSharedFileHeaders }));
857
858
  app.use('/uploads', requireAuth, express.static(dataPath('uploads'), { dotfiles: 'deny', index: false }));
858
859
 
859
860
  app.post('/api/upload', requireAuth, express.raw({ type: '*/*', limit: '50mb' }), (req, res) => {
@@ -23,8 +23,9 @@ 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';
26
+ import { buildAgentEnv, builtinTools, filterMcpServers, allowsEscalate, allowsInternalToken, allowsShare, SHARE_TOOL_ID, SENSITIVE_PATH_PATTERNS } from '../security/enforce.ts';
27
27
  import { escalateMcpServer } from '../security/escalate.ts';
28
+ import { shareMcpServer } from '../share-file.ts';
28
29
  const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
29
30
  const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
30
31
  const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
@@ -517,6 +518,12 @@ export class ClaudeCodeEngine implements AgentEngine {
517
518
  });
518
519
  options['mcpServers'] = { [server.name]: server };
519
520
  }
521
+ // `share_file` (in-process, like escalate): full-access turns only — flag OFF every turn is full.
522
+ if (!eff || allowsShare(eff.profile)) {
523
+ const server = shareMcpServer();
524
+ options['mcpServers'] = { ...(options['mcpServers'] as object | undefined), [server.name]: server };
525
+ options['allowedTools'] = [...allowedTools, SHARE_TOOL_ID];
526
+ }
520
527
 
521
528
  const mcpNames = mcpServers ? Object.keys(mcpServers) : [];
522
529
  const activeModel = (options['model'] as string) || 'default';
@@ -14,9 +14,9 @@
14
14
  import type { Express, Request, Response, NextFunction } from 'express';
15
15
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
16
16
  import { randomBytes, createHash } from 'node:crypto';
17
- import { MCP_TOKEN_TTL, requireAuth, signMcpToken, verifyMcpToken, type AuthUser } from './auth.ts';
17
+ import { AUTH_PROVIDER, MCP_TOKEN_TTL, requireAuth, signMcpToken, verifyMcpToken, type AuthUser } from './auth.ts';
18
18
  import { dataPath } from './paths.ts';
19
- import { security } from './security/runtime.ts';
19
+ import { loginAllowed, security } from './security/runtime.ts';
20
20
  import { fromAuthUser } from './security/principal.ts';
21
21
  import { tokenRevoked } from './security/revocation.ts';
22
22
 
@@ -201,6 +201,11 @@ export function registerMcpOAuthRoutes(app: Express) {
201
201
  if (grant === 'refresh_token') {
202
202
  const id = b.refresh_token ? verifyMcpToken(b.refresh_token) : null;
203
203
  if (!id || id.kind !== 'refresh') return void res.status(400).json({ error: 'invalid_grant', error_description: 'invalid refresh_token' });
204
+ // Re-apply the Firebase login gate: a 30d refresh must not outlive the user's removal from the policy bindings.
205
+ if (AUTH_PROVIDER === 'firebase' && !loginAllowed(fromAuthUser(id))) {
206
+ security()?.authDeny('mcp:oauth-refresh', 'not-whitelisted', req.ip);
207
+ return void res.status(400).json({ error: 'invalid_grant', error_description: 'User not in whitelist — sign in again' });
208
+ }
204
209
  return void res.json({
205
210
  access_token: signMcpToken(id.uid, id.email, 'access', ACCESS_TTL),
206
211
  token_type: 'Bearer',
@@ -36,6 +36,13 @@ export const ESCALATE_SERVER = 'security';
36
36
  export const ESCALATE_TOOL = 'escalate';
37
37
  export const ESCALATE_TOOL_ID = `mcp__${ESCALATE_SERVER}__${ESCALATE_TOOL}`;
38
38
 
39
+ /** In-process MCP server carrying `share_file` (share-file.ts): publishes a file under a public link. Full (`*` tools)
40
+ * profiles only — a restricted principal must not be able to publish arbitrary files to the internet. */
41
+ export const SHARE_SERVER = 'shraga-share';
42
+ export const SHARE_TOOL = 'share_file';
43
+ export const SHARE_TOOL_ID = `mcp__${SHARE_SERVER}__${SHARE_TOOL}`;
44
+ export const allowsShare = (p: Pick<Profile, 'tools'>) => p.tools.includes('*');
45
+
39
46
  /** `escalate` is opt-in by name (reply-only). `*` profiles don't get it: they can act, not just ask. */
40
47
  export const allowsEscalate = (p: Pick<Profile, 'tools'>) => p.tools.includes(ESCALATE_TOOL);
41
48
  export const allowsMcpServer = (p: Pick<Profile, 'mcps'>, server: string) => p.mcps.includes('*') || p.mcps.includes(server);
@@ -58,6 +65,7 @@ export function profileAllowsTool(p: Pick<Profile, 'tools' | 'mcps'>, tool: stri
58
65
  if (tool.startsWith('mcp__')) {
59
66
  const server = tool.slice(5).split('__')[0];
60
67
  if (server === ESCALATE_SERVER) return allowsEscalate(p);
68
+ if (server === SHARE_SERVER) return allowsShare(p);
61
69
  return allowsMcpServer(p, server) || p.tools.includes(tool);
62
70
  }
63
71
  const b = builtinTools(p);
@@ -9,6 +9,7 @@
9
9
  // deduped per key per window, so a busy client isn't one line per request.
10
10
  import { MEMBER_ROLE, OWNER_ROLE, Policy, type PolicyOptions, type Resolved, type TamperReason } from './policy.ts';
11
11
  import { getAgentConfig } from '../agent-config.ts';
12
+ import { isOwnerEmail } from '../owners.ts';
12
13
  import { Audit, type AuditEvent, type AuditOptions } from './audit.ts';
13
14
  import { Guard, type GuardOptions, type TurnAdmission } from './guard.ts';
14
15
  import { fromAuthUser, type Principal } from './principal.ts';
@@ -166,6 +167,31 @@ export function admitTurn(principal: Principal, ctx?: { ip?: string; channel?: s
166
167
  catch (e: any) { console.error(`[security] admitTurn threw — admitting: ${e?.message ?? e}`); return { ok: true, release: () => {} }; }
167
168
  }
168
169
 
170
+ /** `member` rank of a VALID policy, else undefined. A fail-closed policy keeps only owner+anonymous, and resolving an
171
+ * unknown role falls back to the DEFAULT role — whose rank would wrongly admit a guest. So require a real `member` role. */
172
+ function memberRankOf(rt: SecurityRuntime | undefined): number | undefined {
173
+ if (!rt || !rt.policy.valid) return undefined;
174
+ const member = rt.policy.effective(Infinity, MEMBER_ROLE);
175
+ return member.role === MEMBER_ROLE ? member.rank : undefined;
176
+ }
177
+
178
+ /**
179
+ * May this interactive login (a verified `user` principal) use the app? The Firebase login gate — it replaced
180
+ * `whitelist.json`. Allowed iff an OWNER (OWNERS env), or the policy resolves it at/above `member` rank.
181
+ * FAILS CLOSED for everyone else: no runtime yet (before boot's initSecurity), an invalid/fail-closed policy, or a throw
182
+ * ⇒ `false`. Owners are checked first and without the policy, so a broken policy can never lock them out.
183
+ */
184
+ export function loginAllowed(principal: Principal): boolean {
185
+ if (principal.kind === 'user' && principal.verified && principal.email && isOwnerEmail(principal.email)) return true;
186
+ try {
187
+ const memberRank = memberRankOf(current);
188
+ return memberRank !== undefined && resolvePrincipal(current!.policy, principal).rank >= memberRank;
189
+ } catch (e: any) {
190
+ console.error(`[security] loginAllowed threw — denying: ${e?.message ?? e}`);
191
+ return false;
192
+ }
193
+ }
194
+
169
195
  /**
170
196
  * May the agent send an AUTOMATIC outbound reply to this inbound principal? For channels and add-ons
171
197
  * (mail, chat) to call before handing the model's text back to the sender.
@@ -198,17 +224,14 @@ export function mayReplyTo(principal: Principal, ctx: { sessionId?: string; turn
198
224
  try {
199
225
  if (getAgentConfig().allowUntrustedReplies === true) return true;
200
226
  const rt = current;
201
- if (!rt || !rt.policy.valid) return false;
202
- // A fail-closed policy keeps only owner+anonymous, and resolving an unknown role falls back to the
203
- // DEFAULT role — whose rank would wrongly admit a guest. So require a real `member` role.
204
- const member = rt.policy.effective(Infinity, MEMBER_ROLE);
205
- if (member.role !== MEMBER_ROLE) return false;
227
+ const memberRank = memberRankOf(rt);
228
+ if (!rt || memberRank === undefined) return false;
206
229
  const r = resolvePrincipal(rt.policy, principal);
207
- if (principal.verified && r.rank >= member.rank) return true;
230
+ if (principal.verified && r.rank >= memberRank) return true;
208
231
  rt.record({
209
232
  type: 'guard.limit', principal: principal.id, role: r.role, sessionId: ctx.sessionId,
210
233
  target: ctx.channel ?? 'reply', reason: 'untrusted-reply',
211
- meta: { kind: principal.kind, verified: principal.verified, rank: r.rank, memberRank: member.rank },
234
+ meta: { kind: principal.kind, verified: principal.verified, rank: r.rank, memberRank },
212
235
  }, `reply.suppressed|${ctx.turnId ?? ctx.sessionId ?? principal.id}`);
213
236
  return false;
214
237
  } catch (e: any) {
@@ -0,0 +1,119 @@
1
+ // `share_file` — the ONLY way the agent hands a user a link to a file. It copies the file into the public
2
+ // `uploads/shared` dir (boot.ts serves it unauthenticated) under an unguessable name and returns the absolute URL.
3
+ // Before this the agent built share URLs by hand and invented routes that fell through to the SPA shell.
4
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
5
+ import { mkdirSync, realpathSync, statSync } from 'node:fs';
6
+ import { copyFile, stat, unlink } from 'node:fs/promises';
7
+ import type { ServerResponse } from 'node:http';
8
+ import { randomBytes } from 'node:crypto';
9
+ import path from 'node:path';
10
+ import { z } from 'zod/v4';
11
+ import { DATA_DIR } from './paths.ts';
12
+ import { getPublicOrigin } from './shraga-config.ts';
13
+ import { WORKSPACE_DIR } from './workspace.ts';
14
+ import { SHARE_SERVER, SHARE_TOOL, touchesSecretPath } from './security/enforce.ts';
15
+
16
+ /** URL path boot.ts serves publicly from `<data>/uploads/shared`. */
17
+ export const SHARE_ROUTE = '/uploads/shared';
18
+
19
+ export class FileSharerOptions {
20
+ dataDir: string = DATA_DIR;
21
+ /** ALLOWLIST of dirs agents write deliverables to (resolved by realpath). Never the whole data dir: it holds tokens,
22
+ * config and other sessions' auth-protected uploads. */
23
+ roots: () => string[] = () => [WORKSPACE_DIR]; // not tmp: other processes' task outputs/credentials live there
24
+ /** Largest file that may be published. */
25
+ maxBytes: number = MAX_SHARE_BYTES;
26
+ origin: () => string = getPublicOrigin;
27
+ }
28
+
29
+ export const MAX_SHARE_BYTES = 500 * 1024 * 1024;
30
+
31
+ /** Key/token files with no telltale extension (the secret path patterns cover .env, .pem, .key, service accounts). */
32
+ const KEY_NAME_RE = /^(?:id_(?:rsa|dsa|ecdsa|ed25519)(?:_sk)?|credentials?(?:[._-].*)?|tokens?(?:[._-].*)?|.*[._-]tokens?\.json)$/i;
33
+
34
+ export type ShareResult = { ok: true; url: string; path: string; bytes: number } | { ok: false; error: string };
35
+
36
+ const real = (p: string) => { try { return realpathSync(p); } catch { return undefined; } };
37
+ const within = (root: string, p: string) => { const r = path.relative(root, p); return !!r && !r.startsWith('..') && !path.isAbsolute(r); };
38
+
39
+ export class FileSharer {
40
+ public constructor(public options?: Partial<FileSharerOptions>) {
41
+ this.options = { ...new FileSharerOptions(), ...options };
42
+ }
43
+
44
+ private get o() { return this.options as FileSharerOptions; }
45
+
46
+ /** Why this path may not be published, or the single realpath to copy from (no re-resolve between check and copy). */
47
+ public check(filePath: string): { src: string; bytes: number } | { error: string } {
48
+ if (!path.isAbsolute(filePath)) return { error: 'file_path must be absolute.' };
49
+ const src = real(filePath);
50
+ if (!src) return { error: `No such file: ${filePath}` };
51
+ const st = statSync(src);
52
+ if (!st.isFile()) return { error: `Not a regular file: ${filePath}` };
53
+ if (touchesSecretPath('Read', { file_path: filePath }) || touchesSecretPath('Read', { file_path: src }) || KEY_NAME_RE.test(path.basename(src))) return { error: 'Refusing to publish a secret/credential file.' };
54
+ // A hardlink shares its inode with a file elsewhere (e.g. the Claude CLI login) — realpath can't see that.
55
+ if (st.nlink > 1) return { error: 'Refusing to publish a hard-linked file; copy it first.' };
56
+ const roots = [...new Set(this.o.roots().map(real).filter((r): r is string => !!r))];
57
+ const root = roots.filter(r => within(r, src)).sort((a, b) => b.length - a.length)[0];
58
+ if (!root) return { error: `Only files under ${this.o.roots().join(', ')} can be shared.` };
59
+ // Hidden segments (.claude, .git, .ssh, …) hold config and credentials, never deliverables.
60
+ if (path.relative(root, src).split(path.sep).some(s => s.startsWith('.'))) return { error: 'Refusing to publish a file inside a hidden directory or a dotfile.' };
61
+ if (st.size > this.o.maxBytes) return { error: `File is ${st.size} bytes; the share limit is ${this.o.maxBytes}. Compress it or send it another way.` };
62
+ return { src, bytes: st.size };
63
+ }
64
+
65
+ public async share(filePath: string, name?: string): Promise<ShareResult> {
66
+ const origin = this.o.origin();
67
+ if (!origin) return { ok: false, error: 'This deployment has no public origin, so no share link can be made. Send the file as an attachment instead (e.g. Slack file upload).' };
68
+ const c = this.check(filePath);
69
+ if ('error' in c) return { ok: false, error: c.error };
70
+ const { src, bytes } = c;
71
+ const safe = (name || path.basename(src)).normalize('NFKD').replace(/[^\w.-]+/g, '-').replace(/^[.-]+/, '').slice(-100) || 'file';
72
+ const fileName = `${randomBytes(16).toString('hex')}-${safe}`;
73
+ const dir = path.join(this.o.dataDir, 'uploads', 'shared');
74
+ mkdirSync(dir, { recursive: true });
75
+ const dest = path.join(dir, fileName);
76
+ await copyFile(src, dest);
77
+ if ((await stat(dest)).size !== bytes) {
78
+ await unlink(dest).catch(e => console.warn(`[share] cleanup of ${dest} failed: ${e}`));
79
+ return { ok: false, error: `Copy to ${dest} is incomplete; do not send a link.` };
80
+ }
81
+ return { ok: true, url: `${origin}${SHARE_ROUTE}/${encodeURIComponent(fileName)}`, path: dest, bytes };
82
+ }
83
+ }
84
+
85
+ /** Extensions a browser may render inline from our origin without running script. Everything else downloads. */
86
+ const INLINE_SAFE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.mp4', '.webm', '.mov', '.m4v', '.mp3', '.m4a', '.wav', '.ogg', '.pdf', '.txt']);
87
+
88
+ /** express.static `setHeaders` for SHARE_ROUTE (agent shares AND session-less user uploads). The auth token lives in
89
+ * same-origin localStorage, so a published .html/.svg must never execute here. */
90
+ export function setSharedFileHeaders(res: ServerResponse, filePath: string) {
91
+ res.setHeader('X-Content-Type-Options', 'nosniff');
92
+ res.setHeader('Content-Security-Policy', 'sandbox');
93
+ if (!INLINE_SAFE_EXT.has(path.extname(filePath).toLowerCase())) res.setHeader('Content-Disposition', 'attachment');
94
+ }
95
+
96
+ /** In-process MCP server exposing `share_file` (full-access turns only; see engine/claude-code.ts). */
97
+ export function shareMcpServer(sharer = new FileSharer()) {
98
+ return createSdkMcpServer({
99
+ name: SHARE_SERVER,
100
+ version: '1.0.0',
101
+ tools: [tool(
102
+ SHARE_TOOL,
103
+ 'Publish a local file (video, image, PDF, …) and get a public link to send the user. The ONLY valid way to make a file link — never construct share URLs by hand. Anyone with the link can open it (no login).',
104
+ {
105
+ file_path: z.string().min(1).describe('Absolute path of the file (must be under the workspace; move deliverables there first).'),
106
+ name: z.string().max(200).optional().describe('Download name, e.g. "ad-v2.mp4". Defaults to the file name.'),
107
+ },
108
+ async ({ file_path, name }) => {
109
+ let r: ShareResult;
110
+ try { r = await sharer.share(file_path, name); } catch (e) { r = { ok: false, error: `Share failed: ${(e as Error).message}` }; }
111
+ if (!r.ok) console.warn(`[share] refused ${file_path}: ${r.error}`);
112
+ else console.log(`[share] ${file_path} -> ${r.path} (${r.bytes} bytes)`);
113
+ return r.ok
114
+ ? { content: [{ type: 'text' as const, text: `Public link (send exactly this): ${r.url}` }] }
115
+ : { content: [{ type: 'text' as const, text: r.error }], isError: true };
116
+ },
117
+ )],
118
+ });
119
+ }
@@ -4,6 +4,7 @@ import { dataPath } from './paths.ts';
4
4
  import { dataSync } from './data-sync.ts';
5
5
  import { injectFile } from './file-inject.ts';
6
6
  import { getPublicOrigin } from './shraga-config.ts';
7
+ import { SHARE_TOOL } from './security/enforce.ts';
7
8
 
8
9
  export const WORKSPACE_DIR = process.env.WORKSPACE_DIR || dataPath('workspace');
9
10
 
@@ -166,9 +167,8 @@ export function readWorkspaceFile(relPath: string): { content: string; binary: b
166
167
 
167
168
  /** How to hand a user a link to a file (video, image, PDF). Without it the agent invents share routes that don't exist. */
168
169
  function shareFilesGuide(): string {
169
- const origin = getPublicOrigin();
170
- if (!origin) return 'SHARING FILES: this deployment has no public origin — send files as attachments (e.g. Slack file upload), never a made-up URL.';
171
- return `SHARING FILES: to give a link to a file, copy it to ${dataPath('uploads/shared')}/<random-hex>-<name> and link ${origin}/uploads/shared/<random-hex>-<name> (public, no login). Before sending, verify the link with \`curl -sI\` returns 200 and the right content-type. Never invent other share routes.`;
170
+ if (!getPublicOrigin()) return 'SHARING FILES: this deployment has no public origin — send files as attachments (e.g. Slack file upload), never a made-up URL.';
171
+ return `SHARING FILES: to give a user a link to a file, call the \`${SHARE_TOOL}\` tool if you have it and send the URL it returns; without that tool, send the file as an attachment. Never construct share URLs by hand.`;
172
172
  }
173
173
 
174
174
  export function buildWorkspaceContextBlock(): string {