From 4e41780effbc4afb435e57dd000d403f4d7bbd5d Mon Sep 17 00:00:00 2001 From: spytensor <17600413737@163.com> Date: Wed, 5 Aug 2026 15:24:55 +0400 Subject: [PATCH] fix: unify workspace authorization contract --- src/api-routes.fs-roots.test.ts | 30 +++++++ src/api/application-routes.ts | 9 ++- .../handlers/managed-brain.test.ts | 8 +- .../handlers/managed-brain.ts | 4 +- src/index.ts | 14 ++-- src/system-prompt.test.ts | 13 ++++ src/system-prompt.ts | 13 +++- src/tools/fs-root-grants.ts | 16 ++-- src/tools/fs-tools.test.ts | 21 ++++- src/tools/fs-tools.ts | 23 ++++-- src/tools/repo-grounding.ts | 10 ++- src/tools/tool-utils.test.ts | 48 +++++++++++- src/tools/tool-utils.ts | 78 +++++++++++++------ src/tools/web-tools.ts | 7 +- 14 files changed, 230 insertions(+), 64 deletions(-) diff --git a/src/api-routes.fs-roots.test.ts b/src/api-routes.fs-roots.test.ts index 1f5f366..1ada20a 100644 --- a/src/api-routes.fs-roots.test.ts +++ b/src/api-routes.fs-roots.test.ts @@ -87,6 +87,36 @@ describe('api fs roots', () => { } }); + it('publishes the authenticated user workspace root', async () => { + const app = Fastify(); + const userId = 'workspace-user-uuid'; + try { + await registerApiRoutes(app, { + jwtSecret: 'test-secret', + config: { + server: { auth_mode: 'none', host: '127.0.0.1' }, + security: { enterprise: {} }, + http_rate_limit: { global_rpm: 100, auth_rpm: 10, pair_rpm: 5 }, + }, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/fs/roots', + headers: { + authorization: `Bearer ${jwtSign(userId, 'test-secret', 3600, { tenant_id: 'default', roles: ['admin'] })}`, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().roots).toEqual(expect.arrayContaining([ + expect.objectContaining({ tier: 'workspace', path: getWorkspaceDir(userId) }), + ])); + } finally { + await app.close(); + } + }); + it('joins registry identity onto deliverables and leaves legacy files null', async () => { const app = Fastify(); const tenantId = 'default'; diff --git a/src/api/application-routes.ts b/src/api/application-routes.ts index d942b7c..1f5b8fc 100644 --- a/src/api/application-routes.ts +++ b/src/api/application-routes.ts @@ -1631,8 +1631,9 @@ export async function registerApiRoutes( }); // ── REST API: logical filesystem roots ── - app.get('/api/fs/roots', async (_request, reply) => { - return reply.send({ success: true, roots: listFsRoots() }); + app.get('/api/fs/roots', async (request, reply) => { + const tenantContext = getRequestTenantContext(request as { tenantContext?: ApiTenantContext }); + return reply.send({ success: true, roots: listFsRoots(tenantContext?.user_id) }); }); // The cross-session deliverable library (operator decision 2026-07-19): @@ -2282,7 +2283,7 @@ export async function registerApiRoutes( try { const grant = grantProjectRoot(parsed.data.path); - const roots = listFsRoots(); + const roots = listFsRoots(tenantContext?.user_id); logAudit({ tenant_id: tenantContext?.tenant_id ?? 'default', user_id: tenantContext?.user_id, @@ -2330,7 +2331,7 @@ export async function registerApiRoutes( ip_address: request.ip, user_agent: request.headers['user-agent'], }); - return reply.send({ success: true, revoked, roots: listFsRoots() }); + return reply.send({ success: true, revoked, roots: listFsRoots(tenantContext?.user_id) }); }); app.get('/api/runtime/logs', async (request, reply) => { diff --git a/src/background-executor/handlers/managed-brain.test.ts b/src/background-executor/handlers/managed-brain.test.ts index 5444a66..ef237d6 100644 --- a/src/background-executor/handlers/managed-brain.test.ts +++ b/src/background-executor/handlers/managed-brain.test.ts @@ -11,6 +11,8 @@ const hoisted = vi.hoisted(() => ({ getById: vi.fn(), openCronRunSession: vi.fn(), broadcastSessionListChanged: vi.fn(), + loadSystemPrompt: vi.fn(() => 'system prompt'), + loadDelegationSystemPrompt: vi.fn(() => 'delegation prompt'), })); vi.mock('../../config/index.js', () => ({ getConfig: () => ({}) })); @@ -18,8 +20,8 @@ vi.mock('../../core/model-router.js', () => ({ getBrainClient: () => ({ client: { chat: vi.fn() } }), })); vi.mock('../../system-prompt.js', () => ({ - loadSystemPrompt: () => 'system prompt', - loadDelegationSystemPrompt: () => 'delegation prompt', + loadSystemPrompt: hoisted.loadSystemPrompt, + loadDelegationSystemPrompt: hoisted.loadDelegationSystemPrompt, adaptPromptForChannel: (prompt: string) => prompt, })); vi.mock('../../gateway/handler.js', () => ({ handleMessage: hoisted.handleMessage })); @@ -108,6 +110,8 @@ describe('managed Brain scheduled handler', () => { planDeliveryMode: 'caller', suppressAssistantMessagePersistence: true, }); + expect(hoisted.loadSystemPrompt).toHaveBeenCalledWith(expect.anything(), 'default', 'local-user'); + expect(hoisted.loadDelegationSystemPrompt).toHaveBeenCalledWith(expect.anything(), 'default', 'local-user'); await vi.waitFor(() => expect(hoisted.broadcastSessionListChanged).toHaveBeenCalledWith({ targetUserId: 'local-user', tenantId: 'default', sessionId: 'sess-run', })); diff --git a/src/background-executor/handlers/managed-brain.ts b/src/background-executor/handlers/managed-brain.ts index 3a5f0b8..053e2c2 100644 --- a/src/background-executor/handlers/managed-brain.ts +++ b/src/background-executor/handlers/managed-brain.ts @@ -103,7 +103,7 @@ export async function managedBrainHandler(task: BackgroundTask, signal: AbortSig }); const config = getConfig(); - const delegationSystemPrompt = loadDelegationSystemPrompt(config, task.tenant_id); + const delegationSystemPrompt = loadDelegationSystemPrompt(config, task.tenant_id, run.userId); const existingRootId = typeof params.managed_plan_root_id === 'string' ? params.managed_plan_root_id.trim() : ''; @@ -139,7 +139,7 @@ export async function managedBrainHandler(task: BackgroundTask, signal: AbortSig const { client } = getBrainClient({ tenantId: task.tenant_id, userId: task.user_id }); const response = await handleMessage( msg, - adaptPromptForChannel(loadSystemPrompt(config, task.tenant_id), task.channel_type), + adaptPromptForChannel(loadSystemPrompt(config, task.tenant_id, run.userId), task.channel_type), client, progress, undefined, diff --git a/src/index.ts b/src/index.ts index b64e3e4..2ae9068 100644 --- a/src/index.ts +++ b/src/index.ts @@ -378,16 +378,18 @@ export function createMessageHandler( return /aborted|cancelled|canceled|timed out/i.test(message); }; - const resolveSystemPrompt = (tenantId?: string): string => { + const resolveSystemPrompt = (tenantId?: string, userId?: string): string => { const effectiveTenantId = resolveTenantId(tenantId); - const prompt = loadSystemPrompt(config, effectiveTenantId); + const promptKey = `${effectiveTenantId}:${userId ?? ''}`; + const prompt = loadSystemPrompt(config, effectiveTenantId, userId); const fingerprint = createHash('sha1').update(prompt).digest('hex'); - const previousFingerprint = promptFingerprints.get(effectiveTenantId); + const previousFingerprint = promptFingerprints.get(promptKey); if (fingerprint !== previousFingerprint) { - promptFingerprints.set(effectiveTenantId, fingerprint); + promptFingerprints.set(promptKey, fingerprint); logger.info( { tenantId: effectiveTenantId, + userId, promptLength: prompt.length, tools: getAllRegisteredTools(effectiveTenantId).length, }, @@ -492,8 +494,8 @@ export function createMessageHandler( handlerRef = async (msg: IncomingMessage): Promise => { const executeMessage = async (): Promise => { const tenantId = msg.tenantId ?? process.env.MOZI_TENANT_ID ?? 'default'; - const rawSystemPrompt = resolveSystemPrompt(tenantId); - const delegationSystemPrompt = loadDelegationSystemPrompt(config, tenantId); + const rawSystemPrompt = resolveSystemPrompt(tenantId, msg.userId); + const delegationSystemPrompt = loadDelegationSystemPrompt(config, tenantId, msg.userId); const systemPromptContent = adaptPromptForChannel(rawSystemPrompt, msg.channelType); // ── PAIRING GATE (Telegram / WeChat) ── diff --git a/src/system-prompt.test.ts b/src/system-prompt.test.ts index a6546e7..fa6f848 100644 --- a/src/system-prompt.test.ts +++ b/src/system-prompt.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { MoziConfigSchema } from './config/index.js'; import { loadDelegationSystemPrompt, loadSystemPrompt } from './system-prompt.js'; import { setupTestDb, teardownTestDb } from './test-helpers.js'; +import { getWorkspaceDir } from './tools/workspace-policy.js'; let dbTmpDir: string; let workspaceTmpDir: string; @@ -20,6 +21,18 @@ afterAll(() => { }); describe('system prompt assembly', () => { + it('publishes the canonical user workspace instead of the shared config workspace', () => { + const base = MoziConfigSchema.parse({}); + const userId = 'prompt-user-uuid'; + const prompt = loadSystemPrompt({ + ...base, + workspace: { ...base.workspace, dir: workspaceTmpDir }, + }, 'default', userId); + + expect(prompt).toContain(`- workspaceDir: ${getWorkspaceDir(userId)}`); + expect(prompt).not.toContain(`- workspaceDir: ${workspaceTmpDir}`); + }); + it('keeps generic user turns focused on capabilities instead of product self-reference', () => { const base = MoziConfigSchema.parse({}); const prompt = loadSystemPrompt({ diff --git a/src/system-prompt.ts b/src/system-prompt.ts index 2aa6b07..c3a321a 100644 --- a/src/system-prompt.ts +++ b/src/system-prompt.ts @@ -17,6 +17,7 @@ import { getAllRegisteredTools } from './tools/dynamic-registry.js'; import { getRuntimeProjectRoot } from './runtime/project-root.js'; import { getDefaultOutputDir } from './paths.js'; import { formatAvailableToolsSection } from './prompt-sections.js'; +import { getWorkspaceDir } from './tools/workspace-policy.js'; /** * Resolve the workspace directory from config, expanding ~ to home. @@ -40,8 +41,9 @@ export function resolveTenantId(tenantId?: string): string { export function loadSystemPrompt( config: MoziConfig, tenantId?: string, + userId?: string, ): string { - return loadSystemPromptLayers(config, tenantId, true); + return loadSystemPromptLayers(config, tenantId, true, userId); } /** @@ -53,16 +55,19 @@ export function loadSystemPrompt( export function loadDelegationSystemPrompt( config: MoziConfig, tenantId?: string, + userId?: string, ): string { - return loadSystemPromptLayers(config, tenantId, false); + return loadSystemPromptLayers(config, tenantId, false, userId); } function loadSystemPromptLayers( config: MoziConfig, tenantId: string | undefined, includeUserProfile: boolean, + userId: string | undefined, ): string { - const workspaceDir = resolveWorkspaceDir(config); + const overrideWorkspaceDir = resolveWorkspaceDir(config); + const workspaceDir = getWorkspaceDir(userId); const outputDir = resolveOutputDir(); const parts: string[] = []; const runtimeRoot = getRuntimeProjectRoot(); @@ -81,7 +86,7 @@ function loadSystemPromptLayers( ? ['SOUL.local.md', 'AGENTS.local.md', 'USER.md'] : ['SOUL.local.md', 'AGENTS.local.md']; for (const file of userOverrideFiles) { - const userPath = join(workspaceDir, file); + const userPath = join(overrideWorkspaceDir, file); if (existsSync(userPath)) { parts.push(readFileSync(userPath, 'utf-8')); } diff --git a/src/tools/fs-root-grants.ts b/src/tools/fs-root-grants.ts index 624f51d..29c46aa 100644 --- a/src/tools/fs-root-grants.ts +++ b/src/tools/fs-root-grants.ts @@ -98,13 +98,13 @@ function writeFsConfig(raw: RawConfigObject): void { loadConfig(getConfigPath()); } -function defaultRootPaths(): string[] { - return [getOutputDir(), getWorkspaceDir()]; +function defaultRootPaths(userId?: string): string[] { + return [getOutputDir(), getWorkspaceDir(userId)]; } -function isDefaultRoot(path: string): boolean { +function isDefaultRoot(path: string, userId?: string): boolean { const resolved = resolveRootPath(path); - return defaultRootPaths().some((root) => sameRoot(resolved, root)); + return defaultRootPaths(userId).some((root) => sameRoot(resolved, root)); } function uniqueRootRecords(records: FsRootRecord[]): FsRootRecord[] { @@ -123,7 +123,7 @@ function uniqueRootRecords(records: FsRootRecord[]): FsRootRecord[] { * List the logical file-access roots known to Node. This is not an OS sandbox; * Track B adds native security-scoped enforcement underneath this allowlist. */ -export function listFsRoots(): FsRootRecord[] { +export function listFsRoots(userId?: string): FsRootRecord[] { const policy = getFsPolicy(); const roots: FsRootRecord[] = [ { @@ -135,7 +135,7 @@ export function listFsRoots(): FsRootRecord[] { }, { tier: 'workspace', - path: getWorkspaceDir(), + path: getWorkspaceDir(userId), label: 'Workspace', granted_at: null, bookmark: null, @@ -143,7 +143,7 @@ export function listFsRoots(): FsRootRecord[] { ]; for (const grant of policy.grantedProjectRoots) { - if (isDefaultRoot(grant.path)) continue; + if (isDefaultRoot(grant.path, userId)) continue; roots.push({ tier: 'project', path: grant.path, @@ -154,7 +154,7 @@ export function listFsRoots(): FsRootRecord[] { } for (const root of policy.additionalAllowedRoots) { - if (isDefaultRoot(root)) continue; + if (isDefaultRoot(root, userId)) continue; if (roots.some((existing) => sameRoot(existing.path, root))) continue; roots.push({ tier: 'project', diff --git a/src/tools/fs-tools.test.ts b/src/tools/fs-tools.test.ts index 2826192..0c70d79 100644 --- a/src/tools/fs-tools.test.ts +++ b/src/tools/fs-tools.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { createTempDir, removeTempDir } from '../test-helpers.js'; import type { ArtifactEvent } from '../artifacts/types.js'; import { ArtifactCoordinator } from '../artifacts/coordinator.js'; @@ -60,6 +60,25 @@ afterAll(() => { }); describe('tools/fs-tools write_file artifact terminalization', () => { + it('reads back a Full Access write to the configured workspace for a UUID user', async () => { + const context: ToolContext = { + tenantId: 'default', + userId: '08adcd45-6f72-43ed-b08a-b3713209ee54', + agentId: 'test-agent', + permissionLevel: 'L3_FULL_ACCESS', + }; + const path = `${tmpDir}/ashare_20260805/env_probe.json`; + const content = '{"is_trading_day":true}'; + + const written = await executeFsTool('write_file', { path, content }, 'call_write_legacy_workspace', context); + const read = await executeFsTool('read_file', { path }, 'call_read_legacy_workspace', context); + + expect(written).toMatchObject({ is_error: false }); + expect(read).toMatchObject({ is_error: false, content }); + expect(realpathSync(written!.file_path!)).toBe(realpathSync(path)); + expect(realpathSync(read!.file_path!)).toBe(realpathSync(path)); + }); + it('emits a terminal completed patch carrying plugin_id when reusing a pre-opened coordinator id, even for content <= 20 chars', async () => { const events: ArtifactEvent[] = []; const toolCallId = 'call_short_reuse'; diff --git a/src/tools/fs-tools.ts b/src/tools/fs-tools.ts index 9184dcf..548eac0 100644 --- a/src/tools/fs-tools.ts +++ b/src/tools/fs-tools.ts @@ -12,10 +12,9 @@ import { buildFileArtifactPreviewFields } from '../artifacts/file-preview.js'; import { ensureArtifactCoordinator } from '../artifacts/coordinator.js'; import { remoteArtifactDependencies, unresolvedArtifactPlaceholders } from '../artifacts/content-contract.js'; import { - resolveReadPath, resolveWritePath, resolveWriteRoots, - getReadAllowedPaths, + resolveReadRoots, ensureToolWorkspaceDir, runTel, telErrorMessage, @@ -206,8 +205,15 @@ export async function executeFsTool( return { tool_call_id: id, content: 'Error: "path" parameter is required and must be a string', is_error: true }; } await ensureToolWorkspaceDir(userId); + const allowedPaths = resolveReadRoots(context); maybeEnableRepoInspection(context?.repoInspection, path, context?.workspaceRootPath); - const grounding = resolveInspectionReadPath(path, context?.repoInspection, userId, context?.workspaceRootPath); + const grounding = resolveInspectionReadPath( + path, + context?.repoInspection, + userId, + context?.workspaceRootPath, + allowedPaths, + ); if (!grounding.resolvedPath) { return { tool_call_id: id, @@ -216,7 +222,6 @@ export async function executeFsTool( }; } const resolved = grounding.resolvedPath; - const allowedPaths = getReadAllowedPaths(userId, context?.workspaceRootPath); const telResult = await runTel('filesystem', 'read', { path: resolved, ...(allowedPaths ? { allowed_paths: allowedPaths } : {}), @@ -500,8 +505,15 @@ export async function executeFsTool( case 'list_directory': { const path = (args.path as string) || '.'; await ensureToolWorkspaceDir(userId); + const allowedPaths = resolveReadRoots(context); maybeEnableRepoInspection(context?.repoInspection, path, context?.workspaceRootPath); - const grounding = resolveInspectionDirectoryPath(path, context?.repoInspection, userId, context?.workspaceRootPath); + const grounding = resolveInspectionDirectoryPath( + path, + context?.repoInspection, + userId, + context?.workspaceRootPath, + allowedPaths, + ); if (!grounding.resolvedPath) { return { tool_call_id: id, @@ -510,7 +522,6 @@ export async function executeFsTool( }; } const resolved = grounding.resolvedPath; - const allowedPaths = getReadAllowedPaths(userId, context?.workspaceRootPath); const telResult = await runTel('filesystem', 'list', { path: resolved, ...(allowedPaths ? { allowed_paths: allowedPaths } : {}), diff --git a/src/tools/repo-grounding.ts b/src/tools/repo-grounding.ts index 8bab736..55e9d23 100644 --- a/src/tools/repo-grounding.ts +++ b/src/tools/repo-grounding.ts @@ -380,10 +380,11 @@ export function resolveInspectionReadPath( state: RepoInspectionState | undefined, userId?: string, projectRoot?: string, + allowedRoots?: string[], ): RepoGroundingResult { if (!state?.enabled) { return { - resolvedPath: resolveReadPath(requestedPath, userId, projectRoot), + resolvedPath: resolveReadPath(requestedPath, userId, projectRoot, allowedRoots), reason: 'exact', }; } @@ -393,7 +394,7 @@ export function resolveInspectionReadPath( return { resolvedPath: exactProject, reason: 'exact' }; } - const exactResolved = resolveReadPath(requestedPath, userId, projectRoot); + const exactResolved = resolveReadPath(requestedPath, userId, projectRoot, allowedRoots); if (existsSync(exactResolved)) { return { resolvedPath: exactResolved, reason: 'exact' }; } @@ -438,9 +439,10 @@ export function resolveInspectionDirectoryPath( state: RepoInspectionState | undefined, userId?: string, projectRoot?: string, + allowedRoots?: string[], ): RepoGroundingResult { if (!state?.enabled) { - const resolved = resolveReadPath(requestedPath, userId, projectRoot); + const resolved = resolveReadPath(requestedPath, userId, projectRoot, allowedRoots); if (existsSync(resolved) && statSync(resolved).isDirectory()) { return { resolvedPath: resolved, reason: 'exact' }; } @@ -459,7 +461,7 @@ export function resolveInspectionDirectoryPath( } for (const variant of variants) { - const resolved = resolveReadPath(variant, userId, projectRoot); + const resolved = resolveReadPath(variant, userId, projectRoot, allowedRoots); if (existsSync(resolved)) { try { if (statSync(resolved).isDirectory()) { diff --git a/src/tools/tool-utils.test.ts b/src/tools/tool-utils.test.ts index 285a652..c3e3bc8 100644 --- a/src/tools/tool-utils.test.ts +++ b/src/tools/tool-utils.test.ts @@ -33,7 +33,7 @@ vi.mock('../config/index.js', () => ({ }), })); -import { expandHome, resolveReadPath, resolveWritePath, assertFsPathAllowed, PathScopeError, SensitiveWriteError, resolveWriteRoots, isFullAccessContext } from './tool-utils.js'; +import { expandHome, resolveReadPath, resolveWritePath, assertFsPathAllowed, PathScopeError, SensitiveWriteError, resolveReadRoots, resolveWriteRoots, isFullAccessContext } from './tool-utils.js'; import type { ToolContext } from './types.js'; describe('tools/tool-utils path normalization', () => { @@ -193,6 +193,52 @@ describe('write scope × permission level — L3 bypasses project-scope gate (BU }); }); +describe('read/write scope parity — one Full Access workspace contract', () => { + const projectRoot = '/tmp/mozi-proj-scope/alpha'; + const legacyWorkspaceFile = resolve(hoisted.workspaceDir, 'ashare_20260805/env_probe.json'); + + it('admits the configured workspace for both reads and writes at Full Access', () => { + hoisted.workspaceOnly = true; + const context = { + userId: 'user-uuid', + permissionLevel: 'L3_FULL_ACCESS', + workspaceRootPath: projectRoot, + } as ToolContext; + const readRoots = resolveReadRoots(context) ?? []; + const writeRoots = resolveWriteRoots(context) ?? []; + + expect(readRoots).toContain(resolve(hoisted.workspaceDir)); + expect(writeRoots).toContain(resolve(hoisted.workspaceDir)); + expect(resolveReadPath(legacyWorkspaceFile, context.userId, projectRoot, readRoots)).toBe(legacyWorkspaceFile); + expect(resolveWritePath(legacyWorkspaceFile, context.userId, writeRoots, projectRoot)).toBe(legacyWorkspaceFile); + }); + + it('does not widen a project-scoped lower-permission read to the shared workspace', () => { + hoisted.workspaceOnly = true; + const context = { + userId: 'user-uuid', + permissionLevel: 'L1_READ_WRITE', + workspaceRootPath: projectRoot, + } as ToolContext; + const roots = resolveReadRoots(context) ?? []; + + expect(roots).not.toContain(resolve(hoisted.workspaceDir)); + expect(() => resolveReadPath(legacyWorkspaceFile, context.userId, projectRoot, roots)).toThrow(PathScopeError); + }); + + it('keeps an explicit delegated read allow-list pinned even at Full Access', () => { + const runDir = resolve('/tmp/mozi-agent-run'); + const roots = resolveReadRoots({ + userId: 'user-uuid', + permissionLevel: 'L3_FULL_ACCESS', + allowedPaths: [runDir], + } as ToolContext); + + expect(roots).toEqual([runDir]); + expect(() => resolveReadPath(legacyWorkspaceFile, 'user-uuid', undefined, roots)).toThrow(PathScopeError); + }); +}); + describe('write scope — approved out-of-scope grants are honored (approval is not a dead end)', () => { const grantDir = '/tmp/mozi-approved-out-of-scope'; diff --git a/src/tools/tool-utils.ts b/src/tools/tool-utils.ts index 926f9cc..9807ac3 100644 --- a/src/tools/tool-utils.ts +++ b/src/tools/tool-utils.ts @@ -167,12 +167,30 @@ export function assertNotSensitiveWrite(resolvedPath: string): void { } } -export function resolveWriteRoots(context?: ToolContext): string[] | undefined { +export type FsAccessKind = 'read' | 'write'; + +/** + * Resolve the filesystem allow-list from the live ToolContext. This is the + * single owner for project scoping, session grants, and full-access workspace + * compatibility across read and write tools. + */ +export function resolveFsAccessRoots( + context: ToolContext | undefined, + access: FsAccessKind, +): string[] | undefined { + const enforcedRead = access === 'read' + ? (context?.allowedPaths ?? []).filter(isAbsolute).map(p => resolve(p)) + : []; + if (access === 'read' && enforcedRead.length > 0) { + return Array.from(new Set(enforcedRead)); + } // Sandboxed runs (delegated agents) carry a hard allow-list that applies // regardless of fs policy or full-access level — the run-dir pin must not // dissolve into output-dir-wide or unrestricted writes. - const enforced = (context?.enforcedWriteRoots ?? []).filter(isAbsolute).map(p => resolve(p)); - if (enforced.length > 0) { + const enforced = access === 'write' + ? (context?.enforcedWriteRoots ?? []).filter(isAbsolute).map(p => resolve(p)) + : []; + if (access === 'write' && enforced.length > 0) { const grants = (context?.scopeGrants ?? []).filter(isAbsolute).map(p => resolve(p)); return Array.from(new Set([...enforced, ...grants])); } @@ -183,20 +201,37 @@ export function resolveWriteRoots(context?: ToolContext): string[] | undefined { const grants = (context?.scopeGrants ?? []).filter(isAbsolute).map(p => resolve(p)); const scoped = context?.workspaceRootPath?.trim(); if (scoped && !isFullAccessContext(context)) { - return Array.from(new Set([resolve(scoped), getOutputDir(), ...grants])); + const roots = access === 'write' + ? [resolve(scoped), getOutputDir(), ...grants] + : [resolve(scoped), ...getWorkspaceAllowedRoots(context?.userId), ...grants]; + return Array.from(new Set(roots)); } // Default (no project selected, or full access): the workspace roots PLUS any // approved out-of-scope dirs. Without merging grants here, an approved write // still failed the retry — the approval prompt was a dead end whenever no // project was scoped (the exact bug: user clicks Allow, write is blocked anyway). const roots = [...getWorkspaceAllowedRoots(context?.userId), ...grants]; + // The configured workspace is MOZI's legacy/shared workspace. New work is + // rooted in the canonical per-user workspace, but persisted plans and files + // may still carry this absolute path. Full access admits that explicit + // compatibility root for every filesystem operation without opening the + // rest of MOZI home to reads. + if (isFullAccessContext(context)) roots.push(resolve(getWorkspaceDir())); // At full access, MOZI's entire home (~/.mozi) is writable without a per-write // approval prompt — it is MOZI's own sandbox, not the operator's files. The // assertNotSensitiveWrite denylist still hard-protects secrets/keys/DB inside it. - if (isFullAccessContext(context)) roots.push(resolve(getMoziHome())); + if (access === 'write' && isFullAccessContext(context)) roots.push(resolve(getMoziHome())); return Array.from(new Set(roots)); } +export function resolveWriteRoots(context?: ToolContext): string[] | undefined { + return resolveFsAccessRoots(context, 'write'); +} + +export function resolveReadRoots(context?: ToolContext): string[] | undefined { + return resolveFsAccessRoots(context, 'read'); +} + export function dedupWorkspaceDir(resolved: string, wsDir: string): string { const doubled = wsDir + '/' + wsDir.split('/').slice(-2).join('/'); if (resolved.includes(doubled)) { @@ -270,7 +305,12 @@ function stripRepoNamePrefix(relPath: string, projectRoot: string): string | nul return null; } -export function resolveReadPath(userPath: string, userId?: string, selectedProjectRoot?: string): string { +export function resolveReadPath( + userPath: string, + userId?: string, + selectedProjectRoot?: string, + allowedRootsOverride?: string[], +): string { const globalProjectRoot = getProjectRoot(); const workspaceDir = getWorkspaceDir(userId); const { workspaceOnly, allowProjectRootRead } = getFsPolicy(); @@ -279,7 +319,10 @@ export function resolveReadPath(userPath: string, userId?: string, selectedProje // Allowed READ roots include the SELECTED project, so reading the project the // user pointed MOZI at is not blocked by the workspace-only gate. This mirrors // resolveWriteRoots — reads must be as project-aware as writes already are. - const allowed = getReadAllowedPaths(userId, selectedProjectRoot); + const allowed = allowedRootsOverride ?? resolveReadRoots({ + userId, + workspaceRootPath: selectedProjectRoot, + }); if (isAbsolute(expanded)) { const absoluteResolved = resolve(expanded); @@ -343,17 +386,6 @@ export function resolveReadPath(userPath: string, userId?: string, selectedProje return workspaceResolved; } -export function getReadAllowedPaths(userId?: string, selectedProjectRoot?: string): string[] | undefined { - if (!getFsPolicy().workspaceOnly) return undefined; - const roots = getWorkspaceAllowedRoots(userId); - const scoped = selectedProjectRoot?.trim(); - return scoped ? Array.from(new Set([resolve(scoped), ...roots])) : roots; -} - -export function getWriteAllowedPaths(userId?: string): string[] | undefined { - return getFsPolicy().workspaceOnly ? getWorkspaceAllowedRoots(userId) : undefined; -} - // ── TEL wrapper ── let telInitialized = false; @@ -391,16 +423,16 @@ export async function runTel( const config = getConfig(); const rawLevel = context?.permissionLevel ?? config.security?.default_permission ?? 'L3_FULL_ACCESS'; const permissionLevel = isValidLevel(rawLevel) ? rawLevel : 'L0_READ_ONLY'; - // Prefer an explicit allow-list the caller put in the intent params (fs tools - // pass the project-scoped write roots there). Fall back to the context list, - // then the global workspace roots. This keeps the router's validatePath in - // sync with the tool-level resolveWritePath gate under project scoping. + // Prefer an explicit allow-list the caller put in the intent params. Fall + // back to the context list, then the same context-derived read roots used by + // filesystem tools. This prevents TEL from making a second, contradictory + // workspace decision. const paramAllowedPaths = Array.isArray((params as { allowed_paths?: unknown }).allowed_paths) ? ((params as { allowed_paths?: string[] }).allowed_paths as string[]) : undefined; const allowedPaths = paramAllowedPaths ?? context?.allowedPaths - ?? (getFsPolicy().workspaceOnly ? getWorkspaceAllowedRoots(context?.userId) : undefined); + ?? resolveReadRoots(context); const execContext: ExecutionContext = { agent_id: context?.agentId ?? `gateway:${context?.chatId ?? 'system'}`, permission_level: permissionLevel, diff --git a/src/tools/web-tools.ts b/src/tools/web-tools.ts index 574c400..04630c4 100644 --- a/src/tools/web-tools.ts +++ b/src/tools/web-tools.ts @@ -4,7 +4,7 @@ import { analyzeImage } from '../capabilities/vision.js'; import type { ToolDefinition } from '../core/llm.js'; import { validatePath } from '../tel/router.js'; import type { ToolResult, ToolContext, ToolSourceRef } from './types.js'; -import { resolveReadPath } from './tool-utils.js'; +import { resolveReadPath, resolveReadRoots } from './tool-utils.js'; /** Caps keep the source list a lean UI payload, not a transcript. */ const MAX_SOURCES_PER_CALL = 10; @@ -196,8 +196,9 @@ export async function executeWebTool( } let resolved: string; try { - resolved = resolveReadPath(path, context?.userId, context?.workspaceRootPath); - validatePath(resolved, context?.allowedPaths, context?.agentId, context?.tenantId ?? 'default'); + const allowedPaths = resolveReadRoots(context); + resolved = resolveReadPath(path, context?.userId, context?.workspaceRootPath, allowedPaths); + validatePath(resolved, allowedPaths, context?.agentId, context?.tenantId ?? 'default'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { tool_call_id: id, content: `Error: ${msg}`, is_error: true };