@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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +43 -0
- package/dist/app.d.ts +2 -34
- package/dist/app.js +208 -127
- package/dist/b0-b6.test.js +1 -1
- package/dist/boot.d.ts +92 -0
- package/dist/boot.js +146 -0
- package/dist/boot.test.d.ts +1 -0
- package/dist/boot.test.js +84 -0
- package/dist/context-mapper.d.ts +3 -1
- package/dist/context-mapper.js +84 -13
- package/dist/context-mapper.test.js +116 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +13 -88
- package/dist/index.test.js +4 -4
- package/dist/knowledge.test.d.ts +1 -0
- package/dist/knowledge.test.js +138 -0
- package/dist/t4-gating.test.js +1 -1
- package/dist/t5-experience.test.js +1 -1
- package/dist/t6-security.test.js +115 -3
- package/dist/t7-release.test.js +1 -1
- package/dist/teach-mode.test.d.ts +1 -0
- package/dist/teach-mode.test.js +136 -0
- package/package.json +27 -23
- package/src/app.ts +223 -170
- package/src/b0-b6.test.ts +1 -1
- package/src/boot.test.ts +103 -0
- package/src/boot.ts +190 -0
- package/src/context-mapper.test.ts +127 -0
- package/src/context-mapper.ts +94 -13
- package/src/index.test.ts +4 -4
- package/src/index.ts +14 -99
- package/src/knowledge.test.ts +156 -0
- package/src/t4-gating.test.ts +1 -1
- package/src/t5-experience.test.ts +1 -1
- package/src/t6-security.test.ts +126 -2
- package/src/t7-release.test.ts +1 -1
- package/src/teach-mode.test.ts +152 -0
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: '
|
|
46
|
+
memory: { provider: 'sqlite' },
|
|
47
47
|
knowledge: { provider: 'e-knowledge' },
|
|
48
48
|
behavior: { provider: 'active-self' },
|
|
49
49
|
voice: { provider: 'none' },
|
package/src/boot.test.ts
ADDED
|
@@ -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
|
});
|
package/src/context-mapper.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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:
|
|
90
|
-
authenticated:
|
|
91
|
-
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
|
|
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
|
|
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
|
-
:
|
|
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:
|
|
176
|
-
authorizationRole
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
156
|
+
expect(fakeRuntime.mouth.interrupt).toHaveBeenCalledWith('user_barge_in');
|
|
157
157
|
});
|
|
158
158
|
});
|