Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
23 changes: 23 additions & 0 deletions packages/cli/src/config/daemon-trust-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 2 additions & 19 deletions packages/cli/src/config/daemon-trust-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getUserSettingsPath,
} from './settings.js';
import {
getExplicitTrustLevel,
getTrustedFoldersPath,
LoadedTrustedFolders,
TrustLevel,
Expand Down Expand Up @@ -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(
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/src/config/trust-precedence.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, 'TRUST_FOLDER' | 'TRUST_PARENT' | 'DO_NOT_TRUST'>,
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();
});
});
113 changes: 113 additions & 0 deletions packages/cli/src/config/trust-precedence.ts
Original file line number Diff line number Diff line change
@@ -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<TPayload = undefined> {
readonly level: 'trusted' | 'untrusted';
readonly variants: ReadonlySet<string>;
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<TPayload>(
rule: TrustPrecedenceRule<TPayload>,
locationVariants: ReadonlySet<string>,
): 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<T extends string>(
rules: Iterable<{ path: string; trustLevel: T }>,
): Array<TrustPrecedenceRule<T>> {
const result: Array<TrustPrecedenceRule<T>> = [];
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<TPayload>(
rules: Iterable<TrustPrecedenceRule<TPayload>>,
locationVariants: ReadonlySet<string>,
): TrustPrecedenceRule<TPayload> | undefined {
let winner: TrustPrecedenceRule<TPayload> | 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<TPayload>(
rules: Iterable<TrustPrecedenceRule<TPayload>>,
locationVariants: ReadonlySet<string>,
): boolean | undefined {
const winner = resolveTrustRule<TPayload>(rules, locationVariants);
return winner?.level === 'trusted'
? true
: winner?.level === 'untrusted'
? false
: undefined;
}
42 changes: 32 additions & 10 deletions packages/cli/src/config/trustedFolders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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',
});
});
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading