Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .claude/workspace-trust-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# 工作区信任(受限模式)功能设计文档

## 概述

为 IDE 添加「工作区信任」机制,在首次打开项目时提示用户是否信任该项目目录。若用户选择「受限模式」,则 IDE 仅加载特定的安全插件,防止恶意脚本攻击。

## 背景

当前 IDE 打开项目时会自动加载并激活所有插件(extensions),包括第三方插件。如果项目目录来源不可信,其中的恶意插件脚本可能对用户的系统和数据造成威胁。VS Code 的「Workspace Trust」功能提供了类似的防护机制。

## 核心流程

```
用户打开项目目录
├── 查询 globalStorage 中是否已记录该目录的信任状态
│ │
│ ├── 已记录 → 直接使用已有状态
│ └── 未记录 → 弹出信任对话框
│ │
│ ├── 选择「信任」 → 记录为信任状态
│ └── 选择「受限模式」 → 记录为受限状态
├── 根据信任状态决定是否加载插件
│ │
│ ├── 信任 → 加载所有插件
│ └── 受限 → 仅加载白名单插件
└── 根据信任状态更新 UI
├── 信任 → 正常 UI
└── 受限 → 状态栏显示「受限模式」标识
```

## 数据设计

### 存储方案

使用 `globalStorage`(全局持久化存储)记录每个目录的信任状态。

存储 key 格式:`workspace_trust_state:<workspace_path>` 存储 value:`"trusted"` | `"restricted"`

例如:

```
workspace_trust_state:/Users/mahang/Workspace/ide/core = "trusted"
workspace_trust_state:/tmp/suspicious-project = "restricted"
```

### 存储模块

使用 `STORAGE_NAMESPACE.GLOBAL_EXTENSIONS` 全局存储空间,以 key-value 形式持久化。

### 信任白名单插件

受限模式下仅允许加载以下插件(可通过配置扩展):

- `vscode.theme-defaults` - 默认主题
- `vscode.typescript-language-features` - TypeScript 语言服务
- `common-extension` - 通用扩展

## 模块设计

### 新增模块:`workspace-trust`

在 `packages/workspace-trust/` 下创建新模块:

#### 文件结构

```
packages/workspace-trust/
├── package.json
├── src/
│ ├── common/
│ │ └── index.ts # 公共常量和类型定义
│ └── browser/
│ ├── index.ts # 模块入口,BrowserModule 定义
│ ├── workspace-trust.service.ts # WorkspaceTrustService
│ ├── workspace-trust.contribution.ts # ClientAppContribution, 信任对话框
│ └── workspace-trust-statusbar.contribution.ts # 状态栏「受限模式」标识
```

#### `WorkspaceTrustService` 核心方法

| 方法 | 说明 |
| --- | --- |
| `getTrustState(workspacePath: string): WorkspaceTrustState \| undefined` | 获取指定工作区路径的信任状态 |
| `setTrustState(workspacePath: string, state: WorkspaceTrustState): Promise<void>` | 设置并持久化信任状态 |
| `isRestricted(): boolean` | 当前工作区是否处于受限模式 |
| `showTrustDialog(): Promise<WorkspaceTrustState>` | 弹出信任选择对话框 |
| `ensureTrustDecided(): Promise<void>` | 确保当前工作区已有信任决定(如没有则弹窗) |
| `getAllowedExtensionIds(): string[]` | 受限模式下允许加载的插件 ID 白名单 |

#### `WorkspaceTrustContribution`

实现 `ClientAppContribution` 接口:

- `initialize()`: 检查当前工作区的信任状态,如果未决定则弹出信任对话框
- 此方法在 `ExtensionClientAppContribution.initialize()` 之前执行(通过模块依赖保证)

#### `WorkspaceTrustStatusbarContribution`

- 在受限模式下,于状态栏右侧显示「受限模式」标识
- 点击可跳转到设置页面解除受限模式

## 集成点

### 1. 信任检查时机

`WorkspaceTrustContribution.initialize()` 在 app 启动的 `initialize` 阶段执行。此时:

1. 获取当前工作区路径(从 `AppConfig.workspaceDir`)
2. 查询 globalStorage 中是否已有信任状态
3. 如果没有,弹出信任对话框,等待用户选择
4. 将用户的决定写入 globalStorage

### 2. 插件过滤时机

修改 `ExtensionServiceImpl.initExtensionMetaData()`,在获取到所有插件元数据后:

1. 检查 `WorkspaceTrustService.isRestricted()`
2. 如果是受限模式,过滤 `extensionMetaDataArr`,仅保留白名单中的插件

### 3. 状态栏显示

`WorkspaceTrustStatusbarContribution` 在 `onStart()` 阶段:

1. 检查 `WorkspaceTrustService.isRestricted()`
2. 如果是受限模式,在状态栏右侧添加「受限模式」元素

## 模块依赖

```
workspace-trust → extension (依赖关系:workspace-trust 模块需要在 extension 之前初始化)
workspace-trust → workspace (获取工作区路径)
```

## 后续扩展

- 在设置页面添加「管理工作区信任」入口,允许用户修改已有工作区的信任状态
- 支持更多类型的白名单插件配置
- 受限模式下禁用终端、调试等可能存在安全风险的功能
9 changes: 9 additions & 0 deletions configs/ts/references/tsconfig.workspace-trust.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../../../packages/workspace-trust/src",
"outDir": "../../../packages/workspace-trust/lib"
},
"include": ["../../../packages/workspace-trust/src"],
"exclude": ["../../../packages/workspace-trust/__mocks__"]
}
3 changes: 3 additions & 0 deletions configs/ts/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
{
"path": "./references/tsconfig.workspace.json"
},
{
"path": "./references/tsconfig.workspace-trust.json"
},
{
"path": "./references/tsconfig.toolbar.json"
},
Expand Down
2 changes: 2 additions & 0 deletions configs/ts/tsconfig.resolve.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@
"@opensumi/ide-webview/lib/*": ["../packages/webview/src/*"],
"@opensumi/ide-workspace": ["../packages/workspace/src/index.ts"],
"@opensumi/ide-workspace/lib/*": ["../packages/workspace/src/*"],
"@opensumi/ide-workspace-trust": ["../packages/workspace-trust/src/index.ts"],
"@opensumi/ide-workspace-trust/lib/*": ["../packages/workspace-trust/src/*"],
"@opensumi/ide-workspace-edit": ["../packages/workspace-edit/src/index.ts"],
"@opensumi/ide-workspace-edit/lib/*": ["../packages/workspace-edit/src/*"],
"@opensumi/ide-playwright": ["../../../tools/playwright/src/index.ts"],
Expand Down
9 changes: 8 additions & 1 deletion packages/components/src/select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export interface ISelectProps<T = string> {
disabled?: boolean;
onChange?: (value: T) => void;
onSearchChange?: (value: string) => void;
/**
* 搜索行为不采用默认的 filterOptions 进行筛选,由外部托管
*/
externalSearchBehavior?: boolean;
/**
* 当鼠标划过时触发回调
* @param value 鼠标划过的是第几个 option
Expand Down Expand Up @@ -290,13 +294,16 @@ export function Select<T = string>({
description,
notMatchWarning,
onSearchChange,
externalSearchBehavior
}: ISelectProps<T>) {
const [open, setOpen] = useState<boolean>(false);
const [searchInput, setSearchInput] = useState('');

const selectRef = React.useRef<HTMLDivElement | null>(null);
const overlayRef = React.useRef<HTMLDivElement | null>(null);

externalSearchBehavior = externalSearchBehavior ?? !!onSearchChange

const handleToggleOpen = useCallback(
Comment on lines +305 to 307
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

externalSearchBehavior 模式下仍执行内部过滤,和新语义不一致

Line 305 已启用“外部托管”语义,但 Line 484-507 仍会在输入时调用 filterOption。建议在 external 模式直接跳过内部过滤,避免契约偏差和额外开销。

建议修复
-  const filteredOptions = useMemo(() => {
+  const filteredOptions = useMemo(() => {
+    if (externalSearchBehavior) {
+      return options;
+    }
     if (!searchInput) {
       return options;
     }

     if (isDataOptions(options)) {
       return options.filter((o) => filterOption(searchInput, o));
     }
@@
-  }, [options, searchInput, filterOption]);
+  }, [options, searchInput, filterOption, externalSearchBehavior]);

Also applies to: 484-507

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/components/src/select/index.tsx` around lines 305 - 307, The
component currently still runs internal filtering when externalSearchBehavior is
true; update the filtering logic (the function that calls filterOption around
lines 484-507) to short-circuit when externalSearchBehavior is truthy: at the
top of that function (referencing externalSearchBehavior and filterOption), if
externalSearchBehavior is true simply return the unfiltered options (or the
original options list) without invoking filterOption or performing any test, and
ensure any downstream code expects that no filtered subset was produced; this
keeps externalSearchBehavior semantics consistent and avoids extra work.

(e: React.MouseEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -526,7 +533,7 @@ export function Select<T = string>({
{showWarning && <div className='kt-select-warning-text'>{notMatchWarning}</div>}

{open &&
!searchInput &&
!(externalSearchBehavior && searchInput) &&
(isDataOptions(filteredOptions) || isDataOptionGroups(filteredOptions) ? (
<SelectOptionsList
optionRenderer={optionRenderer}
Expand Down
1 change: 1 addition & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@opensumi/ide-terminal-next": "workspace:*",
"@opensumi/ide-utils": "workspace:*",
"@opensumi/ide-webview": "workspace:*",
"@opensumi/ide-workspace-trust": "workspace:*",
"address": "^1.1.2",
"glob-to-regexp": "0.4.1",
"is-running": "^2.1.0",
Expand Down
18 changes: 18 additions & 0 deletions packages/extension/src/browser/extension.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { FileSearchServicePath, IFileSearchService } from '@opensumi/ide-file-se
import { IFileServiceClient } from '@opensumi/ide-file-service';
import { IDialogService, IMessageService } from '@opensumi/ide-overlay';
import { IWorkspaceService } from '@opensumi/ide-workspace';
import { WorkspaceTrustService } from '@opensumi/ide-workspace-trust';

import {
ERestartPolicy,
Expand Down Expand Up @@ -148,6 +149,9 @@ export class ExtensionServiceImpl extends WithEventBus implements ExtensionServi
@Autowired(IFileServiceClient)
protected fileServiceClient: IFileServiceClient;

@Autowired(WorkspaceTrustService)
private readonly workspaceTrustService: WorkspaceTrustService;

constructor() {
super();

Expand Down Expand Up @@ -235,8 +239,22 @@ export class ExtensionServiceImpl extends WithEventBus implements ExtensionServi
await this.updateExtHostData();
}

private isWorkspaceTrustedModuleAvailable() {
try {
return !!this.workspaceTrustService;
} catch (e) {
return false;
}
}

public async activate(): Promise<void> {
await this.initExtensionMetaData();
if (this.isWorkspaceTrustedModuleAvailable()) {
// Wait for workspace trust decision before filtering extensions
await this.workspaceTrustService.whenTrustDecided();
// Apply trust filter if in restricted mode
this.extensionMetaDataArr = this.workspaceTrustService.filterExtensions(this.extensionMetaDataArr);
}
Comment on lines 252 to +263
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

runEagerExtensionsContributes() 被调用两次会重复注册命令前置钩子并重复派发事件

pendingExtensions.length > 0 时,Lines 252 与 262 会两次调用 runEagerExtensionsContributes()。该方法内部执行了 this.commandRegistry.beforeExecuteCommand(...) 注册一个 pre-execute 钩子,且未保存或释放该订阅。第二次调用会再注册一个相同的钩子,导致此后每次命令执行都会 两次 触发 onCommand 激活事件;同时 ExtensionDidContributes 事件也会被重复派发,下游订阅者若不是幂等的将出现重复贡献注册等问题。

♻️ 建议方案

runEagerExtensionsContributes 拆分为「contributes 初始化」与「一次性的钩子注册/事件派发」两部分,让钩子注册和 ExtensionDidContributes 仅在受信决策完成、扩展集最终敲定后执行一次:

   private async runEagerExtensionsContributes() {
     await Promise.all([this.contributesService.initialize(), this.sumiContributesService.initialize()]);
-
-    this.commandRegistry.beforeExecuteCommand(async (command, args) => {
-      await this.activationEventService.fireEvent('onCommand', command);
-      return args;
-    });
-    this.eventBus.fire(new ExtensionDidContributes());
+  }
+
+  private eagerContributesFinalized = false;
+  private finalizeEagerContributes() {
+    if (this.eagerContributesFinalized) {
+      return;
+    }
+    this.eagerContributesFinalized = true;
+    this.commandRegistry.beforeExecuteCommand(async (command, args) => {
+      await this.activationEventService.fireEvent('onCommand', command);
+      return args;
+    });
+    this.eventBus.fire(new ExtensionDidContributes());
   }

并在 activate() 中只在最后一次扩展集合敲定后调用 finalizeEagerContributes()

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/extension/src/browser/extension.service.ts` around lines 252 - 263,
The code calls runEagerExtensionsContributes() twice which re-registers the same
pre-execute hook (commandRegistry.beforeExecuteCommand) and re-dispatches
ExtensionDidContributes; split runEagerExtensionsContributes into two parts: one
function (e.g. prepareEagerContributes) that only computes/initializes
contributes data used by extensions, and a second one (e.g.
finalizeEagerContributes) that registers the one-time pre-execute hook via
commandRegistry.beforeExecuteCommand and emits the ExtensionDidContributes
event; then change activate() flow to call prepareEagerContributes() before
waiting on allowedExtensionService.waitTrustDecided() and
initExtensionInstanceData(pending), and call finalizeEagerContributes() only
once after the trust decision and instance initialization so hooks and events
are registered/dispatched a single time.

await this.initExtensionInstanceData();
await this.runEagerExtensionsContributes();
// update nls config by extensions
Expand Down
27 changes: 18 additions & 9 deletions packages/extension/src/browser/vscode/api/main.thread.language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
IdentifiableInlineCompletions,
IdentifiableInlineEdit,
MonacoModelIdentifier,
RangeSuggestDataDto,
testGlob,
} from '../../../common/vscode';
import { IDocumentFilterDto, fromLanguageSelector } from '../../../common/vscode/converter';
Expand Down Expand Up @@ -270,6 +271,22 @@ export class MainThreadLanguages implements IMainThreadLanguages {

const dtoRange = data[ISuggestDataDtoField.range];

let targetRange: IRange | { insert: IRange; replace: IRange } = defaultRange;

if (dtoRange) {
if (Array.isArray(dtoRange)) {
targetRange = MonacoRange.lift({
startLineNumber: dtoRange[0],
startColumn: dtoRange[1],
endLineNumber: dtoRange[2],
endColumn: dtoRange[3],
})
} else {
targetRange = dtoRange;
}
}


return {
label,
kind: data[ISuggestDataDtoField.kind] ?? modes.CompletionItemKind.Property,
Expand All @@ -280,15 +297,7 @@ export class MainThreadLanguages implements IMainThreadLanguages {
filterText: data[ISuggestDataDtoField.filterText],
preselect: data[ISuggestDataDtoField.preselect],
insertText: data[ISuggestDataDtoField.insertText] ?? (typeof label === 'string' ? label : label.label),
range:
Array.isArray(dtoRange) && dtoRange.length === 4
? MonacoRange.lift({
startLineNumber: dtoRange[0],
startColumn: dtoRange[1],
endLineNumber: dtoRange[2],
endColumn: dtoRange[3],
})
: defaultRange,
range: targetRange,
insertTextRules: data[ISuggestDataDtoField.insertTextRules],
commitCharacters: data[ISuggestDataDtoField.commitCharacters],
additionalTextEdits: data[ISuggestDataDtoField.additionalTextEdits],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ export const FileDialog = ({
searchPlaceholder={selectPath}
value={selectPath}
showSearch={showFilePathSearch}
externalSearchBehavior={true}
>
{directoryList.map((item, idx) => (
<Option value={item} key={`${idx} - ${item}`}>
Expand Down
25 changes: 25 additions & 0 deletions packages/i18n/src/common/en-US.lang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1679,5 +1679,30 @@ export const localizationBundle = {
'ai.native.mcp.tool.action.edit': 'Edit',
'ai.native.mcp.tool.action.delete': 'Delete',
'ai.native.mcp.tool.action.sync': 'Sync',

// Workspace Trust
'workspace.trust.dialog.title': 'Do you trust the authors of the files in this folder?',
'workspace.trust.dialog.message':
'The current IDE provides features that can automatically execute files in this folder.\n\nIf you do not trust the authors of these files, it is recommended to continue using Restricted Mode, as these files may be malicious.',
'workspace.trust.dialog.button.trust': 'Yes, I trust the authors',
'workspace.trust.dialog.button.restricted': 'Restricted Mode',
'workspace.trust.statusbar.restricted': 'Restricted Mode',
'workspace.trust.statusbar.restricted.tooltip': 'Restricted Mode - Some features are disabled for security',
'workspace.trust.exitRestricted.label': 'Exit Restricted Mode',
'workspace.trust.exitRestricted.description':
'Click to trust the authors of files in this workspace and exit Restricted Mode',
'workspace.trust.exitRestricted.confirm.message':
'Are you sure you want to trust the authors of the files in this workspace and exit Restricted Mode?',
'workspace.trust.exitRestricted.confirm.ok': 'Trust and Reload',
'workspace.trust.exitRestricted.cancel': 'Cancel',
'workspace.trust.settings.group.title': 'Workspace Trust',
'workspace.trust.settings.section.title': 'Trust Management',
'workspace.trust.settings.trustButton': 'Trust Current Project',
'workspace.trust.settings.restrictButton': 'Enter Restricted Mode',
'workspace.trust.settings.restrictedDesc':
'Currently in Restricted Mode. The current IDE provides features that can automatically execute files in this folder. Click to trust the authors of files in this workspace.',
'workspace.trust.settings.trustedDesc':
'This workspace is trusted. Click to enter Restricted Mode and disable potentially unsafe features.',
'workspace.trust.settings.loading': 'Processing...',
},
};
22 changes: 22 additions & 0 deletions packages/i18n/src/common/zh-CN.lang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1436,5 +1436,27 @@ export const localizationBundle = {
'ai.native.mcp.tool.action.edit': '修改',
'ai.native.mcp.tool.action.delete': '删除',
'ai.native.mcp.tool.action.sync': '同步',

// Workspace Trust
'workspace.trust.dialog.title': '是否信任此文件夹中的文件的作者?',
'workspace.trust.dialog.message':
'当前 IDE 提供可以自动在此文件夹中执行文件的功能。\n\n如果不信任这些文件的作者,则建议继续使用受限模式,因为这些文件可能是恶意文件。',
'workspace.trust.dialog.button.trust': '是的,我信任他们',
'workspace.trust.dialog.button.restricted': '受限模式',
'workspace.trust.statusbar.restricted': '受限模式',
'workspace.trust.statusbar.restricted.tooltip': '受限模式 - 部分功能已出于安全考虑被禁用',
'workspace.trust.exitRestricted.label': '退出受限模式',
'workspace.trust.exitRestricted.description': '点击以信任此工作区中文件的作者并退出受限模式',
'workspace.trust.exitRestricted.confirm.message': '您确定要信任此工作区中文件的作者并退出受限模式吗?',
'workspace.trust.exitRestricted.confirm.ok': '信任并重新加载',
'workspace.trust.exitRestricted.cancel': '取消',
'workspace.trust.settings.group.title': '工作区信任',
'workspace.trust.settings.section.title': '信任管理',
'workspace.trust.settings.trustButton': '信任当前项目',
'workspace.trust.settings.restrictButton': '进入受限模式',
'workspace.trust.settings.restrictedDesc':
'当前处于受限模式。当前 IDE 提供可以自动在此文件夹中执行文件的功能。点击以信任此工作区中文件的作者。',
'workspace.trust.settings.trustedDesc': '此工作区已被信任。点击以进入受限模式并禁用可能存在风险的功能。',
'workspace.trust.settings.loading': '处理中...',
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,11 @@ export class PreferenceSettingsService extends Disposable implements IPreference
sec.subSections = subSections;
}

if ((sec.preferences && sec.preferences.length > 0) || (sec.subSections && sec.subSections.length > 0)) {
if (
sec.component ||
(sec.preferences && sec.preferences.length > 0) ||
(sec.subSections && sec.subSections.length > 0)
) {
result.push(sec);
}
});
Expand Down
Loading
Loading