@siduri-x/core 2.0.0 → 2.0.1
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 +28 -1
- package/dist/action-policy.js +167 -7
- package/dist/action-policy.test.js +104 -0
- package/dist/adversarial.test.js +4 -2
- package/dist/capability.d.ts +11 -2
- package/dist/capability.js +8 -1
- package/dist/capability.test.js +43 -1
- package/dist/index.d.ts +9 -7
- package/dist/runtime.d.ts +6 -5
- package/dist/runtime.js +19 -8
- package/dist/schema-validator.test.js +2 -2
- package/dist/siduri-db.d.ts +27 -8
- package/dist/siduri-db.js +260 -17
- package/dist/siduri-db.test.js +283 -0
- package/dist/sqlite-action-store.d.ts +3 -2
- package/dist/sqlite-action-store.js +27 -4
- package/dist/sqlite-action-store.test.js +1 -0
- package/package.json +6 -6
package/dist/action-policy.d.ts
CHANGED
|
@@ -15,12 +15,35 @@ export interface ActionPolicyEngineOptions {
|
|
|
15
15
|
defaultRequireApprovalForHighRisk?: boolean;
|
|
16
16
|
store?: ActionStore;
|
|
17
17
|
secretKey?: string;
|
|
18
|
+
allowedApproverRoles?: string[];
|
|
19
|
+
requiredApproverCapabilities?: string[];
|
|
20
|
+
}
|
|
21
|
+
export type ActionApprovalDecisionCode = 'APPROVED' | 'REJECTED_UNAUTHORIZED' | 'REJECTED_UNAUTHENTICATED' | 'REJECTED_ROLE_MISMATCH' | 'REJECTED_MISSING_APPROVER_ID' | 'REJECTED_MISSING_CAPABILITY';
|
|
22
|
+
export interface ActionApprovalResult {
|
|
23
|
+
approved: boolean;
|
|
24
|
+
decisionCode: ActionApprovalDecisionCode;
|
|
25
|
+
reason: string;
|
|
26
|
+
executionId: string;
|
|
27
|
+
approverActorId: string;
|
|
18
28
|
}
|
|
19
29
|
export interface ApproveActionOptions {
|
|
20
30
|
executionId: string;
|
|
21
31
|
approverActorId: string;
|
|
22
32
|
reason?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Optional RequestContext of the approver establishing authenticated identity.
|
|
35
|
+
*/
|
|
36
|
+
context?: RequestContext;
|
|
37
|
+
/**
|
|
38
|
+
* Optional role of the approver (e.g. 'owner', 'administrator', 'operator').
|
|
39
|
+
*/
|
|
40
|
+
approverRole?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Optional capabilities held by the approver.
|
|
43
|
+
*/
|
|
44
|
+
approverCapabilities?: string[];
|
|
23
45
|
}
|
|
46
|
+
export declare function normalizeApproverRole(role?: string, actorId?: string): string;
|
|
24
47
|
export declare class ActionPolicyEngine {
|
|
25
48
|
private readonly toolRegistry;
|
|
26
49
|
private readonly rules;
|
|
@@ -28,7 +51,10 @@ export declare class ActionPolicyEngine {
|
|
|
28
51
|
private readonly defaultRiskLevel;
|
|
29
52
|
private readonly defaultRequireApprovalForHighRisk;
|
|
30
53
|
private readonly secretKey;
|
|
54
|
+
private readonly allowedApproverRoles;
|
|
55
|
+
private readonly requiredApproverCapabilities;
|
|
31
56
|
private readonly approvedExecutions;
|
|
57
|
+
private readonly pendingActions;
|
|
32
58
|
constructor(options?: ActionPolicyEngineOptions);
|
|
33
59
|
registerToolDefinition(tool: ToolDefinition): void;
|
|
34
60
|
unregisterToolDefinition(toolName: string): boolean;
|
|
@@ -38,7 +64,8 @@ export declare class ActionPolicyEngine {
|
|
|
38
64
|
decision: ActionPolicyDecision;
|
|
39
65
|
capability?: AuthorizationCapability;
|
|
40
66
|
}>;
|
|
41
|
-
approveAction(options: ApproveActionOptions): Promise<
|
|
67
|
+
approveAction(options: ApproveActionOptions): Promise<ActionApprovalResult>;
|
|
68
|
+
private recordApprovalAudit;
|
|
42
69
|
recordAudit(action: ActionIntent, context: RequestContext | undefined, decision: ActionPolicyDecision | undefined, lifecycle: ActionLifecycleState, result?: unknown, error?: string, durationMs?: number): Promise<ActionAuditEvent>;
|
|
43
70
|
getAuditLog(): Promise<ActionAuditEvent[]>;
|
|
44
71
|
getStore(): ActionStore;
|
package/dist/action-policy.js
CHANGED
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ActionPolicyEngine = void 0;
|
|
4
|
+
exports.normalizeApproverRole = normalizeApproverRole;
|
|
4
5
|
const capability_1 = require("./capability");
|
|
6
|
+
function normalizeApproverRole(role, actorId) {
|
|
7
|
+
if (role && role.trim() !== '') {
|
|
8
|
+
const lower = role.trim().toLowerCase();
|
|
9
|
+
if (lower === 'administrator' || lower === 'admin')
|
|
10
|
+
return 'administrator';
|
|
11
|
+
return lower;
|
|
12
|
+
}
|
|
13
|
+
if (actorId) {
|
|
14
|
+
const lower = actorId.toLowerCase();
|
|
15
|
+
if (lower.includes('admin'))
|
|
16
|
+
return 'administrator';
|
|
17
|
+
if (lower.includes('owner'))
|
|
18
|
+
return 'owner';
|
|
19
|
+
if (lower.includes('operator'))
|
|
20
|
+
return 'operator';
|
|
21
|
+
if (lower.includes('viewer') || lower.includes('guest') || lower.includes('visitor'))
|
|
22
|
+
return 'viewer';
|
|
23
|
+
}
|
|
24
|
+
return 'owner'; // Default to single-owner role
|
|
25
|
+
}
|
|
5
26
|
class ActionPolicyEngine {
|
|
6
27
|
toolRegistry = new Map();
|
|
7
28
|
rules = [];
|
|
@@ -9,13 +30,18 @@ class ActionPolicyEngine {
|
|
|
9
30
|
defaultRiskLevel;
|
|
10
31
|
defaultRequireApprovalForHighRisk;
|
|
11
32
|
secretKey;
|
|
12
|
-
|
|
33
|
+
allowedApproverRoles;
|
|
34
|
+
requiredApproverCapabilities;
|
|
35
|
+
approvedExecutions = new Map();
|
|
36
|
+
pendingActions = new Map();
|
|
13
37
|
constructor(options = {}) {
|
|
14
38
|
this.rules = options.rules ?? [];
|
|
15
39
|
this.defaultRiskLevel = options.defaultRiskLevel ?? 'HIGH';
|
|
16
40
|
this.defaultRequireApprovalForHighRisk = options.defaultRequireApprovalForHighRisk ?? true;
|
|
17
41
|
this.store = options.store ?? new capability_1.InMemoryActionStore();
|
|
18
42
|
this.secretKey = (0, capability_1.getOrGenerateLocalActionPolicySecret)(options.secretKey);
|
|
43
|
+
this.allowedApproverRoles = (options.allowedApproverRoles ?? ['owner', 'administrator', 'admin', 'operator']).map((r) => r.toLowerCase());
|
|
44
|
+
this.requiredApproverCapabilities = options.requiredApproverCapabilities ?? [];
|
|
19
45
|
}
|
|
20
46
|
registerToolDefinition(tool) {
|
|
21
47
|
const key = tool.providerId ? `${tool.providerId}/${tool.name}` : tool.name;
|
|
@@ -125,14 +151,38 @@ class ActionPolicyEngine {
|
|
|
125
151
|
// Risk level approval check
|
|
126
152
|
const requiresExplicitApproval = toolDef.requiresApproval ??
|
|
127
153
|
(this.defaultRequireApprovalForHighRisk && (riskLevel === 'HIGH' || riskLevel === 'CRITICAL'));
|
|
128
|
-
let
|
|
154
|
+
let approvalRecord = this.approvedExecutions.get(executionId);
|
|
155
|
+
if (!approvalRecord && typeof this.store.getApproval === 'function') {
|
|
156
|
+
approvalRecord = await this.store.getApproval(executionId);
|
|
157
|
+
if (approvalRecord) {
|
|
158
|
+
this.approvedExecutions.set(executionId, approvalRecord);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
let isApproved = Boolean(approvalRecord);
|
|
129
162
|
if (!isApproved && typeof this.store.isActionApproved === 'function') {
|
|
130
163
|
isApproved = await this.store.isActionApproved(executionId);
|
|
131
|
-
|
|
132
|
-
|
|
164
|
+
}
|
|
165
|
+
if (requiresExplicitApproval && isApproved && approvalRecord) {
|
|
166
|
+
const approverRole = (approvalRecord.approverRole || normalizeApproverRole(undefined, approvalRecord.approverActorId)).toLowerCase();
|
|
167
|
+
const isOwnerOrAdmin = approverRole === 'owner' || approverRole === 'administrator' || approverRole === 'admin';
|
|
168
|
+
if (toolDef.allowedRoles && toolDef.allowedRoles.length > 0 && !isOwnerOrAdmin) {
|
|
169
|
+
const allowed = toolDef.allowedRoles.map((r) => r.toLowerCase());
|
|
170
|
+
if (!allowed.includes(approverRole)) {
|
|
171
|
+
const decision = {
|
|
172
|
+
allowed: false,
|
|
173
|
+
reason: `Approver "${approvalRecord.approverActorId}" with role "${approverRole}" is not authorized to approve tool "${action.toolName}"`,
|
|
174
|
+
riskLevel,
|
|
175
|
+
requiredCapabilities: requiredCaps,
|
|
176
|
+
executionId,
|
|
177
|
+
decisionCode: 'REJECTED_UNAUTHORIZED',
|
|
178
|
+
};
|
|
179
|
+
await this.recordAudit(action, effectiveContext, decision, 'REJECTED');
|
|
180
|
+
return { decision };
|
|
181
|
+
}
|
|
133
182
|
}
|
|
134
183
|
}
|
|
135
184
|
if (requiresExplicitApproval && !isApproved) {
|
|
185
|
+
this.pendingActions.set(executionId, { action, context: effectiveContext, toolDef });
|
|
136
186
|
const decision = {
|
|
137
187
|
allowed: false,
|
|
138
188
|
reason: `Action "${action.toolName}" has risk level ${riskLevel} and requires explicit approval`,
|
|
@@ -181,11 +231,121 @@ class ActionPolicyEngine {
|
|
|
181
231
|
return { decision, capability };
|
|
182
232
|
}
|
|
183
233
|
async approveAction(options) {
|
|
184
|
-
|
|
234
|
+
const actorId = (options.context?.actor.actorId || options.approverActorId || '').trim();
|
|
235
|
+
if (!actorId) {
|
|
236
|
+
return {
|
|
237
|
+
approved: false,
|
|
238
|
+
decisionCode: 'REJECTED_MISSING_APPROVER_ID',
|
|
239
|
+
reason: 'approverActorId is required to approve an action execution',
|
|
240
|
+
executionId: options.executionId,
|
|
241
|
+
approverActorId: '',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// 1. Authenticated boundary check
|
|
245
|
+
if (options.context && options.context.actor.authenticated === false) {
|
|
246
|
+
const result = {
|
|
247
|
+
approved: false,
|
|
248
|
+
decisionCode: 'REJECTED_UNAUTHENTICATED',
|
|
249
|
+
reason: `Approver "${actorId}" is unauthenticated and cannot approve actions`,
|
|
250
|
+
executionId: options.executionId,
|
|
251
|
+
approverActorId: actorId,
|
|
252
|
+
};
|
|
253
|
+
await this.recordApprovalAudit(options.executionId, actorId, 'unauthenticated', false, result.reason);
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
// 2. Resolve effective approver role & capabilities
|
|
257
|
+
const rawRole = options.context?.actor.authorizationRole || options.context?.actor?.role || options.approverRole;
|
|
258
|
+
const approverRole = normalizeApproverRole(rawRole, actorId);
|
|
259
|
+
const capabilities = options.context?.actor.capabilities || options.approverCapabilities || [];
|
|
260
|
+
const isOwnerOrAdmin = approverRole === 'owner' || approverRole === 'administrator' || approverRole === 'admin';
|
|
261
|
+
const isRoleAllowed = isOwnerOrAdmin || this.allowedApproverRoles.includes(approverRole);
|
|
262
|
+
// Reject non-allowed roles or explicit viewer/guest role
|
|
263
|
+
if (!isRoleAllowed || approverRole === 'viewer') {
|
|
264
|
+
const result = {
|
|
265
|
+
approved: false,
|
|
266
|
+
decisionCode: 'REJECTED_UNAUTHORIZED',
|
|
267
|
+
reason: `Actor "${actorId}" with role "${approverRole}" is not authorized to approve actions`,
|
|
268
|
+
executionId: options.executionId,
|
|
269
|
+
approverActorId: actorId,
|
|
270
|
+
};
|
|
271
|
+
await this.recordApprovalAudit(options.executionId, actorId, approverRole, false, result.reason);
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
// 3. Tool-specific authorization check if pending action is registered
|
|
275
|
+
const pending = this.pendingActions.get(options.executionId);
|
|
276
|
+
if (pending) {
|
|
277
|
+
const { toolDef } = pending;
|
|
278
|
+
if (toolDef.allowedRoles && toolDef.allowedRoles.length > 0 && !isOwnerOrAdmin) {
|
|
279
|
+
const normalizedToolRoles = new Set(toolDef.allowedRoles.map((r) => r.toLowerCase()));
|
|
280
|
+
if (!normalizedToolRoles.has(approverRole)) {
|
|
281
|
+
const result = {
|
|
282
|
+
approved: false,
|
|
283
|
+
decisionCode: 'REJECTED_ROLE_MISMATCH',
|
|
284
|
+
reason: `Approver role "${approverRole}" is not authorized to approve tool "${toolDef.name}" (requires: ${toolDef.allowedRoles.join(', ')})`,
|
|
285
|
+
executionId: options.executionId,
|
|
286
|
+
approverActorId: actorId,
|
|
287
|
+
};
|
|
288
|
+
await this.recordApprovalAudit(options.executionId, actorId, approverRole, false, result.reason, toolDef.name);
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (this.requiredApproverCapabilities.length > 0 && !isOwnerOrAdmin) {
|
|
293
|
+
const missing = this.requiredApproverCapabilities.filter((c) => !capabilities.includes(c));
|
|
294
|
+
if (missing.length > 0) {
|
|
295
|
+
const result = {
|
|
296
|
+
approved: false,
|
|
297
|
+
decisionCode: 'REJECTED_MISSING_CAPABILITY',
|
|
298
|
+
reason: `Approver is missing required approval capabilities: [${missing.join(', ')}]`,
|
|
299
|
+
executionId: options.executionId,
|
|
300
|
+
approverActorId: actorId,
|
|
301
|
+
};
|
|
302
|
+
await this.recordApprovalAudit(options.executionId, actorId, approverRole, false, result.reason, toolDef.name);
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// 4. Record verified approval
|
|
308
|
+
const record = {
|
|
309
|
+
executionId: options.executionId,
|
|
310
|
+
approverActorId: actorId,
|
|
311
|
+
reason: options.reason,
|
|
312
|
+
approverRole,
|
|
313
|
+
approvedAt: new Date().toISOString(),
|
|
314
|
+
};
|
|
315
|
+
this.approvedExecutions.set(options.executionId, record);
|
|
185
316
|
if (typeof this.store.saveApproval === 'function') {
|
|
186
|
-
await this.store.saveApproval(options.executionId,
|
|
317
|
+
await this.store.saveApproval(options.executionId, actorId, options.reason, approverRole);
|
|
187
318
|
}
|
|
188
|
-
|
|
319
|
+
const reason = options.reason || 'Action approved by authorized policy approver';
|
|
320
|
+
await this.recordApprovalAudit(options.executionId, actorId, approverRole, true, reason, pending?.toolDef.name);
|
|
321
|
+
return {
|
|
322
|
+
approved: true,
|
|
323
|
+
decisionCode: 'APPROVED',
|
|
324
|
+
reason,
|
|
325
|
+
executionId: options.executionId,
|
|
326
|
+
approverActorId: actorId,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async recordApprovalAudit(executionId, approverActorId, approverRole, approved, reason, toolName) {
|
|
330
|
+
const event = {
|
|
331
|
+
executionId,
|
|
332
|
+
actionId: executionId,
|
|
333
|
+
toolName: toolName || 'action:approve',
|
|
334
|
+
companionId: 'system',
|
|
335
|
+
actorId: approverActorId,
|
|
336
|
+
riskLevel: 'HIGH',
|
|
337
|
+
lifecycle: approved ? 'APPROVED' : 'REJECTED',
|
|
338
|
+
decision: {
|
|
339
|
+
allowed: approved,
|
|
340
|
+
reason: reason || (approved ? 'Approval granted' : 'Approval rejected'),
|
|
341
|
+
riskLevel: 'HIGH',
|
|
342
|
+
executionId,
|
|
343
|
+
decisionCode: approved ? 'ALLOWED_POLICY' : 'REJECTED_UNAUTHORIZED',
|
|
344
|
+
},
|
|
345
|
+
parametersHash: (0, capability_1.computeParametersHash)({ executionId, approverActorId, approverRole, reason }),
|
|
346
|
+
timestamp: new Date().toISOString(),
|
|
347
|
+
};
|
|
348
|
+
await this.store.appendAudit(event);
|
|
189
349
|
}
|
|
190
350
|
async recordAudit(action, context, decision, lifecycle, result, error, durationMs) {
|
|
191
351
|
const event = {
|
|
@@ -190,4 +190,108 @@ describe('ActionPolicyEngine Boundary', () => {
|
|
|
190
190
|
expect(resOp.decision.allowed).toBe(false);
|
|
191
191
|
expect(resOp.decision.decisionCode).toBe('REJECTED_UNAUTHORIZED');
|
|
192
192
|
});
|
|
193
|
+
describe('Approval Authorization Semantics', () => {
|
|
194
|
+
const criticalAction = {
|
|
195
|
+
actionId: 'act-sec-auth-1',
|
|
196
|
+
toolName: 'admin/delete_database',
|
|
197
|
+
parameters: {},
|
|
198
|
+
context: {
|
|
199
|
+
companionId: 'companion-1',
|
|
200
|
+
actor: {
|
|
201
|
+
actorId: 'admin-1',
|
|
202
|
+
sessionId: 'sess-1',
|
|
203
|
+
authorizationRole: 'administrator',
|
|
204
|
+
capabilities: ['admin:delete'],
|
|
205
|
+
authenticated: true,
|
|
206
|
+
},
|
|
207
|
+
conversation: { channel: 'direct', correlationId: 'corr-1' },
|
|
208
|
+
},
|
|
209
|
+
executionId: 'exec-sec-auth-1',
|
|
210
|
+
};
|
|
211
|
+
it('rejects action approval when approverActorId is empty', async () => {
|
|
212
|
+
await engine.evaluateAction(criticalAction);
|
|
213
|
+
const res = await engine.approveAction({
|
|
214
|
+
executionId: 'exec-sec-auth-1',
|
|
215
|
+
approverActorId: '',
|
|
216
|
+
});
|
|
217
|
+
expect(res.approved).toBe(false);
|
|
218
|
+
expect(res.decisionCode).toBe('REJECTED_MISSING_APPROVER_ID');
|
|
219
|
+
});
|
|
220
|
+
it('rejects action approval from unauthenticated approver context', async () => {
|
|
221
|
+
await engine.evaluateAction(criticalAction);
|
|
222
|
+
const res = await engine.approveAction({
|
|
223
|
+
executionId: 'exec-sec-auth-1',
|
|
224
|
+
approverActorId: 'fake-admin',
|
|
225
|
+
context: {
|
|
226
|
+
companionId: 'companion-1',
|
|
227
|
+
actor: {
|
|
228
|
+
actorId: 'fake-admin',
|
|
229
|
+
sessionId: 'sess-fake',
|
|
230
|
+
authorizationRole: 'administrator',
|
|
231
|
+
capabilities: ['admin:delete'],
|
|
232
|
+
authenticated: false, // Unauthenticated!
|
|
233
|
+
},
|
|
234
|
+
conversation: { channel: 'direct', correlationId: 'corr-fake' },
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
expect(res.approved).toBe(false);
|
|
238
|
+
expect(res.decisionCode).toBe('REJECTED_UNAUTHENTICATED');
|
|
239
|
+
});
|
|
240
|
+
it('rejects action approval from viewer role', async () => {
|
|
241
|
+
await engine.evaluateAction(criticalAction);
|
|
242
|
+
const res = await engine.approveAction({
|
|
243
|
+
executionId: 'exec-sec-auth-1',
|
|
244
|
+
approverActorId: 'viewer-user',
|
|
245
|
+
approverRole: 'viewer',
|
|
246
|
+
});
|
|
247
|
+
expect(res.approved).toBe(false);
|
|
248
|
+
expect(res.decisionCode).toBe('REJECTED_UNAUTHORIZED');
|
|
249
|
+
});
|
|
250
|
+
it('rejects approval when approver role does not match tool requirements (operator cannot approve admin tool)', async () => {
|
|
251
|
+
// 1. Initial evaluation stages pending execution
|
|
252
|
+
const eval1 = await engine.evaluateAction(criticalAction);
|
|
253
|
+
expect(eval1.decision.allowed).toBe(false);
|
|
254
|
+
expect(eval1.decision.decisionCode).toBe('REJECTED_HIGH_RISK_UNAPPROVED');
|
|
255
|
+
// 2. Operator attempts to approve an administrator-only tool
|
|
256
|
+
const approvalRes = await engine.approveAction({
|
|
257
|
+
executionId: 'exec-sec-auth-1',
|
|
258
|
+
approverActorId: 'operator-alice',
|
|
259
|
+
approverRole: 'operator',
|
|
260
|
+
});
|
|
261
|
+
expect(approvalRes.approved).toBe(false);
|
|
262
|
+
expect(approvalRes.decisionCode).toBe('REJECTED_ROLE_MISMATCH');
|
|
263
|
+
// 3. Action re-evaluation remains unapproved and denied
|
|
264
|
+
const eval2 = await engine.evaluateAction(criticalAction);
|
|
265
|
+
expect(eval2.decision.allowed).toBe(false);
|
|
266
|
+
expect(eval2.decision.decisionCode).toBe('REJECTED_HIGH_RISK_UNAPPROVED');
|
|
267
|
+
});
|
|
268
|
+
it('authorizes approval and emits capability when approver is administrator or owner', async () => {
|
|
269
|
+
await engine.evaluateAction(criticalAction);
|
|
270
|
+
const approvalRes = await engine.approveAction({
|
|
271
|
+
executionId: 'exec-sec-auth-1',
|
|
272
|
+
approverActorId: 'admin-bob',
|
|
273
|
+
approverRole: 'administrator',
|
|
274
|
+
reason: 'Authorized scheduled database purge',
|
|
275
|
+
});
|
|
276
|
+
expect(approvalRes.approved).toBe(true);
|
|
277
|
+
expect(approvalRes.decisionCode).toBe('APPROVED');
|
|
278
|
+
const evalApproved = await engine.evaluateAction(criticalAction);
|
|
279
|
+
expect(evalApproved.decision.allowed).toBe(true);
|
|
280
|
+
expect(evalApproved.decision.decisionCode).toBe('ALLOWED_POLICY');
|
|
281
|
+
expect(evalApproved.capability).toBeDefined();
|
|
282
|
+
});
|
|
283
|
+
it('records structured tamper-evident audit logs for approval decisions', async () => {
|
|
284
|
+
await engine.evaluateAction(criticalAction);
|
|
285
|
+
await engine.approveAction({
|
|
286
|
+
executionId: 'exec-sec-auth-1',
|
|
287
|
+
approverActorId: 'viewer-tamper',
|
|
288
|
+
approverRole: 'viewer',
|
|
289
|
+
});
|
|
290
|
+
const auditLogs = await engine.getAuditLog();
|
|
291
|
+
const rejectionEvent = auditLogs.find((l) => l.executionId === 'exec-sec-auth-1' && l.lifecycle === 'REJECTED' && l.toolName === 'action:approve');
|
|
292
|
+
expect(rejectionEvent).toBeDefined();
|
|
293
|
+
expect(rejectionEvent?.actorId).toBe('viewer-tamper');
|
|
294
|
+
expect(rejectionEvent?.decision?.decisionCode).toBe('REJECTED_UNAUTHORIZED');
|
|
295
|
+
});
|
|
296
|
+
});
|
|
193
297
|
});
|
package/dist/adversarial.test.js
CHANGED
|
@@ -356,7 +356,7 @@ describe('Adversarial Hardening Verification Suite (Phase 3)', () => {
|
|
|
356
356
|
const failingMemory = {
|
|
357
357
|
initialize: jest.fn().mockResolvedValue(undefined),
|
|
358
358
|
searchClaims: jest.fn().mockRejectedValue(new Error('Connection terminated unexpectedly')),
|
|
359
|
-
getDirectives: jest.fn().mockRejectedValue(new Error('
|
|
359
|
+
getDirectives: jest.fn().mockRejectedValue(new Error('Database read timeout')),
|
|
360
360
|
};
|
|
361
361
|
const runtime = new index_1.SiduriRuntime('companion-adv', { name: 'AdvCompanion' }, {
|
|
362
362
|
brain: mockBrain,
|
|
@@ -454,6 +454,7 @@ describe('Adversarial Hardening Verification Suite (Phase 3)', () => {
|
|
|
454
454
|
'riskLevel',
|
|
455
455
|
'lifecycle',
|
|
456
456
|
'parametersHash',
|
|
457
|
+
'resultHash',
|
|
457
458
|
];
|
|
458
459
|
for (const field of criticalFields) {
|
|
459
460
|
const tamperedEvent = { ...event1, [field]: 'TAMPERED_VALUE' };
|
|
@@ -477,12 +478,13 @@ describe('Adversarial Hardening Verification Suite (Phase 3)', () => {
|
|
|
477
478
|
decisionCode: tamperedEvent.decision.decisionCode,
|
|
478
479
|
} : null,
|
|
479
480
|
parametersHash: tamperedEvent.parametersHash || null,
|
|
481
|
+
resultHash: field === 'resultHash' ? 'TAMPERED_VALUE' : null,
|
|
480
482
|
error: tamperedEvent.error || null,
|
|
481
483
|
timestamp: tamperedEvent.timestamp,
|
|
482
484
|
});
|
|
483
485
|
const crypto = require('node:crypto');
|
|
484
486
|
const brokenHash1 = crypto.createHash('sha256').update(`${initialPrevHash}:${canonical}`, 'utf8').digest('hex');
|
|
485
|
-
expect(brokenHash1).not.toBe(event1.
|
|
487
|
+
expect(brokenHash1).not.toBe(event1.eventHash);
|
|
486
488
|
}
|
|
487
489
|
});
|
|
488
490
|
});
|
package/dist/capability.d.ts
CHANGED
|
@@ -37,12 +37,20 @@ export interface PersistentExecutionRecord {
|
|
|
37
37
|
createdAt: string;
|
|
38
38
|
updatedAt: string;
|
|
39
39
|
}
|
|
40
|
+
export interface ActionApprovalRecord {
|
|
41
|
+
executionId: string;
|
|
42
|
+
approverActorId: string;
|
|
43
|
+
reason?: string;
|
|
44
|
+
approvedAt: string;
|
|
45
|
+
approverRole?: string;
|
|
46
|
+
}
|
|
40
47
|
export interface ActionStore {
|
|
41
48
|
reserveExecution(record: PersistentExecutionRecord): Promise<boolean>;
|
|
42
49
|
updateExecution(record: PersistentExecutionRecord): Promise<void>;
|
|
43
50
|
getExecution(executionId: string): Promise<PersistentExecutionRecord | undefined>;
|
|
44
|
-
saveApproval(executionId: string, approverActorId: string, reason?: string): Promise<void>;
|
|
51
|
+
saveApproval(executionId: string, approverActorId: string, reason?: string, approverRole?: string): Promise<void>;
|
|
45
52
|
isActionApproved(executionId: string): Promise<boolean>;
|
|
53
|
+
getApproval?(executionId: string): Promise<ActionApprovalRecord | undefined>;
|
|
46
54
|
appendAudit(event: ActionAuditEvent): Promise<void>;
|
|
47
55
|
getAuditLog(executionId?: string): Promise<ActionAuditEvent[]>;
|
|
48
56
|
}
|
|
@@ -54,8 +62,9 @@ export declare class InMemoryActionStore implements ActionStore {
|
|
|
54
62
|
reserveExecution(record: PersistentExecutionRecord): Promise<boolean>;
|
|
55
63
|
updateExecution(record: PersistentExecutionRecord): Promise<void>;
|
|
56
64
|
getExecution(executionId: string): Promise<PersistentExecutionRecord | undefined>;
|
|
57
|
-
saveApproval(executionId: string, approverActorId: string, reason?: string): Promise<void>;
|
|
65
|
+
saveApproval(executionId: string, approverActorId: string, reason?: string, approverRole?: string): Promise<void>;
|
|
58
66
|
isActionApproved(executionId: string): Promise<boolean>;
|
|
67
|
+
getApproval(executionId: string): Promise<ActionApprovalRecord | undefined>;
|
|
59
68
|
appendAudit(event: ActionAuditEvent): Promise<void>;
|
|
60
69
|
getAuditLog(executionId?: string): Promise<ActionAuditEvent[]>;
|
|
61
70
|
}
|
package/dist/capability.js
CHANGED
|
@@ -53,16 +53,22 @@ class InMemoryActionStore {
|
|
|
53
53
|
const rec = this.executions.get(executionId);
|
|
54
54
|
return rec ? { ...rec } : undefined;
|
|
55
55
|
}
|
|
56
|
-
async saveApproval(executionId, approverActorId, reason) {
|
|
56
|
+
async saveApproval(executionId, approverActorId, reason, approverRole) {
|
|
57
57
|
this.approvals.set(executionId, {
|
|
58
|
+
executionId,
|
|
58
59
|
approverActorId,
|
|
59
60
|
reason,
|
|
61
|
+
approverRole,
|
|
60
62
|
approvedAt: new Date().toISOString(),
|
|
61
63
|
});
|
|
62
64
|
}
|
|
63
65
|
async isActionApproved(executionId) {
|
|
64
66
|
return this.approvals.has(executionId);
|
|
65
67
|
}
|
|
68
|
+
async getApproval(executionId) {
|
|
69
|
+
const record = this.approvals.get(executionId);
|
|
70
|
+
return record ? { ...record } : undefined;
|
|
71
|
+
}
|
|
66
72
|
async appendAudit(event) {
|
|
67
73
|
const prevHash = this.lastAuditHash;
|
|
68
74
|
// Tamper-evident hash chaining over all security-critical event fields
|
|
@@ -85,6 +91,7 @@ class InMemoryActionStore {
|
|
|
85
91
|
decisionCode: event.decision.decisionCode,
|
|
86
92
|
} : null,
|
|
87
93
|
parametersHash: event.parametersHash || null,
|
|
94
|
+
resultHash: event.resultHash || null,
|
|
88
95
|
error: event.error || null,
|
|
89
96
|
timestamp: event.timestamp,
|
|
90
97
|
};
|
package/dist/capability.test.js
CHANGED
|
@@ -165,6 +165,7 @@ describe('AuthorizationCapability Cryptographic & Tamper Review', () => {
|
|
|
165
165
|
decisionCode: event1.decision.decisionCode,
|
|
166
166
|
} : null,
|
|
167
167
|
parametersHash: event1.parametersHash || null,
|
|
168
|
+
resultHash: null,
|
|
168
169
|
error: event1.error || null,
|
|
169
170
|
timestamp: event1.timestamp,
|
|
170
171
|
});
|
|
@@ -190,6 +191,7 @@ describe('AuthorizationCapability Cryptographic & Tamper Review', () => {
|
|
|
190
191
|
decisionCode: event2.decision.decisionCode,
|
|
191
192
|
} : null,
|
|
192
193
|
parametersHash: event2.parametersHash || null,
|
|
194
|
+
resultHash: null,
|
|
193
195
|
error: event2.error || null,
|
|
194
196
|
timestamp: event2.timestamp,
|
|
195
197
|
});
|
|
@@ -203,7 +205,47 @@ describe('AuthorizationCapability Cryptographic & Tamper Review', () => {
|
|
|
203
205
|
});
|
|
204
206
|
const tamperedHash1 = crypto.createHash('sha256').update(`${initialPrevHash}:${tamperedCanonical1}`, 'utf8').digest('hex');
|
|
205
207
|
const brokenHash2 = crypto.createHash('sha256').update(`${tamperedHash1}:${canonical2}`, 'utf8').digest('hex');
|
|
206
|
-
expect(brokenHash2).not.toBe(event2.
|
|
208
|
+
expect(brokenHash2).not.toBe(event2.eventHash);
|
|
209
|
+
});
|
|
210
|
+
it('detects tampering with resultHash on completed action events', async () => {
|
|
211
|
+
const store = new capability_1.InMemoryActionStore();
|
|
212
|
+
const initialPrevHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
213
|
+
const event = {
|
|
214
|
+
executionId: 'exec-1',
|
|
215
|
+
actionId: 'act-1',
|
|
216
|
+
toolName: 'comm/send_email',
|
|
217
|
+
companionId: 'comp-1',
|
|
218
|
+
riskLevel: 'LOW',
|
|
219
|
+
lifecycle: 'COMPLETED',
|
|
220
|
+
parametersHash: 'param-hash-1',
|
|
221
|
+
resultHash: 'result-hash-original',
|
|
222
|
+
timestamp: '2026-09-12T10:00:00.000Z',
|
|
223
|
+
};
|
|
224
|
+
await store.appendAudit(event);
|
|
225
|
+
const log = await store.getAuditLog();
|
|
226
|
+
const recorded = log[0];
|
|
227
|
+
// Tampering with resultHash changes canonical payload and recalculates a mismatched hash
|
|
228
|
+
const tamperedPayload = {
|
|
229
|
+
executionId: recorded.executionId,
|
|
230
|
+
actionId: recorded.actionId,
|
|
231
|
+
toolName: recorded.toolName,
|
|
232
|
+
providerId: recorded.providerId || null,
|
|
233
|
+
companionId: recorded.companionId,
|
|
234
|
+
actorId: recorded.actorId || null,
|
|
235
|
+
sessionId: recorded.sessionId || null,
|
|
236
|
+
channel: recorded.channel || null,
|
|
237
|
+
correlationId: recorded.correlationId || null,
|
|
238
|
+
riskLevel: recorded.riskLevel,
|
|
239
|
+
lifecycle: recorded.lifecycle,
|
|
240
|
+
decision: null,
|
|
241
|
+
parametersHash: recorded.parametersHash || null,
|
|
242
|
+
resultHash: 'result-hash-TAMPERED',
|
|
243
|
+
error: recorded.error || null,
|
|
244
|
+
timestamp: recorded.timestamp,
|
|
245
|
+
};
|
|
246
|
+
const tamperedCanonical = (0, capability_1.canonicalizeJson)(tamperedPayload);
|
|
247
|
+
const recomputedHash = crypto.createHash('sha256').update(`${initialPrevHash}:${tamperedCanonical}`, 'utf8').digest('hex');
|
|
248
|
+
expect(recomputedHash).not.toBe(recorded.eventHash);
|
|
207
249
|
});
|
|
208
250
|
});
|
|
209
251
|
describe('Durable Action Approval Restart Semantics', () => {
|
package/dist/index.d.ts
CHANGED
|
@@ -124,13 +124,15 @@ export interface MemoryOrgan {
|
|
|
124
124
|
markClaimSessionOnly?(id: string): Promise<void>;
|
|
125
125
|
expireClaim?(id: string): Promise<void>;
|
|
126
126
|
revokeClaim?(id: string, reason?: string): Promise<void>;
|
|
127
|
-
getDirectives(): Promise<BehaviorDirective[]>;
|
|
128
|
-
proposeDirective(directiveData: Omit<BehaviorDirective, 'id' | 'status' | 'companionId'>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
getDirectives(companionId?: string): Promise<BehaviorDirective[]>;
|
|
128
|
+
proposeDirective(directiveData: Omit<BehaviorDirective, 'id' | 'status' | 'companionId'> & {
|
|
129
|
+
companionId?: string;
|
|
130
|
+
}): Promise<BehaviorDirective>;
|
|
131
|
+
approveDirective(id: string, companionId?: string): Promise<void>;
|
|
132
|
+
rejectDirective(id: string, companionId?: string): Promise<void>;
|
|
133
|
+
revokeDirective(id: string, companionId?: string): Promise<void>;
|
|
134
|
+
disableDirective(id: string, companionId?: string): Promise<void>;
|
|
135
|
+
expireDirective?(id: string, companionId?: string): Promise<void>;
|
|
134
136
|
supersedeClaim?(id: string, replacement: Omit<Claim, 'id' | 'status' | 'companionId'>): Promise<Claim>;
|
|
135
137
|
updateClaim?(id: string, updates: Partial<Pick<Claim, 'subject' | 'predicate' | 'value' | 'scope' | 'sensitivity' | 'confidence' | 'validFrom' | 'validUntil'>>): Promise<Claim>;
|
|
136
138
|
resetMemory?(): Promise<void>;
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BrainOrgan, MemoryOrgan, VoiceOrgan, KnowledgeOrgan, VisionOrgan, BehaviorOrgan, BodyOrgan, HandsOrgan, EarOrgan, ObservationOrgan, ObservationResult, Observation, Message, RequestContext, ActionPolicyEngine, ActionStore, ResponseGatingEngine, StageResponseOptions, ApproveResponseOptions, RejectResponseOptions, ExperienceDispatcher, ExperienceAdapter, OrganConfig, Claim, BehaviorDirective, StagedResponsePlan, ResponseGateEvaluation, EvidenceRecord, MouthOrgan, MouthUtterance, MouthMedium, FormattedMouthOutput, MouthStreamChunk, MouthChannel, SelfRepository, EKnowledgeOrgan } from './index';
|
|
1
|
+
import { BrainOrgan, MemoryOrgan, VoiceOrgan, KnowledgeOrgan, VisionOrgan, BehaviorOrgan, BodyOrgan, HandsOrgan, EarOrgan, ObservationOrgan, ObservationResult, Observation, Message, RequestContext, ActionPolicyEngine, ApproveActionOptions, ActionApprovalResult, ActionStore, ResponseGatingEngine, StageResponseOptions, ApproveResponseOptions, RejectResponseOptions, ExperienceDispatcher, ExperienceAdapter, OrganConfig, Claim, BehaviorDirective, StagedResponsePlan, ResponseGateEvaluation, EvidenceRecord, MouthOrgan, MouthUtterance, MouthMedium, FormattedMouthOutput, MouthStreamChunk, MouthChannel, SelfRepository, EKnowledgeOrgan } from './index';
|
|
2
2
|
export interface SiduriRuntimeConfig {
|
|
3
3
|
name: string;
|
|
4
4
|
brain?: OrganConfig | Record<string, unknown>;
|
|
@@ -91,10 +91,10 @@ export declare class SiduriRuntime {
|
|
|
91
91
|
approveClaim(id: string): Promise<void>;
|
|
92
92
|
rejectClaim(id: string): Promise<void>;
|
|
93
93
|
updateClaim(id: string, updates: Partial<Pick<Claim, 'subject' | 'predicate' | 'value' | 'scope' | 'sensitivity' | 'confidence' | 'validFrom' | 'validUntil'>>): Promise<Claim>;
|
|
94
|
-
approveDirective(id: string): Promise<void>;
|
|
95
|
-
rejectDirective(id: string): Promise<void>;
|
|
96
|
-
revokeDirective(id: string): Promise<void>;
|
|
97
|
-
disableDirective(id: string): Promise<void>;
|
|
94
|
+
approveDirective(id: string, companionId?: string): Promise<void>;
|
|
95
|
+
rejectDirective(id: string, companionId?: string): Promise<void>;
|
|
96
|
+
revokeDirective(id: string, companionId?: string): Promise<void>;
|
|
97
|
+
disableDirective(id: string, companionId?: string): Promise<void>;
|
|
98
98
|
resetMemory(): Promise<void>;
|
|
99
99
|
stageResponse(options: StageResponseOptions): StagedResponsePlan;
|
|
100
100
|
evaluateGate(plan: StagedResponsePlan, evidenceRecords?: EvidenceRecord[]): ResponseGateEvaluation;
|
|
@@ -108,6 +108,7 @@ export declare class SiduriRuntime {
|
|
|
108
108
|
reason?: string;
|
|
109
109
|
plan?: StagedResponsePlan;
|
|
110
110
|
};
|
|
111
|
+
approveAction(options: ApproveActionOptions): Promise<ActionApprovalResult>;
|
|
111
112
|
getStagedPlan(responseId: string): StagedResponsePlan | undefined;
|
|
112
113
|
findStagedPlanByCorrelation(companionId: string, correlationId: string): StagedResponsePlan | undefined;
|
|
113
114
|
/**
|
package/dist/runtime.js
CHANGED
|
@@ -163,29 +163,37 @@ class SiduriRuntime {
|
|
|
163
163
|
}
|
|
164
164
|
return this.memory.updateClaim(id, updates);
|
|
165
165
|
}
|
|
166
|
-
async approveDirective(id) {
|
|
166
|
+
async approveDirective(id, companionId) {
|
|
167
167
|
if (!this.memory || typeof this.memory.approveDirective !== 'function') {
|
|
168
168
|
throw new Error('Memory organ not configured');
|
|
169
169
|
}
|
|
170
|
-
return
|
|
170
|
+
return companionId !== undefined
|
|
171
|
+
? this.memory.approveDirective(id, companionId)
|
|
172
|
+
: this.memory.approveDirective(id);
|
|
171
173
|
}
|
|
172
|
-
async rejectDirective(id) {
|
|
174
|
+
async rejectDirective(id, companionId) {
|
|
173
175
|
if (!this.memory || typeof this.memory.rejectDirective !== 'function') {
|
|
174
176
|
throw new Error('Memory organ not configured');
|
|
175
177
|
}
|
|
176
|
-
return
|
|
178
|
+
return companionId !== undefined
|
|
179
|
+
? this.memory.rejectDirective(id, companionId)
|
|
180
|
+
: this.memory.rejectDirective(id);
|
|
177
181
|
}
|
|
178
|
-
async revokeDirective(id) {
|
|
182
|
+
async revokeDirective(id, companionId) {
|
|
179
183
|
if (!this.memory || typeof this.memory.revokeDirective !== 'function') {
|
|
180
184
|
throw new Error('Memory organ not configured');
|
|
181
185
|
}
|
|
182
|
-
return
|
|
186
|
+
return companionId !== undefined
|
|
187
|
+
? this.memory.revokeDirective(id, companionId)
|
|
188
|
+
: this.memory.revokeDirective(id);
|
|
183
189
|
}
|
|
184
|
-
async disableDirective(id) {
|
|
190
|
+
async disableDirective(id, companionId) {
|
|
185
191
|
if (!this.memory || typeof this.memory.disableDirective !== 'function') {
|
|
186
192
|
throw new Error('Memory organ not configured');
|
|
187
193
|
}
|
|
188
|
-
return
|
|
194
|
+
return companionId !== undefined
|
|
195
|
+
? this.memory.disableDirective(id, companionId)
|
|
196
|
+
: this.memory.disableDirective(id);
|
|
189
197
|
}
|
|
190
198
|
async resetMemory() {
|
|
191
199
|
if (!this.memory || typeof this.memory.resetMemory !== 'function') {
|
|
@@ -206,6 +214,9 @@ class SiduriRuntime {
|
|
|
206
214
|
rejectResponse(options) {
|
|
207
215
|
return this.gating.rejectResponse(options);
|
|
208
216
|
}
|
|
217
|
+
async approveAction(options) {
|
|
218
|
+
return this.actionPolicy.approveAction(options);
|
|
219
|
+
}
|
|
209
220
|
getStagedPlan(responseId) {
|
|
210
221
|
return this.gating.getStagedPlan(responseId);
|
|
211
222
|
}
|