@highflame/policy 1.1.3 → 1.2.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/actions.gen.d.ts +21 -0
- package/dist/actions.gen.d.ts.map +1 -1
- package/dist/actions.gen.js +21 -0
- package/dist/actions.gen.js.map +1 -1
- package/dist/builder.d.ts +47 -10
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js.map +1 -1
- package/dist/engine.d.ts +37 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +99 -0
- package/dist/engine.js.map +1 -1
- package/dist/engine.test.d.ts +8 -0
- package/dist/engine.test.d.ts.map +1 -0
- package/dist/engine.test.js +190 -0
- package/dist/engine.test.js.map +1 -0
- package/dist/entities.gen.d.ts +4 -0
- package/dist/entities.gen.d.ts.map +1 -1
- package/dist/entities.gen.js +4 -0
- package/dist/entities.gen.js.map +1 -1
- package/dist/parser.d.ts +34 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +348 -0
- package/dist/parser.js.map +1 -0
- package/dist/parser.test.d.ts +8 -0
- package/dist/parser.test.d.ts.map +1 -0
- package/dist/parser.test.js +99 -0
- package/dist/parser.test.js.map +1 -0
- package/dist/schema.gen.d.ts +1 -1
- package/dist/schema.gen.d.ts.map +1 -1
- package/dist/schema.gen.js +331 -17
- package/dist/schema.gen.js.map +1 -1
- package/package.json +4 -2
- package/src/actions.gen.ts +21 -0
- package/src/builder.ts +52 -10
- package/src/engine.test.ts +371 -0
- package/src/engine.ts +145 -0
- package/src/entities.gen.ts +4 -0
- package/src/parser.test.ts +116 -0
- package/src/parser.ts +470 -0
- package/src/schema.gen.ts +331 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@highflame/policy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Highflame Cedar policy types and engine wrapper",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -42,13 +42,15 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build": "tsc",
|
|
44
44
|
"clean": "rm -rf dist",
|
|
45
|
+
"test": "vitest run",
|
|
45
46
|
"prepublishOnly": "npm run build"
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
48
49
|
"@cedar-policy/cedar-wasm": "^4.0.0"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
|
-
"typescript": "^5.3.0"
|
|
52
|
+
"typescript": "^5.3.0",
|
|
53
|
+
"vitest": "^2.0.0"
|
|
52
54
|
},
|
|
53
55
|
"files": [
|
|
54
56
|
"dist",
|
package/src/actions.gen.ts
CHANGED
|
@@ -7,20 +7,41 @@ import { EntityUID } from './entities.gen.js';
|
|
|
7
7
|
* Action types defined in the Highflame Cedar schema.
|
|
8
8
|
*/
|
|
9
9
|
export const ActionType = {
|
|
10
|
+
AccessMemory: 'access_memory',
|
|
10
11
|
AccessServerResource: 'access_server_resource',
|
|
12
|
+
CallExternalApi: 'call_external_api',
|
|
11
13
|
CallTool: 'call_tool',
|
|
12
14
|
ConnectServer: 'connect_server',
|
|
15
|
+
DelegateTask: 'delegate_task',
|
|
16
|
+
DeleteFile: 'delete_file',
|
|
13
17
|
DeployModel: 'deploy_model',
|
|
18
|
+
ExecuteCode: 'execute_code',
|
|
19
|
+
ExportData: 'export_data',
|
|
20
|
+
FilterContent: 'filter_content',
|
|
21
|
+
GitCheckout: 'git_checkout',
|
|
22
|
+
GitClone: 'git_clone',
|
|
23
|
+
GitCommit: 'git_commit',
|
|
24
|
+
GitMerge: 'git_merge',
|
|
25
|
+
GitOperation: 'git_operation',
|
|
26
|
+
GitPull: 'git_pull',
|
|
27
|
+
GitPush: 'git_push',
|
|
28
|
+
GitRebase: 'git_rebase',
|
|
29
|
+
GitReset: 'git_reset',
|
|
14
30
|
HttpRequest: 'http_request',
|
|
31
|
+
InvokeModel: 'invoke_model',
|
|
15
32
|
LoadModel: 'load_model',
|
|
16
33
|
ProcessPrompt: 'process_prompt',
|
|
17
34
|
ProcessResponse: 'process_response',
|
|
18
35
|
QuarantineArtifact: 'quarantine_artifact',
|
|
19
36
|
ReadFile: 'read_file',
|
|
37
|
+
RunBuild: 'run_build',
|
|
38
|
+
RunTests: 'run_tests',
|
|
20
39
|
ScanArtifact: 'scan_artifact',
|
|
21
40
|
ScanPackage: 'scan_package',
|
|
22
41
|
ScanTarget: 'scan_target',
|
|
23
42
|
SkipGuardrails: 'skip_guardrails',
|
|
43
|
+
SpawnSubprocess: 'spawn_subprocess',
|
|
44
|
+
TransferData: 'transfer_data',
|
|
24
45
|
ValidateIntegrity: 'validate_integrity',
|
|
25
46
|
ValidateProvenance: 'validate_provenance',
|
|
26
47
|
WriteFile: 'write_file',
|
package/src/builder.ts
CHANGED
|
@@ -58,7 +58,31 @@ export interface PolicyCondition {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
61
|
+
* Principal or resource entity constraint.
|
|
62
|
+
* Used to specify type-only constraints (any entity of type) or
|
|
63
|
+
* specific entity constraints (type + id).
|
|
64
|
+
*/
|
|
65
|
+
export interface PolicyEntity {
|
|
66
|
+
/** Entity type (e.g., "Agent", "Tool", "FilePath", "User") */
|
|
67
|
+
type: string;
|
|
68
|
+
/** Optional specific entity ID. If omitted, matches any entity of this type. */
|
|
69
|
+
id?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Alias for PolicyEntity when used as principal constraint */
|
|
73
|
+
export type PolicyPrincipal = PolicyEntity;
|
|
74
|
+
|
|
75
|
+
/** Alias for PolicyEntity when used as resource constraint */
|
|
76
|
+
export type PolicyResource = PolicyEntity;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Rule severity levels for UI display and prioritization
|
|
80
|
+
*/
|
|
81
|
+
export type PolicySeverity = 'critical' | 'high' | 'medium' | 'low';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* JSON representation of a policy for storage and editing.
|
|
85
|
+
* This is the base interface used by PolicyBuilder.
|
|
62
86
|
*/
|
|
63
87
|
export interface PolicyJSON {
|
|
64
88
|
/** Unique identifier for this policy */
|
|
@@ -68,23 +92,41 @@ export interface PolicyJSON {
|
|
|
68
92
|
/** Policy effect */
|
|
69
93
|
effect: PolicyEffect;
|
|
70
94
|
/** Principal constraint */
|
|
71
|
-
principal:
|
|
72
|
-
|
|
73
|
-
id?: string;
|
|
74
|
-
} | null;
|
|
75
|
-
/** Action constraint */
|
|
95
|
+
principal: PolicyEntity | null;
|
|
96
|
+
/** Action constraint - single action or array of actions */
|
|
76
97
|
action: string | string[];
|
|
77
98
|
/** Resource constraint */
|
|
78
|
-
resource:
|
|
79
|
-
type: string;
|
|
80
|
-
id?: string;
|
|
81
|
-
} | null;
|
|
99
|
+
resource: PolicyEntity | null;
|
|
82
100
|
/** Conditions (when clause) */
|
|
83
101
|
conditions: PolicyCondition[];
|
|
84
102
|
/** Raw condition string (for advanced users) */
|
|
85
103
|
rawCondition?: string;
|
|
86
104
|
}
|
|
87
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A policy rule with UI/storage metadata.
|
|
108
|
+
* Extends PolicyJSON with fields needed for UI editing and database storage.
|
|
109
|
+
*
|
|
110
|
+
* This is the canonical type used across all Highflame services:
|
|
111
|
+
* - highflame-studio (UI)
|
|
112
|
+
* - highflame-authz (Go backend)
|
|
113
|
+
* - Any Python services
|
|
114
|
+
*
|
|
115
|
+
* Each PolicyRule maps 1:1 to a Cedar policy statement.
|
|
116
|
+
*/
|
|
117
|
+
export interface PolicyRule extends PolicyJSON {
|
|
118
|
+
/** Whether this rule is active (used for toggling rules on/off in UI) */
|
|
119
|
+
enabled: boolean;
|
|
120
|
+
/** Display/evaluation order (0-indexed) */
|
|
121
|
+
order: number;
|
|
122
|
+
/** Optional description (separate from name for longer explanations) */
|
|
123
|
+
description?: string;
|
|
124
|
+
/** Rule severity for display and prioritization */
|
|
125
|
+
severity?: PolicySeverity;
|
|
126
|
+
/** Optional tags for categorization and filtering */
|
|
127
|
+
tags?: string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
88
130
|
/**
|
|
89
131
|
* A built policy that can be converted to Cedar text or JSON
|
|
90
132
|
*/
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine unit tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the PolicyEngine evaluate() function.
|
|
5
|
+
* These tests are consistent across Go, TypeScript, and Python SDKs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
PolicyEngine,
|
|
11
|
+
Decision,
|
|
12
|
+
EntityType,
|
|
13
|
+
ActionType,
|
|
14
|
+
ContextKey,
|
|
15
|
+
InputValidationError,
|
|
16
|
+
DEFAULT_LIMITS,
|
|
17
|
+
} from './index.js';
|
|
18
|
+
|
|
19
|
+
describe('PolicyEngine', () => {
|
|
20
|
+
let engine: PolicyEngine;
|
|
21
|
+
|
|
22
|
+
const permitAllPolicy = `
|
|
23
|
+
permit(principal, action, resource);
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const denyAllPolicy = `
|
|
27
|
+
forbid(principal, action, resource);
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
// Simple context-based policy without action constraint for testing context evaluation
|
|
31
|
+
const contextBasedPolicy = `
|
|
32
|
+
@id("allow-production")
|
|
33
|
+
permit(
|
|
34
|
+
principal,
|
|
35
|
+
action,
|
|
36
|
+
resource
|
|
37
|
+
)
|
|
38
|
+
when { context.environment == "production" };
|
|
39
|
+
|
|
40
|
+
@id("deny-all")
|
|
41
|
+
forbid(principal, action, resource);
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
engine = new PolicyEngine();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('basic evaluation', () => {
|
|
49
|
+
it('should allow when permit policy matches', () => {
|
|
50
|
+
engine.loadPolicies(permitAllPolicy);
|
|
51
|
+
|
|
52
|
+
const decision = engine.evaluateSimple(
|
|
53
|
+
EntityType.Scanner,
|
|
54
|
+
'test-scanner',
|
|
55
|
+
ActionType.ScanArtifact,
|
|
56
|
+
EntityType.Artifact,
|
|
57
|
+
'/model.safetensors'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(decision.effect).toBe('Allow');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should deny when forbid policy matches', () => {
|
|
64
|
+
engine.loadPolicies(denyAllPolicy);
|
|
65
|
+
|
|
66
|
+
const decision = engine.evaluateSimple(
|
|
67
|
+
EntityType.Scanner,
|
|
68
|
+
'test-scanner',
|
|
69
|
+
ActionType.ScanArtifact,
|
|
70
|
+
EntityType.Artifact,
|
|
71
|
+
'/model.safetensors'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(decision.effect).toBe('Deny');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should deny when no policies match (default deny)', () => {
|
|
78
|
+
engine.loadPolicies(''); // No policies
|
|
79
|
+
|
|
80
|
+
const decision = engine.evaluateSimple(
|
|
81
|
+
EntityType.Scanner,
|
|
82
|
+
'test-scanner',
|
|
83
|
+
ActionType.ScanArtifact,
|
|
84
|
+
EntityType.Artifact,
|
|
85
|
+
'/model.safetensors'
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(decision.effect).toBe('Deny');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('context-based evaluation', () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
engine.loadPolicies(contextBasedPolicy);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should allow when context matches permit condition', () => {
|
|
98
|
+
// Use simple permit policy to test context evaluation in isolation
|
|
99
|
+
const simplePermitPolicy = `
|
|
100
|
+
permit(principal, action, resource)
|
|
101
|
+
when { context.environment == "production" };
|
|
102
|
+
`;
|
|
103
|
+
const testEngine = new PolicyEngine();
|
|
104
|
+
testEngine.loadPolicies(simplePermitPolicy);
|
|
105
|
+
|
|
106
|
+
const decision = testEngine.evaluateSimple(
|
|
107
|
+
EntityType.Scanner,
|
|
108
|
+
'palisade',
|
|
109
|
+
ActionType.ScanArtifact,
|
|
110
|
+
EntityType.Artifact,
|
|
111
|
+
'/model.safetensors',
|
|
112
|
+
{ environment: 'production' }
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(decision.effect).toBe('Allow');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should deny when context does not match permit condition', () => {
|
|
119
|
+
const decision = engine.evaluateSimple(
|
|
120
|
+
EntityType.Scanner,
|
|
121
|
+
'palisade',
|
|
122
|
+
ActionType.ScanArtifact,
|
|
123
|
+
EntityType.Artifact,
|
|
124
|
+
'/model.safetensors',
|
|
125
|
+
{ environment: 'development' }
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(decision.effect).toBe('Deny');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should deny when context is missing required field', () => {
|
|
132
|
+
const decision = engine.evaluateSimple(
|
|
133
|
+
EntityType.Scanner,
|
|
134
|
+
'palisade',
|
|
135
|
+
ActionType.ScanArtifact,
|
|
136
|
+
EntityType.Artifact,
|
|
137
|
+
'/model.safetensors',
|
|
138
|
+
{} // Missing environment
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(decision.effect).toBe('Deny');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('input validation', () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
engine.loadPolicies(permitAllPolicy);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should accept valid context', () => {
|
|
151
|
+
const decision = engine.evaluateSimple(
|
|
152
|
+
EntityType.Scanner,
|
|
153
|
+
'test',
|
|
154
|
+
ActionType.ScanArtifact,
|
|
155
|
+
EntityType.Artifact,
|
|
156
|
+
'/model.safetensors',
|
|
157
|
+
{
|
|
158
|
+
environment: 'production',
|
|
159
|
+
severity: 'HIGH',
|
|
160
|
+
count: 42,
|
|
161
|
+
enabled: true,
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(decision.effect).toBe('Allow');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should reject context with too many keys', () => {
|
|
169
|
+
const engine = new PolicyEngine({ limits: { maxContextKeys: 5 } });
|
|
170
|
+
engine.loadPolicies(permitAllPolicy);
|
|
171
|
+
|
|
172
|
+
const bigContext: Record<string, unknown> = {};
|
|
173
|
+
for (let i = 0; i < 10; i++) {
|
|
174
|
+
bigContext[`key${i}`] = 'value';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
expect(() => {
|
|
178
|
+
engine.evaluateSimple(
|
|
179
|
+
EntityType.Scanner,
|
|
180
|
+
'test',
|
|
181
|
+
ActionType.ScanArtifact,
|
|
182
|
+
EntityType.Artifact,
|
|
183
|
+
'/model.safetensors',
|
|
184
|
+
bigContext
|
|
185
|
+
);
|
|
186
|
+
}).toThrow(InputValidationError);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should reject context with too long strings', () => {
|
|
190
|
+
const engine = new PolicyEngine({ limits: { maxStringLength: 100 } });
|
|
191
|
+
engine.loadPolicies(permitAllPolicy);
|
|
192
|
+
|
|
193
|
+
const longString = 'x'.repeat(200);
|
|
194
|
+
|
|
195
|
+
expect(() => {
|
|
196
|
+
engine.evaluateSimple(
|
|
197
|
+
EntityType.Scanner,
|
|
198
|
+
'test',
|
|
199
|
+
ActionType.ScanArtifact,
|
|
200
|
+
EntityType.Artifact,
|
|
201
|
+
'/model.safetensors',
|
|
202
|
+
{ value: longString }
|
|
203
|
+
);
|
|
204
|
+
}).toThrow(InputValidationError);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should reject deeply nested context', () => {
|
|
208
|
+
const engine = new PolicyEngine({ limits: { maxNestingDepth: 3 } });
|
|
209
|
+
engine.loadPolicies(permitAllPolicy);
|
|
210
|
+
|
|
211
|
+
const deepContext = {
|
|
212
|
+
level1: {
|
|
213
|
+
level2: {
|
|
214
|
+
level3: {
|
|
215
|
+
level4: {
|
|
216
|
+
level5: 'too deep',
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
expect(() => {
|
|
224
|
+
engine.evaluateSimple(
|
|
225
|
+
EntityType.Scanner,
|
|
226
|
+
'test',
|
|
227
|
+
ActionType.ScanArtifact,
|
|
228
|
+
EntityType.Artifact,
|
|
229
|
+
'/model.safetensors',
|
|
230
|
+
deepContext
|
|
231
|
+
);
|
|
232
|
+
}).toThrow(InputValidationError);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should allow skipping validation', () => {
|
|
236
|
+
const engine = new PolicyEngine({
|
|
237
|
+
skipValidation: true,
|
|
238
|
+
limits: { maxContextKeys: 1 },
|
|
239
|
+
});
|
|
240
|
+
engine.loadPolicies(permitAllPolicy);
|
|
241
|
+
|
|
242
|
+
// This would normally fail validation
|
|
243
|
+
const decision = engine.evaluateSimple(
|
|
244
|
+
EntityType.Scanner,
|
|
245
|
+
'test',
|
|
246
|
+
ActionType.ScanArtifact,
|
|
247
|
+
EntityType.Artifact,
|
|
248
|
+
'/model.safetensors',
|
|
249
|
+
{ key1: 'value1', key2: 'value2', key3: 'value3' }
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(decision.effect).toBe('Allow');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('complex context types', () => {
|
|
257
|
+
beforeEach(() => {
|
|
258
|
+
engine.loadPolicies(permitAllPolicy);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should handle array context values', () => {
|
|
262
|
+
const decision = engine.evaluateSimple(
|
|
263
|
+
EntityType.Scanner,
|
|
264
|
+
'test',
|
|
265
|
+
ActionType.ScanArtifact,
|
|
266
|
+
EntityType.Artifact,
|
|
267
|
+
'/model.safetensors',
|
|
268
|
+
{ threats: ['malware', 'backdoor', 'injection'] }
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(decision.effect).toBe('Allow');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should handle nested object context', () => {
|
|
275
|
+
const decision = engine.evaluateSimple(
|
|
276
|
+
EntityType.Scanner,
|
|
277
|
+
'test',
|
|
278
|
+
ActionType.ScanArtifact,
|
|
279
|
+
EntityType.Artifact,
|
|
280
|
+
'/model.safetensors',
|
|
281
|
+
{
|
|
282
|
+
metadata: {
|
|
283
|
+
format: 'safetensors',
|
|
284
|
+
size: 1024,
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
expect(decision.effect).toBe('Allow');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should handle empty context', () => {
|
|
293
|
+
// Cedar doesn't have null values - this tests that empty context works
|
|
294
|
+
const decision = engine.evaluateSimple(
|
|
295
|
+
EntityType.Scanner,
|
|
296
|
+
'test',
|
|
297
|
+
ActionType.ScanArtifact,
|
|
298
|
+
EntityType.Artifact,
|
|
299
|
+
'/model.safetensors',
|
|
300
|
+
{}
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(decision.effect).toBe('Allow');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should handle boolean context values', () => {
|
|
307
|
+
const decision = engine.evaluateSimple(
|
|
308
|
+
EntityType.Scanner,
|
|
309
|
+
'test',
|
|
310
|
+
ActionType.ScanArtifact,
|
|
311
|
+
EntityType.Artifact,
|
|
312
|
+
'/model.safetensors',
|
|
313
|
+
{ is_signed: true, is_malicious: false }
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(decision.effect).toBe('Allow');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should handle integer context values', () => {
|
|
320
|
+
// Cedar uses Long type for numbers (integers only, no floats)
|
|
321
|
+
const decision = engine.evaluateSimple(
|
|
322
|
+
EntityType.Scanner,
|
|
323
|
+
'test',
|
|
324
|
+
ActionType.ScanArtifact,
|
|
325
|
+
EntityType.Artifact,
|
|
326
|
+
'/model.safetensors',
|
|
327
|
+
{ severity_score: 7, count: 100 }
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
expect(decision.effect).toBe('Allow');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('error handling', () => {
|
|
335
|
+
it('should handle invalid policy syntax gracefully', () => {
|
|
336
|
+
const invalidPolicy = `permit(principal, action, resource`; // Missing closing paren
|
|
337
|
+
|
|
338
|
+
expect(() => {
|
|
339
|
+
engine.loadPolicies(invalidPolicy);
|
|
340
|
+
engine.evaluateSimple(
|
|
341
|
+
EntityType.Scanner,
|
|
342
|
+
'test',
|
|
343
|
+
ActionType.ScanArtifact,
|
|
344
|
+
EntityType.Artifact,
|
|
345
|
+
'/model.safetensors'
|
|
346
|
+
);
|
|
347
|
+
}).not.toThrow();
|
|
348
|
+
|
|
349
|
+
// Should return deny with reason
|
|
350
|
+
engine.loadPolicies(invalidPolicy);
|
|
351
|
+
const decision = engine.evaluateSimple(
|
|
352
|
+
EntityType.Scanner,
|
|
353
|
+
'test',
|
|
354
|
+
ActionType.ScanArtifact,
|
|
355
|
+
EntityType.Artifact,
|
|
356
|
+
'/model.safetensors'
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(decision.effect).toBe('Deny');
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('DEFAULT_LIMITS', () => {
|
|
364
|
+
it('should have consistent default values', () => {
|
|
365
|
+
expect(DEFAULT_LIMITS.maxContextKeys).toBe(100);
|
|
366
|
+
expect(DEFAULT_LIMITS.maxStringLength).toBe(1_000_000);
|
|
367
|
+
expect(DEFAULT_LIMITS.maxNestingDepth).toBe(10);
|
|
368
|
+
expect(DEFAULT_LIMITS.maxContextSizeBytes).toBe(10_000_000);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
});
|
package/src/engine.ts
CHANGED
|
@@ -8,6 +8,132 @@ import { EntityType, EntityUID, Entity } from "./entities.gen.js";
|
|
|
8
8
|
import { ActionType } from "./actions.gen.js";
|
|
9
9
|
import { CEDAR_SCHEMA } from "./schema.gen.js";
|
|
10
10
|
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// INPUT VALIDATION LIMITS (consistent across all language SDKs)
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default limits for input validation.
|
|
17
|
+
* These are consistent across Go, TypeScript, and Python SDKs.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_LIMITS = {
|
|
20
|
+
/** Maximum number of keys in a context map */
|
|
21
|
+
maxContextKeys: 100,
|
|
22
|
+
/** Maximum length of any string value (1MB) */
|
|
23
|
+
maxStringLength: 1_000_000,
|
|
24
|
+
/** Maximum nesting depth for objects/arrays */
|
|
25
|
+
maxNestingDepth: 10,
|
|
26
|
+
/** Maximum total context size in bytes (10MB) */
|
|
27
|
+
maxContextSizeBytes: 10_000_000,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export interface InputLimits {
|
|
31
|
+
maxContextKeys?: number;
|
|
32
|
+
maxStringLength?: number;
|
|
33
|
+
maxNestingDepth?: number;
|
|
34
|
+
maxContextSizeBytes?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EngineOptions {
|
|
38
|
+
/** Custom input validation limits */
|
|
39
|
+
limits?: InputLimits;
|
|
40
|
+
/** Skip input validation (not recommended for production) */
|
|
41
|
+
skipValidation?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Error thrown when input validation fails.
|
|
46
|
+
*/
|
|
47
|
+
export class InputValidationError extends Error {
|
|
48
|
+
constructor(message: string) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "InputValidationError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate context input against configured limits.
|
|
56
|
+
* @throws InputValidationError if validation fails
|
|
57
|
+
*/
|
|
58
|
+
function validateContext(
|
|
59
|
+
context: Record<string, unknown> | undefined,
|
|
60
|
+
limits: Required<InputLimits>,
|
|
61
|
+
depth: number = 0
|
|
62
|
+
): void {
|
|
63
|
+
if (!context) return;
|
|
64
|
+
|
|
65
|
+
// Check nesting depth
|
|
66
|
+
if (depth > limits.maxNestingDepth) {
|
|
67
|
+
throw new InputValidationError(
|
|
68
|
+
`Context nesting depth exceeds maximum of ${limits.maxNestingDepth}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const keys = Object.keys(context);
|
|
73
|
+
|
|
74
|
+
// Check number of keys (only at top level)
|
|
75
|
+
if (depth === 0 && keys.length > limits.maxContextKeys) {
|
|
76
|
+
throw new InputValidationError(
|
|
77
|
+
`Context has ${keys.length} keys, exceeds maximum of ${limits.maxContextKeys}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check total size (only at top level)
|
|
82
|
+
if (depth === 0) {
|
|
83
|
+
let contextStr: string;
|
|
84
|
+
try {
|
|
85
|
+
contextStr = JSON.stringify(context);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
throw new InputValidationError(
|
|
88
|
+
`Context is invalid or too complex: ${e instanceof Error ? e.message : String(e)}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (contextStr.length > limits.maxContextSizeBytes) {
|
|
92
|
+
throw new InputValidationError(
|
|
93
|
+
`Context size (${contextStr.length} bytes) exceeds maximum of ${limits.maxContextSizeBytes} bytes`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate each value
|
|
99
|
+
for (const value of Object.values(context)) {
|
|
100
|
+
validateValue(value, limits, depth);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateValue(
|
|
105
|
+
value: unknown,
|
|
106
|
+
limits: Required<InputLimits>,
|
|
107
|
+
depth: number
|
|
108
|
+
): void {
|
|
109
|
+
if (value === null || value === undefined) return;
|
|
110
|
+
|
|
111
|
+
if (typeof value === "string") {
|
|
112
|
+
if (value.length > limits.maxStringLength) {
|
|
113
|
+
throw new InputValidationError(
|
|
114
|
+
`String value length (${value.length}) exceeds maximum of ${limits.maxStringLength}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
if (depth + 1 > limits.maxNestingDepth) {
|
|
122
|
+
throw new InputValidationError(
|
|
123
|
+
`Array nesting depth exceeds maximum of ${limits.maxNestingDepth}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
for (const item of value) {
|
|
127
|
+
validateValue(item, limits, depth + 1);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (typeof value === "object") {
|
|
133
|
+
validateContext(value as Record<string, unknown>, limits, depth + 1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
11
137
|
export interface Decision {
|
|
12
138
|
effect: "Allow" | "Deny";
|
|
13
139
|
determining_policies: string[];
|
|
@@ -50,6 +176,18 @@ function toCedarValue(value: unknown): cedar.CedarValueJson {
|
|
|
50
176
|
export class PolicyEngine {
|
|
51
177
|
private policies: string = "";
|
|
52
178
|
private schema: string | undefined;
|
|
179
|
+
private options: EngineOptions;
|
|
180
|
+
private limits: Required<InputLimits>;
|
|
181
|
+
|
|
182
|
+
constructor(options?: EngineOptions) {
|
|
183
|
+
this.options = options ?? {};
|
|
184
|
+
this.limits = {
|
|
185
|
+
maxContextKeys: options?.limits?.maxContextKeys ?? DEFAULT_LIMITS.maxContextKeys,
|
|
186
|
+
maxStringLength: options?.limits?.maxStringLength ?? DEFAULT_LIMITS.maxStringLength,
|
|
187
|
+
maxNestingDepth: options?.limits?.maxNestingDepth ?? DEFAULT_LIMITS.maxNestingDepth,
|
|
188
|
+
maxContextSizeBytes: options?.limits?.maxContextSizeBytes ?? DEFAULT_LIMITS.maxContextSizeBytes,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
53
191
|
|
|
54
192
|
/**
|
|
55
193
|
* Load policies from a Cedar policy string.
|
|
@@ -75,8 +213,14 @@ export class PolicyEngine {
|
|
|
75
213
|
|
|
76
214
|
/**
|
|
77
215
|
* Evaluate a policy request and return a decision.
|
|
216
|
+
* @throws InputValidationError if context validation fails
|
|
78
217
|
*/
|
|
79
218
|
evaluate(req: EvaluateRequest): Decision {
|
|
219
|
+
// Validate input unless explicitly skipped
|
|
220
|
+
if (!this.options.skipValidation) {
|
|
221
|
+
validateContext(req.context, this.limits);
|
|
222
|
+
}
|
|
223
|
+
|
|
80
224
|
// Build EntityUIDs in Cedar JSON format
|
|
81
225
|
const principal: cedar.EntityUidJson = {
|
|
82
226
|
type: req.principal.type,
|
|
@@ -135,6 +279,7 @@ export class PolicyEngine {
|
|
|
135
279
|
|
|
136
280
|
/**
|
|
137
281
|
* Convenience method for simple evaluations.
|
|
282
|
+
* @throws InputValidationError if context validation fails
|
|
138
283
|
*/
|
|
139
284
|
evaluateSimple(
|
|
140
285
|
principalType: EntityType,
|
package/src/entities.gen.ts
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
export const EntityType = {
|
|
8
8
|
Agent: 'Agent',
|
|
9
9
|
Artifact: 'Artifact',
|
|
10
|
+
ExternalAPI: 'ExternalAPI',
|
|
10
11
|
FilePath: 'FilePath',
|
|
12
|
+
GitBranch: 'GitBranch',
|
|
11
13
|
HttpEndpoint: 'HttpEndpoint',
|
|
14
|
+
Memory: 'Memory',
|
|
15
|
+
Model: 'Model',
|
|
12
16
|
Package: 'Package',
|
|
13
17
|
Repository: 'Repository',
|
|
14
18
|
Resource: 'Resource',
|