From 66e24b1060739a87ed3530ed1857a5292afc01e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 15 May 2026 00:26:20 -0400 Subject: [PATCH] fix(security): scope managed path opening --- src/main/database/settings.ts | 24 ++++++++---- src/main/ipc-core-read-handlers.ts | 39 ++++++++++++++----- src/renderer/hooks/useCompanyController.ts | 7 +++- .../views/sections/OperationsSections.tsx | 7 +--- src/shared/contracts/desktop-api/companies.ts | 4 +- src/shared/contracts/preload-forwarders.ts | 5 +-- src/shared/contracts/schemas-company.ts | 5 +++ .../ipc-open-work-product-url-policy.test.ts | 1 + tests/unit/preload-forwarders.test.ts | 4 +- 9 files changed, 66 insertions(+), 30 deletions(-) diff --git a/src/main/database/settings.ts b/src/main/database/settings.ts index 7a8d2282..fa9278d5 100644 --- a/src/main/database/settings.ts +++ b/src/main/database/settings.ts @@ -165,7 +165,7 @@ export function buildSettingsMethods( return purged; }, - canOpenPath(targetPath: string) { + canOpenPath(targetPath: string, companyId: string) { if (!targetPath || !existsSync(targetPath)) { return false; } @@ -184,15 +184,25 @@ export function buildSettingsMethods( if (!targetStats.isDirectory() && forbiddenExecutableExtensions.has(targetExtension)) { return false; } - if (managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, safeRealpath(rootPath)))) { - return true; - } - const workspaces = readAll<{ localPath: string }>("select local_path as localPath from workspaces"); + const workspaces = readAll<{ localPath: string }>( + "select local_path as localPath from workspaces where company_id = ?", + companyId, + ); if (workspaces.some((workspace) => isWithinOrEqual(resolvedTarget, safeRealpath(workspace.localPath)))) { return true; } - const runLogs = readAll<{ logPath: string | null }>("select log_path as logPath from runs where log_path is not null"); - return runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false); + const runLogs = readAll<{ logPath: string | null }>( + "select log_path as logPath from runs where company_id = ? and log_path is not null", + companyId, + ); + if (runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false)) { + return true; + } + const browserScreenshots = readAll<{ screenshotPath: string | null }>( + "select screenshot_path as screenshotPath from browser_actions where company_id = ? and screenshot_path is not null", + companyId, + ); + return browserScreenshots.some((action) => action.screenshotPath ? safeRealpath(action.screenshotPath) === resolvedTarget : false); }, hasCompany(companyId: string) { diff --git a/src/main/ipc-core-read-handlers.ts b/src/main/ipc-core-read-handlers.ts index adbfdd81..a7d07090 100644 --- a/src/main/ipc-core-read-handlers.ts +++ b/src/main/ipc-core-read-handlers.ts @@ -6,6 +6,7 @@ import { connectorInputSchema, connectorModelPoolQuerySchema, entityGetterSchema, + managedPathInputSchema, metricsQuerySchema, settingsSchema, } from "@shared/contracts"; @@ -57,6 +58,21 @@ export function registerCoreReadHandlers({ | "listConnectorModels" | "logger" >) { + function requireCurrentCompanyScope(requestedCompanyId: string) { + const currentCompanyId = db.core.settings.getCurrentCompanyId(); + if (!currentCompanyId) { + return { ok: false as const, error: fail("COMPANY_NOT_SELECTED", "Select a company before opening a managed path.") }; + } + const companyError = ensureCompanyExists(currentCompanyId); + if (companyError) { + return { ok: false as const, error: companyError }; + } + if (requestedCompanyId !== currentCompanyId) { + return { ok: false as const, error: fail("INVALID_COMPANY_REFERENCE", "The requested company must match the current company.") }; + } + return { ok: true as const, companyId: currentCompanyId }; + } + registerHandle(IPC_CHANNELS.loadSnapshot, () => ok(db.projection.snapshot.listRendererSnapshot())); registerHandle(IPC_CHANNELS.pickDirectory, async () => { @@ -66,14 +82,17 @@ export function registerCoreReadHandlers({ return ok(result.canceled ? null : (result.filePaths[0] ?? null)); }); - registerHandle(IPC_CHANNELS.openPath, async (_event, targetPath) => { - if (typeof targetPath !== "string" || targetPath.length === 0) { - return fail("INVALID_PATH", "A valid path is required."); + registerHandle(IPC_CHANNELS.openPath, async (_event, payload) => { + const parsed = managedPathInputSchema.safeParse(payload); + if (!parsed.success) { + return fail("INVALID_PAYLOAD", "openPath requires { companyId, path }."); } - if (!db.core.settings.canOpenPath(targetPath)) { + const currentScope = requireCurrentCompanyScope(parsed.data.companyId); + if (!currentScope.ok) return currentScope.error; + if (!db.core.settings.canOpenPath(parsed.data.path, currentScope.companyId)) { return fail("OPEN_PATH_FORBIDDEN", "The requested path is outside the managed desktop workspace."); } - const error = await shell.openPath(targetPath); + const error = await shell.openPath(parsed.data.path); return error ? fail("OPEN_PATH_FAILED", error) : ok(true); }); @@ -91,9 +110,9 @@ export function registerCoreReadHandlers({ registerHandle(IPC_CHANNELS.openWorkProduct, async (_event, payload) => { const parsed = entityGetterSchema.safeParse(payload); if (!parsed.success) return fail("INVALID_PAYLOAD", "openWorkProduct requires { companyId, id }."); - const companyError = ensureCompanyExists(parsed.data.companyId); - if (companyError) return companyError; - const target = db.core.documents.resolveWorkProductTarget(parsed.data.id, parsed.data.companyId); + const currentScope = requireCurrentCompanyScope(parsed.data.companyId); + if (!currentScope.ok) return currentScope.error; + const target = db.core.documents.resolveWorkProductTarget(parsed.data.id, currentScope.companyId); if (target.kind === "error") { const codeByReason = { not_found: "WORK_PRODUCT_NOT_FOUND", @@ -104,7 +123,7 @@ export function registerCoreReadHandlers({ path_missing: "WORK_PRODUCT_PATH_MISSING", } as const; if (target.reason === "path_missing") { - db.core.documents.updateWorkProduct(parsed.data.id, parsed.data.companyId, { status: "archived" }); + db.core.documents.updateWorkProduct(parsed.data.id, currentScope.companyId, { status: "archived" }); publishDomainChanged(); } return fail(codeByReason[target.reason], target.detail); @@ -112,7 +131,7 @@ export function registerCoreReadHandlers({ if (target.kind === "url") { return openExternalUrlWithTrustPolicy(target.value); } - if (!db.core.settings.canOpenPath(target.value)) { + if (!db.core.settings.canOpenPath(target.value, currentScope.companyId)) { return fail("OPEN_PATH_FORBIDDEN", "The requested path is outside the managed desktop workspace."); } const error = await shell.openPath(target.value); diff --git a/src/renderer/hooks/useCompanyController.ts b/src/renderer/hooks/useCompanyController.ts index ace46568..7684722e 100644 --- a/src/renderer/hooks/useCompanyController.ts +++ b/src/renderer/hooks/useCompanyController.ts @@ -135,7 +135,12 @@ export function useCompanyController() { const openPathWithFeedback = useCallback(async (path: string) => { const store = useAppStore.getState(); - try { unwrap(await window.agentCompany.openPath(path)); store.setError(null); } + const companyId = store.snapshot?.currentCompanyId; + if (!companyId) { + store.setError("Select a company before opening a managed path."); + return; + } + try { unwrap(await window.agentCompany.openPath({ companyId, path })); store.setError(null); } catch (err) { store.setError(describeDesktopError(err)); } }, []); diff --git a/src/renderer/views/sections/OperationsSections.tsx b/src/renderer/views/sections/OperationsSections.tsx index a780d6ca..c92507c3 100644 --- a/src/renderer/views/sections/OperationsSections.tsx +++ b/src/renderer/views/sections/OperationsSections.tsx @@ -159,12 +159,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) { } }} onOpenPath={async (path) => { - try { - unwrap(await window.agentCompany.openPath(path)); - setError(null); - } catch (error) { - setError(describeDesktopError(error)); - } + await openPathWithFeedback(path); }} onCancelAction={async (actionId) => { if (!snapshot?.currentCompanyId) return; diff --git a/src/shared/contracts/desktop-api/companies.ts b/src/shared/contracts/desktop-api/companies.ts index d9dd65ae..e806dea0 100644 --- a/src/shared/contracts/desktop-api/companies.ts +++ b/src/shared/contracts/desktop-api/companies.ts @@ -1,13 +1,13 @@ import type { z } from "zod"; import type { CompanyMetrics, OnboardingBootstrapResult, ProfileSnapshot, StandupReport } from "../../types"; -import type { bootstrapOnboardingSchema, companyInputSchema, connectorDisabledModelsSchema, connectorInputSchema, settingsSchema } from "../schemas-company"; +import type { bootstrapOnboardingSchema, companyInputSchema, connectorDisabledModelsSchema, connectorInputSchema, managedPathInputSchema, settingsSchema } from "../schemas-company"; import type { entityGetterSchema, metricsQuerySchema } from "../schemas-badges"; import type { DesktopResult } from "./common"; export type CompaniesApi = { loadSnapshot: () => Promise>; pickDirectory: () => Promise>; - openPath: (targetPath: string) => Promise>; + openPath: (input: z.infer) => Promise>; openWorkProduct: (input: z.infer) => Promise>; saveCompany: (input: z.infer) => Promise>; setCurrentCompany: (companyId: string) => Promise>; diff --git a/src/shared/contracts/preload-forwarders.ts b/src/shared/contracts/preload-forwarders.ts index d25390be..6e9a81af 100644 --- a/src/shared/contracts/preload-forwarders.ts +++ b/src/shared/contracts/preload-forwarders.ts @@ -30,6 +30,7 @@ import { companyInputSchema, connectorDisabledModelsSchema, connectorInputSchema, + managedPathInputSchema, socialAccountInputSchema, settingsSchema, workspaceInputSchema, @@ -213,10 +214,8 @@ export const PRELOAD_FORWARDERS = [ export type PreloadForwardedMethod = (typeof PRELOAD_FORWARDERS)[number]["api"]; export type PreloadForwardedApi = Pick; -const managedPathSchema = z.string().min(1); - export const PRELOAD_INPUT_SCHEMAS: Partial>> = { - openPath: managedPathSchema, + openPath: managedPathInputSchema, openWorkProduct: entityGetterSchema, saveCompany: companyInputSchema, setCurrentCompany: companyIdSchema, diff --git a/src/shared/contracts/schemas-company.ts b/src/shared/contracts/schemas-company.ts index 75fc2aaa..ba681c7e 100644 --- a/src/shared/contracts/schemas-company.ts +++ b/src/shared/contracts/schemas-company.ts @@ -3,6 +3,11 @@ import { optionalId } from "./helpers"; export const companyIdSchema = z.string().uuid(); +export const managedPathInputSchema = z.object({ + companyId: companyIdSchema, + path: z.string().min(1), +}); + export const companyInputSchema = z.object({ id: optionalId, name: z.string().min(2).max(80), diff --git a/tests/unit/ipc-open-work-product-url-policy.test.ts b/tests/unit/ipc-open-work-product-url-policy.test.ts index 974ac112..b5c0516a 100644 --- a/tests/unit/ipc-open-work-product-url-policy.test.ts +++ b/tests/unit/ipc-open-work-product-url-policy.test.ts @@ -33,6 +33,7 @@ function registerOpenWorkProductHandler(url: string) { }, settings: { canOpenPath: vi.fn(() => true), + getCurrentCompanyId: vi.fn(() => companyId), }, }, projection: { diff --git a/tests/unit/preload-forwarders.test.ts b/tests/unit/preload-forwarders.test.ts index 45540e82..bc72ddc2 100644 --- a/tests/unit/preload-forwarders.test.ts +++ b/tests/unit/preload-forwarders.test.ts @@ -105,7 +105,9 @@ describe("preload forwarders", () => { expect(objectArg).toBeDefined(); expect(mapPreloadInvokeArgs(noArgs!.payload, [])).toEqual([]); - expect(mapPreloadInvokeArgs(singleArg!.payload, ["/tmp/output"])).toEqual(["/tmp/output"]); + expect(mapPreloadInvokeArgs(singleArg!.payload, [{ companyId: "2f88b7d1-dafb-42f1-ac75-34fc2de3b0f4", path: "/tmp/output" }])).toEqual([ + { companyId: "2f88b7d1-dafb-42f1-ac75-34fc2de3b0f4", path: "/tmp/output" }, + ]); expect(mapPreloadInvokeArgs(objectArg!.payload, ["company-1", "/skills/local"])).toEqual([ { companyId: "company-1", source: "/skills/local" }, ]);