diff --git a/backend/__tests__/unit/middleware/logAgentInstallRefusal.test.js b/backend/__tests__/unit/middleware/logAgentInstallRefusal.test.js new file mode 100644 index 0000000000..b08e247efc --- /dev/null +++ b/backend/__tests__/unit/middleware/logAgentInstallRefusal.test.js @@ -0,0 +1,63 @@ +const { logAgentInstallRefusal } = require('../../../middleware/logAgentInstallRefusal'); + +describe('agent install refusal logging', () => { + let warn; + + beforeEach(() => { warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { warn.mockRestore(); }); + + test('machine placement refusal records wrapper and omits credentials', () => { + const req = { + body: { agentName: 'my-agent', instanceId: 'default', machineId: 'machine-1', token: 'secret' }, + user: { id: 'owner-1' }, + }; + const res = { + statusCode: 200, + locals: {}, + json: jest.fn(), + }; + const next = jest.fn(); + logAgentInstallRefusal('/api/agent-binding/request', 'wrapper')(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + res.statusCode = 404; + res.json({ message: 'Agent identity not found' }); + expect(warn).toHaveBeenCalledTimes(1); + const line = JSON.parse(warn.mock.calls[0][1]); + expect(line).toEqual({ + route: '/api/agent-binding/request', + agentName: 'my-agent', + instanceId: 'default', + runtimeType: 'wrapper', + reason: 'agent_identity_not_found', + userId: 'owner-1', + }); + expect(warn.mock.calls[0].join(' ')).not.toContain('secret'); + expect(warn.mock.calls[0].join(' ')).not.toContain('machine-1'); + }); + + test('credential-shaped values in logged fields are redacted before serialization', () => { + const credential = 'cm_agent_example123'; + const req = { + body: { + agentName: credential, + instanceId: credential, + config: { runtime: { runtimeType: credential } }, + }, + user: { id: 'owner-1' }, + }; + const res = { statusCode: 404, locals: {}, json: jest.fn() }; + logAgentInstallRefusal('/api/registry/install')(req, res, () => {}); + res.json({ error: 'Agent not found in registry' }); + expect(warn).toHaveBeenCalledTimes(1); + const fullLine = warn.mock.calls[0].join(' '); + expect(fullLine).not.toContain(credential); + expect(JSON.parse(warn.mock.calls[0][1])).toEqual({ + route: '/api/registry/install', + agentName: '[redacted]', + instanceId: '[redacted]', + runtimeType: 'unknown', + reason: 'agent_not_found_in_registry', + userId: 'owner-1', + }); + }); +}); diff --git a/backend/__tests__/unit/routes/registry.install-reserved-names.test.js b/backend/__tests__/unit/routes/registry.install-reserved-names.test.js index 4eaadc619c..e94a328a91 100644 --- a/backend/__tests__/unit/routes/registry.install-reserved-names.test.js +++ b/backend/__tests__/unit/routes/registry.install-reserved-names.test.js @@ -87,7 +87,12 @@ const buildSelectLeanChain = (result) => ({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue(result) }), }); -const makeRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); +const makeRes = () => ({ + statusCode: 200, + locals: {}, + status: jest.fn(function status(code) { this.statusCode = code; return this; }), + json: jest.fn(), +}); const officialRow = (agentName) => ({ @@ -99,7 +104,7 @@ const officialRow = (agentName) => ({ manifest: { context: { required: [] }, runtime: { type: 'standalone' } }, }); -const installAs = async ({ role = 'user', agentName, runtimeType }) => { +const installAs = async ({ role = 'user', agentName, runtimeType, recordRefusal = false }) => { User.findById.mockReturnValue(buildSelectLeanChain({ username: 'installer', role, entitlements: { cloudAgents: true } })); const req = { body: { @@ -113,6 +118,10 @@ const installAs = async ({ role = 'user', agentName, runtimeType }) => { userId: 'user-1', }; const res = makeRes(); + if (recordRefusal) { + const layer = installRouter.stack.find((entry) => entry.route?.path === '/install'); + layer.route.stack[0].handle(req, res, () => {}); + } await installHandler(req, res); return res; }; @@ -173,4 +182,25 @@ describe('registry install — built-in names are reserved for self-serve instal const res = await installAs({ agentName: 'standup-bot', runtimeType: 'webhook' }); expect(reservedCalls(res)).toHaveLength(0); }); + + test('the registry 404 emits one structured refusal line without credentials', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + AgentRegistry.getByName.mockResolvedValue(null); + const res = await installAs({ agentName: 'new-agent', runtimeType: 'unsupported', recordRefusal: true }); + expect(res.statusCode).toBe(404); + const lines = warn.mock.calls.filter(([prefix]) => prefix === '[agent-install-refused]'); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0][1])).toEqual({ + route: '/api/registry/install', + agentName: 'new-agent', + instanceId: 'default', + runtimeType: 'unknown', + reason: 'agent_not_found_in_registry', + userId: 'user-1', + }); + } finally { + warn.mockRestore(); + } + }); }); diff --git a/backend/middleware/logAgentInstallRefusal.ts b/backend/middleware/logAgentInstallRefusal.ts new file mode 100644 index 0000000000..0658eb7ba4 --- /dev/null +++ b/backend/middleware/logAgentInstallRefusal.ts @@ -0,0 +1,74 @@ +import { NextFunction, Request, Response } from 'express'; + +type RefusalBody = { code?: unknown; error?: unknown; message?: unknown; msg?: unknown }; + +// Only static response text is mapped. Unknown responses retain a bounded HTTP +// code rather than turning user-supplied error text into a log dimension. +const reasonByMessage: Record = { + 'Agent not found in registry': 'agent_not_found_in_registry', + 'Agent identity not found': 'agent_identity_not_found', + 'Machine not found': 'machine_not_found', + 'Pod not found': 'pod_not_found', + Unauthorized: 'unauthorized', + 'agentName required': 'agent_name_required', + 'agentName must be a string': 'agent_name_invalid', + 'machineId required (null to withdraw)': 'machine_id_required', +}; + +const runtimeTypes = new Set([ + 'webhook', 'wrapper', 'hosted', 'native', 'moltbot', 'internal', + 'managed-agents', 'claude-code', 'codex', +]); + +const credentialPrefix = /^(?:cm[_-]agent[_-]|gh[opurs][_-]|github[_-]pat[_-]|sk-|xox[abprs]-|bearer|eyj)/i; + +const logIdentity = (value: unknown, allowScopedName = false): string | null => { + if (typeof value !== 'string') return null; + const name = value.trim().toLowerCase(); + const valid = allowScopedName + ? /^(@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(name) + : /^[a-z0-9-]+$/.test(name); + // Refused requests can supply credentials in place of names. Never "clean" + // an invalid value into something that resembles a safe identity. + if (!valid || name.length > 64 || credentialPrefix.test(name)) return '[redacted]'; + return name || null; +}; + +const logRuntimeType = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + const runtimeType = value.trim().toLowerCase(); + return runtimeTypes.has(runtimeType) ? runtimeType : 'unknown'; +}; + +const reasonCode = (body: RefusalBody, status: number): string => { + if (typeof body.code === 'string' + && /^[a-z][a-z0-9_]{0,79}$/.test(body.code) + && !credentialPrefix.test(body.code)) return body.code; + const message = body.error || body.message || body.msg; + return typeof message === 'string' ? reasonByMessage[message] || `http_${status}` : `http_${status}`; +}; + +/** Record one structured line for each refused install or machine placement. */ +export const logAgentInstallRefusal = (route: string, defaultRuntimeType?: string) => ( + req: Request & { user?: { id?: unknown } }, res: Response, next: NextFunction, +): void => { + const originalJson = res.json.bind(res); + let logged = false; + res.json = ((body: RefusalBody) => { + if (!logged && res.statusCode >= 400) { + logged = true; + const requestBody = req.body && typeof req.body === 'object' ? req.body : {}; + const runtime = requestBody.config?.runtime?.runtimeType || requestBody.runtimeType || defaultRuntimeType; + console.warn('[agent-install-refused]', JSON.stringify({ + route, + agentName: logIdentity(requestBody.agentName, true), + instanceId: logIdentity(res.locals.agentInstallInstanceId || requestBody.instanceId) || 'default', + runtimeType: logRuntimeType(runtime), + reason: reasonCode(body && typeof body === 'object' ? body : {}, res.statusCode), + userId: req.user?.id == null ? null : String(req.user.id), + })); + } + return originalJson(body); + }) as Response['json']; + next(); +}; diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts index f7ecfa6aa0..9c5bbe08e8 100644 --- a/backend/routes/agentBinding.ts +++ b/backend/routes/agentBinding.ts @@ -20,6 +20,7 @@ import { seatEnvironmentKey, } from '../services/seatEnvironmentProjection'; import { ownsRuntimeIdentity, OwnershipFailure } from '../services/runtimeIdentityOwnership'; +import { logAgentInstallRefusal } from '../middleware/logAgentInstallRefusal'; // eslint-disable-next-line @typescript-eslint/no-require-imports const auth = require('../middleware/auth'); @@ -201,7 +202,7 @@ router.post('/adopt', bindingRateLimit, daemonAuth('agents:adopt'), async (req: // machine sees the request in /assigned and performs the adopt CAS itself, // so D3's "no adoption without a user choice" and "one binding writer" both // hold. machineId: null withdraws the request. -router.post('/request', bindingRateLimit, auth, async (req: express.Request & { user?: { id?: string } }, res: express.Response) => { +router.post('/request', logAgentInstallRefusal('/api/agent-binding/request', 'wrapper'), bindingRateLimit, auth, async (req: express.Request & { user?: { id?: string } }, res: express.Response) => { try { const agentName = normalize(req.body?.agentName); const instanceId = normalize(req.body?.instanceId) || 'default'; diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index 401143250f..24c07439c3 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -49,6 +49,7 @@ const { const { validateEnvironmentMcpEntries } = require('../../utils/environmentSpecValidation'); const { willSolelyInstallRuntimeIdentity, webhookUrlRefusal } = require('../../services/runtimeIdentityOwnership'); const { personAgentWakeDefault } = require('../../services/wakeDefaultService'); +const { logAgentInstallRefusal } = require('../../middleware/logAgentInstallRefusal'); const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy'); // Inlined per-route limiter. This comment used to attribute its clean CodeQL @@ -151,7 +152,7 @@ const findExistingAgentInstance = async (agentName: any, instanceId: any) => { * POST /api/registry/install * Install an agent to a pod */ -installRouter.post('/install', installRateLimit, auth, async (req: any, res: any) => { +installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), installRateLimit, auth, async (req: any, res: any) => { try { const { agentName, podId, version, config = {}, scopes = [], instanceId, displayName, gatewayId, description, @@ -245,6 +246,7 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any } else { normalizedInstanceId = deriveInstanceId(displayName, agentName); } + if (res.locals) res.locals.agentInstallInstanceId = normalizedInstanceId; let agent = await AgentRegistry.getByName(agentName);