mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): require explicit trust for project frontmatter and LSP (#16182)
* fix(core): require explicit trust for repository execution * test(integration): explicitly trust skill hook fixtures * test(lsp): add isExplicitlyTrustedFolder to document-sync mock config The document-sync tests create a bare Config mock with only isTrustedFolder. The new explicit-trust gate in NativeLspService and LspServerManager calls isExplicitlyTrustedFolder(), which is undefined on the mock, producing TypeError at reload time. Add isExplicitlyTrustedFolder: () => true alongside the existing isTrustedFolder mock so the 13 affected cases pass again. Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
3a4c7dd0e9
commit
1f09497f45
35 files changed
+341
-73
No files matched your search
@@ -0,0 +1,33 @@
|
||||
# Explicit trust for repository execution
|
||||
|
||||
[English](2026-11-03-project-execution-trust.md) | [简体中文](2026-11-03-project-execution-trust.zh-CN.md)
|
||||
|
||||
Status: implemented, 2026-11-03.
|
||||
|
||||
## Problem and scope
|
||||
|
||||
Issue #14746 identifies that disabled folder trust makes every directory implicitly trusted. PR #14748 gates settings-file hooks separately, but project skill/subagent frontmatter hooks and workspace `.lsp.json` commands still accept that implicit trust. Project skills can also grant automatic tool permissions through the same gate.
|
||||
|
||||
This change closes those remaining execution paths. It does not change the default folder-trust setting, add another approval store or dialog, or modify the settings-file hook approval work in #14748.
|
||||
|
||||
## Decision
|
||||
|
||||
Add `Config.isExplicitlyTrustedFolder()`: an affirmative IDE workspace trust signal, or enabled folder trust with a trusted directory, authorizes repository execution. An IDE denial takes precedence. Missing trust, and disabled folder trust without an IDE decision, deny execution. Keep the existing `isTrustedFolder()` semantics for unrelated consumers.
|
||||
|
||||
Project skill side effects and subagent hooks use this predicate both at registration and at execution. Skill permission grants use the same live predicate so losing an IDE trust signal cannot leave grants active through the legacy default. Skill/agent instructions remain available. User, extension and bundled skill/agent side effects are unchanged.
|
||||
|
||||
The LSP loader records whether each configuration came from the workspace file or an extension. Only the loader chooses this source; JSON cannot override it. Workspace LSP configurations require explicit trust during service admission and again before any command probe, connection or process startup. This covers initial discovery, hot reload and restart. Existing extension admission and `requireTrustedWorkspace` behavior remain unchanged.
|
||||
|
||||
## Compatibility and risks
|
||||
|
||||
Users of project frontmatter hooks, project skill permission grants or workspace LSP configurations must enable `security.folderTrust.enabled` in user settings and trust their directory, or use an IDE that explicitly trusts it. Disabled folder trust no longer authorizes these executable paths, including YOLO sessions. Warnings and feature documentation explain this migration. The existing trust dialog and trust-file persistence provide the approval flow.
|
||||
|
||||
Trust-file edits still take effect after the existing restart boundary; IDE decisions remain live. This is directory-level trust, not a hash approval of every script or hook. It does not make an explicitly trusted malicious repository safe. Settings-file hook approval, per-skill approval and LSP-specific approval dialogs remain separate work.
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
- Reproduce both paths with fresh user settings and harmless markers before changing source.
|
||||
- Default-disabled folder trust must suppress project skill hooks/grants, project subagent hooks and workspace LSP startup, including executable availability probes.
|
||||
- Enabling trust and granting it, or receiving affirmative IDE trust, restores these paths. IDE denial suspends project hook/grant execution; with folder trust disabled, losing the IDE signal also suspends them.
|
||||
- Workspace JSON cannot set an extension source or disable its own trust requirement. Extension LSP configurations and non-project skills/agents retain their existing behavior.
|
||||
- Run focused core unit tests, build, bundle and typecheck; verify the built CLI against isolated marker fixtures. Self-audit the full diff and review the final change.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 仓库代码执行需要显式信任
|
||||
|
||||
[English](2026-11-03-project-execution-trust.md) | [简体中文](2026-11-03-project-execution-trust.zh-CN.md)
|
||||
|
||||
状态:已实现,2026-11-03。
|
||||
|
||||
## 问题与范围
|
||||
|
||||
Issue #14746 指出,关闭目录信任会使所有目录隐式受信任。PR #14748 为 settings 文件中的 hooks 单独增加审批,但项目 skill/subagent 的 frontmatter hooks 和工作区 `.lsp.json` 命令仍接受这种隐式信任。项目 skill 还可以通过同一门禁授予工具自动批准权限。
|
||||
|
||||
本次改动关闭这些剩余执行入口。不改变目录信任的默认值,不增加审批存储或对话框,也不修改 #14748 的 settings hooks 审批工作。
|
||||
|
||||
## 决策
|
||||
|
||||
新增 `Config.isExplicitlyTrustedFolder()`:IDE 明确表示工作区受信任,或已开启目录信任且当前目录受信任时,才授权执行仓库代码。IDE 的拒绝信号优先。缺少信任,以及关闭目录信任且没有 IDE 决策,均拒绝执行。其他使用方继续使用现有 `isTrustedFolder()` 语义。
|
||||
|
||||
项目 skill 的副作用与 subagent hooks 在注册和执行时均使用此判定。skill 权限授予也使用同一实时判定,避免 IDE 信任信号消失后,由旧的默认放行语义维持授权。skill/agent 的指令正文仍可用。用户、扩展和内置 skill/agent 的副作用不变。
|
||||
|
||||
LSP 加载器记录每项配置来自工作区文件还是扩展。来源只能由加载器确定,JSON 不能覆盖。工作区 LSP 配置在服务准入,以及任何命令探测、连接或进程启动之前,都要求显式信任。这覆盖首次发现、热重载和重启。扩展的现有准入与 `requireTrustedWorkspace` 行为不变。
|
||||
|
||||
## 兼容性与风险
|
||||
|
||||
项目 frontmatter hooks、项目 skill 权限授予或工作区 LSP 配置的使用者,需要在用户设置中开启 `security.folderTrust.enabled` 并信任目录,或使用明确授信的 IDE。关闭目录信任不再授权这些执行路径,包括 YOLO 会话。警告和功能文档说明这一迁移。现有信任对话框及信任文件持久化提供审批流程。
|
||||
|
||||
信任文件的手动修改仍沿用现有重启生效边界;IDE 决策保持实时。本方案属于目录级信任,不是对每个脚本或 hook 的哈希审批,也不能让已被明确授信的恶意仓库变得安全。settings hooks 审批、按 skill 审批以及 LSP 专用审批对话框仍属于独立工作。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
- 修改源码前,使用全新用户设置和无害标记复现两类路径。
|
||||
- 默认关闭目录信任时,必须阻止项目 skill hooks/权限授予、项目 subagent hooks 和工作区 LSP 启动,包括探测可执行文件是否存在的命令。
|
||||
- 开启并授予目录信任,或收到 IDE 明确授信后,恢复这些路径。IDE 拒绝授信后暂停项目 hook/权限;关闭目录信任时,失去 IDE 信号也会暂停它们。
|
||||
- 工作区 JSON 不能声明扩展来源或关闭自己的信任要求。扩展 LSP 配置与非项目 skill/agent 保持现有行为。
|
||||
- 运行受影响的 core 单测、构建、打包和类型检查;以隔离标记夹具验证构建后的 CLI;自审完整 diff 并评审最终改动。
|
||||
@@ -6,6 +6,12 @@ The Trusted Folders feature is a security setting that gives you control over wh
|
||||
|
||||
The Trusted Folders feature is **disabled by default**. To use it, you must first enable it in your settings.
|
||||
|
||||
Disabled folder trust does not authorize project skill/subagent frontmatter
|
||||
hooks, project skill automatic tool grants, or workspace `.lsp.json` servers.
|
||||
These require folder trust to be enabled and granted, or an affirmative IDE
|
||||
workspace trust signal. Other settings-file hooks follow their own loading
|
||||
policy; see [Hooks](../features/hooks.md).
|
||||
|
||||
Add the following to your user `settings.json` file:
|
||||
|
||||
```json
|
||||
|
||||
@@ -46,6 +46,12 @@ agents or run for parents and siblings. Global settings hooks and session-wide
|
||||
skill/function hooks retain their existing scope. Project-agent hooks recheck
|
||||
the trust of the workspace that supplied the agent before every event.
|
||||
|
||||
Project skill and agent frontmatter hooks require **explicit folder trust**:
|
||||
enable `security.folderTrust.enabled` in user settings and trust the directory,
|
||||
or grant workspace trust through your IDE. Disabling folder trust does not
|
||||
authorize frontmatter hooks, including in YOLO mode. Their instructions still
|
||||
load. User and bundled frontmatter hooks keep their existing behavior.
|
||||
|
||||
Use `SubagentStop` for agent completion; `Stop` is not automatically remapped.
|
||||
Move an agent hook to settings if it should observe the whole session.
|
||||
|
||||
|
||||
@@ -310,18 +310,18 @@ Code action kinds:
|
||||
|
||||
## Security
|
||||
|
||||
LSP servers are only started in trusted workspaces by default. This is because language servers run with your user permissions and can execute code.
|
||||
Language servers run with your user permissions and can execute code. Servers configured by a workspace's `.lsp.json` require **explicit folder trust**, including their executable availability probes and hot reloads. Enable `security.folderTrust.enabled` in user settings and trust the directory, or grant workspace trust through your IDE. Disabling folder trust does not authorize workspace LSP configurations, including in YOLO mode.
|
||||
|
||||
### Trust Controls
|
||||
|
||||
- **Trusted Workspace**: LSP servers start if configured
|
||||
- **Untrusted Workspace**: LSP servers won't start unless `trustRequired: false` is set in the server configuration
|
||||
- **Explicitly Trusted Workspace**: Workspace LSP servers start if configured.
|
||||
- **No Explicit Trust**: Workspace `.lsp.json` servers are skipped. The repository cannot override this by setting `trustRequired: false` or declaring an extension source.
|
||||
|
||||
To mark a workspace as trusted, use the `/trust` command.
|
||||
After enabling folder trust in user settings, use the startup trust dialog or `/trust` to trust the directory. Restart after changing the CLI's trust decision.
|
||||
|
||||
### Per-Server Trust Override
|
||||
|
||||
You can override trust requirements for specific servers in their configuration:
|
||||
Installed extensions retain their existing trust policy. An extension can declare `trustRequired: false` when the host permits LSP discovery in untrusted workspaces. This override is ignored in workspace `.lsp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -233,7 +233,7 @@ Notes:
|
||||
- A Skill hook's `matcher:` follows the same rules as a settings hook (see [Hooks](hooks.md)). An omitted or empty `matcher:` matches every tool, and matchers are not anchored, so `edit` also matches `notebook_edit`. Skill hooks used to wrap their matcher in `^…$` and to match nothing when it was omitted: write `^edit$` to match exactly one tool, and write exclusions as `^(?!write_file).*$`.
|
||||
- The `command:` runs through the platform shell: `bash` on macOS and Linux, and on Windows Git Bash when it is detected (`MSYSTEM`/`TERM`), otherwise `cmd.exe` or PowerShell. The example above is POSIX shell — under `cmd.exe` `$QWEN_SKILL_ROOT` is not expanded and a `.sh` script is not executable, so the gate fails open there. A hook may set `shell: bash` to force bash, but that resolves to whatever `bash` is on `PATH`, so on Windows outside Git Bash write the gate for the shell you actually have.
|
||||
- Sessions that disable hooks register none of them — `disableAllHooks`, safe mode, and an ACP client's `skipHooks`. The Skill's body and its `allowedTools` still apply in those sessions, but its gate does not, so a rule you rely on a hook to enforce is not enforced there. Bare mode goes further: no Skills are discovered at all, so there is no body and no `allowedTools` either.
|
||||
- A **project** Skill's hooks run repo-supplied commands, so they are registered only in a trusted folder, and trust is re-read every time a hook fires and every time a permission is decided. With an IDE companion connected that value is live: revoking trust silences an already-registered gate — and suspends the Skill's `allowedTools` — at the next tool call, without a restart. Without an IDE connection the value is fixed when the CLI starts, so a change made through the CLI's own trust dialog takes effect on restart. Granting trust never retro-registers: invoke the Skill again.
|
||||
- A **project** Skill's hooks and `allowedTools` require **explicit folder trust**: enable `security.folderTrust.enabled` in user settings and trust the directory, or grant workspace trust through your IDE. Disabling folder trust does not authorize these side effects, including in YOLO mode. The skill's instructions still load. Trust is re-read every time a hook fires or a permission is decided: revoking IDE trust suspends registered project hooks and grants. With folder trust disabled, disconnecting the IDE also suspends them. Without an IDE connection, changes through the CLI trust dialog take effect on restart. Granting trust never retro-registers: invoke the Skill again.
|
||||
- `hooks:` is read for project, user, and bundled Skills. Extension-provided Skills do not support it; use the extension's own manifest-level hooks instead.
|
||||
- See [Hooks](hooks.md) for the full event list, matcher syntax, and output format.
|
||||
|
||||
|
||||
@@ -873,6 +873,7 @@ Always follow these standards:
|
||||
|
||||
- **Tool Restrictions**: Use `tools` to limit which tools a subagent can access, or `disallowedTools` to block specific tools while inheriting everything else
|
||||
- **Permission Mode**: Subagents inherit their parent's permission mode by default. Plan-mode sessions cannot escalate to auto-edit through delegated agents. Privileged modes (auto-edit, yolo) are blocked in untrusted folders.
|
||||
- **Project Hooks**: Project subagent frontmatter hooks require explicit folder trust: enable `security.folderTrust.enabled` in user settings and trust the directory, or grant IDE workspace trust. Disabling folder trust does not authorize these hooks, even in YOLO mode. Subagent instructions remain available; user-level hooks keep their existing behavior.
|
||||
- **Provider Selection**: A subagent with `model: authType:modelId`, or
|
||||
`model: fast` where `fastModel` resolves to another auth type, sends that
|
||||
subagent's model requests to the selected provider. Make sure that provider is
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { applyContainerSandboxNoProxy, type TestRig } from '../test-helper.js';
|
||||
@@ -159,8 +160,18 @@ export function stubFakeModelEnv(
|
||||
vi.stubEnv('OPENAI_BASE_URL', fakeServer.baseUrl);
|
||||
vi.stubEnv('OPENAI_MODEL', 'fake-model');
|
||||
vi.stubEnv('QWEN_MODEL', 'fake-model');
|
||||
vi.stubEnv('QWEN_HOME', join(rig.testDir!, '.qwen-home'));
|
||||
vi.stubEnv('QWEN_RUNTIME_DIR', join(rig.testDir!, '.qwen-home'));
|
||||
const qwenHome = join(rig.testDir!, '.qwen-home');
|
||||
mkdirSync(qwenHome, { recursive: true });
|
||||
writeFileSync(
|
||||
join(qwenHome, 'settings.json'),
|
||||
JSON.stringify({ security: { folderTrust: { enabled: true } } }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(qwenHome, 'trustedFolders.json'),
|
||||
JSON.stringify({ [realpathSync(rig.testDir!)]: 'TRUST_FOLDER' }),
|
||||
);
|
||||
vi.stubEnv('QWEN_HOME', qwenHome);
|
||||
vi.stubEnv('QWEN_RUNTIME_DIR', qwenHome);
|
||||
const restoreNoProxy = applyContainerSandboxNoProxy();
|
||||
// The gate's required value is deliberately absent, in every session.
|
||||
vi.stubEnv('DOWNSTREAM_SESSION_ID', '');
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('SkillCommandLoader', () => {
|
||||
getPermissionManager: vi
|
||||
.fn()
|
||||
.mockReturnValue({ addSessionAllowRule: mockAddSessionAllowRule }),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
isExplicitlyTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
// SkillCommandLoader filters via this. Default to empty so existing
|
||||
// assertions about "all skills surface" stay true; per-test cases
|
||||
// override to verify the filter behavior.
|
||||
@@ -346,13 +346,13 @@ describe('SkillCommandLoader', () => {
|
||||
}
|
||||
|
||||
it('grants no session allow rules in an untrusted folder', async () => {
|
||||
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
|
||||
vi.mocked(mockConfig.isExplicitlyTrustedFolder).mockReturnValue(false);
|
||||
await runProjectSkill();
|
||||
expect(mockAddSessionAllowRule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grants them in a trusted folder — marked trust-gated for the live revocation check', async () => {
|
||||
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.isExplicitlyTrustedFolder).mockReturnValue(true);
|
||||
await runProjectSkill();
|
||||
expect(mockAddSessionAllowRule).toHaveBeenCalledTimes(2);
|
||||
// Exactly `{ trustGated: true }`: suspension keys solely off the
|
||||
@@ -709,9 +709,7 @@ describe('SkillCommandLoader', () => {
|
||||
});
|
||||
|
||||
it('registers no hooks for a project skill in an untrusted folder', async () => {
|
||||
(mockConfig.isTrustedFolder as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
vi.mocked(mockConfig.isExplicitlyTrustedFolder).mockReturnValue(false);
|
||||
|
||||
await runSkillCommand(
|
||||
makeSkill({
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from './config.js';
|
||||
import { GOAL_DEFAULT_TOKEN_BUDGET } from '../goals/goal-protocol.js';
|
||||
import { Storage } from './storage.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import { SshExecutionEnvironment } from '../services/ssh-execution-environment.js';
|
||||
import { DEFAULT_MAX_TOOL_CALLS_PER_TURN } from '../services/loopDetectionService.js';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -10728,6 +10729,41 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit folder trust for repository execution', () => {
|
||||
afterEach(() => ideContextStore.clear());
|
||||
|
||||
it.each([
|
||||
[false, undefined, true, false],
|
||||
[false, true, true, false],
|
||||
[true, undefined, false, false],
|
||||
[true, false, false, false],
|
||||
[true, true, true, true],
|
||||
] as const)(
|
||||
'distinguishes enabled=%s, trusted=%s from implicit trust',
|
||||
(folderTrust, trustedFolder, legacy, explicit) => {
|
||||
ideContextStore.clear();
|
||||
const config = new Config({
|
||||
...MINIMAL_PARAMS,
|
||||
folderTrust,
|
||||
trustedFolder,
|
||||
});
|
||||
expect(config.isTrustedFolder()).toBe(legacy);
|
||||
expect(config.isExplicitlyTrustedFolder()).toBe(explicit);
|
||||
},
|
||||
);
|
||||
|
||||
it('honors live IDE trust, including denial and disconnect', () => {
|
||||
const config = new Config({ ...MINIMAL_PARAMS, folderTrust: false });
|
||||
ideContextStore.set({ workspaceState: { isTrusted: true } });
|
||||
expect(config.isExplicitlyTrustedFolder()).toBe(true);
|
||||
ideContextStore.set({ workspaceState: { isTrusted: false } });
|
||||
expect(config.isExplicitlyTrustedFolder()).toBe(false);
|
||||
ideContextStore.clear();
|
||||
expect(config.isTrustedFolder()).toBe(true);
|
||||
expect(config.isExplicitlyTrustedFolder()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setApprovalMode with folder trust', () => {
|
||||
const baseParams = MINIMAL_PARAMS;
|
||||
/** A Config whose folder trust decision is pinned to `trusted`. */
|
||||
|
||||
@@ -11944,6 +11944,13 @@ export class Config {
|
||||
return this.trustedFolder ?? !this.folderTrust;
|
||||
}
|
||||
|
||||
isExplicitlyTrustedFolder(): boolean {
|
||||
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
|
||||
return (
|
||||
(this.folderTrust || ideTrust !== undefined) && this.isTrustedFolder()
|
||||
);
|
||||
}
|
||||
|
||||
setIdeMode(value: boolean): void {
|
||||
if (this.shellExecutionSandbox && value)
|
||||
throw new Error(
|
||||
|
||||
@@ -794,7 +794,7 @@ describe('HookEventHandler', () => {
|
||||
describe('project-skill trust gate at fire time', () => {
|
||||
// The second side of the gate `applySideEffects` enforces on the way
|
||||
// in: a hook registered from a repository's `.qwen/skills/` frontmatter
|
||||
// fires only while the folder is STILL trusted. `isTrustedFolder()` is
|
||||
// fires only while the folder is STILL trusted. `isExplicitlyTrustedFolder()` is
|
||||
// live under an IDE connection, so a revocation mid-session must
|
||||
// silence the hook at its next event — no restart, no unregistration.
|
||||
const gated = {
|
||||
@@ -811,9 +811,9 @@ describe('HookEventHandler', () => {
|
||||
config: { type: HookType.Command, command: './mine.sh' },
|
||||
};
|
||||
const trust = (value: boolean) => {
|
||||
const isTrustedFolder = vi.fn().mockReturnValue(value);
|
||||
Object.assign(mockConfig, { isTrustedFolder });
|
||||
return isTrustedFolder;
|
||||
const isExplicitlyTrustedFolder = vi.fn().mockReturnValue(value);
|
||||
Object.assign(mockConfig, { isExplicitlyTrustedFolder });
|
||||
return isExplicitlyTrustedFolder;
|
||||
};
|
||||
const fire = async (sessionHooks: unknown[]) => {
|
||||
vi.mocked(mockSessionHooksManager.getMatchingHooks).mockReturnValue(
|
||||
|
||||
@@ -1006,7 +1006,7 @@ export class HookEventHandler {
|
||||
: [];
|
||||
// The second side of the project-skill trust gate: a hook registered
|
||||
// from a repository's `.qwen/skills/` frontmatter (`trustGated`) runs
|
||||
// only while the folder is STILL trusted. `Config.isTrustedFolder()`
|
||||
// only while the folder is STILL trusted. `Config.isExplicitlyTrustedFolder()`
|
||||
// is live under an IDE connection, so a revocation mid-session
|
||||
// silences the hook at its next event without a restart or any
|
||||
// per-skill unregistration; trust granted again lets it fire. Only
|
||||
@@ -1015,7 +1015,8 @@ export class HookEventHandler {
|
||||
(entry) => entry.trustGated === true,
|
||||
)
|
||||
? registeredSessionHooks.filter(
|
||||
(entry) => !entry.trustGated || this.config.isTrustedFolder(),
|
||||
(entry) =>
|
||||
!entry.trustGated || this.config.isExplicitlyTrustedFolder(),
|
||||
)
|
||||
: registeredSessionHooks;
|
||||
if (sessionHooks.length !== registeredSessionHooks.length) {
|
||||
|
||||
@@ -92,6 +92,8 @@ describe('HookSystem', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
isExplicitlyTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/test/cwd'),
|
||||
@@ -237,6 +239,25 @@ describe('HookSystem', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('matches project skill hooks only while explicitly trusted', () => {
|
||||
hookSystem
|
||||
.getSessionHooksManager()
|
||||
.addSessionHook(
|
||||
'test-session-id',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
{ type: HookType.Command, command: 'echo check' },
|
||||
{ trustGated: true },
|
||||
);
|
||||
expect(
|
||||
hookSystem.hasHooksMatchingTool(HookEventName.PreToolUse, 'Bash'),
|
||||
).toBe(false);
|
||||
vi.mocked(mockConfig.isExplicitlyTrustedFolder).mockReturnValue(true);
|
||||
expect(
|
||||
hookSystem.hasHooksMatchingTool(HookEventName.PreToolUse, 'Bash'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe('hasHooksForEvent', () => {
|
||||
it('should return false when no hooks are registered for the event', () => {
|
||||
vi.mocked(mockHookRegistry.getHooksForEvent).mockReturnValue([]);
|
||||
|
||||
@@ -183,7 +183,10 @@ export class HookSystem {
|
||||
?.hookConfigs.length ||
|
||||
this.sessionHooksManager
|
||||
.getMatchingHooks(owner.sessionId, event, toolName)
|
||||
.some((entry) => !entry.trustGated || this.config.isTrustedFolder()),
|
||||
.some(
|
||||
(entry) =>
|
||||
!entry.trustGated || this.config.isExplicitlyTrustedFolder(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ function makeConfig(options: {
|
||||
safeMode?: boolean;
|
||||
bareMode?: boolean;
|
||||
trusted?: boolean;
|
||||
explicitlyTrusted?: boolean;
|
||||
systemHooks?: Record<string, unknown>;
|
||||
userHooks?: Record<string, unknown>;
|
||||
projectHooks?: Record<string, unknown>;
|
||||
@@ -60,6 +61,9 @@ function makeConfig(options: {
|
||||
getBareMode: vi.fn().mockReturnValue(options.bareMode ?? false),
|
||||
getSessionId: vi.fn().mockReturnValue(options.sessionId ?? SESSION_ID),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(options.trusted ?? true),
|
||||
isExplicitlyTrustedFolder: vi
|
||||
.fn()
|
||||
.mockReturnValue(options.explicitlyTrusted ?? options.trusted ?? true),
|
||||
getSystemHooks: vi.fn().mockReturnValue(options.systemHooks),
|
||||
getUserHooks: vi.fn().mockReturnValue(options.userHooks),
|
||||
getProjectHooks: vi.fn().mockReturnValue(options.projectHooks),
|
||||
@@ -361,7 +365,11 @@ describe('buildHooksListing', () => {
|
||||
});
|
||||
|
||||
/** The row for one session hook (`trustGated` or not) in a folder. */
|
||||
function trustGatedRow(trustGated: boolean, trusted: boolean) {
|
||||
function trustGatedRow(
|
||||
trustGated: boolean,
|
||||
trusted: boolean,
|
||||
explicitlyTrusted = trusted,
|
||||
) {
|
||||
const session = new SessionHooksManager();
|
||||
session.addSessionHook(
|
||||
SESSION_ID,
|
||||
@@ -370,7 +378,7 @@ describe('buildHooksListing', () => {
|
||||
{ type: HookType.Command, command: './skill-check.sh' },
|
||||
{ skillRoot: '/project/.qwen/skills/check', trustGated },
|
||||
);
|
||||
return rows({ session, trusted })[0];
|
||||
return rows({ session, trusted, explicitlyTrusted })[0];
|
||||
}
|
||||
|
||||
it('disables a trust-gated session hook in an untrusted folder', () => {
|
||||
@@ -391,6 +399,14 @@ describe('buildHooksListing', () => {
|
||||
expect(row).not.toHaveProperty('disabledReason');
|
||||
});
|
||||
|
||||
it('lists a project skill hook as disabled when legacy trust is only implicit', () => {
|
||||
expect(trustGatedRow(true, true, false)).toMatchObject({
|
||||
enabled: false,
|
||||
disabledReason: 'untrusted',
|
||||
});
|
||||
expect(trustGatedRow(false, true, false)?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trust-gate a session hook that is not trust-gated', () => {
|
||||
const row = trustGatedRow(false, false);
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export type HooksListingConfig = Pick<
|
||||
| 'getBareMode'
|
||||
| 'getSessionId'
|
||||
| 'isTrustedFolder'
|
||||
| 'isExplicitlyTrustedFolder'
|
||||
| 'getSystemHooks'
|
||||
| 'getUserHooks'
|
||||
| 'getProjectHooks'
|
||||
@@ -444,7 +445,9 @@ export function buildHooksListing(config: HooksListingConfig): HooksListing {
|
||||
hookId: entry.hookId,
|
||||
skillRoot: entry.skillRoot,
|
||||
},
|
||||
gates,
|
||||
entry.trustGated
|
||||
? { ...gates, trustedFolder: config.isExplicitlyTrustedFolder() }
|
||||
: gates,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,16 +197,19 @@ describe('LspConfigLoader config-driven behavior', () => {
|
||||
typescript: {
|
||||
command: 'typescript-language-server',
|
||||
trustRequired: false,
|
||||
source: 'extension',
|
||||
},
|
||||
});
|
||||
const configLoader = loader();
|
||||
|
||||
await expect(configLoader.loadUserConfigs()).resolves.toEqual([
|
||||
expect.objectContaining({ trustRequired: true }),
|
||||
expect.objectContaining({ trustRequired: true, source: 'workspace' }),
|
||||
]);
|
||||
await expect(configLoader.loadUserConfigsStrict()).resolves.toEqual({
|
||||
ok: true,
|
||||
configs: [expect.objectContaining({ trustRequired: true })],
|
||||
configs: [
|
||||
expect.objectContaining({ trustRequired: true, source: 'workspace' }),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,6 +347,7 @@ export class LspConfigLoader {
|
||||
command,
|
||||
args,
|
||||
transport,
|
||||
source: options.forceTrustRequired ? 'workspace' : 'extension',
|
||||
env,
|
||||
initializationOptions,
|
||||
settings,
|
||||
|
||||
@@ -49,6 +49,9 @@ describe('lspServerConfigHash', () => {
|
||||
});
|
||||
|
||||
it('changes when runtime config fields change', () => {
|
||||
expect(
|
||||
lspServerConfigHash({ ...baseConfig, source: 'workspace' }),
|
||||
).not.toBe(lspServerConfigHash({ ...baseConfig, source: 'extension' }));
|
||||
expect(
|
||||
lspServerConfigHash({
|
||||
...baseConfig,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { sortJsonValue } from './sort-json-value.js';
|
||||
|
||||
export function lspServerConfigHash(config: LspServerConfig): string {
|
||||
const hashInput = {
|
||||
source: config.source,
|
||||
name: config.name,
|
||||
languages: config.languages,
|
||||
transport: config.transport,
|
||||
|
||||
@@ -116,6 +116,7 @@ function createTrustedManager(): LspServerManager {
|
||||
return new LspServerManager(
|
||||
{
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
isExplicitlyTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as CoreConfig,
|
||||
{} as WorkspaceContext,
|
||||
{} as FileDiscoveryService,
|
||||
@@ -726,6 +727,39 @@ describe('LspServerManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks workspace command probes until explicitly trusted, without blocking extension configs', async () => {
|
||||
const config = {
|
||||
isTrustedFolder: () => true,
|
||||
isExplicitlyTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
const manager = new LspServerManager(
|
||||
config as unknown as CoreConfig,
|
||||
{} as WorkspaceContext,
|
||||
{} as FileDiscoveryService,
|
||||
{ requireTrustedWorkspace: false, workspaceRoot: '/workspace' },
|
||||
);
|
||||
const probe = openGates(manager);
|
||||
const connection = createMockConnection();
|
||||
stubConnection(manager, { connection, process: createMockProcess() });
|
||||
manager.setServerConfigs([{ ...serverConfig, source: 'workspace' }]);
|
||||
|
||||
await manager.startAll();
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(manager.getHandles().get('clangd')?.status).toBe('FAILED');
|
||||
|
||||
config.isExplicitlyTrustedFolder.mockReturnValue(true);
|
||||
await manager.startAll();
|
||||
expect(probe).toHaveBeenCalled();
|
||||
await manager.stopAll();
|
||||
|
||||
probe.mockClear();
|
||||
config.isExplicitlyTrustedFolder.mockReturnValue(false);
|
||||
manager.setServerConfigs([{ ...serverConfig, source: 'extension' }]);
|
||||
await manager.startAll();
|
||||
expect(probe).toHaveBeenCalled();
|
||||
await manager.stopAll();
|
||||
});
|
||||
|
||||
it('does not probe command existence for unsafe command paths', async () => {
|
||||
const manager = createTrustedManager();
|
||||
spyPrivate(manager, 'checkWorkspaceTrust').mockResolvedValue(true);
|
||||
|
||||
@@ -383,11 +383,13 @@ export class LspServerManager {
|
||||
): Promise<void> {
|
||||
const workspaceTrusted = this.config.isTrustedFolder();
|
||||
if (
|
||||
(this.requireTrustedWorkspace || handle.config.trustRequired) &&
|
||||
!workspaceTrusted
|
||||
((this.requireTrustedWorkspace || handle.config.trustRequired) &&
|
||||
!workspaceTrusted) ||
|
||||
(handle.config.source === 'workspace' &&
|
||||
!this.config.isExplicitlyTrustedFolder())
|
||||
) {
|
||||
debugLogger.warn(
|
||||
`LSP server ${name} requires trusted workspace, skipping startup`,
|
||||
`LSP server ${name} requires trusted workspace, skipping startup. Workspace .lsp.json requires explicit trust: enable security.folderTrust.enabled in user settings and trust this folder, or grant IDE workspace trust.`,
|
||||
);
|
||||
// The admission early-returns precede the `try` that assigns
|
||||
// handle.error, so record the cause here — a bare FAILED state gives
|
||||
|
||||
@@ -127,6 +127,7 @@ describe('NativeLspService disk document synchronization', () => {
|
||||
{
|
||||
getProjectRoot: () => directory,
|
||||
isTrustedFolder: () => true,
|
||||
isExplicitlyTrustedFolder: () => true,
|
||||
} as unknown as Config,
|
||||
{ getDirectories: () => [directory] } as unknown as WorkspaceContext,
|
||||
new EventEmitter(),
|
||||
|
||||
@@ -204,6 +204,10 @@ class MockConfig {
|
||||
return this.trusted;
|
||||
}
|
||||
|
||||
isExplicitlyTrustedFolder(): boolean {
|
||||
return this.trusted;
|
||||
}
|
||||
|
||||
setTrusted(trusted: boolean): void {
|
||||
this.trusted = trusted;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ class MockConfig {
|
||||
return true;
|
||||
}
|
||||
|
||||
isExplicitlyTrustedFolder(): boolean {
|
||||
return this.isTrustedFolder();
|
||||
}
|
||||
|
||||
get(_key: string) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -669,6 +673,34 @@ describe('NativeLspService', () => {
|
||||
expect(internals.lastConnections.has('tsserver')).toBe(false);
|
||||
});
|
||||
|
||||
test('blocks workspace LSP discovery and reload when legacy trust is only implicit', () =>
|
||||
inTempDir('lsp-implicit-trust-', async (dir) => {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.lsp.json'),
|
||||
JSON.stringify({ ts: { command: 'typescript-language-server' } }),
|
||||
);
|
||||
const config = configAt(dir);
|
||||
vi.spyOn(config, 'isExplicitlyTrustedFolder').mockReturnValue(false);
|
||||
const service = new NativeLspService(
|
||||
config as unknown as CoreConfig,
|
||||
new MockWorkspaceContext() as unknown as WorkspaceContext,
|
||||
new EventEmitter(),
|
||||
new MockFileDiscoveryService() as unknown as FileDiscoveryService,
|
||||
new MockIdeContextStore() as unknown as IdeContextStore,
|
||||
{ workspaceRoot: dir, requireTrustedWorkspace: false },
|
||||
);
|
||||
await service.discoverAndPrepare();
|
||||
expect(service.getStatus().size).toBe(0);
|
||||
|
||||
const reconcileServerConfigs = vi.fn(async () => reconcile());
|
||||
internalsOf(service).serverManager = { reconcileServerConfigs };
|
||||
const result = await service.reinitialize();
|
||||
expect(reconcileServerConfigs).toHaveBeenCalledWith([]);
|
||||
expect(result.skipped).toEqual([
|
||||
{ name: 'typescript-language-server', reason: 'server_trust_required' },
|
||||
]);
|
||||
}));
|
||||
|
||||
test('reinitialize skips all user-configured servers in untrusted workspaces', () =>
|
||||
inTempDir('lsp-untrusted-', async (dir) => {
|
||||
const service = untrustedService(dir);
|
||||
|
||||
@@ -545,9 +545,13 @@ export class NativeLspService {
|
||||
const admitted: LspServerConfig[] = [];
|
||||
const skipped: LspSkippedServer[] = [];
|
||||
for (const config of configs) {
|
||||
if (!workspaceTrusted && config.trustRequired) {
|
||||
if (
|
||||
(!workspaceTrusted && config.trustRequired) ||
|
||||
(config.source === 'workspace' &&
|
||||
!this.config.isExplicitlyTrustedFolder())
|
||||
) {
|
||||
debugLogger.warn(
|
||||
`LSP server ${config.name} requires trusted workspace, skipping`,
|
||||
`LSP server ${config.name} requires trusted workspace, skipping. Workspace .lsp.json requires explicit trust: enable security.folderTrust.enabled in user settings and trust this folder, or grant IDE workspace trust.`,
|
||||
);
|
||||
skipped.push({ name: config.name, reason: 'server_trust_required' });
|
||||
continue;
|
||||
|
||||
@@ -441,6 +441,8 @@ export interface LspSocketOptions {
|
||||
* Configuration for an LSP server instance.
|
||||
*/
|
||||
export interface LspServerConfig {
|
||||
/** Set by the loader, never from configuration JSON. */
|
||||
source?: 'workspace' | 'extension';
|
||||
/** Unique name identifier for the server */
|
||||
name: string;
|
||||
/** List of languages this server handles */
|
||||
|
||||
@@ -168,13 +168,15 @@ type PmOpts = Partial<{
|
||||
* tool. Independent of the permission rules (#10075).
|
||||
*/
|
||||
eagerTools: string[];
|
||||
/** Live folder trust; absent reads as trusted. */
|
||||
isTrustedFolder: () => boolean;
|
||||
/** Live explicit folder trust; absent denies repository grants. */
|
||||
isExplicitlyTrustedFolder: () => boolean;
|
||||
}>;
|
||||
|
||||
function makeConfig(opts: PmOpts = {}): PermissionManagerConfig {
|
||||
return {
|
||||
...(opts.isTrustedFolder ? { isTrustedFolder: opts.isTrustedFolder } : {}),
|
||||
...(opts.isExplicitlyTrustedFolder
|
||||
? { isExplicitlyTrustedFolder: opts.isExplicitlyTrustedFolder }
|
||||
: {}),
|
||||
getPermissionsAllow: () => opts.permissionsAllow,
|
||||
getPermissionsAsk: () => opts.permissionsAsk,
|
||||
getPermissionsDeny: () => opts.permissionsDeny,
|
||||
@@ -2680,7 +2682,7 @@ describe('PermissionManager', () => {
|
||||
// so a mid-session revocation applies at the next call and a later
|
||||
// grant restores the rule (the second side of the gate on the way in).
|
||||
let trusted = true;
|
||||
pm = makePm({ isTrustedFolder: () => trusted });
|
||||
pm = makePm({ isExplicitlyTrustedFolder: () => trusted });
|
||||
const call = sh('git commit');
|
||||
pm.addSessionAllowRule('Bash(git *)', { trustGated: true });
|
||||
pm.addSessionAllowRule('Bash(npm *)'); // the user's own grant
|
||||
@@ -2704,7 +2706,7 @@ describe('PermissionManager', () => {
|
||||
// the user's, which no folder trust suspends. A gated re-arrival (skill
|
||||
// reload) stays a skip.
|
||||
let trusted = true;
|
||||
pm = makePm({ isTrustedFolder: () => trusted });
|
||||
pm = makePm({ isExplicitlyTrustedFolder: () => trusted });
|
||||
const call = sh('git commit');
|
||||
pm.addSessionAllowRule('Bash(git *)', { trustGated: true });
|
||||
pm.addSessionAllowRule('Bash(git *)'); // the user-level skill's grant
|
||||
@@ -2717,9 +2719,9 @@ describe('PermissionManager', () => {
|
||||
expect(sessionAllowRules(pm)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a trust-gated rule stays in force when the config reports no trust probe', async () => {
|
||||
it('a trust-gated rule is withheld when the config reports no trust probe', async () => {
|
||||
pm.addSessionAllowRule('Bash(git *)', { trustGated: true });
|
||||
expect(await pm.evaluate(sh('git commit'))).toBe('allow');
|
||||
expect(await pm.evaluate(sh('git commit'))).toBe('ask');
|
||||
});
|
||||
|
||||
it('clearSessionAllowRules drops live and AUTO-stashed session grants', async () => {
|
||||
|
||||
@@ -167,9 +167,9 @@ export interface PermissionManagerConfig {
|
||||
/**
|
||||
* Live folder trust. Read on every permission decision for the session
|
||||
* allow rules a project skill granted (`trustGated`): those apply only
|
||||
* while the folder is trusted. Absent means trusted.
|
||||
* while the folder is explicitly trusted. Absent denies repository grants.
|
||||
*/
|
||||
isTrustedFolder?(): boolean;
|
||||
isExplicitlyTrustedFolder?(): boolean;
|
||||
/**
|
||||
* Returns the current approval mode (plan/default/auto-edit/yolo).
|
||||
* Used by `getDefaultMode()` to determine the fallback when no rule matches.
|
||||
@@ -1409,14 +1409,14 @@ export class PermissionManager {
|
||||
/**
|
||||
* The session allow rules in force right now: every rule the user granted,
|
||||
* plus the repository-granted (`trustGated`) ones only while the folder is
|
||||
* trusted. Trust is re-read on every call — `Config.isTrustedFolder()` is
|
||||
* trusted. Trust is re-read on every call — `Config.isExplicitlyTrustedFolder()` is
|
||||
* live under an IDE connection — so a revocation mid-session suspends a
|
||||
* project skill's grants at the next decision, and a later grant of trust
|
||||
* restores them, the second side of the gate `applySideEffects` enforces
|
||||
* on the way in.
|
||||
*/
|
||||
private activeSessionAllowRules(): PermissionRule[] {
|
||||
const trusted = this.config.isTrustedFolder?.() ?? true;
|
||||
const trusted = this.config.isExplicitlyTrustedFolder?.() ?? false;
|
||||
return trusted
|
||||
? this.sessionRules.allow
|
||||
: this.sessionRules.allow.filter((rule) => !rule.trustGated);
|
||||
|
||||
@@ -2960,7 +2960,9 @@ describe('SubagentManager', () => {
|
||||
|
||||
it('does not register hooks for a project-level subagent in an untrusted folder', async () => {
|
||||
const addAgentHooksSpy = stubHookRegistry();
|
||||
vi.spyOn(mockConfig, 'isTrustedFolder').mockReturnValue(false);
|
||||
vi.spyOn(mockConfig, 'isExplicitlyTrustedFolder').mockReturnValue(
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await createHooked('Bash', { level: 'project' });
|
||||
|
||||
@@ -2972,14 +2974,14 @@ describe('SubagentManager', () => {
|
||||
|
||||
it('registers hooks for a project-level subagent in a trusted folder', async () => {
|
||||
const addAgentHooksSpy = stubHookRegistry();
|
||||
vi.spyOn(mockConfig, 'isTrustedFolder').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isExplicitlyTrustedFolder').mockReturnValue(true);
|
||||
|
||||
const result = await createHooked('Bash', { level: 'project' });
|
||||
|
||||
expect(addAgentHooksSpy).toHaveBeenCalledTimes(1);
|
||||
const registration = addAgentHooksSpy.mock.calls[0][2];
|
||||
expect(registration.isSourceTrusted()).toBe(true);
|
||||
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
|
||||
vi.mocked(mockConfig.isExplicitlyTrustedFolder).mockReturnValue(false);
|
||||
expect(registration.isSourceTrusted()).toBe(false);
|
||||
await result.dispose();
|
||||
});
|
||||
@@ -2991,9 +2993,9 @@ describe('SubagentManager', () => {
|
||||
({ getRegistry: () => ({ addAgentHooks }) }) as unknown as ReturnType<
|
||||
Config['getHookSystem']
|
||||
>;
|
||||
runtime.isTrustedFolder = () => true;
|
||||
runtime.isExplicitlyTrustedFolder = () => true;
|
||||
const sourceTrust = vi
|
||||
.spyOn(mockConfig, 'isTrustedFolder')
|
||||
.spyOn(mockConfig, 'isExplicitlyTrustedFolder')
|
||||
.mockReturnValue(false);
|
||||
const config: SubagentConfig = {
|
||||
...baseConfig,
|
||||
@@ -3006,7 +3008,7 @@ describe('SubagentManager', () => {
|
||||
expect(addAgentHooks).not.toHaveBeenCalled();
|
||||
await untrusted.dispose();
|
||||
sourceTrust.mockReturnValue(true);
|
||||
runtime.isTrustedFolder = () => false;
|
||||
runtime.isExplicitlyTrustedFolder = () => false;
|
||||
const trusted = await manager.createAgentHeadless(config, runtime);
|
||||
expect(addAgentHooks).toHaveBeenCalledOnce();
|
||||
const registration = addAgentHooks.mock.calls[0][2];
|
||||
|
||||
@@ -1208,11 +1208,11 @@ export class SubagentManager {
|
||||
if (config.hooks && Object.keys(config.hooks).length > 0) {
|
||||
const isSourceTrusted =
|
||||
config.level === 'project'
|
||||
? () => this.config.isTrustedFolder()
|
||||
? () => this.config.isExplicitlyTrustedFolder()
|
||||
: undefined;
|
||||
if (isSourceTrusted && !isSourceTrusted()) {
|
||||
debugLogger.warn(
|
||||
`Subagent "${config.name}" is a project agent in an untrusted folder; ignoring its hooks.`,
|
||||
`Subagent "${config.name}" requires explicit folder trust; ignoring its hooks. Enable security.folderTrust.enabled in user settings and trust this folder, or grant IDE workspace trust.`,
|
||||
);
|
||||
} else if (hookRegistry) {
|
||||
unregisterAgentHooks = hookRegistry.addAgentHooks(
|
||||
|
||||
@@ -103,8 +103,8 @@ describe('applySkillAllowedTools', () => {
|
||||
});
|
||||
|
||||
describe('canApplySkillSideEffects', () => {
|
||||
const trusted = { isTrustedFolder: () => true };
|
||||
const untrusted = { isTrustedFolder: () => false };
|
||||
const trusted = { isExplicitlyTrustedFolder: () => true };
|
||||
const untrusted = { isExplicitlyTrustedFolder: () => false };
|
||||
|
||||
it('gates project skills on folder trust', () => {
|
||||
expect(canApplySkillSideEffects({ level: 'project' }, trusted)).toBe(true);
|
||||
@@ -156,7 +156,7 @@ describe('applySkillSideEffects', () => {
|
||||
|
||||
function makeConfig(
|
||||
overrides: Partial<{
|
||||
isTrustedFolder: () => boolean;
|
||||
isExplicitlyTrustedFolder: () => boolean;
|
||||
getHookSystem: () => unknown;
|
||||
getSessionId: () => string | undefined;
|
||||
isWorkspaceAgentSession: () => boolean;
|
||||
@@ -165,7 +165,7 @@ describe('applySkillSideEffects', () => {
|
||||
const { pm, addSessionAllowRule } = mockPermissionManager();
|
||||
const addSessionHook = vi.fn();
|
||||
const config = {
|
||||
isTrustedFolder: () => true,
|
||||
isExplicitlyTrustedFolder: () => true,
|
||||
getPermissionManager: () => pm,
|
||||
getSessionId: () => 'session-1',
|
||||
enableReviewWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -254,7 +254,7 @@ describe('applySkillSideEffects', () => {
|
||||
|
||||
it('applies neither for a project skill in an untrusted folder', async () => {
|
||||
const { config, addSessionAllowRule, addSessionHook } = makeConfig({
|
||||
isTrustedFolder: () => false,
|
||||
isExplicitlyTrustedFolder: () => false,
|
||||
});
|
||||
const projectSkill = {
|
||||
...gatedSkill,
|
||||
@@ -267,7 +267,7 @@ describe('applySkillSideEffects', () => {
|
||||
|
||||
it('warns for a project skill in an untrusted folder that declares only hooks', async () => {
|
||||
const { config, addSessionAllowRule, addSessionHook } = makeConfig({
|
||||
isTrustedFolder: () => false,
|
||||
isExplicitlyTrustedFolder: () => false,
|
||||
});
|
||||
// The sibling test above uses a skill carrying both halves, so it passes
|
||||
// on the `allowedTools` operand alone. This one pins the `|| skill.hooks`
|
||||
@@ -283,7 +283,7 @@ describe('applySkillSideEffects', () => {
|
||||
expect(addSessionAllowRule).not.toHaveBeenCalled();
|
||||
expect(addSessionHook).not.toHaveBeenCalled();
|
||||
expect(debugLoggerSpies.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('untrusted folder'),
|
||||
expect.stringContaining('requires explicit folder trust'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -326,15 +326,15 @@ ${escapeXml(entry.description)}
|
||||
* frontmatter hooks — may be applied. A project skill is discovered from
|
||||
* `<repo>/.qwen/skills/` regardless of folder trust because its body only
|
||||
* influences the model, but its side effects grant tool approvals or run
|
||||
* repo-supplied commands, so they need a trusted folder: the same gate
|
||||
* `Config.getProjectHooks()` applies to settings-file hooks. User, extension
|
||||
* repo-supplied commands, so they need explicit folder trust. Disabling
|
||||
* folder trust does not authorize these side effects. User, extension
|
||||
* and bundled skills are not repo-controlled and are unaffected.
|
||||
*/
|
||||
export function canApplySkillSideEffects(
|
||||
skill: Pick<SkillConfig, 'level'>,
|
||||
config: Pick<Config, 'isTrustedFolder'>,
|
||||
config: Pick<Config, 'isExplicitlyTrustedFolder'>,
|
||||
): boolean {
|
||||
return skill.level !== 'project' || config.isTrustedFolder();
|
||||
return skill.level !== 'project' || config.isExplicitlyTrustedFolder();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,7 +387,7 @@ export function applySkillAllowedTools(
|
||||
* added, so re-invoking a skill never stacks duplicate hooks.
|
||||
*
|
||||
* A project skill's hooks are marked trust-gated by `registerSkillHooks`, so
|
||||
* the event handler re-reads folder trust at fire time. `isTrustedFolder()`
|
||||
* the event handler re-reads folder trust at fire time. `isExplicitlyTrustedFolder()`
|
||||
* reads the IDE context store first and only falls back to the `Config`'s
|
||||
* own readonly field, so with an IDE companion connected that value is live
|
||||
* and revoking trust silences an already-registered gate at the next event,
|
||||
@@ -470,7 +470,9 @@ export async function applySkillSideEffects(
|
||||
| (Pick<Config, 'getHookSystem' | 'getSessionId' | 'getPermissionManager'> &
|
||||
Pick<
|
||||
Config,
|
||||
'isTrustedFolder' | 'enableReviewWorkflow' | 'isWorkspaceAgentSession'
|
||||
| 'isExplicitlyTrustedFolder'
|
||||
| 'enableReviewWorkflow'
|
||||
| 'isWorkspaceAgentSession'
|
||||
>)
|
||||
| null
|
||||
| undefined,
|
||||
@@ -494,7 +496,7 @@ export async function applySkillSideEffects(
|
||||
if (!canApplySkillSideEffects(skill, config)) {
|
||||
if (skill.allowedTools?.length || skill.hooks) {
|
||||
debugLogger.warn(
|
||||
`Skill "${skill.name}" is a project skill in an untrusted folder; ignoring its allowedTools and hooks.`,
|
||||
`Skill "${skill.name}" requires explicit folder trust; ignoring its allowedTools and hooks. Enable security.folderTrust.enabled in user settings and trust this folder, or grant IDE workspace trust.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('SkillTool', () => {
|
||||
enableReviewWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
getAutoSkillEnabled: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
isExplicitlyTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getHookSystem: vi.fn().mockReturnValue(undefined),
|
||||
getSkillManager: vi.fn(),
|
||||
getLlmClient: vi.fn().mockReturnValue(undefined),
|
||||
@@ -678,14 +678,14 @@ describe('SkillTool', () => {
|
||||
}
|
||||
|
||||
it('applies neither allowedTools nor hooks for a project skill in an untrusted folder, but still loads the body', async () => {
|
||||
vi.mocked(config.isTrustedFolder).mockReturnValue(false);
|
||||
vi.mocked(config.isExplicitlyTrustedFolder).mockReturnValue(false);
|
||||
const result = await invoke(repoSkill);
|
||||
expectNoSideEffects();
|
||||
expect(partToString(result.llmContent)).toContain('Repo skill body.');
|
||||
});
|
||||
|
||||
it('applies both for a project skill in a trusted folder — the grants trust-gated', async () => {
|
||||
vi.mocked(config.isTrustedFolder).mockReturnValue(true);
|
||||
vi.mocked(config.isExplicitlyTrustedFolder).mockReturnValue(true);
|
||||
await invoke(repoSkill);
|
||||
// Marked repository-controlled: the permission manager re-checks folder
|
||||
// trust at every decision (a mid-session revocation suspends them), and
|
||||
@@ -701,11 +701,11 @@ describe('SkillTool', () => {
|
||||
});
|
||||
|
||||
it('applies the side effects on re-invocation once trust is granted mid-session', async () => {
|
||||
vi.mocked(config.isTrustedFolder).mockReturnValue(false);
|
||||
vi.mocked(config.isExplicitlyTrustedFolder).mockReturnValue(false);
|
||||
await invoke(repoSkill);
|
||||
expectNoSideEffects();
|
||||
|
||||
vi.mocked(config.isTrustedFolder).mockReturnValue(true);
|
||||
vi.mocked(config.isExplicitlyTrustedFolder).mockReturnValue(true);
|
||||
const result = await invoke(repoSkill);
|
||||
// The dedup guard still answers "already loaded", but the gate is
|
||||
// re-evaluated and the grants are applied now.
|
||||
@@ -715,14 +715,14 @@ describe('SkillTool', () => {
|
||||
});
|
||||
|
||||
it('keeps refusing on re-invocation while the folder stays untrusted', async () => {
|
||||
vi.mocked(config.isTrustedFolder).mockReturnValue(false);
|
||||
vi.mocked(config.isExplicitlyTrustedFolder).mockReturnValue(false);
|
||||
await invoke(repoSkill);
|
||||
await invoke(repoSkill);
|
||||
expectNoSideEffects();
|
||||
});
|
||||
|
||||
it('applies both for a user skill regardless of folder trust', async () => {
|
||||
vi.mocked(config.isTrustedFolder).mockReturnValue(false);
|
||||
vi.mocked(config.isExplicitlyTrustedFolder).mockReturnValue(false);
|
||||
await invoke({
|
||||
...repoSkill,
|
||||
name: 'home-skill',
|
||||
@@ -1345,7 +1345,7 @@ describe('SkillTool', () => {
|
||||
vi.mocked(registerSkillHooks).mockClear();
|
||||
vi.mocked(mockSkillManager.listSkills).mockClear();
|
||||
mockDebugLogger.warn.mockClear();
|
||||
vi.mocked(config.isTrustedFolder).mockReturnValue(true);
|
||||
vi.mocked(config.isExplicitlyTrustedFolder).mockReturnValue(true);
|
||||
useHookSystem();
|
||||
caches([gatedSkill]);
|
||||
});
|
||||
@@ -1423,7 +1423,7 @@ describe('SkillTool', () => {
|
||||
skillRoot: '/project/.qwen/skills/gated-skill',
|
||||
};
|
||||
caches([projectSkill]);
|
||||
vi.mocked(config.isTrustedFolder).mockReturnValue(false);
|
||||
vi.mocked(config.isExplicitlyTrustedFolder).mockReturnValue(false);
|
||||
|
||||
await restore(resumedHistory(projectSkill));
|
||||
|
||||
|
||||
Reference in new issue
Block a user