@highflame/policy 2.0.9 → 2.1.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.
Files changed (50) hide show
  1. package/_schemas/guardrails/context.json +435 -0
  2. package/_schemas/guardrails/schema.cedarschema +225 -0
  3. package/_schemas/guardrails/templates/defaults/agentic_safety.cedar +94 -0
  4. package/_schemas/guardrails/templates/defaults/baseline.cedar +24 -0
  5. package/_schemas/guardrails/templates/defaults/injection.cedar +70 -0
  6. package/_schemas/guardrails/templates/defaults/pii.cedar +48 -0
  7. package/_schemas/guardrails/templates/defaults/secrets.cedar +40 -0
  8. package/_schemas/guardrails/templates/defaults/semantic.cedar +59 -0
  9. package/_schemas/guardrails/templates/defaults/tool_risk.cedar +58 -0
  10. package/_schemas/guardrails/templates/defaults/toxicity.cedar +76 -0
  11. package/_schemas/guardrails/templates/mcp_tool_permissions.cedar +84 -0
  12. package/_schemas/guardrails/templates/profiles/chat_assistant/privacy.cedar +22 -0
  13. package/_schemas/guardrails/templates/profiles/chat_assistant/security.cedar +35 -0
  14. package/_schemas/guardrails/templates/profiles/chat_assistant/trust_safety.cedar +43 -0
  15. package/_schemas/guardrails/templates/profiles/chat_assistant.cedar +85 -0
  16. package/_schemas/guardrails/templates/profiles/code_agent/agentic_security.cedar +109 -0
  17. package/_schemas/guardrails/templates/profiles/code_agent/security.cedar +22 -0
  18. package/_schemas/guardrails/templates/profiles/code_agent.cedar +125 -0
  19. package/_schemas/guardrails/templates/profiles/data_pipeline/agentic_security.cedar +38 -0
  20. package/_schemas/guardrails/templates/profiles/data_pipeline/privacy.cedar +40 -0
  21. package/_schemas/guardrails/templates/profiles/data_pipeline/security.cedar +49 -0
  22. package/_schemas/guardrails/templates/profiles/data_pipeline.cedar +111 -0
  23. package/_schemas/guardrails/templates/templates.json +213 -0
  24. package/_schemas/overwatch/context.json +54 -54
  25. package/_schemas/overwatch/schema.cedarschema +77 -68
  26. package/dist/builder.d.ts +106 -13
  27. package/dist/builder.js +103 -34
  28. package/dist/engine.d.ts +20 -2
  29. package/dist/engine.js +50 -20
  30. package/dist/entities.gen.d.ts +4 -0
  31. package/dist/entities.gen.js +4 -0
  32. package/dist/explain.d.ts +150 -0
  33. package/dist/explain.js +363 -0
  34. package/dist/guardrails-context.gen.d.ts +49 -0
  35. package/dist/guardrails-context.gen.js +50 -0
  36. package/dist/guardrails-defaults.gen.d.ts +61 -0
  37. package/dist/guardrails-defaults.gen.js +1278 -0
  38. package/dist/guardrails-entities.gen.d.ts +11 -0
  39. package/dist/guardrails-entities.gen.js +37 -0
  40. package/dist/index.d.ts +6 -1
  41. package/dist/index.js +6 -1
  42. package/dist/overwatch-defaults.gen.js +122 -2
  43. package/dist/parser.js +136 -4
  44. package/dist/schema.gen.d.ts +1 -1
  45. package/dist/schema.gen.js +6 -0
  46. package/dist/service-schemas.gen.d.ts +15 -11
  47. package/dist/service-schemas.gen.js +509 -84
  48. package/dist/types.d.ts +6 -1
  49. package/dist/types.js +6 -1
  50. package/package.json +5 -1
@@ -84,8 +84,8 @@ action process_prompt appliesTo {
84
84
  user_email: String, // User identifier
85
85
 
86
86
  // Workspace
87
- cwd: String, // Current working directory
88
- workspace_root: String, // Workspace/repository root
87
+ cwd?: String, // Current working directory
88
+ workspace_root?: String, // Workspace/repository root
89
89
 
90
90
  // Threat Detection
91
91
  threat_count: Long, // Total threats detected
@@ -94,24 +94,27 @@ action process_prompt appliesTo {
94
94
  yara_threats: Set<String>, // YARA rule names
95
95
  max_threat_severity: Long, // Numeric severity (0-4)
96
96
  contains_secrets: Bool, // Whether secrets detected
97
- prompt_text: String, // Same as content (legacy)
98
- response_content: String, // Response content (if available)
97
+ prompt_text?: String, // Same as content (legacy)
98
+ response_content?: String, // Response content (if available)
99
99
 
100
100
  // Trust/Safety Scores (0-100, from Javelin/Lakera/LlamaGuard classifiers)
101
- violence_score: Long, // Violence content detection score
102
- weapons_score: Long, // Weapons content detection score
103
- hate_speech_score: Long, // Hate speech detection score
104
- crime_score: Long, // Criminal content detection score
105
- sexual_score: Long, // Sexual content detection score
106
- profanity_score: Long, // Profanity detection score
101
+ // Required: content safety classifiers always run for prompt processing
102
+ violence_score: Long, // Violence content detection score
103
+ weapons_score: Long, // Weapons content detection score
104
+ hate_speech_score: Long, // Hate speech detection score
105
+ crime_score: Long, // Criminal content detection score
106
+ sexual_score: Long, // Sexual content detection score
107
+ profanity_score: Long, // Profanity detection score
107
108
 
108
109
  // Detector Confidence Scores (0-100, ML classifier confidence)
109
- pii_confidence: Long, // PII detection confidence
110
- injection_confidence: Long, // Prompt injection confidence
111
- jailbreak_confidence: Long, // Jailbreak detection confidence
110
+ // Required: ML classifiers always run for prompt processing
111
+ pii_confidence: Long, // PII detection confidence
112
+ injection_confidence: Long, // Prompt injection confidence
113
+ jailbreak_confidence: Long, // Jailbreak detection confidence
112
114
 
113
115
  // Agent Security (0-100)
114
- indirect_injection_score: Long, // Indirect prompt injection risk
116
+ // Required: agent security scanners always run for prompt processing
117
+ indirect_injection_score: Long, // Indirect prompt injection risk
115
118
  },
116
119
  };
117
120
 
@@ -127,46 +130,50 @@ action call_tool appliesTo {
127
130
  user_email: String, // User identifier
128
131
 
129
132
  // Tool & MCP
130
- tool_name: String, // Normalized tool name ("shell", "read_file", etc.)
131
- mcp_server: String, // MCP server name
132
- mcp_tool: String, // MCP tool name
133
+ tool_name?: String, // Normalized tool name ("shell", "read_file", etc.)
134
+ mcp_server?: String, // MCP server name
135
+ mcp_tool?: String, // MCP tool name
133
136
 
134
137
  // File & Path
135
- path: String, // File path (if file operation)
138
+ path?: String, // File path (if file operation)
136
139
 
137
140
  // Workspace
138
- cwd: String,
139
- workspace_root: String,
140
-
141
- // Threat Detection
142
- threat_count: Long,
143
- highest_severity: String,
144
- threat_categories: Set<String>,
145
- yara_threats: Set<String>,
146
- max_threat_severity: Long,
147
- contains_secrets: Bool,
148
- response_content: String,
141
+ cwd?: String,
142
+ workspace_root?: String,
143
+
144
+ // Threat Detection (optional: scanning may not have run before tool call)
145
+ threat_count?: Long,
146
+ highest_severity?: String,
147
+ threat_categories?: Set<String>,
148
+ yara_threats?: Set<String>,
149
+ max_threat_severity?: Long,
150
+ contains_secrets?: Bool,
151
+ response_content?: String,
149
152
 
150
153
  // Trust/Safety Scores (0-100, from Javelin/Lakera/LlamaGuard classifiers)
151
- violence_score: Long, // Violence content detection score
152
- weapons_score: Long, // Weapons content detection score
153
- hate_speech_score: Long, // Hate speech detection score
154
- crime_score: Long, // Criminal content detection score
155
- sexual_score: Long, // Sexual content detection score
156
- profanity_score: Long, // Profanity detection score
154
+ // Optional: only present when trust/safety classifiers have run
155
+ violence_score?: Long, // Violence content detection score
156
+ weapons_score?: Long, // Weapons content detection score
157
+ hate_speech_score?: Long, // Hate speech detection score
158
+ crime_score?: Long, // Criminal content detection score
159
+ sexual_score?: Long, // Sexual content detection score
160
+ profanity_score?: Long, // Profanity detection score
157
161
 
158
162
  // Detector Confidence Scores (0-100, ML classifier confidence)
159
- pii_confidence: Long, // PII detection confidence
160
- injection_confidence: Long, // Prompt injection confidence
161
- jailbreak_confidence: Long, // Jailbreak detection confidence
163
+ // Optional: only present when ML classifiers have run
164
+ pii_confidence?: Long, // PII detection confidence
165
+ injection_confidence?: Long, // Prompt injection confidence
166
+ jailbreak_confidence?: Long, // Jailbreak detection confidence
162
167
 
163
168
  // Agent Security (0-100)
164
- tool_poisoning_score: Long, // Tool description manipulation risk
165
- rug_pull_score: Long, // Tool behavior mismatch risk
166
- indirect_injection_score: Long, // Indirect prompt injection risk
169
+ // Optional: only present when agent security scanners have run
170
+ tool_poisoning_score?: Long, // Tool description manipulation risk
171
+ rug_pull_score?: Long, // Tool behavior mismatch risk
172
+ indirect_injection_score?: Long, // Indirect prompt injection risk
167
173
 
168
174
  // MCP Trust
169
- mcp_server_verified: Bool, // Whether server is from verified registry
175
+ // Optional: only present when MCP server verification has run
176
+ mcp_server_verified?: Bool, // Whether server is from verified registry
170
177
  },
171
178
  };
172
179
 
@@ -175,23 +182,25 @@ action connect_server appliesTo {
175
182
  principal: [User, Agent],
176
183
  resource: [Server],
177
184
  context: {
178
- content: String,
185
+ content?: String, // No content to scan when connecting
179
186
  source: String,
180
187
  event: String,
181
188
  user_email: String,
182
- mcp_server: String,
183
- threat_count: Long,
184
- highest_severity: String,
185
- threat_categories: Set<String>,
186
- max_threat_severity: Long,
189
+ mcp_server?: String,
190
+ threat_count?: Long, // Threat scanning may not run for connections
191
+ highest_severity?: String,
192
+ threat_categories?: Set<String>,
193
+ max_threat_severity?: Long,
187
194
 
188
195
  // Agent Security (0-100)
189
- tool_poisoning_score: Long, // Tool description manipulation risk
190
- rug_pull_score: Long, // Tool behavior mismatch risk
191
- indirect_injection_score: Long, // Indirect prompt injection risk
196
+ // Optional: only present when agent security scanners have run
197
+ tool_poisoning_score?: Long, // Tool description manipulation risk
198
+ rug_pull_score?: Long, // Tool behavior mismatch risk
199
+ indirect_injection_score?: Long, // Indirect prompt injection risk
192
200
 
193
201
  // MCP Trust
194
- mcp_server_verified: Bool, // Whether server is from verified registry
202
+ // Optional: only present when MCP server verification has run
203
+ mcp_server_verified?: Bool, // Whether server is from verified registry
195
204
  },
196
205
  };
197
206
 
@@ -204,14 +213,14 @@ action read_file appliesTo {
204
213
  source: String,
205
214
  event: String,
206
215
  user_email: String,
207
- path: String,
208
- cwd: String,
209
- workspace_root: String,
210
- threat_count: Long,
211
- highest_severity: String,
212
- threat_categories: Set<String>,
213
- max_threat_severity: Long,
214
- contains_secrets: Bool,
216
+ path?: String,
217
+ cwd?: String,
218
+ workspace_root?: String,
219
+ threat_count?: Long, // Threat scanning may not have run
220
+ highest_severity?: String,
221
+ threat_categories?: Set<String>,
222
+ max_threat_severity?: Long,
223
+ contains_secrets?: Bool,
215
224
  },
216
225
  };
217
226
 
@@ -224,14 +233,14 @@ action write_file appliesTo {
224
233
  source: String,
225
234
  event: String,
226
235
  user_email: String,
227
- path: String,
228
- cwd: String,
229
- workspace_root: String,
230
- threat_count: Long,
231
- highest_severity: String,
232
- threat_categories: Set<String>,
233
- max_threat_severity: Long,
234
- contains_secrets: Bool,
236
+ path?: String,
237
+ cwd?: String,
238
+ workspace_root?: String,
239
+ threat_count?: Long, // Threat scanning may not have run
240
+ highest_severity?: String,
241
+ threat_categories?: Set<String>,
242
+ max_threat_severity?: Long,
243
+ contains_secrets?: Bool,
235
244
  },
236
245
  };
237
246
 
package/dist/builder.d.ts CHANGED
@@ -33,6 +33,7 @@
33
33
  import { EntityType, EntityUID } from './entities.gen.js';
34
34
  import { ActionType } from './actions.gen.js';
35
35
  import { type PolicyAnnotations, type CustomAnnotations, type PolicySeverity } from './annotations.js';
36
+ import type { ServiceContext } from './service-schemas.gen.js';
36
37
  /**
37
38
  * Policy effect - permit or forbid
38
39
  */
@@ -52,16 +53,75 @@ export interface PolicyCondition {
52
53
  /** The value to compare against */
53
54
  value: string | number | boolean | string[];
54
55
  }
56
+ /** context.field <op> value */
57
+ export interface ConditionComparison {
58
+ kind: 'comparison';
59
+ field: string;
60
+ operator: ConditionOperator;
61
+ value: string | number | boolean | string[];
62
+ }
63
+ /** context.field.contains(value) */
64
+ export interface ConditionContains {
65
+ kind: 'contains';
66
+ field: string;
67
+ value: string | number | boolean;
68
+ }
69
+ /** context.field like "pattern" */
70
+ export interface ConditionLike {
71
+ kind: 'like';
72
+ field: string;
73
+ pattern: string;
74
+ }
75
+ /** context has field (existence check) */
76
+ export interface ConditionHas {
77
+ kind: 'has';
78
+ field: string;
79
+ }
80
+ /** N-ary AND (flattened from binary && chains) */
81
+ export interface ConditionAnd {
82
+ kind: 'and';
83
+ children: ConditionExpression[];
84
+ }
85
+ /** N-ary OR (flattened from binary || chains) */
86
+ export interface ConditionOr {
87
+ kind: 'or';
88
+ children: ConditionExpression[];
89
+ }
90
+ /** Unary NOT */
91
+ export interface ConditionNot {
92
+ kind: 'not';
93
+ child: ConditionExpression;
94
+ }
95
+ /** Fallback for expressions that cannot be decomposed */
96
+ export interface ConditionRaw {
97
+ kind: 'raw';
98
+ text: string;
99
+ }
100
+ /**
101
+ * Recursive condition expression tree parsed from Cedar JSON AST.
102
+ * Used by Studio UI for visual condition block rendering and by
103
+ * explainDecision() for per-condition evaluation with actual values.
104
+ */
105
+ export type ConditionExpression = ConditionComparison | ConditionContains | ConditionLike | ConditionHas | ConditionAnd | ConditionOr | ConditionNot | ConditionRaw;
55
106
  /**
56
107
  * Principal or resource entity constraint.
57
108
  * Used to specify type-only constraints (any entity of type) or
58
109
  * specific entity constraints (type + id).
110
+ *
111
+ * The `operator` field controls Cedar scope syntax:
112
+ * - `'eq'` (default): `resource == Type::"id"` — exact match
113
+ * - `'in'`: `resource in Type::"id"` — hierarchy match (descendants)
114
+ *
115
+ * Use `'in'` for container resource types (Account, Project, App) to match
116
+ * all descendant entities. Use `'eq'` for leaf types (Session, Tool).
59
117
  */
60
118
  export interface PolicyEntity {
61
119
  /** Entity type (e.g., "Agent", "Tool", "FilePath", "User") */
62
120
  type: string;
63
121
  /** Optional specific entity ID. If omitted, matches any entity of this type. */
64
122
  id?: string;
123
+ /** Scope operator: 'eq' for exact match (==), 'in' for hierarchy match (in). Default: 'eq' */
124
+ operator?: 'eq' | 'in';
65
125
  }
66
126
  /** Alias for PolicyEntity when used as principal constraint */
67
127
  export type PolicyPrincipal = PolicyEntity;
@@ -129,6 +189,8 @@ export interface PolicyRule {
129
189
  conditions: PolicyCondition[];
130
190
  /** Raw condition string (for advanced/complex conditions) */
131
191
  rawCondition?: string;
192
+ /** Recursive condition expression tree from Cedar JSON AST */
193
+ conditionExpression?: ConditionExpression;
132
194
  /** Whether this rule is active - NOT embedded in Cedar (runtime state) */
133
195
  enabled: boolean;
134
196
  /** Display/evaluation order - NOT embedded in Cedar (runtime state) */
@@ -163,8 +225,11 @@ export declare class Policy {
163
225
  /**
164
226
  * Convert to Cedar policy text.
165
227
  * Uses proper Cedar @annotation syntax.
228
+ *
229
+ * @param optionalFields - Set of context field names that are optional and need `context has` guards.
230
+ * Use `getOptionalFields()` to compute this from service context metadata.
166
231
  */
167
- toCedar(): string;
232
+ toCedar(optionalFields?: Set<string>): string;
168
233
  /**
169
234
  * Get JSON representation for storage
170
235
  */
@@ -178,10 +243,35 @@ export declare class Policy {
178
243
  */
179
244
  getName(): string | undefined;
180
245
  }
246
+ /**
247
+ * Get the set of optional context fields for the given action(s).
248
+ *
249
+ * A field is considered optional if it has `required: false` in ANY of the
250
+ * targeted actions. This is the safe choice because at evaluation time,
251
+ * the policy could be matched against any of the specified actions.
252
+ *
253
+ * @param serviceContext - The service context metadata (from OVERWATCH_CONTEXT, etc.)
254
+ * @param actions - Single action name or array of action names
255
+ * @returns Set of field names that are optional and need `context has` guards
256
+ *
257
+ * @example
258
+ * ```typescript
259
+ * import { OVERWATCH_CONTEXT } from '@highflame/policy/types';
260
+ *
261
+ * const optionalFields = getOptionalFields(OVERWATCH_CONTEXT, 'call_tool');
262
+ * // Set { 'tool_name', 'mcp_server', 'threat_count', ... }
263
+ *
264
+ * const cedar = ruleToCedar(rule, optionalFields);
265
+ * // Conditions on optional fields auto-get `context has` guards
266
+ * ```
267
+ */
268
+ export declare function getOptionalFields(serviceContext: ServiceContext, actions: string | string[]): Set<string>;
181
269
  /**
182
270
  * Convert a PolicyRule to Cedar policy text with proper annotations.
183
271
  *
184
272
  * @param rule - The PolicyRule to convert
273
+ * @param optionalFields - Set of context field names that are optional and need `context has` guards.
274
+ * Use `getOptionalFields()` to compute this from service context metadata.
185
275
  * @returns Cedar policy text string
186
276
  *
187
277
  * @example
@@ -197,35 +287,38 @@ export declare class Policy {
197
287
  * order: 0,
198
288
  * };
199
289
  *
290
+ * // Without optional fields - no guards injected
200
291
  * const cedar = ruleToCedar(rule);
201
- * // Output:
202
- * // @id("rule-001")
203
- * // @name("Block threats")
204
- * // @severity("high")
205
- * // forbid (
206
- * // principal,
207
- * // action == Action::"call_tool",
208
- * // resource
209
- * // )
210
- * // when { context.threat_count > 0 };
292
+ *
293
+ * // With optional fields - auto-injects `context has` guards
294
+ * import { OVERWATCH_CONTEXT } from '@highflame/policy/types';
295
+ * const optionalFields = getOptionalFields(OVERWATCH_CONTEXT, 'call_tool');
296
+ * const cedarWithGuards = ruleToCedar(rule, optionalFields);
297
+ * // when { context has threat_count && context.threat_count > 0 };
211
298
  * ```
212
299
  */
213
- export declare function ruleToCedar(rule: PolicyRule): string;
300
+ export declare function ruleToCedar(rule: PolicyRule, optionalFields?: Set<string>): string;
214
301
  /**
215
302
  * Convert multiple PolicyRules to Cedar policy text.
216
303
  * Only enabled rules are included, sorted by order.
217
304
  *
218
305
  * @param rules - Array of PolicyRules to convert
219
306
  * @param includeDisabled - If true, include disabled rules as comments (default: false)
307
+ * @param optionalFields - Set of context field names that are optional and need `context has` guards.
308
+ * Use `getOptionalFields()` to compute this from service context metadata.
220
309
  * @returns Cedar policy text with all rules separated by blank lines
221
310
  *
222
311
  * @example
223
312
  * ```typescript
224
313
  * const rules: PolicyRule[] = [...];
225
314
  * const cedarText = rulesToCedar(rules);
315
+ *
316
+ * // With optional fields awareness
317
+ * const optionalFields = getOptionalFields(OVERWATCH_CONTEXT, 'call_tool');
318
+ * const cedarText = rulesToCedar(rules, false, optionalFields);
226
319
  * ```
227
320
  */
228
- export declare function rulesToCedar(rules: PolicyRule[], includeDisabled?: boolean): string;
321
+ export declare function rulesToCedar(rules: PolicyRule[], includeDisabled?: boolean, optionalFields?: Set<string>): string;
229
322
  /**
230
323
  * Builder for constructing Cedar policies with type safety.
231
324
  */
package/dist/builder.js CHANGED
@@ -133,8 +133,11 @@ export class Policy {
133
133
  /**
134
134
  * Convert to Cedar policy text.
135
135
  * Uses proper Cedar @annotation syntax.
136
+ *
137
+ * @param optionalFields - Set of context field names that are optional and need `context has` guards.
138
+ * Use `getOptionalFields()` to compute this from service context metadata.
136
139
  */
137
- toCedar() {
140
+ toCedar(optionalFields) {
138
141
  const lines = [];
139
142
  // Generate proper Cedar annotations
140
143
  if (this.data.id || this.data.name) {
@@ -145,7 +148,7 @@ export class Policy {
145
148
  lines.push(...generateAnnotationLines(annotations));
146
149
  }
147
150
  // Generate policy body
148
- lines.push(generatePolicyBody(this.data.effect, this.data.principal, this.data.action, this.data.resource, this.data.conditions, this.data.rawCondition));
151
+ lines.push(generatePolicyBody(this.data.effect, this.data.principal, this.data.action, this.data.resource, this.data.conditions, this.data.rawCondition, optionalFields));
149
152
  return lines.join('\n');
150
153
  }
151
154
  /**
@@ -170,40 +173,99 @@ export class Policy {
170
173
  // ============================================================================
171
174
  // Cedar Generation Functions
172
175
  // ============================================================================
176
+ /**
177
+ * Get the set of optional context fields for the given action(s).
178
+ *
179
+ * A field is considered optional if it has `required: false` in ANY of the
180
+ * targeted actions. This is the safe choice because at evaluation time,
181
+ * the policy could be matched against any of the specified actions.
182
+ *
183
+ * @param serviceContext - The service context metadata (from OVERWATCH_CONTEXT, etc.)
184
+ * @param actions - Single action name or array of action names
185
+ * @returns Set of field names that are optional and need `context has` guards
186
+ *
187
+ * @example
188
+ * ```typescript
189
+ * import { OVERWATCH_CONTEXT } from '@highflame/policy/types';
190
+ *
191
+ * const optionalFields = getOptionalFields(OVERWATCH_CONTEXT, 'call_tool');
192
+ * // Set { 'tool_name', 'mcp_server', 'threat_count', ... }
193
+ *
194
+ * const cedar = ruleToCedar(rule, optionalFields);
195
+ * // Conditions on optional fields auto-get `context has` guards
196
+ * ```
197
+ */
198
+ export function getOptionalFields(serviceContext, actions) {
199
+ const actionList = Array.isArray(actions) ? actions : [actions];
200
+ const optionalFields = new Set();
201
+ for (const actionName of actionList) {
202
+ const action = serviceContext.actions.find(a => a.name === actionName);
203
+ if (action) {
204
+ for (const attr of action.context_attributes) {
205
+ if (!attr.required) {
206
+ optionalFields.add(attr.key);
207
+ }
208
+ }
209
+ }
210
+ }
211
+ return optionalFields;
212
+ }
173
213
  /**
174
214
  * Convert a condition to Cedar syntax.
175
215
  * Field names are sanitized to prevent injection attacks.
216
+ *
217
+ * When `optionalFields` is provided and the condition's field is in the set,
218
+ * the output is wrapped with a `context has` guard:
219
+ * `context has field && context.field > value`
176
220
  */
177
- function conditionToCedar(condition) {
221
+ function conditionToCedar(condition, optionalFields) {
178
222
  const field = sanitizeIdentifier(condition.field, 'field');
179
223
  const { operator, value } = condition;
180
224
  const valueStr = valueToString(value);
225
+ let expr;
181
226
  switch (operator) {
182
227
  case 'eq':
183
- return `context.${field} == ${valueStr}`;
228
+ expr = `context.${field} == ${valueStr}`;
229
+ break;
184
230
  case 'neq':
185
- return `context.${field} != ${valueStr}`;
231
+ expr = `context.${field} != ${valueStr}`;
232
+ break;
186
233
  case 'lt':
187
- return `context.${field} < ${valueStr}`;
234
+ expr = `context.${field} < ${valueStr}`;
235
+ break;
188
236
  case 'lte':
189
- return `context.${field} <= ${valueStr}`;
237
+ expr = `context.${field} <= ${valueStr}`;
238
+ break;
190
239
  case 'gt':
191
- return `context.${field} > ${valueStr}`;
240
+ expr = `context.${field} > ${valueStr}`;
241
+ break;
192
242
  case 'gte':
193
- return `context.${field} >= ${valueStr}`;
243
+ expr = `context.${field} >= ${valueStr}`;
244
+ break;
194
245
  case 'contains':
195
- return `context.${field}.contains(${valueStr})`;
246
+ expr = `context.${field}.contains(${valueStr})`;
247
+ break;
196
248
  case 'in':
197
249
  if (Array.isArray(value)) {
198
250
  const items = value.map(v => `"${escapeCedarString(v)}"`).join(', ');
199
- return `context.${field} in [${items}]`;
251
+ expr = `context.${field} in [${items}]`;
252
+ }
253
+ else {
254
+ expr = `context.${field} in ${valueStr}`;
200
255
  }
201
- return `context.${field} in ${valueStr}`;
256
+ break;
202
257
  case 'like':
203
- return `context.${field} like ${valueStr}`;
258
+ expr = `context.${field} like ${valueStr}`;
259
+ break;
204
260
  default:
205
- return `context.${field} == ${valueStr}`;
261
+ expr = `context.${field} == ${valueStr}`;
262
+ break;
263
+ }
264
+ // Auto-inject `context has` guard for optional fields
265
+ if (optionalFields?.has(field)) {
266
+ return `context has ${field} && ${expr}`;
206
267
  }
268
+ return expr;
207
269
  }
208
270
  /**
209
271
  * Convert a value to Cedar string representation.
@@ -225,13 +287,14 @@ function valueToString(value) {
225
287
  * Generate the Cedar policy body (permit/forbid statement).
226
288
  * All inputs are sanitized/escaped to prevent injection attacks.
227
289
  */
228
- function generatePolicyBody(effect, principal, action, resource, conditions, rawCondition) {
290
+ function generatePolicyBody(effect, principal, action, resource, conditions, rawCondition, optionalFields) {
229
291
  let policyLine = `${effect} (`;
230
292
  // Principal
231
293
  if (principal) {
232
294
  const entityType = sanitizeIdentifier(principal.type, 'principal_type');
233
295
  if (principal.id) {
234
- policyLine += `\n principal == ${entityType}::"${escapeCedarString(principal.id)}"`;
296
+ const op = principal.operator === 'in' ? 'in' : '==';
297
+ policyLine += `\n principal ${op} ${entityType}::"${escapeCedarString(principal.id)}"`;
235
298
  }
236
299
  else {
237
300
  policyLine += `\n principal is ${entityType}`;
@@ -264,7 +327,8 @@ function generatePolicyBody(effect, principal, action, resource, conditions, raw
264
327
  if (resource) {
265
328
  const entityType = sanitizeIdentifier(resource.type, 'resource_type');
266
329
  if (resource.id) {
267
- policyLine += `,\n resource == ${entityType}::"${escapeCedarString(resource.id)}"`;
330
+ const op = resource.operator === 'in' ? 'in' : '==';
331
+ policyLine += `,\n resource ${op} ${entityType}::"${escapeCedarString(resource.id)}"`;
268
332
  }
269
333
  else {
270
334
  policyLine += `,\n resource is ${entityType}`;
@@ -283,7 +347,7 @@ function generatePolicyBody(effect, principal, action, resource, conditions, raw
283
347
  }
284
348
  else if (conditions.length > 0) {
285
349
  // Fallback to structured conditions if rawCondition is rejected
286
- const conditionStr = conditions.map(c => conditionToCedar(c)).join(' && ');
350
+ const conditionStr = conditions.map(c => conditionToCedar(c, optionalFields)).join(' && ');
287
351
  policyLine += `\nwhen { ${conditionStr} };`;
288
352
  }
289
353
  else {
@@ -291,7 +355,7 @@ function generatePolicyBody(effect, principal, action, resource, conditions, raw
291
355
  }
292
356
  }
293
357
  else if (conditions.length > 0) {
294
- const conditionStr = conditions.map(c => conditionToCedar(c)).join(' && ');
358
+ const conditionStr = conditions.map(c => conditionToCedar(c, optionalFields)).join(' && ');
295
359
  policyLine += `\nwhen { ${conditionStr} };`;
296
360
  }
297
361
  else {
@@ -303,6 +367,8 @@ function generatePolicyBody(effect, principal, action, resource, conditions, raw
303
367
  * Convert a PolicyRule to Cedar policy text with proper annotations.
304
368
  *
305
369
  * @param rule - The PolicyRule to convert
370
+ * @param optionalFields - Set of context field names that are optional and need `context has` guards.
371
+ * Use `getOptionalFields()` to compute this from service context metadata.
306
372
  * @returns Cedar policy text string
307
373
  *
308
374
  * @example
@@ -318,25 +384,22 @@ function generatePolicyBody(effect, principal, action, resource, conditions, raw
318
384
  * order: 0,
319
385
  * };
320
386
  *
387
+ * // Without optional fields - no guards injected
321
388
  * const cedar = ruleToCedar(rule);
322
- * // Output:
323
- * // @id("rule-001")
324
- * // @name("Block threats")
325
- * // @severity("high")
326
- * // forbid (
327
- * // principal,
328
- * // action == Action::"call_tool",
329
- * // resource
330
- * // )
331
- * // when { context.threat_count > 0 };
389
+ *
390
+ * // With optional fields - auto-injects `context has` guards
391
+ * import { OVERWATCH_CONTEXT } from '@highflame/policy/types';
392
+ * const optionalFields = getOptionalFields(OVERWATCH_CONTEXT, 'call_tool');
393
+ * const cedarWithGuards = ruleToCedar(rule, optionalFields);
394
+ * // when { context has threat_count && context.threat_count > 0 };
332
395
  * ```
333
396
  */
334
- export function ruleToCedar(rule) {
397
+ export function ruleToCedar(rule, optionalFields) {
335
398
  const lines = [];
336
399
  // Generate Cedar annotations
337
400
  lines.push(...generateAnnotationLines(rule.annotations, rule.customAnnotations));
338
401
  // Generate policy body
339
- lines.push(generatePolicyBody(rule.effect, rule.principal, rule.action, rule.resource, rule.conditions, rule.rawCondition));
402
+ lines.push(generatePolicyBody(rule.effect, rule.principal, rule.action, rule.resource, rule.conditions, rule.rawCondition, optionalFields));
340
403
  return lines.join('\n');
341
404
  }
342
405
  /**
@@ -345,24 +408,30 @@ export function ruleToCedar(rule) {
345
408
  *
346
409
  * @param rules - Array of PolicyRules to convert
347
410
  * @param includeDisabled - If true, include disabled rules as comments (default: false)
411
+ * @param optionalFields - Set of context field names that are optional and need `context has` guards.
412
+ * Use `getOptionalFields()` to compute this from service context metadata.
348
413
  * @returns Cedar policy text with all rules separated by blank lines
349
414
  *
350
415
  * @example
351
416
  * ```typescript
352
417
  * const rules: PolicyRule[] = [...];
353
418
  * const cedarText = rulesToCedar(rules);
419
+ *
420
+ * // With optional fields awareness
421
+ * const optionalFields = getOptionalFields(OVERWATCH_CONTEXT, 'call_tool');
422
+ * const cedarText = rulesToCedar(rules, false, optionalFields);
354
423
  * ```
355
424
  */
356
- export function rulesToCedar(rules, includeDisabled = false) {
425
+ export function rulesToCedar(rules, includeDisabled = false, optionalFields) {
357
426
  const sortedRules = [...rules].sort((a, b) => a.order - b.order);
358
427
  const cedarPolicies = [];
359
428
  for (const rule of sortedRules) {
360
429
  if (rule.enabled) {
361
- cedarPolicies.push(ruleToCedar(rule));
430
+ cedarPolicies.push(ruleToCedar(rule, optionalFields));
362
431
  }
363
432
  else if (includeDisabled) {
364
433
  // Include disabled rules as comments
365
- const cedarLines = ruleToCedar(rule).split('\n');
434
+ const cedarLines = ruleToCedar(rule, optionalFields).split('\n');
366
435
  cedarPolicies.push(cedarLines.map(line => `// [DISABLED] ${line}`).join('\n'));
367
436
  }
368
437
  }