diff --git a/backend/__tests__/service/operatorOnlyPodPolicy.test.js b/backend/__tests__/service/operatorOnlyPodPolicy.test.js new file mode 100644 index 0000000000..73fece93cd --- /dev/null +++ b/backend/__tests__/service/operatorOnlyPodPolicy.test.js @@ -0,0 +1,64 @@ +const mongoose = require('mongoose'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); +const express = require('express'); +const request = require('supertest'); +const { hash } = require('../../utils/secret'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const app = express(); +app.use('/api/agents/runtime', require('../../routes/agentsRuntime')); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const Integration = require('../../models/Integration'); +const { operatorOnlyPodAllowed } = require('../../services/operatorOnlyPodPolicy'); +let owner; let bot; let pod; let installations; +beforeAll(setupMongoDb); +afterAll(closeMongoDb); +beforeEach(async () => { + await clearMongoDb(); + owner = await User.create({ username: 'native-owner', email: 'native-owner@test.com', password: 'password123' }); + bot = await User.create({ username: 'native-fixture', email: 'native-fixture@test.com', password: 'password123', isBot: true, botMetadata: { agentName: 'native-fixture', instanceId: 'default' }, agentRuntimeTokens: [{ tokenHash: hash('cm_agent_fixture'), label: 'fixture', createdAt: new Date(), lastUsedAt: new Date() }] }); + pod = await Pod.create({ name: 'Private', type: 'chat', createdBy: owner._id, + members: [owner._id, bot._id], joinPolicy: 'invite-only', publicRead: false, communityListed: false }); + installations = [{ agentName: 'native-fixture', instanceId: 'default', podId: pod._id, installedBy: owner._id }]; +}); +const allowed = () => operatorOnlyPodAllowed(String(pod._id), installations, bot); +test('only private operator and exact listener are allowed', async () => { + expect(await allowed()).toBe(true); + expect(await operatorOnlyPodAllowed(String(pod._id), installations, null)).toBe(true); + expect(await operatorOnlyPodAllowed('invalid', installations, bot)).toBe(false); + expect(await operatorOnlyPodAllowed(String(pod._id), [], bot)).toBe(false); +}); +test.each([false, true])('added human/bot (%s) revokes eligibility', async (isBot) => { + const peer = await User.create({ username: 'peer', email: 'peer@test.com', password: 'password123', isBot }); + await Pod.updateOne({ _id: pod._id }, { $push: { members: peer._id } }); + expect(await allowed()).toBe(false); +}); +test.each([{ type: 'agent-dm' }, { type: 'agent-room' }, { type: 'agent-admin' }, { publicRead: true }, { communityListed: true }, { joinPolicy: 'open' }, + { createdBy: new mongoose.Types.ObjectId() }, { members: [] }])('unsafe pod %j is refused', async (change) => { + await Pod.updateOne({ _id: pod._id }, { $set: change }); + expect(await allowed()).toBe(false); +}); +test('missing listener, mixed owners and other identity tokens are refused', async () => { + expect(await operatorOnlyPodAllowed(String(pod._id), installations, { _id: owner._id })).toBe(false); + installations.push({ ...installations[0], installedBy: new mongoose.Types.ObjectId() }); + expect(await allowed()).toBe(false); + installations.pop(); + await User.deleteOne({ _id: bot._id }); + expect(await allowed()).toBe(false); +}); +test('active bridge revokes private audience', async () => { + await Integration.create({ podId: pod._id, scope: 'pod', type: 'slack', status: 'connected', + isActive: true, createdBy: owner._id, config: {} }); + expect(await allowed()).toBe(false); +}); + +test('actual runtime-token route checks current Mongo audience and denies unknown credentials', async () => { + await AgentInstallation.create({ ...installations[0], version: '1.0.0', status: 'active', scopes: ['context:read'] }); + const get = (token) => request(app).get(`/api/agents/runtime/pods/${pod._id}/operator-only`).set('Authorization', `Bearer ${token}`); + expect((await get('cm_agent_invalid')).status).toBe(401); + const good = await get('cm_agent_fixture'); + expect(good.status).toBe(200); + expect(good.body).toEqual({ allowed: true, podId: String(pod._id), agentName: 'native-fixture', instanceId: 'default' }); + await Pod.updateOne({ _id: pod._id }, { $push: { members: new mongoose.Types.ObjectId() } }); + expect((await get('cm_agent_fixture')).status).toBe(403); +}); diff --git a/backend/__tests__/unit/routes/agentsRuntime.installations.test.js b/backend/__tests__/unit/routes/agentsRuntime.installations.test.js index cd1629af51..9816116e92 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.installations.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.installations.test.js @@ -2,6 +2,9 @@ jest.mock('jsonwebtoken', () => ({ sign: jest.fn(), verify: jest.fn(), decode: jest.fn() })); +let mockOperatorAllowed = true; +jest.mock('../../../services/operatorOnlyPodPolicy', () => ({ operatorOnlyPodAllowed: jest.fn(async () => mockOperatorAllowed) })); + const mockInstallation = { podId: 'pod-1', instanceId: 'writer', @@ -103,3 +106,22 @@ describe('GET /installations runtime adapter projection', () => { } }); }); + +describe('GET /pods/:podId/operator-only', () => { + test('projects authenticated identity or refuses without private details', async () => { + mockOperatorAllowed = true; + const ok = await request(app).get('/api/agents/runtime/pods/pod-1/operator-only'); + expect(ok.status).toBe(200); + expect(ok.headers['cache-control']).toBe('no-store'); + expect(ok.body).toEqual({ allowed: true, podId: 'pod-1', instanceId: 'writer' }); + mockOperatorAllowed = false; + const denied = await request(app).get('/api/agents/runtime/pods/pod-1/operator-only'); + expect(denied.status).toBe(403); + }); + test('database failure refuses instead of authorizing', async () => { + require('../../../services/operatorOnlyPodPolicy').operatorOnlyPodAllowed.mockRejectedValueOnce(Error('DB unavailable')); + const result = await request(app).get('/api/agents/runtime/pods/pod-1/operator-only'); + expect(result.status).toBe(503); + expect(result.body).not.toHaveProperty('allowed'); + }); +}); diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 61fb4b1e55..a31f0ae1f3 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -14,6 +14,7 @@ const MAX_UPSTREAM_STATUS = 599; // ESM import shape but has trouble tracing rate-limit middleware through // require() returns; using `import` here makes the recognition unambiguous. import rateLimit from 'express-rate-limit'; +import { operatorOnlyPodAllowed } from '../services/operatorOnlyPodPolicy'; import { platformIpRateLimit } from '../middleware/platformRateLimit'; const express = require('express'); @@ -502,6 +503,22 @@ router.delete('/messages/:messageId/claim', phase4RateLimit, agentRuntimeAuth, a } }); +// Fail closed for full-tool in-process harnesses. Re-read before every turn. +router.get('/pods/:podId/operator-only', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { + res.set('Cache-Control', 'no-store'); + try { + const installations = req.agentInstallations || []; + if (!await operatorOnlyPodAllowed(req.params.podId, installations, req.agentUser)) { + return res.status(403).json({ code: 'operator_only_required' }); + } + const identity = installations[0]; + return res.json({ allowed: true, podId: req.params.podId, + agentName: identity.agentName, instanceId: identity.instanceId || 'default' }); + } catch { + return res.status(503).json({ code: 'operator_only_unavailable' }); + } +}); + router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const installations = req.agentInstallations || []; diff --git a/backend/services/operatorOnlyPodPolicy.ts b/backend/services/operatorOnlyPodPolicy.ts new file mode 100644 index 0000000000..bb7abebfc5 --- /dev/null +++ b/backend/services/operatorOnlyPodPolicy.ts @@ -0,0 +1,37 @@ +import { Types } from 'mongoose'; +import Pod from '../models/Pod'; +import User from '../models/User'; +import { podIsBridged } from './outsiderGrantPodEligibility'; +import AgentIdentityService from './agentIdentityService'; + +/** Full-tool native sessions accept only their human operator and exact agent. */ +export const operatorOnlyPodAllowed = async ( + podId: string, installations: any[], agentUser: any, +): Promise => { + if (!/^[a-f0-9]{24}$/i.test(podId) || !installations.length) return false; + const first = installations[0]; + const owner = String(first.installedBy || ''); + const instanceId = first.instanceId || 'default'; + if (!/^[a-f0-9]{24}$/i.test(owner) || installations.some((row) => ( + String(row.installedBy || '') !== owner || row.agentName !== first.agentName + || (row.instanceId || 'default') !== instanceId + )) || !installations.some((row) => String(row.podId) === podId)) return false; + const listener = await User.findOne({ + username: AgentIdentityService.buildAgentUsername( + AgentIdentityService.resolveAgentType(first.agentName), instanceId, + ), + isBot: true, + }).select('_id').lean(); + if (!listener || (agentUser && String(agentUser._id) !== String(listener._id))) return false; + const human = await User.findById(owner).select('isBot botType').lean(); + if (!human || human.isBot || human.botType) return false; + const pod = await Pod.findById(new Types.ObjectId(podId)) + .select('type createdBy members joinPolicy publicRead communityListed').lean(); + if (!pod || ['agent-dm', 'agent-room', 'agent-admin'].includes(pod.type) + || pod.joinPolicy !== 'invite-only' || pod.publicRead !== false + || pod.communityListed !== false || String(pod.createdBy) !== owner) return false; + const members = (pod.members || []).map(String); + if (!members.includes(owner) || !members.includes(String(listener._id)) + || members.some((member) => member !== owner && member !== String(listener._id))) return false; + return !await podIsBridged(String(pod._id)); +}; diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 9753b978ba..d01ee8517a 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -47,7 +47,7 @@ await jest.unstable_mockModule('../src/lib/pod-focus.js', () => ({ })); const { createClient } = await import('../src/lib/api.js'); -const { performRun } = await import('../src/commands/agent.js'); +const { performRun } = await import('../src/runtime.js'); const { getSession, setSession, diff --git a/cli/src/runtime.js b/cli/src/runtime.js new file mode 100644 index 0000000000..8a2a185eb2 --- /dev/null +++ b/cli/src/runtime.js @@ -0,0 +1,23 @@ +/** + * In-process runtime entry for harness plugins. This is the same consumer used + * by `commonly agent run`: claims, cascade admission, pod-separated batches, + * memory, delivery suppression and acknowledgement stay in the CLI. + * + * A native adapter implements `spawn(prompt, options)` and returns a Promise + * of `{ text, newSessionId? }`. Resolve only for the exact completed input, + * never merely because a shared session became idle. `options.metadata.event` + * identifies the source pod/batch; native adapters must keep separate sessions + * per pod. Runtime tokens in options are credentials, never prompt content. + * + * `performRun` returns `{ stop() }`. Stop prevents further polls but does not + * cancel an already-started adapter or HTTP request. A plugin owns its adapter + * cancellation/lifetime and must not cancel unrelated human work. + * + * This entry does not register/login an agent, enforce an OS sandbox around + * an in-process harness, or make mutable global process.env safe per turn. + * Full-tool native sessions must verify each bound pod through the deployed + * GET /api/agents/runtime/pods/:podId/operator-only policy before a turn and + * before releasing its response. This entry alone does not authorize a pod. + * Do not replace adapter-scoped credentials with a shared global token. + */ +export { performRun } from './commands/agent.js';