@siduri-x/core 1.0.1 → 1.0.3
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/dist/action-policy.d.ts +1 -1
- package/dist/action-policy.js +12 -2
- package/dist/action.d.ts +2 -0
- package/dist/adversarial.test.d.ts +1 -0
- package/dist/adversarial.test.js +493 -0
- package/dist/architecture-boundary.test.js +2 -2
- package/dist/capability.d.ts +5 -0
- package/dist/capability.js +31 -3
- package/dist/capability.test.js +93 -4
- package/dist/chat-contract.d.ts +78 -0
- package/dist/chat-contract.js +63 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1 -0
- package/dist/runtime.js +8 -0
- package/dist/teaching.js +4 -4
- package/package.json +2 -2
package/dist/action-policy.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare class ActionPolicyEngine {
|
|
|
38
38
|
decision: ActionPolicyDecision;
|
|
39
39
|
capability?: AuthorizationCapability;
|
|
40
40
|
}>;
|
|
41
|
-
approveAction(options: ApproveActionOptions): boolean
|
|
41
|
+
approveAction(options: ApproveActionOptions): Promise<boolean>;
|
|
42
42
|
recordAudit(action: ActionIntent, context: RequestContext | undefined, decision: ActionPolicyDecision | undefined, lifecycle: ActionLifecycleState, result?: unknown, error?: string, durationMs?: number): Promise<ActionAuditEvent>;
|
|
43
43
|
getAuditLog(): Promise<ActionAuditEvent[]>;
|
|
44
44
|
getStore(): ActionStore;
|
package/dist/action-policy.js
CHANGED
|
@@ -123,7 +123,14 @@ class ActionPolicyEngine {
|
|
|
123
123
|
// Risk level approval check
|
|
124
124
|
const requiresExplicitApproval = toolDef.requiresApproval ??
|
|
125
125
|
(this.defaultRequireApprovalForHighRisk && (riskLevel === 'HIGH' || riskLevel === 'CRITICAL'));
|
|
126
|
-
|
|
126
|
+
let isApproved = this.approvedExecutions.has(executionId);
|
|
127
|
+
if (!isApproved && typeof this.store.isActionApproved === 'function') {
|
|
128
|
+
isApproved = await this.store.isActionApproved(executionId);
|
|
129
|
+
if (isApproved) {
|
|
130
|
+
this.approvedExecutions.add(executionId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (requiresExplicitApproval && !isApproved) {
|
|
127
134
|
const decision = {
|
|
128
135
|
allowed: false,
|
|
129
136
|
reason: `Action "${action.toolName}" has risk level ${riskLevel} and requires explicit approval`,
|
|
@@ -171,8 +178,11 @@ class ActionPolicyEngine {
|
|
|
171
178
|
await this.recordAudit(action, effectiveContext, decision, 'APPROVED');
|
|
172
179
|
return { decision, capability };
|
|
173
180
|
}
|
|
174
|
-
approveAction(options) {
|
|
181
|
+
async approveAction(options) {
|
|
175
182
|
this.approvedExecutions.add(options.executionId);
|
|
183
|
+
if (typeof this.store.saveApproval === 'function') {
|
|
184
|
+
await this.store.saveApproval(options.executionId, options.approverActorId, options.reason);
|
|
185
|
+
}
|
|
176
186
|
return true;
|
|
177
187
|
}
|
|
178
188
|
async recordAudit(action, context, decision, lifecycle, result, error, durationMs) {
|
package/dist/action.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_1 = require("./index");
|
|
4
|
+
describe('Adversarial Hardening Verification Suite (Phase 3)', () => {
|
|
5
|
+
const secretKey = 'test_policy_secret_key_123';
|
|
6
|
+
const baseOwnerContext = {
|
|
7
|
+
companionId: 'companion-adv',
|
|
8
|
+
actor: {
|
|
9
|
+
actorId: 'owner-user',
|
|
10
|
+
sessionId: 'sess-owner',
|
|
11
|
+
authorizationRole: 'administrator',
|
|
12
|
+
capabilities: ['chat:public', 'chat:private', 'system:manage', 'tools:all'],
|
|
13
|
+
authenticated: true,
|
|
14
|
+
},
|
|
15
|
+
conversation: {
|
|
16
|
+
channel: 'private',
|
|
17
|
+
audienceId: 'audience-owner',
|
|
18
|
+
correlationId: 'corr-adv-1',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const baseViewerContext = {
|
|
22
|
+
companionId: 'companion-adv',
|
|
23
|
+
actor: {
|
|
24
|
+
actorId: 'anonymous-viewer',
|
|
25
|
+
sessionId: 'sess-viewer',
|
|
26
|
+
authorizationRole: 'viewer',
|
|
27
|
+
capabilities: ['chat:public'],
|
|
28
|
+
authenticated: false,
|
|
29
|
+
},
|
|
30
|
+
conversation: {
|
|
31
|
+
channel: 'public',
|
|
32
|
+
audienceId: 'audience-public',
|
|
33
|
+
correlationId: 'corr-adv-2',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
// INVARIANT 1: Memory Truth & Scoping Boundaries
|
|
37
|
+
describe('Invariant 1: Memory Truth & Cognition Filtering', () => {
|
|
38
|
+
test('expired, future, and below-threshold claims are not injected into Brain contextPrompt', async () => {
|
|
39
|
+
const mockBrain = {
|
|
40
|
+
generatePlan: jest.fn().mockResolvedValue({ speech: 'Cognition received context', language: 'en' }),
|
|
41
|
+
};
|
|
42
|
+
const now = new Date();
|
|
43
|
+
const pastTime = new Date(now.getTime() - 100_000).toISOString();
|
|
44
|
+
const futureTime = new Date(now.getTime() + 100_000).toISOString();
|
|
45
|
+
// Memory mock simulating search output containing valid claim only after organ filtering
|
|
46
|
+
const validClaim = {
|
|
47
|
+
id: 'c-valid',
|
|
48
|
+
companionId: 'companion-adv',
|
|
49
|
+
subject: 'User',
|
|
50
|
+
predicate: 'favoriteColor',
|
|
51
|
+
value: 'Azure',
|
|
52
|
+
status: 'APPROVED',
|
|
53
|
+
scope: 'OWNER',
|
|
54
|
+
confidence: 0.95,
|
|
55
|
+
validFrom: pastTime,
|
|
56
|
+
validUntil: futureTime,
|
|
57
|
+
};
|
|
58
|
+
const mockMemory = {
|
|
59
|
+
initialize: jest.fn().mockResolvedValue(undefined),
|
|
60
|
+
searchClaims: jest.fn().mockResolvedValue([validClaim]),
|
|
61
|
+
getDirectives: jest.fn().mockResolvedValue([]),
|
|
62
|
+
};
|
|
63
|
+
const runtime = new index_1.SiduriRuntime('companion-adv', { name: 'AdvCompanion' }, {
|
|
64
|
+
brain: mockBrain,
|
|
65
|
+
memory: mockMemory,
|
|
66
|
+
});
|
|
67
|
+
await runtime.handleUserMessage('What is my favorite color?', baseOwnerContext);
|
|
68
|
+
expect(mockMemory.searchClaims).toHaveBeenCalled();
|
|
69
|
+
const brainCall = mockBrain.generatePlan.mock.calls[0][0];
|
|
70
|
+
expect(brainCall.contextPrompt).toContain('User favoriteColor Azure');
|
|
71
|
+
});
|
|
72
|
+
test('companion isolation: runtime passes only companionId matching context', async () => {
|
|
73
|
+
const mockBrain = {
|
|
74
|
+
generatePlan: jest.fn().mockResolvedValue({ speech: 'OK', language: 'en' }),
|
|
75
|
+
};
|
|
76
|
+
const mockMemory = {
|
|
77
|
+
initialize: jest.fn().mockResolvedValue(undefined),
|
|
78
|
+
searchClaims: jest.fn().mockResolvedValue([]),
|
|
79
|
+
getDirectives: jest.fn().mockResolvedValue([]),
|
|
80
|
+
};
|
|
81
|
+
const runtime = new index_1.SiduriRuntime('companion-A', { name: 'AdvA' }, {
|
|
82
|
+
brain: mockBrain,
|
|
83
|
+
memory: mockMemory,
|
|
84
|
+
});
|
|
85
|
+
await runtime.handleUserMessage('Query', {
|
|
86
|
+
...baseOwnerContext,
|
|
87
|
+
companionId: 'companion-A',
|
|
88
|
+
});
|
|
89
|
+
expect(mockMemory.searchClaims).toHaveBeenCalledWith('Query', expect.objectContaining({ channel: 'private', audienceId: 'audience-owner' }), 5);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// INVARIANT 2: Authority & Request Boundary
|
|
93
|
+
describe('Invariant 2: Authority & Request Context Boundary', () => {
|
|
94
|
+
test('viewer cannot execute admin action intents even if request context is maliciously populated', async () => {
|
|
95
|
+
const store = new index_1.InMemoryActionStore();
|
|
96
|
+
const policyEngine = new index_1.ActionPolicyEngine({ store, secretKey });
|
|
97
|
+
policyEngine.registerToolDefinition({
|
|
98
|
+
name: 'database/drop_tables',
|
|
99
|
+
providerId: 'db',
|
|
100
|
+
description: 'Drop DB tables',
|
|
101
|
+
inputSchema: {},
|
|
102
|
+
riskLevel: 'CRITICAL',
|
|
103
|
+
allowedRoles: ['administrator'],
|
|
104
|
+
requiredCapabilities: ['system:manage'],
|
|
105
|
+
});
|
|
106
|
+
const intent = {
|
|
107
|
+
actionId: 'act-drop-1',
|
|
108
|
+
toolName: 'db/database/drop_tables',
|
|
109
|
+
parameters: {},
|
|
110
|
+
context: baseViewerContext, // Viewer role
|
|
111
|
+
};
|
|
112
|
+
const { decision, capability } = await policyEngine.evaluateAction(intent);
|
|
113
|
+
expect(decision.allowed).toBe(false);
|
|
114
|
+
expect(decision.decisionCode).toBe('REJECTED_UNAUTHORIZED');
|
|
115
|
+
expect(capability).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
test('missing request context strictly blocks authorization', async () => {
|
|
118
|
+
const store = new index_1.InMemoryActionStore();
|
|
119
|
+
const policyEngine = new index_1.ActionPolicyEngine({ store, secretKey });
|
|
120
|
+
policyEngine.registerToolDefinition({
|
|
121
|
+
name: 'test_tool',
|
|
122
|
+
providerId: 'sys',
|
|
123
|
+
description: 'Test Tool',
|
|
124
|
+
inputSchema: {},
|
|
125
|
+
riskLevel: 'LOW',
|
|
126
|
+
});
|
|
127
|
+
const intent = {
|
|
128
|
+
actionId: 'act-no-ctx',
|
|
129
|
+
toolName: 'sys/test_tool',
|
|
130
|
+
parameters: {},
|
|
131
|
+
};
|
|
132
|
+
const { decision, capability } = await policyEngine.evaluateAction(intent, undefined);
|
|
133
|
+
expect(decision.allowed).toBe(false);
|
|
134
|
+
expect(decision.decisionCode).toBe('REJECTED_UNAUTHORIZED');
|
|
135
|
+
expect(capability).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
// INVARIANT 4: Action Replay & Concurrency Protection
|
|
139
|
+
describe('Invariant 4: Action Idempotency, Concurrency & Signature Replay', () => {
|
|
140
|
+
let store;
|
|
141
|
+
let mockHandlerExecute;
|
|
142
|
+
class TestHandsOrgan {
|
|
143
|
+
store;
|
|
144
|
+
secretKey;
|
|
145
|
+
constructor(store, secretKey) {
|
|
146
|
+
this.store = store;
|
|
147
|
+
this.secretKey = secretKey;
|
|
148
|
+
}
|
|
149
|
+
async listTools() {
|
|
150
|
+
return [{
|
|
151
|
+
name: 'transfer_funds',
|
|
152
|
+
providerId: 'bank',
|
|
153
|
+
description: 'Transfer money',
|
|
154
|
+
inputSchema: {},
|
|
155
|
+
riskLevel: 'CRITICAL',
|
|
156
|
+
}];
|
|
157
|
+
}
|
|
158
|
+
async executeAction(action, authorization) {
|
|
159
|
+
const actionId = action?.actionId || 'unknown';
|
|
160
|
+
const toolName = action?.toolName || 'unknown';
|
|
161
|
+
if (!authorization || authorization.allowed !== true) {
|
|
162
|
+
return { actionId, executionId: 'unauthorized', toolName, lifecycle: 'REJECTED', success: false, error: 'Unauthorized' };
|
|
163
|
+
}
|
|
164
|
+
if (!(0, index_1.verifyCapabilitySignature)(authorization, this.secretKey)) {
|
|
165
|
+
return { actionId, executionId: authorization.executionId, toolName, lifecycle: 'REJECTED', success: false, error: 'Invalid or forged AuthorizationCapability signature' };
|
|
166
|
+
}
|
|
167
|
+
if (authorization.expiresAt && new Date(authorization.expiresAt).getTime() <= Date.now()) {
|
|
168
|
+
return { actionId, executionId: authorization.executionId, toolName, lifecycle: 'REJECTED', success: false, error: 'AuthorizationCapability has expired' };
|
|
169
|
+
}
|
|
170
|
+
const currentParamsHash = (0, index_1.computeParametersHash)(action.parameters);
|
|
171
|
+
if (authorization.parametersHash !== currentParamsHash) {
|
|
172
|
+
return { actionId, executionId: authorization.executionId, toolName, lifecycle: 'REJECTED', success: false, error: 'Parameters hash mismatch' };
|
|
173
|
+
}
|
|
174
|
+
const executionId = authorization.executionId;
|
|
175
|
+
const existing = await this.store.getExecution(executionId);
|
|
176
|
+
if (existing && existing.lifecycle === 'COMPLETED') {
|
|
177
|
+
return { actionId, executionId, toolName, lifecycle: 'COMPLETED', success: true, result: existing.result };
|
|
178
|
+
}
|
|
179
|
+
const reserved = await this.store.reserveExecution({
|
|
180
|
+
executionId,
|
|
181
|
+
actionId,
|
|
182
|
+
toolName,
|
|
183
|
+
providerId: authorization.providerId,
|
|
184
|
+
parametersHash: currentParamsHash,
|
|
185
|
+
lifecycle: 'EXECUTING',
|
|
186
|
+
createdAt: new Date().toISOString(),
|
|
187
|
+
updatedAt: new Date().toISOString(),
|
|
188
|
+
});
|
|
189
|
+
if (!reserved) {
|
|
190
|
+
return { actionId, executionId, toolName, lifecycle: 'FAILED', success: false, error: 'Reservation conflict' };
|
|
191
|
+
}
|
|
192
|
+
const result = await mockHandlerExecute(action.parameters);
|
|
193
|
+
await this.store.updateExecution({
|
|
194
|
+
executionId,
|
|
195
|
+
actionId,
|
|
196
|
+
toolName,
|
|
197
|
+
providerId: authorization.providerId,
|
|
198
|
+
parametersHash: currentParamsHash,
|
|
199
|
+
lifecycle: 'COMPLETED',
|
|
200
|
+
result,
|
|
201
|
+
createdAt: new Date().toISOString(),
|
|
202
|
+
updatedAt: new Date().toISOString(),
|
|
203
|
+
});
|
|
204
|
+
return { actionId, executionId, toolName, lifecycle: 'COMPLETED', success: true, result };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
let hands;
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
store = new index_1.InMemoryActionStore();
|
|
210
|
+
mockHandlerExecute = jest.fn().mockResolvedValue({ transactionId: 'tx-12345', status: 'CONFIRMED' });
|
|
211
|
+
hands = new TestHandsOrgan(store, secretKey);
|
|
212
|
+
});
|
|
213
|
+
test('replaying a completed capability returns cached result without re-executing handler', async () => {
|
|
214
|
+
const mockExecute = mockHandlerExecute;
|
|
215
|
+
const action = {
|
|
216
|
+
actionId: 'act-tx-1',
|
|
217
|
+
executionId: 'exec-tx-1',
|
|
218
|
+
toolName: 'bank/transfer_funds',
|
|
219
|
+
parameters: { amount: 100, to: 'Alice' },
|
|
220
|
+
};
|
|
221
|
+
const paramsHash = (0, index_1.computeParametersHash)(action.parameters);
|
|
222
|
+
const capabilityPayload = {
|
|
223
|
+
executionId: 'exec-tx-1',
|
|
224
|
+
actionId: 'act-tx-1',
|
|
225
|
+
toolName: 'bank/transfer_funds',
|
|
226
|
+
providerId: 'bank',
|
|
227
|
+
parametersHash: paramsHash,
|
|
228
|
+
companionId: 'companion-adv',
|
|
229
|
+
actorId: 'owner-user',
|
|
230
|
+
sessionId: 'sess-owner',
|
|
231
|
+
channel: 'private',
|
|
232
|
+
correlationId: 'corr-adv-1',
|
|
233
|
+
riskLevel: 'CRITICAL',
|
|
234
|
+
issuedAt: new Date().toISOString(),
|
|
235
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
236
|
+
};
|
|
237
|
+
const signature = (0, index_1.signCapabilityPayload)(capabilityPayload, secretKey);
|
|
238
|
+
const capability = {
|
|
239
|
+
...capabilityPayload,
|
|
240
|
+
allowed: true,
|
|
241
|
+
signature,
|
|
242
|
+
};
|
|
243
|
+
// 1. First execution succeeds
|
|
244
|
+
const res1 = await hands.executeAction(action, capability);
|
|
245
|
+
expect(res1.success).toBe(true);
|
|
246
|
+
expect(res1.lifecycle).toBe('COMPLETED');
|
|
247
|
+
expect(mockExecute).toHaveBeenCalledTimes(1);
|
|
248
|
+
// 2. Replay execution with identical capability
|
|
249
|
+
const res2 = await hands.executeAction(action, capability);
|
|
250
|
+
expect(res2.success).toBe(true);
|
|
251
|
+
expect(res2.lifecycle).toBe('COMPLETED');
|
|
252
|
+
expect(res2.result).toEqual({ transactionId: 'tx-12345', status: 'CONFIRMED' });
|
|
253
|
+
// Handler was NOT called a second time (replay defended)
|
|
254
|
+
expect(mockExecute).toHaveBeenCalledTimes(1);
|
|
255
|
+
});
|
|
256
|
+
test('tampering with action parameters invalidates cryptographic capability', async () => {
|
|
257
|
+
const action = {
|
|
258
|
+
actionId: 'act-tx-2',
|
|
259
|
+
executionId: 'exec-tx-2',
|
|
260
|
+
toolName: 'bank/transfer_funds',
|
|
261
|
+
parameters: { amount: 100, to: 'Alice' },
|
|
262
|
+
};
|
|
263
|
+
const paramsHash = (0, index_1.computeParametersHash)(action.parameters);
|
|
264
|
+
const capabilityPayload = {
|
|
265
|
+
executionId: 'exec-tx-2',
|
|
266
|
+
actionId: 'act-tx-2',
|
|
267
|
+
toolName: 'bank/transfer_funds',
|
|
268
|
+
providerId: 'bank',
|
|
269
|
+
parametersHash: paramsHash,
|
|
270
|
+
companionId: 'companion-adv',
|
|
271
|
+
actorId: 'owner-user',
|
|
272
|
+
sessionId: 'sess-owner',
|
|
273
|
+
channel: 'private',
|
|
274
|
+
correlationId: 'corr-adv-1',
|
|
275
|
+
riskLevel: 'CRITICAL',
|
|
276
|
+
issuedAt: new Date().toISOString(),
|
|
277
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
278
|
+
};
|
|
279
|
+
const signature = (0, index_1.signCapabilityPayload)(capabilityPayload, secretKey);
|
|
280
|
+
const capability = { ...capabilityPayload, allowed: true, signature };
|
|
281
|
+
// Attacker tampers with parameters from $100 to $10,000
|
|
282
|
+
const tamperedAction = {
|
|
283
|
+
...action,
|
|
284
|
+
parameters: { amount: 10_000, to: 'Attacker' },
|
|
285
|
+
};
|
|
286
|
+
const res = await hands.executeAction(tamperedAction, capability);
|
|
287
|
+
expect(res.success).toBe(false);
|
|
288
|
+
expect(res.lifecycle).toBe('REJECTED');
|
|
289
|
+
expect(res.error).toContain('Parameters hash mismatch');
|
|
290
|
+
});
|
|
291
|
+
test('forged signature on capability is rejected by constant-time verification', async () => {
|
|
292
|
+
const action = {
|
|
293
|
+
actionId: 'act-tx-3',
|
|
294
|
+
executionId: 'exec-tx-3',
|
|
295
|
+
toolName: 'bank/transfer_funds',
|
|
296
|
+
parameters: { amount: 100, to: 'Alice' },
|
|
297
|
+
};
|
|
298
|
+
const capability = {
|
|
299
|
+
executionId: 'exec-tx-3',
|
|
300
|
+
actionId: 'act-tx-3',
|
|
301
|
+
toolName: 'bank/transfer_funds',
|
|
302
|
+
providerId: 'bank',
|
|
303
|
+
parametersHash: (0, index_1.computeParametersHash)(action.parameters),
|
|
304
|
+
companionId: 'companion-adv',
|
|
305
|
+
actorId: 'owner-user',
|
|
306
|
+
sessionId: 'sess-owner',
|
|
307
|
+
channel: 'private',
|
|
308
|
+
correlationId: 'corr-adv-1',
|
|
309
|
+
riskLevel: 'CRITICAL',
|
|
310
|
+
issuedAt: new Date().toISOString(),
|
|
311
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
312
|
+
allowed: true,
|
|
313
|
+
signature: '0000000000000000000000000000000000000000000000000000000000000000', // Forged signature
|
|
314
|
+
};
|
|
315
|
+
const res = await hands.executeAction(action, capability);
|
|
316
|
+
expect(res.success).toBe(false);
|
|
317
|
+
expect(res.lifecycle).toBe('REJECTED');
|
|
318
|
+
expect(res.error).toContain('Invalid or forged AuthorizationCapability signature');
|
|
319
|
+
});
|
|
320
|
+
test('expired authorization capability is rejected', async () => {
|
|
321
|
+
const action = {
|
|
322
|
+
actionId: 'act-tx-4',
|
|
323
|
+
executionId: 'exec-tx-4',
|
|
324
|
+
toolName: 'bank/transfer_funds',
|
|
325
|
+
parameters: { amount: 100, to: 'Alice' },
|
|
326
|
+
};
|
|
327
|
+
const paramsHash = (0, index_1.computeParametersHash)(action.parameters);
|
|
328
|
+
const expiredTime = new Date(Date.now() - 5000).toISOString();
|
|
329
|
+
const capabilityPayload = {
|
|
330
|
+
executionId: 'exec-tx-4',
|
|
331
|
+
actionId: 'act-tx-4',
|
|
332
|
+
toolName: 'bank/transfer_funds',
|
|
333
|
+
providerId: 'bank',
|
|
334
|
+
parametersHash: paramsHash,
|
|
335
|
+
companionId: 'companion-adv',
|
|
336
|
+
actorId: 'owner-user',
|
|
337
|
+
sessionId: 'sess-owner',
|
|
338
|
+
channel: 'private',
|
|
339
|
+
correlationId: 'corr-adv-1',
|
|
340
|
+
riskLevel: 'CRITICAL',
|
|
341
|
+
issuedAt: new Date(Date.now() - 10000).toISOString(),
|
|
342
|
+
expiresAt: expiredTime,
|
|
343
|
+
};
|
|
344
|
+
const signature = (0, index_1.signCapabilityPayload)(capabilityPayload, secretKey);
|
|
345
|
+
const capability = { ...capabilityPayload, allowed: true, signature };
|
|
346
|
+
const res = await hands.executeAction(action, capability);
|
|
347
|
+
expect(res.success).toBe(false);
|
|
348
|
+
expect(res.lifecycle).toBe('REJECTED');
|
|
349
|
+
expect(res.error).toContain('AuthorizationCapability has expired');
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
// INVARIANT 6: Failure Semantics & Degradation
|
|
353
|
+
describe('Invariant 6: Subsystem Failure Diagnostics & Non-Empty Propagation', () => {
|
|
354
|
+
test('database/memory query failure surfaces diagnostic in contextPrompt and metadata', async () => {
|
|
355
|
+
const mockBrain = {
|
|
356
|
+
generatePlan: jest.fn().mockResolvedValue({ speech: 'Graceful fallback response', language: 'en' }),
|
|
357
|
+
};
|
|
358
|
+
const failingMemory = {
|
|
359
|
+
initialize: jest.fn().mockResolvedValue(undefined),
|
|
360
|
+
searchClaims: jest.fn().mockRejectedValue(new Error('Connection terminated unexpectedly')),
|
|
361
|
+
getDirectives: jest.fn().mockRejectedValue(new Error('PostgreSQL read timeout')),
|
|
362
|
+
};
|
|
363
|
+
const runtime = new index_1.SiduriRuntime('companion-adv', { name: 'AdvCompanion' }, {
|
|
364
|
+
brain: mockBrain,
|
|
365
|
+
memory: failingMemory,
|
|
366
|
+
});
|
|
367
|
+
const response = await runtime.handleUserMessage('Hello companion', baseOwnerContext);
|
|
368
|
+
expect(response.status).toBe('APPROVED');
|
|
369
|
+
expect(response.metadata.subsystem_diagnostics).toBeDefined();
|
|
370
|
+
expect(response.metadata.subsystem_diagnostics.memory_claims).toContain('UNAVAILABLE');
|
|
371
|
+
expect(response.metadata.subsystem_diagnostics.memory_directives).toContain('UNAVAILABLE');
|
|
372
|
+
const brainCall = mockBrain.generatePlan.mock.calls[0][0];
|
|
373
|
+
expect(brainCall.contextPrompt).toContain('SUBSYSTEM STATUS (DEGRADED):');
|
|
374
|
+
expect(brainCall.contextPrompt).toContain('memory_claims');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
// INVARIANT 8: Truth Gate Admissibility vs Factuality
|
|
378
|
+
describe('Invariant 8: Response Gating Evidence Admissibility Semantics', () => {
|
|
379
|
+
test('gate strictly enforces evidence admissibility and disclosure without claiming unverified factuality', () => {
|
|
380
|
+
const gating = new index_1.ResponseGatingEngine();
|
|
381
|
+
const publicEvidence = {
|
|
382
|
+
evidenceId: 'ev-pub-1',
|
|
383
|
+
sourceId: 'src-facts',
|
|
384
|
+
origin: 'knowledge',
|
|
385
|
+
trust: 'configured',
|
|
386
|
+
sensitivity: 'public',
|
|
387
|
+
allowedAudiences: ['audience-public'],
|
|
388
|
+
companionId: 'companion-adv',
|
|
389
|
+
correlationId: 'corr-adv-1',
|
|
390
|
+
createdAt: new Date().toISOString(),
|
|
391
|
+
};
|
|
392
|
+
const privateEvidence = {
|
|
393
|
+
evidenceId: 'ev-priv-1',
|
|
394
|
+
sourceId: 'src-secrets',
|
|
395
|
+
origin: 'knowledge',
|
|
396
|
+
trust: 'configured',
|
|
397
|
+
sensitivity: 'restricted',
|
|
398
|
+
allowedAudiences: ['audience-owner'],
|
|
399
|
+
companionId: 'companion-adv',
|
|
400
|
+
correlationId: 'corr-adv-1',
|
|
401
|
+
createdAt: new Date().toISOString(),
|
|
402
|
+
};
|
|
403
|
+
// Staged for public channel with both public and restricted evidence attached
|
|
404
|
+
const staged = gating.stageResponse({
|
|
405
|
+
requestContext: baseViewerContext, // Public channel
|
|
406
|
+
candidateSpeech: 'Siduri was created in 1840 by aliens.',
|
|
407
|
+
candidateLanguage: 'en',
|
|
408
|
+
evidenceRecords: [publicEvidence, privateEvidence],
|
|
409
|
+
});
|
|
410
|
+
const evaluation = gating.evaluateGate(staged, [publicEvidence, privateEvidence]);
|
|
411
|
+
expect(evaluation.admissible).toBe(true);
|
|
412
|
+
expect(evaluation.reasonCode).toBe('APPROVED_DIRECT');
|
|
413
|
+
// Public evidence admitted, restricted private evidence excluded from public emission
|
|
414
|
+
expect(evaluation.filteredEvidenceIds).toEqual(['ev-pub-1']);
|
|
415
|
+
expect(evaluation.filteredEvidenceIds).not.toContain('ev-priv-1');
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
// INVARIANT 9: Full-Field Tamper-Evident Audit Trail
|
|
419
|
+
describe('Invariant 9: Full-Field SHA-256 Audit Trail Chaining', () => {
|
|
420
|
+
test('mutating any security-critical field breaks cryptographic hash chain', async () => {
|
|
421
|
+
const store = new index_1.InMemoryActionStore();
|
|
422
|
+
const policyEngine = new index_1.ActionPolicyEngine({ store, secretKey });
|
|
423
|
+
policyEngine.registerToolDefinition({
|
|
424
|
+
name: 'test_tool',
|
|
425
|
+
providerId: 'sys',
|
|
426
|
+
description: 'Test Tool',
|
|
427
|
+
inputSchema: {},
|
|
428
|
+
riskLevel: 'LOW',
|
|
429
|
+
});
|
|
430
|
+
// Event 1
|
|
431
|
+
await policyEngine.evaluateAction({
|
|
432
|
+
actionId: 'act-1',
|
|
433
|
+
toolName: 'sys/test_tool',
|
|
434
|
+
parameters: { step: 1 },
|
|
435
|
+
context: baseOwnerContext,
|
|
436
|
+
});
|
|
437
|
+
// Event 2
|
|
438
|
+
await policyEngine.evaluateAction({
|
|
439
|
+
actionId: 'act-2',
|
|
440
|
+
toolName: 'sys/test_tool',
|
|
441
|
+
parameters: { step: 2 },
|
|
442
|
+
context: baseOwnerContext,
|
|
443
|
+
});
|
|
444
|
+
const auditTrail = await store.getAuditLog();
|
|
445
|
+
expect(auditTrail.length).toBe(2);
|
|
446
|
+
const event1 = auditTrail[0];
|
|
447
|
+
const event2 = auditTrail[1];
|
|
448
|
+
// Mutate security-critical fields in event1 and verify chain discrepancy
|
|
449
|
+
const criticalFields = [
|
|
450
|
+
'executionId',
|
|
451
|
+
'actionId',
|
|
452
|
+
'toolName',
|
|
453
|
+
'companionId',
|
|
454
|
+
'actorId',
|
|
455
|
+
'sessionId',
|
|
456
|
+
'channel',
|
|
457
|
+
'correlationId',
|
|
458
|
+
'riskLevel',
|
|
459
|
+
'lifecycle',
|
|
460
|
+
'parametersHash',
|
|
461
|
+
];
|
|
462
|
+
for (const field of criticalFields) {
|
|
463
|
+
const tamperedEvent = { ...event1, [field]: 'TAMPERED_VALUE' };
|
|
464
|
+
const initialPrevHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
465
|
+
const canonical = (0, index_1.canonicalizeJson)({
|
|
466
|
+
executionId: tamperedEvent.executionId,
|
|
467
|
+
actionId: tamperedEvent.actionId,
|
|
468
|
+
toolName: tamperedEvent.toolName,
|
|
469
|
+
providerId: tamperedEvent.providerId || null,
|
|
470
|
+
companionId: tamperedEvent.companionId,
|
|
471
|
+
actorId: tamperedEvent.actorId || null,
|
|
472
|
+
sessionId: tamperedEvent.sessionId || null,
|
|
473
|
+
channel: tamperedEvent.channel || null,
|
|
474
|
+
correlationId: tamperedEvent.correlationId || null,
|
|
475
|
+
riskLevel: tamperedEvent.riskLevel,
|
|
476
|
+
lifecycle: tamperedEvent.lifecycle,
|
|
477
|
+
decision: tamperedEvent.decision ? {
|
|
478
|
+
allowed: tamperedEvent.decision.allowed,
|
|
479
|
+
reason: tamperedEvent.decision.reason,
|
|
480
|
+
riskLevel: tamperedEvent.decision.riskLevel,
|
|
481
|
+
decisionCode: tamperedEvent.decision.decisionCode,
|
|
482
|
+
} : null,
|
|
483
|
+
parametersHash: tamperedEvent.parametersHash || null,
|
|
484
|
+
error: tamperedEvent.error || null,
|
|
485
|
+
timestamp: tamperedEvent.timestamp,
|
|
486
|
+
});
|
|
487
|
+
const crypto = require('node:crypto');
|
|
488
|
+
const brokenHash1 = crypto.createHash('sha256').update(`${initialPrevHash}:${canonical}`, 'utf8').digest('hex');
|
|
489
|
+
expect(brokenHash1).not.toBe(event1.resultHash);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
});
|
|
@@ -51,7 +51,7 @@ describe('Architecture: Core & Organ Package Boundaries (Phase 2)', () => {
|
|
|
51
51
|
{ dir: 'voice', name: '@siduri-x/voice', organType: 'voice', configKey: 'voice' },
|
|
52
52
|
{ dir: 'observation', name: '@siduri-x/observation', organType: 'observation', configKey: 'observation' },
|
|
53
53
|
];
|
|
54
|
-
it('package.json has zero dependencies on @siduri-
|
|
54
|
+
it('package.json has zero dependencies on @siduri-x organ packages', () => {
|
|
55
55
|
const pkg = JSON.parse(fs.readFileSync(corePackageJsonPath, 'utf8'));
|
|
56
56
|
const allDeps = {
|
|
57
57
|
...(pkg.dependencies || {}),
|
|
@@ -61,7 +61,7 @@ describe('Architecture: Core & Organ Package Boundaries (Phase 2)', () => {
|
|
|
61
61
|
const organDeps = Object.keys(allDeps).filter((dep) => dep.startsWith('@siduri-x/') && dep !== '@siduri-x/core');
|
|
62
62
|
expect(organDeps).toEqual([]);
|
|
63
63
|
});
|
|
64
|
-
it('source files in packages/core have zero imports referencing @siduri-
|
|
64
|
+
it('source files in packages/core have zero imports referencing @siduri-x organ packages', () => {
|
|
65
65
|
const files = fs.readdirSync(coreSrcDir).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
|
66
66
|
const forbiddenImports = [];
|
|
67
67
|
for (const file of files) {
|
package/dist/capability.d.ts
CHANGED
|
@@ -33,16 +33,21 @@ export interface ActionStore {
|
|
|
33
33
|
reserveExecution(record: PersistentExecutionRecord): Promise<boolean>;
|
|
34
34
|
updateExecution(record: PersistentExecutionRecord): Promise<void>;
|
|
35
35
|
getExecution(executionId: string): Promise<PersistentExecutionRecord | undefined>;
|
|
36
|
+
saveApproval(executionId: string, approverActorId: string, reason?: string): Promise<void>;
|
|
37
|
+
isActionApproved(executionId: string): Promise<boolean>;
|
|
36
38
|
appendAudit(event: ActionAuditEvent): Promise<void>;
|
|
37
39
|
getAuditLog(executionId?: string): Promise<ActionAuditEvent[]>;
|
|
38
40
|
}
|
|
39
41
|
export declare class InMemoryActionStore implements ActionStore {
|
|
40
42
|
private readonly executions;
|
|
43
|
+
private readonly approvals;
|
|
41
44
|
private readonly auditLog;
|
|
42
45
|
private lastAuditHash;
|
|
43
46
|
reserveExecution(record: PersistentExecutionRecord): Promise<boolean>;
|
|
44
47
|
updateExecution(record: PersistentExecutionRecord): Promise<void>;
|
|
45
48
|
getExecution(executionId: string): Promise<PersistentExecutionRecord | undefined>;
|
|
49
|
+
saveApproval(executionId: string, approverActorId: string, reason?: string): Promise<void>;
|
|
50
|
+
isActionApproved(executionId: string): Promise<boolean>;
|
|
46
51
|
appendAudit(event: ActionAuditEvent): Promise<void>;
|
|
47
52
|
getAuditLog(executionId?: string): Promise<ActionAuditEvent[]>;
|
|
48
53
|
}
|
package/dist/capability.js
CHANGED
|
@@ -11,6 +11,7 @@ exports.verifyCapabilitySignature = verifyCapabilitySignature;
|
|
|
11
11
|
const crypto = require('crypto');
|
|
12
12
|
class InMemoryActionStore {
|
|
13
13
|
executions = new Map();
|
|
14
|
+
approvals = new Map();
|
|
14
15
|
auditLog = [];
|
|
15
16
|
lastAuditHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
16
17
|
async reserveExecution(record) {
|
|
@@ -27,24 +28,51 @@ class InMemoryActionStore {
|
|
|
27
28
|
const rec = this.executions.get(executionId);
|
|
28
29
|
return rec ? { ...rec } : undefined;
|
|
29
30
|
}
|
|
31
|
+
async saveApproval(executionId, approverActorId, reason) {
|
|
32
|
+
this.approvals.set(executionId, {
|
|
33
|
+
approverActorId,
|
|
34
|
+
reason,
|
|
35
|
+
approvedAt: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async isActionApproved(executionId) {
|
|
39
|
+
return this.approvals.has(executionId);
|
|
40
|
+
}
|
|
30
41
|
async appendAudit(event) {
|
|
31
|
-
|
|
42
|
+
const prevHash = this.lastAuditHash;
|
|
43
|
+
// Tamper-evident hash chaining over all security-critical event fields
|
|
32
44
|
const eventPayload = {
|
|
33
45
|
executionId: event.executionId,
|
|
34
46
|
actionId: event.actionId,
|
|
35
47
|
toolName: event.toolName,
|
|
48
|
+
providerId: event.providerId || null,
|
|
49
|
+
companionId: event.companionId,
|
|
50
|
+
actorId: event.actorId || null,
|
|
51
|
+
sessionId: event.sessionId || null,
|
|
52
|
+
channel: event.channel || null,
|
|
53
|
+
correlationId: event.correlationId || null,
|
|
54
|
+
riskLevel: event.riskLevel,
|
|
36
55
|
lifecycle: event.lifecycle,
|
|
37
|
-
|
|
56
|
+
decision: event.decision ? {
|
|
57
|
+
allowed: event.decision.allowed,
|
|
58
|
+
reason: event.decision.reason,
|
|
59
|
+
riskLevel: event.decision.riskLevel,
|
|
60
|
+
decisionCode: event.decision.decisionCode,
|
|
61
|
+
} : null,
|
|
62
|
+
parametersHash: event.parametersHash || null,
|
|
63
|
+
error: event.error || null,
|
|
38
64
|
timestamp: event.timestamp,
|
|
39
65
|
};
|
|
40
66
|
const canonical = canonicalizeJson(eventPayload);
|
|
41
67
|
const eventHash = crypto
|
|
42
68
|
.createHash('sha256')
|
|
43
|
-
.update(`${
|
|
69
|
+
.update(`${prevHash}:${canonical}`, 'utf8')
|
|
44
70
|
.digest('hex');
|
|
45
71
|
this.lastAuditHash = eventHash;
|
|
46
72
|
const recordWithHash = {
|
|
47
73
|
...event,
|
|
74
|
+
previousEventHash: prevHash,
|
|
75
|
+
eventHash,
|
|
48
76
|
resultHash: event.resultHash || eventHash,
|
|
49
77
|
};
|
|
50
78
|
this.auditLog.push(recordWithHash);
|
package/dist/capability.test.js
CHANGED
|
@@ -151,22 +151,52 @@ describe('AuthorizationCapability Cryptographic & Tamper Review', () => {
|
|
|
151
151
|
executionId: event1.executionId,
|
|
152
152
|
actionId: event1.actionId,
|
|
153
153
|
toolName: event1.toolName,
|
|
154
|
+
providerId: event1.providerId || null,
|
|
155
|
+
companionId: event1.companionId,
|
|
156
|
+
actorId: event1.actorId || null,
|
|
157
|
+
sessionId: event1.sessionId || null,
|
|
158
|
+
channel: event1.channel || null,
|
|
159
|
+
correlationId: event1.correlationId || null,
|
|
160
|
+
riskLevel: event1.riskLevel,
|
|
154
161
|
lifecycle: event1.lifecycle,
|
|
155
|
-
|
|
162
|
+
decision: event1.decision ? {
|
|
163
|
+
allowed: event1.decision.allowed,
|
|
164
|
+
reason: event1.decision.reason,
|
|
165
|
+
riskLevel: event1.decision.riskLevel,
|
|
166
|
+
decisionCode: event1.decision.decisionCode,
|
|
167
|
+
} : null,
|
|
168
|
+
parametersHash: event1.parametersHash || null,
|
|
169
|
+
error: event1.error || null,
|
|
156
170
|
timestamp: event1.timestamp,
|
|
157
171
|
});
|
|
158
172
|
const expectedHash1 = crypto.createHash('sha256').update(`${initialPrevHash}:${canonical1}`, 'utf8').digest('hex');
|
|
159
|
-
expect(event1.
|
|
173
|
+
expect(event1.eventHash).toBe(expectedHash1);
|
|
174
|
+
expect(event1.previousEventHash).toBe(initialPrevHash);
|
|
160
175
|
const canonical2 = (0, capability_1.canonicalizeJson)({
|
|
161
176
|
executionId: event2.executionId,
|
|
162
177
|
actionId: event2.actionId,
|
|
163
178
|
toolName: event2.toolName,
|
|
179
|
+
providerId: event2.providerId || null,
|
|
180
|
+
companionId: event2.companionId,
|
|
181
|
+
actorId: event2.actorId || null,
|
|
182
|
+
sessionId: event2.sessionId || null,
|
|
183
|
+
channel: event2.channel || null,
|
|
184
|
+
correlationId: event2.correlationId || null,
|
|
185
|
+
riskLevel: event2.riskLevel,
|
|
164
186
|
lifecycle: event2.lifecycle,
|
|
165
|
-
|
|
187
|
+
decision: event2.decision ? {
|
|
188
|
+
allowed: event2.decision.allowed,
|
|
189
|
+
reason: event2.decision.reason,
|
|
190
|
+
riskLevel: event2.decision.riskLevel,
|
|
191
|
+
decisionCode: event2.decision.decisionCode,
|
|
192
|
+
} : null,
|
|
193
|
+
parametersHash: event2.parametersHash || null,
|
|
194
|
+
error: event2.error || null,
|
|
166
195
|
timestamp: event2.timestamp,
|
|
167
196
|
});
|
|
168
197
|
const expectedHash2 = crypto.createHash('sha256').update(`${expectedHash1}:${canonical2}`, 'utf8').digest('hex');
|
|
169
|
-
expect(event2.
|
|
198
|
+
expect(event2.eventHash).toBe(expectedHash2);
|
|
199
|
+
expect(event2.previousEventHash).toBe(expectedHash1);
|
|
170
200
|
// If an attacker altered event 1 retrospectively, the hash chain breaks for event 2
|
|
171
201
|
const tamperedCanonical1 = (0, capability_1.canonicalizeJson)({
|
|
172
202
|
...JSON.parse(canonical1),
|
|
@@ -177,4 +207,63 @@ describe('AuthorizationCapability Cryptographic & Tamper Review', () => {
|
|
|
177
207
|
expect(brokenHash2).not.toBe(event2.resultHash);
|
|
178
208
|
});
|
|
179
209
|
});
|
|
210
|
+
describe('Durable Action Approval Restart Semantics', () => {
|
|
211
|
+
it('preserves approved execution authorization across ActionPolicyEngine process restart', async () => {
|
|
212
|
+
const sharedStore = new capability_1.InMemoryActionStore();
|
|
213
|
+
// Instance 1: Operator reviews and approves a HIGH risk action
|
|
214
|
+
const engine1 = new action_policy_1.ActionPolicyEngine({
|
|
215
|
+
store: sharedStore,
|
|
216
|
+
secretKey,
|
|
217
|
+
defaultRiskLevel: 'HIGH',
|
|
218
|
+
defaultRequireApprovalForHighRisk: true,
|
|
219
|
+
});
|
|
220
|
+
engine1.registerToolDefinition({
|
|
221
|
+
name: 'database/cleanup',
|
|
222
|
+
providerId: 'db',
|
|
223
|
+
description: 'Cleanup DB',
|
|
224
|
+
inputSchema: {},
|
|
225
|
+
riskLevel: 'HIGH',
|
|
226
|
+
requiresApproval: true,
|
|
227
|
+
});
|
|
228
|
+
const highRiskAction = {
|
|
229
|
+
actionId: 'act-restart-1',
|
|
230
|
+
executionId: 'exec-restart-1',
|
|
231
|
+
toolName: 'db/database/cleanup',
|
|
232
|
+
parameters: { target: 'logs' },
|
|
233
|
+
context: sampleContext,
|
|
234
|
+
};
|
|
235
|
+
// 1. Initial evaluation without approval is rejected
|
|
236
|
+
const eval1 = await engine1.evaluateAction(highRiskAction);
|
|
237
|
+
expect(eval1.decision.allowed).toBe(false);
|
|
238
|
+
expect(eval1.decision.decisionCode).toBe('REJECTED_HIGH_RISK_UNAPPROVED');
|
|
239
|
+
// 2. Operator explicitly approves
|
|
240
|
+
await engine1.approveAction({
|
|
241
|
+
executionId: 'exec-restart-1',
|
|
242
|
+
approverActorId: 'operator-1',
|
|
243
|
+
reason: 'Scheduled maintenance',
|
|
244
|
+
});
|
|
245
|
+
// 3. Process restarts: new ActionPolicyEngine instance with empty in-memory set but shared durable store
|
|
246
|
+
const engine2 = new action_policy_1.ActionPolicyEngine({
|
|
247
|
+
store: sharedStore,
|
|
248
|
+
secretKey,
|
|
249
|
+
defaultRiskLevel: 'HIGH',
|
|
250
|
+
defaultRequireApprovalForHighRisk: true,
|
|
251
|
+
});
|
|
252
|
+
engine2.registerToolDefinition({
|
|
253
|
+
name: 'database/cleanup',
|
|
254
|
+
providerId: 'db',
|
|
255
|
+
description: 'Cleanup DB',
|
|
256
|
+
inputSchema: {},
|
|
257
|
+
riskLevel: 'HIGH',
|
|
258
|
+
requiresApproval: true,
|
|
259
|
+
});
|
|
260
|
+
// 4. Evaluation after restart loads durable approval and successfully authorizes capability
|
|
261
|
+
const eval2 = await engine2.evaluateAction(highRiskAction);
|
|
262
|
+
expect(eval2.decision.allowed).toBe(true);
|
|
263
|
+
expect(eval2.decision.decisionCode).toBe('ALLOWED_POLICY');
|
|
264
|
+
expect(eval2.capability).toBeDefined();
|
|
265
|
+
expect(eval2.capability?.executionId).toBe('exec-restart-1');
|
|
266
|
+
expect((0, capability_1.verifyCapabilitySignature)(eval2.capability, secretKey)).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
180
269
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { RequestContext } from './context';
|
|
2
|
+
import { Message, Claim } from './index';
|
|
3
|
+
import { ActionExecutionResult } from './action';
|
|
4
|
+
import { ResponseCitation } from './evidence';
|
|
5
|
+
import { SiduriRuntime } from './runtime';
|
|
6
|
+
export interface ChatRequest {
|
|
7
|
+
id?: string;
|
|
8
|
+
companionId?: string;
|
|
9
|
+
message: string;
|
|
10
|
+
role?: 'OWNER' | 'VIEWER' | 'OPERATOR';
|
|
11
|
+
context?: RequestContext;
|
|
12
|
+
history?: Message[];
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}
|
|
15
|
+
export interface ChatResponseMetadataEvent {
|
|
16
|
+
event_id: string;
|
|
17
|
+
kind: string;
|
|
18
|
+
lifecycle: string;
|
|
19
|
+
approval?: string;
|
|
20
|
+
expression?: string;
|
|
21
|
+
action?: string;
|
|
22
|
+
durationMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ChatResponsePlan {
|
|
25
|
+
speech_id?: string;
|
|
26
|
+
audio_url?: string;
|
|
27
|
+
subtitle_ja: string;
|
|
28
|
+
subtitle_en: string;
|
|
29
|
+
spoken_ja?: string;
|
|
30
|
+
evidence_ids?: string[];
|
|
31
|
+
}
|
|
32
|
+
export interface ChatResponseMetadata {
|
|
33
|
+
language?: string;
|
|
34
|
+
internal_monologue?: string;
|
|
35
|
+
proposals?: Claim[];
|
|
36
|
+
memory_proposals?: Array<{
|
|
37
|
+
proposal_id: string;
|
|
38
|
+
subject?: string;
|
|
39
|
+
predicate?: string;
|
|
40
|
+
value?: string;
|
|
41
|
+
status: string;
|
|
42
|
+
content?: string;
|
|
43
|
+
claim_type?: string;
|
|
44
|
+
}>;
|
|
45
|
+
behavioral_proposals?: Array<{
|
|
46
|
+
directive_id: string;
|
|
47
|
+
memory_class: string;
|
|
48
|
+
domain: string;
|
|
49
|
+
subject: string;
|
|
50
|
+
predicate: string;
|
|
51
|
+
value: string;
|
|
52
|
+
status: string;
|
|
53
|
+
behavior?: any;
|
|
54
|
+
runtime_effect?: string;
|
|
55
|
+
}>;
|
|
56
|
+
action_results?: ActionExecutionResult[];
|
|
57
|
+
evidence_ids?: string[];
|
|
58
|
+
citations?: ResponseCitation[];
|
|
59
|
+
subsystem_diagnostics?: Record<string, string>;
|
|
60
|
+
events?: ChatResponseMetadataEvent[];
|
|
61
|
+
[key: string]: any;
|
|
62
|
+
}
|
|
63
|
+
export interface ChatResponse {
|
|
64
|
+
status: 'APPROVED' | 'REJECTED' | 'STAGED' | string;
|
|
65
|
+
response_id?: string;
|
|
66
|
+
correlation_id?: string;
|
|
67
|
+
response: ChatResponsePlan;
|
|
68
|
+
metadata?: ChatResponseMetadata;
|
|
69
|
+
reply?: string;
|
|
70
|
+
text?: string;
|
|
71
|
+
audioUrl?: string;
|
|
72
|
+
expression?: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Canonical helper to dispatch a chat request to a SiduriRuntime instance.
|
|
76
|
+
* Guarantees a 1:1 identical response structure for localweb (apps/api) and standalone CLI.
|
|
77
|
+
*/
|
|
78
|
+
export declare function dispatchCompanionChat(runtime: SiduriRuntime, payload: ChatRequest): Promise<ChatResponse>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dispatchCompanionChat = dispatchCompanionChat;
|
|
4
|
+
/**
|
|
5
|
+
* Canonical helper to dispatch a chat request to a SiduriRuntime instance.
|
|
6
|
+
* Guarantees a 1:1 identical response structure for localweb (apps/api) and standalone CLI.
|
|
7
|
+
*/
|
|
8
|
+
async function dispatchCompanionChat(runtime, payload) {
|
|
9
|
+
const userMessage = payload.message || payload.text || '';
|
|
10
|
+
const history = Array.isArray(payload.history) ? payload.history : [];
|
|
11
|
+
let roleOrContext;
|
|
12
|
+
if (payload.role) {
|
|
13
|
+
roleOrContext = payload.role;
|
|
14
|
+
}
|
|
15
|
+
else if (payload.context) {
|
|
16
|
+
// Map authorization role to legacy memory scope for backwards-compatible runtime calls
|
|
17
|
+
const authRole = payload.context.actor?.authorizationRole;
|
|
18
|
+
roleOrContext =
|
|
19
|
+
authRole === 'administrator'
|
|
20
|
+
? 'OWNER'
|
|
21
|
+
: authRole === 'operator'
|
|
22
|
+
? 'OPERATOR'
|
|
23
|
+
: 'VIEWER';
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
roleOrContext = 'VIEWER';
|
|
27
|
+
}
|
|
28
|
+
const runtimeResult = await runtime.handleUserMessage(userMessage, roleOrContext, history);
|
|
29
|
+
// Normalize response plan
|
|
30
|
+
const speech = runtimeResult?.response?.subtitle_ja || runtimeResult?.response?.subtitle_en || '';
|
|
31
|
+
const audioUrl = runtimeResult?.response?.audio_url;
|
|
32
|
+
// Extract avatar expression if any event was generated
|
|
33
|
+
let expression = 'neutral';
|
|
34
|
+
const events = runtimeResult?.metadata?.events || [];
|
|
35
|
+
const avatarEvent = events.find((e) => e.kind === 'avatar' || e.kind === 'body');
|
|
36
|
+
if (avatarEvent && avatarEvent.expression) {
|
|
37
|
+
expression = avatarEvent.expression;
|
|
38
|
+
}
|
|
39
|
+
// Ensure both spoken_ja and subtitle_en are accessible alongside speech_id and evidence_ids
|
|
40
|
+
const responsePlan = {
|
|
41
|
+
speech_id: runtimeResult?.response?.speech_id,
|
|
42
|
+
audio_url: audioUrl,
|
|
43
|
+
subtitle_ja: runtimeResult?.response?.subtitle_ja ?? speech,
|
|
44
|
+
subtitle_en: runtimeResult?.response?.subtitle_en ?? speech,
|
|
45
|
+
spoken_ja: runtimeResult?.response?.spoken_ja ?? runtimeResult?.response?.subtitle_ja ?? speech,
|
|
46
|
+
evidence_ids: runtimeResult?.metadata?.evidence_ids ?? runtimeResult?.response?.evidence_ids ?? [],
|
|
47
|
+
};
|
|
48
|
+
const metadata = {
|
|
49
|
+
...(runtimeResult?.metadata || {}),
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
status: runtimeResult?.status || 'APPROVED',
|
|
53
|
+
response_id: runtimeResult?.response_id,
|
|
54
|
+
correlation_id: runtimeResult?.correlation_id,
|
|
55
|
+
response: responsePlan,
|
|
56
|
+
metadata,
|
|
57
|
+
// Convenience fields for legacy/simple consumers
|
|
58
|
+
reply: speech,
|
|
59
|
+
text: speech,
|
|
60
|
+
audioUrl,
|
|
61
|
+
expression,
|
|
62
|
+
};
|
|
63
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './ear-types';
|
|
|
9
9
|
export * from './capability';
|
|
10
10
|
export * from './teaching';
|
|
11
11
|
export * from './runtime';
|
|
12
|
+
export * from './chat-contract';
|
|
12
13
|
import { ActionIntent } from './action';
|
|
13
14
|
import { EarIngestOptions } from './ear-types';
|
|
14
15
|
export interface OrganConfig {
|
|
@@ -123,6 +124,8 @@ export interface MemoryQueryOptions {
|
|
|
123
124
|
audienceId?: string;
|
|
124
125
|
sensitivity?: string;
|
|
125
126
|
limit?: number;
|
|
127
|
+
minConfidence?: number;
|
|
128
|
+
now?: string | Date;
|
|
126
129
|
}
|
|
127
130
|
export interface MemoryOrgan {
|
|
128
131
|
initialize(companionId: string): Promise<void>;
|
|
@@ -143,6 +146,8 @@ export interface MemoryOrgan {
|
|
|
143
146
|
disableDirective(id: string): Promise<void>;
|
|
144
147
|
expireDirective?(id: string): Promise<void>;
|
|
145
148
|
supersedeClaim?(id: string, replacement: Omit<Claim, 'id' | 'status' | 'companionId'>): Promise<Claim>;
|
|
149
|
+
updateClaim?(id: string, updates: Partial<Pick<Claim, 'subject' | 'predicate' | 'value' | 'scope' | 'sensitivity' | 'confidence' | 'validFrom' | 'validUntil' | 'allowedAudiences'>>): Promise<Claim>;
|
|
150
|
+
resetMemory?(): Promise<void>;
|
|
146
151
|
addSourceEvent?(event: SourceEvent): Promise<SourceEvent>;
|
|
147
152
|
getSourceEvent?(id: string): Promise<SourceEvent | undefined>;
|
|
148
153
|
}
|
package/dist/index.js
CHANGED
package/dist/runtime.js
CHANGED
|
@@ -111,17 +111,21 @@ class SiduriRuntime {
|
|
|
111
111
|
limit: 5,
|
|
112
112
|
}
|
|
113
113
|
: role;
|
|
114
|
+
const subsystemDiagnostics = {};
|
|
114
115
|
const [knowledgeData, memoryData, activeDirectives] = await Promise.all([
|
|
115
116
|
this.knowledge && shouldQueryKnowledge && typeof this.knowledge.search === 'function' ? this.knowledge.search(perceivedText).catch(e => {
|
|
116
117
|
console.error("[SiduriRuntime] Knowledge search failed:", e.message);
|
|
118
|
+
subsystemDiagnostics['knowledge'] = `UNAVAILABLE: ${e.message}`;
|
|
117
119
|
return [];
|
|
118
120
|
}) : Promise.resolve([]),
|
|
119
121
|
this.memory && typeof this.memory.searchClaims === 'function' ? this.memory.searchClaims(perceivedText, queryOptions, 5).catch(e => {
|
|
120
122
|
console.error("[SiduriRuntime] Memory search failed:", e.message);
|
|
123
|
+
subsystemDiagnostics['memory_claims'] = `UNAVAILABLE: ${e.message}`;
|
|
121
124
|
return [];
|
|
122
125
|
}) : Promise.resolve([]),
|
|
123
126
|
this.memory && typeof this.memory.getDirectives === 'function' ? this.memory.getDirectives().catch(e => {
|
|
124
127
|
console.error("[SiduriRuntime] Memory directives failed:", e.message);
|
|
128
|
+
subsystemDiagnostics['memory_directives'] = `UNAVAILABLE: ${e.message}`;
|
|
125
129
|
return [];
|
|
126
130
|
}) : Promise.resolve([])
|
|
127
131
|
]);
|
|
@@ -154,6 +158,9 @@ class SiduriRuntime {
|
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
let contextPrompt = "";
|
|
161
|
+
if (Object.keys(subsystemDiagnostics).length > 0) {
|
|
162
|
+
contextPrompt += "SUBSYSTEM STATUS (DEGRADED):\n" + Object.entries(subsystemDiagnostics).map(([k, v]) => `- [${k}] ${v}`).join("\n") + "\n";
|
|
163
|
+
}
|
|
157
164
|
if (knowledgeData.length > 0) {
|
|
158
165
|
contextPrompt += "KNOWLEDGE:\n" + knowledgeData.map(k => `- [revision:${k.revision} source:${k.provenance}] ${k.content}`).join("\n") + "\n";
|
|
159
166
|
}
|
|
@@ -389,6 +396,7 @@ class SiduriRuntime {
|
|
|
389
396
|
action_results: actionResults,
|
|
390
397
|
evidence_ids: gateEval.filteredEvidenceIds,
|
|
391
398
|
citations: gateEval.filteredCitations,
|
|
399
|
+
subsystem_diagnostics: Object.keys(subsystemDiagnostics).length > 0 ? subsystemDiagnostics : undefined,
|
|
392
400
|
events: experienceEvents.map(e => ({
|
|
393
401
|
event_id: e.eventId,
|
|
394
402
|
kind: e.kind,
|
package/dist/teaching.js
CHANGED
|
@@ -52,7 +52,7 @@ function extractDeterministicTeaching(message, context, sourceEventId) {
|
|
|
52
52
|
}
|
|
53
53
|
// 2. Actor's Name: "my name is X"
|
|
54
54
|
const myNameMatch = text.match(/\bmy name is\s+(.+?)(?=\s+and\s+(?:i\b|my\b|you\b)|[.;,]|$)/i);
|
|
55
|
-
if (myNameMatch && !/\b(?:private|public|
|
|
55
|
+
if (myNameMatch && !/\b(?:private|public|everywhere)\b/i.test(text)) {
|
|
56
56
|
const name = cleanValue(myNameMatch[1], 80);
|
|
57
57
|
claims.push({
|
|
58
58
|
subject: actorSubject,
|
|
@@ -66,8 +66,8 @@ function extractDeterministicTeaching(message, context, sourceEventId) {
|
|
|
66
66
|
sourceEventId,
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
|
-
// 3. Preferred Address / Call me X: "call me X
|
|
70
|
-
const callMeMatch = text.match(/\b(?:(?:from now on|only),?\s*)?call me\s+(.+?)(?:\s+(in private|privately|
|
|
69
|
+
// 3. Preferred Address / Call me X: "call me X"
|
|
70
|
+
const callMeMatch = text.match(/\b(?:(?:from now on|only),?\s*)?call me\s+(.+?)(?:\s+(in private|privately|in public|publicly|everywhere|in direct conversations))?(?=\s+and\s+(?:i\b|my\b|you\b)|[.;,]|$)/i);
|
|
71
71
|
if (callMeMatch) {
|
|
72
72
|
const address = cleanValue(callMeMatch[1], 80);
|
|
73
73
|
const scopePhrase = (callMeMatch[2] || '').toLowerCase();
|
|
@@ -79,7 +79,7 @@ function extractDeterministicTeaching(message, context, sourceEventId) {
|
|
|
79
79
|
claimAudiences = [context?.conversation?.audienceId || `audience-private-${actorId}`];
|
|
80
80
|
directiveInstruction += ' in private conversations';
|
|
81
81
|
}
|
|
82
|
-
else if (scopePhrase.includes('
|
|
82
|
+
else if (scopePhrase.includes('public') || scopePhrase.includes('publicly')) {
|
|
83
83
|
claimSensitivity = 'public';
|
|
84
84
|
claimAudiences = ['audience-public'];
|
|
85
85
|
directiveInstruction += ' in public conversations';
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siduri-x/core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Core runtime types, evidence protocol, action dispatcher, capability validation, and SiduriRuntime protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/
|
|
8
|
+
"url": "https://github.com/vxnus-studio/siduri-x",
|
|
9
9
|
"directory": "packages/core"
|
|
10
10
|
},
|
|
11
11
|
"publishConfig": {
|