diff --git a/backend/__tests__/unit/cap/session-token.test.js b/backend/__tests__/unit/cap/session-token.test.js index e9285c9857..928d523166 100644 --- a/backend/__tests__/unit/cap/session-token.test.js +++ b/backend/__tests__/unit/cap/session-token.test.js @@ -19,6 +19,11 @@ const { MongoMemoryServer } = require('mongodb-memory-server'); const { MONGO_BINARY_VERSION, MONGOMS_DOWNLOAD_DIR } = require('../../utils/mongoBinaryConfig'); const jwt = require('jsonwebtoken'); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); +const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); + // Models const User = require('../../../models/User'); const Pod = require('../../../models/Pod'); @@ -93,6 +98,7 @@ afterAll(async () => { }); afterEach(async () => { + podHasOutsiderWritableGrant.mockResolvedValue(false); // Clean up agent users and installations; restore pod to original membership await User.deleteMany({ isBot: true }); await AgentInstallation.deleteMany({}); @@ -102,6 +108,23 @@ afterEach(async () => { // ── tests ───────────────────────────────────────────────────────────────────── describe('POST /api/registry/admin/agents/claude-code/session-token', () => { + test('refuses an admin seat attach in a protected pod before any identity, membership, install, or token write', async () => { + podHasOutsiderWritableGrant.mockResolvedValueOnce(true); + const before = await Pod.findById(testPod._id).lean(); + const res = await request(app) + .post('/api/registry/admin/agents/claude-code/session-token') + .send({ podId: testPod._id.toString(), instanceId: 'protected-pod' }); + + expect(res.status).toBe(403); + expect(res.body).toEqual(expect.objectContaining({ + code: 'outsider_grant_non_native_agent_join', + message: expect.stringContaining('Use a separate pod'), + })); + expect((await Pod.findById(testPod._id)).members.map(String)).toEqual(before.members.map(String)); + expect(await User.findOne({ isBot: true, botType: 'agent' })).toBeNull(); + expect(await AgentInstallation.findOne({ podId: testPod._id, instanceId: 'protected-pod' })).toBeNull(); + }); + test('returns 200 with token starting cm_agent_ and expiresAt ~24h from now', async () => { const before = Date.now(); const res = await request(app) diff --git a/backend/__tests__/unit/controllers/podController.test.js b/backend/__tests__/unit/controllers/podController.test.js index 953fb5e0b2..4f00f8b451 100644 --- a/backend/__tests__/unit/controllers/podController.test.js +++ b/backend/__tests__/unit/controllers/podController.test.js @@ -49,6 +49,10 @@ jest.mock('../../../services/agentIdentityService', () => ({ // Mirror the production set so the test exercises the real guard. DM_POD_TYPES_GUARD: new Set(['agent-room', 'agent-dm']), })); +jest.mock('../../../services/outsiderGrantAgentJoinPolicy', () => ({ + assertInstalledAgentMayJoinPod: jest.fn(), +})); +const { assertInstalledAgentMayJoinPod } = require('../../../services/outsiderGrantAgentJoinPolicy'); describe('podController', () => { beforeEach(() => { @@ -400,6 +404,30 @@ describe('podController', () => { expect(res.status).not.toHaveBeenCalledWith(403); }); + it('joinPod refuses a bot before membership when its installation cannot enter a protected pod', async () => { + const pod = { + _id: 'chat-1', type: 'chat', members: ['human-id'], + joinPolicy: 'open', publicRead: true, communityListed: true, + }; + Pod.findById.mockResolvedValue(pod); + User.findById.mockReturnValue({ select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + isBot: true, botMetadata: { agentName: 'outside-agent', instanceId: 'default' }, + }), + }) }); + assertInstalledAgentMayJoinPod.mockRejectedValue(Object.assign(new Error('Use a separate pod'), { + code: 'outsider_grant_non_native_agent_join', + })); + const req = { params: { id: 'chat-1' }, userId: 'bot-id', user: {} }; + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await podController.joinPod(req, res); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + code: 'outsider_grant_non_native_agent_join', + })); + expect(Pod.updateOne).not.toHaveBeenCalled(); + }); + it('getPodsByType filters agent-room to caller membership for non-admins', async () => { const otherPod = { _id: 'p1', type: 'agent-room', members: [{ _id: 'agent-id' }, { _id: 'someone-else' }] }; const myPod = { _id: 'p2', type: 'agent-room', members: [{ _id: 'agent-id' }, { _id: 'me' }] }; diff --git a/backend/__tests__/unit/routes/agentsRuntime.podCreateDedup.test.js b/backend/__tests__/unit/routes/agentsRuntime.podCreateDedup.test.js index b3def25d92..c4b93b5205 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.podCreateDedup.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.podCreateDedup.test.js @@ -22,10 +22,11 @@ jest.mock('jsonwebtoken', () => ({ sign: jest.fn(), verify: jest.fn(), decode: jest.fn() })); +const mockSourceConfig = { value: {} }; jest.mock('../../../middleware/agentRuntimeAuth', () => (req, res, next) => { req.agentUser = { _id: 'bot-1' }; req.agentInstallation = { - agentName: 'openclaw', instanceId: 'nova', version: '1.0.0', config: {}, scopes: [], + agentName: 'openclaw', instanceId: 'nova', version: '1.0.0', config: mockSourceConfig.value, scopes: [], }; next(); }); @@ -71,10 +72,14 @@ jest.mock('../../../models/Activity', () => ({})); jest.mock('../../../models/User', () => ({ find: jest.fn(), findById: jest.fn() })); jest.mock('../../../models/Post', () => ({ findById: jest.fn() })); jest.mock('../../../models/Integration', () => ({ find: jest.fn(), findOne: jest.fn() })); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); const express = require('express'); const request = require('supertest'); const router = require('../../../routes/agentsRuntime'); +const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); const app = express(); app.use(express.json()); @@ -105,6 +110,8 @@ describe('POST /pods dedup refuses to widen a 1:1 DM (ADR-001 §3.10)', () => { beforeEach(() => { jest.clearAllMocks(); mockFoundPod.value = null; + mockSourceConfig.value = {}; + podHasOutsiderWritableGrant.mockResolvedValue(false); }); describe.each(['agent-room', 'agent-dm'])('name collides with an existing %s', (type) => { @@ -152,6 +159,29 @@ describe('POST /pods dedup refuses to widen a 1:1 DM (ADR-001 §3.10)', () => { expect(res.status).not.toBe(403); }); + test('a protected pod refuses non-native dedup before member and install writes', async () => { + podHasOutsiderWritableGrant.mockResolvedValue(true); + mockSourceConfig.value = new Map([['runtime', { runtimeType: 'opencode' }]]); + mockFoundPod.value = existing('chat', asMember, { joinPolicy: 'invite-only' }); + const res = await createPod(); + expect(res.status).toBe(403); + expect(res.body.code).toBe('outsider_grant_non_native_agent_join'); + expect(podSave).not.toHaveBeenCalled(); + expect(mockInstall).not.toHaveBeenCalled(); + }); + + test('a native Map config keeps its runtime declaration when copied into a protected pod', async () => { + podHasOutsiderWritableGrant.mockResolvedValue(true); + mockSourceConfig.value = new Map([['runtime', { runtimeType: 'native' }]]); + mockFoundPod.value = existing('chat', asMember, { joinPolicy: 'invite-only' }); + const res = await createPod(); + expect(res.status).toBe(200); + expect(mockInstall).toHaveBeenCalledWith('openclaw', mockFoundPod.value._id, expect.objectContaining({ + config: expect.objectContaining({ runtime: { runtimeType: 'native' } }), + })); + expect(mockInstall.mock.calls.map(([agentName]) => agentName)).not.toContain('commonly-bot'); + }); + // ------------------------------------------------------------------ // A guessed name was a credential for joining any non-DM pod. The five // other writers to `members` all gate on joinability; this branch gated on diff --git a/backend/__tests__/unit/routes/podInvites.management.test.js b/backend/__tests__/unit/routes/podInvites.management.test.js index 891e54bdbc..523c97166a 100644 --- a/backend/__tests__/unit/routes/podInvites.management.test.js +++ b/backend/__tests__/unit/routes/podInvites.management.test.js @@ -21,6 +21,10 @@ jest.mock('../../../models/PodInvite', () => ({ const Pod = require('../../../models/Pod'); jest.mock('../../../models/Pod'); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); +jest.mock('../../../services/outsiderGrantAgentJoinPolicy', () => ({ + assertInstalledAgentMayJoinPod: jest.fn(), +})); jest.mock('../../../services/agentIdentityService', () => ({ DM_POD_TYPES_GUARD: new Set(['agent-room', 'agent-dm']), @@ -28,6 +32,8 @@ jest.mock('../../../services/agentIdentityService', () => ({ // eslint-disable-next-line import/no-unresolved, import/extensions const routes = require('../../../routes/podInvites'); +const User = require('../../../models/User'); +const { assertInstalledAgentMayJoinPod } = require('../../../services/outsiderGrantAgentJoinPolicy'); const POD_ID = '507f1f77bcf86cd799439011'; const TOKEN = 'a'.repeat(32); @@ -197,4 +203,28 @@ describe('pod invite management routes', () => { expect(res.body.code).toBe('dm_membership_refused'); }); + + it('refuses a bot invite before membership when the pod has an outsider-writable grant', async () => { + mockFindOne.mockResolvedValue({ token: TOKEN, podId: POD_ID, isUsable: () => true }); + const pod = memberPod({ members: [], save: jest.fn() }); + Pod.findById.mockResolvedValue(pod); + User.findById.mockReturnValue({ + select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ + isBot: true, botMetadata: { agentName: 'outside-agent', instanceId: 'default' }, + }) }), + }); + assertInstalledAgentMayJoinPod.mockRejectedValue(Object.assign(new Error('Use a separate pod'), { + code: 'outsider_grant_non_native_agent_join', + })); + + const res = await request(app).post(`/api/invites/${TOKEN}/redeem`).send({}); + expect(res.status).toBe(403); + expect(res.body).toEqual(expect.objectContaining({ + code: 'outsider_grant_non_native_agent_join', + message: expect.stringContaining('Use a separate pod'), + })); + expect(assertInstalledAgentMayJoinPod).toHaveBeenCalledWith(POD_ID, 'outside-agent', 'default'); + expect(pod.members).toEqual([]); + expect(pod.save).not.toHaveBeenCalled(); + }); }); diff --git a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js index 94d4dc7591..d5c4021206 100644 --- a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js +++ b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js @@ -35,11 +35,15 @@ jest.mock('../../../models/AgentProfile', () => ({ jest.mock('../../../models/User', () => ({ findById: jest.fn(), })); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); const Pod = require('../../../models/Pod'); const { AgentInstallation } = require('../../../models/AgentRegistry'); const AgentProfile = require('../../../models/AgentProfile'); const User = require('../../../models/User'); +const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); const registryRoutes = require('../../../routes/registry'); const app = express(); @@ -85,9 +89,74 @@ const setPod = (over = {}) => { describe('agent config PATCH — installer gate', () => { beforeEach(() => { jest.clearAllMocks(); + podHasOutsiderWritableGrant.mockResolvedValue(false); setCallerRole('user'); }); + it('refuses a non-admin runtime switch before saving any fan-out row', async () => { + podHasOutsiderWritableGrant.mockResolvedValue(true); + const primary = installation({ + installedBy: MEMBER, + config: new Map([['runtime', { runtimeType: 'native' }]]), + }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', config: { runtime: { runtimeType: 'opencode' } } }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('runtime_type_change'); + expect(primary.save).not.toHaveBeenCalled(); + expect(primary.config.get('runtime')).toEqual({ runtimeType: 'native' }); + expect(podHasOutsiderWritableGrant).not.toHaveBeenCalled(); + }); + + it('allows an existing non-native installation to reactivate once the grant is revoked', async () => { + const primary = installation({ + installedBy: MEMBER, + status: 'paused', + config: new Map([['runtime', { runtimeType: 'opencode' }]]), + }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', status: 'active' }); + expect(res.status).toBe(200); + expect(primary.save).toHaveBeenCalledTimes(1); + expect(primary.status).toBe('active'); + }); + + it('refuses the entire runtime fan-out when a later pod holds a live grant', async () => { + podHasOutsiderWritableGrant.mockImplementation(async (podId) => String(podId) === 'pod-2'); + const primary = installation({ installedBy: MEMBER, config: new Map([ + ['runtime', { runtimeType: 'native' }], + ]) }); + const peer = installation({ podId: 'pod-2', installedBy: MEMBER, config: new Map([ + ['runtime', { runtimeType: 'native' }], + ]) }); + setCallerRole('admin'); + setPod(); + Pod.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([ + { _id: 'pod-1', members: [{ userId: MEMBER }] }, + { _id: 'pod-2', members: [{ userId: MEMBER }] }, + ]) }) }); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary, peer]); + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', config: { runtime: { runtimeType: 'opencode' } } }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('outsider_grant_non_native_agent_join'); + expect(primary.save).not.toHaveBeenCalled(); + expect(peer.save).not.toHaveBeenCalled(); + }); + it('refuses a non-installer member changing config, and leaves the row alone', async () => { const primary = installation(); setPod(); diff --git a/backend/__tests__/unit/routes/registry.install-runtime-type.test.js b/backend/__tests__/unit/routes/registry.install-runtime-type.test.js index 07d02efe74..49c880a2b5 100644 --- a/backend/__tests__/unit/routes/registry.install-runtime-type.test.js +++ b/backend/__tests__/unit/routes/registry.install-runtime-type.test.js @@ -9,6 +9,9 @@ jest.mock('../../../models/AgentRegistry', () => ({ install: jest.fn(), }, })); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), @@ -66,6 +69,7 @@ const AgentProfile = require('../../../models/AgentProfile'); const AgentTemplate = require('../../../models/AgentTemplate'); const Activity = require('../../../models/Activity'); const AgentIdentityService = require('../../../services/agentIdentityService'); +const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); const FirstContactService = require('../../../services/firstContactService'); const installRouter = require('../../../routes/registry/install'); @@ -110,6 +114,7 @@ describe('registry install runtimeType fallback', () => { beforeEach(() => { jest.clearAllMocks(); + podHasOutsiderWritableGrant.mockResolvedValue(false); Pod.findById.mockReturnValue(buildLeanChain({ _id: 'pod-1', @@ -245,6 +250,30 @@ describe('registry install runtimeType fallback', () => { expect(res.status).not.toHaveBeenCalledWith(500); }); + it('refuses a non-native install into a protected pod before any installation or membership write', async () => { + podHasOutsiderWritableGrant.mockResolvedValue(true); + AgentRegistry.getByName.mockResolvedValue({ + agentName: 'sample-agent', displayName: 'Sample Agent', latestVersion: '1.0.0', + manifest: persistedManifest({ + name: 'sample-agent', version: '1.0.0', context: { required: [] }, + runtime: { type: 'standalone', runtimeType: 'opencode' }, + }), + }); + const req = { + body: { agentName: 'sample-agent', podId: 'pod-1', version: '1.0.0', config: {}, scopes: [] }, + user: { id: 'user-1', username: 'installer' }, userId: 'user-1', + }; + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await installHandler(req, res); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + code: 'outsider_grant_non_native_agent_join', + message: expect.stringContaining('Use a separate pod'), + })); + expect(AgentInstallation.install).not.toHaveBeenCalled(); + expect(AgentIdentityService.ensureAgentInPod).not.toHaveBeenCalled(); + }); + it('does not copy manifest.runtime.type deployment metadata into runtimeType', async () => { AgentRegistry.getByName.mockResolvedValue({ agentName: 'sample-agent', diff --git a/backend/__tests__/unit/routes/registry.reprovision-outsider-grant.test.js b/backend/__tests__/unit/routes/registry.reprovision-outsider-grant.test.js new file mode 100644 index 0000000000..bd97eeb33b --- /dev/null +++ b/backend/__tests__/unit/routes/registry.reprovision-outsider-grant.test.js @@ -0,0 +1,31 @@ +jest.mock('../../../services/agentIdentityService', () => ({ + getAgentTypeConfig: jest.fn(() => ({ runtime: 'moltbot' })), + getOrCreateAgentUser: jest.fn(), + ensureAgentInPod: jest.fn(), +})); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(true), +})); + +const AgentIdentityService = require('../../../services/agentIdentityService'); +const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); +const { reprovisionInstallation } = require('../../../routes/registry/reprovision'); + +describe('reprovision into a pod holding an outsider-writable grant', () => { + beforeEach(() => { + jest.clearAllMocks(); + podHasOutsiderWritableGrant.mockResolvedValue(true); + }); + + it('refuses a non-native runtime before identity, token, or runtime side effects', async () => { + const installation = { + podId: 'pod-1', agentName: 'openclaw', instanceId: 'default', save: jest.fn(), + }; + await expect(reprovisionInstallation({ installation })).rejects.toMatchObject({ + code: 'outsider_grant_non_native_agent_join', + message: expect.stringContaining('Use a separate pod'), + }); + expect(AgentIdentityService.getOrCreateAgentUser).not.toHaveBeenCalled(); + expect(installation.save).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/unit/services/agentAutoJoinService.outsiderGrant.test.js b/backend/__tests__/unit/services/agentAutoJoinService.outsiderGrant.test.js new file mode 100644 index 0000000000..5db812bb29 --- /dev/null +++ b/backend/__tests__/unit/services/agentAutoJoinService.outsiderGrant.test.js @@ -0,0 +1,53 @@ +jest.mock('../../../models/Pod', () => ({ find: jest.fn() })); +jest.mock('../../../models/Activity', () => ({ create: jest.fn().mockResolvedValue({}) })); +jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { + find: jest.fn(), isInstalled: jest.fn(), install: jest.fn(), +} })); +jest.mock('../../../services/agentIdentityService', () => ({ + getOrCreateAgentUser: jest.fn().mockResolvedValue({ _id: 'bot-1', username: 'bot' }), + ensureAgentInPod: jest.fn().mockResolvedValue({}), +})); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(true), +})); + +const Pod = require('../../../models/Pod'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); +const AgentAutoJoinService = require('../../../services/agentAutoJoinService'); + +const chain = (rows) => ({ select: () => ({ lean: async () => rows }) }); + +describe('auto-join into a pod holding an outsider-writable grant', () => { + beforeEach(() => { + jest.clearAllMocks(); + podHasOutsiderWritableGrant.mockResolvedValue(true); + Pod.find.mockReturnValue(chain([{ _id: 'target-pod' }])); + AgentInstallation.isInstalled.mockResolvedValue(false); + AgentInstallation.install.mockResolvedValue({}); + }); + + it('skips a non-native source before installing or adding membership', async () => { + AgentInstallation.find.mockReturnValue(chain([{ + agentName: 'seat', instanceId: 'default', podId: 'source-pod', + config: new Map([['runtime', { runtimeType: 'opencode' }]]), + }])); + const result = await AgentAutoJoinService.runAutoJoinAgentOwnedPods(); + expect(result).toEqual(expect.objectContaining({ installed: 0, skipped: 1 })); + expect(AgentInstallation.install).not.toHaveBeenCalled(); + }); + + it('copies a native Map declaration into the new installation', async () => { + AgentInstallation.find.mockReturnValue(chain([{ + agentName: 'native-agent', instanceId: 'default', podId: 'source-pod', + config: new Map([['runtime', { runtimeType: 'native' }]]), + }])); + const result = await AgentAutoJoinService.runAutoJoinAgentOwnedPods(); + expect(result).toEqual(expect.objectContaining({ installed: 1, skipped: 0 })); + expect(AgentInstallation.install).toHaveBeenCalledWith( + 'native-agent', 'target-pod', expect.objectContaining({ + config: expect.objectContaining({ runtime: { runtimeType: 'native' } }), + }), + ); + }); +}); diff --git a/backend/__tests__/unit/services/agentIdentityService.ensureAgentInPod.test.js b/backend/__tests__/unit/services/agentIdentityService.ensureAgentInPod.test.js index 080ed2f455..ecb1a254cd 100644 --- a/backend/__tests__/unit/services/agentIdentityService.ensureAgentInPod.test.js +++ b/backend/__tests__/unit/services/agentIdentityService.ensureAgentInPod.test.js @@ -7,12 +7,61 @@ // don't stub them still work — only Pod + User need explicit mocks here. jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); jest.mock('../../../models/User', () => ({ findOne: jest.fn(), findById: jest.fn() })); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); const Pod = require('../../../models/Pod'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); const AgentIdentityService = require('../../../services/agentIdentityService'); describe('AgentIdentityService.ensureAgentInPod — agent-room 1:1 invariant', () => { - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => { + jest.clearAllMocks(); + podHasOutsiderWritableGrant.mockResolvedValue(false); + }); + + it('refuses a non-native bot before adding membership to a protected pod', async () => { + podHasOutsiderWritableGrant.mockResolvedValue(true); + const pod = { _id: 'p-chat', type: 'chat', members: ['human'], save: jest.fn() }; + Pod.findById.mockResolvedValue(pod); + await expect(AgentIdentityService.ensureAgentInPod({ _id: 'bot' }, 'p-chat', 'opencode')) + .rejects.toHaveProperty('code', 'outsider_grant_non_native_agent_join'); + expect(pod.members).toEqual(['human']); + expect(pod.save).not.toHaveBeenCalled(); + }); + + it('refuses an undeclared join when the active installation is non-native', async () => { + podHasOutsiderWritableGrant.mockResolvedValue(true); + const findOne = jest.spyOn(AgentInstallation, 'findOne').mockReturnValue({ + select: () => ({ lean: async () => ({ config: { runtime: { runtimeType: 'opencode' } } }) }), + }); + const pod = { _id: 'p-chat', type: 'chat', members: ['human'], save: jest.fn() }; + Pod.findById.mockResolvedValue(pod); + + try { + await expect(AgentIdentityService.ensureAgentInPod({ + _id: 'bot', botMetadata: { agentName: 'opencode', instanceId: 'seat-1' }, + }, 'p-chat')).rejects.toHaveProperty('code', 'outsider_grant_non_native_agent_join'); + expect(findOne).toHaveBeenCalledWith({ + podId: 'p-chat', agentName: 'opencode', instanceId: 'seat-1', status: 'active', + }); + expect(pod.members).toEqual(['human']); + expect(pod.save).not.toHaveBeenCalled(); + } finally { + findOne.mockRestore(); + } + }); + + it('allows a narrowed native bot into the protected pod', async () => { + podHasOutsiderWritableGrant.mockResolvedValue(true); + const pod = { _id: 'p-chat', type: 'chat', members: ['human'], save: jest.fn() }; + Pod.findById.mockResolvedValue(pod); + await expect(AgentIdentityService.ensureAgentInPod({ _id: 'bot' }, 'p-chat', 'native')) + .resolves.toBe(pod); + expect(pod.members).toEqual(['human', 'bot']); + }); it('refuses to add a third agent to an existing agent-room', async () => { const pod = { diff --git a/backend/__tests__/unit/services/approvalActionService.test.js b/backend/__tests__/unit/services/approvalActionService.test.js index 7823f5b84f..d25c5d2704 100644 --- a/backend/__tests__/unit/services/approvalActionService.test.js +++ b/backend/__tests__/unit/services/approvalActionService.test.js @@ -55,6 +55,9 @@ jest.mock('../../../services/agentIdentityService', () => ({ jest.mock('../../../config/socket', () => ({ getIO: jest.fn(() => null) })); // Name reservation has its own suite (agentNameReservation); these arms use free names. jest.mock('../../../services/agentNameReservation', () => ({ reservedAgentNameRefusal: jest.fn().mockResolvedValue(null) })); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); // #2160: approving wakes the proposer; that path has its own suite (approvalActionService.proposerWake). jest.mock('../../../services/agentEventService', () => ({ enqueue: jest.fn().mockResolvedValue({}) })); const mockPostMessage = jest.fn().mockResolvedValue({ id: 1 }); @@ -66,6 +69,7 @@ jest.mock('../../../services/attentionItemService', () => ({ })); const { resolveApproval } = require('../../../services/approvalActionService'); +const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); const OWNER = 'aaaaaaaaaaaaaaaaaaaaaaa1'; const STRANGER = 'bbbbbbbbbbbbbbbbbbbbbbb2'; @@ -88,6 +92,7 @@ const flaggedRow = (over = {}) => ({ beforeEach(() => { jest.clearAllMocks(); + podHasOutsiderWritableGrant.mockResolvedValue(false); delete process.env.PG_HOST; mockUserFindById.mockReturnValue({ select: jest.fn(() => ({ lean: jest.fn().mockResolvedValue({ isBot: false }) })), @@ -320,6 +325,19 @@ describe('approved connect_local_agent execution — the seat, never the token', ...over, }); + test('refuses a webhook seat when the pod gained an outsider-writable grant after proposal', async () => { + podHasOutsiderWritableGrant.mockResolvedValue(true); + mockApprovalFindById.mockResolvedValue(seatRow()); + const resolved = seatRow({ status: 'resolved', decision: 'approved' }); + mockApprovalFindOneAndUpdate.mockResolvedValue(resolved); + mockInstallFindOne.mockResolvedValue(null); + const res = await resolveApproval({ approvalId: 'appr-1', callerUserId: OWNER, decision: 'approved' }); + expect(res.status).toBe(200); + expect(resolved.executionError).toContain('Use a separate pod'); + expect(mockRegistryCreate).not.toHaveBeenCalled(); + expect(mockInstallUpsert).not.toHaveBeenCalled(); + }); + test('creates the seat owned by the USER and hands back a connect path — no credential anywhere', async () => { mockApprovalFindById.mockResolvedValue(seatRow()); const resolved = seatRow({ status: 'resolved', decision: 'approved' }); diff --git a/backend/__tests__/unit/services/outsiderGrantAgentJoinPolicy.test.js b/backend/__tests__/unit/services/outsiderGrantAgentJoinPolicy.test.js new file mode 100644 index 0000000000..93e33e3d06 --- /dev/null +++ b/backend/__tests__/unit/services/outsiderGrantAgentJoinPolicy.test.js @@ -0,0 +1,74 @@ +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn(), +})); +jest.mock('../../../models/AgentRegistry', () => ({ + AgentInstallation: { findOne: jest.fn() }, +})); + +const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { + assertAgentRuntimeMayJoinPod, + assertInstalledAgentMayJoinPod, + runtimeTypeFromConfig, +} = require('../../../services/outsiderGrantAgentJoinPolicy'); + +describe('outsider-writable grant agent join policy', () => { + beforeEach(() => { + jest.clearAllMocks(); + podHasOutsiderWritableGrant.mockResolvedValue(true); + }); + + it('refuses a non-native join with a separate-pod reason', async () => { + await expect(assertAgentRuntimeMayJoinPod('pod-1', 'opencode')).rejects.toMatchObject({ + code: 'outsider_grant_non_native_agent_join', + statusCode: 403, + message: expect.stringContaining('Use a separate pod'), + }); + }); + + it('allows a native agent and a non-native agent after the grant ends', async () => { + await expect(assertAgentRuntimeMayJoinPod('pod-1', 'native')).resolves.toBeUndefined(); + expect(podHasOutsiderWritableGrant).not.toHaveBeenCalled(); + podHasOutsiderWritableGrant.mockResolvedValue(false); + await expect(assertAgentRuntimeMayJoinPod('pod-1', 'opencode')).resolves.toBeUndefined(); + }); + + it('requires a live native installation for an undeclared membership writer', async () => { + AgentInstallation.findOne.mockReturnValue({ + select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ + config: new Map([['runtime', { runtimeType: 'native' }]]), + }) }), + }); + await expect(assertInstalledAgentMayJoinPod('pod-1', 'sample-agent', 'default')).resolves.toBeUndefined(); + expect(AgentInstallation.findOne).toHaveBeenCalledWith(expect.objectContaining({ + podId: 'pod-1', agentName: 'sample-agent', instanceId: 'default', status: 'active', + })); + expect(runtimeTypeFromConfig(new Map([['runtime', { runtimeType: 'native' }]]))).toBe('native'); + }); + + it('never puts a caller-controlled query object in the installation lookup', async () => { + AgentInstallation.findOne.mockReturnValue({ + select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }), + }); + await expect(assertInstalledAgentMayJoinPod({ $ne: null }, 'bot', 'default')).rejects.toHaveProperty( + 'code', 'outsider_grant_non_native_agent_join', + ); + expect(AgentInstallation.findOne).toHaveBeenCalledWith(expect.objectContaining({ + podId: '[object Object]', + })); + }); + + it('refuses absent and non-native installations before membership', async () => { + const lean = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + config: new Map([['runtime', { runtimeType: 'webhook' }]]), + }); + AgentInstallation.findOne.mockReturnValue({ select: jest.fn().mockReturnValue({ lean }) }); + await expect(assertInstalledAgentMayJoinPod('pod-1', 'bot', 'default')).rejects.toHaveProperty( + 'code', 'outsider_grant_non_native_agent_join', + ); + await expect(assertInstalledAgentMayJoinPod('pod-1', 'bot', 'default')).rejects.toHaveProperty( + 'code', 'outsider_grant_non_native_agent_join', + ); + }); +}); diff --git a/backend/controllers/podController.ts b/backend/controllers/podController.ts index c6435bef16..30382ef59e 100644 --- a/backend/controllers/podController.ts +++ b/backend/controllers/podController.ts @@ -552,6 +552,21 @@ exports.joinPod = async (req: any, res: any) => { }); } + const joiningUser = await User.findById(userId).select('isBot botType botMetadata').lean(); + if (joiningUser?.isBot || joiningUser?.botType) { + const { assertInstalledAgentMayJoinPod } = require('../services/outsiderGrantAgentJoinPolicy'); + try { + await assertInstalledAgentMayJoinPod( + pod._id, joiningUser.botMetadata?.agentName, joiningUser.botMetadata?.instanceId, + ); + } catch (joinError: any) { + if (joinError?.code === 'outsider_grant_non_native_agent_join') { + return res.status(403).json({ code: joinError.code, message: joinError.message }); + } + throw joinError; + } + } + await Pod.updateOne( { _id: pod._id }, { diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 9b22e6f144..5e4f4976f8 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -46,6 +46,10 @@ const { cloudflareIpRateLimitKeyGenerator } = require('../middleware/ipRateLimit const { resolveDiscordBotToken } = require('../utils/discordBotToken'); const { rateLimitObserver } = require('../middleware/rateLimitObserver'); const { adapterForRuntimeBinding } = require('../services/grantBrokerConfinement'); +const { + assertAgentRuntimeMayJoinPod, + runtimeTypeFromConfig, +} = require('../services/outsiderGrantAgentJoinPolicy'); // ADR-003 Phase 4: per-token rate limiter for the cross-agent surface. // Token-global (covers any pod the token is valid for). Complementary to the @@ -2921,6 +2925,7 @@ router.post('/posts', agentRuntimeAuth, async (req: any, res: any) => { */ async function ensureCommonlyBotInstalled(podId: any, installedBy: any) { try { + await assertAgentRuntimeMayJoinPod(podId, undefined); await AgentInstallation.install('commonly-bot', podId, { version: '1.0.0', config: {}, @@ -3140,13 +3145,24 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an return refusePodNameCollision('caller is not a member and the pod is not directly joinable'); } + const sourceInstall = req.agentInstallation || (req.agentInstallations || [])[0]; + const sourceConfig = sourceInstall?.config instanceof Map + ? Object.fromEntries(sourceInstall.config) : sourceInstall?.config || {}; + try { + await assertAgentRuntimeMayJoinPod(existingPod._id, runtimeTypeFromConfig(sourceConfig)); + } catch (joinError: any) { + if (joinError?.code === 'outsider_grant_non_native_agent_join') { + return res.status(403).json({ code: joinError.code, message: joinError.message }); + } + throw joinError; + } + if (!isMember) { existingPod.members.push(agentUser._id); await existingPod.save(); await existingPod.populate('members', 'username profilePicture'); } // Ensure an AgentInstallation exists so the agent appears in the Agents section - const sourceInstall = req.agentInstallation || (req.agentInstallations || [])[0]; if (sourceInstall) { try { const existing = await AgentInstallation.findOne({ @@ -3158,10 +3174,10 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an await AgentInstallation.install(sourceInstall.agentName, existingPod._id, { version: sourceInstall.version || '1.0.0', config: { - ...(sourceInstall.config || {}), + ...sourceConfig, heartbeat: { enabled: false }, autonomy: { - ...(sourceInstall.config?.autonomy || {}), + ...(sourceConfig.autonomy || {}), autoJoined: true, autoJoinedFromPodId: sourceInstall.podId?.toString(), autoJoinSource: 'pod-dedup', @@ -3290,6 +3306,17 @@ router.post('/pods/:podId/self-install', phase4RateLimit, agentRuntimeAuth, asyn return res.status(403).json({ message: 'No active installation found for this agent' }); } + const sourceConfig = sourceInstall.config instanceof Map + ? Object.fromEntries(sourceInstall.config) : sourceInstall.config || {}; + try { + await assertAgentRuntimeMayJoinPod(podId, runtimeTypeFromConfig(sourceConfig)); + } catch (joinError: any) { + if (joinError?.code === 'outsider_grant_non_native_agent_join') { + return res.status(403).json({ code: joinError.code, message: joinError.message }); + } + throw joinError; + } + const alreadyInstalled = await AgentInstallation.isInstalled( sourceInstall.agentName, podId, @@ -3300,11 +3327,11 @@ router.post('/pods/:podId/self-install', phase4RateLimit, agentRuntimeAuth, asyn } const mergedConfig = { - ...(sourceInstall.config || {}), + ...sourceConfig, // Agent self-installed pods never get heartbeat — prevents cascading heartbeat explosion heartbeat: { enabled: false }, autonomy: { - ...(sourceInstall.config?.autonomy || {}), + ...(sourceConfig.autonomy || {}), autoJoined: true, autoJoinedFromPodId: sourceInstall.podId?.toString(), autoJoinSource: 'self-install', diff --git a/backend/routes/podInvites.ts b/backend/routes/podInvites.ts index a5cc010045..3f296c54d9 100644 --- a/backend/routes/podInvites.ts +++ b/backend/routes/podInvites.ts @@ -9,6 +9,8 @@ import crypto, { createHash } from 'crypto'; import mongoose from 'mongoose'; import rateLimit from 'express-rate-limit'; import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; +import User from '../models/User'; +import { assertInstalledAgentMayJoinPod } from '../services/outsiderGrantAgentJoinPolicy'; const router = express.Router(); const auth = require('../middleware/auth'); const Pod = require('../models/Pod'); @@ -247,6 +249,19 @@ router.post('/invites/:token/redeem', inviteWriteRateLimit, auth, async (req: an (m: any) => String(m?._id || m) === String(userId), ); if (!alreadyMember) { + const joiningUser = await User.findById(userId).select('isBot botType botMetadata').lean(); + if (joiningUser?.isBot || joiningUser?.botType) { + try { + await assertInstalledAgentMayJoinPod( + pod._id, joiningUser.botMetadata?.agentName, joiningUser.botMetadata?.instanceId, + ); + } catch (joinError: any) { + if (joinError?.code === 'outsider_grant_non_native_agent_join') { + return res.status(403).json({ code: joinError.code, message: joinError.message }); + } + throw joinError; + } + } pod.members.push(userId); pod.updatedAt = new Date(); await pod.save(); diff --git a/backend/routes/registry/admin.ts b/backend/routes/registry/admin.ts index c9d09fd6cd..5c8c61ce5e 100644 --- a/backend/routes/registry/admin.ts +++ b/backend/routes/registry/admin.ts @@ -1,5 +1,5 @@ // Admin-only registry routes — extracted from registry.js (GH#112) -export {}; +import { assertAgentRuntimeMayJoinPod, OutsiderGrantAgentJoinError } from '../../services/outsiderGrantAgentJoinPolicy'; const express = require('express'); const auth = require('../../middleware/auth'); const adminAuth = require('../../middleware/adminAuth'); @@ -314,6 +314,10 @@ adminRouter.post('/admin/agents/claude-code/session-token', auth, adminAuth, asy return res.status(404).json({ error: 'Pod not found' }); } + // A session token also installs and joins a non-native seat. Refuse before + // creating its identity, membership, installation, or token. + await assertAgentRuntimeMayJoinPod(podId, 'claude-code'); + const instanceId = requestedInstanceId || `sess-${randomSecret(8)}`; // Default to Commonly's existing ` (Context)` parenthetical naming @@ -398,6 +402,9 @@ adminRouter.post('/admin/agents/claude-code/session-token', auth, adminAuth, asy expiresAt, }); } catch (error) { + if (error instanceof OutsiderGrantAgentJoinError) { + return res.status(error.statusCode).json({ code: error.code, message: error.message }); + } console.error('Error issuing claude-code session token:', error); return res.status(500).json({ error: 'Failed to issue session token' }); } diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts index 956f7f28ea..64787a869f 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -27,6 +27,7 @@ const { const { validateEnvironmentMcpEntries } = require('../../utils/environmentSpecValidation'); const { ownsRuntimeIdentity, webhookUrlRefusal } = require('../../services/runtimeIdentityOwnership'); const { configWriteRefusal, runtimeKeepingType } = require('../../services/configWriteGuard'); +const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy'); const agentConfigRouter = express.Router(); @@ -262,6 +263,23 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: const { status: refusalStatus, ...refusalBody } = writeRefusal; return res.status(refusalStatus).json(refusalBody); } + // Decide for every fan-out target before saving any row. A runtime object + // without runtimeType also removes an existing native declaration here. + if (config?.runtime !== undefined || status === 'active') { + try { + for (const targetInstallation of installationsToUpdate) { + const stored = normalizeConfigMap(targetInstallation.config) || {}; + const runtime = config?.runtime !== undefined ? config.runtime : stored.runtime; + // eslint-disable-next-line no-await-in-loop + await assertAgentRuntimeMayJoinPod(targetInstallation.podId, runtime?.runtimeType); + } + } catch (joinError: any) { + if (joinError?.code === 'outsider_grant_non_native_agent_join') { + return res.status(403).json({ code: joinError.code, message: joinError.message }); + } + throw joinError; + } + } for (const targetInstallation of installationsToUpdate) { applyInstallationSettings(targetInstallation); diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index 20be0839fe..30fbfd1ac3 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -42,6 +42,7 @@ const { } = require('./tokens'); const { validateEnvironmentMcpEntries } = require('../../utils/environmentSpecValidation'); const { willSolelyInstallRuntimeIdentity, webhookUrlRefusal } = require('../../services/runtimeIdentityOwnership'); +const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy'); // Inlined per-route limiter. This comment used to attribute its clean CodeQL // status to `js/missing-rate-limiting` "only seeing express-rate-limit calls @@ -461,6 +462,14 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any // even when the caller omits an explicit runtimeType. const effectiveRuntimeType = String(runtimeConfig.runtimeType || '').trim().toLowerCase() || String(AgentIdentityService.getAgentTypeConfig(safeAgentName)?.runtime || '').trim().toLowerCase(); + try { + await assertAgentRuntimeMayJoinPod(podId, effectiveRuntimeType); + } catch (joinError: any) { + if (joinError?.code === 'outsider_grant_non_native_agent_join') { + return res.status(403).json({ code: joinError.code, message: joinError.message }); + } + throw joinError; + } if (AgentIdentityService.isCloudRuntime({ runtimeType: effectiveRuntimeType, host: runtimeConfig.host, @@ -630,7 +639,7 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any ...(explicitDescription ? { description: explicitDescription } : {}), ...(avatarSeed ? { profilePicture: avatarSeed } : {}), }); - await AgentIdentityService.ensureAgentInPod(agentUser, podId); + await AgentIdentityService.ensureAgentInPod(agentUser, podId, effectiveRuntimeType); } catch (identityError: unknown) { // A refusal is not a best-effort failure (TASK-133 b, wren 73987/73988): // swallowing it would answer 2xx and leave an AgentInstallation row with @@ -643,6 +652,9 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any username: refusal.username, }); } + if (refusal?.code === 'outsider_grant_non_native_agent_join') { + return res.status(403).json({ code: refusal.code, message: refusal.message }); + } console.warn('Failed to provision agent user identity:', (identityError as Error).message); } diff --git a/backend/routes/registry/provision.ts b/backend/routes/registry/provision.ts index 1154c2111d..a1d3d9e63d 100644 --- a/backend/routes/registry/provision.ts +++ b/backend/routes/registry/provision.ts @@ -11,6 +11,7 @@ const Pod = require('../../models/Pod'); const User = require('../../models/User'); const Integration = require('../../models/Integration'); const AgentIdentityService = require('../../services/agentIdentityService'); +const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy'); const DMService = require('../../services/dmService'); const { provisionAgentRuntime, @@ -153,6 +154,14 @@ provisionRouter.post('/pods/:podId/agents/:name/provision', provisionRateLimit, if (!runtimeType) { return res.status(400).json({ error: 'Unknown agent runtime type' }); } + try { + await assertAgentRuntimeMayJoinPod(podId, runtimeType); + } catch (joinError: any) { + if (joinError?.code === 'outsider_grant_non_native_agent_join') { + return res.status(403).json({ code: joinError.code, message: joinError.message }); + } + throw joinError; + } // Hosted-agent entitlement gate (mirrors routes/registry/install.ts). A // cloud (Commonly-hosted) runtime requires admin OR the cloudAgents diff --git a/backend/routes/registry/reprovision.ts b/backend/routes/registry/reprovision.ts index 80e3765287..3bbc597b7a 100644 --- a/backend/routes/registry/reprovision.ts +++ b/backend/routes/registry/reprovision.ts @@ -4,6 +4,7 @@ const { AgentInstallation } = require('../../models/AgentRegistry'); const AgentProfile = require('../../models/AgentProfile'); const Integration = require('../../models/Integration'); const AgentIdentityService = require('../../services/agentIdentityService'); +const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy'); const { provisionAgentRuntime, startAgentRuntime, @@ -55,6 +56,7 @@ const reprovisionInstallation = async ({ if (!runtimeType) { throw new Error('Unknown agent runtime type'); } + await assertAgentRuntimeMayJoinPod(podId, runtimeType); const agentUser = await AgentIdentityService.getOrCreateAgentUser(name, { instanceId: normalizedInstanceId, diff --git a/backend/services/agentAutoJoinService.ts b/backend/services/agentAutoJoinService.ts index feb91ec7c7..293caf9dca 100644 --- a/backend/services/agentAutoJoinService.ts +++ b/backend/services/agentAutoJoinService.ts @@ -2,6 +2,7 @@ import Pod from '../models/Pod'; import Activity from '../models/Activity'; import { AgentInstallation } from '../models/AgentRegistry'; import AgentIdentityService from './agentIdentityService'; +import { assertAgentRuntimeMayJoinPod, runtimeTypeFromConfig } from './outsiderGrantAgentJoinPolicy'; const MAX_TOTAL_INSTALLS = Math.max(1, parseInt(process.env.AGENT_AUTO_JOIN_MAX_TOTAL || '200', 10)); const MAX_PER_SOURCE = Math.max(1, parseInt(process.env.AGENT_AUTO_JOIN_MAX_PER_SOURCE || '25', 10)); @@ -58,7 +59,9 @@ class AgentAutoJoinService { continue; } - const sourceConfig = (sourceInstall.config || {}) as Record; + const sourceConfig = (sourceInstall.config instanceof Map + ? Object.fromEntries(sourceInstall.config) + : sourceInstall.config || {}) as Record; const sourceAutonomy = (sourceConfig.autonomy || {}) as Record; const mergedConfig = { ...sourceConfig, @@ -72,6 +75,8 @@ class AgentAutoJoinService { }; try { + // eslint-disable-next-line no-await-in-loop + await assertAgentRuntimeMayJoinPod((pod as Record)._id, runtimeTypeFromConfig(sourceInstall.config)); // eslint-disable-next-line no-await-in-loop await AgentInstallation.install(String(sourceInstall.agentName), (pod as { _id: import('mongoose').Types.ObjectId })._id, { version: String(sourceInstall.version || '1.0.0'), diff --git a/backend/services/agentIdentityService.ts b/backend/services/agentIdentityService.ts index e0770381b8..6bb8f43f42 100644 --- a/backend/services/agentIdentityService.ts +++ b/backend/services/agentIdentityService.ts @@ -3,6 +3,7 @@ import User from '../models/User'; import Pod from '../models/Pod'; import { AgentRegistry } from '../models/AgentRegistry'; import { normalizeAvatarUrl } from './avatarService'; +import { assertAgentRuntimeMayJoinPod, assertInstalledAgentMayJoinPod } from './outsiderGrantAgentJoinPolicy'; let dbPg: { pool: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } } | null; try { @@ -652,7 +653,9 @@ class AgentIdentityService { return buildAgentUsername(agentType, instanceId); } - static async ensureAgentInPod(agentUser: InstanceType, podId: unknown): Promise | null> { + static async ensureAgentInPod( + agentUser: InstanceType, podId: unknown, intendedRuntimeType?: string, + ): Promise | null> { if (!agentUser || !podId) return null; const pod = await Pod.findById(podId); if (!pod) return null; @@ -689,6 +692,13 @@ class AgentIdentityService { ); return null; } + if (intendedRuntimeType) { + await assertAgentRuntimeMayJoinPod(podId, intendedRuntimeType); + } else { + await assertInstalledAgentMayJoinPod( + podId, agentUser.botMetadata?.agentName, agentUser.botMetadata?.instanceId, + ); + } pod.members.push(agentId); await pod.save(); } diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index 782c7b7ed2..fb272f8cff 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -14,6 +14,8 @@ const ThreadUserState = require('../models/pg/ThreadUserState') as { }; // eslint-disable-next-line global-require const AgentEvent = require('../models/AgentEvent'); +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const { assertAgentRuntimeMayJoinPod } = require('./outsiderGrantAgentJoinPolicy'); // eslint-disable-next-line global-require const chatSummarizerService = require('./chatSummarizerService'); // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports @@ -351,6 +353,10 @@ const autoJoinAgentToPod = async ( } } + // This auto-join creates a row without a native runtime declaration. Refuse + // before either the installation or membership write in a protected pod. + await assertAgentRuntimeMayJoinPod(podId, undefined); + // Upsert the AgentInstallation (heartbeat off — agent-dm and pulled- // in agents are reactive, not scheduled). Idempotent. await AgentInstallation.upsert(agentName, podId, { diff --git a/backend/services/approvalActionService.ts b/backend/services/approvalActionService.ts index 4c2d539897..20bc0bd000 100644 --- a/backend/services/approvalActionService.ts +++ b/backend/services/approvalActionService.ts @@ -15,6 +15,7 @@ import { randomUUID } from 'crypto'; import ApprovalAction, { IApprovalAction, ApprovalActionType, ApprovalToolCall } from '../models/ApprovalAction'; import Pod from '../models/Pod'; import User from '../models/User'; +import { assertAgentRuntimeMayJoinPod } from './outsiderGrantAgentJoinPolicy'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { AgentInstallation } = require('../models/AgentRegistry'); @@ -881,6 +882,10 @@ const executeConnectLocalAgent = async (row: IApprovalAction): Promise throw new Error(`the agent name "${agentName}" is now in use by another user`); } + // Approval runs later than proposal. Check the live pod boundary before + // creating either the ephemeral registry row or the webhook installation. + await assertAgentRuntimeMayJoinPod(row.podId, 'webhook'); + const connectPath = `/v2/agents/byo?pod=${encodeURIComponent(String(row.podId))}&name=${encodeURIComponent(agentName)}`; const existing = await AgentInstallation.findOne({ diff --git a/backend/services/outsiderGrantAgentJoinPolicy.ts b/backend/services/outsiderGrantAgentJoinPolicy.ts new file mode 100644 index 0000000000..f2a24271ee --- /dev/null +++ b/backend/services/outsiderGrantAgentJoinPolicy.ts @@ -0,0 +1,41 @@ +/** Keep non-native agents out of pods that hold outsider-writable grants. */ +import { AgentInstallation } from '../models/AgentRegistry'; +import { podHasOutsiderWritableGrant } from './nativeOutsiderGrantPolicy'; + +export class OutsiderGrantAgentJoinError extends Error { + readonly code = 'outsider_grant_non_native_agent_join'; + + readonly statusCode = 403; + + constructor() { + super('This pod has an outsider-writable connector grant. Use a separate pod for an agent outside the narrowed native runtime.'); + } +} + +export const runtimeTypeFromConfig = (config: unknown): string => { + const values = config instanceof Map ? Object.fromEntries(config) : config; + const runtime = (values && typeof values === 'object' && 'runtime' in values) + ? (values as { runtime?: unknown }).runtime : null; + return runtime && typeof runtime === 'object' && 'runtimeType' in runtime + ? String((runtime as { runtimeType?: unknown }).runtimeType || '').trim().toLowerCase() : ''; +}; + +export const assertAgentRuntimeMayJoinPod = async (podId: unknown, runtimeType: unknown): Promise => { + if (String(runtimeType || '').trim().toLowerCase() === 'native') return; + if (await podHasOutsiderWritableGrant(String(podId))) throw new OutsiderGrantAgentJoinError(); +}; + +/** A membership write without an install declaration may only trust a live native row. */ +export const assertInstalledAgentMayJoinPod = async ( + podId: unknown, agentName: unknown, instanceId: unknown, +): Promise => { + const podIdValue = String(podId ?? ''); + if (!await podHasOutsiderWritableGrant(podIdValue)) return; + const installation = await AgentInstallation.findOne({ + podId: podIdValue, + agentName: String(agentName || '').trim().toLowerCase(), + instanceId: String(instanceId || 'default'), + status: 'active', + }).select('config').lean(); + if (runtimeTypeFromConfig(installation?.config) !== 'native') throw new OutsiderGrantAgentJoinError(); +}; diff --git a/backend/services/personaHireService.ts b/backend/services/personaHireService.ts index cab485fe11..589c9ddecd 100644 --- a/backend/services/personaHireService.ts +++ b/backend/services/personaHireService.ts @@ -14,6 +14,8 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const { createHash } = require('crypto'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { assertAgentRuntimeMayJoinPod } = require('./outsiderGrantAgentJoinPolicy'); interface HireArgs { agentName: string; @@ -76,6 +78,8 @@ export const hirePersona = async ({ agentName, userId, podId }: HireArgs): Promi // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const { buildInstallationConfig } = require('../scripts/seed-native-agents'); + const installationConfig = buildInstallationConfig(manifest); + await assertAgentRuntimeMayJoinPod(podId, installationConfig?.runtime?.runtimeType); await AgentInstallation.findOneAndUpdate( { agentName: key, podId, instanceId }, { @@ -84,7 +88,7 @@ export const hirePersona = async ({ agentName, userId, podId }: HireArgs): Promi version: '1.0.0', displayName: manifest.displayName, scopes: ['context:read', 'messages:write', 'memory:read', 'memory:write'], - config: buildInstallationConfig(manifest), + config: installationConfig, }, $setOnInsert: { agentName: key,