@highflame/policy 2.2.45 → 2.4.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.
@@ -1321,6 +1321,126 @@ when {
1321
1321
  )
1322
1322
  };
1323
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
+ `;
1324
1444
  // =============================================================================
1325
1445
  // CATEGORIES
1326
1446
  // =============================================================================
@@ -1330,6 +1450,7 @@ export const AI_GATEWAY_CATEGORIES = [
1330
1450
  { id: 'agent-security', name: 'Agent Security', description: 'Detect tool poisoning, rug pull attacks, indirect prompt injection, and MCP supply chain threats.', layer: 'guardrail' },
1331
1451
  { id: 'data-protection', name: 'Data Protection', description: 'Prevent secrets leakage in LLM chat completions and MCP operations.', layer: 'guardrail' },
1332
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' },
1333
1454
  { id: 'organization', name: 'Organization', description: 'Organization-wide baselines and default permit policies.', layer: 'access' },
1334
1455
  ];
1335
1456
  // =============================================================================
@@ -1451,6 +1572,24 @@ export const AI_GATEWAY_TEMPLATES = [
1451
1572
  severity: 'critical',
1452
1573
  tags: ['category:privacy', 'threat:pii', 'detection:pattern', 'compliance:pci-dss', 'compliance:hipaa'],
1453
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
+ },
1454
1593
  ];
1455
1594
  // =============================================================================
1456
1595
  // TEMPLATES METADATA
@@ -1491,6 +1630,12 @@ export const AI_GATEWAY_TEMPLATES_JSON = `{
1491
1630
  "description": "Block personally identifiable information (PII) in LLM prompts and MCP tool calls.",
1492
1631
  "layer": "guardrail"
1493
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
+ },
1494
1639
  {
1495
1640
  "id": "organization",
1496
1641
  "name": "Organization",
@@ -1655,6 +1800,37 @@ export const AI_GATEWAY_TEMPLATES_JSON = `{
1655
1800
  "compliance:pci-dss",
1656
1801
  "compliance:hipaa"
1657
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
+ ]
1658
1834
  }
1659
1835
  ]
1660
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;