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
5 changes: 3 additions & 2 deletions src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,8 @@ export function mergeConfigFiles(
// A cloned repo's .moss/config.json must not silently lower the user's
// safety stance (e.g. approvalPolicy: 'never', safetyMode: 'full-access').
// If the user hasn't set a scalar, the project value is still used.
// trustedTools is a grant list, so a trusted project unions with the user.
// trustedTools follows that rule: the user's array wins exactly. A trusted
// project's list is used only when the user did not set one.
// loadCliConfigFile removes an untrusted project's trustedTools,
// permissions.allow, permissions.defaultMode, permissions.deviceTrust,
// permissions.trustedDevices, and a looser profile before this merge, so
Expand All @@ -956,7 +957,7 @@ export function mergeConfigFiles(
// override both (resolveCliConfig).
safetyMode: userConfig.safetyMode ?? projectConfig.safetyMode,
approvalPolicy: userConfig.approvalPolicy ?? projectConfig.approvalPolicy,
trustedTools: unionOptionalStringList(userConfig.trustedTools, projectConfig.trustedTools),
trustedTools: userConfig.trustedTools ?? projectConfig.trustedTools,
deniedTools: userConfig.deniedTools ?? projectConfig.deniedTools,
permissions: mergePermissionsConfig(userConfig.permissions, projectConfig.permissions),
promptCache: mergePromptCacheConfig(userConfig.promptCache, projectConfig.promptCache),
Expand Down
14 changes: 10 additions & 4 deletions test/cli-config-merge.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* over user for ALL top-level fields, so a project could silently lower the
* user's safety stance (approvalPolicy: 'never', safetyMode: 'full-access',
* widening trustedTools when the user had set a narrower list). Scalars stay
* user-priority. trustedTools is a grant list and unions both layers;
* user-priority. trustedTools is user-priority too: the user's array wins
* exactly, and a project list applies only when the user did not set one.
* loadCliConfigFile drops an untrusted project's trustedTools before merge.
* Non-safety fields keep project-priority.
*/
Expand All @@ -23,15 +24,20 @@ import { mergeConfigFiles } from '../dist/cli/config.js';
assert.equal(merged.approvalPolicy, 'prompt', 'user approvalPolicy wins over project');
}

// ─── 2. trustedTools unions both layers (untrusted projects are stripped first)
// ─── 2. trustedTools: the user's list wins; project only when user unset ──
{
const user = { trustedTools: ['safe_tool'] };
const project = { trustedTools: ['safe_tool', 'dangerous_tool'] };
const merged = mergeConfigFiles(project, user);
assert.deepEqual(
merged.trustedTools,
['safe_tool', 'dangerous_tool'],
'trustedTools unions user and project; an untrusted project list is removed before merge'
['safe_tool'],
'user trustedTools wins exactly; a project list does not widen it'
);
assert.deepEqual(
mergeConfigFiles({ trustedTools: ['exec'] }, {}).trustedTools,
['exec'],
'project trustedTools used when the user did not set the list'
);
}

Expand Down
6 changes: 3 additions & 3 deletions test/cli-config-untrusted-permissions.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,12 @@ function noticeLine(stderr, field) {
assert.equal(trusted.droppedProjectPermissions, undefined);
assert.equal(trusted.config.permissions.defaultMode, 'manual');
assert.deepEqual(trusted.config.permissions.allow, ['exec']);
assert.deepEqual(trusted.config.trustedTools, ['safe_tool', 'exec']);
// O55: the user's trustedTools array wins exactly. Project allow still unions.
assert.deepEqual(trusted.config.trustedTools, ['safe_tool']);
assert.deepEqual(trusted.config.permissions.deny, ['read_file', 'exec']);
const resolvedTrusted = resolveCliConfig(env, trusted.config, {}, trusted);
assert.ok(resolvedTrusted.permissions.allow.includes('exec'));
assert.ok(resolvedTrusted.trustedTools.includes('safe_tool'));
assert.ok(resolvedTrusted.trustedTools.includes('exec'));
assert.deepEqual(resolvedTrusted.trustedTools, ['safe_tool']);
}

{
Expand Down
76 changes: 76 additions & 0 deletions test/o55-user-trustedtools.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* O55: a user's trustedTools array wins exactly over a TRUSTED project's list.
* The project list applies only when the user did not set trustedTools.
* An untrusted project's trustedTools stays dropped.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { isolatedCliEnv } from './helpers/isolated-cli-env.mjs';
import { loadCliConfigFile, resolveCliConfig } from '../dist/cli/config.js';

function put(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value));
}

function setup(project, { trust = false, user = {} } = {}) {
const env = isolatedCliEnv({ prefix: 'moss-o55-' });
for (const key of Object.keys(env)) {
if (/^MOSS_(SAFETY|APPROVAL|ASK_FOR|TRUST|AUTO_APPROVE|CLI_|DEVICE)/.test(key)) {
delete env[key];
}
}
delete env.MOSS_TRUSTED_TOOLS;
delete env.MOSS_PROFILE;
delete env.MOSS_CONFIG_PROFILE;
const ws = path.join(env.HOME, 'ws');
put(path.join(env.MOSS_CONFIG_DIR, 'config.json'), user);
put(path.join(ws, '.moss', 'config.json'), project);
const loaded = loadCliConfigFile(env, [], ws, { trustProjectRouting: trust });
const resolved = resolveCliConfig(env, loaded.config, {}, loaded);
return { loaded, resolved };
}

const failures = [];
function check(label, fn) {
try {
fn();
console.log(`[PASS] ${label}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failures.push(label);
console.log(`[FAIL] ${label}: ${message}`);
}
}

check('user [read_file] wins over trusted project [read_file, exec]', () => {
const { loaded, resolved } = setup(
{ trustedTools: ['read_file', 'exec'] },
{ trust: true, user: { trustedTools: ['read_file'] } }
);
assert.deepEqual(loaded.config.trustedTools, ['read_file']);
assert.deepEqual(resolved.trustedTools, ['read_file']);
assert.ok(!resolved.trustedTools.includes('exec'));
});

check('user unset + trusted project [exec] resolves to [exec]', () => {
const { loaded, resolved } = setup({ trustedTools: ['exec'] }, { trust: true });
assert.deepEqual(loaded.config.trustedTools, ['exec']);
assert.deepEqual(resolved.trustedTools, ['exec']);
assert.equal(loaded.droppedProjectPermissions, undefined);
});

check('untrusted project [exec] with user unset does not grant exec', () => {
const { loaded, resolved } = setup({ trustedTools: ['exec'] });
assert.deepEqual(loaded.droppedProjectPermissions, ['trustedTools']);
assert.equal(loaded.config.trustedTools, undefined);
assert.ok(!resolved.trustedTools.includes('exec'));
});

if (failures.length > 0) {
console.log(`${failures.length} failing`);
process.exit(1);
}
console.log('[PASS] o55-user-trustedtools');
Loading