Compare commits

...

15 Commits

Author SHA1 Message Date
Jarvis
726f7ab772 test(tess): cover streamed private key redaction
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-12 19:43:53 -05:00
Jarvis
fdd58a353b fix(tess): harden redacted stream boundaries 2026-07-12 19:39:37 -05:00
Jarvis
70851d9474 fix(tess): preserve redaction across stream chunks 2026-07-12 19:32:58 -05:00
Jarvis
98b4f95284 test(tess): cover redacted chat egress 2026-07-12 19:23:14 -05:00
Jarvis
0de3d51466 fix(tess): redact chat persistence and egress 2026-07-12 19:19:06 -05:00
Jarvis
5f9067cf57 fix(tess): redact sensitive runtime content 2026-07-12 19:18:37 -05:00
e92186d768 feat: add Tess runtime provider registry (#722)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 23:53:54 +00:00
119f64e69d feat(#707): secure Discord service ingress (#716)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 23:18:29 +00:00
46ca3ce742 fix(tess): enforce command authorization approvals (#718)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-12 23:18:01 +00:00
227b73fcdf fix(security): enforce Tess MCP server identity (#717)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 22:49:00 +00:00
a959b1d6b4 ci: restrict push-event CI to protected branches (halve feature-branch load) (#721)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:48:58 +00:00
62f8177806 feat(tess): define runtime provider contract (#719)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-12 22:29:21 +00:00
353e43c947 fix: enforce Tess session ownership scope (#715)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:14:17 +00:00
ca9c2b5c23 restore Tess markdown formatting gate (v2, non-author) (#714)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-07-12 21:32:16 +00:00
b580d37d51 Fixes #703 (#705)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-12 20:49:43 +00:00
65 changed files with 4351 additions and 395 deletions

View File

@@ -5,3 +5,5 @@ pnpm-lock.yaml
**/drizzle **/drizzle
**/.next **/.next
.claude/ .claude/
docs/tess/TASKS.md
docs/scratchpads/

View File

@@ -7,7 +7,14 @@ variables:
- &enable_pnpm 'corepack enable' - &enable_pnpm 'corepack enable'
when: when:
- event: [push, pull_request, manual] # PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
# push CI is restricted to protected branches (main) so a feature-branch push no
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
# CI load on the storage-constrained runner with zero loss of gating (branch
# protection requires no push/ci status context; main still gets full push CI).
- event: [pull_request, manual]
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker # Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN). # repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).

View File

@@ -31,6 +31,7 @@
"@mariozechner/pi-ai": "^0.65.0", "@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0", "@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1", "@modelcontextprotocol/sdk": "^1.27.1",
"@mosaicstack/agent": "workspace:^",
"@mosaicstack/auth": "workspace:^", "@mosaicstack/auth": "workspace:^",
"@mosaicstack/brain": "workspace:^", "@mosaicstack/brain": "workspace:^",
"@mosaicstack/config": "workspace:^", "@mosaicstack/config": "workspace:^",

View File

@@ -20,7 +20,7 @@ export class AdminHealthController {
async check(): Promise<HealthStatusDto> { async check(): Promise<HealthStatusDto> {
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]); const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
const sessions = this.agentService.listSessions(); const sessions = this.agentService.listAllSessionsForSystem();
const providers = this.providerService.listProviders(); const providers = this.providerService.listProviders();
const allOk = database.status === 'ok' && cache.status === 'ok'; const allOk = database.status === 'ok' && cache.status === 'ok';

View File

@@ -0,0 +1,142 @@
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { AgentService, type AgentSession } from '../agent.service.js';
import type { ActorTenantScope } from '../../auth/session-scope.js';
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
type AgentServiceInternals = {
sessions: Map<string, AgentSession>;
creating: Map<string, Promise<AgentSession>>;
};
function makeService(): AgentService {
return new AgentService(
{} as never,
{} as never,
{} as never,
{ available: false } as never,
{} as never,
{} as never,
{} as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
);
}
function internals(service: AgentService): AgentServiceInternals {
return service as unknown as AgentServiceInternals;
}
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp/tess-session-ownership-test',
allowedTools: null,
userId: scope.userId,
tenantId: scope.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
describe('AgentService owner/tenant scope enforcement', () => {
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
const service = makeService();
const session = makeSession();
internals(service).sessions.set(CONVERSATION_ID, session);
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
expect(session.channels.has('websocket:owner')).toBe(true);
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() =>
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
expect(session.modelId).toBe('owner-model');
expect(() =>
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
expect(session.agentConfigId).toBe('agent-owner');
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
cleanup();
await expect(
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
expect(session.piSession.dispose).toHaveBeenCalled();
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
});
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
const service = makeService();
const session = makeSession();
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
await expect(
service.createSession(CONVERSATION_ID, {
userId: FOREIGN_SCOPE.userId,
tenantId: FOREIGN_SCOPE.tenantId,
}),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
service.createSession(CONVERSATION_ID, {
userId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
}),
).resolves.toBe(session);
});
});

View File

@@ -0,0 +1,285 @@
import { describe, expect, it } from 'vitest';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import type { ActorTenantScope } from '../../auth/session-scope.js';
import {
RuntimeProviderService,
type RuntimeAuditEvent,
type RuntimeAuditSink,
type RuntimeApprovalVerifier,
} from '../runtime-provider-registry.service.js';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
const CONTEXT = {
actorScope: OWNER_SCOPE,
channelId: 'cli',
correlationId: 'correlation-1',
};
class RecordingRuntimeProvider implements AgentRuntimeProvider {
readonly id = 'fleet';
readonly receivedScopes: RuntimeScope[] = [];
readonly sentMessages: RuntimeMessage[] = [];
terminateCalls = 0;
throwAfterSend = false;
constructor(private readonly supported: RuntimeCapability[]) {}
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
this.receivedScopes.push(scope);
return { supported: this.supported };
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
this.receivedScopes.push(scope);
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
this.receivedScopes.push(scope);
return [];
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
this.receivedScopes.push(scope);
return [];
}
async *streamSession(
_sessionId: string,
_cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
this.receivedScopes.push(scope);
return;
}
async sendMessage(
_sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
this.receivedScopes.push(scope);
this.sentMessages.push(message);
if (this.throwAfterSend) {
throw new Error('provider acknowledgement failed');
}
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
this.receivedScopes.push(scope);
return {
attachmentId: 'attachment-1',
sessionId,
mode,
expiresAt: '2026-07-12T00:00:00.000Z',
};
}
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
}
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
this.terminateCalls += 1;
}
}
class RecordingAuditSink implements RuntimeAuditSink {
readonly events: RuntimeAuditEvent[] = [];
async record(event: RuntimeAuditEvent): Promise<void> {
this.events.push(event);
}
}
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
async consume(): Promise<boolean> {
return false;
}
}
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
async consume(
_approvalRef: string,
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
): Promise<boolean> {
this.consumedAction = action;
return true;
}
}
function makeService(
provider: RecordingRuntimeProvider,
audit: RuntimeAuditSink = new RecordingAuditSink(),
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
): RuntimeProviderService {
const registry = new AgentRuntimeProviderRegistry();
registry.register(provider);
return new RuntimeProviderService(registry, audit, approval);
}
describe('RuntimeProviderService security boundary', (): void => {
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
);
const providerScope = provider.receivedScopes[0];
expect(providerScope).toEqual({
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
});
expect(Object.isFrozen(providerScope)).toBe(true);
expect(audit.events).toContainEqual({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
resourceId: 'session-1',
});
expect(JSON.stringify(audit.events)).not.toContain('hello');
expect(JSON.stringify(audit.events)).not.toContain('key-1');
});
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider([]);
const service = makeService(provider);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/capability denied/);
expect(provider.sentMessages).toEqual([]);
});
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new DenyingApprovalVerifier();
const service = makeService(provider, new RecordingAuditSink(), approval);
await expect(
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
).rejects.toThrow(/approval denied/);
expect(provider.terminateCalls).toBe(0);
});
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new AcceptingApprovalVerifier();
const service = makeService(provider, new RecordingAuditSink(), approval);
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
expect(approval.consumedAction).toEqual({
providerId: 'fleet',
sessionId: 'session-1',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
});
expect(provider.terminateCalls).toBe(1);
});
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const unavailableAudit: RuntimeAuditSink = {
async record(): Promise<void> {
throw new Error('audit unavailable');
},
};
const service = makeService(provider, unavailableAudit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/audit unavailable/);
expect(provider.sentMessages).toEqual([]);
});
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAfterSend = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider acknowledgement failed/);
expect(provider.sentMessages).toHaveLength(1);
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
'requested',
'failed',
]);
});
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let auditCalls = 0;
const audit: RuntimeAuditSink = {
async record(): Promise<void> {
auditCalls += 1;
if (auditCalls === 2) {
throw new Error('completion audit unavailable');
}
},
};
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).resolves.toBeUndefined();
expect(provider.sentMessages).toHaveLength(1);
expect(auditCalls).toBe(2);
});
});

View File

@@ -0,0 +1,262 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
vi.mock('../../commands/command-executor.service.js', () => ({
CommandExecutorService: class CommandExecutorService {},
}));
vi.mock('../routing/routing-engine.service.js', () => ({
RoutingEngineService: class RoutingEngineService {},
}));
import { SessionsController } from '../sessions.controller.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
promptCount: 0,
channels: [],
durationMs: 0,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
...overrides,
};
}
function makeAgentSession(owner = USER_A): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp',
allowedTools: null,
userId: owner.id,
tenantId: owner.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
),
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
),
destroySession: vi.fn(),
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
recordMessage: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
}
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain('listSessions(scope: ActorTenantScope)');
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain(
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
);
expect(source).toContain('scope: ActorTenantScope,');
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
expect(source).not.toContain('scope?: ActorTenantScope');
});
});
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
expect(agentService.listSessions).toHaveBeenCalledWith({
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not reveal another owner/tenant session by guessed id', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not terminate another owner/tenant session by guessed id', async () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
NotFoundException,
);
expect(agentService.destroySession).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
const agentService = makeScopedAgentService();
const controller = new ChatController(agentService as never);
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.prompt).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
function makeGateway(agentService = makeScopedAgentService()) {
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
const commandExecutor = { execute: vi.fn() };
const routingEngine = {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
commandRegistry as never,
commandExecutor as never,
routingEngine as never,
);
return { gateway, agentService };
}
function makeSocket() {
return {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
}
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
});
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.onEvent).not.toHaveBeenCalled();
expect(agentService.addChannel).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
});

View File

@@ -1,4 +1,5 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import { AgentService } from './agent.service.js'; import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js'; import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js'; import { ProviderCredentialsService } from './provider-credentials.service.js';
@@ -13,6 +14,14 @@ import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js'; import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js'; import { GCModule } from '../gc/gc.module.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
DenyRuntimeApprovalVerifier,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
@Global() @Global()
@Module({ @Module({
@@ -23,6 +32,21 @@ import { GCModule } from '../gc/gc.module.js';
RoutingService, RoutingService,
RoutingEngineService, RoutingEngineService,
SkillLoaderService, SkillLoaderService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
},
RuntimeProviderAuditService,
{
provide: RUNTIME_PROVIDER_AUDIT_SINK,
useExisting: RuntimeProviderAuditService,
},
DenyRuntimeApprovalVerifier,
{
provide: RUNTIME_APPROVAL_VERIFIER,
useExisting: DenyRuntimeApprovalVerifier,
},
RuntimeProviderService,
AgentService, AgentService,
], ],
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController], controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
@@ -33,6 +57,8 @@ import { GCModule } from '../gc/gc.module.js';
RoutingService, RoutingService,
RoutingEngineService, RoutingEngineService,
SkillLoaderService, SkillLoaderService,
RuntimeProviderService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
], ],
}) })
export class AgentModule {} export class AgentModule {}

View File

@@ -1,4 +1,11 @@
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common'; import {
ForbiddenException,
Inject,
Injectable,
Logger,
Optional,
type OnModuleDestroy,
} from '@nestjs/common';
import { import {
createAgentSession, createAgentSession,
DefaultResourceLoader, DefaultResourceLoader,
@@ -28,6 +35,7 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
import { SystemOverrideService } from '../preferences/system-override.service.js'; import { SystemOverrideService } from '../preferences/system-override.service.js';
import { PreferencesService } from '../preferences/preferences.service.js'; import { PreferencesService } from '../preferences/preferences.service.js';
import { SessionGCService } from '../gc/session-gc.service.js'; import { SessionGCService } from '../gc/session-gc.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
/** A single message from DB conversation history, used for context injection. */ /** A single message from DB conversation history, used for context injection. */
export interface ConversationHistoryMessage { export interface ConversationHistoryMessage {
@@ -68,6 +76,8 @@ export interface AgentSessionOptions {
agentConfigId?: string; agentConfigId?: string;
/** ID of the user who owns this session. Used for preferences and system override lookups. */ /** ID of the user who owns this session. Used for preferences and system override lookups. */
userId?: string; userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/** /**
* Prior conversation messages to inject as context when resuming a session. * Prior conversation messages to inject as context when resuming a session.
* These messages are formatted and prepended to the system prompt so the * These messages are formatted and prepended to the system prompt so the
@@ -94,6 +104,8 @@ export interface AgentSession {
allowedTools: string[] | null; allowedTools: string[] | null;
/** User ID that owns this session, used for preference lookups. */ /** User ID that owns this session, used for preference lookups. */
userId?: string; userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/** Agent config ID applied to this session, if any (M5-001). */ /** Agent config ID applied to this session, if any (M5-001). */
agentConfigId?: string; agentConfigId?: string;
/** Human-readable agent name applied to this session, if any (M5-001). */ /** Human-readable agent name applied to this session, if any (M5-001). */
@@ -174,12 +186,20 @@ export class AgentService implements OnModuleDestroy {
.filter((t) => t.length > 0); .filter((t) => t.length > 0);
} }
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> { async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
const scope = this.scopeFromOptions(options);
const existing = this.sessions.get(sessionId); const existing = this.sessions.get(sessionId);
if (existing) return existing; if (existing) {
this.assertSessionScope(existing, scope);
return existing;
}
const inflight = this.creating.get(sessionId); const inflight = this.creating.get(sessionId);
if (inflight) return inflight; if (inflight) {
const session = await inflight;
this.assertSessionScope(session, scope);
return session;
}
const promise = this.doCreateSession(sessionId, options).finally(() => { const promise = this.doCreateSession(sessionId, options).finally(() => {
this.creating.delete(sessionId); this.creating.delete(sessionId);
@@ -342,6 +362,7 @@ export class AgentService implements OnModuleDestroy {
sandboxDir, sandboxDir,
allowedTools, allowedTools,
userId: mergedOptions?.userId, userId: mergedOptions?.userId,
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
agentConfigId: mergedOptions?.agentConfigId, agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName, agentName: resolvedAgentName,
metrics: { metrics: {
@@ -473,38 +494,70 @@ export class AgentService implements OnModuleDestroy {
return this.providerService.getDefaultModel() ?? null; return this.providerService.getDefaultModel() ?? null;
} }
getSession(sessionId: string): AgentSession | undefined { getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
return this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
return session;
} }
listSessions(): SessionInfoDto[] { listSessions(scope: ActorTenantScope): SessionInfoDto[] {
const now = Date.now(); const now = Date.now();
return Array.from(this.sessions.values()).map((s) => ({ return Array.from(this.sessions.values())
id: s.id, .filter((s) => this.sessionMatchesScope(s, scope))
provider: s.provider, .map((s) => this.toSessionInfo(s, now));
modelId: s.modelId,
...(s.agentName ? { agentName: s.agentName } : {}),
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: now - s.createdAt,
metrics: { ...s.metrics },
}));
} }
getSessionInfo(sessionId: string): SessionInfoDto | undefined { listAllSessionsForSystem(): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
const s = this.sessions.get(sessionId); const s = this.sessions.get(sessionId);
if (!s) return undefined; if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
return this.toSessionInfo(s);
}
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
if (!options.userId) {
throw new ForbiddenException('Session owner scope is required');
}
return { return {
id: s.id, userId: options.userId,
provider: s.provider, tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
modelId: s.modelId, };
...(s.agentName ? { agentName: s.agentName } : {}), }
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount, private tenantIdFor(
channels: Array.from(s.channels), userId: string | undefined,
durationMs: Date.now() - s.createdAt, tenantId: string | undefined,
metrics: { ...s.metrics }, ): string | undefined {
return tenantId ?? userId;
}
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
return (
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
);
}
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
if (!this.sessionMatchesScope(session, scope)) {
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
}
}
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
return {
id: session.id,
provider: session.provider,
modelId: session.modelId,
...(session.agentName ? { agentName: session.agentName } : {}),
createdAt: new Date(session.createdAt).toISOString(),
promptCount: session.promptCount,
channels: Array.from(session.channels),
durationMs: now - session.createdAt,
metrics: { ...session.metrics },
}; };
} }
@@ -553,9 +606,10 @@ export class AgentService implements OnModuleDestroy {
* not reconstructed — the model is used on the next createSession call for * not reconstructed — the model is used on the next createSession call for
* the same conversationId when the session is torn down or a new one is created. * the same conversationId when the session is torn down or a new one is created.
*/ */
updateSessionModel(sessionId: string, modelId: string): void { updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return; if (!session) return;
this.assertSessionScope(session, scope);
const prev = session.modelId; const prev = session.modelId;
session.modelId = modelId; session.modelId = modelId;
this.recordModelSwitch(sessionId); this.recordModelSwitch(sessionId);
@@ -572,48 +626,51 @@ export class AgentService implements OnModuleDestroy {
sessionId: string, sessionId: string,
agentConfigId: string, agentConfigId: string,
agentName: string, agentName: string,
scope: ActorTenantScope,
modelId?: string, modelId?: string,
): void { ): void {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return; if (!session) return;
this.assertSessionScope(session, scope);
session.agentConfigId = agentConfigId; session.agentConfigId = agentConfigId;
session.agentName = agentName; session.agentName = agentName;
if (modelId) { if (modelId) {
this.updateSessionModel(sessionId, modelId); this.updateSessionModel(sessionId, modelId, scope);
} }
this.logger.log( this.logger.log(
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`, `Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
); );
} }
addChannel(sessionId: string, channel: string): void { addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (session) { if (!session) return;
this.assertSessionScope(session, scope);
session.channels.add(channel); session.channels.add(channel);
} }
}
removeChannel(sessionId: string, channel: string): void { removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (session) { if (!session) return;
this.assertSessionScope(session, scope);
session.channels.delete(channel); session.channels.delete(channel);
} }
}
async prompt(sessionId: string, message: string): Promise<void> { async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
throw new Error(`No agent session found: ${sessionId}`); throw new Error(`No agent session found: ${sessionId}`);
} }
this.assertSessionScope(session, scope);
session.promptCount += 1; session.promptCount += 1;
// Prepend session-scoped system override if present (renew TTL on each turn) // Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = message; let effectiveMessage = message;
if (this.systemOverride) { if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId); const override = await this.systemOverride.get(sessionId, scope);
if (override) { if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`; effectiveMessage = `[System Override]\n${override}\n\n${message}`;
await this.systemOverride.renew(sessionId); await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`); this.logger.debug(`Applied system override for session ${sessionId}`);
} }
} }
@@ -629,16 +686,28 @@ export class AgentService implements OnModuleDestroy {
} }
} }
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void { onEvent(
sessionId: string,
listener: (event: AgentSessionEvent) => void,
scope: ActorTenantScope,
): () => void {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
throw new Error(`No agent session found: ${sessionId}`); throw new Error(`No agent session found: ${sessionId}`);
} }
this.assertSessionScope(session, scope);
session.listeners.add(listener); session.listeners.add(listener);
return () => session.listeners.delete(listener); return () => session.listeners.delete(listener);
} }
async destroySession(sessionId: string): Promise<void> { async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
await this.destroySessionForSystem(sessionId);
}
private async destroySessionForSystem(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return; if (!session) return;
this.logger.log(`Destroying agent session ${sessionId}`); this.logger.log(`Destroying agent session ${sessionId}`);
@@ -667,7 +736,7 @@ export class AgentService implements OnModuleDestroy {
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
this.logger.log('Shutting down all agent sessions'); this.logger.log('Shutting down all agent sessions');
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id)); const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
const results = await Promise.allSettled(stops); const results = await Promise.allSettled(stops);
for (const result of results) { for (const result of results) {
if (result.status === 'rejected') { if (result.status === 'rejected') {

View File

@@ -0,0 +1,404 @@
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
export type RuntimeProviderOperation =
| RuntimeCapability
| 'runtime.capabilities'
| 'runtime.health';
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
export interface RuntimeProviderRequestContext {
actorScope: ActorTenantScope;
channelId: string;
correlationId: string;
}
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
export interface RuntimeAuditEvent {
providerId: string;
operation: RuntimeProviderOperation;
outcome: RuntimeProviderAuditOutcome;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
resourceId?: string;
}
export interface RuntimeAuditSink {
record(event: RuntimeAuditEvent): Promise<void>;
}
/** Exact action shape that a durable approval implementation must consume once. */
export interface RuntimeTerminationAction {
providerId: string;
sessionId: string;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
}
export interface RuntimeApprovalVerifier {
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
}
class RuntimeApprovalDeniedError extends Error {
constructor() {
super('Runtime termination approval denied');
}
}
/**
* The default denies all runtime termination until a durable, exact-action
* approval implementation is configured. This is safer than a permissive stub.
*/
@Injectable()
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
return false;
}
}
/**
* Temporary metadata-only audit sink. M1 observability can replace this token
* with a durable audit writer without changing provider call sites.
*/
@Injectable()
export class RuntimeProviderAuditService implements RuntimeAuditSink {
private readonly logger = new Logger(RuntimeProviderAuditService.name);
async record(event: RuntimeAuditEvent): Promise<void> {
this.logger.log(JSON.stringify(event));
}
}
@Injectable()
export class RuntimeProviderService {
private readonly logger = new Logger(RuntimeProviderService.name);
constructor(
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
private readonly registry: AgentRuntimeProviderRegistry,
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly audit: RuntimeAuditSink,
@Inject(RUNTIME_APPROVAL_VERIFIER)
private readonly approvals: RuntimeApprovalVerifier,
) {}
async capabilities(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeCapabilitySet> {
return this.execute(
providerId,
'runtime.capabilities',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
provider.capabilities(scope),
);
}
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
return this.execute(
providerId,
'runtime.health',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
provider.health(scope),
);
}
async listSessions(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSession[]> {
return this.execute(
providerId,
'session.list',
'session.list',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
provider.listSessions(scope),
);
}
async getSessionTree(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSessionTree[]> {
return this.execute(
providerId,
'session.tree',
'session.tree',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
provider.getSessionTree(scope),
);
}
streamSession(
providerId: string,
sessionId: string,
cursor: string | undefined,
context: RuntimeProviderRequestContext,
): AsyncIterable<RuntimeStreamEvent> {
return this.stream(
providerId,
'session.stream',
'session.stream',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
provider.streamSession(sessionId, cursor, scope),
);
}
async sendMessage(
providerId: string,
sessionId: string,
message: RuntimeMessage,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.send',
'session.send',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.sendMessage(sessionId, message, scope),
);
}
async attach(
providerId: string,
sessionId: string,
mode: RuntimeAttachMode,
context: RuntimeProviderRequestContext,
): Promise<RuntimeAttachHandle> {
return this.execute(
providerId,
'session.attach',
'session.attach',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
provider.attach(sessionId, mode, scope),
);
}
async detach(
providerId: string,
attachmentId: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.attach',
'session.attach',
attachmentId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.detach(attachmentId, scope),
);
}
async terminate(
providerId: string,
sessionId: string,
approvalRef: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.terminate',
'session.terminate',
sessionId,
context,
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
const approved = await this.approvals.consume(approvalRef, {
providerId,
sessionId,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
});
if (!approved) {
throw new RuntimeApprovalDeniedError();
}
await provider.terminate(sessionId, approvalRef, scope);
},
);
}
private async execute<T>(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability | undefined,
resourceId: string | undefined,
context: RuntimeProviderRequestContext,
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
): Promise<T> {
const scope = this.deriveScope(context);
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
if (requiredCapability) {
await this.assertCapability(provider, requiredCapability, scope);
}
invocationStarted = true;
const result = await invoke(provider, scope);
await this.recordCompletion(providerId, operation, scope, resourceId);
return result;
} catch (error: unknown) {
if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) {
await this.recordFailure(providerId, operation, scope, resourceId);
} else {
await this.record(providerId, operation, 'denied', scope, resourceId);
}
throw error;
}
}
private async *stream(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability,
resourceId: string,
context: RuntimeProviderRequestContext,
invoke: (
provider: AgentRuntimeProvider,
scope: RuntimeScope,
) => AsyncIterable<RuntimeStreamEvent>,
): AsyncIterable<RuntimeStreamEvent> {
const scope = this.deriveScope(context);
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
await this.assertCapability(provider, requiredCapability, scope);
invocationStarted = true;
for await (const event of invoke(provider, scope)) {
yield event;
}
await this.recordCompletion(providerId, operation, scope, resourceId);
} catch (error: unknown) {
if (invocationStarted) {
await this.recordFailure(providerId, operation, scope, resourceId);
} else {
await this.record(providerId, operation, 'denied', scope, resourceId);
}
throw error;
}
}
private provider(providerId: string): AgentRuntimeProvider {
try {
return this.registry.require(providerId);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
throw new NotFoundException(message);
}
}
private async assertCapability(
provider: AgentRuntimeProvider,
requiredCapability: RuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
const capabilities = await provider.capabilities(scope);
if (!capabilities.supported.includes(requiredCapability)) {
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
}
}
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
const actorId = context.actorScope.userId.trim();
const tenantId = context.actorScope.tenantId.trim();
const channelId = context.channelId.trim();
const correlationId = context.correlationId.trim();
if (!actorId || !tenantId || !channelId || !correlationId) {
throw new ForbiddenException(
'Authenticated runtime actor scope and correlation are required',
);
}
return Object.freeze({ actorId, tenantId, channelId, correlationId });
}
private async recordFailure(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
): Promise<void> {
try {
await this.record(providerId, operation, 'failed', scope, resourceId);
} catch {
this.logger.error(
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async recordCompletion(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
): Promise<void> {
try {
await this.record(providerId, operation, 'succeeded', scope, resourceId);
} catch {
this.logger.error(
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async record(
providerId: string,
operation: RuntimeProviderOperation,
outcome: RuntimeProviderAuditOutcome,
scope: RuntimeScope,
resourceId: string | undefined,
): Promise<void> {
await this.audit.record({
providerId,
operation,
outcome,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
...(resourceId ? { resourceId } : {}),
});
}
}

View File

@@ -10,6 +10,8 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { AgentService } from './agent.service.js'; import { AgentService } from './agent.service.js';
@Controller('api/sessions') @Controller('api/sessions')
@@ -18,23 +20,24 @@ export class SessionsController {
constructor(@Inject(AgentService) private readonly agentService: AgentService) {} constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Get() @Get()
list() { list(@CurrentUser() user: AuthenticatedUserLike) {
const sessions = this.agentService.listSessions(); const sessions = this.agentService.listSessions(scopeFromUser(user));
return { sessions, total: sessions.length }; return { sessions, total: sessions.length };
} }
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const info = this.agentService.getSessionInfo(id); const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
if (!info) throw new NotFoundException('Session not found'); if (!info) throw new NotFoundException('Session not found');
return info; return info;
} }
@Delete(':id') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async destroy(@Param('id') id: string) { async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const info = this.agentService.getSessionInfo(id); const scope = scopeFromUser(user);
const info = this.agentService.getSessionInfo(id, scope);
if (!info) throw new NotFoundException('Session not found'); if (!info) throw new NotFoundException('Session not found');
await this.agentService.destroySession(id); await this.agentService.destroySession(id, scope);
} }
} }

View File

@@ -0,0 +1,24 @@
export interface AuthenticatedUserLike {
id: string;
tenantId?: string | null;
teamId?: string | null;
organizationId?: string | null;
orgId?: string | null;
}
export interface ActorTenantScope {
userId: string;
tenantId: string;
}
/**
* Build the immutable server-derived scope used for Tess session operations.
* Current Mosaic auth is user-scoped; future org/team claims can populate one
* of the tenant fields without allowing clients to choose another tenant.
*/
export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope {
return {
userId: user.id,
tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id,
};
}

View File

@@ -12,7 +12,8 @@ describe('Chat controller source hardening', () => {
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8'); const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect(source).toContain('@UseGuards(AuthGuard)'); expect(source).toContain('@UseGuards(AuthGuard)');
expect(source).toContain('@CurrentUser() user: { id: string }'); expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
expect(source).toContain('const scope = scopeFromUser(user);');
}); });
}); });

View File

@@ -3,8 +3,10 @@ import {
Post, Post,
Body, Body,
Logger, Logger,
ForbiddenException,
HttpException, HttpException,
HttpStatus, HttpStatus,
NotFoundException,
Inject, Inject,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { ChatRequestDto } from './chat.dto.js'; import { ChatRequestDto } from './chat.dto.js';
@@ -32,16 +35,23 @@ export class ChatController {
@Throttle({ default: { limit: 10, ttl: 60_000 } }) @Throttle({ default: { limit: 10, ttl: 60_000 } })
async chat( async chat(
@Body() body: ChatRequestDto, @Body() body: ChatRequestDto,
@CurrentUser() user: { id: string }, @CurrentUser() user: AuthenticatedUserLike,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
const conversationId = body.conversationId ?? uuid(); const conversationId = body.conversationId ?? uuid();
const scope = scopeFromUser(user);
try { try {
let agentSession = this.agentService.getSession(conversationId); let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) { if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId); agentSession = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
});
} }
} catch (err) { } catch (err) {
if (err instanceof ForbiddenException) {
throw new NotFoundException('Session not found');
}
this.logger.error( this.logger.error(
`Session creation failed for conversation=${conversationId}`, `Session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err), err instanceof Error ? err.stack : String(err),
@@ -60,8 +70,13 @@ export class ChatController {
reject(new Error('Agent response timed out')); reject(new Error('Agent response timed out'));
}, 120_000); }, 120_000);
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => { const cleanup = this.agentService.onEvent(
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') { conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta; responseText += event.assistantMessageEvent.delta;
} }
if (event.type === 'agent_end') { if (event.type === 'agent_end') {
@@ -69,11 +84,13 @@ export class ChatController {
cleanup(); cleanup();
resolve(); resolve();
} }
}); },
scope,
);
}); });
try { try {
await this.agentService.prompt(conversationId, body.content); await this.agentService.prompt(conversationId, body.content, scope);
await done; await done;
} catch (err) { } catch (err) {
if (err instanceof HttpException) throw err; if (err instanceof HttpException) throw err;

View File

@@ -1,3 +1,4 @@
import { timingSafeEqual } from 'node:crypto';
import type { IncomingHttpHeaders } from 'node:http'; import type { IncomingHttpHeaders } from 'node:http';
import { fromNodeHeaders } from 'better-auth/node'; import { fromNodeHeaders } from 'better-auth/node';
@@ -12,6 +13,19 @@ export interface SessionAuth {
}; };
} }
export function validateDiscordServiceToken(
candidate: unknown,
expected: string | undefined,
): boolean {
if (typeof candidate !== 'string' || !expected) return false;
const candidateBuffer = Buffer.from(candidate);
const expectedBuffer = Buffer.from(expected);
return (
candidateBuffer.length === expectedBuffer.length &&
timingSafeEqual(candidateBuffer, expectedBuffer)
);
}
export async function validateSocketSession( export async function validateSocketSession(
headers: IncomingHttpHeaders, headers: IncomingHttpHeaders,
auth: SessionAuth, auth: SessionAuth,

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { ChatGateway } from './chat.gateway.js';
const payload: SlashCommandPayload = {
command: 'gc',
conversationId: 'conversation-1',
approvalId: 'approval-1',
};
function buildGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
commandExecutor as never,
{} as never,
);
}
describe('ChatGateway command approval ingress', () => {
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
createApproval: vi.fn(),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, payload);
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
userId: 'admin-1',
tenantId: 'admin-1',
});
expect(client.emit).toHaveBeenCalledWith(
'command:result',
expect.objectContaining({ success: true }),
);
});
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn(),
createApproval: vi.fn().mockResolvedValue({
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
}),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandApproval(client as never, {
command: 'gc',
conversationId: 'conversation-1',
});
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
{ command: 'gc', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith('command:approval', {
command: 'gc',
conversationId: 'conversation-1',
success: true,
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
});
});
});

View File

@@ -0,0 +1,170 @@
import { describe, expect, it, vi } from 'vitest';
import { ChatGateway } from './chat.gateway.js';
const CONVERSATION_ID = 'conversation-1';
const CANARY = 'sk_canary12345678';
type GatewayInternals = {
clientSessions: Map<string, unknown>;
relayEvent(client: unknown, conversationId: string, event: unknown): void;
};
function buildGateway() {
const brain = {
conversations: {
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
return { gateway: gateway as unknown as GatewayInternals, brain };
}
describe('ChatGateway redaction boundary', (): void => {
it('redacts a secret split across assistant deltas before egress and persistence', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
const session = {
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
};
gateway.clientSessions.set(client.id, session);
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' },
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary');
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(session.assistantText).toBe(`${CANARY} `);
expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY);
});
it('retains a split secret label until its value can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'token ' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123');
});
it('holds a streamed private key until it can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET]',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary');
});
it('drops an oversized unterminated stream fragment rather than retaining it', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_STREAM_OVERFLOW]',
});
});
it('persists only redacted assistant content with classifications', (): void => {
const { gateway, brain } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.clientSessions.set(client.id, {
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: CANARY,
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
});
gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' });
expect(brain.conversations.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: '[REDACTED_SECRET]',
metadata: expect.objectContaining({ classifications: ['secret'] }),
}),
'user-1',
);
expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY);
});
});

View File

@@ -11,10 +11,17 @@ import {
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import {
verifyDiscordIngressEnvelope,
type DiscordIngressEnvelope,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import type { Auth } from '@mosaicstack/auth'; import type { Auth } from '@mosaicstack/auth';
import type { Brain } from '@mosaicstack/brain'; import type { Brain } from '@mosaicstack/brain';
import { redactSensitiveContent } from '@mosaicstack/log';
import type { import type {
SetThinkingPayload, SetThinkingPayload,
SlashCommandApprovalResultPayload,
SlashCommandPayload, SlashCommandPayload,
SystemReloadPayload, SystemReloadPayload,
RoutingDecisionInfo, RoutingDecisionInfo,
@@ -22,13 +29,19 @@ import type {
} from '@mosaicstack/types'; } from '@mosaicstack/types';
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
import { AUTH } from '../auth/auth.tokens.js'; import { AUTH } from '../auth/auth.tokens.js';
import {
scopeFromUser,
type ActorTenantScope,
type AuthenticatedUserLike,
} from '../auth/session-scope.js';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { CommandRegistryService } from '../commands/command-registry.service.js'; import { CommandRegistryService } from '../commands/command-registry.service.js';
import { CommandExecutorService } from '../commands/command-executor.service.js'; import { CommandExecutorService } from '../commands/command-executor.service.js';
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { ChatSocketMessageDto } from './chat.dto.js'; import { ChatSocketMessageDto } from './chat.dto.js';
import { validateSocketSession } from './chat.gateway-auth.js'; import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js';
import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
/** Per-client state tracking streaming accumulation for persistence. */ /** Per-client state tracking streaming accumulation for persistence. */
interface ClientSession { interface ClientSession {
@@ -40,6 +53,8 @@ interface ClientSession {
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>; toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
/** Tool calls in-flight (started but not ended yet). */ /** Tool calls in-flight (started but not ended yet). */
pendingToolCalls: Map<string, { toolName: string; args: unknown }>; pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
/** Server-derived owner/tenant scope for this socket's conversation attachment. */
scope: ActorTenantScope;
/** Last routing decision made for this session (M4-008) */ /** Last routing decision made for this session (M4-008) */
lastRoutingDecision?: RoutingDecisionInfo; lastRoutingDecision?: RoutingDecisionInfo;
} }
@@ -49,6 +64,38 @@ interface ClientSession {
* Keyed by conversationId, value is the model name to use. * Keyed by conversationId, value is the model name to use.
*/ */
const modelOverrides = new Map<string, string>(); const modelOverrides = new Map<string, string>();
const MAX_REDACTION_BUFFER_LENGTH = 8_192;
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
if (typeof value !== 'object' || value === null) return false;
const envelope = value as { payload?: unknown; signature?: unknown };
if (
typeof envelope.signature !== 'string' ||
typeof envelope.payload !== 'object' ||
envelope.payload === null
) {
return false;
}
const payload = envelope.payload as Record<string, unknown>;
return [
payload['correlationId'],
payload['messageId'],
payload['guildId'],
payload['channelId'],
payload['userId'],
payload['conversationId'],
payload['content'],
].every((field: unknown): boolean => typeof field === 'string');
}
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
if (typeof value !== 'object' || value === null) return false;
const payload = value as { content?: unknown; conversationId?: unknown };
return (
typeof payload.content === 'string' &&
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
);
}
@WebSocketGateway({ @WebSocketGateway({
cors: { cors: {
@@ -62,6 +109,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
private readonly logger = new Logger(ChatGateway.name); private readonly logger = new Logger(ChatGateway.name);
private readonly clientSessions = new Map<string, ClientSession>(); private readonly clientSessions = new Map<string, ClientSession>();
/** Raw stream fragments are kept in memory only until they are safe to redact and emit. */
private readonly textEgressBuffers = new Map<string, string>();
private readonly thinkingEgressBuffers = new Map<string, string>();
private readonly overflowedEgress = new Set<string>();
private readonly discordReplayProtector = new DiscordReplayProtector();
constructor( constructor(
@Inject(AgentService) private readonly agentService: AgentService, @Inject(AgentService) private readonly agentService: AgentService,
@@ -77,6 +129,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
async handleConnection(client: Socket): Promise<void> { async handleConnection(client: Socket): Promise<void> {
const serviceToken = client.handshake.auth['discordServiceToken'];
if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) {
client.data.discordService = true;
this.logger.log(`Authenticated Discord service connected: ${client.id}`);
return;
}
const session = await validateSocketSession(client.handshake.headers, this.auth); const session = await validateSocketSession(client.handshake.headers, this.auth);
if (!session) { if (!session) {
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`); this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
@@ -87,8 +146,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
client.data.user = session.user; client.data.user = session.user;
client.data.session = session.session; client.data.session = session.session;
this.logger.log(`Client connected: ${client.id}`); this.logger.log(`Client connected: ${client.id}`);
// Broadcast command manifest to the newly connected client
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() }); client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
} }
@@ -97,25 +154,84 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const session = this.clientSessions.get(client.id); const session = this.clientSessions.get(client.id);
if (session) { if (session) {
session.cleanup(); session.cleanup();
this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`); this.agentService.removeChannel(
session.conversationId,
`websocket:${client.id}`,
session.scope,
);
this.clientSessions.delete(client.id); this.clientSessions.delete(client.id);
} }
this.textEgressBuffers.delete(client.id);
this.thinkingEgressBuffers.delete(client.id);
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
}
private getClientScope(client: Socket): ActorTenantScope | null {
const user = client.data.user as AuthenticatedUserLike | undefined;
if (!user?.id) return null;
return scopeFromUser(user);
}
private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string {
return `${scope.tenantId}:${scope.userId}:${conversationId}`;
}
private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean {
return a.userId === b.userId && a.tenantId === b.tenantId;
} }
@SubscribeMessage('message') @SubscribeMessage('message')
async handleMessage( async handleMessage(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: ChatSocketMessageDto, @MessageBody() rawData: unknown,
): Promise<void> { ): Promise<void> {
let discordIngress: DiscordIngressPayload | null = null;
let data: ChatSocketMessageDto;
if (client.data.discordService) {
if (!isDiscordIngressEnvelope(rawData)) {
this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`);
return;
}
discordIngress = this.resolveDiscordIngress(client, rawData);
if (!discordIngress) return;
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
} else {
if (!isChatSocketMessage(rawData)) {
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
return;
}
data = rawData;
}
const conversationId = data.conversationId ?? uuid(); const conversationId = data.conversationId ?? uuid();
const userId = (client.data.user as { id: string } | undefined)?.id; const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordIngress && !discordServiceUserId) {
this.logger.warn(
`Rejected Discord ingress without configured service owner from ${client.id}`,
);
return;
}
const scope = discordIngress
? {
userId: discordServiceUserId!,
tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!,
}
: this.getClientScope(client);
if (!scope) {
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
return;
}
const userId = scope.userId;
const correlationId = discordIngress?.correlationId;
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); this.logger.log(
`Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`,
);
// Ensure agent session exists for this conversation // Ensure agent session exists for this conversation
let sessionRoutingDecision: RoutingDecisionInfo | undefined; let sessionRoutingDecision: RoutingDecisionInfo | undefined;
try { try {
let agentSession = this.agentService.getSession(conversationId); let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) { if (!agentSession) {
// When resuming an existing conversation, load prior messages to inject as context (M1-004) // When resuming an existing conversation, load prior messages to inject as context (M1-004)
const conversationHistory = await this.loadConversationHistory(conversationId, userId); const conversationHistory = await this.loadConversationHistory(conversationId, userId);
@@ -135,7 +251,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
let resolvedProvider = data.provider; let resolvedProvider = data.provider;
let resolvedModelId = data.modelId; let resolvedModelId = data.modelId;
const modelOverride = modelOverrides.get(conversationId); const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope));
if (modelOverride) { if (modelOverride) {
// /model override bypasses routing engine (M4-007) // /model override bypasses routing engine (M4-007)
resolvedModelId = modelOverride; resolvedModelId = modelOverride;
@@ -172,6 +288,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
modelId: resolvedModelId, modelId: resolvedModelId,
agentConfigId: data.agentId, agentConfigId: data.agentId,
userId, userId,
tenantId: scope.tenantId,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
}); });
@@ -210,9 +327,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{ {
conversationId, conversationId,
role: 'user', role: 'user',
content: data.content, content: redactSensitiveContent(data.content).content,
metadata: { metadata: {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...(correlationId
? {
correlationId,
discordMessageId: discordIngress?.messageId,
discordUserId: discordIngress?.userId,
}
: {}),
classifications: redactSensitiveContent(data.content).classifications,
}, },
}, },
userId, userId,
@@ -232,9 +357,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
// Subscribe to agent events and relay to client // Subscribe to agent events and relay to client
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => { const cleanup = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
this.relayEvent(client, conversationId, event); this.relayEvent(client, conversationId, event);
}); },
scope,
);
// Preserve routing decision from the existing client session if we didn't get a new one // Preserve routing decision from the existing client session if we didn't get a new one
const prevClientSession = this.clientSessions.get(client.id); const prevClientSession = this.clientSessions.get(client.id);
@@ -246,16 +375,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
assistantText: '', assistantText: '',
toolCalls: [], toolCalls: [],
pendingToolCalls: new Map(), pendingToolCalls: new Map(),
scope,
lastRoutingDecision: routingDecisionToStore, lastRoutingDecision: routingDecisionToStore,
}); });
// Track channel connection // Track channel connection
this.agentService.addChannel(conversationId, `websocket:${client.id}`); this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope);
// Send session info so the client knows the model/provider (M4-008: include routing decision) // Send session info so the client knows the model/provider (M4-008: include routing decision)
// Include agentName when a named agent config is active (M5-001) // Include agentName when a named agent config is active (M5-001)
{ {
const agentSession = this.agentService.getSession(conversationId); const agentSession = this.agentService.getSession(conversationId, scope);
if (agentSession) { if (agentSession) {
const piSession = agentSession.piSession; const piSession = agentSession.piSession;
client.emit('session:info', { client.emit('session:info', {
@@ -271,11 +401,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
// Send acknowledgment // Send acknowledgment
client.emit('message:ack', { conversationId, messageId: uuid() }); client.emit('message:ack', {
conversationId,
messageId: uuid(),
...(correlationId
? {
correlationId,
discordMessageId: discordIngress?.messageId,
discordUserId: discordIngress?.userId,
}
: {}),
});
// Dispatch to agent // Dispatch to agent
try { try {
await this.agentService.prompt(conversationId, data.content); await this.agentService.prompt(conversationId, data.content, scope);
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`, `Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
@@ -293,7 +433,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: SetThinkingPayload, @MessageBody() data: SetThinkingPayload,
): void { ): void {
const session = this.agentService.getSession(data.conversationId); const scope = this.getClientScope(client);
if (!scope) {
client.emit('error', {
conversationId: data.conversationId,
error: 'Authenticated user scope is required.',
});
return;
}
const session = this.agentService.getSession(data.conversationId, scope);
if (!session) { if (!session) {
client.emit('error', { client.emit('error', {
conversationId: data.conversationId, conversationId: data.conversationId,
@@ -334,7 +483,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const conversationId = data.conversationId; const conversationId = data.conversationId;
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`); this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
const session = this.agentService.getSession(conversationId); const scope = this.getClientScope(client);
if (!scope) {
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
return;
}
const session = this.agentService.getSession(conversationId, scope);
if (!session) { if (!session) {
client.emit('error', { client.emit('error', {
conversationId, conversationId,
@@ -363,11 +518,45 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() payload: SlashCommandPayload, @MessageBody() payload: SlashCommandPayload,
): Promise<void> { ): Promise<void> {
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown'; const scope = this.getClientScope(client);
const result = await this.commandExecutor.execute(payload, userId); if (!scope) {
client.emit('command:result', {
command: payload.command,
conversationId: payload.conversationId,
success: false,
message: 'Authenticated user scope is required.',
});
return;
}
const result = await this.commandExecutor.execute(payload, scope);
client.emit('command:result', result); client.emit('command:result', result);
} }
@SubscribeMessage('command:approve')
async handleCommandApproval(
@ConnectedSocket() client: Socket,
@MessageBody() payload: SlashCommandPayload,
): Promise<void> {
const scope = this.getClientScope(client);
const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null;
const result: SlashCommandApprovalResultPayload = approval
? {
command: payload.command,
conversationId: payload.conversationId,
success: true,
approvalId: approval.approvalId,
expiresAt: approval.expiresAt,
}
: {
command: payload.command,
conversationId: payload.conversationId,
success: false,
message: 'Not authorized to approve this command.',
};
client.emit('command:approval', result);
}
broadcastReload(payload: SystemReloadPayload): void { broadcastReload(payload: SystemReloadPayload): void {
this.server.emit('system:reload', payload); this.server.emit('system:reload', payload);
this.logger.log('Broadcasted system:reload to all connected clients'); this.logger.log('Broadcasted system:reload to all connected clients');
@@ -380,18 +569,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
* M5-005: Emits session:info to clients subscribed to this conversation when a model is set. * M5-005: Emits session:info to clients subscribed to this conversation when a model is set.
* M5-007: Records a model switch in session metrics. * M5-007: Records a model switch in session metrics.
*/ */
setModelOverride(conversationId: string, modelName: string | null): void { setModelOverride(
conversationId: string,
modelName: string | null,
scope: ActorTenantScope,
): void {
const key = this.modelOverrideKey(conversationId, scope);
if (modelName) { if (modelName) {
modelOverrides.set(conversationId, modelName); modelOverrides.set(key, modelName);
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`); this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
// M5-002: Update the live session's modelId so session:info reflects the new model immediately // M5-002: Update the live session's modelId so session:info reflects the new model immediately
this.agentService.updateSessionModel(conversationId, modelName); this.agentService.updateSessionModel(conversationId, modelName, scope);
// M5-005: Broadcast session:info to all clients subscribed to this conversation // M5-005: Broadcast session:info to all clients subscribed to this conversation
this.broadcastSessionInfo(conversationId); this.broadcastSessionInfo(conversationId, scope);
} else { } else {
modelOverrides.delete(conversationId); modelOverrides.delete(key);
this.logger.log(`Model override cleared: conversation=${conversationId}`); this.logger.log(`Model override cleared: conversation=${conversationId}`);
} }
} }
@@ -399,8 +593,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
/** /**
* Return the active model override for a conversation, or undefined if none. * Return the active model override for a conversation, or undefined if none.
*/ */
getModelOverride(conversationId: string): string | undefined { getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined {
return modelOverrides.get(conversationId); return modelOverrides.get(this.modelOverrideKey(conversationId, scope));
} }
/** /**
@@ -409,9 +603,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
*/ */
broadcastSessionInfo( broadcastSessionInfo(
conversationId: string, conversationId: string,
scope: ActorTenantScope,
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo }, extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
): void { ): void {
const agentSession = this.agentService.getSession(conversationId); const agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) return; if (!agentSession) return;
const piSession = agentSession.piSession; const piSession = agentSession.piSession;
@@ -428,7 +623,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
// Emit to all clients currently subscribed to this conversation // Emit to all clients currently subscribed to this conversation
for (const [clientId, session] of this.clientSessions) { for (const [clientId, session] of this.clientSessions) {
if (session.conversationId === conversationId) { if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
const socket = this.server.sockets.sockets.get(clientId); const socket = this.server.sockets.sockets.get(clientId);
if (socket?.connected) { if (socket?.connected) {
socket.emit('session:info', payload); socket.emit('session:info', payload);
@@ -442,6 +637,39 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
* Creates it if absent — safe to call concurrently since a duplicate insert * Creates it if absent — safe to call concurrently since a duplicate insert
* would fail on the PK constraint and be caught here. * would fail on the PK constraint and be caught here.
*/ */
private resolveDiscordIngress(
client: Socket,
envelope: DiscordIngressEnvelope,
): DiscordIngressPayload | null {
const payload = verifyDiscordIngressEnvelope(
envelope,
process.env['DISCORD_SERVICE_TOKEN'] ?? '',
{
guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
},
);
if (!payload) {
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
return null;
}
if (!this.discordReplayProtector.claim(payload.messageId)) {
this.logger.warn(
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
);
return null;
}
return payload;
}
private readDiscordAllowlist(name: string): string[] {
return (process.env[name] ?? '')
.split(',')
.map((id: string): string => id.trim())
.filter((id: string): boolean => id.length > 0);
}
private async ensureConversation(conversationId: string, userId: string): Promise<void> { private async ensureConversation(conversationId: string, userId: string): Promise<void> {
try { try {
const existing = await this.brain.conversations.findById(conversationId, userId); const existing = await this.brain.conversations.findById(conversationId, userId);
@@ -527,6 +755,127 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
} }
private appendAndFlushRedactedEgress(
client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
buffers: Map<string, string>,
delta: string,
): void {
const key = this.egressKey(client, eventName);
if (this.overflowedEgress.has(key)) return;
const buffered = `${buffers.get(client.id) ?? ''}${delta}`;
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
buffers.delete(client.id);
this.overflowedEgress.add(key);
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
return;
}
buffers.set(client.id, buffered);
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
}
/**
* Holds any suffix that could become a secret, email, or phone number after a
* later stream chunk. This avoids relying on downstream redaction after data
* has already reached the socket.
*/
private flushRedactedEgress(
client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
buffers: Map<string, string>,
final: boolean,
): void {
const key = this.egressKey(client, eventName);
if (this.overflowedEgress.has(key)) {
if (final) this.overflowedEgress.delete(key);
return;
}
const buffered = buffers.get(client.id) ?? '';
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
const released = buffered.slice(0, releaseLength);
const pending = buffered.slice(releaseLength);
if (pending) {
buffers.set(client.id, pending);
} else {
buffers.delete(client.id);
}
if (released) {
client.emit(eventName, {
conversationId,
text: redactSensitiveContent(released).content,
});
}
}
private safeRedactionPrefixLength(content: string): number {
let retainedFrom = content.length;
// Retain the current token because it may become a split secret or email.
const token = /(?:^|\s)(\S*)$/.exec(content);
if (token) {
const matched = token[0] ?? '';
const trailingToken = token[1] ?? '';
retainedFrom = token.index + matched.length - trailingToken.length;
}
// The secret classifier accepts whitespace around ':' and '=', so preserve
// a pending label until its value and delimiter are both complete.
const pendingSecretLabel =
/(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec(
content,
);
if (pendingSecretLabel) {
const label = pendingSecretLabel[1] ?? '';
retainedFrom = Math.min(
retainedFrom,
pendingSecretLabel.index + pendingSecretLabel[0].length - label.length,
);
}
const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec(
content,
);
if (secretLabel) {
retainedFrom = Math.min(retainedFrom, secretLabel.index);
}
// Phone numbers can contain whitespace and punctuation; preserve the full
// trailing numeric candidate until a non-phone character establishes a boundary.
const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content);
if (phone) {
const matched = phone[0] ?? '';
const trailingPhoneCandidate = phone[1] ?? '';
retainedFrom = Math.min(
retainedFrom,
phone.index + matched.length - trailingPhoneCandidate.length,
);
}
const privateKeyStart = content.lastIndexOf('-----BEGIN');
if (privateKeyStart >= 0) {
const privateKey = content.slice(privateKeyStart);
if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) {
// Release the complete block in one pass so the full-block classifier can redact it.
retainedFrom = content.length;
} else {
retainedFrom = Math.min(retainedFrom, privateKeyStart);
}
}
return retainedFrom;
}
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string {
return `${client.id}:${eventName}`;
}
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
if (!client.connected) { if (!client.connected) {
this.logger.warn( this.logger.warn(
@@ -544,13 +893,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
cs.toolCalls = []; cs.toolCalls = [];
cs.pendingToolCalls.clear(); cs.pendingToolCalls.clear();
} }
this.textEgressBuffers.set(client.id, '');
this.thinkingEgressBuffers.set(client.id, '');
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
client.emit('agent:start', { conversationId }); client.emit('agent:start', { conversationId });
break; break;
} }
case 'agent_end': { case 'agent_end': {
// Gather usage stats from the Pi session // Gather usage stats from the Pi session
const agentSession = this.agentService.getSession(conversationId); const activeClientSession = this.clientSessions.get(client.id);
const agentSession = activeClientSession
? this.agentService.getSession(conversationId, activeClientSession.scope)
: undefined;
const piSession = agentSession?.piSession; const piSession = agentSession?.piSession;
const stats = piSession?.getSessionStats(); const stats = piSession?.getSessionStats();
const contextUsage = piSession?.getContextUsage(); const contextUsage = piSession?.getContextUsage();
@@ -569,6 +925,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
: undefined; : undefined;
this.flushRedactedEgress(
client,
conversationId,
'agent:text',
this.textEgressBuffers,
true,
);
this.flushRedactedEgress(
client,
conversationId,
'agent:thinking',
this.thinkingEgressBuffers,
true,
);
client.emit('agent:end', { client.emit('agent:end', {
conversationId, conversationId,
usage: usagePayload, usage: usagePayload,
@@ -611,8 +981,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{ {
conversationId, conversationId,
role: 'assistant', role: 'assistant',
content: cs.assistantText, content: redactSensitiveContent(cs.assistantText).content,
metadata, metadata: {
...metadata,
classifications: redactSensitiveContent(cs.assistantText).classifications,
},
}, },
userId, userId,
) )
@@ -634,20 +1007,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'message_update': { case 'message_update': {
const assistantEvent = event.assistantMessageEvent; const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type === 'text_delta') { if (assistantEvent.type === 'text_delta') {
// Accumulate assistant text for persistence // Keep raw stream material in memory only; persist and emit only redacted text.
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(client.id);
if (cs) { if (cs) {
cs.assistantText += assistantEvent.delta; cs.assistantText += assistantEvent.delta;
} }
client.emit('agent:text', { this.appendAndFlushRedactedEgress(
client,
conversationId, conversationId,
text: assistantEvent.delta, 'agent:text',
}); this.textEgressBuffers,
assistantEvent.delta,
);
} else if (assistantEvent.type === 'thinking_delta') { } else if (assistantEvent.type === 'thinking_delta') {
client.emit('agent:thinking', { this.appendAndFlushRedactedEgress(
client,
conversationId, conversationId,
text: assistantEvent.delta, 'agent:thinking',
}); this.thinkingEgressBuffers,
assistantEvent.delta,
);
} }
break; break;
} }

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
const adminCommand: CommandDef = {
name: 'gc',
description: 'GC',
aliases: [],
scope: 'admin',
execution: 'socket',
available: true,
};
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
function createService(role: string): CommandAuthorizationService {
const entries = new Map<string, string>();
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('CommandAuthorizationService', () => {
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(true);
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
const mutated = { ...payload, conversationId: 'other-conversation' };
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
const service = createService('member');
const approval = await service.createApproval(adminCommand, payload, 'member-1');
expect(approval).toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
).toBe(false);
});
});

View File

@@ -0,0 +1,138 @@
import { createHash, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
export type CommandRole = 'admin' | 'member' | 'viewer';
export interface CommandApproval {
approvalId: string;
actionDigest: string;
actorId: string;
command: string;
expiresAt: string;
}
export interface CommandAuthorizationResult {
allowed: boolean;
reason?: string;
}
@Injectable()
export class CommandAuthorizationService {
constructor(
@Inject(DB) private readonly db: Db,
@Inject(COMMANDS_REDIS)
private readonly redis: {
get(key: string): Promise<string | null>;
set(key: string, value: string, ...args: string[]): Promise<unknown>;
del(key: string): Promise<number>;
},
) {}
async authorize(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
approvalId?: string,
): Promise<CommandAuthorizationResult> {
const role = await this.resolveRole(actorId);
if (!role || !this.hasScope(role, command.scope)) {
return { allowed: false, reason: 'not authorized for this command scope' };
}
if (command.scope !== 'admin') return { allowed: true };
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
const actionDigest = this.actionDigest(command.name, payload);
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
return approved
? { allowed: true }
: {
allowed: false,
reason: 'approval is invalid, expired, replayed, or does not match this action',
};
}
async createApproval(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
): Promise<CommandApproval | null> {
const role = await this.resolveRole(actorId);
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
const approvalId = randomUUID();
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
const approval: CommandApproval = {
approvalId,
actionDigest: this.actionDigest(command.name, payload),
actorId,
command: command.name,
expiresAt,
};
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
return approval;
}
private async resolveRole(actorId: string): Promise<CommandRole | null> {
const [user] = await this.db
.select({ role: usersTable.role })
.from(usersTable)
.where(eq(usersTable.id, actorId))
.limit(1);
const role = user?.role;
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (role === 'admin') return true;
return role === 'member' && (scope === 'core' || scope === 'agent');
}
private async consumeApproval(
approvalId: string,
actorId: string,
actionDigest: string,
): Promise<boolean> {
const key = this.key(approvalId);
const encoded = await this.redis.get(key);
if (!encoded) return false;
const parsed: unknown = JSON.parse(encoded);
if (
!this.isApproval(parsed) ||
parsed.actorId !== actorId ||
parsed.actionDigest !== actionDigest ||
Date.parse(parsed.expiresAt) <= Date.now()
)
return false;
return (await this.redis.del(key)) === 1;
}
private actionDigest(command: string, payload: SlashCommandPayload): string {
return createHash('sha256')
.update(
JSON.stringify({
command,
args: payload.args?.trim() ?? '',
conversationId: payload.conversationId,
}),
)
.digest('hex');
}
private isApproval(value: unknown): value is CommandApproval {
return (
typeof value === 'object' &&
value !== null &&
'approvalId' in value &&
'actionDigest' in value &&
'actorId' in value &&
'expiresAt' in value
);
}
private key(approvalId: string): string {
return `tess:command-approval:${approvalId}`;
}
}

View File

@@ -89,6 +89,7 @@ function buildService(): CommandExecutorService {
describe('CommandExecutorService — P8-012 commands', () => { describe('CommandExecutorService — P8-012 commands', () => {
let service: CommandExecutorService; let service: CommandExecutorService;
const userId = 'user-123'; const userId = 'user-123';
const userScope = { userId, tenantId: userId };
const conversationId = 'conv-456'; const conversationId = 'conv-456';
beforeEach(() => { beforeEach(() => {
@@ -99,31 +100,26 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider login — missing provider name // /provider login — missing provider name
it('/provider login with no provider name returns usage error', async () => { it('/provider login with no provider name returns usage error', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId }; const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider login'); expect(result.message).toContain('Usage: /provider login');
expect(result.command).toBe('provider'); expect(result.command).toBe('provider');
}); });
// /provider login anthropic — success with URL containing poll token // /provider login anthropic — no bearer token or auth URL reaches chat output
it('/provider login <name> returns success with URL and poll token', async () => { it('/provider login <name> keeps its one-time token out of chat output', async () => {
const payload: SlashCommandPayload = { const payload: SlashCommandPayload = {
command: 'provider', command: 'provider',
args: 'login anthropic', args: 'login anthropic',
conversationId, conversationId,
}; };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe('provider'); expect(result.command).toBe('provider');
expect(result.message).toContain('anthropic'); expect(result.message).toContain('anthropic');
expect(result.message).toContain('http'); expect(result.message).not.toContain('http');
// data should contain loginUrl and pollToken expect(result.message).not.toContain('token=');
expect(result.data).toBeDefined(); expect(result.data).toEqual({ provider: 'anthropic' });
const data = result.data as Record<string, unknown>;
expect(typeof data['loginUrl']).toBe('string');
expect(typeof data['pollToken']).toBe('string');
expect(data['loginUrl'] as string).toContain('anthropic');
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
// Verify Valkey was called // Verify Valkey was called
expect(mockRedis.set).toHaveBeenCalledOnce(); expect(mockRedis.set).toHaveBeenCalledOnce();
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number]; const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
@@ -138,7 +134,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider with no args — returns usage // /provider with no args — returns usage
it('/provider with no args returns usage message', async () => { it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId }; const payload: SlashCommandPayload = { command: 'provider', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /provider'); expect(result.message).toContain('Usage: /provider');
}); });
@@ -146,7 +142,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider list // /provider list
it('/provider list returns success', async () => { it('/provider list returns success', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId }; const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe('provider'); expect(result.command).toBe('provider');
}); });
@@ -154,7 +150,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider logout with no name — usage error // /provider logout with no name — usage error
it('/provider logout with no name returns error', async () => { it('/provider logout with no name returns error', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId }; const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider logout'); expect(result.message).toContain('Usage: /provider logout');
}); });
@@ -166,7 +162,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'unknown', args: 'unknown',
conversationId, conversationId,
}; };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.message).toContain('Unknown subcommand'); expect(result.message).toContain('Unknown subcommand');
}); });
@@ -174,7 +170,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /mission status // /mission status
it('/mission status returns stub message', async () => { it('/mission status returns stub message', async () => {
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId }; const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe('mission'); expect(result.command).toBe('mission');
expect(result.message).toContain('Mission status'); expect(result.message).toContain('Mission status');
@@ -183,7 +179,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /mission with no args // /mission with no args
it('/mission with no args returns status stub', async () => { it('/mission with no args returns status stub', async () => {
const payload: SlashCommandPayload = { command: 'mission', conversationId }; const payload: SlashCommandPayload = { command: 'mission', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('Mission status'); expect(result.message).toContain('Mission status');
}); });
@@ -195,7 +191,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'set my-mission-123', args: 'set my-mission-123',
conversationId, conversationId,
}; };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('my-mission-123'); expect(result.message).toContain('my-mission-123');
}); });
@@ -203,7 +199,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /agent list // /agent list
it('/agent list returns stub message', async () => { it('/agent list returns stub message', async () => {
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId }; const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe('agent'); expect(result.command).toBe('agent');
expect(result.message).toContain('agent'); expect(result.message).toContain('agent');
@@ -212,7 +208,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /agent with no args // /agent with no args
it('/agent with no args returns usage', async () => { it('/agent with no args returns usage', async () => {
const payload: SlashCommandPayload = { command: 'agent', conversationId }; const payload: SlashCommandPayload = { command: 'agent', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /agent'); expect(result.message).toContain('Usage: /agent');
}); });
@@ -224,7 +220,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'my-agent-id', args: 'my-agent-id',
conversationId, conversationId,
}; };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('my-agent-id'); expect(result.message).toContain('my-agent-id');
}); });
@@ -232,7 +228,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /prdy // /prdy
it('/prdy returns PRD wizard message', async () => { it('/prdy returns PRD wizard message', async () => {
const payload: SlashCommandPayload = { command: 'prdy', conversationId }; const payload: SlashCommandPayload = { command: 'prdy', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe('prdy'); expect(result.command).toBe('prdy');
expect(result.message).toContain('mosaic prdy'); expect(result.message).toContain('mosaic prdy');
@@ -241,7 +237,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /tools // /tools
it('/tools returns tools stub message', async () => { it('/tools returns tools stub message', async () => {
const payload: SlashCommandPayload = { command: 'tools', conversationId }; const payload: SlashCommandPayload = { command: 'tools', conversationId };
const result = await service.execute(payload, userId); const result = await service.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe('tools'); expect(result.command).toBe('tools');
expect(result.message).toContain('tools'); expect(result.message).toContain('tools');

View File

@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
const registry = {
getManifest: vi.fn(() => ({
version: 1,
commands: [
{
name: 'gc',
description: 'System-wide garbage collection',
aliases: [],
scope: 'admin' as const,
execution: 'socket' as const,
available: true,
},
],
skills: [],
})),
};
const sessionGc = {
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
};
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
const authorization = {
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
Promise.resolve(
actorId === 'member-1'
? { allowed: false, reason: 'durable approval is required' }
: { allowed: false, reason: 'not authorized for this command scope' },
),
),
};
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
return new CommandExecutorService(
registry as never,
{ getSession: vi.fn() } as never,
{ clear: vi.fn(), set: vi.fn() } as never,
sessionGc as never,
{ set: vi.fn() } as never,
{ agents: {} } as never,
null,
null,
null,
authorizationService as never,
);
}
function createDurableAuthorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
beforeEach((): void => {
vi.clearAllMocks();
});
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
expect(result.success).toBe(false);
expect(result.message).toContain('not authorized');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('member-1'));
expect(result.success).toBe(false);
expect(result.message).toContain('approval');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
const executor = buildExecutor(createDurableAuthorization());
const adminScope = scope('admin-1');
const denied = await executor.execute(payload, adminScope);
const approval = await executor.createApproval(payload, adminScope);
const approved = await executor.execute(
{ ...payload, approvalId: approval?.approvalId },
adminScope,
);
expect(denied.success).toBe(false);
expect(denied.message).toContain('approval');
expect(approval).not.toBeNull();
expect(approved.success).toBe(true);
expect(sessionGc.sweepOrphans).toHaveBeenCalledOnce();
});
});

View File

@@ -3,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue';
import type { Brain } from '@mosaicstack/brain'; import type { Brain } from '@mosaicstack/brain';
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types'; import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { ChatGateway } from '../chat/chat.gateway.js'; import { ChatGateway } from '../chat/chat.gateway.js';
import { SessionGCService } from '../gc/session-gc.service.js'; import { SessionGCService } from '../gc/session-gc.service.js';
import { SystemOverrideService } from '../preferences/system-override.service.js'; import { SystemOverrideService } from '../preferences/system-override.service.js';
@@ -10,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js';
import { McpClientService } from '../mcp-client/mcp-client.service.js'; import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { COMMANDS_REDIS } from './commands.tokens.js'; import { COMMANDS_REDIS } from './commands.tokens.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandRegistryService } from './command-registry.service.js'; import { CommandRegistryService } from './command-registry.service.js';
@Injectable() @Injectable()
@@ -32,10 +34,17 @@ export class CommandExecutorService {
@Optional() @Optional()
@Inject(McpClientService) @Inject(McpClientService)
private readonly mcpClient: McpClientService | null, private readonly mcpClient: McpClientService | null,
@Optional()
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService | null = null,
) {} ) {}
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> { async execute(
payload: SlashCommandPayload,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
const { command, args, conversationId } = payload; const { command, args, conversationId } = payload;
const userId = scope.userId;
const def = this.registry.getManifest().commands.find((c) => c.name === command); const def = this.registry.getManifest().commands.find((c) => c.name === command);
if (!def) { if (!def) {
@@ -47,14 +56,24 @@ export class CommandExecutorService {
}; };
} }
const authorization = await this.authorization?.authorize(
def,
payload,
userId,
payload.approvalId,
);
if (authorization && !authorization.allowed) {
return { command, conversationId, success: false, message: authorization.reason };
}
try { try {
switch (command) { switch (command) {
case 'model': case 'model':
return await this.handleModel(args ?? null, conversationId); return await this.handleModel(args ?? null, conversationId, scope);
case 'thinking': case 'thinking':
return await this.handleThinking(args ?? null, conversationId); return await this.handleThinking(args ?? null, conversationId);
case 'system': case 'system':
return await this.handleSystem(args ?? null, conversationId); return await this.handleSystem(args ?? null, conversationId, scope);
case 'new': case 'new':
return { return {
command, command,
@@ -94,7 +113,7 @@ export class CommandExecutorService {
}; };
} }
case 'agent': case 'agent':
return await this.handleAgent(args ?? null, conversationId, userId); return await this.handleAgent(args ?? null, conversationId, scope);
case 'provider': case 'provider':
return await this.handleProvider(args ?? null, userId, conversationId); return await this.handleProvider(args ?? null, userId, conversationId);
case 'mission': case 'mission':
@@ -143,13 +162,22 @@ export class CommandExecutorService {
} }
} }
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
const def = this.registry
.getManifest()
.commands.find((command) => command.name === payload.command);
if (!def || !this.authorization) return null;
return this.authorization.createApproval(def, payload, scope.userId);
}
private async handleModel( private async handleModel(
args: string | null, args: string | null,
conversationId: string, conversationId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> { ): Promise<SlashCommandResultPayload> {
if (!args || args.trim().length === 0) { if (!args || args.trim().length === 0) {
// Show current override or usage hint // Show current override or usage hint
const currentOverride = this.chatGateway?.getModelOverride(conversationId); const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
if (currentOverride) { if (currentOverride) {
return { return {
command: 'model', command: 'model',
@@ -171,7 +199,7 @@ export class CommandExecutorService {
// /model clear removes the override and re-enables automatic routing // /model clear removes the override and re-enables automatic routing
if (modelName === 'clear') { if (modelName === 'clear') {
this.chatGateway?.setModelOverride(conversationId, null); this.chatGateway?.setModelOverride(conversationId, null, scope);
return { return {
command: 'model', command: 'model',
conversationId, conversationId,
@@ -181,9 +209,9 @@ export class CommandExecutorService {
} }
// Set the sticky per-session override (M4-007) // Set the sticky per-session override (M4-007)
this.chatGateway?.setModelOverride(conversationId, modelName); this.chatGateway?.setModelOverride(conversationId, modelName, scope);
const session = this.agentService.getSession(conversationId); const session = this.agentService.getSession(conversationId, scope);
if (!session) { if (!session) {
return { return {
command: 'model', command: 'model',
@@ -224,10 +252,11 @@ export class CommandExecutorService {
private async handleSystem( private async handleSystem(
args: string | null, args: string | null,
conversationId: string, conversationId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> { ): Promise<SlashCommandResultPayload> {
if (!args || args.trim().length === 0) { if (!args || args.trim().length === 0) {
// Clear the override when called with no args // Clear the override when called with no args
await this.systemOverride.clear(conversationId); await this.systemOverride.clear(conversationId, scope);
return { return {
command: 'system', command: 'system',
conversationId, conversationId,
@@ -236,7 +265,7 @@ export class CommandExecutorService {
}; };
} }
await this.systemOverride.set(conversationId, args.trim()); await this.systemOverride.set(conversationId, args.trim(), scope);
return { return {
command: 'system', command: 'system',
conversationId, conversationId,
@@ -248,8 +277,9 @@ export class CommandExecutorService {
private async handleAgent( private async handleAgent(
args: string | null, args: string | null,
conversationId: string, conversationId: string,
userId: string, scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> { ): Promise<SlashCommandResultPayload> {
const userId = scope.userId;
if (!args) { if (!args) {
return { return {
command: 'agent', command: 'agent',
@@ -338,11 +368,14 @@ export class CommandExecutorService {
conversationId, conversationId,
agentConfig.id, agentConfig.id,
agentConfig.name, agentConfig.name,
scope,
agentConfig.model ?? undefined, agentConfig.model ?? undefined,
); );
// Broadcast updated session:info so TUI TopBar reflects new agent/model // Broadcast updated session:info so TUI TopBar reflects new agent/model
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name }); this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
agentName: agentConfig.name,
});
this.logger.log( this.logger.log(
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`, `Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
@@ -403,22 +436,28 @@ export class CommandExecutorService {
}; };
} }
const pollToken = crypto.randomUUID(); const pollToken = crypto.randomUUID();
const key = `mosaic:auth:poll:${pollToken}`; const tokenDigest = await crypto.subtle.digest(
// Store pending state in Valkey (TTL 5 minutes) 'SHA-256',
new TextEncoder().encode(pollToken),
);
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
byte.toString(16).padStart(2, '0'),
).join('');
const key = `mosaic:auth:poll:${tokenHash}`;
// Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set( await this.redis.set(
key, key,
JSON.stringify({ status: 'pending', provider: providerName, userId }), JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX', 'EX',
300, 300,
); );
// In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return { return {
command: 'provider', command: 'provider',
success: true, success: true,
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`, message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
conversationId, conversationId,
data: { loginUrl, pollToken, provider: providerName }, data: { provider: providerName },
}; };
} }

View File

@@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => {
let registry: CommandRegistryService; let registry: CommandRegistryService;
let executor: CommandExecutorService; let executor: CommandExecutorService;
const userId = 'user-integ-001'; const userId = 'user-integ-001';
const userScope = { userId, tenantId: userId };
const conversationId = 'conv-integ-001'; const conversationId = 'conv-integ-001';
beforeEach(() => { beforeEach(() => {
@@ -170,7 +171,7 @@ describe('CommandExecutorService — integration', () => {
// Unknown command returns error // Unknown command returns error
it('unknown command returns success:false with descriptive message', async () => { it('unknown command returns success:false with descriptive message', async () => {
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId }; const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.message).toContain('nonexistent'); expect(result.message).toContain('nonexistent');
expect(result.command).toBe('nonexistent'); expect(result.command).toBe('nonexistent');
@@ -179,7 +180,7 @@ describe('CommandExecutorService — integration', () => {
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg) // /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => { it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId }; const payload: SlashCommandPayload = { command: 'gc', conversationId };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith(); expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('GC sweep complete'); expect(result.message).toContain('GC sweep complete');
@@ -190,8 +191,8 @@ describe('CommandExecutorService — integration', () => {
it('/system with text calls SystemOverrideService.set', async () => { it('/system with text calls SystemOverrideService.set', async () => {
const override = 'You are a helpful assistant.'; const override = 'You are a helpful assistant.';
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId }; const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override); expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('override set'); expect(result.message).toContain('override set');
}); });
@@ -199,8 +200,8 @@ describe('CommandExecutorService — integration', () => {
// /system with no args clears the override // /system with no args clears the override
it('/system with no args calls SystemOverrideService.clear', async () => { it('/system with no args calls SystemOverrideService.clear', async () => {
const payload: SlashCommandPayload = { command: 'system', conversationId }; const payload: SlashCommandPayload = { command: 'system', conversationId };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId); expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('cleared'); expect(result.message).toContain('cleared');
}); });
@@ -212,7 +213,7 @@ describe('CommandExecutorService — integration', () => {
args: 'claude-3-opus', args: 'claude-3-opus',
conversationId, conversationId,
}; };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe('model'); expect(result.command).toBe('model');
expect(result.message).toContain('claude-3-opus'); expect(result.message).toContain('claude-3-opus');
@@ -221,7 +222,7 @@ describe('CommandExecutorService — integration', () => {
// /thinking with valid level returns success // /thinking with valid level returns success
it('/thinking with valid level returns success', async () => { it('/thinking with valid level returns success', async () => {
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId }; const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('high'); expect(result.message).toContain('high');
}); });
@@ -229,7 +230,7 @@ describe('CommandExecutorService — integration', () => {
// /thinking with invalid level returns usage message // /thinking with invalid level returns usage message
it('/thinking with invalid level returns usage message', async () => { it('/thinking with invalid level returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId }; const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('Usage:'); expect(result.message).toContain('Usage:');
}); });
@@ -237,7 +238,7 @@ describe('CommandExecutorService — integration', () => {
// /new command returns success // /new command returns success
it('/new returns success', async () => { it('/new returns success', async () => {
const payload: SlashCommandPayload = { command: 'new', conversationId }; const payload: SlashCommandPayload = { command: 'new', conversationId };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe('new'); expect(result.command).toBe('new');
}); });
@@ -245,7 +246,7 @@ describe('CommandExecutorService — integration', () => {
// /reload without reloadService returns failure // /reload without reloadService returns failure
it('/reload without ReloadService returns failure', async () => { it('/reload without ReloadService returns failure', async () => {
const payload: SlashCommandPayload = { command: 'reload', conversationId }; const payload: SlashCommandPayload = { command: 'reload', conversationId };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.message).toContain('ReloadService'); expect(result.message).toContain('ReloadService');
}); });
@@ -255,7 +256,7 @@ describe('CommandExecutorService — integration', () => {
for (const cmd of stubCommands) { for (const cmd of stubCommands) {
it(`/${cmd} returns success (stub)`, async () => { it(`/${cmd} returns success (stub)`, async () => {
const payload: SlashCommandPayload = { command: cmd, conversationId }; const payload: SlashCommandPayload = { command: cmd, conversationId };
const result = await executor.execute(payload, userId); const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe(cmd); expect(result.command).toBe(cmd);
}); });

View File

@@ -3,6 +3,7 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import { ChatModule } from '../chat/chat.module.js'; import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js'; import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js'; import { ReloadModule } from '../reload/reload.module.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js'; import { CommandExecutorService } from './command-executor.service.js';
import { CommandRegistryService } from './command-registry.service.js'; import { CommandRegistryService } from './command-registry.service.js';
import { COMMANDS_REDIS } from './commands.tokens.js'; import { COMMANDS_REDIS } from './commands.tokens.js';
@@ -24,6 +25,7 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
inject: [COMMANDS_QUEUE_HANDLE], inject: [COMMANDS_QUEUE_HANDLE],
}, },
CommandRegistryService, CommandRegistryService,
CommandAuthorizationService,
CommandExecutorService, CommandExecutorService,
], ],
exports: [CommandRegistryService, CommandExecutorService], exports: [CommandRegistryService, CommandExecutorService],

View File

@@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common';
import { fromNodeHeaders } from 'better-auth/node'; import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaicstack/auth'; import type { Auth } from '@mosaicstack/auth';
import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import type { McpService } from './mcp.service.js'; import {
createMcpActorContext,
deriveMcpToolScopesForUser,
type McpService,
} from './mcp.service.js';
import { AUTH } from '../auth/auth.tokens.js'; import { AUTH } from '../auth/auth.tokens.js';
/** /**
@@ -67,14 +71,25 @@ async function handleMcpRequest(
return; return;
} }
const userId = result.user.id; const authUser = result.user as {
id: string;
role?: string | null;
tenantId?: string | null;
organizationId?: string | null;
};
const actor = createMcpActorContext({
userId: authUser.id,
role: authUser.role,
tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined,
scopes: deriveMcpToolScopesForUser({ role: authUser.role }),
});
// ─── Session routing ───────────────────────────────────────────────────── // ─── Session routing ─────────────────────────────────────────────────────
const sessionId = req.raw.headers['mcp-session-id']; const sessionId = req.raw.headers['mcp-session-id'];
if (typeof sessionId === 'string' && sessionId.length > 0) { if (typeof sessionId === 'string' && sessionId.length > 0) {
// Existing session request // Existing session request
const transport = mcpService.getSession(sessionId); const transport = mcpService.getSession(sessionId, actor);
if (!transport) { if (!transport) {
logger.warn(`MCP session not found: ${sessionId}`); logger.warn(`MCP session not found: ${sessionId}`);
reply.raw.writeHead(404, { 'Content-Type': 'application/json' }); reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
@@ -112,8 +127,10 @@ async function handleMcpRequest(
} }
// Create new session and handle this initializing request // Create new session and handle this initializing request
const { transport } = mcpService.createSession(userId); const { transport } = mcpService.createSession(actor);
logger.log(`New MCP session created for user ${userId}`); logger.log(
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
);
await transport.handleRequest(req.raw, reply.raw, body); await transport.handleRequest(req.raw, reply.raw, body);
} }

View File

@@ -0,0 +1,461 @@
import { describe, expect, it, vi } from 'vitest';
import type { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Brain } from '@mosaicstack/brain';
import type { Memory } from '@mosaicstack/memory';
import type { EmbeddingService } from '../memory/embedding.service.js';
import type { CoordService } from '../coord/coord.service.js';
import {
assertMcpToolAuthorized,
createMcpActorContext,
deriveMcpToolScopesForUser,
MCP_TOOL_SCOPES,
McpService,
type McpToolName,
} from './mcp.service.js';
type ToolResult = { content: Array<{ type: 'text'; text: string }> };
type ToolHandler = (params: Record<string, unknown>) => Promise<ToolResult>;
interface CapturedTool {
inputSchema: z.ZodType;
handler: ToolHandler;
}
function makeCapturingServer(): { server: McpServer; tools: Map<string, CapturedTool> } {
const tools = new Map<string, CapturedTool>();
const server = {
registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void {
tools.set(name, { inputSchema: config.inputSchema, handler });
},
};
return { server: server as unknown as McpServer, tools };
}
function makeService(opts?: {
projects?: Array<Record<string, unknown> & { id: string; ownerId?: string | null }>;
missions?: Array<
Record<string, unknown> & { id: string; projectId?: string | null; userId?: string | null }
>;
tasks?: Array<
Record<string, unknown> & {
id: string;
projectId?: string | null;
missionId?: string | null;
status?: string;
}
>;
}) {
const projects = opts?.projects ?? [];
const missions = opts?.missions ?? [];
const tasks = opts?.tasks ?? [];
const brain = {
projects: {
findAll: vi.fn(async () => projects),
findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null),
},
tasks: {
findAll: vi.fn(async () => tasks),
findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
findByProject: vi.fn(async (projectId: string) =>
tasks.filter((task) => task.projectId === projectId),
),
findByMission: vi.fn(async (missionId: string) =>
tasks.filter((task) => task.missionId === missionId),
),
findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)),
create: vi.fn(async (task: Record<string, unknown>) => ({ id: 'task-1', ...task })),
update: vi.fn(async (id: string, updates: Record<string, unknown>) => ({ id, ...updates })),
},
missions: {
findAll: vi.fn(async () => missions),
findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null),
findByProject: vi.fn(async (projectId: string) =>
missions.filter((mission) => mission.projectId === projectId),
),
},
conversations: {
findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]),
},
} as unknown as Brain;
const memory = {
insights: {
searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]),
create: vi.fn(async (insight: Record<string, unknown>) => ({ id: 'insight-2', ...insight })),
},
preferences: {
findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]),
findByUserAndCategory: vi.fn(async (userId: string, category: string) => [
{ key: 'theme', userId, category },
]),
upsert: vi.fn(async (preference: Record<string, unknown>) => ({
id: 'pref-1',
...preference,
})),
},
} as unknown as Memory;
const embeddings = {
available: true,
embed: vi.fn(async () => [0.1, 0.2, 0.3]),
} as unknown as EmbeddingService;
const coord = {
getMissionStatus: vi.fn(async () => null),
listTasks: vi.fn(async () => []),
getTaskStatus: vi.fn(async () => null),
} as unknown as CoordService;
return {
service: new McpService(brain, memory, embeddings, coord),
brain: brain as unknown as {
conversations: { findAll: ReturnType<typeof vi.fn> };
tasks: {
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
},
memory: memory as unknown as {
insights: { searchByEmbedding: ReturnType<typeof vi.fn> };
},
coord: coord as unknown as {
listTasks: ReturnType<typeof vi.fn>;
},
};
}
function getTool(tools: Map<string, CapturedTool>, name: McpToolName): CapturedTool {
const tool = tools.get(name);
if (!tool) throw new Error(`Missing captured tool ${name}`);
return tool;
}
function makeMemberActor(userId = 'authenticated-user') {
return createMcpActorContext({
userId,
role: 'member',
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
});
}
function makeAdminActor(userId = 'admin-user', tenantId?: string) {
return createMcpActorContext({
userId,
tenantId,
role: 'admin',
scopes: deriveMcpToolScopesForUser({ role: 'admin' }),
});
}
function makePlatformAdminActor(userId = 'platform-admin-user') {
return createMcpActorContext({
userId,
role: 'platform-admin',
scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }),
});
}
describe('MCP actor identity and tool scope enforcement', () => {
it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => {
const actor = createMcpActorContext({
userId: ' user-authenticated ',
role: 'member',
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
});
expect(actor.userId).toBe('user-authenticated');
expect(actor.tenantId).toBe('user:user-authenticated');
expect(actor.role).toBe('member');
expect(actor.channel).toBe('mcp');
expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i);
expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true);
expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(true);
});
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
const actor = createMcpActorContext({ userId: 'user-authenticated' });
expect(actor.scopes.size).toBe(0);
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
'MCP tool scope denied: memory:insight:read',
);
});
it('fails closed when a tool caller supplies actor or tenant identity fields', () => {
const actor = createMcpActorContext({ userId: 'user-authenticated' });
expect(() =>
assertMcpToolAuthorized(actor, 'memory_search', {
userId: 'victim-user',
query: 'private data',
}),
).toThrow('MCP caller-controlled identity field is forbidden: userId');
expect(() =>
assertMcpToolAuthorized(actor, 'coord_list_tasks', {
tenantId: 'victim-tenant',
projectPath: '/tmp/project',
}),
).toThrow('MCP caller-controlled identity field is forbidden: tenantId');
expect(() =>
assertMcpToolAuthorized(actor, 'brain_create_task', {
title: 'forged org',
organizationId: 'victim-org',
}),
).toThrow('MCP caller-controlled identity field is forbidden: organizationId');
expect(() =>
assertMcpToolAuthorized(actor, 'brain_update_task', {
title: 'forged team',
teamId: 'victim-team',
}),
).toThrow('MCP caller-controlled identity field is forbidden: teamId');
});
it('fails closed when the server-derived actor lacks the required per-tool scope', () => {
const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] });
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
'MCP tool scope denied: memory:insight:read',
);
});
it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => {
const { service, memory } = makeService();
const { server, tools } = makeCapturingServer();
const actor = makeMemberActor('authenticated-user');
service.registerTools(server, actor);
const tool = getTool(tools, 'memory_search');
expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe(
false,
);
await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow(
'MCP caller-controlled identity field is forbidden: userId',
);
expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled();
await tool.handler({ query: 'only my notes' });
expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith(
'authenticated-user',
[0.1, 0.2, 0.3],
5,
);
});
it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => {
const { service, brain } = makeService();
const { server, tools } = makeCapturingServer();
const actor = makeMemberActor('authenticated-user');
service.registerTools(server, actor);
const tool = getTool(tools, 'brain_list_conversations');
expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false);
await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow(
'MCP caller-controlled identity field is forbidden: userId',
);
expect(brain.conversations.findAll).not.toHaveBeenCalled();
await tool.handler({});
expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user');
});
it('scopes brain project, mission, and task reads to the authenticated actor', async () => {
const { service } = makeService({
projects: [
{ id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' },
{ id: 'project-victim', ownerId: 'victim-user', name: 'victim' },
],
missions: [
{ id: 'mission-owned', projectId: 'project-owned' },
{ id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' },
],
tasks: [
{ id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' },
{ id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' },
{ id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' },
{ id: 'task-unowned', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeMemberActor('authenticated-user');
service.registerTools(server, actor);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual([
'task-owned-project',
'task-owned-mission',
]);
});
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
const { service } = makeService({
projects: [
{
id: 'project-tenant-a',
ownerId: 'other-user-a',
teamId: 'tenant-a',
name: 'same tenant',
},
{
id: 'project-tenant-b',
ownerId: 'other-user-b',
teamId: 'tenant-b',
name: 'other tenant',
},
],
missions: [
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, actor);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
});
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
const { service, brain } = makeService({
projects: [
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
],
missions: [
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, actor);
await expect(
getTool(tools, 'brain_create_task').handler({
title: 'unscoped tenant write',
}),
).rejects.toThrow('MCP task scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
await expect(
getTool(tools, 'brain_create_task').handler({
title: 'cross-tenant write',
projectId: 'project-tenant-b',
}),
).rejects.toThrow('MCP task project scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
await expect(
getTool(tools, 'brain_update_task').handler({
id: 'task-tenant-a',
projectId: 'project-tenant-b',
}),
).rejects.toThrow('MCP task project scope denied');
expect(brain.tasks.update).not.toHaveBeenCalled();
const updateResult = await getTool(tools, 'brain_update_task').handler({
id: 'task-tenant-b',
title: 'cross-tenant update',
});
expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b');
expect(brain.tasks.update).not.toHaveBeenCalled();
await getTool(tools, 'brain_create_task').handler({
title: 'same-tenant write',
projectId: 'project-tenant-a',
});
expect(brain.tasks.create).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }),
);
});
it('keeps admin-only coordination tools on server-derived paths', async () => {
const { service, coord } = makeService();
const { server, tools } = makeCapturingServer();
const member = makeMemberActor('authenticated-user');
const tenantAdmin = makeAdminActor('admin-user');
const platformAdmin = makePlatformAdminActor('platform-admin-user');
service.registerTools(server, member);
const memberTool = getTool(tools, 'coord_list_tasks');
expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false);
await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
tools.clear();
service.registerTools(server, tenantAdmin);
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
tools.clear();
service.registerTools(server, platformAdmin);
const platformAdminTool = getTool(tools, 'coord_list_tasks');
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
});
it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => {
const { service } = makeService();
const owner = makeMemberActor('owner-user');
const attacker = makeMemberActor('attacker-user');
const admin = makeAdminActor('admin-user');
const downgradedAdmin = makeMemberActor('admin-user');
const { sessionId, transport } = service.createSession(owner);
const staleSession = service.createSession(admin);
expect(service.getSession(sessionId, owner)).toBe(transport);
expect(service.getSession(sessionId, attacker)).toBeNull();
expect(service.getSession(sessionId, owner)).toBe(transport);
expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull();
expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport);
await service.onModuleDestroy();
});
});

View File

@@ -10,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js';
import { EmbeddingService } from '../memory/embedding.service.js'; import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.service.js'; import { CoordService } from '../coord/coord.service.js';
export const MCP_CALLER_IDENTITY_FIELDS = [
'actorId',
'actor',
'authenticatedUserId',
'channel',
'organizationId',
'ownerId',
'sessionUserId',
'teamId',
'tenant',
'tenantId',
'user',
'userId',
] as const;
type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number];
export const MCP_TOOL_SCOPES = {
brain_list_projects: 'brain:project:read',
brain_get_project: 'brain:project:read',
brain_list_tasks: 'brain:task:read',
brain_create_task: 'brain:task:write',
brain_update_task: 'brain:task:write',
brain_list_missions: 'brain:mission:read',
brain_list_conversations: 'brain:conversation:read',
memory_search: 'memory:insight:read',
memory_get_preferences: 'memory:preference:read',
memory_save_preference: 'memory:preference:write',
memory_save_insight: 'memory:insight:write',
coord_mission_status: 'coord:read',
coord_list_tasks: 'coord:read',
coord_task_detail: 'coord:read',
} as const;
export type McpToolName = keyof typeof MCP_TOOL_SCOPES;
export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName];
export interface McpActorContext {
userId: string;
tenantId: string;
role: string;
channel: 'mcp';
correlationId: string;
scopes: ReadonlySet<McpToolScope>;
}
interface SessionEntry { interface SessionEntry {
server: McpServer; server: McpServer;
transport: StreamableHTTPServerTransport; transport: StreamableHTTPServerTransport;
createdAt: Date; createdAt: Date;
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
export function deriveMcpToolScopesForUser(input: {
role?: string | null;
}): ReadonlySet<McpToolScope> {
if (input.role === 'platform-admin' || input.role === 'super-admin') {
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
}
if (input.role === 'admin') {
return new Set(TENANT_ADMIN_MCP_SCOPES);
}
return new Set(MEMBER_MCP_SCOPES);
}
export function createMcpActorContext(input: {
userId: string; userId: string;
tenantId?: string;
role?: string | null;
scopes?: Iterable<McpToolScope>;
correlationId?: string;
}): McpActorContext {
const userId = input.userId.trim();
if (userId.length === 0) {
throw new Error('MCP authenticated user is required');
}
return {
userId,
tenantId: input.tenantId?.trim() || `user:${userId}`,
role: input.role ?? 'member',
channel: 'mcp',
correlationId: input.correlationId ?? randomUUID(),
scopes: new Set(input.scopes ?? []),
};
}
export function assertNoCallerControlledIdentity(params: unknown): void {
if (params === null || typeof params !== 'object') return;
const keys = new Set(Object.keys(params));
const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) =>
keys.has(field),
);
if (forbidden) {
throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`);
}
}
export function assertMcpToolAuthorized(
actor: McpActorContext,
toolName: McpToolName,
params: unknown,
): void {
assertNoCallerControlledIdentity(params);
const requiredScope = MCP_TOOL_SCOPES[toolName];
if (!actor.scopes.has(requiredScope)) {
throw new Error(`MCP tool scope denied: ${requiredScope}`);
}
}
function strictObject<T extends z.ZodRawShape>(shape: T): z.ZodObject<T> {
return z.object(shape).strict();
}
type TenantScopedLike = {
tenantId?: string | null;
organizationId?: string | null;
teamId?: string | null;
};
type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null };
type MissionLike = TenantScopedLike & {
id: string;
projectId?: string | null;
userId?: string | null;
};
type TaskLike = TenantScopedLike & {
projectId?: string | null;
missionId?: string | null;
userId?: string | null;
};
function isGlobalAdminActor(actor: McpActorContext): boolean {
return actor.role === 'platform-admin' || actor.role === 'super-admin';
}
function isTenantAdminActor(actor: McpActorContext): boolean {
return actor.role === 'admin';
}
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
return (
record.tenantId === actor.tenantId ||
record.organizationId === actor.tenantId ||
record.teamId === actor.tenantId
);
}
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
if (isGlobalAdminActor(actor)) return projects;
return projects.filter(
(project) =>
project.ownerId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
);
}
function filterMissionsByDirectActorScope<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): T[] {
if (isGlobalAdminActor(actor)) return missions;
return missions.filter(
(mission) =>
mission.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
);
}
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
if (left.size !== right.size) return false;
for (const scope of left) {
if (!right.has(scope)) return false;
}
return true;
}
function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean {
return (
stored.userId === current.userId &&
stored.tenantId === current.tenantId &&
stored.role === current.role &&
scopesEqual(stored.scopes, current.scopes)
);
} }
@Injectable() @Injectable()
@@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy {
* Creates a new MCP session with its own server + transport pair. * Creates a new MCP session with its own server + transport pair.
* Returns the transport for use by the controller. * Returns the transport for use by the controller.
*/ */
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } { createSession(actor: McpActorContext): {
sessionId: string;
transport: StreamableHTTPServerTransport;
} {
const sessionId = randomUUID(); const sessionId = randomUUID();
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId, sessionIdGenerator: () => sessionId,
onsessioninitialized: (id) => { onsessioninitialized: (id) => {
this.logger.log(`MCP session initialized: ${id} for user ${userId}`); this.logger.log(
`MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
);
}, },
}); });
@@ -48,7 +258,7 @@ export class McpService implements OnModuleDestroy {
{ capabilities: { tools: {} } }, { capabilities: { tools: {} } },
); );
this.registerTools(server, userId); this.registerTools(server, actor);
transport.onclose = () => { transport.onclose = () => {
this.logger.log(`MCP session closed: ${sessionId}`); this.logger.log(`MCP session closed: ${sessionId}`);
@@ -61,31 +271,126 @@ export class McpService implements OnModuleDestroy {
); );
}); });
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId }); this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor });
return { sessionId, transport }; return { sessionId, transport };
} }
/** /**
* Returns the transport for an existing session, or null if not found. * Returns the transport for an existing session only when it belongs to the
* currently authenticated MCP actor. Guessed or cross-tenant session IDs grant
* no authority.
*/ */
getSession(sessionId: string): StreamableHTTPServerTransport | null { getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
return this.sessions.get(sessionId)?.transport ?? null; const entry = this.sessions.get(sessionId);
if (!entry) return null;
if (!sameActorAuthorization(entry.actor, actor)) {
this.logger.warn(
`MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`,
);
return null;
}
return entry.transport;
}
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
}
private async filterMissionsForActor<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return missions;
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
const projectIds = new Set(
filterProjectsForActor(actor, projects).map((project) => project.id),
);
return missions.filter(
(mission) =>
filterMissionsByDirectActorScope(actor, [mission]).length === 1 ||
(typeof mission.projectId === 'string' && projectIds.has(mission.projectId)),
);
}
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
if (!mission) return false;
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
}
private async assertTaskReferencesAuthorized(
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) {
throw new Error('MCP task project scope denied');
}
if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) {
throw new Error('MCP task mission scope denied');
}
}
private async assertTaskCreateScopeAuthorized(
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
}
private async filterTasksForActor<T extends TaskLike>(
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return tasks;
const [projects, missions] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
]);
const projectIds = new Set(
filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id),
);
const missionIds = new Set(
(await this.filterMissionsForActor(actor, missions as MissionLike[])).map(
(mission) => mission.id,
),
);
return tasks.filter(
(task) =>
task.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
);
} }
/** /**
* Registers all platform tools on the given McpServer instance. * Registers all platform tools on the given McpServer instance.
*/ */
private registerTools(server: McpServer, _userId: string): void { registerTools(server: McpServer, actor: McpActorContext): void {
// ─── Brain: Project tools ──────────────────────────────────────────── // ─── Brain: Project tools ────────────────────────────────────────────
server.registerTool( server.registerTool(
'brain_list_projects', 'brain_list_projects',
{ {
description: 'List all projects in the brain.', description: 'List all projects in the brain.',
inputSchema: z.object({}), inputSchema: strictObject({}),
}, },
async () => { async (params) => {
const projects = await this.brain.projects.findAll(); assertMcpToolAuthorized(actor, 'brain_list_projects', params);
const projects = filterProjectsForActor(
actor,
(await this.brain.projects.findAll()) as ProjectLike[],
);
return { return {
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }], content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
}; };
@@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy {
'brain_get_project', 'brain_get_project',
{ {
description: 'Get a project by ID.', description: 'Get a project by ID.',
inputSchema: z.object({ inputSchema: strictObject({
id: z.string().describe('Project ID (UUID)'), id: z.string().describe('Project ID (UUID)'),
}), }),
}, },
async ({ id }) => { async ({ id, ...params }) => {
const project = await this.brain.projects.findById(id); assertMcpToolAuthorized(actor, 'brain_get_project', params);
const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined;
const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined;
return { return {
content: [ content: [
{ {
type: 'text' as const, type: 'text' as const,
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`, text: authorizedProject
? JSON.stringify(authorizedProject, null, 2)
: `Project not found: ${id}`,
}, },
], ],
}; };
@@ -119,20 +428,23 @@ export class McpService implements OnModuleDestroy {
'brain_list_tasks', 'brain_list_tasks',
{ {
description: 'List tasks, optionally filtered by project, mission, or status.', description: 'List tasks, optionally filtered by project, mission, or status.',
inputSchema: z.object({ inputSchema: strictObject({
projectId: z.string().optional().describe('Filter by project ID'), projectId: z.string().optional().describe('Filter by project ID'),
missionId: z.string().optional().describe('Filter by mission ID'), missionId: z.string().optional().describe('Filter by mission ID'),
status: z.string().optional().describe('Filter by status'), status: z.string().optional().describe('Filter by status'),
}), }),
}, },
async ({ projectId, missionId, status }) => { async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_tasks', params);
const { projectId, missionId, status } = params;
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
let tasks; let tasks;
if (projectId) tasks = await this.brain.tasks.findByProject(projectId); if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId); else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus); else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
else tasks = await this.brain.tasks.findAll(); else tasks = await this.brain.tasks.findAll();
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]);
return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] };
}, },
); );
@@ -140,7 +452,7 @@ export class McpService implements OnModuleDestroy {
'brain_create_task', 'brain_create_task',
{ {
description: 'Create a new task in the brain.', description: 'Create a new task in the brain.',
inputSchema: z.object({ inputSchema: strictObject({
title: z.string().describe('Task title'), title: z.string().describe('Task title'),
description: z.string().optional().describe('Task description'), description: z.string().optional().describe('Task description'),
projectId: z.string().optional().describe('Project ID'), projectId: z.string().optional().describe('Project ID'),
@@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy {
}), }),
}, },
async (params) => { async (params) => {
assertMcpToolAuthorized(actor, 'brain_create_task', params);
await this.assertTaskCreateScopeAuthorized(actor, params);
type Priority = 'low' | 'medium' | 'high' | 'critical'; type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await this.brain.tasks.create({ const task = await this.brain.tasks.create({
...params, ...params,
@@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy {
'brain_update_task', 'brain_update_task',
{ {
description: 'Update an existing task.', description: 'Update an existing task.',
inputSchema: z.object({ inputSchema: strictObject({
id: z.string().describe('Task ID'), id: z.string().describe('Task ID'),
title: z.string().optional(), title: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
@@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy {
.optional() .optional()
.describe('not-started, in-progress, blocked, done, cancelled'), .describe('not-started, in-progress, blocked, done, cancelled'),
priority: z.string().optional(), priority: z.string().optional(),
projectId: z.string().optional().describe('Project ID'),
missionId: z.string().optional().describe('Mission ID'),
}), }),
}, },
async ({ id, ...updates }) => { async ({ id, ...updates }) => {
assertMcpToolAuthorized(actor, 'brain_update_task', updates);
const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined;
if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) {
return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] };
}
await this.assertTaskReferencesAuthorized(actor, updates);
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
type Priority = 'low' | 'medium' | 'high' | 'critical'; type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await this.brain.tasks.update(id, { const task = await this.brain.tasks.update(id, {
@@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy {
'brain_list_missions', 'brain_list_missions',
{ {
description: 'List all missions, optionally filtered by project.', description: 'List all missions, optionally filtered by project.',
inputSchema: z.object({ inputSchema: strictObject({
projectId: z.string().optional().describe('Filter by project ID'), projectId: z.string().optional().describe('Filter by project ID'),
}), }),
}, },
async ({ projectId }) => { async (params) => {
const missions = projectId assertMcpToolAuthorized(actor, 'brain_list_missions', params);
const { projectId } = params;
const missions = await this.filterMissionsForActor(
actor,
(projectId
? await this.brain.missions.findByProject(projectId) ? await this.brain.missions.findByProject(projectId)
: await this.brain.missions.findAll(); : await this.brain.missions.findAll()) as MissionLike[],
);
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
}, },
); );
@@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy {
server.registerTool( server.registerTool(
'brain_list_conversations', 'brain_list_conversations',
{ {
description: 'List conversations for a user.', description: 'List conversations for the authenticated MCP actor.',
inputSchema: z.object({ inputSchema: strictObject({}),
userId: z.string().describe('User ID'),
}),
}, },
async ({ userId }) => { async (params) => {
const conversations = await this.brain.conversations.findAll(userId); assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
const conversations = await this.brain.conversations.findAll(actor.userId);
return { return {
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }], content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
}; };
@@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy {
'memory_search', 'memory_search',
{ {
description: description:
'Search across stored insights and knowledge using natural language. Returns semantically similar results.', 'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
inputSchema: z.object({ inputSchema: strictObject({
userId: z.string().describe('User ID to search memory for'),
query: z.string().describe('Natural language search query'), query: z.string().describe('Natural language search query'),
limit: z.number().optional().describe('Max results (default 5)'), limit: z.number().optional().describe('Max results (default 5)'),
}), }),
}, },
async ({ userId, query, limit }) => { async (params) => {
assertMcpToolAuthorized(actor, 'memory_search', params);
const { query, limit } = params;
if (!this.embeddings.available) { if (!this.embeddings.available) {
return { return {
content: [ content: [
@@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy {
}; };
} }
const embedding = await this.embeddings.embed(query); const embedding = await this.embeddings.embed(query);
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5); const results = await this.memory.insights.searchByEmbedding(
actor.userId,
embedding,
limit ?? 5,
);
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
}, },
); );
@@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy {
server.registerTool( server.registerTool(
'memory_get_preferences', 'memory_get_preferences',
{ {
description: 'Retrieve stored preferences for a user.', description: 'Retrieve stored preferences for the authenticated MCP actor.',
inputSchema: z.object({ inputSchema: strictObject({
userId: z.string().describe('User ID'),
category: z category: z
.string() .string()
.optional() .optional()
.describe('Filter by category: communication, coding, workflow, appearance, general'), .describe('Filter by category: communication, coding, workflow, appearance, general'),
}), }),
}, },
async ({ userId, category }) => { async (params) => {
assertMcpToolAuthorized(actor, 'memory_get_preferences', params);
const { category } = params;
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
const prefs = category const prefs = category
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat) ? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
: await this.memory.preferences.findByUser(userId); : await this.memory.preferences.findByUser(actor.userId);
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
}, },
); );
@@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_preference', 'memory_save_preference',
{ {
description: description:
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").', 'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
inputSchema: z.object({ inputSchema: strictObject({
userId: z.string().describe('User ID'),
key: z.string().describe('Preference key'), key: z.string().describe('Preference key'),
value: z.string().describe('Preference value (JSON string)'), value: z.string().describe('Preference value (JSON string)'),
category: z category: z
@@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy {
.describe('Category: communication, coding, workflow, appearance, general'), .describe('Category: communication, coding, workflow, appearance, general'),
}), }),
}, },
async ({ userId, key, value, category }) => { async (params) => {
assertMcpToolAuthorized(actor, 'memory_save_preference', params);
const { key, value, category } = params;
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
let parsedValue: unknown; let parsedValue: unknown;
try { try {
@@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy {
parsedValue = value; parsedValue = value;
} }
const pref = await this.memory.preferences.upsert({ const pref = await this.memory.preferences.upsert({
userId, userId: actor.userId,
key, key,
value: parsedValue, value: parsedValue,
category: (category as Cat) ?? 'general', category: (category as Cat) ?? 'general',
@@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_insight', 'memory_save_insight',
{ {
description: description:
'Store a learned insight, decision, or knowledge extracted from the current interaction.', 'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
inputSchema: z.object({ inputSchema: strictObject({
userId: z.string().describe('User ID'),
content: z.string().describe('The insight or knowledge to store'), content: z.string().describe('The insight or knowledge to store'),
category: z category: z
.string() .string()
@@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy {
.describe('Category: decision, learning, preference, fact, pattern, general'), .describe('Category: decision, learning, preference, fact, pattern, general'),
}), }),
}, },
async ({ userId, content, category }) => { async (params) => {
assertMcpToolAuthorized(actor, 'memory_save_insight', params);
const { content, category } = params;
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general'; type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null; const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
const insight = await this.memory.insights.create({ const insight = await this.memory.insights.create({
userId, userId: actor.userId,
content, content,
embedding, embedding,
source: 'agent', source: 'agent',
@@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy {
{ {
description: description:
'Get the current orchestration mission status including milestones, tasks, and active session.', 'Get the current orchestration mission status including milestones, tasks, and active session.',
inputSchema: z.object({ inputSchema: strictObject({}),
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
}, },
async ({ projectPath }) => { async (params) => {
const resolvedPath = projectPath ?? process.cwd(); assertMcpToolAuthorized(actor, 'coord_mission_status', params);
const status = await this.coordService.getMissionStatus(resolvedPath); const status = await this.coordService.getMissionStatus(process.cwd());
return { return {
content: [ content: [
{ {
@@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy {
'coord_list_tasks', 'coord_list_tasks',
{ {
description: 'List all tasks from the orchestration TASKS.md file.', description: 'List all tasks from the orchestration TASKS.md file.',
inputSchema: z.object({ inputSchema: strictObject({}),
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
}, },
async ({ projectPath }) => { async (params) => {
const resolvedPath = projectPath ?? process.cwd(); assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
const tasks = await this.coordService.listTasks(resolvedPath); const tasks = await this.coordService.listTasks(process.cwd());
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
}, },
); );
@@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy {
'coord_task_detail', 'coord_task_detail',
{ {
description: 'Get detailed status for a specific orchestration task.', description: 'Get detailed status for a specific orchestration task.',
inputSchema: z.object({ inputSchema: strictObject({
taskId: z.string().describe('Task ID (e.g. P2-005)'), taskId: z.string().describe('Task ID (e.g. P2-005)'),
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}), }),
}, },
async ({ taskId, projectPath }) => { async (params) => {
const resolvedPath = projectPath ?? process.cwd(); assertMcpToolAuthorized(actor, 'coord_task_detail', params);
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId); const { taskId } = params;
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
return { return {
content: [ content: [
{ {

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import {
createDiscordIngressEnvelope,
verifyDiscordIngressEnvelope,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
import { DiscordReplayProtector } from './discord-replay-protector.js';
const SERVICE_TOKEN = 'test-service-token';
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
return {
correlationId: 'correlation-001',
messageId: 'discord-message-001',
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'discord-channel-001',
content: 'hello Tess',
...overrides,
};
}
describe('Discord ingress security', () => {
it('accepts only the configured Discord service identity', () => {
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
});
it('rejects unauthenticated or tampered service envelopes', () => {
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
expect(
verifyDiscordIngressEnvelope(
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
SERVICE_TOKEN,
),
).toBeNull();
});
it.each([
['guild', { guildId: 'unlisted-guild' }],
['channel', { channelId: 'unlisted-channel' }],
['user', { userId: 'unlisted-user' }],
])(
'rejects an unallowlisted Discord %s',
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toBeNull();
},
);
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
const payload = createPayload({
correlationId: 'correlation-trace-123',
messageId: 'discord-snowflake-987',
});
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toEqual(payload);
});
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
const replayProtector = new DiscordReplayProtector(60_000, 2);
expect(replayProtector.claim('discord-message-001')).toBe(true);
expect(replayProtector.claim('discord-message-001')).toBe(false);
expect(replayProtector.claim('discord-message-002')).toBe(true);
expect(replayProtector.claim('discord-message-003')).toBe(true);
expect(replayProtector.size).toBe(2);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Bounded replay cache for Discord's globally unique native message IDs.
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
*/
export class DiscordReplayProtector {
private readonly claimedAt = new Map<string, number>();
constructor(
private readonly ttlMs = 15 * 60 * 1000,
private readonly maxEntries = 10_000,
) {}
get size(): number {
return this.claimedAt.size;
}
/** Claims an ID exactly once within its bounded retention window. */
claim(messageId: string, now = Date.now()): boolean {
this.prune(now);
if (this.claimedAt.has(messageId)) return false;
this.claimedAt.set(messageId, now);
this.evictOverflow();
return true;
}
private prune(now: number): void {
for (const [messageId, claimedAt] of this.claimedAt) {
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
}
}
private evictOverflow(): void {
while (this.claimedAt.size > this.maxEntries) {
const oldestMessageId = this.claimedAt.keys().next().value;
if (oldestMessageId === undefined) return;
this.claimedAt.delete(oldestMessageId);
}
}
}

View File

@@ -50,19 +50,41 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
const DEFAULT_GATEWAY_URL = 'http://localhost:14242'; const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
function requiredDiscordAllowlist(name: string): string[] {
const value = process.env[name]
?.split(',')
.map((id: string): string => id.trim())
.filter((id: string): boolean => id.length > 0);
if (!value || value.length === 0) {
throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`);
}
return value;
}
function createPluginRegistry(): IChannelPlugin[] { function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = []; const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN']; const discordToken = process.env['DISCORD_BOT_TOKEN'];
const discordGuildId = process.env['DISCORD_GUILD_ID']; const discordGuildId = process.env['DISCORD_GUILD_ID'];
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL; const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN'];
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordToken) { if (discordToken) {
if (!discordServiceToken || !discordServiceUserId) {
throw new Error(
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
);
}
plugins.push( plugins.push(
new DiscordChannelPluginAdapter( new DiscordChannelPluginAdapter(
new DiscordPlugin({ new DiscordPlugin({
token: discordToken, token: discordToken,
guildId: discordGuildId, guildId: discordGuildId,
gatewayUrl: discordGatewayUrl, gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken,
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
}), }),
), ),
); );

View File

@@ -1,9 +1,13 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { ActorTenantScope } from '../auth/session-scope.js';
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`; const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) => `${scope.tenantId}:${scope.userId}:${sessionId}`;
`mosaic:session:${sessionId}:system:fragments`; const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) =>
`mosaic:session:${scopedSessionId(sessionId, scope)}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) =>
`mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`;
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
interface OverrideFragment { interface OverrideFragment {
@@ -20,9 +24,9 @@ export class SystemOverrideService {
this.handle = createQueue(); this.handle = createQueue();
} }
async set(sessionId: string, override: string): Promise<void> { async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
// Load existing fragments // Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId)); const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing const fragments: OverrideFragment[] = existing
? (JSON.parse(existing) as OverrideFragment[]) ? (JSON.parse(existing) as OverrideFragment[])
: []; : [];
@@ -37,11 +41,11 @@ export class SystemOverrideService {
// Store both: fragments array and condensed result // Store both: fragments array and condensed result
const pipeline = this.handle.redis.pipeline(); const pipeline = this.handle.redis.pipeline();
pipeline.setex( pipeline.setex(
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
SYSTEM_OVERRIDE_TTL_SECONDS, SYSTEM_OVERRIDE_TTL_SECONDS,
JSON.stringify(fragments), JSON.stringify(fragments),
); );
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed); pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
await pipeline.exec(); await pipeline.exec();
this.logger.debug( this.logger.debug(
@@ -49,21 +53,21 @@ export class SystemOverrideService {
); );
} }
async get(sessionId: string): Promise<string | null> { async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId)); return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
} }
async renew(sessionId: string): Promise<void> { async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
const pipeline = this.handle.redis.pipeline(); const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
await pipeline.exec(); await pipeline.exec();
} }
async clear(sessionId: string): Promise<void> { async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
await this.handle.redis.del( await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId), SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
); );
this.logger.debug(`Cleared system override for session ${sessionId}`); this.logger.debug(`Cleared system override for session ${sessionId}`);
} }

View File

@@ -68,7 +68,7 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
## Workstreams ## Workstreams
| # | ID | Name | Status | Manifest | Notes | | # | ID | Name | Status | Manifest | Notes |
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | | --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460#466 filed | | W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460#466 filed |
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | | W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | | W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |

View File

@@ -85,7 +85,7 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent. Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
The objective is to ship **Tess** (from *tessera*, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency. The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
### Scope ### Scope

View File

@@ -15,7 +15,7 @@
## Workstream Rollup ## Workstream Rollup
| id | status | workstream | progress | tasks file | notes | | id | status | workstream | progress | tasks file | notes |
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- | | --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning | | W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning |
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | | W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |

View File

@@ -232,6 +232,10 @@ The following sections document how each supported channel maps its native messa
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. **Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
### Discord service ingress security
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
--- ---
### Telegram ### Telegram

View File

@@ -294,13 +294,24 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
### Plugins ### Plugins
| Variable | Description | | Variable | Description |
| ---------------------- | -------------------------------------------------------------------------- | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | | `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only |
| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata |
| `DISCORD_GUILD_ID` | Discord guild/server ID | | `DISCORD_GUILD_ID` | Discord guild/server ID |
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | | `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny |
| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny |
| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | | `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | | `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
### Discord ingress security
When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged.
Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state.
### Observability ### Observability
| Variable | Default | Description | | Variable | Default | Description |

View File

@@ -0,0 +1,49 @@
# #703 Git Wrapper Interactive and Auth Resilience
## Objective
Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback.
## Scope
- `packages/mosaic/framework/tools/git/issue-create.sh`
- `packages/mosaic/framework/tools/git/detect-platform.sh`
- Git wrapper regression harnesses
- This scratchpad
## Requirements / acceptance evidence
1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials.
2. Explicit command-line fields retain precedence and do not trigger prompt input.
3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity.
4. A Tea `GetUserByName` failure falls back to authenticated API creation.
5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green.
6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end.
## Plan
1. Add failing shell regression harness for interactive input and stale Tea user fallback.
2. Implement minimal helper and parser changes.
3. Run wrapper harnesses, syntax checks, and repository baseline checks.
4. Re-seed deployed framework and run live wrapper verification.
5. Commit, queue guard, push, open PR, and stop for independent review.
## Progress
- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger.
- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1).
- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition.
- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation.
- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched.
- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`.
## Verification
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh`
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh`
- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh`
- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh`
- PASS: Prettier check for this scratchpad

View File

@@ -0,0 +1,38 @@
# TESS-M1-002 — Provider Registry
- **Issue:** #707
- **Branch:** `feat/tess-provider-registry`
- **Objective:** Build the runtime provider registry/service boundary that derives immutable actor/tenant/channel/correlation scope server-side, fail-closes unsupported and destructive runtime operations, binds termination approval to the exact structured action, and emits correlation-safe audit events.
## Plan
1. Add a provider-agnostic registry in `@mosaicstack/agent` over the merged `AgentRuntimeProvider` contract.
2. Write abuse-case tests before implementation for duplicate/unknown providers, immutable server-derived scope, capability denial, approval mismatch/absence, and audit failure.
3. Implement the Gateway service that converts only authenticated `ActorTenantScope` plus trusted ingress metadata into a frozen `RuntimeScope`, gates capabilities and terminate approval, and records metadata-only audits.
4. Register the service in `AgentModule`, then run focused, baseline, cold-cache, and independent-review gates.
## Security Invariants
- Caller-supplied actor/tenant identity never reaches runtime providers.
- Provider capability absence and approval/audit failure deny before side effects.
- Termination approval is verified against provider, session, actor, tenant, channel, and correlation context.
- Audit events retain correlation and authority metadata but never message content or approval material.
## Progress
- 2026-07-12: Created fresh worktree from `origin/main` at `119f64e6`; source and Tess planning/security documentation reviewed.
- 2026-07-12: Security TDD added registry and gateway abuse tests before implementation.
- 2026-07-12: Implemented `AgentRuntimeProviderRegistry` and gateway `RuntimeProviderService`; registered both in `AgentModule` and documented the internal boundary.
- 2026-07-12: Independent review found two audit correctness issues. Remediated completion-audit failure handling and provider execution failures: pre-invocation denials are audited as `denied`; post-invocation errors as `failed`; completion audit failure does not misreport a completed effect as retryable.
- 2026-07-12: Final independent security review: no findings. Final code review had one false positive: `@mosaicstack/types` is already declared in `packages/agent/package.json`.
## Tests
- TDD red: `pnpm --filter @mosaicstack/gateway test -- runtime-provider-registry.service.test.ts` failed before both audit remediations, as expected.
- Focused: package registry 2 tests and gateway security boundary 7 tests pass.
- Cold-cache: removed this worktree's `node_modules`, then `pnpm install --offline --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` passed.
- Cold-cache baseline: `TURBO_FORCE=true pnpm typecheck` — 42/42 tasks passed; `TURBO_FORCE=true pnpm lint` — 23/23 tasks passed; `TURBO_FORCE=true pnpm format:check` passed; `TURBO_FORCE=true pnpm test` — 42/42 tasks passed (gateway 548 tests passed, 11 intentionally skipped).
## Risks / Blockers
- The canonical durable approval implementation is currently command-specific. This card introduces a fail-closed runtime approval verifier boundary so a runtime provider cannot terminate until its exact-action verifier is wired; later provider implementations cannot bypass it.

View File

@@ -0,0 +1,27 @@
# TESS-M1-SEC-001 — Command authorization and exact-action approval
- Issue/milestone: #707 / M1
- Branch: `fix/tess-command-authz`
- Requirement: `TESS-SEC-002`, with approval binding controls from `TESS-SEC-007`
- Scope: `apps/gateway` only, plus required in-repo security/developer documentation.
## Plan
1. Locate the gateway command executor, command metadata, authorization context, and existing test conventions.
2. Write abuse/authz tests before production changes. Expected red cases: non-admin blocked from admin/system command; forged caller scope cannot authorize; privileged/destructive action requires durable exact-action approval; expired/replayed/mutated approvals deny.
3. Implement server-derived role/scope enforcement and durable approval validation/consumption with audit results.
4. Run focused security tests, then repository baseline gates: typecheck, lint, format-check, test.
5. Run independent security/code review, commit, queue-guard, push, and open the PR to `main` through the stated Gitea API fallback. Stop after PR creation.
## Assumptions
- The existing gateway persistence interface is the available durable approval boundary. If no persistence abstraction exists, a minimal injectable repository interface will be introduced rather than an in-memory approval implementation, because TESS-SEC-002/007 require durable enforcement.
- “Exact action” is a canonical digest over structured command identity and normalized arguments; role/scope checks always use authenticated server context, not client-declared claims.
## TDD evidence
- Pending: abuse/authz test written and observed red before implementation.
## Verification evidence
- Pending.

View File

@@ -0,0 +1,35 @@
# Scratchpad — TESS-M1-SEC-004 Discord ingress
- **Task / issue:** TESS-M1-SEC-004 / #707
- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd`
- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs.
- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs.
- **Budget:** Task estimate 28K; no explicit hard cap supplied.
- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work.
## Plan
1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection.
2. Implement the signed Discord ingress envelope and allowlist validation in the plugin.
3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch.
4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review.
## Progress
- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed.
- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks.
- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved.
## Risks / blockers
- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work.
## Verification evidence
- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed.
- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped.
- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`.
- `pnpm typecheck` — passed.
- `pnpm lint` — passed.
- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification.
- Codex security review — no findings; final Codex code review — approved.

View File

@@ -37,10 +37,16 @@ Required operations:
Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors. Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors.
### M1 Registry Boundary
`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references.
Termination is fail-closed: a runtime approval verifier must consume a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. Until the durable verifier is wired, the default verifier denies termination. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary.
## Authority Model ## Authority Model
| Intent | Owner | Tess behavior | | Intent | Owner | Tess behavior |
| --- | --- | --- | | --------------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------- |
| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy | | Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy |
| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result | | Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result |
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | | Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |

View File

@@ -3,7 +3,7 @@
Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover. Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover.
| Capability | Current source | Target | Initial status | Cutover/rollback intent | | Capability | Current source | Target | Initial status | Cutover/rollback intent |
| --- | --- | --- | --- | --- | | ---------------------------------------- | ---------------------------------------- | ------------------------------------------ | -------------- | ----------------------------------------------------------------------- |
| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway | | Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway |
| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak | | Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak |
| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback | | CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback |

View File

@@ -28,7 +28,7 @@ Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction ag
## Milestones ## Milestones
| ID | Issue | Name | Status | Exit gate | | ID | Issue | Name | Status | Exit gate |
| --- | --- | --- | --- | --- | | ------- | ----- | ------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged | | TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged |
| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests | | TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests |
| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals | | TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals |
@@ -42,5 +42,5 @@ All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. T
## Session History ## Session History
| Session | Date | Runtime | Outcome | | Session | Date | Runtime | Outcome |
| --- | --- | --- | --- | | ------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. | | S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. |

View File

@@ -9,7 +9,7 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden
## Threat Matrix ## Threat Matrix
| ID | Severity | Threat | Required control | Required verification | | ID | Severity | Threat | Required control | Required verification |
| --- | --- | --- | --- | --- | | ----- | -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit | | TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit |
| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods | | TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods |
| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned | | TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned |

View File

@@ -1,7 +1,7 @@
# Tess Verification Matrix # Tess Verification Matrix
| Acceptance criterion | Requirements | Planned evidence | Gate | | Acceptance criterion | Requirements | Planned evidence | Gate |
| --- | --- | --- | --- | | -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | | AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | | AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | | AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |

View File

@@ -1 +1,3 @@
export const VERSION = '0.0.0'; export const VERSION = '0.0.0';
export * from './runtime-provider-registry.js';

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
import { AgentRuntimeProviderRegistry } from './runtime-provider-registry.js';
class TestRuntimeProvider implements AgentRuntimeProvider {
readonly id = 'test-runtime';
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
return { supported: ['session.list'] };
}
async health(_scope: RuntimeScope): Promise<RuntimeHealth> {
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
}
async listSessions(_scope: RuntimeScope): Promise<RuntimeSession[]> {
return [];
}
async getSessionTree(_scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
return [];
}
async *streamSession(
_sessionId: string,
_cursor: string | undefined,
_scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
return;
}
async sendMessage(
_sessionId: string,
_message: RuntimeMessage,
_scope: RuntimeScope,
): Promise<void> {}
async attach(
_sessionId: string,
_mode: RuntimeAttachMode,
_scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
return {
attachmentId: 'attachment-1',
sessionId: 'session-1',
mode: 'read',
expiresAt: '2026-07-12T00:00:00.000Z',
};
}
async detach(_attachmentId: string, _scope: RuntimeScope): Promise<void> {}
async terminate(_sessionId: string, _approvalRef: string, _scope: RuntimeScope): Promise<void> {}
}
describe('AgentRuntimeProviderRegistry', (): void => {
it('resolves only registered runtime providers', (): void => {
const registry = new AgentRuntimeProviderRegistry();
const provider = new TestRuntimeProvider();
registry.register(provider);
expect(registry.get(provider.id)).toBe(provider);
expect(registry.get('unknown-runtime')).toBeUndefined();
expect(registry.list()).toEqual([provider]);
});
it('rejects duplicate and blank provider identities rather than silently replacing a runtime', (): void => {
const registry = new AgentRuntimeProviderRegistry();
const provider = new TestRuntimeProvider();
registry.register(provider);
expect((): void => {
registry.register(provider);
}).toThrow(/already registered/);
expect((): void => {
registry.require('');
}).toThrow(/provider ID is required/);
expect((): void => {
registry.require('unknown-runtime');
}).toThrow(/not registered/);
});
});

View File

@@ -0,0 +1,40 @@
import type { AgentRuntimeProvider } from '@mosaicstack/types';
/**
* Registry for runtime providers. Registration is explicit and replacement is
* forbidden so a provider identity cannot be silently hijacked at runtime.
*/
export class AgentRuntimeProviderRegistry {
private readonly providers = new Map<string, AgentRuntimeProvider>();
register(provider: AgentRuntimeProvider): void {
const providerId = provider.id.trim();
if (providerId.length === 0) {
throw new Error('Runtime provider ID is required');
}
if (this.providers.has(providerId)) {
throw new Error(`Runtime provider is already registered: ${providerId}`);
}
this.providers.set(providerId, provider);
}
get(providerId: string): AgentRuntimeProvider | undefined {
return this.providers.get(providerId);
}
require(providerId: string): AgentRuntimeProvider {
const normalizedProviderId = providerId.trim();
if (normalizedProviderId.length === 0) {
throw new Error('Runtime provider ID is required');
}
const provider = this.providers.get(normalizedProviderId);
if (!provider) {
throw new Error(`Runtime provider is not registered: ${normalizedProviderId}`);
}
return provider;
}
list(): AgentRuntimeProvider[] {
return Array.from(this.providers.values());
}
}

View File

@@ -10,3 +10,8 @@ export {
type LogQuery, type LogQuery,
} from './agent-logs.js'; } from './agent-logs.js';
export { registerLogCommand } from './cli.js'; export { registerLogCommand } from './cli.js';
export {
redactSensitiveContent,
type RedactionResult,
type SensitiveClassification,
} from './redaction.js';

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { redactSensitiveContent } from './redaction.js';
describe('redactSensitiveContent', (): void => {
it('redacts seeded secret and PII canaries before persistence or egress', (): void => {
const result = redactSensitiveContent(
'email canary@example.test token=sk_CANARY12345678 phone +1 555 555 1212',
);
expect(result.content).not.toContain('canary@example.test');
expect(result.content).not.toContain('sk_CANARY12345678');
expect(result.content).not.toContain('+1 555 555 1212');
expect(result.classifications).toEqual(['secret', 'pii']);
});
it('redacts common provider credential formats', (): void => {
const result = redactSensitiveContent(
'Authorization: Bearer canary.bearer.token jwt eyJcanary.eyJpayload.eyJsignature aws AKIACANARY1234567890',
);
expect(result.content).not.toContain('canary.bearer.token');
expect(result.content).not.toContain('eyJcanary.eyJpayload.eyJsignature');
expect(result.content).not.toContain('AKIACANARY1234567890');
expect(result.classifications).toEqual(['secret']);
});
});

View File

@@ -0,0 +1,40 @@
export type SensitiveClassification = 'secret' | 'pii';
export interface RedactionResult {
content: string;
classifications: SensitiveClassification[];
}
const SECRET_PATTERNS: RegExp[] = [
/\b(?:sk|ghp|gitea)_[A-Za-z0-9_-]{8,}\b/g,
/\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi,
/\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
/-----BEGIN(?: [A-Z]+)* KEY-----[\s\S]*?-----END(?: [A-Z]+)* KEY-----/g,
/https?:\/\/[^\s?#]+[^\s]*[?&](?:token|key|secret|signature|sig)=[^\s&#]+/gi,
];
const PII_PATTERNS: RegExp[] = [
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
/\b\+?\d[\d(). -]{7,}\d\b/g,
];
export function redactSensitiveContent(content: string): RedactionResult {
let redacted = content;
const classifications: SensitiveClassification[] = [];
for (const pattern of SECRET_PATTERNS) {
if (pattern.test(redacted)) {
classifications.push('secret');
redacted = redacted.replace(pattern, '[REDACTED_SECRET]');
}
pattern.lastIndex = 0;
}
for (const pattern of PII_PATTERNS) {
if (pattern.test(redacted)) {
classifications.push('pii');
redacted = redacted.replace(pattern, '[REDACTED_PII]');
}
pattern.lastIndex = 0;
}
return { content: redacted, classifications: [...new Set(classifications)] };
}

View File

@@ -240,6 +240,33 @@ get_gitea_login_for_host() {
return 1 return 1
} }
# Validate the current authenticated Gitea user for a resolved Tea login.
# Tea stores a user name with each login which can become stale after user rename,
# token rotation, or server migration. Querying /user derives the identity from the
# active credential instead of trusting that saved name. Callers fall back to the
# host-scoped API path when this validation fails.
get_gitea_authenticated_user() {
local login_name="$1" response
command -v tea >/dev/null 2>&1 || return 1
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
import json
import os
try:
user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"])
except (KeyError, json.JSONDecodeError):
raise SystemExit(1)
login = user.get("login") if isinstance(user, dict) else None
if isinstance(login, str) and login:
print(login)
raise SystemExit(0)
raise SystemExit(1)
PY
}
get_default_tea_login() { get_default_tea_login() {
local logins_json local logins_json

View File

@@ -12,6 +12,7 @@ TITLE=""
BODY="" BODY=""
LABELS="" LABELS=""
MILESTONE="" MILESTONE=""
INTERACTIVE=false
# get_remote_host and get_gitea_token are provided by detect-platform.sh # get_remote_host and get_gitea_token are provided by detect-platform.sh
@@ -66,11 +67,13 @@ Options:
-b, --body BODY Issue body/description -b, --body BODY Issue body/description
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature") -l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
-m, --milestone NAME Milestone name to assign -m, --milestone NAME Milestone name to assign
-i, --interactive Prompt for missing issue fields
-h, --help Show this help message -h, --help Show this help message
Examples: Examples:
$(basename "$0") -t "Fix login bug" -l "bug,priority-high" $(basename "$0") -t "Fix login bug" -l "bug,priority-high"
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0" $(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
$(basename "$0") -i
EOF EOF
exit "${1:-1}" exit "${1:-1}"
} }
@@ -94,6 +97,10 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2" MILESTONE="$2"
shift 2 shift 2
;; ;;
-i|--interactive)
INTERACTIVE=true
shift
;;
-h|--help) -h|--help)
usage 0 usage 0
;; ;;
@@ -104,6 +111,13 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
if [[ "$INTERACTIVE" == true ]]; then
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
[[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true
[[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true
fi
if [[ -z "$TITLE" ]]; then if [[ -z "$TITLE" ]]; then
echo "Error: Title is required (-t)" >&2 echo "Error: Title is required (-t)" >&2
usage usage
@@ -127,6 +141,11 @@ case "$PLATFORM" in
gitea_issue_create_api gitea_issue_create_api
exit $? exit $?
} }
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_issue_create_api
exit $?
fi
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE") CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY") [[ -n "$BODY" ]] && CMD+=(--description "$BODY")

View File

@@ -183,6 +183,11 @@ case "$PLATFORM" in
gitea_pr_create_api gitea_pr_create_api
exit $? exit $?
} }
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_pr_create_api
exit $?
fi
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE") CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY") [[ -n "$BODY" ]] && CMD+=(--description "$BODY")

View File

@@ -45,6 +45,11 @@ JSON
exit 0 exit 0
fi fi
if [[ "${1:-}" == "api" ]]; then
printf '%s\n' '{"login":"ci-bot"}'
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG" printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
echo 'GetUserByName: simulated stale login failure' >&2 echo 'GetUserByName: simulated stale login failure' >&2

View File

@@ -55,6 +55,11 @@ JSON
exit 0 exit 0
fi fi
if [[ "${1:-}" == "api" ]]; then
printf '%s\n' '{"login":"ci-bot"}'
exit 0
fi
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
desc="" desc=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Regression harness for #703: interactive issue creation and stale Tea-user fallback.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
JSON
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
if [[ "${1:-}" == "api" ]]; then
if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then
echo 'GetUserByName: stale configured user' >&2
exit 1
fi
printf '%s\n' '{"login":"current-user"}'
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
exit 0
SH
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
printf '%s\n' '{"number":703}'
SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
run_wrapper() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
)
}
: > "$LOG_FILE"
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE"
# Explicit values take precedence in interactive mode: no title input is
# supplied, but the wrapper still creates the issue with the explicit title.
: > "$LOG_FILE"
printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE"
: > "$LOG_FILE"
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr"
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE"
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr"
if grep -q -- 'tea issue create' "$LOG_FILE"; then
echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2
exit 1
fi
: > "$LOG_FILE"
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr"
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE"
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr"
if grep -q -- 'tea pr create' "$LOG_FILE"; then
echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2
exit 1
fi
echo 'issue-create interactive/auth regression harness passed'

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeCapability,
RuntimeError,
RuntimeSession,
RuntimeStreamEvent,
} from './agent-runtime-provider.js';
describe('AgentRuntimeProvider contract', (): void => {
it('exposes stable, normalized runtime-only contracts', (): void => {
const capability: RuntimeCapability = 'session.attach';
const session: RuntimeSession = {
id: 'session-1',
providerId: 'fleet',
runtimeId: 'tmux',
state: 'active',
createdAt: '2026-07-12T00:00:00.000Z',
updatedAt: '2026-07-12T00:00:00.000Z',
};
const event: RuntimeStreamEvent = {
type: 'message.delta',
sessionId: session.id,
cursor: '2',
occurredAt: session.updatedAt,
content: 'hello',
};
const error: RuntimeError = {
code: 'capability_unsupported',
message: 'Denied',
retryable: false,
};
const attach: RuntimeAttachHandle = {
attachmentId: 'attach-1',
sessionId: session.id,
mode: 'read',
expiresAt: session.updatedAt,
};
const provider: Pick<AgentRuntimeProvider, 'capabilities' | 'getSessionTree' | 'attach'> =
{} as Pick<AgentRuntimeProvider, 'capabilities' | 'getSessionTree' | 'attach'>;
expect([capability, session.id, event.type, error.code, attach.mode, provider]).toHaveLength(6);
});
});

View File

@@ -0,0 +1,110 @@
export type RuntimeCapability =
| 'session.list'
| 'session.tree'
| 'session.stream'
| 'session.send'
| 'session.attach'
| 'session.terminate';
export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed';
export type RuntimeAttachMode = 'read' | 'control';
/** Server-derived immutable authority context. Client identity fields are intentionally absent. */
export interface RuntimeScope {
readonly actorId: string;
readonly tenantId: string;
readonly channelId: string;
readonly correlationId: string;
}
export interface RuntimeSession {
id: string;
providerId: string;
runtimeId: string;
parentSessionId?: string;
state: RuntimeSessionState;
createdAt: string;
updatedAt: string;
}
export interface RuntimeSessionTree {
session: RuntimeSession;
children: RuntimeSessionTree[];
}
export interface RuntimeCapabilitySet {
supported: RuntimeCapability[];
}
export interface RuntimeHealth {
status: 'healthy' | 'degraded' | 'down';
checkedAt: string;
detail?: string;
}
export interface RuntimeMessage {
content: string;
idempotencyKey: string;
}
export interface RuntimeAttachHandle {
attachmentId: string;
sessionId: string;
mode: RuntimeAttachMode;
expiresAt: string;
}
export interface RuntimeError {
code:
| 'capability_unsupported'
| 'not_found'
| 'forbidden'
| 'conflict'
| 'unavailable'
| 'invalid_request';
message: string;
retryable: boolean;
}
export type RuntimeStreamEvent =
| {
type: 'session.state';
sessionId: string;
cursor: string;
occurredAt: string;
state: RuntimeSessionState;
}
| {
type: 'message.delta';
sessionId: string;
cursor: string;
occurredAt: string;
content: string;
}
| {
type: 'message.complete';
sessionId: string;
cursor: string;
occurredAt: string;
messageId: string;
}
| {
type: 'runtime.error';
sessionId: string;
cursor: string;
occurredAt: string;
error: RuntimeError;
};
/** Runtime-neutral boundary; implementations fail closed for unsupported operations. */
export interface AgentRuntimeProvider {
readonly id: string;
capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet>;
health(scope: RuntimeScope): Promise<RuntimeHealth>;
listSessions(scope: RuntimeScope): Promise<RuntimeSession[]>;
getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]>;
streamSession(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent>;
sendMessage(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle>;
detach(attachmentId: string, scope: RuntimeScope): Promise<void>;
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
}

View File

@@ -2,3 +2,5 @@
export interface AgentSessionHandle { export interface AgentSessionHandle {
readonly id: string; readonly id: string;
} }
export * from './agent-runtime-provider.js';

View File

@@ -1,5 +1,6 @@
import type { import type {
CommandManifestPayload, CommandManifestPayload,
SlashCommandApprovalResultPayload,
SlashCommandPayload, SlashCommandPayload,
SlashCommandResultPayload, SlashCommandResultPayload,
SystemReloadPayload, SystemReloadPayload,
@@ -116,6 +117,7 @@ export interface ServerToClientEvents {
'session:info': (payload: SessionInfoPayload) => void; 'session:info': (payload: SessionInfoPayload) => void;
'commands:manifest': (payload: CommandManifestPayload) => void; 'commands:manifest': (payload: CommandManifestPayload) => void;
'command:result': (payload: SlashCommandResultPayload) => void; 'command:result': (payload: SlashCommandResultPayload) => void;
'command:approval': (payload: SlashCommandApprovalResultPayload) => void;
'system:reload': (payload: SystemReloadPayload) => void; 'system:reload': (payload: SystemReloadPayload) => void;
error: (payload: ErrorPayload) => void; error: (payload: ErrorPayload) => void;
} }
@@ -125,5 +127,6 @@ export interface ClientToServerEvents {
message: (data: ChatMessagePayload) => void; message: (data: ChatMessagePayload) => void;
'set:thinking': (data: SetThinkingPayload) => void; 'set:thinking': (data: SetThinkingPayload) => void;
'command:execute': (data: SlashCommandPayload) => void; 'command:execute': (data: SlashCommandPayload) => void;
'command:approve': (data: SlashCommandPayload) => void;
abort: (data: AbortPayload) => void; abort: (data: AbortPayload) => void;
} }

View File

@@ -63,6 +63,18 @@ export interface SlashCommandPayload {
conversationId: string; conversationId: string;
command: string; command: string;
args?: string; args?: string;
/** One-time server-issued approval for a privileged command execution. */
approvalId?: string;
}
/** Server response to a request to approve a privileged slash command. */
export interface SlashCommandApprovalResultPayload {
conversationId: string;
command: string;
success: boolean;
approvalId?: string;
expiresAt?: string;
message?: string;
} }
/** Server response to a slash command */ /** Server response to a slash command */

View File

@@ -1,24 +1,109 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js'; import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
import { io, type Socket } from 'socket.io-client'; import { io, type Socket } from 'socket.io-client';
export interface DiscordPluginConfig { export interface DiscordPluginConfig {
token: string; token: string;
gatewayUrl: string; gatewayUrl: string;
/** Which guild to bind to (single-guild only for v0.1.0) */ /** Shared service credential injected by the approved secret mechanism. */
serviceToken: string;
/** Which guild to bind to (single-guild only for v0.1.0). */
guildId?: string; guildId?: string;
allowedGuildIds: readonly string[];
allowedChannelIds: readonly string[];
allowedUserIds: readonly string[];
}
export interface DiscordIngressPayload {
correlationId: string;
messageId: string;
guildId: string;
channelId: string;
userId: string;
conversationId: string;
content: string;
}
export interface DiscordIngressEnvelope {
payload: DiscordIngressPayload;
signature: string;
}
export interface DiscordIngressAllowlists {
guildIds: readonly string[];
channelIds: readonly string[];
userIds: readonly string[];
}
function signedPayload(payload: DiscordIngressPayload): string {
return [
payload.correlationId,
payload.messageId,
payload.guildId,
payload.channelId,
payload.userId,
payload.conversationId,
payload.content,
].join('\n');
}
function signPayload(payload: DiscordIngressPayload, serviceToken: string): string {
return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex');
}
function isSignatureValid(actual: string, expected: string): boolean {
const actualBuffer = Buffer.from(actual, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
return (
actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
);
}
function includesId(allowedIds: readonly string[], id: string): boolean {
return allowedIds.includes(id);
}
/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */
export function createDiscordIngressEnvelope(
payload: DiscordIngressPayload,
serviceToken: string,
): DiscordIngressEnvelope {
return { payload, signature: signPayload(payload, serviceToken) };
}
/**
* Verifies service-origin integrity and applies default-deny Discord identity allowlists.
* Returns null instead of a partially trusted payload on every failure path.
*/
export function verifyDiscordIngressEnvelope(
envelope: DiscordIngressEnvelope,
serviceToken: string,
allowlists?: DiscordIngressAllowlists,
): DiscordIngressPayload | null {
const expectedSignature = signPayload(envelope.payload, serviceToken);
if (!isSignatureValid(envelope.signature, expectedSignature)) return null;
if (
allowlists &&
(!includesId(allowlists.guildIds, envelope.payload.guildId) ||
!includesId(allowlists.channelIds, envelope.payload.channelId) ||
!includesId(allowlists.userIds, envelope.payload.userId))
) {
return null;
}
return envelope.payload;
} }
export class DiscordPlugin { export class DiscordPlugin {
private client: Client; private client: Client;
private socket: Socket | null = null; private socket: Socket | null = null;
private config: DiscordPluginConfig; /** Map Discord channel ID → Mosaic conversation ID. */
/** Map Discord channel ID → Mosaic conversation ID */
private channelConversations = new Map<string, string>(); private channelConversations = new Map<string, string>();
/** Track in-flight responses to avoid duplicate streaming */ /** Track in-flight responses to avoid duplicate streaming. */
private pendingResponses = new Map<string, string>(); private pendingResponses = new Map<string, string>();
constructor(config: DiscordPluginConfig) { constructor(private readonly config: DiscordPluginConfig) {
this.config = config;
this.client = new Client({ this.client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
@@ -30,8 +115,8 @@ export class DiscordPlugin {
} }
async start(): Promise<void> { async start(): Promise<void> {
// Connect to gateway WebSocket
this.socket = io(`${this.config.gatewayUrl}/chat`, { this.socket = io(`${this.config.gatewayUrl}/chat`, {
auth: { discordServiceToken: this.config.serviceToken },
transports: ['websocket'], transports: ['websocket'],
}); });
@@ -48,7 +133,6 @@ export class DiscordPlugin {
console.error(`[discord] Gateway connection error: ${err.message}`); console.error(`[discord] Gateway connection error: ${err.message}`);
}); });
// Handle streaming text from gateway
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => { this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
const pending = this.pendingResponses.get(data.conversationId); const pending = this.pendingResponses.get(data.conversationId);
if (pending !== undefined) { if (pending !== undefined) {
@@ -56,12 +140,11 @@ export class DiscordPlugin {
} }
}); });
// When agent finishes, send the accumulated response
this.socket.on('agent:end', (data: { conversationId: string }) => { this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId); const text = this.pendingResponses.get(data.conversationId);
if (text) { if (text) {
this.pendingResponses.delete(data.conversationId); this.pendingResponses.delete(data.conversationId);
this.sendToDiscord(data.conversationId, text).catch((err) => { this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
console.error(`[discord] Error sending response for ${data.conversationId}:`, err); console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
}); });
} }
@@ -71,8 +154,9 @@ export class DiscordPlugin {
this.pendingResponses.set(data.conversationId, ''); this.pendingResponses.set(data.conversationId, '');
}); });
// Set up Discord message handler this.client.on('messageCreate', (message: DiscordMessage) =>
this.client.on('messageCreate', (message) => this.handleDiscordMessage(message)); this.handleDiscordMessage(message),
);
this.client.on('ready', () => { this.client.on('ready', () => {
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`); console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
@@ -96,7 +180,6 @@ export class DiscordPlugin {
const guild = this.client.guilds.cache.get(this.config.guildId); const guild = this.client.guilds.cache.get(this.config.guildId);
if (!guild) return null; if (!guild) return null;
// Slugify project name for channel: lowercase, replace spaces/special chars with hyphens
const channelName = `mosaic-${project.name const channelName = `mosaic-${project.name
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
@@ -108,58 +191,58 @@ export class DiscordPlugin {
topic: project.description ?? `Mosaic project: ${project.name}`, topic: project.description ?? `Mosaic project: ${project.name}`,
}); });
// Register the channel mapping so messages route correctly
this.channelConversations.set(channel.id, `discord-${channel.id}`); this.channelConversations.set(channel.id, `discord-${channel.id}`);
return { channelId: channel.id }; return { channelId: channel.id };
} }
private handleDiscordMessage(message: DiscordMessage): void { private handleDiscordMessage(message: DiscordMessage): void {
// Ignore bot messages if (message.author.bot || !this.client.user) return;
if (message.author.bot) return; if (!message.guildId || !this.isAllowedMessage(message)) return;
// Not ready yet
if (!this.client.user) return;
// Check guild binding
if (this.config.guildId && message.guildId !== this.config.guildId) return;
// Respond to DMs always, or mentions in channels
const isDM = !message.guildId;
const isMention = message.mentions.has(this.client.user); const isMention = message.mentions.has(this.client.user);
if (!isMention) return;
if (!isDM && !isMention) return;
// Strip bot mention from message content
const content = message.content const content = message.content
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '') .replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
.trim(); .trim();
if (!content) return; if (!content) return;
// Get or create conversation for this Discord channel
const channelId = message.channelId;
let conversationId = this.channelConversations.get(channelId);
if (!conversationId) {
conversationId = `discord-${channelId}`;
this.channelConversations.set(channelId, conversationId);
}
// Send to gateway
if (!this.socket?.connected) { if (!this.socket?.connected) {
console.error( console.error(
`[discord] Cannot forward message: not connected to gateway. channel=${channelId}`, `[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
); );
return; return;
} }
this.socket.emit('message', {
const channelId = message.channelId;
const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`;
this.channelConversations.set(channelId, conversationId);
const envelope = createDiscordIngressEnvelope(
{
correlationId: randomUUID(),
messageId: message.id,
guildId: message.guildId,
channelId,
userId: message.author.id,
conversationId, conversationId,
content, content,
}); },
this.config.serviceToken,
);
this.socket.emit('message', envelope);
}
private isAllowedMessage(message: DiscordMessage): boolean {
const guildId = message.guildId;
return (
guildId !== null &&
includesId(this.config.allowedGuildIds, guildId) &&
includesId(this.config.allowedChannelIds, message.channelId) &&
includesId(this.config.allowedUserIds, message.author.id)
);
} }
private async sendToDiscord(conversationId: string, text: string): Promise<void> { private async sendToDiscord(conversationId: string, text: string): Promise<void> {
// Find the Discord channel for this conversation
const channelId = Array.from(this.channelConversations.entries()).find( const channelId = Array.from(this.channelConversations.entries()).find(
([, convId]) => convId === conversationId, ([, convId]) => convId === conversationId,
)?.[0]; )?.[0];
@@ -177,12 +260,10 @@ export class DiscordPlugin {
return; return;
} }
// Chunk responses for Discord's 2000-char limit for (const chunk of this.chunkText(text, 1900)) {
const chunks = this.chunkText(text, 1900);
for (const chunk of chunks) {
try { try {
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk); await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
} catch (err) { } catch (err: unknown) {
console.error(`[discord] Failed to send message to channel ${channelId}:`, err); console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
} }
} }
@@ -193,21 +274,16 @@ export class DiscordPlugin {
const chunks: string[] = []; const chunks: string[] = [];
let remaining = text; let remaining = text;
while (remaining.length > 0) { while (remaining.length > 0) {
if (remaining.length <= maxLength) { if (remaining.length <= maxLength) {
chunks.push(remaining); chunks.push(remaining);
break; break;
} }
// Try to break at a newline
let breakPoint = remaining.lastIndexOf('\n', maxLength); let breakPoint = remaining.lastIndexOf('\n', maxLength);
if (breakPoint <= 0) breakPoint = maxLength; if (breakPoint <= 0) breakPoint = maxLength;
chunks.push(remaining.slice(0, breakPoint)); chunks.push(remaining.slice(0, breakPoint));
remaining = remaining.slice(breakPoint).trimStart(); remaining = remaining.slice(breakPoint).trimStart();
} }
return chunks; return chunks;
} }
} }

3
pnpm-lock.yaml generated
View File

@@ -75,6 +75,9 @@ importers:
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
specifier: ^1.27.1 specifier: ^1.27.1
version: 1.27.1(zod@4.3.6) version: 1.27.1(zod@4.3.6)
'@mosaicstack/agent':
specifier: workspace:^
version: link:../../packages/agent
'@mosaicstack/auth': '@mosaicstack/auth':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/auth version: link:../../packages/auth