feat(policies): backend-driven policy enforcement (frontend) (#6598)

## Summary
Adds the **Policies** feature (proprietary, behind the
`POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed
tool pipeline on documents, docked in the right tool sidebar alongside
Tools.

## Highlights
- **Policy catalog** — 5 categories; **Security** is wired (redact PII +
sanitize), the others are marked "Coming soon".
- **Backend as source of truth** — policies persist via the Policies
engine (`/api/v1/policies`), one policy per category, with a local cache
+ offline fallback.
- **Auto-run** — enabled policies run on every uploaded file: dispatch →
poll → import outputs into the workspace.
- **Security redact config** — PII preset dropdown + custom word/regex
entry + advanced options; tool params map to the backend endpoint
fields.
- **Activity feed** with retry on failures; **file badges** showing
which policies ran on a file (sidebar + files page), tinted to the
policy colour.
- Reuses the **Watched Folders** engine for each policy's backing
folder; policy-owned folders are filtered out of the Watched Folders UI.

## Notes
- Gated by `POLICIES_ENABLED` (true in proprietary, false in core) —
unreachable in the open-source build.
- Frontend-only diff; depends on the backend Policies engine and the
merged Watched Folders feature.
This commit is contained in:
Reece Browne
2026-06-10 15:57:08 +01:00
committed by GitHub
parent ebc28b0a14
commit 8dde4262ec
91 changed files with 7250 additions and 99 deletions
@@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
@@ -17,6 +18,7 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import { FileId } from "@app/types/file";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
import { useFolders } from "@app/contexts/FolderContext";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import { StirlingFileStub } from "@app/types/fileContext";
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
import {
@@ -572,6 +574,31 @@ function FolderCard({
);
}
/** Shield badges for the policies that have run on a file. */
function PolicyBadges({ fileId }: { fileId: string }) {
const badges = usePolicyFileBadges().get(fileId) ?? [];
if (badges.length === 0) return null;
return (
<span className="files-page-policy-badges" data-no-select>
{badges.slice(0, 3).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="files-page-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
);
}
interface FileCardProps {
file: StirlingFileStub;
isSelected: boolean;
@@ -731,6 +758,7 @@ function FileCard({
<span>{fileSize}</span>
<span>·</span>
<span>{fileDate}</span>
<PolicyBadges fileId={file.id as string} />
</div>
</div>
<div className="files-page-card-actions">
@@ -1315,6 +1343,7 @@ function FileRow({
)}
</span>
<FileOriginBadge origin={getFileOrigin(file)} compact />
<PolicyBadges fileId={file.id as string} />
{isInWorkspace && (
<span className="files-page-row-open-pill">
<span className="files-page-card-open-dot" />
@@ -484,6 +484,24 @@
gap: 0.4rem;
}
/* Policy activity badges (a shield per policy that has run on the file). */
.files-page-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.files-page-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` set inline to the policy accent; tint follows it. */
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
the user can tell which folder each hit lives in without navigating. */
.files-page-card-path {
@@ -0,0 +1,38 @@
/**
* Core stubs for the right-rail Policies UI.
*
* The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx}
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
* build is active. Core builds render nothing, so the right rail shows only the
* tool list unchanged.
*/
import type { ReactNode } from "react";
/** Whether the right rail should host the Policies section. False in core. */
export function usePoliciesEnabled(): boolean {
return false;
}
/**
* Whether a policy is open (its detail should take over the rail). Always false
* in core; proprietary bridges to the policy-selection store.
*/
export function usePolicyDetailActive(): boolean {
return false;
}
/** Collapsible policy list rendered above the Tools section. Null in core. */
export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) {
return null;
}
/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */
export function PolicyDetailTakeover() {
return null;
}
/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */
export function PoliciesCollapsedButton(_props: { onExpand: () => void }) {
return null;
}
@@ -0,0 +1,11 @@
/**
* Core stub for the policy auto-run controller.
*
* The real implementation lives in
* {@code proprietary/components/policies/PolicyAutoRunController.tsx} and shadows
* this stub via the {@code @app/*} alias cascade in the proprietary build. Core
* builds have no Policies feature, so this renders nothing and runs no policies.
*/
export function PolicyAutoRunController() {
return null;
}
@@ -42,6 +42,7 @@ import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import { useFolderMembership } from "@app/hooks/useFolderMembership";
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import {
setWatchedFolderDraggedFileIds,
clearWatchedFolderDraggedFileIds,
@@ -131,6 +132,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
// the workbench). The same map drives the per-file membership dots.
const folderMembership = useFolderMembership();
const allFolders = useAllWatchedFolders();
const policyFileBadges = usePolicyFileBadges();
const folderById = useMemo(
() => new Map(allFolders.map((f) => [f.id, f])),
[allFolders],
@@ -892,6 +894,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
onDragStart={handleWatchedFolderDragStart}
folders={memberFolders}
onFolderClick={openWatchedFolder}
policies={
policyFileBadges.get(stub.id as string) ?? []
}
/>
);
})}
@@ -134,6 +134,25 @@
text-overflow: ellipsis;
}
/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */
.file-sidebar-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.file-sidebar-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` is set inline to the policy's accent; the tint follows it. */
color: var(--text-secondary);
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* ---- Folder membership tags ---- */
.file-sidebar-folder-tags {
display: flex;
@@ -4,6 +4,7 @@ import { Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
@@ -125,6 +126,14 @@ export interface FileItemFolderRef {
accentColor: string;
}
/** A policy that has run on this file, used for the activity badges. */
export interface FileItemPolicyRef {
id: string;
name: string;
/** CSS colour for the badge (matches the policy's accent). */
accentColor: string;
}
export interface FileItemProps {
fileId: FileId;
name: string;
@@ -143,9 +152,12 @@ export interface FileItemProps {
folders?: FileItemFolderRef[];
/** Clicking a membership dot opens that folder. */
onFolderClick?: (folderId: string) => void;
/** Policies that have run on this file — rendered as small shield badges. */
policies?: FileItemPolicyRef[];
}
const MAX_VISIBLE_FOLDER_TAGS = 2;
const MAX_VISIBLE_POLICY_BADGES = 3;
export function FileItem({
fileId,
@@ -162,6 +174,7 @@ export function FileItem({
onDragStart,
folders = [],
onFolderClick,
policies = [],
}: FileItemProps) {
const { t } = useTranslation();
const ext = getFileExtension(name);
@@ -237,6 +250,25 @@ export function FileItem({
{dateLabel && typeLabel ? " · " : ""}
{typeLabel}
</span>
{policies.length > 0 && (
<span className="file-sidebar-policy-badges" data-no-select>
{policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="file-sidebar-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
)}
</span>
{folders.length > 0 && (
<span className="file-sidebar-folder-tags" data-no-select>
@@ -1,4 +1,4 @@
import { createContext, useContext, ReactNode } from "react";
import { createContext, useContext, useEffect, ReactNode } from "react";
import { MantineProvider } from "@mantine/core";
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
import { mantineTheme } from "@app/theme/mantineTheme";
@@ -7,6 +7,10 @@ import { ToastProvider } from "@app/components/toast";
import ToastRenderer from "@app/components/toast/ToastRenderer";
import { ToastPortalBinder } from "@app/components/toast";
import type { ThemeMode } from "@app/constants/theme";
// SUI shared design-system tokens (used by @shared/components). Additive — its
// var names don't clash with the editor's own theme.css. The effect below
// bridges Mantine's color scheme to the `data-theme` attribute SUI keys on.
import "@shared/tokens/tokens.css";
interface RainbowThemeContextType {
themeMode: ThemeMode;
@@ -40,6 +44,16 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
const mantineColorScheme =
rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode;
// Bridge the resolved scheme to the `data-theme` attribute SUI's tokens.css
// keys its dark palette on (the editor otherwise only sets Mantine's
// data-mantine-color-scheme), so @shared/components theme correctly.
useEffect(() => {
document.documentElement.setAttribute(
"data-theme",
mantineColorScheme === "dark" ? "dark" : "light",
);
}, [mantineColorScheme]);
return (
<RainbowThemeContext.Provider value={rainbowTheme}>
<MantineProvider
@@ -13,6 +13,14 @@ import {
AgentsSection,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import {
PoliciesCollapsedButton,
PoliciesSection,
PolicyDetailTakeover,
usePoliciesEnabled,
usePolicyDetailActive,
} from "@app/components/policies/PoliciesSidebar";
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
import { useChat } from "@app/components/chat/ChatContext";
import { ChatPanel } from "@app/components/chat/ChatPanel";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
@@ -70,6 +78,8 @@ export default function RightSidebar() {
} = useToolWorkflow();
const agentsEnabled = useAgentsEnabled();
const policiesEnabled = usePoliciesEnabled();
const rawPolicyDetailActive = usePolicyDetailActive();
const fullscreenExpanded = useIsFullscreenExpanded();
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
@@ -153,15 +163,35 @@ export default function RightSidebar() {
});
};
// Opening a policy (e.g. from the collapsed rail) lands the rail in the clean
// default tool-picker view — the only view the policy takeover renders in — so
// it never collides with an open tool or the all-tools/search view.
const handleOpenPolicy = () => {
withViewTransition(() => {
if (readerMode) setReaderMode(false);
setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
setAllToolsView(false);
setSearchQuery("");
});
};
// The header shows [back] [search] when we have somewhere to go back to —
// i.e. the user is in a specific tool, or already in the all-tools/search view.
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
// Show search input whenever there's a close button, or when agents are off and
// we're in the default tool-picker view (search filters the full list inline).
// Policies sit above the tool list in the default tool-picker view.
const showPolicies =
policiesEnabled && !allToolsView && leftPanelView === "toolPicker";
// When Policies are shown, the search moves OUT of the header to sit between
// the Policies and Tools sections (separating them); otherwise it stays in the
// header. Show the header search when there's a close button, or when agents
// are off in the default tool-picker view (inline filtering).
const showInlineSearch = showPolicies && !showCloseButton;
const showHeaderSearch =
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
!showInlineSearch &&
(showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker"));
const handleHeaderBack = () => {
if (inToolView) {
@@ -201,11 +231,21 @@ export default function RightSidebar() {
const showAgents =
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
// The detail takeover replaces the tool list ONLY in the same default view —
// never over an open tool or the all-tools view (which must keep priority).
// A lingering selection is harmless: it stays hidden behind a tool and the
// list/takeover reappears on return to the picker (as in the prototype).
const policyDetailActive = rawPolicyDetailActive && showPolicies;
// The rail widens when a policy detail takes it over — the tool list is fine
// at 18.5rem, but the policy detail/wizard/settings need more breathing room.
const expandedWidth = policyDetailActive ? "25rem" : "18.5rem";
const computedWidth = () => {
if (isMobile) return "100%";
if (isChatOpen) return `${chatWidthPx}px`;
if (!isPanelVisible) return "3.5rem";
return "18.5rem";
return expandedWidth;
};
// Collapsed rail: show favourites + recommended tools as icons.
@@ -248,6 +288,8 @@ export default function RightSidebar() {
...(isChatDragging ? { transition: "none" } : {}),
}}
>
{/* Headless: enforces enabled policies on every uploaded file. */}
{policiesEnabled && <PolicyAutoRunController />}
{!fullscreenExpanded && isChatOpen && (
<div
style={{
@@ -279,7 +321,7 @@ export default function RightSidebar() {
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
className="tool-panel__expand-btn tool-panel__toggle-vt"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
@@ -288,6 +330,7 @@ export default function RightSidebar() {
<AgentsCollapsedButton onExpand={handleExpand} />
</div>
<div className="tool-panel__collapsed-divider" />
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
<div className="tool-panel__collapsed-tools">
{collapsedRailItems.map(({ id, tool }) => (
<AppTooltip
@@ -326,84 +369,122 @@ export default function RightSidebar() {
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
width: isMobile ? "100%" : "18.5rem",
width: isMobile ? "100%" : expandedWidth,
flexShrink: 0,
display: "flex",
flexDirection: "column",
}}
>
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
{policyDetailActive ? (
<div className="pol-takeover">
<PolicyDetailTakeover />
</div>
) : (
<>
{!showPolicies && (
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
/>
</div>
) : (
showAgents && (
<span className="tool-panel__section-label">
{t("agents.section_title", "Agents")}
</span>
)
)}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
)}
{showAgents && <AgentsSection />}
{showPolicies && (
<PoliciesSection
leadingControl={
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
}
/>
</div>
) : (
showAgents && (
<span className="tool-panel__section-label">
{t("agents.section_title", "Agents")}
</span>
)
)}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
)}
{showAgents && <AgentsSection />}
{showInlineSearch && (
<div className="tool-panel__between-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
)}
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={agentsEnabled && !allToolsView}
/>
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={agentsEnabled && !allToolsView}
/>
</>
)}
</div>
)}
@@ -201,6 +201,13 @@
border-color: var(--border-subtle) !important;
}
/* The collapse/expand toggle keeps a stable identity across the collapsed strip
and the expanded header, so a view transition translates it between the two
(same size) instead of cross-fading/scaling it ("big then small"). */
.tool-panel__toggle-vt {
view-transition-name: sidebar-toggle;
}
.tool-panel__expand-btn svg {
color: var(--text-secondary) !important;
}
@@ -271,6 +278,22 @@
width: 100%;
}
/* Search that separates the Policies section from the Tools list below it. */
.tool-panel__between-search {
padding: 0.5rem 0.75rem 0.25rem;
border-top: 1px solid var(--border-subtle, var(--color-border));
}
.tool-panel__between-search .search-input-container {
width: 100%;
margin-bottom: 0;
}
/* Slightly recessed search field so it reads as distinct from the panel. */
.tool-panel__between-search input {
background-color: var(--bg-muted);
}
.tool-panel__active-tool-pill {
display: inline-flex;
align-items: center;
@@ -4,11 +4,18 @@
*/
/**
* Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the
* custom workbench registration (seeding, URL sync, view) and the sidebar
* entry point, so the feature is unreachable from the UI. Flip to `true` to
* restore access.
* Watched Folders. Disabled for now — gates both the custom workbench
* registration (seeding, URL sync, view) and the sidebar entry point, so the
* feature is unreachable from the UI. Flip to `true` to restore access.
*/
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
// as constant/unreachable conditions by the type checker and linter.
export const WATCHED_FOLDERS_ENABLED: boolean = false;
/**
* Policies — a proprietary, automation-backed feature (like Watched Folders but
* backend-driven, with non-folder triggers). The implementation lives under
* `proprietary/`; this core value stays `false` so the shared sidebar entry
* never appears in the open-source build.
*/
export const POLICIES_ENABLED: boolean = false;
@@ -17,7 +17,9 @@ export function useAllWatchedFolders(): WatchedFolder[] {
useEffect(() => {
const load = async () => {
try {
setFolders(await watchedFolderStorage.getAllFolders());
// Policy-owned folders are managed by Policies, not shown here.
const all = await watchedFolderStorage.getAllFolders();
setFolders(all.filter((f) => !f.policyCategoryId));
} catch (err) {
console.error("Failed to load smart folders:", err);
}
@@ -0,0 +1,10 @@
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
/**
* Policies that have run on each file, keyed by fileId — drives the shield
* badges in the file sidebar. Empty in core; the proprietary build shadows this
* with an implementation backed by the policy run store.
*/
export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
return new Map();
}
@@ -248,6 +248,14 @@ export async function mockAppApis(
await page.route("**/api/v1/info/wau", (route: Route) =>
route.fulfill({ json: { count: 0 } }),
);
// Policies (proprietary): the reconcile fires GET /api/v1/policies on app
// load. Return an empty list so the stubbed (backend-free) env stays clean —
// no failed request polluting the console, and the auto-run controller has
// nothing to dispatch.
await page.route("**/api/v1/policies", (route: Route) =>
route.fulfill({ json: [] }),
);
}
/**
@@ -25,6 +25,9 @@ export interface WatchedFolder {
hasOutputDirectory?: boolean; // true when a local FS output folder is configured
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
inputSource?: "idb" | "local-folder";
/** Set when this folder is the backing store for a Policy (its category id).
* Policy-owned folders are filtered out of the Watched Folders UI. */
policyCategoryId?: string;
}
export interface FolderFileMetadata {