diff --git a/packages/cli/src/config/daemon-trust-policy.test.ts b/packages/cli/src/config/daemon-trust-policy.test.ts index 0f0a91c26bf..4e46f80effc 100644 --- a/packages/cli/src/config/daemon-trust-policy.test.ts +++ b/packages/cli/src/config/daemon-trust-policy.test.ts @@ -346,6 +346,29 @@ describe('daemon trust policy', () => { }); }); + it('uses the most-specific trust rule for descendant workspaces', async () => { + installFiles({ + '/config/user.json': JSON.stringify({ + security: { folderTrust: { enabled: true } }, + }), + '/config/trusted.json': JSON.stringify({ + '/projects': TrustLevel.TRUST_FOLDER, + '/projects/evil': TrustLevel.DO_NOT_TRUST, + }), + }); + + const snapshot = await readDaemonTrustPolicySnapshot(); + + expect( + evaluateDaemonWorkspaceTrust(snapshot, '/projects/evil/packages/foo'), + ).toMatchObject({ + state: 'untrusted', + targetTrusted: false, + source: 'file', + explicitTrustLevel: TrustLevel.DO_NOT_TRUST, + }); + }); + it('fails closed for malformed trusted folders when file trust is needed', async () => { installFiles({ '/config/user.json': JSON.stringify({ diff --git a/packages/cli/src/config/daemon-trust-policy.ts b/packages/cli/src/config/daemon-trust-policy.ts index 6cc994431b0..9ee7b98fd82 100644 --- a/packages/cli/src/config/daemon-trust-policy.ts +++ b/packages/cli/src/config/daemon-trust-policy.ts @@ -14,6 +14,7 @@ import { getUserSettingsPath, } from './settings.js'; import { + getExplicitTrustLevel, getTrustedFoldersPath, LoadedTrustedFolders, TrustLevel, @@ -331,25 +332,7 @@ function explicitTrustLevel( snapshot: DaemonTrustPolicySnapshot, workspaceCwd: string, ): TrustLevel | null { - const folders = new LoadedTrustedFolders( - { - path: getTrustedFoldersPath(), - config: { ...snapshot.trustedFolders }, - }, - [], - ); - const effective = folders.isPathTrusted(workspaceCwd); - if (effective === undefined) return null; - for (const [rulePath, trustLevel] of Object.entries( - snapshot.trustedFolders, - )) { - const preview = new LoadedTrustedFolders( - { path: getTrustedFoldersPath(), config: { [rulePath]: trustLevel } }, - [], - ).isPathTrusted(workspaceCwd); - if (preview === effective) return trustLevel; - } - return null; + return getExplicitTrustLevel(snapshot.trustedFolders, workspaceCwd); } export function evaluateDaemonWorkspaceTrust( diff --git a/packages/cli/src/config/trust-precedence.test.ts b/packages/cli/src/config/trust-precedence.test.ts new file mode 100644 index 00000000000..03e62a329be --- /dev/null +++ b/packages/cli/src/config/trust-precedence.test.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { getPathComparisonVariants } from './path-comparison.js'; +import { + buildTrustPrecedenceRules, + resolveTrustDecision, + resolveTrustRule, +} from './trust-precedence.js'; + +function decision( + config: Record, + location: string, +): boolean | undefined { + return resolveTrustDecision( + buildTrustPrecedenceRules( + Object.entries(config).map(([path, trustLevel]) => ({ + path, + trustLevel, + })), + ), + getPathComparisonVariants(location), + ); +} + +describe('trust precedence', () => { + it('uses the deepest matching rule in either direction', () => { + const config = { + '/projects': 'TRUST_FOLDER' as const, + '/projects/evil': 'DO_NOT_TRUST' as const, + '/projects/good': 'TRUST_FOLDER' as const, + }; + + expect(decision(config, '/projects/evil/packages/foo')).toBe(false); + expect(decision(config, '/projects/good/src')).toBe(true); + }); + + it('is independent of persisted rule insertion order', () => { + const forward = { + '/projects': 'TRUST_FOLDER' as const, + '/projects/evil': 'DO_NOT_TRUST' as const, + }; + const reversed = { + '/projects/evil': 'DO_NOT_TRUST' as const, + '/projects': 'TRUST_FOLDER' as const, + }; + + expect(decision(forward, '/projects/evil/src')).toBe(false); + expect(decision(reversed, '/projects/evil/src')).toBe(false); + }); + + it('lets distrust win an exact-depth tie', () => { + const winner = resolveTrustRule( + [ + { + level: 'trusted' as const, + variants: getPathComparisonVariants('/projects/evil'), + }, + { + level: 'untrusted' as const, + variants: getPathComparisonVariants('/projects/evil'), + }, + ], + getPathComparisonVariants('/projects/evil/src'), + ); + + expect(winner?.level).toBe('untrusted'); + }); + + it('collapses TRUST_PARENT before applying specificity', () => { + const config = { + '/projects': 'DO_NOT_TRUST' as const, + '/projects/good/marker': 'TRUST_PARENT' as const, + }; + + expect(decision(config, '/projects/good/src')).toBe(true); + expect(decision(config, '/projects/other/src')).toBe(false); + }); + + it('returns undefined when no rule contains the location', () => { + expect( + decision({ '/projects': 'TRUST_FOLDER' }, '/other/project'), + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/config/trust-precedence.ts b/packages/cli/src/config/trust-precedence.ts new file mode 100644 index 00000000000..2f1b52ea41b --- /dev/null +++ b/packages/cli/src/config/trust-precedence.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { getPathComparisonVariants, isWithinRoot } from './path-comparison.js'; + +export type TrustRuleLevel = 'TRUST_FOLDER' | 'TRUST_PARENT' | 'DO_NOT_TRUST'; + +export interface TrustPrecedenceRule { + readonly level: 'trusted' | 'untrusted'; + readonly variants: ReadonlySet; + readonly payload?: TPayload; +} + +function pathDepth(value: string): number { + const root = path.parse(value).root; + const relative = path.relative(root, value); + return relative === '' ? 0 : relative.split(path.sep).filter(Boolean).length; +} + +function matchingDepth( + rule: TrustPrecedenceRule, + locationVariants: ReadonlySet, +): number { + let deepestMatch = -1; + for (const locationVariant of locationVariants) { + for (const ruleVariant of rule.variants) { + if (isWithinRoot(locationVariant, ruleVariant)) { + deepestMatch = Math.max(deepestMatch, pathDepth(ruleVariant)); + } + } + } + return deepestMatch; +} + +/** + * Convert persisted folder-trust rules into the shared precedence shape. + * TRUST_PARENT is resolved to the containing directory before matching. + */ +export function buildTrustPrecedenceRules( + rules: Iterable<{ path: string; trustLevel: T }>, +): Array> { + const result: Array> = []; + for (const rule of rules) { + let level: TrustPrecedenceRule['level']; + let rulePath = rule.path; + switch (rule.trustLevel) { + case 'TRUST_FOLDER': + level = 'trusted'; + break; + case 'TRUST_PARENT': + level = 'trusted'; + rulePath = path.dirname(rule.path); + break; + case 'DO_NOT_TRUST': + level = 'untrusted'; + break; + default: + continue; + } + result.push({ + level, + variants: getPathComparisonVariants(rulePath), + payload: rule.trustLevel, + }); + } + return result; +} + +/** + * Resolve the most-specific rule that contains the requested location. + * An untrusted rule wins when trusted and untrusted rules match at the same + * depth. The result is independent of persisted rule insertion order. + */ +export function resolveTrustRule( + rules: Iterable>, + locationVariants: ReadonlySet, +): TrustPrecedenceRule | undefined { + let winner: TrustPrecedenceRule | undefined; + let winnerDepth = -1; + + for (const rule of rules) { + const depth = matchingDepth(rule, locationVariants); + if (depth < 0) continue; + + if ( + depth > winnerDepth || + (depth === winnerDepth && + rule.level === 'untrusted' && + winner?.level !== 'untrusted') + ) { + winner = rule; + winnerDepth = depth; + } + } + + return winner; +} + +export function resolveTrustDecision( + rules: Iterable>, + locationVariants: ReadonlySet, +): boolean | undefined { + const winner = resolveTrustRule(rules, locationVariants); + return winner?.level === 'trusted' + ? true + : winner?.level === 'untrusted' + ? false + : undefined; +} diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index ac2895694f8..85f2dc61316 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -150,14 +150,24 @@ describe('Trusted Folders Loading', () => { expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true); // No explicit rule covers this file - expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe( - undefined, - ); - expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe( - undefined, - ); + expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(false); + expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(false); expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined); }); + + it('uses the deepest matching rule for both trust directions', () => { + const { folders } = setup({ + config: { + '/projects': TrustLevel.DO_NOT_TRUST, + '/projects/good': TrustLevel.TRUST_FOLDER, + '/projects/good/private': TrustLevel.DO_NOT_TRUST, + }, + }); + + expect(folders.isPathTrusted('/projects/evil/src')).toBe(false); + expect(folders.isPathTrusted('/projects/good/src')).toBe(true); + expect(folders.isPathTrusted('/projects/good/private/src')).toBe(false); + }); }); it('should load user rules if only user file exists', () => { @@ -558,10 +568,10 @@ describe('isWorkspaceTrusted', () => { }); }); - it('should return undefined for a child of an untrusted folder', () => { + it('should return false for a child of an untrusted folder', () => { mockCwd = '/home/user/untrusted/src'; mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined(); + expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(false); }); it('should return undefined when no rules match', () => { @@ -571,12 +581,12 @@ describe('isWorkspaceTrusted', () => { expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined(); }); - it('should prioritize trust over distrust', () => { + it('should prioritize exact distrust over ancestor trust', () => { mockCwd = '/home/user/projectA/untrusted'; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST; expect(isWorkspaceTrusted(mockSettings)).toEqual({ - isTrusted: true, + isTrusted: false, source: 'file', }); }); @@ -704,6 +714,18 @@ describe('getWorkspaceTrustStatus', () => { }); }); + it('reports exact distrust over ancestor trust', () => { + mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; + mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST; + + expect( + getWorkspaceTrustStatus(mockSettings, '/home/user/projectA/untrusted'), + ).toMatchObject({ + effective: { state: 'untrusted', source: 'file' }, + explicitTrustLevel: TrustLevel.DO_NOT_TRUST, + }); + }); + it('does not mutate the cached config when a preview override is passed', () => { mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 57f2ae803bf..828b6b70174 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -21,8 +21,12 @@ import { parseJsoncObject, updateJsoncContent } from '../utils/jsonc-editor.js'; import { arePathsEquivalent, getPathComparisonVariants, - isWithinRoot, } from './path-comparison.js'; +import { + buildTrustPrecedenceRules, + resolveTrustDecision, + resolveTrustRule, +} from './trust-precedence.js'; const debugLogger = createDebugLogger('TRUSTED_FOLDERS'); @@ -115,50 +119,10 @@ export class LoadedTrustedFolders { * @returns */ isPathTrusted(location: string): boolean | undefined { - const trustedPaths: string[] = []; - const untrustedPaths: string[] = []; - - for (const rule of this.rules) { - switch (rule.trustLevel) { - case TrustLevel.TRUST_FOLDER: - trustedPaths.push(rule.path); - break; - case TrustLevel.TRUST_PARENT: - trustedPaths.push(path.dirname(rule.path)); - break; - case TrustLevel.DO_NOT_TRUST: - untrustedPaths.push(rule.path); - break; - default: - // Do nothing for unknown trust levels. - break; - } - } - - const locationVariants = getPathComparisonVariants(location); - for (const trustedPath of trustedPaths) { - for (const locationVariant of locationVariants) { - for (const trustedVariant of getPathComparisonVariants(trustedPath)) { - if (isWithinRoot(locationVariant, trustedVariant)) { - return true; - } - } - } - } - - for (const untrustedPath of untrustedPaths) { - for (const locationVariant of locationVariants) { - for (const untrustedVariant of getPathComparisonVariants( - untrustedPath, - )) { - if (locationVariant === untrustedVariant) { - return false; - } - } - } - } - - return undefined; + return resolveTrustDecision( + buildTrustPrecedenceRules(this.rules), + getPathComparisonVariants(location), + ); } setValue(path: string, trustLevel: TrustLevel): void { @@ -321,44 +285,20 @@ export function isFolderTrustEnabled(settings: Settings): boolean { return folderTrustSetting; } -function isWithinRootAcrossVariants(childPath: string, parentPath: string) { - for (const childVariant of getPathComparisonVariants(childPath)) { - for (const parentVariant of getPathComparisonVariants(parentPath)) { - if (isWithinRoot(childVariant, parentVariant)) { - return true; - } - } - } - return false; -} - -function getExplicitTrustLevel( +export function getExplicitTrustLevel( trustConfig: Record, workspaceCwd: string, ): TrustLevel | null { - for (const [rulePath, trustLevel] of Object.entries(trustConfig)) { - if ( - trustLevel === TrustLevel.TRUST_FOLDER && - isWithinRootAcrossVariants(workspaceCwd, rulePath) - ) { - return trustLevel; - } - if ( - trustLevel === TrustLevel.TRUST_PARENT && - isWithinRootAcrossVariants(workspaceCwd, path.dirname(rulePath)) - ) { - return trustLevel; - } - } - for (const [rulePath, trustLevel] of Object.entries(trustConfig)) { - if ( - trustLevel === TrustLevel.DO_NOT_TRUST && - arePathsEquivalent(workspaceCwd, rulePath) - ) { - return trustLevel; - } - } - return null; + const winner = resolveTrustRule( + buildTrustPrecedenceRules( + Object.entries(trustConfig).map(([rulePath, trustLevel]) => ({ + path: rulePath, + trustLevel, + })), + ), + getPathComparisonVariants(workspaceCwd), + ); + return winner?.payload ?? null; } function loadTrustedFoldersWithOverrides( diff --git a/packages/cli/src/serve/fast-path-settings.ts b/packages/cli/src/serve/fast-path-settings.ts index d6ec03003dc..ff50f0f39a5 100644 --- a/packages/cli/src/serve/fast-path-settings.ts +++ b/packages/cli/src/serve/fast-path-settings.ts @@ -21,10 +21,12 @@ import { getSystemSettingsPath, SETTINGS_DIRECTORY_NAME, } from '../config/storage-paths-lite.js'; +import { getPathComparisonVariants } from '../config/path-comparison.js'; import { - getPathComparisonVariants, - isWithinRoot, -} from '../config/path-comparison.js'; + buildTrustPrecedenceRules, + resolveTrustDecision, + type TrustPrecedenceRule, +} from '../config/trust-precedence.js'; import { publishPendingCompileCache } from '../config/compile-cache.js'; import type { Settings } from '../config/settingsSchema.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; @@ -44,13 +46,7 @@ export type ServeFastPathSettings = Pick< policy?: ServeFastPathPolicyInput; }; const V2_SETTINGS_VERSION = 2; -const TRUST_FOLDER = 'TRUST_FOLDER'; -const TRUST_PARENT = 'TRUST_PARENT'; -const DO_NOT_TRUST = 'DO_NOT_TRUST'; -type CachedTrustRule = { - level: 'trusted' | 'untrusted'; - variants: Set; -}; +type CachedTrustRule = TrustPrecedenceRule; let homeEnvBootstrapped = false; let cachedTrustedFoldersPath: string | undefined; let cachedTrustedFolderRules: CachedTrustRule[] | undefined; @@ -339,54 +335,17 @@ function readTrustedFolderRulesFastPath(): readonly CachedTrustRule[] { function buildTrustedFolderRules( trustedFolders: Record, ): CachedTrustRule[] { - const rules: CachedTrustRule[] = []; - for (const [rulePath, trustLevel] of Object.entries(trustedFolders)) { - if (trustLevel === TRUST_FOLDER) { - rules.push({ - level: 'trusted', - variants: getPathComparisonVariants(rulePath), - }); - } else if (trustLevel === TRUST_PARENT) { - rules.push({ - level: 'trusted', - variants: getPathComparisonVariants(path.dirname(rulePath)), - }); - } else if (trustLevel === DO_NOT_TRUST) { - rules.push({ - level: 'untrusted', - variants: getPathComparisonVariants(rulePath), - }); - } - } - return rules; + return buildTrustPrecedenceRules( + Object.entries(trustedFolders).map(([rulePath, trustLevel]) => ({ + path: rulePath, + trustLevel, + })), + ); } function isPathTrustedFastPath(location: string): boolean | undefined { const rules = readTrustedFolderRulesFastPath(); - const locationVariants = getPathComparisonVariants(location); - for (const rule of rules) { - if (rule.level !== 'trusted') continue; - for (const locationVariant of locationVariants) { - for (const trustedVariant of rule.variants) { - if (isWithinRoot(locationVariant, trustedVariant)) { - return true; - } - } - } - } - - for (const rule of rules) { - if (rule.level !== 'untrusted') continue; - for (const locationVariant of locationVariants) { - for (const untrustedVariant of rule.variants) { - if (locationVariant === untrustedVariant) { - return false; - } - } - } - } - - return undefined; + return resolveTrustDecision(rules, getPathComparisonVariants(location)); } function isWorkspaceTrustedFastPath( diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index 75dbb6b7621..1a5da4e26a4 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -1706,7 +1706,7 @@ describe('serve fast path environment bootstrap', () => { expect(process.env['QWEN_SERVER_TOKEN']).toBe('trusted'); }); - it('prioritizes trusted parent folders over nested distrust rules', async () => { + it('does not load env from an explicitly untrusted nested workspace', async () => { delete process.env['QWEN_SERVER_TOKEN']; const qwenHome = useTempQwenHome(); tempWorkspace = realpathSync( @@ -1733,7 +1733,75 @@ describe('serve fast path environment bootstrap', () => { await bootstrapServeFastPathEnvironment(childWorkspace); - expect(process.env['QWEN_SERVER_TOKEN']).toBe('trusted'); + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + }); + + it('does not load env from a descendant of an explicitly untrusted workspace', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-trust-descendant-')), + ); + const childWorkspace = join(tempWorkspace, 'evil-repo'); + const subDir = join(childWorkspace, 'packages', 'foo'); + mkdirSync(subDir, { recursive: true }); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ security: { folderTrust: { enabled: true } } }), + ); + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = join( + qwenHome, + 'trustedFolders.json', + ); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ + [tempWorkspace]: TrustLevel.TRUST_FOLDER, + [childWorkspace]: TrustLevel.DO_NOT_TRUST, + }), + ); + writeFileSync( + join(childWorkspace, '.env'), + 'QWEN_SERVER_TOKEN=from-untrusted-descendant-env\n', + ); + + await bootstrapServeFastPathEnvironment(subDir); + + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + }); + + it('allows a trusted child rule to override an untrusted parent', async () => { + delete process.env['QWEN_SERVER_TOKEN']; + const qwenHome = useTempQwenHome(); + tempWorkspace = realpathSync( + mkdtempSync(join(os.tmpdir(), 'qws-fast-path-trust-opt-in-')), + ); + const trustedWorkspace = join(tempWorkspace, 'good-repo'); + const subDir = join(trustedWorkspace, 'src'); + mkdirSync(subDir, { recursive: true }); + writeFileSync( + join(qwenHome, 'settings.json'), + JSON.stringify({ security: { folderTrust: { enabled: true } } }), + ); + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = join( + qwenHome, + 'trustedFolders.json', + ); + writeFileSync( + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'], + JSON.stringify({ + [tempWorkspace]: TrustLevel.DO_NOT_TRUST, + [trustedWorkspace]: TrustLevel.TRUST_FOLDER, + }), + ); + writeFileSync( + join(trustedWorkspace, '.env'), + 'QWEN_SERVER_TOKEN=from-trusted-child-env\n', + ); + + await bootstrapServeFastPathEnvironment(subDir); + + expect(process.env['QWEN_SERVER_TOKEN']).toBe('from-trusted-child-env'); }); it('treats TRUST_PARENT as trusting the containing folder', async () => { diff --git a/packages/cli/src/ui/components/TrustDialog.test.tsx b/packages/cli/src/ui/components/TrustDialog.test.tsx index cdf2e8bc182..9a5a1f02077 100644 --- a/packages/cli/src/ui/components/TrustDialog.test.tsx +++ b/packages/cli/src/ui/components/TrustDialog.test.tsx @@ -93,7 +93,7 @@ describe('TrustDialog', () => { await waitFor(() => { expect(lastFrame()).toContain( - 'Note: This folder behaves as a trusted folder because one of the parent folders is trusted.', + 'currently inherits trust from a parent folder', ); }); }); diff --git a/packages/cli/src/ui/components/TrustDialog.tsx b/packages/cli/src/ui/components/TrustDialog.tsx index 64850859687..4c59dd4e079 100644 --- a/packages/cli/src/ui/components/TrustDialog.tsx +++ b/packages/cli/src/ui/components/TrustDialog.tsx @@ -88,10 +88,8 @@ export function TrustDialog({ {isInheritedTrustFromParent && ( - Note: This folder behaves as a trusted folder because one of the - parent folders is trusted. It will remain trusted even if you set - a different trust level here. To change this, you need to modify - the trust setting in the parent folder. + Note: This folder currently inherits trust from a parent folder. A + more-specific trust rule here can override that decision. )} {isInheritedTrustFromIde && (