@siduri-x/core 1.0.0
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 +45 -0
- package/dist/action-policy.js +207 -0
- package/dist/action-policy.test.d.ts +1 -0
- package/dist/action-policy.test.js +157 -0
- package/dist/action.d.ts +70 -0
- package/dist/action.js +2 -0
- package/dist/architecture-boundary.test.d.ts +1 -0
- package/dist/architecture-boundary.test.js +116 -0
- package/dist/capability.d.ts +52 -0
- package/dist/capability.js +102 -0
- package/dist/capability.test.d.ts +1 -0
- package/dist/capability.test.js +180 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.js +92 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +109 -0
- package/dist/dispatcher.d.ts +14 -0
- package/dist/dispatcher.js +40 -0
- package/dist/dispatcher.test.d.ts +1 -0
- package/dist/dispatcher.test.js +60 -0
- package/dist/ear-types.d.ts +33 -0
- package/dist/ear-types.js +2 -0
- package/dist/evidence.d.ts +72 -0
- package/dist/evidence.js +45 -0
- package/dist/evidence.test.d.ts +1 -0
- package/dist/evidence.test.js +101 -0
- package/dist/experience.d.ts +56 -0
- package/dist/experience.js +78 -0
- package/dist/experience.test.d.ts +1 -0
- package/dist/experience.test.js +58 -0
- package/dist/gating.d.ts +45 -0
- package/dist/gating.js +189 -0
- package/dist/gating.test.d.ts +1 -0
- package/dist/gating.test.js +190 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.js +28 -0
- package/dist/runtime.d.ts +50 -0
- package/dist/runtime.js +404 -0
- package/dist/teaching.d.ts +15 -0
- package/dist/teaching.js +159 -0
- package/package.json +43 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { RequestContext } from './context';
|
|
2
|
+
import { ActionIntent, ActionPolicyDecision, ActionAuditEvent, ToolDefinition, ActionRiskLevel, ActionLifecycleState } from './action';
|
|
3
|
+
import { AuthorizationCapability, ActionStore } from './capability';
|
|
4
|
+
export interface ActionPolicyRule {
|
|
5
|
+
toolNamePattern: string | RegExp;
|
|
6
|
+
riskLevel?: ActionRiskLevel;
|
|
7
|
+
requiredCapabilities?: string[];
|
|
8
|
+
allowedRoles?: string[];
|
|
9
|
+
allowedChannels?: string[];
|
|
10
|
+
requiresExplicitApproval?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ActionPolicyEngineOptions {
|
|
13
|
+
rules?: ActionPolicyRule[];
|
|
14
|
+
defaultRiskLevel?: ActionRiskLevel;
|
|
15
|
+
defaultRequireApprovalForHighRisk?: boolean;
|
|
16
|
+
store?: ActionStore;
|
|
17
|
+
secretKey?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ApproveActionOptions {
|
|
20
|
+
executionId: string;
|
|
21
|
+
approverActorId: string;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare class ActionPolicyEngine {
|
|
25
|
+
private readonly toolRegistry;
|
|
26
|
+
private readonly rules;
|
|
27
|
+
private readonly store;
|
|
28
|
+
private readonly defaultRiskLevel;
|
|
29
|
+
private readonly defaultRequireApprovalForHighRisk;
|
|
30
|
+
private readonly secretKey;
|
|
31
|
+
private readonly approvedExecutions;
|
|
32
|
+
constructor(options?: ActionPolicyEngineOptions);
|
|
33
|
+
registerToolDefinition(tool: ToolDefinition): void;
|
|
34
|
+
unregisterToolDefinition(toolName: string): boolean;
|
|
35
|
+
getRegisteredTools(): ToolDefinition[];
|
|
36
|
+
findToolDefinition(toolName: string): ToolDefinition | undefined;
|
|
37
|
+
evaluateAction(action: ActionIntent, context?: RequestContext): Promise<{
|
|
38
|
+
decision: ActionPolicyDecision;
|
|
39
|
+
capability?: AuthorizationCapability;
|
|
40
|
+
}>;
|
|
41
|
+
approveAction(options: ApproveActionOptions): boolean;
|
|
42
|
+
recordAudit(action: ActionIntent, context: RequestContext | undefined, decision: ActionPolicyDecision | undefined, lifecycle: ActionLifecycleState, result?: unknown, error?: string, durationMs?: number): Promise<ActionAuditEvent>;
|
|
43
|
+
getAuditLog(): Promise<ActionAuditEvent[]>;
|
|
44
|
+
getStore(): ActionStore;
|
|
45
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ActionPolicyEngine = void 0;
|
|
4
|
+
const capability_1 = require("./capability");
|
|
5
|
+
class ActionPolicyEngine {
|
|
6
|
+
toolRegistry = new Map();
|
|
7
|
+
rules = [];
|
|
8
|
+
store;
|
|
9
|
+
defaultRiskLevel;
|
|
10
|
+
defaultRequireApprovalForHighRisk;
|
|
11
|
+
secretKey;
|
|
12
|
+
approvedExecutions = new Set();
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.rules = options.rules ?? [];
|
|
15
|
+
this.defaultRiskLevel = options.defaultRiskLevel ?? 'HIGH';
|
|
16
|
+
this.defaultRequireApprovalForHighRisk = options.defaultRequireApprovalForHighRisk ?? true;
|
|
17
|
+
this.store = options.store ?? new capability_1.InMemoryActionStore();
|
|
18
|
+
const envSecret = typeof process !== 'undefined' && process.env ? process.env.ACTION_POLICY_SECRET : undefined;
|
|
19
|
+
const providedSecret = options.secretKey || envSecret;
|
|
20
|
+
if (!providedSecret && typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') {
|
|
21
|
+
throw new Error('FATAL: ACTION_POLICY_SECRET is required in production environment');
|
|
22
|
+
}
|
|
23
|
+
this.secretKey = providedSecret ?? 'siduri_y_action_policy_secret';
|
|
24
|
+
}
|
|
25
|
+
registerToolDefinition(tool) {
|
|
26
|
+
const key = tool.providerId ? `${tool.providerId}/${tool.name}` : tool.name;
|
|
27
|
+
this.toolRegistry.set(key, tool);
|
|
28
|
+
// Also index by bare name if unique
|
|
29
|
+
if (!this.toolRegistry.has(tool.name)) {
|
|
30
|
+
this.toolRegistry.set(tool.name, tool);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
unregisterToolDefinition(toolName) {
|
|
34
|
+
return this.toolRegistry.delete(toolName);
|
|
35
|
+
}
|
|
36
|
+
getRegisteredTools() {
|
|
37
|
+
const set = new Set(this.toolRegistry.values());
|
|
38
|
+
return Array.from(set);
|
|
39
|
+
}
|
|
40
|
+
findToolDefinition(toolName) {
|
|
41
|
+
return this.toolRegistry.get(toolName);
|
|
42
|
+
}
|
|
43
|
+
async evaluateAction(action, context) {
|
|
44
|
+
const effectiveContext = action.context || context;
|
|
45
|
+
const executionId = action.executionId || `exec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
46
|
+
action.executionId = executionId;
|
|
47
|
+
const toolDef = this.findToolDefinition(action.toolName);
|
|
48
|
+
const paramsHash = (0, capability_1.computeParametersHash)(action.parameters);
|
|
49
|
+
// Rule: Unknown tools/actions must not be implicitly authorized
|
|
50
|
+
if (!toolDef) {
|
|
51
|
+
const decision = {
|
|
52
|
+
allowed: false,
|
|
53
|
+
reason: `Tool "${action.toolName}" is not registered or known in action policy`,
|
|
54
|
+
riskLevel: 'CRITICAL',
|
|
55
|
+
executionId,
|
|
56
|
+
decisionCode: 'REJECTED_UNKNOWN_TOOL',
|
|
57
|
+
};
|
|
58
|
+
await this.recordAudit(action, effectiveContext, decision, 'REJECTED');
|
|
59
|
+
return { decision };
|
|
60
|
+
}
|
|
61
|
+
const riskLevel = toolDef.riskLevel || this.defaultRiskLevel;
|
|
62
|
+
const requiredCaps = toolDef.requiredCapabilities || [];
|
|
63
|
+
// Check request context presence
|
|
64
|
+
if (!effectiveContext) {
|
|
65
|
+
const decision = {
|
|
66
|
+
allowed: false,
|
|
67
|
+
reason: 'Missing RequestContext: Actions cannot be authorized without request context',
|
|
68
|
+
riskLevel,
|
|
69
|
+
requiredCapabilities: requiredCaps,
|
|
70
|
+
executionId,
|
|
71
|
+
decisionCode: 'REJECTED_UNAUTHORIZED',
|
|
72
|
+
};
|
|
73
|
+
await this.recordAudit(action, undefined, decision, 'REJECTED');
|
|
74
|
+
return { decision };
|
|
75
|
+
}
|
|
76
|
+
// Role check if tool restricts roles
|
|
77
|
+
if (toolDef.allowedRoles && toolDef.allowedRoles.length > 0) {
|
|
78
|
+
const actorRole = effectiveContext.actor.authorizationRole;
|
|
79
|
+
if (!toolDef.allowedRoles.includes(actorRole)) {
|
|
80
|
+
const decision = {
|
|
81
|
+
allowed: false,
|
|
82
|
+
reason: `Actor role "${actorRole}" is not authorized to execute tool "${action.toolName}"`,
|
|
83
|
+
riskLevel,
|
|
84
|
+
requiredCapabilities: requiredCaps,
|
|
85
|
+
executionId,
|
|
86
|
+
decisionCode: 'REJECTED_UNAUTHORIZED',
|
|
87
|
+
};
|
|
88
|
+
await this.recordAudit(action, effectiveContext, decision, 'REJECTED');
|
|
89
|
+
return { decision };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Channel check if tool restricts channels
|
|
93
|
+
if (toolDef.allowedChannels && toolDef.allowedChannels.length > 0) {
|
|
94
|
+
const channel = effectiveContext.conversation.channel;
|
|
95
|
+
if (!toolDef.allowedChannels.includes(channel)) {
|
|
96
|
+
const decision = {
|
|
97
|
+
allowed: false,
|
|
98
|
+
reason: `Tool "${action.toolName}" cannot be executed in channel "${channel}"`,
|
|
99
|
+
riskLevel,
|
|
100
|
+
requiredCapabilities: requiredCaps,
|
|
101
|
+
executionId,
|
|
102
|
+
decisionCode: 'REJECTED_CHANNEL_RESTRICTED',
|
|
103
|
+
};
|
|
104
|
+
await this.recordAudit(action, effectiveContext, decision, 'REJECTED');
|
|
105
|
+
return { decision };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Capability check
|
|
109
|
+
const actorCaps = effectiveContext.actor.capabilities || [];
|
|
110
|
+
const missingCaps = requiredCaps.filter((cap) => !actorCaps.includes(cap));
|
|
111
|
+
if (missingCaps.length > 0) {
|
|
112
|
+
const decision = {
|
|
113
|
+
allowed: false,
|
|
114
|
+
reason: `Actor is missing required capabilities: [${missingCaps.join(', ')}]`,
|
|
115
|
+
riskLevel,
|
|
116
|
+
requiredCapabilities: requiredCaps,
|
|
117
|
+
executionId,
|
|
118
|
+
decisionCode: 'REJECTED_MISSING_CAPABILITY',
|
|
119
|
+
};
|
|
120
|
+
await this.recordAudit(action, effectiveContext, decision, 'REJECTED');
|
|
121
|
+
return { decision };
|
|
122
|
+
}
|
|
123
|
+
// Risk level approval check
|
|
124
|
+
const requiresExplicitApproval = toolDef.requiresApproval ??
|
|
125
|
+
(this.defaultRequireApprovalForHighRisk && (riskLevel === 'HIGH' || riskLevel === 'CRITICAL'));
|
|
126
|
+
if (requiresExplicitApproval && !this.approvedExecutions.has(executionId)) {
|
|
127
|
+
const decision = {
|
|
128
|
+
allowed: false,
|
|
129
|
+
reason: `Action "${action.toolName}" has risk level ${riskLevel} and requires explicit approval`,
|
|
130
|
+
riskLevel,
|
|
131
|
+
requiredCapabilities: requiredCaps,
|
|
132
|
+
executionId,
|
|
133
|
+
decisionCode: 'REJECTED_HIGH_RISK_UNAPPROVED',
|
|
134
|
+
};
|
|
135
|
+
await this.recordAudit(action, effectiveContext, decision, 'POLICY_CHECKED');
|
|
136
|
+
return { decision };
|
|
137
|
+
}
|
|
138
|
+
const decision = {
|
|
139
|
+
allowed: true,
|
|
140
|
+
reason: 'Action authorized by policy',
|
|
141
|
+
riskLevel,
|
|
142
|
+
requiredCapabilities: requiredCaps,
|
|
143
|
+
executionId,
|
|
144
|
+
decisionCode: 'ALLOWED_POLICY',
|
|
145
|
+
};
|
|
146
|
+
// Issue cryptographic/structural AuthorizationCapability
|
|
147
|
+
const issuedAt = new Date().toISOString();
|
|
148
|
+
const expiresAt = new Date(Date.now() + 60_000).toISOString();
|
|
149
|
+
const providerId = toolDef.providerId || 'builtin';
|
|
150
|
+
const capabilityPayload = {
|
|
151
|
+
executionId,
|
|
152
|
+
actionId: action.actionId,
|
|
153
|
+
toolName: action.toolName,
|
|
154
|
+
providerId,
|
|
155
|
+
parametersHash: paramsHash,
|
|
156
|
+
companionId: effectiveContext.companionId,
|
|
157
|
+
actorId: effectiveContext.actor.actorId,
|
|
158
|
+
sessionId: effectiveContext.actor.sessionId,
|
|
159
|
+
channel: effectiveContext.conversation.channel,
|
|
160
|
+
correlationId: effectiveContext.conversation.correlationId,
|
|
161
|
+
riskLevel,
|
|
162
|
+
issuedAt,
|
|
163
|
+
expiresAt,
|
|
164
|
+
};
|
|
165
|
+
const signature = (0, capability_1.signCapabilityPayload)(capabilityPayload, this.secretKey);
|
|
166
|
+
const capability = {
|
|
167
|
+
...capabilityPayload,
|
|
168
|
+
allowed: true,
|
|
169
|
+
signature,
|
|
170
|
+
};
|
|
171
|
+
await this.recordAudit(action, effectiveContext, decision, 'APPROVED');
|
|
172
|
+
return { decision, capability };
|
|
173
|
+
}
|
|
174
|
+
approveAction(options) {
|
|
175
|
+
this.approvedExecutions.add(options.executionId);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
async recordAudit(action, context, decision, lifecycle, result, error, durationMs) {
|
|
179
|
+
const event = {
|
|
180
|
+
executionId: action.executionId || decision?.executionId || 'unknown',
|
|
181
|
+
actionId: action.actionId,
|
|
182
|
+
toolName: action.toolName,
|
|
183
|
+
companionId: context?.companionId || 'unknown',
|
|
184
|
+
actorId: context?.actor.actorId,
|
|
185
|
+
sessionId: context?.actor.sessionId,
|
|
186
|
+
channel: context?.conversation.channel,
|
|
187
|
+
correlationId: context?.conversation.correlationId,
|
|
188
|
+
riskLevel: decision?.riskLevel || 'LOW',
|
|
189
|
+
lifecycle,
|
|
190
|
+
decision,
|
|
191
|
+
parametersHash: (0, capability_1.computeParametersHash)(action.parameters),
|
|
192
|
+
resultHash: result !== undefined ? (0, capability_1.computeParametersHash)(result) : undefined,
|
|
193
|
+
timestamp: new Date().toISOString(),
|
|
194
|
+
durationMs,
|
|
195
|
+
error,
|
|
196
|
+
};
|
|
197
|
+
await this.store.appendAudit(event);
|
|
198
|
+
return event;
|
|
199
|
+
}
|
|
200
|
+
async getAuditLog() {
|
|
201
|
+
return this.store.getAuditLog();
|
|
202
|
+
}
|
|
203
|
+
getStore() {
|
|
204
|
+
return this.store;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
exports.ActionPolicyEngine = ActionPolicyEngine;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const action_policy_1 = require("./action-policy");
|
|
4
|
+
const capability_1 = require("./capability");
|
|
5
|
+
describe('ActionPolicyEngine Boundary', () => {
|
|
6
|
+
let engine;
|
|
7
|
+
let sampleContext;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
engine = new action_policy_1.ActionPolicyEngine({
|
|
10
|
+
defaultRiskLevel: 'HIGH',
|
|
11
|
+
defaultRequireApprovalForHighRisk: true,
|
|
12
|
+
secretKey: 'test_secret_key',
|
|
13
|
+
});
|
|
14
|
+
sampleContext = {
|
|
15
|
+
companionId: 'companion-1',
|
|
16
|
+
actor: {
|
|
17
|
+
actorId: 'user-1',
|
|
18
|
+
sessionId: 'sess-1',
|
|
19
|
+
authorizationRole: 'operator',
|
|
20
|
+
capabilities: ['web:search', 'calc:basic'],
|
|
21
|
+
authenticated: true,
|
|
22
|
+
},
|
|
23
|
+
conversation: {
|
|
24
|
+
channel: 'direct',
|
|
25
|
+
audienceId: 'audience-direct',
|
|
26
|
+
correlationId: 'corr-1',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
const searchTool = {
|
|
30
|
+
name: 'search_web',
|
|
31
|
+
providerId: 'builtin',
|
|
32
|
+
description: 'Search the web',
|
|
33
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
|
|
34
|
+
riskLevel: 'LOW',
|
|
35
|
+
requiredCapabilities: ['web:search'],
|
|
36
|
+
allowedChannels: ['direct', 'public', 'private'],
|
|
37
|
+
};
|
|
38
|
+
const deleteTool = {
|
|
39
|
+
name: 'delete_database',
|
|
40
|
+
providerId: 'admin',
|
|
41
|
+
description: 'Delete entire database',
|
|
42
|
+
inputSchema: { type: 'object' },
|
|
43
|
+
riskLevel: 'CRITICAL',
|
|
44
|
+
requiredCapabilities: ['admin:delete'],
|
|
45
|
+
allowedRoles: ['administrator'],
|
|
46
|
+
requiresApproval: true,
|
|
47
|
+
};
|
|
48
|
+
engine.registerToolDefinition(searchTool);
|
|
49
|
+
engine.registerToolDefinition(deleteTool);
|
|
50
|
+
});
|
|
51
|
+
it('approves an authorized low-risk action and issues a signed AuthorizationCapability', async () => {
|
|
52
|
+
const action = {
|
|
53
|
+
actionId: 'act-1',
|
|
54
|
+
toolName: 'builtin/search_web',
|
|
55
|
+
parameters: { query: 'Antigravity' },
|
|
56
|
+
context: sampleContext,
|
|
57
|
+
};
|
|
58
|
+
const { decision, capability } = await engine.evaluateAction(action);
|
|
59
|
+
expect(decision.allowed).toBe(true);
|
|
60
|
+
expect(decision.decisionCode).toBe('ALLOWED_POLICY');
|
|
61
|
+
expect(decision.riskLevel).toBe('LOW');
|
|
62
|
+
expect(capability).toBeDefined();
|
|
63
|
+
expect(capability?.allowed).toBe(true);
|
|
64
|
+
expect(capability?.toolName).toBe('builtin/search_web');
|
|
65
|
+
expect(capability?.actionId).toBe('act-1');
|
|
66
|
+
expect(capability?.signature).toBeDefined();
|
|
67
|
+
expect((0, capability_1.verifyCapabilitySignature)(capability, 'test_secret_key')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
it('rejects an unknown tool/action by default and issues no capability', async () => {
|
|
70
|
+
const action = {
|
|
71
|
+
actionId: 'act-2',
|
|
72
|
+
toolName: 'unregistered_tool',
|
|
73
|
+
parameters: {},
|
|
74
|
+
context: sampleContext,
|
|
75
|
+
};
|
|
76
|
+
const { decision, capability } = await engine.evaluateAction(action);
|
|
77
|
+
expect(decision.allowed).toBe(false);
|
|
78
|
+
expect(decision.decisionCode).toBe('REJECTED_UNKNOWN_TOOL');
|
|
79
|
+
expect(decision.riskLevel).toBe('CRITICAL');
|
|
80
|
+
expect(capability).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
it('rejects action if request context is missing', async () => {
|
|
83
|
+
const action = {
|
|
84
|
+
actionId: 'act-3',
|
|
85
|
+
toolName: 'builtin/search_web',
|
|
86
|
+
parameters: { query: 'test' },
|
|
87
|
+
// No context provided
|
|
88
|
+
};
|
|
89
|
+
const { decision, capability } = await engine.evaluateAction(action);
|
|
90
|
+
expect(decision.allowed).toBe(false);
|
|
91
|
+
expect(decision.decisionCode).toBe('REJECTED_UNAUTHORIZED');
|
|
92
|
+
expect(decision.reason).toContain('Missing RequestContext');
|
|
93
|
+
expect(capability).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
it('rejects action when actor lacks required capability', async () => {
|
|
96
|
+
const action = {
|
|
97
|
+
actionId: 'act-4',
|
|
98
|
+
toolName: 'admin/delete_database',
|
|
99
|
+
parameters: {},
|
|
100
|
+
context: sampleContext, // has 'web:search' and 'calc:basic', not 'admin:delete'
|
|
101
|
+
};
|
|
102
|
+
const { decision, capability } = await engine.evaluateAction(action);
|
|
103
|
+
expect(decision.allowed).toBe(false);
|
|
104
|
+
expect(decision.decisionCode).toBe('REJECTED_UNAUTHORIZED'); // operator is not administrator
|
|
105
|
+
expect(capability).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
it('rejects high/critical risk action requiring explicit approval before approval is granted, and allows after approval', async () => {
|
|
108
|
+
const adminContext = {
|
|
109
|
+
...sampleContext,
|
|
110
|
+
actor: {
|
|
111
|
+
...sampleContext.actor,
|
|
112
|
+
authorizationRole: 'administrator',
|
|
113
|
+
capabilities: ['admin:delete'],
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const action = {
|
|
117
|
+
actionId: 'act-5',
|
|
118
|
+
toolName: 'admin/delete_database',
|
|
119
|
+
parameters: {},
|
|
120
|
+
context: adminContext,
|
|
121
|
+
executionId: 'exec-admin-delete-1',
|
|
122
|
+
};
|
|
123
|
+
// First attempt: unapproved
|
|
124
|
+
const res1 = await engine.evaluateAction(action);
|
|
125
|
+
expect(res1.decision.allowed).toBe(false);
|
|
126
|
+
expect(res1.decision.decisionCode).toBe('REJECTED_HIGH_RISK_UNAPPROVED');
|
|
127
|
+
expect(res1.capability).toBeUndefined();
|
|
128
|
+
// Grant explicit approval
|
|
129
|
+
engine.approveAction({
|
|
130
|
+
executionId: 'exec-admin-delete-1',
|
|
131
|
+
approverActorId: 'admin-super-user',
|
|
132
|
+
});
|
|
133
|
+
// Second attempt: approved
|
|
134
|
+
const res2 = await engine.evaluateAction(action);
|
|
135
|
+
expect(res2.decision.allowed).toBe(true);
|
|
136
|
+
expect(res2.decision.decisionCode).toBe('ALLOWED_POLICY');
|
|
137
|
+
expect(res2.capability).toBeDefined();
|
|
138
|
+
expect((0, capability_1.verifyCapabilitySignature)(res2.capability, 'test_secret_key')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
it('records structured tamper-evident audit log entries with hash chaining', async () => {
|
|
141
|
+
const action = {
|
|
142
|
+
actionId: 'act-audit',
|
|
143
|
+
toolName: 'builtin/search_web',
|
|
144
|
+
parameters: { query: 'Audit Test' },
|
|
145
|
+
context: sampleContext,
|
|
146
|
+
};
|
|
147
|
+
await engine.evaluateAction(action);
|
|
148
|
+
const auditLogs = await engine.getAuditLog();
|
|
149
|
+
expect(auditLogs.length).toBeGreaterThan(0);
|
|
150
|
+
const log = auditLogs.find((l) => l.actionId === 'act-audit');
|
|
151
|
+
expect(log).toBeDefined();
|
|
152
|
+
expect(log?.actorId).toBe('user-1');
|
|
153
|
+
expect(log?.toolName).toBe('builtin/search_web');
|
|
154
|
+
expect(log?.parametersHash).toBeDefined();
|
|
155
|
+
expect(log?.resultHash).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
package/dist/action.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { RequestContext } from './context';
|
|
2
|
+
export type ActionRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
|
3
|
+
export type ActionLifecycleState = 'PROPOSED' | 'VALIDATED' | 'POLICY_CHECKED' | 'APPROVED' | 'EXECUTING' | 'COMPLETED' | 'REJECTED' | 'FAILED' | 'CANCELLED' | 'TIMED_OUT';
|
|
4
|
+
export interface ActionIntent {
|
|
5
|
+
actionId: string;
|
|
6
|
+
toolName: string;
|
|
7
|
+
parameters: Record<string, unknown>;
|
|
8
|
+
description?: string;
|
|
9
|
+
context?: RequestContext;
|
|
10
|
+
executionId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ActionPolicyDecision {
|
|
13
|
+
allowed: boolean;
|
|
14
|
+
reason: string;
|
|
15
|
+
riskLevel: ActionRiskLevel;
|
|
16
|
+
requiredCapabilities?: string[];
|
|
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';
|
|
19
|
+
}
|
|
20
|
+
export interface ActionAuditEvent {
|
|
21
|
+
executionId: string;
|
|
22
|
+
actionId: string;
|
|
23
|
+
toolName: string;
|
|
24
|
+
providerId?: string;
|
|
25
|
+
companionId: string;
|
|
26
|
+
actorId?: string;
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
channel?: string;
|
|
29
|
+
correlationId?: string;
|
|
30
|
+
riskLevel: ActionRiskLevel;
|
|
31
|
+
lifecycle: ActionLifecycleState;
|
|
32
|
+
decision?: ActionPolicyDecision;
|
|
33
|
+
parametersHash?: string;
|
|
34
|
+
resultHash?: string;
|
|
35
|
+
timestamp: string;
|
|
36
|
+
durationMs?: number;
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface ActionExecutionResult {
|
|
40
|
+
actionId: string;
|
|
41
|
+
executionId: string;
|
|
42
|
+
toolName: string;
|
|
43
|
+
lifecycle: ActionLifecycleState;
|
|
44
|
+
success: boolean;
|
|
45
|
+
result?: unknown;
|
|
46
|
+
error?: string;
|
|
47
|
+
decision?: ActionPolicyDecision;
|
|
48
|
+
durationMs?: number;
|
|
49
|
+
}
|
|
50
|
+
export interface ToolDefinition {
|
|
51
|
+
name: string;
|
|
52
|
+
description: string;
|
|
53
|
+
inputSchema: Record<string, unknown>;
|
|
54
|
+
providerId?: string;
|
|
55
|
+
riskLevel?: ActionRiskLevel;
|
|
56
|
+
requiredCapabilities?: string[];
|
|
57
|
+
allowedRoles?: string[];
|
|
58
|
+
allowedChannels?: string[];
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
requiresApproval?: boolean;
|
|
61
|
+
}
|
|
62
|
+
export interface ToolExecutionOptions {
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
}
|
|
66
|
+
export interface HandsOrgan {
|
|
67
|
+
listTools(): Promise<ToolDefinition[]>;
|
|
68
|
+
executeAction(action: ActionIntent, authorization: any, // AuthorizationCapability required
|
|
69
|
+
options?: ToolExecutionOptions): Promise<ActionExecutionResult>;
|
|
70
|
+
}
|
package/dist/action.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
const fs = __importStar(require("fs"));
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
describe('Architecture: Core & Organ Package Boundaries (Phase 2)', () => {
|
|
39
|
+
const rootOrgansDir = path.resolve(__dirname, '../../organs');
|
|
40
|
+
const coreSrcDir = path.resolve(__dirname);
|
|
41
|
+
const corePackageJsonPath = path.resolve(__dirname, '../package.json');
|
|
42
|
+
const EXPECTED_ORGANS = [
|
|
43
|
+
{ dir: 'brain', name: '@siduri-x/brain', organType: 'brain', configKey: 'brain' },
|
|
44
|
+
{ dir: 'memory', name: '@siduri-x/memory', organType: 'memory', configKey: 'memory' },
|
|
45
|
+
{ dir: 'knowledge', name: '@siduri-x/knowledge', organType: 'knowledge', configKey: 'knowledge' },
|
|
46
|
+
{ dir: 'behavior', name: '@siduri-x/behavior', organType: 'behavior', configKey: 'behavior' },
|
|
47
|
+
{ dir: 'ear', name: '@siduri-x/ear', organType: 'ear', configKey: 'ear' },
|
|
48
|
+
{ dir: 'vision', name: '@siduri-x/vision', organType: 'vision', configKey: 'vision' },
|
|
49
|
+
{ dir: 'hands', name: '@siduri-x/hands', organType: 'hands', configKey: 'hands' },
|
|
50
|
+
{ dir: 'body', name: '@siduri-x/body', organType: 'body', configKey: 'body' },
|
|
51
|
+
{ dir: 'voice', name: '@siduri-x/voice', organType: 'voice', configKey: 'voice' },
|
|
52
|
+
{ dir: 'observation', name: '@siduri-x/observation', organType: 'observation', configKey: 'observation' },
|
|
53
|
+
];
|
|
54
|
+
it('package.json has zero dependencies on @siduri-y organ packages', () => {
|
|
55
|
+
const pkg = JSON.parse(fs.readFileSync(corePackageJsonPath, 'utf8'));
|
|
56
|
+
const allDeps = {
|
|
57
|
+
...(pkg.dependencies || {}),
|
|
58
|
+
...(pkg.devDependencies || {}),
|
|
59
|
+
...(pkg.peerDependencies || {}),
|
|
60
|
+
};
|
|
61
|
+
const organDeps = Object.keys(allDeps).filter((dep) => dep.startsWith('@siduri-x/') && dep !== '@siduri-x/core');
|
|
62
|
+
expect(organDeps).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
it('source files in packages/core have zero imports referencing @siduri-y organ packages', () => {
|
|
65
|
+
const files = fs.readdirSync(coreSrcDir).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
|
66
|
+
const forbiddenImports = [];
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
const content = fs.readFileSync(path.join(coreSrcDir, file), 'utf8');
|
|
69
|
+
const lines = content.split('\n');
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
if ((line.includes('import ') || line.includes('require(') || line.includes('export * from')) &&
|
|
72
|
+
line.includes('@siduri-x/') &&
|
|
73
|
+
!line.includes('@siduri-x/core')) {
|
|
74
|
+
forbiddenImports.push({ file, match: line.trim() });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
expect(forbiddenImports).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
it('all 10 organ packages have a valid organ-manifest.json', () => {
|
|
81
|
+
for (const organ of EXPECTED_ORGANS) {
|
|
82
|
+
const manifestPath = path.join(rootOrgansDir, organ.dir, 'organ-manifest.json');
|
|
83
|
+
expect(fs.existsSync(manifestPath)).toBe(true);
|
|
84
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
85
|
+
expect(manifest.name).toBe(organ.name);
|
|
86
|
+
expect(manifest.organType).toBe(organ.organType);
|
|
87
|
+
expect(manifest.configKey).toBe(organ.configKey);
|
|
88
|
+
expect(typeof manifest.version).toBe('string');
|
|
89
|
+
expect(typeof manifest.displayName).toBe('string');
|
|
90
|
+
expect(typeof manifest.entrypoint).toBe('string');
|
|
91
|
+
expect(typeof manifest.factory).toBe('string');
|
|
92
|
+
expect(manifest.configSchema).toBeDefined();
|
|
93
|
+
expect(Array.isArray(manifest.environment)).toBe(true);
|
|
94
|
+
expect(Array.isArray(manifest.services)).toBe(true);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
it('no organ package contains link: or relative monorepo dependencies in package.json', () => {
|
|
98
|
+
for (const organ of EXPECTED_ORGANS) {
|
|
99
|
+
const pkgPath = path.join(rootOrgansDir, organ.dir, 'package.json');
|
|
100
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
101
|
+
const deps = { ...(pkg.dependencies || {}) };
|
|
102
|
+
for (const [depName, version] of Object.entries(deps)) {
|
|
103
|
+
if (typeof version === 'string') {
|
|
104
|
+
expect(version.startsWith('link:')).toBe(false);
|
|
105
|
+
expect(version.includes('../')).toBe(false);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
it('memory organ packages SQL migrations', () => {
|
|
111
|
+
const memoryMigrationsDir = path.join(rootOrgansDir, 'memory', 'migrations');
|
|
112
|
+
expect(fs.existsSync(memoryMigrationsDir)).toBe(true);
|
|
113
|
+
const files = fs.readdirSync(memoryMigrationsDir).filter((f) => f.endsWith('.sql'));
|
|
114
|
+
expect(files.length).toBeGreaterThanOrEqual(1);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ActionRiskLevel, ActionLifecycleState, ActionAuditEvent, ActionPolicyDecision } from './action';
|
|
2
|
+
export interface AuthorizationCapability {
|
|
3
|
+
executionId: string;
|
|
4
|
+
actionId: string;
|
|
5
|
+
toolName: string;
|
|
6
|
+
providerId: string;
|
|
7
|
+
parametersHash: string;
|
|
8
|
+
companionId: string;
|
|
9
|
+
actorId?: string;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
channel?: string;
|
|
12
|
+
correlationId?: string;
|
|
13
|
+
riskLevel: ActionRiskLevel;
|
|
14
|
+
issuedAt: string;
|
|
15
|
+
expiresAt: string;
|
|
16
|
+
allowed: true;
|
|
17
|
+
signature: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PersistentExecutionRecord {
|
|
20
|
+
executionId: string;
|
|
21
|
+
actionId: string;
|
|
22
|
+
toolName: string;
|
|
23
|
+
providerId: string;
|
|
24
|
+
parametersHash: string;
|
|
25
|
+
lifecycle: ActionLifecycleState;
|
|
26
|
+
decision?: ActionPolicyDecision;
|
|
27
|
+
result?: unknown;
|
|
28
|
+
error?: string;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ActionStore {
|
|
33
|
+
reserveExecution(record: PersistentExecutionRecord): Promise<boolean>;
|
|
34
|
+
updateExecution(record: PersistentExecutionRecord): Promise<void>;
|
|
35
|
+
getExecution(executionId: string): Promise<PersistentExecutionRecord | undefined>;
|
|
36
|
+
appendAudit(event: ActionAuditEvent): Promise<void>;
|
|
37
|
+
getAuditLog(executionId?: string): Promise<ActionAuditEvent[]>;
|
|
38
|
+
}
|
|
39
|
+
export declare class InMemoryActionStore implements ActionStore {
|
|
40
|
+
private readonly executions;
|
|
41
|
+
private readonly auditLog;
|
|
42
|
+
private lastAuditHash;
|
|
43
|
+
reserveExecution(record: PersistentExecutionRecord): Promise<boolean>;
|
|
44
|
+
updateExecution(record: PersistentExecutionRecord): Promise<void>;
|
|
45
|
+
getExecution(executionId: string): Promise<PersistentExecutionRecord | undefined>;
|
|
46
|
+
appendAudit(event: ActionAuditEvent): Promise<void>;
|
|
47
|
+
getAuditLog(executionId?: string): Promise<ActionAuditEvent[]>;
|
|
48
|
+
}
|
|
49
|
+
export declare function canonicalizeJson(obj: unknown): string;
|
|
50
|
+
export declare function computeParametersHash(params: unknown): string;
|
|
51
|
+
export declare function signCapabilityPayload(payload: Record<string, unknown>, secretKey?: string): string;
|
|
52
|
+
export declare function verifyCapabilitySignature(capability: AuthorizationCapability, secretKey?: string): boolean;
|