@siduri-x/core 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,12 +15,42 @@ 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' | 'REJECTED_UNKNOWN_EXECUTION';
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[];
45
+ /**
46
+ * Optional explicit action binding parameters when pre-authorizing a known action
47
+ */
48
+ toolName?: string;
49
+ parametersHash?: string;
50
+ companionId?: string;
51
+ actorId?: string;
23
52
  }
53
+ export declare function normalizeApproverRole(role?: string, actorId?: string): string;
24
54
  export declare class ActionPolicyEngine {
25
55
  private readonly toolRegistry;
26
56
  private readonly rules;
@@ -28,7 +58,10 @@ export declare class ActionPolicyEngine {
28
58
  private readonly defaultRiskLevel;
29
59
  private readonly defaultRequireApprovalForHighRisk;
30
60
  private readonly secretKey;
61
+ private readonly allowedApproverRoles;
62
+ private readonly requiredApproverCapabilities;
31
63
  private readonly approvedExecutions;
64
+ private readonly pendingActions;
32
65
  constructor(options?: ActionPolicyEngineOptions);
33
66
  registerToolDefinition(tool: ToolDefinition): void;
34
67
  unregisterToolDefinition(toolName: string): boolean;
@@ -38,7 +71,8 @@ export declare class ActionPolicyEngine {
38
71
  decision: ActionPolicyDecision;
39
72
  capability?: AuthorizationCapability;
40
73
  }>;
41
- approveAction(options: ApproveActionOptions): Promise<boolean>;
74
+ approveAction(options: ApproveActionOptions): Promise<ActionApprovalResult>;
75
+ private recordApprovalAudit;
42
76
  recordAudit(action: ActionIntent, context: RequestContext | undefined, decision: ActionPolicyDecision | undefined, lifecycle: ActionLifecycleState, result?: unknown, error?: string, durationMs?: number): Promise<ActionAuditEvent>;
43
77
  getAuditLog(): Promise<ActionAuditEvent[]>;
44
78
  getStore(): ActionStore;
@@ -1,7 +1,20 @@
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
+ // Principle of least privilege: Never derive privileged roles (administrator, owner, operator)
14
+ // from arbitrary substring matching on unverified actorId strings.
15
+ // When an explicit authorization role is not provided, default to 'viewer'.
16
+ return 'viewer';
17
+ }
5
18
  class ActionPolicyEngine {
6
19
  toolRegistry = new Map();
7
20
  rules = [];
@@ -9,13 +22,18 @@ class ActionPolicyEngine {
9
22
  defaultRiskLevel;
10
23
  defaultRequireApprovalForHighRisk;
11
24
  secretKey;
12
- approvedExecutions = new Set();
25
+ allowedApproverRoles;
26
+ requiredApproverCapabilities;
27
+ approvedExecutions = new Map();
28
+ pendingActions = new Map();
13
29
  constructor(options = {}) {
14
30
  this.rules = options.rules ?? [];
15
31
  this.defaultRiskLevel = options.defaultRiskLevel ?? 'HIGH';
16
32
  this.defaultRequireApprovalForHighRisk = options.defaultRequireApprovalForHighRisk ?? true;
17
33
  this.store = options.store ?? new capability_1.InMemoryActionStore();
18
34
  this.secretKey = (0, capability_1.getOrGenerateLocalActionPolicySecret)(options.secretKey);
35
+ this.allowedApproverRoles = (options.allowedApproverRoles ?? ['owner', 'administrator', 'admin', 'operator']).map((r) => r.toLowerCase());
36
+ this.requiredApproverCapabilities = options.requiredApproverCapabilities ?? [];
19
37
  }
20
38
  registerToolDefinition(tool) {
21
39
  const key = tool.providerId ? `${tool.providerId}/${tool.name}` : tool.name;
@@ -70,7 +88,9 @@ class ActionPolicyEngine {
70
88
  }
71
89
  // Role check if tool restricts roles (supports administrator/owner role parity)
72
90
  if (toolDef.allowedRoles && toolDef.allowedRoles.length > 0) {
73
- const actorRole = effectiveContext.actor.authorizationRole || effectiveContext.actor.role || 'owner';
91
+ const actorRole = effectiveContext.actor.authorizationRole ||
92
+ effectiveContext.actor.role ||
93
+ (effectiveContext.actor.authenticated === false ? 'viewer' : 'owner');
74
94
  const normalizedActorRoles = new Set([actorRole.toLowerCase()]);
75
95
  if (actorRole.toLowerCase() === 'administrator' || actorRole.toLowerCase() === 'owner') {
76
96
  normalizedActorRoles.add('administrator');
@@ -125,14 +145,79 @@ class ActionPolicyEngine {
125
145
  // Risk level approval check
126
146
  const requiresExplicitApproval = toolDef.requiresApproval ??
127
147
  (this.defaultRequireApprovalForHighRisk && (riskLevel === 'HIGH' || riskLevel === 'CRITICAL'));
128
- let isApproved = this.approvedExecutions.has(executionId);
148
+ let approvalRecord = this.approvedExecutions.get(executionId);
149
+ if (!approvalRecord && typeof this.store.getApproval === 'function') {
150
+ approvalRecord = await this.store.getApproval(executionId);
151
+ if (approvalRecord) {
152
+ this.approvedExecutions.set(executionId, approvalRecord);
153
+ }
154
+ }
155
+ // Cryptographic / structural binding verification:
156
+ // Ensure the approval record matches the exact tool, parameters, companion, and actor
157
+ if (approvalRecord) {
158
+ const toolMatch = !approvalRecord.toolName || approvalRecord.toolName === action.toolName;
159
+ const paramsMatch = !approvalRecord.parametersHash || approvalRecord.parametersHash === paramsHash;
160
+ const companionMatch = !approvalRecord.companionId || approvalRecord.companionId === effectiveContext.companionId;
161
+ const actorMatch = !approvalRecord.actorId || approvalRecord.actorId === effectiveContext.actor.actorId;
162
+ if (!toolMatch || !paramsMatch || !companionMatch || !actorMatch) {
163
+ const decision = {
164
+ allowed: false,
165
+ reason: `Approval record for execution "${executionId}" does not match the requested tool, parameters, companion, or actor`,
166
+ riskLevel,
167
+ requiredCapabilities: requiredCaps,
168
+ executionId,
169
+ decisionCode: 'REJECTED_APPROVAL_MISMATCH',
170
+ };
171
+ await this.recordAudit(action, effectiveContext, decision, 'REJECTED');
172
+ return { decision };
173
+ }
174
+ }
175
+ let isApproved = Boolean(approvalRecord);
129
176
  if (!isApproved && typeof this.store.isActionApproved === 'function') {
130
177
  isApproved = await this.store.isActionApproved(executionId);
131
- if (isApproved) {
132
- this.approvedExecutions.add(executionId);
178
+ }
179
+ if (requiresExplicitApproval && isApproved && approvalRecord) {
180
+ const approverRole = (approvalRecord.approverRole || normalizeApproverRole(undefined, approvalRecord.approverActorId)).toLowerCase();
181
+ const isOwnerOrAdmin = approverRole === 'owner' || approverRole === 'administrator' || approverRole === 'admin';
182
+ if (toolDef.allowedRoles && toolDef.allowedRoles.length > 0 && !isOwnerOrAdmin) {
183
+ const allowed = toolDef.allowedRoles.map((r) => r.toLowerCase());
184
+ if (!allowed.includes(approverRole)) {
185
+ const decision = {
186
+ allowed: false,
187
+ reason: `Approver "${approvalRecord.approverActorId}" with role "${approverRole}" is not authorized to approve tool "${action.toolName}"`,
188
+ riskLevel,
189
+ requiredCapabilities: requiredCaps,
190
+ executionId,
191
+ decisionCode: 'REJECTED_UNAUTHORIZED',
192
+ };
193
+ await this.recordAudit(action, effectiveContext, decision, 'REJECTED');
194
+ return { decision };
195
+ }
133
196
  }
134
197
  }
135
198
  if (requiresExplicitApproval && !isApproved) {
199
+ this.pendingActions.set(executionId, { action, context: effectiveContext, toolDef });
200
+ if (typeof this.store.reserveExecution === 'function') {
201
+ const record = {
202
+ executionId,
203
+ actionId: action.actionId,
204
+ toolName: action.toolName,
205
+ providerId: toolDef.providerId || 'builtin',
206
+ parametersHash: paramsHash,
207
+ lifecycle: 'POLICY_CHECKED',
208
+ decision: {
209
+ allowed: false,
210
+ reason: `Action "${action.toolName}" has risk level ${riskLevel} and requires explicit approval`,
211
+ riskLevel,
212
+ requiredCapabilities: requiredCaps,
213
+ executionId,
214
+ decisionCode: 'REJECTED_HIGH_RISK_UNAPPROVED',
215
+ },
216
+ createdAt: new Date().toISOString(),
217
+ updatedAt: new Date().toISOString(),
218
+ };
219
+ await this.store.reserveExecution(record).catch(() => { });
220
+ }
136
221
  const decision = {
137
222
  allowed: false,
138
223
  reason: `Action "${action.toolName}" has risk level ${riskLevel} and requires explicit approval`,
@@ -181,11 +266,146 @@ class ActionPolicyEngine {
181
266
  return { decision, capability };
182
267
  }
183
268
  async approveAction(options) {
184
- this.approvedExecutions.add(options.executionId);
269
+ const actorId = (options.context?.actor.actorId || options.approverActorId || '').trim();
270
+ if (!actorId) {
271
+ return {
272
+ approved: false,
273
+ decisionCode: 'REJECTED_MISSING_APPROVER_ID',
274
+ reason: 'approverActorId is required to approve an action execution',
275
+ executionId: options.executionId,
276
+ approverActorId: '',
277
+ };
278
+ }
279
+ // 1. Authenticated boundary check
280
+ if (options.context && options.context.actor.authenticated === false) {
281
+ const result = {
282
+ approved: false,
283
+ decisionCode: 'REJECTED_UNAUTHENTICATED',
284
+ reason: `Approver "${actorId}" is unauthenticated and cannot approve actions`,
285
+ executionId: options.executionId,
286
+ approverActorId: actorId,
287
+ };
288
+ await this.recordApprovalAudit(options.executionId, actorId, 'unauthenticated', false, result.reason);
289
+ return result;
290
+ }
291
+ // 2. Resolve effective approver role & capabilities
292
+ const rawRole = options.context?.actor.authorizationRole || options.context?.actor?.role || options.approverRole;
293
+ const approverRole = normalizeApproverRole(rawRole, actorId);
294
+ const capabilities = options.context?.actor.capabilities || options.approverCapabilities || [];
295
+ const isOwnerOrAdmin = approverRole === 'owner' || approverRole === 'administrator' || approverRole === 'admin';
296
+ const isRoleAllowed = isOwnerOrAdmin || this.allowedApproverRoles.includes(approverRole);
297
+ // Reject non-allowed roles or explicit viewer/guest role
298
+ if (!isRoleAllowed || approverRole === 'viewer') {
299
+ const result = {
300
+ approved: false,
301
+ decisionCode: 'REJECTED_UNAUTHORIZED',
302
+ reason: `Actor "${actorId}" with role "${approverRole}" is not authorized to approve actions`,
303
+ executionId: options.executionId,
304
+ approverActorId: actorId,
305
+ };
306
+ await this.recordApprovalAudit(options.executionId, actorId, approverRole, false, result.reason);
307
+ return result;
308
+ }
309
+ // 3. Locate pending in-memory action or persisted execution record
310
+ const pending = this.pendingActions.get(options.executionId);
311
+ let executionRecord;
312
+ if (!pending && typeof this.store.getExecution === 'function') {
313
+ executionRecord = await this.store.getExecution(options.executionId);
314
+ }
315
+ if (!pending && !executionRecord && !options.toolName) {
316
+ const result = {
317
+ approved: false,
318
+ decisionCode: 'REJECTED_UNKNOWN_EXECUTION',
319
+ reason: `Cannot approve execution "${options.executionId}": execution is not pending or recognized`,
320
+ executionId: options.executionId,
321
+ approverActorId: actorId,
322
+ };
323
+ await this.recordApprovalAudit(options.executionId, actorId, approverRole, false, result.reason);
324
+ return result;
325
+ }
326
+ const targetToolName = pending?.action.toolName || executionRecord?.toolName || options.toolName || '';
327
+ const targetToolDef = pending?.toolDef || (targetToolName ? this.findToolDefinition(targetToolName) : undefined);
328
+ const targetParamsHash = (pending ? (0, capability_1.computeParametersHash)(pending.action.parameters) : undefined) ||
329
+ executionRecord?.parametersHash ||
330
+ options.parametersHash;
331
+ const targetCompanionId = pending?.context?.companionId || pending?.action.context?.companionId || options.companionId;
332
+ const targetActorId = pending?.context?.actor.actorId || pending?.action.context?.actor.actorId || options.actorId;
333
+ if (targetToolDef) {
334
+ if (targetToolDef.allowedRoles && targetToolDef.allowedRoles.length > 0 && !isOwnerOrAdmin) {
335
+ const normalizedToolRoles = new Set(targetToolDef.allowedRoles.map((r) => r.toLowerCase()));
336
+ if (!normalizedToolRoles.has(approverRole)) {
337
+ const result = {
338
+ approved: false,
339
+ decisionCode: 'REJECTED_ROLE_MISMATCH',
340
+ reason: `Approver role "${approverRole}" is not authorized to approve tool "${targetToolDef.name}" (requires: ${targetToolDef.allowedRoles.join(', ')})`,
341
+ executionId: options.executionId,
342
+ approverActorId: actorId,
343
+ };
344
+ await this.recordApprovalAudit(options.executionId, actorId, approverRole, false, result.reason, targetToolDef.name);
345
+ return result;
346
+ }
347
+ }
348
+ if (this.requiredApproverCapabilities.length > 0 && !isOwnerOrAdmin) {
349
+ const missing = this.requiredApproverCapabilities.filter((c) => !capabilities.includes(c));
350
+ if (missing.length > 0) {
351
+ const result = {
352
+ approved: false,
353
+ decisionCode: 'REJECTED_MISSING_CAPABILITY',
354
+ reason: `Approver is missing required approval capabilities: [${missing.join(', ')}]`,
355
+ executionId: options.executionId,
356
+ approverActorId: actorId,
357
+ };
358
+ await this.recordApprovalAudit(options.executionId, actorId, approverRole, false, result.reason, targetToolDef.name);
359
+ return result;
360
+ }
361
+ }
362
+ }
363
+ // 4. Record verified approval with exact tool, parameter, companion, and actor binding
364
+ const record = {
365
+ executionId: options.executionId,
366
+ approverActorId: actorId,
367
+ reason: options.reason,
368
+ approverRole,
369
+ approvedAt: new Date().toISOString(),
370
+ toolName: targetToolName,
371
+ parametersHash: targetParamsHash,
372
+ companionId: targetCompanionId,
373
+ actorId: targetActorId,
374
+ };
375
+ this.approvedExecutions.set(options.executionId, record);
185
376
  if (typeof this.store.saveApproval === 'function') {
186
- await this.store.saveApproval(options.executionId, options.approverActorId, options.reason);
377
+ await this.store.saveApproval(options.executionId, actorId, options.reason, approverRole, targetToolName, targetParamsHash, targetCompanionId, targetActorId);
187
378
  }
188
- return true;
379
+ const reason = options.reason || 'Action approved by authorized policy approver';
380
+ await this.recordApprovalAudit(options.executionId, actorId, approverRole, true, reason, targetToolName);
381
+ return {
382
+ approved: true,
383
+ decisionCode: 'APPROVED',
384
+ reason,
385
+ executionId: options.executionId,
386
+ approverActorId: actorId,
387
+ };
388
+ }
389
+ async recordApprovalAudit(executionId, approverActorId, approverRole, approved, reason, toolName) {
390
+ const event = {
391
+ executionId,
392
+ actionId: executionId,
393
+ toolName: toolName || 'action:approve',
394
+ companionId: 'system',
395
+ actorId: approverActorId,
396
+ riskLevel: 'HIGH',
397
+ lifecycle: approved ? 'APPROVED' : 'REJECTED',
398
+ decision: {
399
+ allowed: approved,
400
+ reason: reason || (approved ? 'Approval granted' : 'Approval rejected'),
401
+ riskLevel: 'HIGH',
402
+ executionId,
403
+ decisionCode: approved ? 'ALLOWED_POLICY' : 'REJECTED_UNAUTHORIZED',
404
+ },
405
+ parametersHash: (0, capability_1.computeParametersHash)({ executionId, approverActorId, approverRole, reason }),
406
+ timestamp: new Date().toISOString(),
407
+ };
408
+ await this.store.appendAudit(event);
189
409
  }
190
410
  async recordAudit(action, context, decision, lifecycle, result, error, durationMs) {
191
411
  const event = {
@@ -125,9 +125,10 @@ describe('ActionPolicyEngine Boundary', () => {
125
125
  expect(res1.decision.decisionCode).toBe('REJECTED_HIGH_RISK_UNAPPROVED');
126
126
  expect(res1.capability).toBeUndefined();
127
127
  // Grant explicit approval
128
- engine.approveAction({
128
+ await engine.approveAction({
129
129
  executionId: 'exec-admin-delete-1',
130
130
  approverActorId: 'admin-super-user',
131
+ approverRole: 'administrator',
131
132
  });
132
133
  // Second attempt: approved
133
134
  const res2 = await engine.evaluateAction(action);
@@ -190,4 +191,173 @@ describe('ActionPolicyEngine Boundary', () => {
190
191
  expect(resOp.decision.allowed).toBe(false);
191
192
  expect(resOp.decision.decisionCode).toBe('REJECTED_UNAUTHORIZED');
192
193
  });
194
+ describe('Approval Authorization Semantics', () => {
195
+ const criticalAction = {
196
+ actionId: 'act-sec-auth-1',
197
+ toolName: 'admin/delete_database',
198
+ parameters: {},
199
+ context: {
200
+ companionId: 'companion-1',
201
+ actor: {
202
+ actorId: 'admin-1',
203
+ sessionId: 'sess-1',
204
+ authorizationRole: 'administrator',
205
+ capabilities: ['admin:delete'],
206
+ authenticated: true,
207
+ },
208
+ conversation: { channel: 'direct', correlationId: 'corr-1' },
209
+ },
210
+ executionId: 'exec-sec-auth-1',
211
+ };
212
+ it('rejects action approval when approverActorId is empty', async () => {
213
+ await engine.evaluateAction(criticalAction);
214
+ const res = await engine.approveAction({
215
+ executionId: 'exec-sec-auth-1',
216
+ approverActorId: '',
217
+ });
218
+ expect(res.approved).toBe(false);
219
+ expect(res.decisionCode).toBe('REJECTED_MISSING_APPROVER_ID');
220
+ });
221
+ it('rejects action approval from unauthenticated approver context', async () => {
222
+ await engine.evaluateAction(criticalAction);
223
+ const res = await engine.approveAction({
224
+ executionId: 'exec-sec-auth-1',
225
+ approverActorId: 'fake-admin',
226
+ context: {
227
+ companionId: 'companion-1',
228
+ actor: {
229
+ actorId: 'fake-admin',
230
+ sessionId: 'sess-fake',
231
+ authorizationRole: 'administrator',
232
+ capabilities: ['admin:delete'],
233
+ authenticated: false, // Unauthenticated!
234
+ },
235
+ conversation: { channel: 'direct', correlationId: 'corr-fake' },
236
+ },
237
+ });
238
+ expect(res.approved).toBe(false);
239
+ expect(res.decisionCode).toBe('REJECTED_UNAUTHENTICATED');
240
+ });
241
+ it('rejects action approval from viewer role', async () => {
242
+ await engine.evaluateAction(criticalAction);
243
+ const res = await engine.approveAction({
244
+ executionId: 'exec-sec-auth-1',
245
+ approverActorId: 'viewer-user',
246
+ approverRole: 'viewer',
247
+ });
248
+ expect(res.approved).toBe(false);
249
+ expect(res.decisionCode).toBe('REJECTED_UNAUTHORIZED');
250
+ });
251
+ it('rejects approval when approver role does not match tool requirements (operator cannot approve admin tool)', async () => {
252
+ // 1. Initial evaluation stages pending execution
253
+ const eval1 = await engine.evaluateAction(criticalAction);
254
+ expect(eval1.decision.allowed).toBe(false);
255
+ expect(eval1.decision.decisionCode).toBe('REJECTED_HIGH_RISK_UNAPPROVED');
256
+ // 2. Operator attempts to approve an administrator-only tool
257
+ const approvalRes = await engine.approveAction({
258
+ executionId: 'exec-sec-auth-1',
259
+ approverActorId: 'operator-alice',
260
+ approverRole: 'operator',
261
+ });
262
+ expect(approvalRes.approved).toBe(false);
263
+ expect(approvalRes.decisionCode).toBe('REJECTED_ROLE_MISMATCH');
264
+ // 3. Action re-evaluation remains unapproved and denied
265
+ const eval2 = await engine.evaluateAction(criticalAction);
266
+ expect(eval2.decision.allowed).toBe(false);
267
+ expect(eval2.decision.decisionCode).toBe('REJECTED_HIGH_RISK_UNAPPROVED');
268
+ });
269
+ it('authorizes approval and emits capability when approver is administrator or owner', async () => {
270
+ await engine.evaluateAction(criticalAction);
271
+ const approvalRes = await engine.approveAction({
272
+ executionId: 'exec-sec-auth-1',
273
+ approverActorId: 'admin-bob',
274
+ approverRole: 'administrator',
275
+ reason: 'Authorized scheduled database purge',
276
+ });
277
+ expect(approvalRes.approved).toBe(true);
278
+ expect(approvalRes.decisionCode).toBe('APPROVED');
279
+ const evalApproved = await engine.evaluateAction(criticalAction);
280
+ expect(evalApproved.decision.allowed).toBe(true);
281
+ expect(evalApproved.decision.decisionCode).toBe('ALLOWED_POLICY');
282
+ expect(evalApproved.capability).toBeDefined();
283
+ });
284
+ it('records structured tamper-evident audit logs for approval decisions', async () => {
285
+ await engine.evaluateAction(criticalAction);
286
+ await engine.approveAction({
287
+ executionId: 'exec-sec-auth-1',
288
+ approverActorId: 'viewer-tamper',
289
+ approverRole: 'viewer',
290
+ });
291
+ const auditLogs = await engine.getAuditLog();
292
+ const rejectionEvent = auditLogs.find((l) => l.executionId === 'exec-sec-auth-1' && l.lifecycle === 'REJECTED' && l.toolName === 'action:approve');
293
+ expect(rejectionEvent).toBeDefined();
294
+ expect(rejectionEvent?.actorId).toBe('viewer-tamper');
295
+ expect(rejectionEvent?.decision?.decisionCode).toBe('REJECTED_UNAUTHORIZED');
296
+ });
297
+ it('rejects approval attempt for arbitrary unknown execution ID', async () => {
298
+ const res = await engine.approveAction({
299
+ executionId: 'arbitrary-unregistered-id',
300
+ approverActorId: 'admin-bob',
301
+ approverRole: 'administrator',
302
+ });
303
+ expect(res.approved).toBe(false);
304
+ expect(res.decisionCode).toBe('REJECTED_UNKNOWN_EXECUTION');
305
+ });
306
+ it('rejects privilege escalation from actorId strings (attacker-admin is not an administrator without explicit role)', async () => {
307
+ await engine.evaluateAction(criticalAction);
308
+ const res = await engine.approveAction({
309
+ executionId: 'exec-sec-auth-1',
310
+ approverActorId: 'attacker-admin-user',
311
+ // Notice no approverRole is provided!
312
+ });
313
+ expect(res.approved).toBe(false);
314
+ expect(res.decisionCode).toBe('REJECTED_UNAUTHORIZED');
315
+ });
316
+ it('rejects action evaluation if parameters are tampered after approval (REJECTED_APPROVAL_MISMATCH)', async () => {
317
+ await engine.evaluateAction(criticalAction);
318
+ const approval = await engine.approveAction({
319
+ executionId: 'exec-sec-auth-1',
320
+ approverActorId: 'admin-bob',
321
+ approverRole: 'administrator',
322
+ });
323
+ expect(approval.approved).toBe(true);
324
+ // Attacker re-evaluates the same approved executionId but with injected dangerous parameters
325
+ const tamperedAction = {
326
+ ...criticalAction,
327
+ parameters: { injected: 'malicious-payload' },
328
+ };
329
+ const evalTampered = await engine.evaluateAction(tamperedAction);
330
+ expect(evalTampered.decision.allowed).toBe(false);
331
+ expect(evalTampered.decision.decisionCode).toBe('REJECTED_APPROVAL_MISMATCH');
332
+ expect(evalTampered.capability).toBeUndefined();
333
+ });
334
+ it('rejects action evaluation if tool name is swapped after approval (REJECTED_APPROVAL_MISMATCH)', async () => {
335
+ await engine.evaluateAction(criticalAction);
336
+ const approval = await engine.approveAction({
337
+ executionId: 'exec-sec-auth-1',
338
+ approverActorId: 'admin-bob',
339
+ approverRole: 'administrator',
340
+ });
341
+ expect(approval.approved).toBe(true);
342
+ // Register another tool
343
+ engine.registerToolDefinition({
344
+ name: 'admin/format_drive',
345
+ providerId: 'admin',
346
+ description: 'Format drive',
347
+ inputSchema: {},
348
+ riskLevel: 'CRITICAL',
349
+ allowedRoles: ['administrator'],
350
+ requiresApproval: true,
351
+ });
352
+ // Attacker re-uses the executionId on a different tool
353
+ const swappedAction = {
354
+ ...criticalAction,
355
+ toolName: 'admin/format_drive',
356
+ };
357
+ const evalSwapped = await engine.evaluateAction(swappedAction);
358
+ expect(evalSwapped.decision.allowed).toBe(false);
359
+ expect(evalSwapped.decision.decisionCode).toBe('REJECTED_APPROVAL_MISMATCH');
360
+ expect(evalSwapped.capability).toBeUndefined();
361
+ });
362
+ });
193
363
  });
package/dist/action.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface ActionPolicyDecision {
15
15
  riskLevel: ActionRiskLevel;
16
16
  requiredCapabilities?: string[];
17
17
  executionId: string;
18
- decisionCode: 'ALLOWED_AUTO' | 'ALLOWED_POLICY' | 'REJECTED_UNKNOWN_TOOL' | 'REJECTED_UNAUTHORIZED' | 'REJECTED_MISSING_CAPABILITY' | 'REJECTED_HIGH_RISK_UNAPPROVED' | 'REJECTED_CHANNEL_RESTRICTED' | 'REJECTED_COMPANION_MISMATCH' | 'REJECTED_POLICY';
18
+ decisionCode: 'ALLOWED_AUTO' | 'ALLOWED_POLICY' | 'REJECTED_UNKNOWN_TOOL' | 'REJECTED_UNAUTHORIZED' | 'REJECTED_MISSING_CAPABILITY' | 'REJECTED_HIGH_RISK_UNAPPROVED' | 'REJECTED_CHANNEL_RESTRICTED' | 'REJECTED_COMPANION_MISMATCH' | 'REJECTED_APPROVAL_MISMATCH' | 'REJECTED_POLICY';
19
19
  }
20
20
  export interface ActionAuditEvent {
21
21
  executionId: string;
@@ -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,24 @@ 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
+ toolName?: string;
47
+ parametersHash?: string;
48
+ companionId?: string;
49
+ actorId?: string;
50
+ }
40
51
  export interface ActionStore {
41
52
  reserveExecution(record: PersistentExecutionRecord): Promise<boolean>;
42
53
  updateExecution(record: PersistentExecutionRecord): Promise<void>;
43
54
  getExecution(executionId: string): Promise<PersistentExecutionRecord | undefined>;
44
- saveApproval(executionId: string, approverActorId: string, reason?: string): Promise<void>;
55
+ saveApproval(executionId: string, approverActorId: string, reason?: string, approverRole?: string, toolName?: string, parametersHash?: string, companionId?: string, actorId?: string): Promise<void>;
45
56
  isActionApproved(executionId: string): Promise<boolean>;
57
+ getApproval?(executionId: string): Promise<ActionApprovalRecord | undefined>;
46
58
  appendAudit(event: ActionAuditEvent): Promise<void>;
47
59
  getAuditLog(executionId?: string): Promise<ActionAuditEvent[]>;
48
60
  }
@@ -54,8 +66,9 @@ export declare class InMemoryActionStore implements ActionStore {
54
66
  reserveExecution(record: PersistentExecutionRecord): Promise<boolean>;
55
67
  updateExecution(record: PersistentExecutionRecord): Promise<void>;
56
68
  getExecution(executionId: string): Promise<PersistentExecutionRecord | undefined>;
57
- saveApproval(executionId: string, approverActorId: string, reason?: string): Promise<void>;
69
+ saveApproval(executionId: string, approverActorId: string, reason?: string, approverRole?: string, toolName?: string, parametersHash?: string, companionId?: string, actorId?: string): Promise<void>;
58
70
  isActionApproved(executionId: string): Promise<boolean>;
71
+ getApproval(executionId: string): Promise<ActionApprovalRecord | undefined>;
59
72
  appendAudit(event: ActionAuditEvent): Promise<void>;
60
73
  getAuditLog(executionId?: string): Promise<ActionAuditEvent[]>;
61
74
  }
@@ -53,16 +53,26 @@ 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, toolName, parametersHash, companionId, actorId) {
57
57
  this.approvals.set(executionId, {
58
+ executionId,
58
59
  approverActorId,
59
60
  reason,
61
+ approverRole,
60
62
  approvedAt: new Date().toISOString(),
63
+ toolName,
64
+ parametersHash,
65
+ companionId,
66
+ actorId,
61
67
  });
62
68
  }
63
69
  async isActionApproved(executionId) {
64
70
  return this.approvals.has(executionId);
65
71
  }
72
+ async getApproval(executionId) {
73
+ const record = this.approvals.get(executionId);
74
+ return record ? { ...record } : undefined;
75
+ }
66
76
  async appendAudit(event) {
67
77
  const prevHash = this.lastAuditHash;
68
78
  // Tamper-evident hash chaining over all security-critical event fields
@@ -85,6 +95,7 @@ class InMemoryActionStore {
85
95
  decisionCode: event.decision.decisionCode,
86
96
  } : null,
87
97
  parametersHash: event.parametersHash || null,
98
+ resultHash: event.resultHash || null,
88
99
  error: event.error || null,
89
100
  timestamp: event.timestamp,
90
101
  };