@siduri-x/api 1.0.5 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/b0-b6.test.ts CHANGED
@@ -43,7 +43,7 @@ describe('T0 B0 & B6 Runtime Proof Suite', () => {
43
43
  const config = {
44
44
  name: 'NeutralCompanion',
45
45
  brain: { provider: 'openrouter' },
46
- memory: { provider: 'postgres' },
46
+ memory: { provider: 'sqlite' },
47
47
  knowledge: { provider: 'e-knowledge' },
48
48
  behavior: { provider: 'active-self' },
49
49
  voice: { provider: 'none' },
@@ -0,0 +1,103 @@
1
+ import { bootCompanion, isDisabled, createBrain, createMemory, createVoice, createKnowledge, createVision, createBehavior, createBody, createHands, createEar, createMouth, createSelf, createObservation } from './boot';
2
+ import { FixtureObservationOrgan } from '@siduri-x/observation';
3
+
4
+ describe('Canonical bootCompanion & Organ Factory Suite', () => {
5
+ const originalEnv = process.env;
6
+
7
+ beforeEach(() => {
8
+ process.env = { ...originalEnv };
9
+ });
10
+
11
+ afterAll(() => {
12
+ process.env = originalEnv;
13
+ });
14
+
15
+ test('isDisabled correctly identifies undefined or none provider', () => {
16
+ expect(isDisabled(undefined)).toBe(true);
17
+ expect(isDisabled({ provider: 'none' })).toBe(true);
18
+ expect(isDisabled({ provider: 'openrouter' })).toBe(false);
19
+ expect(isDisabled({ provider: 'sqlite' })).toBe(false);
20
+ });
21
+
22
+ test('creates all standard organs with fallback configurations', () => {
23
+ const brain = createBrain({ provider: 'openai-compatible', baseUrl: 'http://localhost:11434/v1', apiKey: 'test' });
24
+ expect(brain).toBeDefined();
25
+
26
+ const voice = createVoice({ provider: 'none' });
27
+ expect(voice).toBeUndefined();
28
+
29
+ const memory = createMemory({ provider: 'sqlite', dbPath: ':memory:' });
30
+ expect(memory).toBeDefined();
31
+
32
+ const selfRepo = createSelf({ dbPath: ':memory:' });
33
+ expect(selfRepo).toBeDefined();
34
+
35
+ const vision = createVision({ provider: 'none' });
36
+ expect(vision).toBeUndefined();
37
+
38
+ const behavior = createBehavior({ provider: 'active_self' });
39
+ expect(behavior).toBeDefined();
40
+
41
+ const body = createBody({ provider: 'none' });
42
+ expect(body).toBeUndefined();
43
+
44
+ const hands = createHands();
45
+ expect(hands).toBeDefined();
46
+
47
+ const ear = createEar();
48
+ expect(ear).toBeDefined();
49
+
50
+ const mouth = createMouth(undefined, voice);
51
+ expect(mouth).toBeDefined();
52
+
53
+ const observation = createObservation();
54
+ expect(observation).toBeInstanceOf(FixtureObservationOrgan);
55
+ });
56
+
57
+ test('bootCompanion wires all organs and runs migrations', async () => {
58
+ let migrationsRun = false;
59
+ const testConfig = {
60
+ name: 'Test Companion',
61
+ brain: { provider: 'openrouter', apiKey: 'mock-key', model: 'mock-model' },
62
+ memory: { provider: 'none' }, // will use custom mock
63
+ voice: { provider: 'none' },
64
+ vision: { provider: 'none' },
65
+ behavior: { provider: 'none' },
66
+ body: { provider: 'none' },
67
+ hands: { provider: 'none' },
68
+ ear: { provider: 'none' },
69
+ mouth: { provider: 'none' },
70
+ knowledge: { provider: 'none' },
71
+ };
72
+
73
+ const mockObservation = new FixtureObservationOrgan({ analyze: async () => JSON.stringify({ readings: [] }) });
74
+
75
+ const runtime = await bootCompanion('test-comp-1', testConfig as any, {
76
+ observationOrgan: mockObservation,
77
+ });
78
+
79
+ expect(runtime).toBeDefined();
80
+ expect(runtime.id).toBe('test-comp-1');
81
+ expect(runtime.observation).toBe(mockObservation);
82
+ expect(runtime.hands).toBeDefined();
83
+ expect(runtime.ear).toBeDefined();
84
+ expect(runtime.mouth).toBeDefined();
85
+ });
86
+
87
+ test('bootCompanion handles nested organs configuration structure', async () => {
88
+ const nestedConfig = {
89
+ name: 'Nested Companion',
90
+ organs: {
91
+ brain: { provider: 'openrouter', apiKey: 'mock' },
92
+ memory: { provider: 'none' },
93
+ knowledge: { provider: 'none' },
94
+ voice: { provider: 'none' },
95
+ },
96
+ };
97
+
98
+ const runtime = await bootCompanion('nested-comp', nestedConfig as any);
99
+ expect(runtime).toBeDefined();
100
+ expect(runtime.id).toBe('nested-comp');
101
+ expect(runtime.brain).toBeDefined();
102
+ });
103
+ });
package/src/boot.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { OpenAICompatibleBrain, OpenRouterBrain } from '@siduri-x/brain';
2
+ import { SqliteMemoryStore } from '@siduri-x/memory';
3
+ import { VoiceAdapter, VoiceConfig } from '@siduri-x/voice';
4
+ import { UnifiedKnowledgeOrgan, UnifiedKnowledgeConfig } from '@siduri-x/knowledge';
5
+ import { OpenRouterVisionAdapter, OpenRouterVisionConfig } from '@siduri-x/vision';
6
+ import { ActiveSelfCompiler, SqliteSelfRepository } from '@siduri-x/self';
7
+ import { Live2DAdapter, Live2DAdapterConfig } from '@siduri-x/body';
8
+ import { FixtureObservationOrgan } from '@siduri-x/observation';
9
+ import { DefaultHandsOrgan, DefaultHandsOrganConfig } from '@siduri-x/hands';
10
+ import { DefaultEarOrgan, EarOrganConfig } from '@siduri-x/ear';
11
+ import { DefaultMouthOrgan, DefaultMouthOrganConfig } from '@siduri-x/mouth';
12
+ import { SiduriRuntime } from './runtime';
13
+
14
+ export interface AppBrainConfig {
15
+ provider?: 'openrouter' | 'openai-compatible' | string;
16
+ model?: string;
17
+ apiKey?: string;
18
+ apiKeyEnv?: string;
19
+ baseUrl?: string;
20
+ timeoutMs?: number;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface AppBehaviorConfig {
25
+ provider?: 'active_self' | 'none' | string;
26
+ preset?: string;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ export interface AppBootCompanionConfig {
31
+ name: string;
32
+ id?: string;
33
+ brain?: AppBrainConfig;
34
+ voice?: VoiceConfig;
35
+ memory?: { provider?: string; connectionString?: string; maxConnections?: number; dbPath?: string; [key: string]: unknown };
36
+ knowledge?: UnifiedKnowledgeConfig;
37
+ vision?: OpenRouterVisionConfig;
38
+ behavior?: AppBehaviorConfig;
39
+ body?: Live2DAdapterConfig;
40
+ hands?: DefaultHandsOrganConfig;
41
+ ear?: EarOrganConfig;
42
+ mouth?: DefaultMouthOrganConfig;
43
+ self?: { dbPath?: string; [key: string]: unknown };
44
+ organs?: Record<string, any>;
45
+ [key: string]: unknown;
46
+ }
47
+
48
+ export interface BootCompanionOptions {
49
+ observationOrgan?: FixtureObservationOrgan;
50
+ }
51
+
52
+ export function isDisabled(config?: { provider?: string }): boolean {
53
+ return !config || config.provider === 'none';
54
+ }
55
+
56
+ export function createBrain(config?: AppBrainConfig) {
57
+ const provider = config?.provider || 'openrouter';
58
+ const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
59
+ const apiKey = config?.apiKey || process.env[config?.apiKeyEnv || defaultKeyEnv] || '';
60
+ if (provider === 'openai-compatible') {
61
+ return new OpenAICompatibleBrain({
62
+ apiKey,
63
+ model: config?.model || 'local-model',
64
+ baseUrl: config?.baseUrl || 'http://127.0.0.1:1234/v1',
65
+ });
66
+ }
67
+ return new OpenRouterBrain({ apiKey, model: config?.model || 'gpt-4o-mini' });
68
+ }
69
+
70
+ export function createVoice(config?: VoiceConfig) {
71
+ return isDisabled(config)
72
+ ? undefined
73
+ : new VoiceAdapter({
74
+ provider: (config?.provider as any) || 'voicevox',
75
+ baseUrl: config?.baseUrl || process.env.VOICEVOX_URL || 'http://localhost:50021',
76
+ speakerId: config?.speakerId || 1,
77
+ ...config,
78
+ });
79
+ }
80
+
81
+ export function createKnowledge(config?: UnifiedKnowledgeConfig) {
82
+ if (isDisabled(config)) return undefined;
83
+ const dbPath = (config?.dbPath as string) || process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite';
84
+ return new UnifiedKnowledgeOrgan({
85
+ ...config,
86
+ dbPath,
87
+ lifeDatabase: config?.lifeDatabase ?? true,
88
+ });
89
+ }
90
+
91
+ export function createVision(config?: OpenRouterVisionConfig & { provider?: string }) {
92
+ return isDisabled(config)
93
+ ? undefined
94
+ : new OpenRouterVisionAdapter({
95
+ apiKey: config?.apiKey || process.env.OPENROUTER_API_KEY || '',
96
+ model: config?.model || 'gpt-4-vision',
97
+ ...config,
98
+ });
99
+ }
100
+
101
+ export function createBehavior(config?: AppBehaviorConfig) {
102
+ return isDisabled(config) ? undefined : new ActiveSelfCompiler();
103
+ }
104
+
105
+ export function createBody(config?: Live2DAdapterConfig & { provider?: string }) {
106
+ return isDisabled(config)
107
+ ? undefined
108
+ : new Live2DAdapter(config);
109
+ }
110
+
111
+ export function createHands(config?: DefaultHandsOrganConfig & { provider?: string }) {
112
+ return isDisabled(config)
113
+ ? new DefaultHandsOrgan()
114
+ : new DefaultHandsOrgan(config);
115
+ }
116
+
117
+ export function createEar(config?: EarOrganConfig & { provider?: string }) {
118
+ return isDisabled(config)
119
+ ? new DefaultEarOrgan()
120
+ : new DefaultEarOrgan(config);
121
+ }
122
+
123
+ export function createMouth(config?: DefaultMouthOrganConfig & { provider?: string }, voice?: any) {
124
+ return isDisabled(config)
125
+ ? new DefaultMouthOrgan({ voice })
126
+ : new DefaultMouthOrgan({ ...config, voice });
127
+ }
128
+
129
+ export function createMemory(config?: { provider?: string; connectionString?: string; maxConnections?: number; dbPath?: string }) {
130
+ if (isDisabled(config)) return undefined;
131
+ return new SqliteMemoryStore({ dbPath: config?.dbPath || process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite' });
132
+ }
133
+
134
+ export function createSelf(config?: { dbPath?: string }) {
135
+ return new SqliteSelfRepository({ dbPath: config?.dbPath || process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite' });
136
+ }
137
+
138
+ export function createObservation(vision?: any): FixtureObservationOrgan {
139
+ return new FixtureObservationOrgan(
140
+ vision ?? { analyze: async () => JSON.stringify({ readings: [] }) }
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Canonical companion bootstrapper.
146
+ * Wires all 10 organs, runs memory migrations, and initializes the SiduriRuntime.
147
+ */
148
+ export async function bootCompanion(
149
+ id: string,
150
+ config: AppBootCompanionConfig,
151
+ options?: BootCompanionOptions
152
+ ): Promise<SiduriRuntime> {
153
+ const organs = (config as any)?.organs || {};
154
+
155
+ const brain = createBrain(organs.brain || config?.brain);
156
+ const memory = createMemory(organs.memory || (config as any)?.memory);
157
+ const selfRepo = createSelf(organs.self || (config as any)?.self);
158
+ const voice = createVoice(organs.voice || config?.voice);
159
+ const knowledge = createKnowledge(organs.knowledge || config?.knowledge);
160
+ const vision = createVision(organs.vision || config?.vision);
161
+ const behavior = createBehavior(organs.behavior || config?.behavior);
162
+ const body = createBody(organs.body || config?.body);
163
+ const hands = createHands(organs.hands || config?.hands);
164
+ const ear = createEar(organs.ear || config?.ear);
165
+ const mouth = createMouth(organs.mouth || config?.mouth, voice);
166
+ const observation = options?.observationOrgan;
167
+
168
+ if (memory && typeof (memory as any).runMigrations === 'function') {
169
+ await (memory as any).runMigrations().catch((e: any) => console.warn("Migrations warning:", e.message));
170
+ }
171
+
172
+ const runtime = new SiduriRuntime(id, config as any, {
173
+ brain,
174
+ memory,
175
+ voice,
176
+ knowledge,
177
+ vision,
178
+ behavior,
179
+ body,
180
+ hands,
181
+ ear,
182
+ mouth,
183
+ observation,
184
+ self: selfRepo,
185
+ externalKnowledge: (knowledge as any)?.eAdapter ?? knowledge,
186
+ });
187
+
188
+ await runtime.initialize();
189
+ return runtime;
190
+ }
@@ -150,4 +150,131 @@ describe('API Request Context Mapper (Single-Owner, Single-Machine)', () => {
150
150
  expect(result.accepted).toBe(true);
151
151
  expect(result.context?.conversation.correlationId).toMatch(/^corr-/);
152
152
  });
153
+
154
+ test('Overrides forged client actor authentication and caps capabilities on unauthenticated requests', () => {
155
+ const forgedInput = {
156
+ companionId: 'companion-a',
157
+ authenticated: false, // Server detected unauthenticated request
158
+ source: 'external',
159
+ context: {
160
+ actor: {
161
+ actorId: 'attacker',
162
+ sessionId: 'session-att',
163
+ authenticated: true, // Forged
164
+ capabilities: ['system:exec', 'bash', 'chat'], // Forged elevated capabilities
165
+ authorizationRole: 'OWNER', // Forged role
166
+ },
167
+ conversation: {
168
+ correlationId: 'corr-forged-1',
169
+ },
170
+ },
171
+ };
172
+
173
+ const result = mapRequestContext(forgedInput);
174
+ expect(result.accepted).toBe(true);
175
+ expect(result.context?.actor.authenticated).toBe(false);
176
+ expect(result.context?.actor.capabilities).toEqual(['chat']);
177
+ expect(result.context?.actor.authorizationRole).toBe('viewer');
178
+ expect(result.context?.source).toBe('external');
179
+ expect(result.diagnostics).toContain('role_escalation_attempt_suppressed');
180
+ expect(result.diagnostics).toContain('capability_escalation_attempt_suppressed');
181
+ });
182
+
183
+ test('Prevents attenuated caller from escalating to administrator role or forging elevated capabilities', () => {
184
+ const attenuatedPayload = {
185
+ companionId: 'companion-a',
186
+ authenticated: true,
187
+ role: 'VIEWER',
188
+ source: 'external',
189
+ context: {
190
+ actor: {
191
+ actorId: 'visitor-bob',
192
+ sessionId: 'session-vis-1',
193
+ authorizationRole: 'administrator', // Forged owner/admin role
194
+ capabilities: ['chat', 'system', 'root:manage', 'action:execute'], // Forged system/root capabilities
195
+ },
196
+ conversation: {
197
+ correlationId: 'corr-att-escalate',
198
+ },
199
+ },
200
+ };
201
+
202
+ const result = mapRequestContext(attenuatedPayload);
203
+ expect(result.accepted).toBe(true);
204
+ expect(result.context?.actor.authenticated).toBe(true);
205
+ expect(result.context?.actor.authorizationRole).toBe('viewer');
206
+ expect(result.context?.actor.capabilities).toEqual(['chat']);
207
+ expect(result.diagnostics).toContain('role_escalation_attempt_suppressed');
208
+ expect(result.diagnostics).toContain('capability_escalation_attempt_suppressed');
209
+ });
210
+
211
+ test('Strips unknown capabilities for authenticated owner while preserving canonical companion capabilities', () => {
212
+ const ownerPayload = {
213
+ companionId: 'companion-a',
214
+ authenticated: true,
215
+ source: 'local',
216
+ context: {
217
+ actor: {
218
+ actorId: 'owner-user',
219
+ sessionId: 'session-owner-1',
220
+ capabilities: ['chat', 'system', 'root:unauthorized', 'arbitrary:hack'],
221
+ },
222
+ conversation: {
223
+ correlationId: 'corr-owner-caps',
224
+ },
225
+ },
226
+ };
227
+
228
+ const result = mapRequestContext(ownerPayload);
229
+ expect(result.accepted).toBe(true);
230
+ expect(result.context?.actor.authorizationRole).toBe('administrator');
231
+ expect(result.context?.actor.capabilities).toEqual(['chat', 'system']);
232
+ expect(result.diagnostics).toContain('capability_escalation_attempt_suppressed');
233
+ });
234
+
235
+ test('Flat envelope: suppresses role and capability escalation when serverRole is VIEWER', () => {
236
+ const viewerPayload = {
237
+ companionId: 'companion-a',
238
+ authenticated: false,
239
+ serverRole: 'VIEWER',
240
+ role: 'OWNER', // Forged in flat envelope
241
+ capabilities: ['chat', 'system', 'action:execute'],
242
+ generateCorrelationId: true,
243
+ };
244
+
245
+ const result = mapRequestContext(viewerPayload);
246
+ expect(result.accepted).toBe(true);
247
+ expect(result.context?.actor.authorizationRole).toBe('viewer');
248
+ expect(result.context?.actor.capabilities).toEqual(['chat']);
249
+ expect(result.diagnostics).toContain('role_escalation_attempt_suppressed');
250
+ });
251
+
252
+ test('Authenticated owner receives canonical administrator role and capabilities, and can safely attenuate to viewer', () => {
253
+ const ownerPayload = {
254
+ companionId: 'companion-a',
255
+ authenticated: true,
256
+ serverRole: 'OWNER',
257
+ generateCorrelationId: true,
258
+ };
259
+
260
+ const ownerResult = mapRequestContext(ownerPayload);
261
+ expect(ownerResult.accepted).toBe(true);
262
+ expect(ownerResult.context?.actor.authorizationRole).toBe('administrator');
263
+ expect(ownerResult.context?.actor.capabilities).toEqual([
264
+ 'chat',
265
+ 'memory:approve',
266
+ 'action:execute',
267
+ 'system',
268
+ ]);
269
+
270
+ // Attenuation to viewer
271
+ const attenuatedPayload = {
272
+ ...ownerPayload,
273
+ role: 'VIEWER',
274
+ };
275
+ const attenuatedResult = mapRequestContext(attenuatedPayload);
276
+ expect(attenuatedResult.accepted).toBe(true);
277
+ expect(attenuatedResult.context?.actor.authorizationRole).toBe('viewer');
278
+ expect(attenuatedResult.diagnostics).not.toContain('role_escalation_attempt_suppressed');
279
+ });
153
280
  });
@@ -20,8 +20,10 @@ export interface MapRequestContextResult {
20
20
  /**
21
21
  * Maps incoming HTTP requests to a canonical RequestContext.
22
22
  * In a single-owner, single-machine model:
23
- * - Security is enforced at the external boundary, not internally between roles.
23
+ * - Security is enforced at the external machine boundary, NOT internally between roles.
24
24
  * - No internal audience or viewer/operator/owner role hierarchies.
25
+ * - Authenticated callers are the single owner with full companion access.
26
+ * - Unauthenticated callers are bounded to public chat.
25
27
  */
26
28
  export function mapRequestContext(
27
29
  input: any,
@@ -43,7 +45,10 @@ export function mapRequestContext(
43
45
  if (input.context && typeof input.context === 'object') {
44
46
  const rawCtx = input.context;
45
47
  const companionId = input.companionId || rawCtx.companionId || input.id;
46
- const correlationId = rawCtx.conversation?.correlationId || input.correlationId;
48
+ const correlationId =
49
+ rawCtx.conversation?.correlationId ||
50
+ input.correlationId ||
51
+ (input.generateCorrelationId ? `corr-${Date.now()}` : undefined);
47
52
 
48
53
  if (!companionId) {
49
54
  return {
@@ -81,15 +86,56 @@ export function mapRequestContext(
81
86
  };
82
87
  }
83
88
 
89
+ const isViewerRequested =
90
+ (typeof input.role === 'string' && input.role.toLowerCase() === 'viewer') ||
91
+ (typeof input.serverRole === 'string' && input.serverRole.toLowerCase() === 'viewer') ||
92
+ (typeof actor.authorizationRole === 'string' && actor.authorizationRole.toLowerCase() === 'viewer') ||
93
+ (typeof (actor as any).role === 'string' && (actor as any).role.toLowerCase() === 'viewer');
94
+
95
+ // Determine server-enforced authentication status at the machine boundary
96
+ const isAuthenticated = input.authenticated !== undefined
97
+ ? Boolean(input.authenticated)
98
+ : (actor.authenticated !== undefined ? Boolean(actor.authenticated) : true);
99
+
100
+ const isViewer = !isAuthenticated || isViewerRequested;
101
+
102
+ const CANONICAL_CAPABILITIES = new Set(['chat', 'memory:approve', 'action:execute', 'system']);
103
+ const DEFAULT_OWNER_CAPABILITIES = ['chat', 'memory:approve', 'action:execute', 'system'];
104
+
105
+ let safeCapabilities: string[];
106
+ let authorizationRole: string;
107
+
108
+ if (isViewer) {
109
+ safeCapabilities = ['chat'];
110
+ authorizationRole = 'viewer';
111
+ if (Array.isArray(actor.capabilities) && actor.capabilities.some((c: string) => c !== 'chat' && c !== 'chat:public')) {
112
+ diagnostics.push('capability_escalation_attempt_suppressed');
113
+ }
114
+ if (actor.authorizationRole && actor.authorizationRole.toLowerCase() !== 'viewer') {
115
+ diagnostics.push('role_escalation_attempt_suppressed');
116
+ }
117
+ } else {
118
+ authorizationRole = 'administrator';
119
+ if (Array.isArray(actor.capabilities) && actor.capabilities.length > 0) {
120
+ const filtered = actor.capabilities.filter((c: string) => CANONICAL_CAPABILITIES.has(c));
121
+ if (filtered.length < actor.capabilities.length) {
122
+ diagnostics.push('capability_escalation_attempt_suppressed');
123
+ }
124
+ safeCapabilities = filtered.length > 0 ? filtered : [...DEFAULT_OWNER_CAPABILITIES];
125
+ } else {
126
+ safeCapabilities = [...DEFAULT_OWNER_CAPABILITIES];
127
+ }
128
+ }
129
+
84
130
  const constructed: RequestContext = {
85
131
  companionId,
86
132
  actor: {
133
+ ...actor,
87
134
  actorId: actor.actorId,
88
135
  sessionId: actor.sessionId,
89
- capabilities: Array.isArray(actor.capabilities) ? actor.capabilities : ['chat'],
90
- authenticated: actor.authenticated !== undefined ? Boolean(actor.authenticated) : true,
91
- authorizationRole: actor.authorizationRole,
92
- ...actor,
136
+ capabilities: safeCapabilities,
137
+ authenticated: isAuthenticated,
138
+ authorizationRole,
93
139
  },
94
140
  conversation: {
95
141
  correlationId,
@@ -97,7 +143,7 @@ export function mapRequestContext(
97
143
  isLive: rawCtx.conversation?.isLive,
98
144
  ...rawCtx.conversation,
99
145
  },
100
- source: input.source || rawCtx.source || 'local',
146
+ source: input.source || rawCtx.source || (isAuthenticated ? 'local' : 'external'),
101
147
  subject: rawCtx.subject,
102
148
  };
103
149
 
@@ -154,17 +200,52 @@ export function mapRequestContext(
154
200
  };
155
201
  }
156
202
 
157
- const actorId = input.actorId || input.actor?.actorId || 'local-user';
203
+ const isViewerRequested =
204
+ (typeof input.role === 'string' && input.role.toLowerCase() === 'viewer') ||
205
+ (typeof input.serverRole === 'string' && input.serverRole.toLowerCase() === 'viewer');
206
+
207
+ const isAuthenticated = input.authenticated !== undefined ? Boolean(input.authenticated) : true;
208
+ const isViewer = !isAuthenticated || isViewerRequested;
209
+
210
+ const actorId = input.actorId || input.actor?.actorId || (isAuthenticated && !isViewer ? 'local-user' : 'anonymous-session');
158
211
  const sessionId = input.sessionId || input.actor?.sessionId || `session-${Date.now()}`;
159
212
  if (!input.actorId && !input.actor?.actorId) {
160
213
  diagnostics.push('anonymous_session_generated');
161
214
  }
162
215
 
163
- const capabilities = Array.isArray(input.capabilities)
216
+ const CANONICAL_CAPABILITIES = new Set(['chat', 'memory:approve', 'action:execute', 'system']);
217
+ const DEFAULT_OWNER_CAPABILITIES = ['chat', 'memory:approve', 'action:execute', 'system'];
218
+
219
+ const rawCaps = Array.isArray(input.capabilities)
164
220
  ? input.capabilities
165
221
  : Array.isArray(input.actor?.capabilities)
166
222
  ? input.actor.capabilities
167
- : ['chat', 'system'];
223
+ : undefined;
224
+
225
+ let capabilities: string[];
226
+ let authorizationRole: string;
227
+
228
+ if (isViewer) {
229
+ capabilities = ['chat'];
230
+ authorizationRole = 'viewer';
231
+ if (rawCaps && rawCaps.some((c: string) => c !== 'chat' && c !== 'chat:public')) {
232
+ diagnostics.push('capability_escalation_attempt_suppressed');
233
+ }
234
+ if (input.role && input.role.toLowerCase() !== 'viewer') {
235
+ diagnostics.push('role_escalation_attempt_suppressed');
236
+ }
237
+ } else {
238
+ authorizationRole = 'administrator';
239
+ if (rawCaps && rawCaps.length > 0) {
240
+ const filtered = rawCaps.filter((c: string) => CANONICAL_CAPABILITIES.has(c));
241
+ if (filtered.length < rawCaps.length) {
242
+ diagnostics.push('capability_escalation_attempt_suppressed');
243
+ }
244
+ capabilities = filtered.length > 0 ? filtered : [...DEFAULT_OWNER_CAPABILITIES];
245
+ } else {
246
+ capabilities = [...DEFAULT_OWNER_CAPABILITIES];
247
+ }
248
+ }
168
249
 
169
250
  const mappedContext: RequestContext = {
170
251
  companionId,
@@ -172,14 +253,14 @@ export function mapRequestContext(
172
253
  actorId,
173
254
  sessionId,
174
255
  capabilities,
175
- authenticated: input.authenticated !== undefined ? Boolean(input.authenticated) : true,
176
- authorizationRole: input.role ? (input.role.toLowerCase() === 'viewer' ? 'viewer' : 'administrator') : undefined,
256
+ authenticated: isAuthenticated,
257
+ authorizationRole,
177
258
  },
178
259
  conversation: {
179
260
  channel: input.channel || input.conversation?.channel || 'direct',
180
261
  correlationId,
181
262
  },
182
- source: input.source || 'local',
263
+ source: input.source || (isAuthenticated ? 'local' : 'external'),
183
264
  subject: input.subject,
184
265
  };
185
266
 
package/src/index.test.ts CHANGED
@@ -127,7 +127,7 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
127
127
  });
128
128
 
129
129
  test('handles barge-in interruption via POST /chat/interrupt', async () => {
130
- fakeRuntime.interruptMouth = jest.fn();
130
+ fakeRuntime.mouth = { interrupt: jest.fn() };
131
131
 
132
132
  const res = await request(app)
133
133
  .post('/chat/interrupt')
@@ -138,11 +138,11 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
138
138
 
139
139
  expect(res.status).toBe(200);
140
140
  expect(res.body.interrupted).toBe(true);
141
- expect(fakeRuntime.interruptMouth).toHaveBeenCalledWith('user_stop');
141
+ expect(fakeRuntime.mouth.interrupt).toHaveBeenCalledWith('user_stop');
142
142
  });
143
143
 
144
144
  test('handles mouth interruption via POST /mouth/interrupt', async () => {
145
- fakeRuntime.interruptMouth = jest.fn();
145
+ fakeRuntime.mouth = { interrupt: jest.fn() };
146
146
 
147
147
  const res = await request(app)
148
148
  .post('/mouth/interrupt')
@@ -153,6 +153,6 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
153
153
 
154
154
  expect(res.status).toBe(200);
155
155
  expect(res.body.interrupted).toBe(true);
156
- expect(fakeRuntime.interruptMouth).toHaveBeenCalledWith('user_barge_in');
156
+ expect(fakeRuntime.mouth.interrupt).toHaveBeenCalledWith('user_barge_in');
157
157
  });
158
158
  });