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.
- package/dist/client/assets/{index-CeGkHIed.js → index-Bke9ulHK.js} +249 -249
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +33 -0
- package/src/client/components/Sidebar.tsx +1 -16
- package/src/server/auth.ts +4 -16
- package/src/server/boot.ts +2 -1
- package/src/server/engine/claude-code.ts +8 -1
- package/src/server/mcp-oauth.ts +7 -2
- package/src/server/security/enforce.ts +8 -0
- package/src/server/security/runtime.ts +30 -7
- package/src/server/share-file.ts +119 -0
- package/src/server/workspace.ts +3 -3
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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",
|
package/src/client/App.tsx
CHANGED
|
@@ -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
|
|
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 && (
|
package/src/server/auth.ts
CHANGED
|
@@ -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);
|
package/src/server/boot.ts
CHANGED
|
@@ -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';
|
package/src/server/mcp-oauth.ts
CHANGED
|
@@ -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
|
-
|
|
202
|
-
|
|
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 >=
|
|
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
|
|
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
|
+
}
|
package/src/server/workspace.ts
CHANGED
|
@@ -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
|
-
|
|
170
|
-
|
|
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 {
|