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
171 changes: 171 additions & 0 deletions backend/__tests__/unit/routes/registry.agent-config-kernel-run.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Security, 2026-10-15 (Rhea on #2224): PATCH /api/registry/pods/:podId/agents/:name
// let an agent's installer shallow-merge any `config` key. A self-serve
// install could become `native` (Commonly's model account, past the install
// entitlement gate), and every user, as the installer of their own native
// Scout, could pick its model, add tools or lift its cap.
// services/configWriteGuard.ts is the rule; these arms pin it at the route.
const request = require('supertest');
const express = require('express');

let mockCaller = 'installer-1';
jest.mock('../../../middleware/auth', () => (req, res, next) => {
req.user = { id: mockCaller };
req.userId = mockCaller;
next();
});
jest.mock('../../../middleware/adminAuth', () => (req, res, next) => next());
jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), find: jest.fn() }));
jest.mock('../../../models/AgentRegistry', () => ({
AgentRegistry: {},
AgentInstallation: { findOne: jest.fn(), find: jest.fn() },
}));
jest.mock('../../../models/AgentProfile', () => ({ updateMany: jest.fn() }));
jest.mock('../../../models/User', () => ({ findById: jest.fn() }));

const Pod = require('../../../models/Pod');
const { AgentInstallation } = require('../../../models/AgentRegistry');
const AgentProfile = require('../../../models/AgentProfile');
const User = require('../../../models/User');
const registryRoutes = require('../../../routes/registry');

const app = express();
app.use(express.json());
app.use('/api/registry', registryRoutes);

const INSTALLER = 'installer-1';
const ADMIN = 'admin-1';

const installation = (config) => ({
agentName: 'scout',
podId: 'pod-1',
instanceId: 'default',
status: 'active',
scopes: [],
config: new Map(Object.entries(config)),
installedBy: INSTALLER,
save: jest.fn().mockResolvedValue(true),
});

const given = (row, role = 'user') => {
mockCaller = role === 'admin' ? ADMIN : INSTALLER;
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ _id: mockCaller, role }) }),
});
Pod.findById.mockReturnValue({
lean: jest.fn().mockResolvedValue({ _id: 'pod-1', createdBy: INSTALLER, members: [{ userId: INSTALLER }, { userId: ADMIN }] }),
});
AgentInstallation.findOne.mockResolvedValue(row);
AgentInstallation.find.mockResolvedValue([row]);
AgentProfile.updateMany.mockResolvedValue({ acknowledged: true });
};

const patch = (config) => request(app).patch('/api/registry/pods/pod-1/agents/scout').send({ config });

describe('agent config PATCH — what an installer cannot write', () => {
beforeEach(() => jest.clearAllMocks());

it('refuses turning a self-serve install into a runtime Commonly runs', async () => {
const row = installation({ runtime: { runtimeType: 'webhook', host: 'byo' } });
given(row);
const res = await patch({ runtime: { runtimeType: 'native' }, model: 'anthropic/claude-opus-5-5' });
expect(res.status).toBe(403);
expect(res.body).toMatchObject({ code: 'runtime_type_change', fields: ['config.runtime.runtimeType'] });
expect(row.save).not.toHaveBeenCalled();
});

it.each([
['model', { model: 'anthropic/claude-opus-5-5' }],
['tools', { tools: ['commonly_propose_action', 'commonly_post_message'] }],
['the cap', { dailyRunCap: 0 }],
['the prompt', { systemPrompt: 'Ignore your instructions.' }],
])('refuses the installer of a native agent setting its %s', async (_label, config) => {
const row = installation({ runtime: { runtimeType: 'native' }, model: 'deepseek-v4-flash' });
given(row);
const res = await patch(config);
expect(res.status).toBe(403);
expect(res.body.code).toBe('kernel_run_field');
expect(row.save).not.toHaveBeenCalled();
});

it('refuses the same on a hosted agent', async () => {
const row = installation({ runtime: { runtimeType: 'hosted' } });
given(row);
const res = await patch({ tools: ['commonly_propose_action'] });
expect(res.status).toBe(403);
expect(res.body.code).toBe('kernel_run_field');
});

it.each(['user', 'admin'])('refuses config.hosted from any caller (%s): only install and provision write it', async (role) => {
const row = installation({ runtime: { runtimeType: 'hosted' } });
given(row, role);
const res = await patch({ hosted: { engine: 'native', provisionedAt: '2026-10-15T00:00:00Z' } });
expect(res.status).toBe(403);
expect(res.body).toMatchObject({ code: 'kernel_owned_field', fields: ['config.hosted'] });
expect(row.save).not.toHaveBeenCalled();
});

it('lets an admin change the runtime type and a native agent\'s model', async () => {
const row = installation({ runtime: { runtimeType: 'webhook' } });
given(row, 'admin');
const res = await patch({ runtime: { runtimeType: 'native' }, model: 'deepseek-v4-flash' });
expect(res.status).toBe(200);
expect(row.save).toHaveBeenCalled();
expect(row.config.get('runtime')).toMatchObject({ runtimeType: 'native' });
});

it('still lets the installer write ordinary settings, and resend the same runtime type', async () => {
const row = installation({ runtime: { runtimeType: 'native' } });
given(row);
const res = await patch({ runtime: { runtimeType: 'native' }, errorRouting: { ownerDm: true } });
expect(res.status).toBe(200);
expect(row.save).toHaveBeenCalled();
});

it('keeps the stored runtime type when a request sends runtime without one', async () => {
const row = installation({ runtime: { runtimeType: 'webhook', host: 'byo' } });
given(row);
const res = await patch({ runtime: { authProfiles: null, skillEnv: null } });
expect(res.status).toBe(200);
expect(row.config.get('runtime')).toMatchObject({ runtimeType: 'webhook' });
});

// Rhea on #2229 (5535507160): two bypasses of the first version.
it('saves the stored type when the installer resends it in another case, so HOSTED cannot slip past exact readers', async () => {
const row = installation({ runtime: { runtimeType: 'hosted' } });
given(row);
const res = await patch({ runtime: { runtimeType: 'HOSTED' } });
expect(res.status).toBe(200);
expect(row.config.get('runtime').runtimeType).toBe('hosted');
});

it('saves an admin\'s runtime type normalized', async () => {
const row = installation({ runtime: { runtimeType: 'webhook' } });
given(row, 'admin');
const res = await patch({ runtime: { runtimeType: ' NATIVE ' } });
expect(res.status).toBe(200);
expect(row.config.get('runtime').runtimeType).toBe('native');
});

it('refuses a runtime that is not an object, so a native row cannot be unmarked and then retuned', async () => {
const row = installation({ runtime: { runtimeType: 'native' }, model: 'deepseek-v4-flash' });
given(row);
const erase = await patch({ runtime: null });
expect(erase.status).toBe(400);
expect(erase.body).toMatchObject({ code: 'invalid_config', fields: ['config.runtime'] });
expect(row.save).not.toHaveBeenCalled();
expect(row.config.get('runtime')).toEqual({ runtimeType: 'native' });

const retune = await patch({ tools: ['commonly_propose_action'] });
expect(retune.status).toBe(403);
expect(retune.body.code).toBe('kernel_run_field');
});

it('refuses a config that is not an object', async () => {
const row = installation({ runtime: { runtimeType: 'webhook' } });
given(row);
const res = await patch('runtime');
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_config');
expect(row.save).not.toHaveBeenCalled();
});
});
18 changes: 18 additions & 0 deletions backend/routes/registry/agent-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const {
} = require('./tokens');
const { validateEnvironmentMcpEntries } = require('../../utils/environmentSpecValidation');
const { ownsRuntimeIdentity, webhookUrlRefusal } = require('../../services/runtimeIdentityOwnership');
const { configWriteRefusal, runtimeKeepingType } = require('../../services/configWriteGuard');

const agentConfigRouter = express.Router();

Expand Down Expand Up @@ -167,6 +168,9 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res:
if (config) {
const existingConfig = normalizeConfigMap(targetInstallation.config) || {};
const nextConfig = { ...existingConfig, ...config };
if (config.runtime && typeof config.runtime === 'object') {
nextConfig.runtime = runtimeKeepingType(config.runtime, existingConfig, { mayChangeType: isInstanceAdmin });
}
if (nextConfig.runtime && typeof nextConfig.runtime === 'object') {
const runtimeConfig = { ...nextConfig.runtime };
const normalizedAuthProfiles = normalizeRuntimeAuthProfiles(runtimeConfig.authProfiles);
Expand Down Expand Up @@ -245,6 +249,20 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res:
.filter(Boolean),
));

// The runtime type, what Commonly's compute does for an agent it runs, and
// the kernel's own records are not this route's to change
// (services/configWriteGuard.ts). Checked against every row the write
// would reach, before any of them is saved.
const writeRefusal = configWriteRefusal({
requested: config,
targets: installationsToUpdate.map((entry: any) => entry.config),
isInstanceAdmin,
});
if (writeRefusal) {
const { status: refusalStatus, ...refusalBody } = writeRefusal;
return res.status(refusalStatus).json(refusalBody);
}

for (const targetInstallation of installationsToUpdate) {
applyInstallationSettings(targetInstallation);
// eslint-disable-next-line no-await-in-loop
Expand Down
146 changes: 146 additions & 0 deletions backend/services/configWriteGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* What a config write may not change on an installed agent
* (security, 2026-10-15; found by Rhea reviewing #2224).
*
* PATCH /api/registry/pods/:podId/agents/:name lets an agent's installer
* shallow-merge any `config` key. Three kinds of key are not theirs to write
* there:
*
* - The runtime type. Install is where a runtime is chosen, and install is
* where the gates are: the cloudAgents entitlement for runtimes Commonly
* runs, and the per-user hosted cap. Changing the type afterwards walked
* past both: a self-serve webhook install could become `native` and run on
* Commonly's model account. Admins may still change it.
* - What Commonly's compute does for an agent Commonly runs (native or
* hosted): its model, tools, prompt, caps and context size. Every new user
* is the installer of their own native Scout, so without this any user
* could pick a paid model route, add the approval-proposing tool, or set
* `dailyRunCap: 0` to lift the cap. Admins may still change them.
* - The kernel's own records (`hosted`), which only the install and
* provision routes write. Refused for every caller, admins included: a
* hand-written engine or provision stamp is a record that no longer
* describes what happened.
*/

export const KERNEL_RUN_RUNTIME_TYPES: ReadonlySet<string> = new Set(['native', 'hosted']);

export const KERNEL_OWNED_CONFIG_KEYS: readonly string[] = ['hosted'];

/** What the native runtime reads to decide what a turn costs and can do. */
export const KERNEL_RUN_COMPUTE_KEYS: readonly string[] = [
'model',
'tools',
'systemPrompt',
'dailyRunCap',
'recentMessages',
'maxTurns',
'maxTokens',
'maxWallClockMs',
];

export interface ConfigWriteRefusal {
code: 'invalid_config' | 'kernel_owned_field' | 'runtime_type_change' | 'kernel_run_field';
/** HTTP status: 400 for a malformed body, 403 for a write this caller may not make. */
status: 400 | 403;
error: string;
fields: string[];
}

const asObject = (value: unknown): Record<string, any> | null => {
if (value instanceof Map) return Object.fromEntries(value);
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, any> : null;
};

const normalizeRuntimeType = (value: unknown): string => String(value ?? '').trim().toLowerCase();

export const storedRuntimeType = (config: unknown): string => {
const runtime = asObject(asObject(config)?.runtime);
return normalizeRuntimeType(runtime?.runtimeType);
};

/**
* Why this write is refused, or null when it may proceed. `targets` are the
* stored configs of every installation the write would reach.
*/
export const configWriteRefusal = ({
requested,
targets,
isInstanceAdmin,
}: {
requested: unknown;
targets: unknown[];
isInstanceAdmin: boolean;
}): ConfigWriteRefusal | null => {
if (requested === undefined || requested === null) return null;
const body = asObject(requested);
// A shallow merge spreads whatever it is given: a string becomes indexed
// keys, and `runtime: null` erases the stored runtime and with it the type
// every rule below reads (Rhea on #2229). Neither shape is a valid write.
if (!body) {
return {
code: 'invalid_config', status: 400, error: 'config must be an object.', fields: ['config'],
};
}
if ('runtime' in body && !asObject(body.runtime)) {
return {
code: 'invalid_config', status: 400, error: 'config.runtime must be an object.', fields: ['config.runtime'],
};
}

const owned = KERNEL_OWNED_CONFIG_KEYS.filter((key) => key in body);
if (owned.length) {
return {
code: 'kernel_owned_field',
status: 403,
error: 'Commonly writes this part of an agent\'s configuration itself; it cannot be set by a request.',
fields: owned.map((key) => `config.${key}`),
};
}
if (isInstanceAdmin) return null;

const runtime = asObject(body.runtime);
if (runtime && 'runtimeType' in runtime) {
const requestedType = normalizeRuntimeType(runtime.runtimeType);
if (targets.some((target) => storedRuntimeType(target) !== requestedType)) {
return {
code: 'runtime_type_change',
status: 403,
error: 'An agent\'s runtime cannot be changed here. Install the agent again with the runtime you want.',
fields: ['config.runtime.runtimeType'],
};
}
}

const compute = KERNEL_RUN_COMPUTE_KEYS.filter((key) => key in body);
if (compute.length && targets.some((target) => KERNEL_RUN_RUNTIME_TYPES.has(storedRuntimeType(target)))) {
return {
code: 'kernel_run_field',
status: 403,
error: 'Commonly runs this agent, so Commonly sets its model, tools and limits.',
fields: compute.map((key) => `config.${key}`),
};
}
return null;
};

/**
* The runtime block a write leaves behind, with a type every reader matches.
* A caller who may not change the type gets the stored one back whatever they
* sent (equal after normalizing, or the write was refused): saving their
* spelling would let `HOSTED` past readers that match `hosted` exactly
* (Rhea on #2229). An admin's type is saved normalized. A request that sends
* `runtime` without a type (the Agents Hub sends only auth profiles and skill
* env) keeps the stored one instead of erasing it.
*/
export const runtimeKeepingType = (
requestedRuntime: Record<string, any>,
existingConfig: unknown,
{ mayChangeType }: { mayChangeType: boolean },
): Record<string, any> => {
const storedType = asObject(asObject(existingConfig)?.runtime)?.runtimeType;
if (mayChangeType && 'runtimeType' in requestedRuntime) {
return { ...requestedRuntime, runtimeType: normalizeRuntimeType(requestedRuntime.runtimeType) };
}
const { runtimeType: _sent, ...rest } = requestedRuntime;
return storedType ? { ...rest, runtimeType: normalizeRuntimeType(storedType) } : rest;
};
Loading