@highflame/policy 2.2.44 → 2.3.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.
@@ -76,6 +76,30 @@ export interface RedactionStrategyDirective {
76
76
  */
77
77
  strategy: string;
78
78
  }
79
+ /**
80
+ * Parsed form of `@step_up_scope`. Absent means a step-up approval authorizes
81
+ * exactly one call; "session" lets it cover repeated calls to the same tool
82
+ * for the life of the approval token. Only valid with `@step_up_required`.
83
+ */
84
+ export interface StepUpScopeDirective {
85
+ /** Always "session" — the only legal value. */
86
+ scope: string;
87
+ }
88
+ /**
89
+ * Parsed form of `@step_up_four_eyes`. Marker: the approver must be a
90
+ * different person from the requesting agent's owner. Written bare or as
91
+ * `@step_up_four_eyes("true")`. Only valid with `@step_up_required`.
92
+ */
93
+ export interface StepUpFourEyesDirective {
94
+ }
95
+ /**
96
+ * Parsed form of `@single_use`. The allow produced by the permit carrying it
97
+ * can be spent once. Only valid on a permit (see validateAARMAnnotationsForEffect).
98
+ */
99
+ export interface SingleUseDirective {
100
+ /** "tool" (default; what the bare annotation means) or "request" (same arguments). */
101
+ granularity: string;
102
+ }
79
103
  /**
80
104
  * Every typed AARM directive parsed from a single policy's annotation map.
81
105
  * Each field is present iff the corresponding annotation was present and
@@ -87,6 +111,9 @@ export interface AARMDirectives {
87
111
  deferBelowConfidence?: DeferBelowConfidenceDirective;
88
112
  deferUntilContext?: DeferUntilContextDirective;
89
113
  redactionStrategy?: RedactionStrategyDirective;
114
+ stepUpScope?: StepUpScopeDirective;
115
+ stepUpFourEyes?: StepUpFourEyesDirective;
116
+ singleUse?: SingleUseDirective;
90
117
  }
91
118
  /** True iff at least one AARM directive was parsed. */
92
119
  export declare function hasAnyAARMDirective(d: AARMDirectives | null | undefined): boolean;
@@ -127,12 +154,29 @@ export interface ParseAARMResult {
127
154
  * offending annotation at once — Studio shows all squiggles in one pass.
128
155
  * Keys are processed in sorted order for stable error reporting.
129
156
  */
130
- export declare function parseAARMAnnotations(raw: Record<string, string> | null | undefined): ParseAARMResult;
157
+ export declare function parseAARMAnnotations(raw: Record<string, string | null> | null | undefined): ParseAARMResult;
158
+ /**
159
+ * THE authoring-time AARM check: run it on every rule of a policy before
160
+ * saving it (Studio's lint, Admin's policy-create/update), passing the rule's
161
+ * effect. It is validateAARMAnnotations plus the checks that depend on the
162
+ * effect: `@single_use` is only meaningful on a permit (a forbid never
163
+ * produces an allow to consume). validateAARMAnnotations alone is
164
+ * effect-blind and is not sufficient.
165
+ *
166
+ * Shield runs the same check at sync, but there an error is not always a
167
+ * reject: a permit with an invalid annotation is dropped (fail closed), while
168
+ * a forbid is loaded with the offending annotation ignored, since dropping a
169
+ * forbid would remove a block. Surface the error here, at save time.
170
+ *
171
+ * Exported from both the package root and the browser-safe `./types` entry.
172
+ */
173
+ export declare function validateAARMAnnotationsForEffect(effect: 'permit' | 'forbid', raw: Record<string, string | null> | null | undefined): AARMAnnotationError[];
131
174
  /**
132
175
  * Validate-only variant: returns the error list without the typed
133
- * directives. Suitable for Admin's policy-create endpoint, which only needs
134
- * the yes/no verdict + error list.
176
+ * directives. Effect-blind: authoring checks that know the rule's effect
177
+ * (Admin's policy-create, Studio's lint) must use
178
+ * validateAARMAnnotationsForEffect instead.
135
179
  */
136
- export declare function validateAARMAnnotations(raw: Record<string, string> | null | undefined): AARMAnnotationError[];
180
+ export declare function validateAARMAnnotations(raw: Record<string, string | null> | null | undefined): AARMAnnotationError[];
137
181
  /** True iff key is a platform-recognized AARM annotation. */
138
182
  export declare function isAARMAnnotationKey(key: string): boolean;
@@ -45,7 +45,10 @@ export function hasAnyAARMDirective(d) {
45
45
  d.deferOnConflict !== undefined ||
46
46
  d.deferBelowConfidence !== undefined ||
47
47
  d.deferUntilContext !== undefined ||
48
- d.redactionStrategy !== undefined);
48
+ d.redactionStrategy !== undefined ||
49
+ d.stepUpScope !== undefined ||
50
+ d.stepUpFourEyes !== undefined ||
51
+ d.singleUse !== undefined);
49
52
  }
50
53
  /**
51
54
  * Structured error for a malformed AARM annotation. Mirrors Go's
@@ -100,7 +103,11 @@ export function parseAARMAnnotations(raw) {
100
103
  if (!def) {
101
104
  continue; // not an AARM-recognized annotation; ignore
102
105
  }
103
- const value = raw[key];
106
+ // cedar-wasm reports a value-less annotation (`@single_use`) as null:
107
+ // treat it as bare, like Go and parseAnnotations do. undefined and
108
+ // non-strings still fail closed in parseAARMAnnotationParams.
109
+ const rawValue = raw[key];
110
+ const value = (rawValue === null ? '' : rawValue);
104
111
  const parsed = parseAARMAnnotationParams(def, value);
105
112
  if (parsed.error) {
106
113
  errors.push(parsed.error);
@@ -143,6 +150,30 @@ export function parseAARMAnnotations(raw) {
143
150
  directives.redactionStrategy = r.directive;
144
151
  break;
145
152
  }
153
+ case 'step_up_scope': {
154
+ const r = buildStepUpScope(def, params, value);
155
+ if (r.error)
156
+ errors.push(r.error);
157
+ else
158
+ directives.stepUpScope = r.directive;
159
+ break;
160
+ }
161
+ case 'step_up_four_eyes': {
162
+ const r = buildStepUpFourEyes(def, params, value);
163
+ if (r.error)
164
+ errors.push(r.error);
165
+ else
166
+ directives.stepUpFourEyes = r.directive;
167
+ break;
168
+ }
169
+ case 'single_use': {
170
+ const r = buildSingleUse(def, params, value);
171
+ if (r.error)
172
+ errors.push(r.error);
173
+ else
174
+ directives.singleUse = r.directive;
175
+ break;
176
+ }
146
177
  default:
147
178
  // Registry entry exists but no typed extractor is wired here — a
148
179
  // programming error in highflame-policy. Fail closed at runtime.
@@ -154,12 +185,64 @@ export function parseAARMAnnotations(raw) {
154
185
  }));
155
186
  }
156
187
  }
188
+ // The step-up modifiers only mean something next to @step_up_required.
189
+ // Without it the policy would permit outright while its author believes a
190
+ // human approval is required, so reject rather than ignore.
191
+ if (!directives.stepUpRequired) {
192
+ for (const key of ['step_up_four_eyes', 'step_up_scope']) {
193
+ if (key in raw && !errors.some((e) => e.key === key)) {
194
+ errors.push(new AARMAnnotationError({
195
+ key,
196
+ rawValue: raw[key] ?? '',
197
+ reason: 'requires @step_up_required on the same policy',
198
+ }));
199
+ }
200
+ }
201
+ }
202
+ // "self" routes the approval to the requesting identity's owner, and
203
+ // four-eyes forbids exactly that person from approving: every gated call
204
+ // would wait for an approval that cannot happen, then deny.
205
+ if (directives.stepUpRequired?.role === 'self' && directives.stepUpFourEyes) {
206
+ errors.push(new AARMAnnotationError({
207
+ key: 'step_up_four_eyes',
208
+ rawValue: raw.step_up_four_eyes ?? '',
209
+ reason: 'cannot be combined with @step_up_required("self"): self sends the approval ' +
210
+ 'to the owner, whom four-eyes forbids from approving',
211
+ }));
212
+ }
157
213
  return { directives, errors };
158
214
  }
215
+ /**
216
+ * THE authoring-time AARM check: run it on every rule of a policy before
217
+ * saving it (Studio's lint, Admin's policy-create/update), passing the rule's
218
+ * effect. It is validateAARMAnnotations plus the checks that depend on the
219
+ * effect: `@single_use` is only meaningful on a permit (a forbid never
220
+ * produces an allow to consume). validateAARMAnnotations alone is
221
+ * effect-blind and is not sufficient.
222
+ *
223
+ * Shield runs the same check at sync, but there an error is not always a
224
+ * reject: a permit with an invalid annotation is dropped (fail closed), while
225
+ * a forbid is loaded with the offending annotation ignored, since dropping a
226
+ * forbid would remove a block. Surface the error here, at save time.
227
+ *
228
+ * Exported from both the package root and the browser-safe `./types` entry.
229
+ */
230
+ export function validateAARMAnnotationsForEffect(effect, raw) {
231
+ const errors = validateAARMAnnotations(raw);
232
+ if (raw && 'single_use' in raw && effect !== 'permit' && !errors.some((e) => e.key === 'single_use')) {
233
+ errors.push(new AARMAnnotationError({
234
+ key: 'single_use',
235
+ rawValue: raw.single_use ?? '',
236
+ reason: 'is only valid on a permit policy',
237
+ }));
238
+ }
239
+ return errors;
240
+ }
159
241
  /**
160
242
  * Validate-only variant: returns the error list without the typed
161
- * directives. Suitable for Admin's policy-create endpoint, which only needs
162
- * the yes/no verdict + error list.
243
+ * directives. Effect-blind: authoring checks that know the rule's effect
244
+ * (Admin's policy-create, Studio's lint) must use
245
+ * validateAARMAnnotationsForEffect instead.
163
246
  */
164
247
  export function validateAARMAnnotations(raw) {
165
248
  return parseAARMAnnotations(raw).errors;
@@ -258,6 +341,16 @@ function parseAARMAnnotationParams(def, raw) {
258
341
  // Single-positional shorthand. Bind the whole value to the positional param.
259
342
  const pos = positionalParameter(def);
260
343
  if (!pos) {
344
+ // An annotation whose only parameter is optional accepts both the bare
345
+ // (value-less) form, meaning "use the default", and a bare value as
346
+ // shorthand for that parameter: @single_use, @single_use("request").
347
+ const sole = soleOptionalParameter(def);
348
+ if (sole) {
349
+ const v = raw.trim();
350
+ if (v !== '')
351
+ values[sole.name] = v;
352
+ return { params: values };
353
+ }
261
354
  return {
262
355
  params: {},
263
356
  error: new AARMAnnotationError({
@@ -321,6 +414,56 @@ function buildRedactionStrategy(def, params, raw) {
321
414
  }
322
415
  return { directive: { strategy: strategy.value } };
323
416
  }
417
+ /** Mirrors the enums in schemas/annotations.json (the generated registry carries none). */
418
+ const STEP_UP_SCOPES = ['session'];
419
+ const SINGLE_USE_GRANULARITIES = ['tool', 'request'];
420
+ function buildStepUpScope(def, params, raw) {
421
+ const scope = requireStringParam(def, params, 'scope', raw);
422
+ if ('error' in scope)
423
+ return { error: scope.error };
424
+ if (!STEP_UP_SCOPES.includes(scope.value)) {
425
+ return {
426
+ error: new AARMAnnotationError({
427
+ key: def.key,
428
+ parameter: 'scope',
429
+ rawValue: raw,
430
+ reason: 'scope must be "session"; omit the annotation for the one-call default',
431
+ }),
432
+ };
433
+ }
434
+ return { directive: { scope: scope.value } };
435
+ }
436
+ function buildStepUpFourEyes(def, params, raw) {
437
+ // Bare or "true" only: "false" would make the annotation present yet
438
+ // disabled, which consumers checking presence would misread.
439
+ const v = params.enabled;
440
+ if (v !== undefined && v !== '' && v !== 'true') {
441
+ return {
442
+ error: new AARMAnnotationError({
443
+ key: def.key,
444
+ parameter: 'enabled',
445
+ rawValue: raw,
446
+ reason: 'value must be omitted or "true"',
447
+ }),
448
+ };
449
+ }
450
+ return { directive: {} };
451
+ }
452
+ function buildSingleUse(def, params, raw) {
453
+ const gDef = mustLookupParam(def, 'granularity');
454
+ const granularity = params.granularity || (gDef.default?.kind === 'string' ? gDef.default.value : 'tool');
455
+ if (!SINGLE_USE_GRANULARITIES.includes(granularity)) {
456
+ return {
457
+ error: new AARMAnnotationError({
458
+ key: def.key,
459
+ parameter: 'granularity',
460
+ rawValue: raw,
461
+ reason: 'value must be "tool" or "request" (or omitted for "tool"); values are case-sensitive',
462
+ }),
463
+ };
464
+ }
465
+ return { directive: { granularity } };
466
+ }
324
467
  function buildDeferUntilContext(def, params, raw) {
325
468
  const field = requireStringParam(def, params, 'field', raw);
326
469
  if ('error' in field)
@@ -510,6 +653,11 @@ function hasParameter(def, name) {
510
653
  function parameterNames(def) {
511
654
  return def.parameters.map((p) => p.name);
512
655
  }
656
+ /** The annotation's only parameter when it is optional and non-positional. */
657
+ function soleOptionalParameter(def) {
658
+ const [only] = def.parameters;
659
+ return def.parameters.length === 1 && !only.required && !only.positional ? only : undefined;
660
+ }
513
661
  function positionalParameter(def) {
514
662
  return def.parameters.find((p) => p.positional);
515
663
  }
@@ -85,6 +85,48 @@ export const AARM_ANNOTATIONS = [
85
85
  },
86
86
  ],
87
87
  },
88
+ {
89
+ key: 'single_use',
90
+ description: 'Make an allow by this permit spendable exactly once. Shield records a consume key on the first allow and denies replays of the same grant. Bare means per tool; @single_use(\"tool\") and @single_use(\"request\") are explicit. Valid only on permit. A request without a bearer token (gateway or internal-service traffic) has no token id to record against and is denied single_use_unbound.',
91
+ aarmRequirement: 'R9',
92
+ promotesCapability: '',
93
+ decisionEffect: '',
94
+ parameters: [
95
+ {
96
+ name: 'granularity',
97
+ type: 'string',
98
+ required: false,
99
+ positional: false,
100
+ description: 'What one use covers. tool: any call to the same action and tool. request: only a call with the same canonicalized arguments. Case-sensitive.',
101
+ default: { kind: 'string', value: 'tool' },
102
+ min: null,
103
+ max: null,
104
+ pattern: '',
105
+ valueSource: '',
106
+ },
107
+ ],
108
+ },
109
+ {
110
+ key: 'step_up_four_eyes',
111
+ description: 'Require that the approver is a different person from the requesting agent\'s owner. Bare (value-less) or @step_up_four_eyes(\"true\"); no other value is legal. Requires @step_up_required on the same policy.',
112
+ aarmRequirement: 'R4',
113
+ promotesCapability: '',
114
+ decisionEffect: '',
115
+ parameters: [
116
+ {
117
+ name: 'enabled',
118
+ type: 'bool',
119
+ required: false,
120
+ positional: false,
121
+ description: 'Always true when present. Accepted spellings are the bare annotation and the string \"true\"; \"false\" is rejected so the annotation cannot be present yet disabled.',
122
+ default: { kind: 'bool', value: true },
123
+ min: null,
124
+ max: null,
125
+ pattern: '',
126
+ valueSource: '',
127
+ },
128
+ ],
129
+ },
88
130
  {
89
131
  key: 'step_up_required',
90
132
  description: 'Suspend the action pending human approval from an approver with the named role. AARM R4 STEP_UP decision: Shield issues an OpenID CIBA bc-authorize challenge and the action does not execute until an approver carrying the role resolves it (POST /oauth2/bc-authorize/{auth_req_id}/approve via AuthN), OR timeout_seconds elapses (fail-closed: timeout DENYs the action, never permits).',
@@ -118,6 +160,27 @@ export const AARM_ANNOTATIONS = [
118
160
  },
119
161
  ],
120
162
  },
163
+ {
164
+ key: 'step_up_scope',
165
+ description: 'Widen a step-up approval from one call to the approved tool for the life of the approval token. Without this annotation an approval authorizes exactly one call. With scope session, repeated calls to the SAME tool are covered until the token expires; other tools are never covered. Requires @step_up_required on the same policy.',
166
+ aarmRequirement: 'R4',
167
+ promotesCapability: '',
168
+ decisionEffect: '',
169
+ parameters: [
170
+ {
171
+ name: 'scope',
172
+ type: 'string',
173
+ required: true,
174
+ positional: true,
175
+ description: 'Approval scope. The only legal value is \"session\"; omit the annotation for the one-call default. Case-sensitive.',
176
+ default: null,
177
+ min: null,
178
+ max: null,
179
+ pattern: '',
180
+ valueSource: '',
181
+ },
182
+ ],
183
+ },
121
184
  ];
122
185
  /**
123
186
  * O(1) lookup over AARM_ANNOTATIONS. Use this when validating raw
@@ -2,7 +2,7 @@
2
2
  * AiGateway policy category identifiers.
3
3
  * Maps to UI tab names in Studio.
4
4
  */
5
- export type AiGatewayCategory = 'semantic' | 'tools' | 'agent-security' | 'data-protection' | 'privacy' | 'organization';
5
+ export type AiGatewayCategory = 'semantic' | 'tools' | 'agent-security' | 'data-protection' | 'privacy' | 'agent-identity' | 'organization';
6
6
  /**
7
7
  * Category metadata for UI display.
8
8
  */
@@ -59,6 +59,21 @@ const AI_GATEWAY_SEMANTIC_DEFAULTS_CEDAR = `// =================================
59
59
  // Section 1: Prompt injection
60
60
  // ---------------------------------------------------------------------------
61
61
 
62
+ @id("semantic.block-injection-prompt-ml")
63
+ @name("Block prompt injection in LLM prompts (ML)")
64
+ @description("Blocks process_prompt when injection_score >= 75.")
65
+ @severity("critical")
66
+ @tags("category:semantic,threat:injection,detection:ml,surface:process-prompt,owasp:llm01,mitre:atlas-t0051")
67
+ @reject_message("Prompt blocked: the ML classifier detected prompt injection with high confidence.")
68
+ forbid (
69
+ principal,
70
+ action == AIGateway::Action::"process_prompt",
71
+ resource
72
+ )
73
+ when {
74
+ context has injection_score && context.injection_score >= 75
75
+ };
76
+
62
77
  @id("semantic.block-injection-ml")
63
78
  @name("Block injection (ML)")
64
79
  @description("Blocks call_tool when injection_score >= 75.")
@@ -78,6 +93,21 @@ when {
78
93
  // Section 2: Jailbreak
79
94
  // ---------------------------------------------------------------------------
80
95
 
96
+ @id("semantic.block-jailbreak-prompt-ml")
97
+ @name("Block jailbreak in LLM prompts (ML)")
98
+ @description("Blocks process_prompt when jailbreak_score >= 75.")
99
+ @severity("critical")
100
+ @tags("category:semantic,threat:jailbreak,detection:ml,surface:process-prompt,owasp:llm02,mitre:atlas-t0054")
101
+ @reject_message("Prompt blocked: the ML classifier detected a jailbreak attempt with high confidence.")
102
+ forbid (
103
+ principal,
104
+ action == AIGateway::Action::"process_prompt",
105
+ resource
106
+ )
107
+ when {
108
+ context has jailbreak_score && context.jailbreak_score >= 75
109
+ };
110
+
81
111
  @id("semantic.block-jailbreak-ml")
82
112
  @name("Block jailbreak (ML)")
83
113
  @description("Blocks call_tool when jailbreak_score >= 75.")
@@ -1291,6 +1321,126 @@ when {
1291
1321
  )
1292
1322
  };
1293
1323
  `;
1324
+ const AI_GATEWAY_AGENT_IDENTITY_BLOCK_UNADOPTED_AGENT_SENSITIVE_TOOLS_CEDAR = `// =============================================================================
1325
+ // Unadopted Agents: No Sensitive Tool Calls
1326
+ // =============================================================================
1327
+ // Agents that an enterprise registers in its own IdP (Entra Agent ID, Okta AI
1328
+ // agents) are discovered by Highflame before anyone adopts them. A discovered
1329
+ // or pending agent has an identity but no owner-reviewed policy, so it should
1330
+ // not reach sensitive tools until it is adopted (status "active").
1331
+ //
1332
+ // \`context.actor.status\` carries the agent lifecycle status. When no actor
1333
+ // record is present the rule falls back to the legacy \`context.principal.status\`
1334
+ // profile. Absence means "unknown", not "active": the has-guards below make a
1335
+ // request with no status fall through to the other rules instead of being
1336
+ // matched here. Any status other than "active" is matched, so suspended and
1337
+ // deactivated agents are blocked too. The comparison is case-sensitive, like
1338
+ // ZeroID's lowercase status values.
1339
+ //
1340
+ // Today this template is forward-looking. ZeroID mints tokens only for active
1341
+ // identities and stamps "active" at mint, and every caller of Shield forwards
1342
+ // that value, so no live path reports another status yet. It takes effect when
1343
+ // a non-active status reaches Shield, as ADR 0042 plans for agents registered
1344
+ // in a customer IdP. The status is a mint-time snapshot: an identity suspended
1345
+ // after its token was minted reads "active" until the token expires.
1346
+ //
1347
+ // \`tool_is_sensitive\` is set by Shield's tool_validator from a fixed list of
1348
+ // tool names (shell, execute_command, write_file, http_post, send_email,
1349
+ // delete_file, run_sql, plus Shield's configured list); other tools are not
1350
+ // sensitive and are not matched.
1351
+ //
1352
+ // This copy covers gateway traffic (Firehog), which Shield evaluates in the
1353
+ // AIGateway namespace. The Guardrails copy covers Guardrails traffic.
1354
+ //
1355
+ // Deploy in MONITOR mode first to see which discovered agents already call
1356
+ // sensitive tools, adopt them, then switch to enforce.
1357
+ //
1358
+ // Context keys consumed:
1359
+ // - actor, principal: records - status is the agent lifecycle status
1360
+ // - tool_is_sensitive: Bool
1361
+ //
1362
+ // Compliance:
1363
+ // - OWASP ASI04
1364
+ //
1365
+ // Category: agent-identity
1366
+ // Namespace: AIGateway
1367
+ // =============================================================================
1368
+
1369
+ @id("agent-identity.block-unadopted-agent-sensitive-tools")
1370
+ @name("Block unadopted agents from sensitive tools")
1371
+ @description("Blocks call_tool on a sensitive tool when actor.status (or, without an actor record, principal.status) is present and is not active.")
1372
+ @severity("high")
1373
+ @tags("category:agent-identity,detection:rule,surface:call-tool,scope:per-agent,posture:deny-default,owasp:asi04")
1374
+ @reject_message("Tool execution blocked: this agent has not been adopted, so it may not call sensitive tools. Adopt the agent to activate it.")
1375
+ forbid (
1376
+ principal,
1377
+ action == AIGateway::Action::"call_tool",
1378
+ resource
1379
+ )
1380
+ when {
1381
+ (
1382
+ (context has actor && context.actor has status && context.actor.status != "active") ||
1383
+ (!(context has actor) && context has principal && context.principal has status &&
1384
+ context.principal.status != "active")
1385
+ ) &&
1386
+ context has tool_is_sensitive && context.tool_is_sensitive
1387
+ };
1388
+ `;
1389
+ const AI_GATEWAY_AGENT_IDENTITY_PERMIT_SINGLE_USE_SENSITIVE_TOOLS_CEDAR = `// =============================================================================
1390
+ // Single-Use for Sensitive Tools
1391
+ // =============================================================================
1392
+ // Permits sensitive tool calls, but only once per grant. @single_use makes
1393
+ // Shield record the first allow in an atomic consume store and deny any replay
1394
+ // of the same grant ("grant_consumed"). Bare @single_use means per tool: one
1395
+ // allow covers any call to the same action and tool. Use
1396
+ // @single_use("request") instead to bind the allow to one exact set of
1397
+ // arguments.
1398
+ //
1399
+ // @single_use is only valid on a permit. Deny, DEFER and step-up outcomes
1400
+ // never consume. If the consume store is unavailable the request is denied
1401
+ // ("single_use_store_unavailable") unless the operator opted into fail-open.
1402
+ //
1403
+ // Requests that carry no bearer token, such as gateway and internal-service
1404
+ // traffic, have no token id to record the use against. Shield denies every
1405
+ // sensitive call_tool from them ("single_use_unbound"), whatever the
1406
+ // fail-open setting. Until the gateway forwards the caller's token to Shield
1407
+ // (ADR 0041 D16, ADR 0042 D10), that means every sensitive gateway call.
1408
+ //
1409
+ // Try it with request-level monitor mode (guard requests sent with
1410
+ // mode=monitor): single_use.would_deny on each response shows what enforcing
1411
+ // would deny. Per-policy monitor mode does NOT exercise @single_use, because
1412
+ // Shield reads it only from enforcing policies; a monitor-mode copy of this
1413
+ // template reports nothing, and switching it to enforce takes effect at once.
1414
+ //
1415
+ // Adopted alone, this permit also allows every sensitive call_tool (once per
1416
+ // grant). Pair it with the policies that decide which sensitive calls are
1417
+ // allowed at all.
1418
+ //
1419
+ // Context keys consumed:
1420
+ // - tool_is_sensitive: Bool
1421
+ //
1422
+ // Compliance:
1423
+ // - OWASP ASI02
1424
+ //
1425
+ // Category: agent-identity
1426
+ // Namespace: AIGateway
1427
+ // =============================================================================
1428
+
1429
+ @id("agent-identity.permit-single-use-sensitive-tools")
1430
+ @name("Permit sensitive tools once per grant")
1431
+ @description("Permits call_tool on a sensitive tool once per grant.")
1432
+ @severity("medium")
1433
+ @tags("category:agent-identity,surface:call-tool,scope:per-tool,owasp:asi02,single-use")
1434
+ @single_use
1435
+ permit (
1436
+ principal,
1437
+ action == AIGateway::Action::"call_tool",
1438
+ resource
1439
+ )
1440
+ when {
1441
+ context has tool_is_sensitive && context.tool_is_sensitive
1442
+ };
1443
+ `;
1294
1444
  // =============================================================================
1295
1445
  // CATEGORIES
1296
1446
  // =============================================================================
@@ -1300,6 +1450,7 @@ export const AI_GATEWAY_CATEGORIES = [
1300
1450
  { id: 'agent-security', name: 'Agent Security', description: 'Detect tool poisoning, rug pull attacks, indirect prompt injection, and MCP supply chain threats.', layer: 'guardrail' },
1301
1451
  { id: 'data-protection', name: 'Data Protection', description: 'Prevent secrets leakage in LLM chat completions and MCP operations.', layer: 'guardrail' },
1302
1452
  { id: 'privacy', name: 'Privacy', description: 'Block personally identifiable information (PII) in LLM prompts and MCP tool calls.', layer: 'guardrail' },
1453
+ { id: 'agent-identity', name: 'Agent Identity', description: 'Agent lifecycle and provenance controls for gateway traffic: unadopted agents, single-use sensitive tools.', layer: 'guardrail' },
1303
1454
  { id: 'organization', name: 'Organization', description: 'Organization-wide baselines and default permit policies.', layer: 'access' },
1304
1455
  ];
1305
1456
  // =============================================================================
@@ -1421,6 +1572,24 @@ export const AI_GATEWAY_TEMPLATES = [
1421
1572
  severity: 'critical',
1422
1573
  tags: ['category:privacy', 'threat:pii', 'detection:pattern', 'compliance:pci-dss', 'compliance:hipaa'],
1423
1574
  },
1575
+ {
1576
+ id: 'agent-identity.block-unadopted-agent-sensitive-tools',
1577
+ name: 'Unadopted Agents: No Sensitive Tool Calls',
1578
+ description: 'Block sensitive tool calls from agents whose lifecycle status is not active (discovered or pending). Deploy in monitor mode first to see which discovered agents already call sensitive tools.',
1579
+ category: 'agent-identity',
1580
+ cedarText: AI_GATEWAY_AGENT_IDENTITY_BLOCK_UNADOPTED_AGENT_SENSITIVE_TOOLS_CEDAR,
1581
+ severity: 'high',
1582
+ tags: ['category:agent-identity', 'detection:rule', 'surface:call-tool', 'scope:per-agent', 'posture:deny-default', 'owasp:asi04'],
1583
+ },
1584
+ {
1585
+ id: 'agent-identity.permit-single-use-sensitive-tools',
1586
+ name: 'Single-Use for Sensitive Tools',
1587
+ description: 'Permit sensitive tool calls once per grant: the first allow is recorded and a replay of the same grant is denied. Bare @single_use means per tool; use @single_use(\"request\") to bind to one set of arguments. Requests without a bearer token (gateway and internal-service traffic, until the gateway forwards the caller token) are denied (single_use_unbound). Try it with request-level mode=monitor: per-policy monitor mode does not exercise @single_use. Adopted alone, it also permits every sensitive call.',
1588
+ category: 'agent-identity',
1589
+ cedarText: AI_GATEWAY_AGENT_IDENTITY_PERMIT_SINGLE_USE_SENSITIVE_TOOLS_CEDAR,
1590
+ severity: 'medium',
1591
+ tags: ['category:agent-identity', 'surface:call-tool', 'scope:per-tool', 'owasp:asi02', 'single-use'],
1592
+ },
1424
1593
  ];
1425
1594
  // =============================================================================
1426
1595
  // TEMPLATES METADATA
@@ -1461,6 +1630,12 @@ export const AI_GATEWAY_TEMPLATES_JSON = `{
1461
1630
  "description": "Block personally identifiable information (PII) in LLM prompts and MCP tool calls.",
1462
1631
  "layer": "guardrail"
1463
1632
  },
1633
+ {
1634
+ "id": "agent-identity",
1635
+ "name": "Agent Identity",
1636
+ "description": "Agent lifecycle and provenance controls for gateway traffic: unadopted agents, single-use sensitive tools.",
1637
+ "layer": "guardrail"
1638
+ },
1464
1639
  {
1465
1640
  "id": "organization",
1466
1641
  "name": "Organization",
@@ -1625,6 +1800,37 @@ export const AI_GATEWAY_TEMPLATES_JSON = `{
1625
1800
  "compliance:pci-dss",
1626
1801
  "compliance:hipaa"
1627
1802
  ]
1803
+ },
1804
+ {
1805
+ "id": "agent-identity.block-unadopted-agent-sensitive-tools",
1806
+ "name": "Unadopted Agents: No Sensitive Tool Calls",
1807
+ "description": "Block sensitive tool calls from agents whose lifecycle status is not active (discovered or pending). Deploy in monitor mode first to see which discovered agents already call sensitive tools.",
1808
+ "category": "agent-identity",
1809
+ "file": "unadopted_agents.cedar",
1810
+ "severity": "high",
1811
+ "tags": [
1812
+ "category:agent-identity",
1813
+ "detection:rule",
1814
+ "surface:call-tool",
1815
+ "scope:per-agent",
1816
+ "posture:deny-default",
1817
+ "owasp:asi04"
1818
+ ]
1819
+ },
1820
+ {
1821
+ "id": "agent-identity.permit-single-use-sensitive-tools",
1822
+ "name": "Single-Use for Sensitive Tools",
1823
+ "description": "Permit sensitive tool calls once per grant: the first allow is recorded and a replay of the same grant is denied. Bare @single_use means per tool; use @single_use(\\"request\\") to bind to one set of arguments. Requests without a bearer token (gateway and internal-service traffic, until the gateway forwards the caller token) are denied (single_use_unbound). Try it with request-level mode=monitor: per-policy monitor mode does not exercise @single_use. Adopted alone, it also permits every sensitive call.",
1824
+ "category": "agent-identity",
1825
+ "file": "single_use_sensitive_tools.cedar",
1826
+ "severity": "medium",
1827
+ "tags": [
1828
+ "category:agent-identity",
1829
+ "surface:call-tool",
1830
+ "scope:per-tool",
1831
+ "owasp:asi02",
1832
+ "single-use"
1833
+ ]
1628
1834
  }
1629
1835
  ]
1630
1836
  }
@@ -112,13 +112,14 @@ export interface ParseAnnotationsResult {
112
112
  customAnnotations?: CustomAnnotations;
113
113
  }
114
114
  /**
115
- * Parse Cedar annotations from a Record<string, string> (as returned by cedar-wasm).
115
+ * Parse Cedar annotations from a Record<string, string | null> (as returned by cedar-wasm;
116
+ * null marks a value-less annotation).
116
117
  * Separates predefined annotations from custom annotations.
117
118
  *
118
119
  * @param rawAnnotations - Raw annotation map from Cedar JSON
119
120
  * @returns Parsed annotations object
120
121
  */
121
- export declare function parseAnnotations(rawAnnotations: Record<string, string> | undefined): ParseAnnotationsResult;
122
+ export declare function parseAnnotations(rawAnnotations: Record<string, string | null> | undefined): ParseAnnotationsResult;
122
123
  /**
123
124
  * Generate a UUID v4 for rule IDs.
124
125
  * Uses crypto.randomUUID if available, falls back to manual generation.
@@ -108,7 +108,8 @@ export function generateAnnotationLines(annotations, customAnnotations) {
108
108
  return lines;
109
109
  }
110
110
  /**
111
- * Parse Cedar annotations from a Record<string, string> (as returned by cedar-wasm).
111
+ * Parse Cedar annotations from a Record<string, string | null> (as returned by cedar-wasm;
112
+ * null marks a value-less annotation).
112
113
  * Separates predefined annotations from custom annotations.
113
114
  *
114
115
  * @param rawAnnotations - Raw annotation map from Cedar JSON
@@ -124,7 +125,10 @@ export function parseAnnotations(rawAnnotations) {
124
125
  return { annotations };
125
126
  }
126
127
  for (const [key, value] of Object.entries(rawAnnotations)) {
127
- const unescapedValue = unescapeAnnotationValue(value);
128
+ // A value-less annotation (`@single_use`) arrives from cedar-wasm as null.
129
+ // Normalize to the empty string, which is what cedar-go reports and what
130
+ // the AARM validators read as "bare".
131
+ const unescapedValue = unescapeAnnotationValue(value ?? '');
128
132
  switch (key) {
129
133
  case 'id':
130
134
  annotations.id = unescapedValue;