@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.
@@ -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<boolean>;
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;
@@ -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
- approvedExecutions = new Set();
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 isApproved = this.approvedExecutions.has(executionId);
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
- if (isApproved) {
132
- this.approvedExecutions.add(executionId);
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
- this.approvedExecutions.add(options.executionId);
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, options.approverActorId, options.reason);
317
+ await this.store.saveApproval(options.executionId, actorId, options.reason, approverRole);
187
318
  }
188
- return true;
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
  });
@@ -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('PostgreSQL read timeout')),
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.resultHash);
487
+ expect(brokenHash1).not.toBe(event1.eventHash);
486
488
  }
487
489
  });
488
490
  });
@@ -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
  }
@@ -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
  };
@@ -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.resultHash);
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'>): Promise<BehaviorDirective>;
129
- approveDirective(id: string): Promise<void>;
130
- rejectDirective(id: string): Promise<void>;
131
- revokeDirective(id: string): Promise<void>;
132
- disableDirective(id: string): Promise<void>;
133
- expireDirective?(id: string): Promise<void>;
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 this.memory.approveDirective(id);
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 this.memory.rejectDirective(id);
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 this.memory.revokeDirective(id);
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 this.memory.disableDirective(id);
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
  }