Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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: 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 @@
disabled?: boolean;
onChange?: (value: T) => void;
onSearchChange?: (value: string) => void;
/**
* 搜索行为不采用默认的 filterOptions 进行筛选,由外部托管
*/
externalSearchBehavior?: boolean;
/**
* 当鼠标划过时触发回调
* @param value 鼠标划过的是第几个 option
Expand Down Expand Up @@ -290,13 +294,16 @@
description,
notMatchWarning,
onSearchChange,
externalSearchBehavior

Check failure on line 297 in packages/components/src/select/index.tsx

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.x)

Missing trailing comma
}: 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

Check failure on line 305 in packages/components/src/select/index.tsx

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.x)

Missing semicolon

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 @@
{showWarning && <div className='kt-select-warning-text'>{notMatchWarning}</div>}

{open &&
!searchInput &&
!(externalSearchBehavior && searchInput) &&
(isDataOptions(filteredOptions) || isDataOptionGroups(filteredOptions) ? (
<SelectOptionsList
optionRenderer={optionRenderer}
Expand Down
13 changes: 13 additions & 0 deletions packages/core-browser/src/bootstrap/inner-providers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable sort-imports */
import { Injector, Provider } from '@opensumi/di';
import {
AppLifeCycleServiceToken,
Expand Down Expand Up @@ -79,6 +80,13 @@ import { NextToolbarRegistryImpl, ToolbarClientAppContribution } from '../toolba
import { VariableContribution, VariableRegistry, VariableRegistryImpl } from '../variable';
import { IWindowService } from '../window';
import { WindowService } from '../window/window.service';
import {
WorkspaceTrustService,
WorkspaceTrustContribution,
WorkspaceTrustStatusBarContribution,
WorkspaceTrustCommandContribution,
WorkspaceTrustSettingsContribution,
} from '../workspace-trust';

import { ClientAppContextContribution } from './context-contribution';
import { AppLifeCycleService } from './lifecycle.service';
Expand Down Expand Up @@ -275,6 +283,11 @@ export function injectInnerProviders(injector: Injector) {
token: ConnectionHelperFactory,
useFactory: ConnectionHelperFactory,
},
WorkspaceTrustService,
WorkspaceTrustContribution,
WorkspaceTrustStatusBarContribution,
WorkspaceTrustCommandContribution,
WorkspaceTrustSettingsContribution,
];
injector.addProviders(...providers);
}
14 changes: 13 additions & 1 deletion packages/core-browser/src/common/common.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* because the `localize` func maybe cannot get the correct languageId at the beginning of whole lifecycle.
*/

import { Command } from '@opensumi/ide-core-common';
import { Command, MessageType } from '@opensumi/ide-core-common';

import { getIcon } from '../style/icon/icon';

Expand Down Expand Up @@ -872,13 +872,25 @@ export namespace KEYBOARD_COMMANDS {
};
}

export interface OpenDialogArgs {
message: string;
type: MessageType;
buttons?: string[];
closable?: boolean;
}

export namespace DIALOG_COMMANDS {
const CATEGORY = 'dialog';

export const ENSURE: Command = {
id: 'dialog.ensure',
category: CATEGORY,
};

export const OPEN: Command = {
id: 'dialog.open',
category: CATEGORY,
};
}

export namespace TERMINAL_COMMANDS {
Expand Down
2 changes: 2 additions & 0 deletions packages/core-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,5 @@ export * from './extensions';

export * from './static-resource';
export * from './context-key';

export * from './workspace-trust';
21 changes: 21 additions & 0 deletions packages/core-browser/src/workspace-trust/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Workspace trust state enum
*/
export enum WorkspaceTrustState {
/** User has not yet decided */
Undecided = 'undecided',
/** User trusts this workspace - all extensions loaded */
Trusted = 'trusted',
/** User chose restricted mode - only allowed extensions loaded */
Restricted = 'restricted',
}

/**
* Storage key prefix for workspace trust state
*/
export const WORKSPACE_TRUST_STORAGE_KEY = 'workspace_trust_state';

/**
* Service token for WorkspaceTrustService
*/
export const WorkspaceTrustServiceToken = Symbol('WorkspaceTrustService');
9 changes: 9 additions & 0 deletions packages/core-browser/src/workspace-trust/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export { WorkspaceTrustService } from './workspace-trust.service';
export { WorkspaceTrustContribution } from './workspace-trust.contribution';
export { WorkspaceTrustStatusBarContribution } from './workspace-trust-statusbar.contribution';
export {
WorkspaceTrustCommandContribution,
WORKSPACE_TRUST_EXIT_RESTRICTED_COMMAND,
} from './workspace-trust-command.contribution';
export { WorkspaceTrustSettingsContribution } from './workspace-trust-settings.contribution';
export { WorkspaceTrustState } from './common';
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Autowired } from '@opensumi/di';
import { CommandContribution, CommandRegistry, CommandService, MessageType, localize } from '@opensumi/ide-core-common';
import { Domain } from '@opensumi/ide-core-common/lib/di-helper';

import { IClientApp } from '../browser-module';
import { ClientAppContribution } from '../common/common.define';

import { WorkspaceTrustState } from './common';
import { WorkspaceTrustService } from './workspace-trust.service';

export const WORKSPACE_TRUST_EXIT_RESTRICTED_COMMAND = {
id: 'workspace.trust.exitRestricted',
label: localize('workspace.trust.exitRestricted.label', 'Exit Restricted Mode'),
};

@Domain(CommandContribution, ClientAppContribution)
export class WorkspaceTrustCommandContribution implements CommandContribution, ClientAppContribution {
@Autowired(WorkspaceTrustService)
private readonly workspaceTrustService: WorkspaceTrustService;

@Autowired(IClientApp)
private readonly clientApp: IClientApp;

@Autowired(CommandService)
private readonly commandService: CommandService;

registerCommands(commands: CommandRegistry) {
commands.registerCommand(WORKSPACE_TRUST_EXIT_RESTRICTED_COMMAND, {
execute: async () => {
const okText = localize('workspace.trust.exitRestricted.confirm.ok', 'Trust and Reload');
const cancelText = localize('workspace.trust.exitRestricted.cancel', 'Cancel');

const msg = await this.commandService.executeCommand<string>('dialog.open', {
message: localize(
'workspace.trust.exitRestricted.confirm.message',
'Are you sure you want to trust the authors of the files in this workspace and exit Restricted Mode?',
),
type: MessageType.Info,
buttons: [okText, cancelText],
});

if (msg === okText) {
await this.workspaceTrustService.setTrustState(WorkspaceTrustState.Trusted);
this.clientApp.fireOnReload(true);
}
},
});
}

onStart() {
// no-op
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.workspaceTrustPanel {
padding: 12px 18px;

.description {
margin-bottom: 12px;
color: var(--foreground);
font-size: 13px;
line-height: 1.5;
}

.button {
padding: 6px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: #fff;
transition: opacity 0.15s;

&:hover:not(:disabled) {
opacity: 0.85;
}

&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}

.buttonTrust {
background-color: var(--button-background);
}

.buttonRestrict {
background-color: var(--button-background);
}
}
Loading
Loading