@highflame/policy 2.2.42 → 2.2.43

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.
@@ -3670,6 +3670,338 @@ when {
3670
3670
  context has secrets_detected && context.secrets_detected == true
3671
3671
  };
3672
3672
  `;
3673
+ const GUARDRAILS_SECURITY_MULTI_TURN_TRAJECTORY_CEDAR = `// =============================================================================
3674
+ // Multi-Turn Trajectory Escalation
3675
+ // =============================================================================
3676
+ // Blocks a conversation whose TRAJECTORY is an attack, even when no single
3677
+ // message is. A crescendo attack never sends a message alarming enough to
3678
+ // block on its own; it walks the model there in small, plausible steps and
3679
+ // asks for the payoff in a sentence that would pass any single-message filter.
3680
+ //
3681
+ // Shield scores every turn twice and exposes both scores separately:
3682
+ // - injection_pulse_score / jailbreak_pulse_score — the single-turn
3683
+ // classifier. Sees only the current message.
3684
+ // - injection_deep_context_score / jailbreak_deep_context_score — the
3685
+ // multi-turn model, which carries hidden state across turns keyed on
3686
+ // session_id. Sees the whole conversation.
3687
+ //
3688
+ // Because they are separate keys, a rule can fire on the GAP between them:
3689
+ // high trajectory score, low message score. In plain language, "the history
3690
+ // is an attack and this message is not" — a condition a filter that only ever
3691
+ // has one number cannot express. Section 1 is that rule. Section 2 is a safety
3692
+ // net for a trajectory blatant enough to stand on its own.
3693
+ //
3694
+ // Requires a stable session_id on every request in the conversation. With no
3695
+ // session_id there is no threaded state, multi_turn_detection is false, and
3696
+ // none of these rules can fire.
3697
+ //
3698
+ // Detection layers:
3699
+ // - injection (single-turn classifier, always available)
3700
+ // - deepcontext (multi-turn model, always available)
3701
+ //
3702
+ // Context keys consumed:
3703
+ // - multi_turn_detection: Bool — true only when threaded state was used
3704
+ // - injection_deep_context_score, injection_pulse_score: Long (0-100)
3705
+ // - jailbreak_deep_context_score, jailbreak_pulse_score: Long (0-100)
3706
+ //
3707
+ // Compliance:
3708
+ // - OWASP LLM01, OWASP LLM02, MITRE ATLAS AML.T0051, AML.T0054
3709
+ //
3710
+ // Category: security
3711
+ // Namespace: Guardrails
3712
+ // =============================================================================
3713
+
3714
+ // ---------------------------------------------------------------------------
3715
+ // Section 1: Trajectory/turn divergence
3716
+ // The conversation scores as an attack; this message does not.
3717
+ // ---------------------------------------------------------------------------
3718
+
3719
+ @id("security.block-trajectory-injection-divergence")
3720
+ @name("Block multi-turn injection the current turn hides")
3721
+ @description("Blocks process_prompt and process_response when threaded state is in use, injection_deep_context_score >= 60, and injection_pulse_score <= 39.")
3722
+ @severity("critical")
3723
+ @tags("category:security,threat:injection,detection:ml,surface:process-prompt,surface:process-response,scope:multi-turn,owasp:llm01,mitre:atlas-t0051")
3724
+ @reject_message("Prompt blocked: this conversation is a progressive prompt-injection attempt. No single message crossed the line; the sequence did.")
3725
+ forbid (
3726
+ principal,
3727
+ action in [Guardrails::Action::"process_prompt", Guardrails::Action::"process_response"],
3728
+ resource
3729
+ )
3730
+ when {
3731
+ context has multi_turn_detection && context.multi_turn_detection == true &&
3732
+ context has injection_deep_context_score && context.injection_deep_context_score >= 60 &&
3733
+ context has injection_pulse_score && context.injection_pulse_score <= 39
3734
+ };
3735
+
3736
+ @id("security.block-trajectory-jailbreak-divergence")
3737
+ @name("Block multi-turn jailbreak the current turn hides")
3738
+ @description("Blocks process_prompt and process_response when threaded state is in use, jailbreak_deep_context_score >= 60, and jailbreak_pulse_score <= 39.")
3739
+ @severity("critical")
3740
+ @tags("category:security,threat:jailbreak,detection:ml,surface:process-prompt,surface:process-response,scope:multi-turn,owasp:llm02,mitre:atlas-t0054")
3741
+ @reject_message("Prompt blocked: this conversation is a progressive jailbreak. No single message crossed the line; the sequence did.")
3742
+ forbid (
3743
+ principal,
3744
+ action in [Guardrails::Action::"process_prompt", Guardrails::Action::"process_response"],
3745
+ resource
3746
+ )
3747
+ when {
3748
+ context has multi_turn_detection && context.multi_turn_detection == true &&
3749
+ context has jailbreak_deep_context_score && context.jailbreak_deep_context_score >= 60 &&
3750
+ context has jailbreak_pulse_score && context.jailbreak_pulse_score <= 39
3751
+ };
3752
+
3753
+ // ---------------------------------------------------------------------------
3754
+ // Section 2: Blatant trajectory, regardless of the message score
3755
+ // Covers the band between Section 1 and the default single-turn thresholds.
3756
+ // ---------------------------------------------------------------------------
3757
+
3758
+ @id("security.block-trajectory-injection-high")
3759
+ @name("Block high-confidence multi-turn injection")
3760
+ @description("Blocks process_prompt and process_response when threaded state is in use and injection_deep_context_score >= 75.")
3761
+ @severity("high")
3762
+ @tags("category:security,threat:injection,detection:ml,surface:process-prompt,surface:process-response,scope:multi-turn,owasp:llm01")
3763
+ @reject_message("Prompt blocked: the multi-turn model scored this conversation as a prompt-injection attempt with high confidence.")
3764
+ forbid (
3765
+ principal,
3766
+ action in [Guardrails::Action::"process_prompt", Guardrails::Action::"process_response"],
3767
+ resource
3768
+ )
3769
+ when {
3770
+ context has multi_turn_detection && context.multi_turn_detection == true &&
3771
+ context has injection_deep_context_score && context.injection_deep_context_score >= 75
3772
+ };
3773
+
3774
+ @id("security.block-trajectory-jailbreak-high")
3775
+ @name("Block high-confidence multi-turn jailbreak")
3776
+ @description("Blocks process_prompt and process_response when threaded state is in use and jailbreak_deep_context_score >= 75.")
3777
+ @severity("high")
3778
+ @tags("category:security,threat:jailbreak,detection:ml,surface:process-prompt,surface:process-response,scope:multi-turn,owasp:llm02")
3779
+ @reject_message("Prompt blocked: the multi-turn model scored this conversation as a jailbreak attempt with high confidence.")
3780
+ forbid (
3781
+ principal,
3782
+ action in [Guardrails::Action::"process_prompt", Guardrails::Action::"process_response"],
3783
+ resource
3784
+ )
3785
+ when {
3786
+ context has multi_turn_detection && context.multi_turn_detection == true &&
3787
+ context has jailbreak_deep_context_score && context.jailbreak_deep_context_score >= 75
3788
+ };
3789
+ `;
3790
+ const GUARDRAILS_AGENT_SECURITY_SESSION_RISK_ACCUMULATION_CEDAR = `// =============================================================================
3791
+ // Session Risk Accumulation
3792
+ // =============================================================================
3793
+ // Blocks the privileged action a probing conversation is working toward. A
3794
+ // patient attacker expects some turns to be refused; what they want is one
3795
+ // tool call at the end — send the email, move the money, read the file. So
3796
+ // the useful question at a tool call is not "is this call suspicious?" but
3797
+ // "what has this conversation been doing up to now?"
3798
+ //
3799
+ // Shield accumulates that history on the session and projects it three ways,
3800
+ // because attackers come in three shapes:
3801
+ // - session_max_* — a high-water mark that never decays. "Did this session
3802
+ // EVER cross a line?" Catches the attacker who probes hard, is refused,
3803
+ // goes quiet, then calmly asks for the tool.
3804
+ // - session_cumulative_risk_score — an uncapped running sum. "How much
3805
+ // total pressure has this session applied?" Catches death by a thousand
3806
+ // cuts, where no single turn is alarming.
3807
+ // - session_threat_turns — a count of turns that tripped a detector. "Is
3808
+ // this sustained, or a one-off?" Separates probing from a false positive.
3809
+ //
3810
+ // Requires a stable session_id on every request, prompts AND tool calls. The
3811
+ // tool call must ride the same session as the conversation, or it reads 0.
3812
+ //
3813
+ // Detection layers:
3814
+ // - session (aggregate over detection history, always available)
3815
+ // - tool_validator (tool_is_sensitive, always available)
3816
+ //
3817
+ // Context keys consumed:
3818
+ // - session_max_injection_score, session_max_jailbreak_score: Long (0-100)
3819
+ // - session_cumulative_risk_score: Long — uncapped sum
3820
+ // - session_threat_turns: Long — count
3821
+ // - tool_is_sensitive: Bool
3822
+ //
3823
+ // Compliance:
3824
+ // - OWASP LLM01, OWASP LLM06, OWASP ASI01, OWASP ASI04
3825
+ //
3826
+ // Category: agent-security
3827
+ // Namespace: Guardrails
3828
+ // =============================================================================
3829
+
3830
+ // ---------------------------------------------------------------------------
3831
+ // Section 1: A session that ever crossed the line does not get to act
3832
+ // The conversation is what scored high; the tool call is what gets stopped.
3833
+ // ---------------------------------------------------------------------------
3834
+
3835
+ @id("agent-security.block-tool-after-injection-in-session")
3836
+ @name("Block tools after an injection or jailbreak in the session")
3837
+ @description("Blocks call_tool when session_max_injection_score >= 60 or session_max_jailbreak_score >= 60, because an earlier turn attempted injection or jailbreak.")
3838
+ @severity("critical")
3839
+ @tags("category:agent-security,threat:escalation,detection:aggregate,surface:call-tool,scope:multi-turn,owasp:llm01")
3840
+ @reject_message("Tool execution blocked: an earlier turn in this session attempted prompt injection or jailbreak. Start a new session to use tools.")
3841
+ forbid (
3842
+ principal,
3843
+ action == Guardrails::Action::"call_tool",
3844
+ resource
3845
+ )
3846
+ when {
3847
+ (context has session_max_injection_score && context.session_max_injection_score >= 60) ||
3848
+ (context has session_max_jailbreak_score && context.session_max_jailbreak_score >= 60)
3849
+ };
3850
+
3851
+ // ---------------------------------------------------------------------------
3852
+ // Section 2: Accumulated pressure gates sensitive tools
3853
+ // No single turn set a max, but the session as a whole kept pushing.
3854
+ // ---------------------------------------------------------------------------
3855
+
3856
+ @id("agent-security.block-sensitive-tool-on-session-risk")
3857
+ @name("Block sensitive tools once the session has accumulated risk")
3858
+ @description("Blocks call_tool when session_cumulative_risk_score >= 151 and tool_is_sensitive is true.")
3859
+ @severity("high")
3860
+ @tags("category:agent-security,threat:escalation,detection:aggregate,surface:call-tool,scope:multi-turn,owasp:asi01")
3861
+ @reject_message("Tool execution blocked: this session has accumulated significant risk across earlier turns. Sensitive tools are withheld for the remainder of the session.")
3862
+ forbid (
3863
+ principal,
3864
+ action == Guardrails::Action::"call_tool",
3865
+ resource
3866
+ )
3867
+ when {
3868
+ context has session_cumulative_risk_score &&
3869
+ context.session_cumulative_risk_score >= 151 &&
3870
+ context has tool_is_sensitive && context.tool_is_sensitive == true
3871
+ };
3872
+
3873
+ // ---------------------------------------------------------------------------
3874
+ // Section 3: Sustained probing, independent of any single score
3875
+ // Two is the smallest bar that separates repeated from one-off. Long-running
3876
+ // agent sessions accumulate turns faster and warrant a higher bar.
3877
+ // ---------------------------------------------------------------------------
3878
+
3879
+ @id("agent-security.block-tool-on-repeated-threat-turns")
3880
+ @name("Block tool use in a session with repeated threat turns")
3881
+ @description("Blocks call_tool when session_threat_turns >= 2, because more than one turn in this session tripped a detector.")
3882
+ @severity("high")
3883
+ @tags("category:agent-security,threat:escalation,detection:aggregate,surface:call-tool,scope:multi-turn,owasp:asi04")
3884
+ @reject_message("Tool execution blocked: more than one turn in this session tripped a detector. This is sustained probing, not a one-off false positive.")
3885
+ forbid (
3886
+ principal,
3887
+ action == Guardrails::Action::"call_tool",
3888
+ resource
3889
+ )
3890
+ when {
3891
+ context has session_threat_turns && context.session_threat_turns >= 2
3892
+ };
3893
+ `;
3894
+ const GUARDRAILS_AGENT_IDENTITY_DUAL_ATTRIBUTION_CEDAR = `// =============================================================================
3895
+ // Dual Attribution
3896
+ // =============================================================================
3897
+ // Blocks privileged agent actions that cannot be attributed to a human. "Which
3898
+ // agent did this?" is half an answer; the other half is "on whose behalf?" An
3899
+ // agent is not an accountable party — the person who pointed it at the work
3900
+ // is. Shield projects both sides of that pair, so the requirement can be a
3901
+ // policy rather than a reporting convention nobody enforces.
3902
+ //
3903
+ // The agent comes from the authenticated identity, never from the request
3904
+ // body: agent_id, agent_type, agent_trust_level, agent_framework. The human
3905
+ // comes from the identity claims on the credential, as the \`principal\` record
3906
+ // — its \`act_sub\` field names the party the agent is acting for.
3907
+ //
3908
+ // Deploy in MONITOR mode first. Section 2 blocks unverified agents, and a
3909
+ // service key authenticates as unverified until the agent is registered and
3910
+ // adopted in Studio. Monitor records what each rule would have blocked on
3911
+ // every event without blocking anything; register the agent, then switch to
3912
+ // enforce.
3913
+ //
3914
+ // Detection layers:
3915
+ // - agent identity (authentication layer, always available)
3916
+ // - tool_validator (tool_is_sensitive, tool_category, always available)
3917
+ //
3918
+ // Context keys consumed:
3919
+ // - agent_id, agent_type, agent_trust_level: String
3920
+ // - principal: record — act_sub names the accountable human
3921
+ // - tool_is_sensitive: Bool
3922
+ // - tool_category: String
3923
+ //
3924
+ // Compliance:
3925
+ // - OWASP ASI01, OWASP ASI04
3926
+ //
3927
+ // Category: agent-identity
3928
+ // Namespace: Guardrails
3929
+ // =============================================================================
3930
+
3931
+ // ---------------------------------------------------------------------------
3932
+ // Section 1: No unattributed privileged action
3933
+ // \`unless\` because the rule must fire when attribution is ABSENT, and an
3934
+ // absent field cannot be compared — only tested for.
3935
+ // ---------------------------------------------------------------------------
3936
+
3937
+ @id("agent-identity.require-principal-for-sensitive-tools")
3938
+ @name("Block sensitive agent tools without a principal")
3939
+ @description("Blocks call_tool on a sensitive tool by an agent unless the credential carries a principal whose act_sub names the human the agent acts for.")
3940
+ @severity("critical")
3941
+ @tags("category:agent-identity,detection:rule,surface:call-tool,scope:per-agent,posture:deny-default,owasp:asi01")
3942
+ @reject_message("Tool execution blocked: this agent invoked a sensitive tool with no accountable human attached. Every privileged agent action must name the person it acts for.")
3943
+ forbid (
3944
+ principal,
3945
+ action == Guardrails::Action::"call_tool",
3946
+ resource
3947
+ )
3948
+ when {
3949
+ context has agent_id && context.agent_id != "" &&
3950
+ context has tool_is_sensitive && context.tool_is_sensitive == true
3951
+ }
3952
+ unless {
3953
+ context has principal && context.principal has act_sub
3954
+ };
3955
+
3956
+ // ---------------------------------------------------------------------------
3957
+ // Section 2: Trust level gates the blast radius
3958
+ // Start in monitor: a service key is unverified until the agent is adopted.
3959
+ // ---------------------------------------------------------------------------
3960
+
3961
+ @id("agent-identity.block-unverified-agent-sensitive-tools")
3962
+ @name("Block unverified agents from sensitive tools")
3963
+ @description("Blocks call_tool when agent_trust_level is unverified and the tool is sensitive or dangerous.")
3964
+ @severity("critical")
3965
+ @tags("category:agent-identity,detection:rule,surface:call-tool,scope:per-agent,posture:deny-default,owasp:asi01")
3966
+ @reject_message("Tool execution blocked: unverified agents may not call sensitive or dangerous tools. Register and adopt the agent to raise its trust level.")
3967
+ forbid (
3968
+ principal,
3969
+ action == Guardrails::Action::"call_tool",
3970
+ resource
3971
+ )
3972
+ when {
3973
+ context has agent_trust_level && context.agent_trust_level == "unverified" &&
3974
+ (
3975
+ (context has tool_is_sensitive && context.tool_is_sensitive == true) ||
3976
+ (context has tool_category && context.tool_category == "dangerous")
3977
+ )
3978
+ };
3979
+
3980
+ // ---------------------------------------------------------------------------
3981
+ // Section 3: Autonomous agents get a higher bar
3982
+ // Nobody watches an autonomous agent in real time, so a sensitive tool call
3983
+ // from one requires first-party trust rather than merely "not unverified".
3984
+ // ---------------------------------------------------------------------------
3985
+
3986
+ @id("agent-identity.restrict-autonomous-agent-sensitive-tools")
3987
+ @name("Block non-first-party autonomous agent tool use")
3988
+ @description("Blocks call_tool on a sensitive tool when agent_type is autonomous unless agent_trust_level is first_party.")
3989
+ @severity("high")
3990
+ @tags("category:agent-identity,threat:escalation,detection:rule,surface:call-tool,scope:per-agent,owasp:asi04")
3991
+ @reject_message("Tool execution blocked: autonomous agents must be first-party to call sensitive tools. No human is in the loop to catch a mistake.")
3992
+ forbid (
3993
+ principal,
3994
+ action == Guardrails::Action::"call_tool",
3995
+ resource
3996
+ )
3997
+ when {
3998
+ context has agent_type && context.agent_type == "autonomous" &&
3999
+ context has tool_is_sensitive && context.tool_is_sensitive == true
4000
+ }
4001
+ unless {
4002
+ context has agent_trust_level && context.agent_trust_level == "first_party"
4003
+ };
4004
+ `;
3673
4005
  // =============================================================================
3674
4006
  // CATEGORIES
3675
4007
  // =============================================================================
@@ -4045,6 +4377,33 @@ export const GUARDRAILS_TEMPLATES = [
4045
4377
  severity: 'critical',
4046
4378
  tags: ['category:data-protection', 'threat:secrets', 'surface:process-response', 'detection:rule'],
4047
4379
  },
4380
+ {
4381
+ id: 'security.multi-turn-trajectory',
4382
+ name: 'Multi-Turn Trajectory Escalation',
4383
+ description: 'Block a conversation whose trajectory is an attack even when no single message is: fires on the gap between the multi-turn model and the single-turn classifier.',
4384
+ category: 'security',
4385
+ cedarText: GUARDRAILS_SECURITY_MULTI_TURN_TRAJECTORY_CEDAR,
4386
+ severity: 'critical',
4387
+ tags: ['category:security', 'threat:injection', 'threat:jailbreak', 'detection:ml', 'scope:multi-turn', 'owasp:llm01', 'owasp:llm02'],
4388
+ },
4389
+ {
4390
+ id: 'agent-security.session-risk-accumulation',
4391
+ name: 'Session Risk Accumulation',
4392
+ description: 'Block the privileged action a probing conversation is working toward, using the session\'s high-water mark, cumulative risk, and count of threat turns.',
4393
+ category: 'agent-security',
4394
+ cedarText: GUARDRAILS_AGENT_SECURITY_SESSION_RISK_ACCUMULATION_CEDAR,
4395
+ severity: 'critical',
4396
+ tags: ['category:agent-security', 'threat:escalation', 'detection:aggregate', 'surface:call-tool', 'scope:multi-turn', 'owasp:asi01', 'owasp:asi04'],
4397
+ },
4398
+ {
4399
+ id: 'agent-identity.dual-attribution',
4400
+ name: 'Dual Attribution',
4401
+ description: 'Block privileged agent actions that cannot be attributed to a human. Deploy in monitor mode first; a service key is unverified until the agent is adopted.',
4402
+ category: 'agent-identity',
4403
+ cedarText: GUARDRAILS_AGENT_IDENTITY_DUAL_ATTRIBUTION_CEDAR,
4404
+ severity: 'critical',
4405
+ tags: ['category:agent-identity', 'detection:rule', 'surface:call-tool', 'scope:per-agent', 'posture:deny-default', 'owasp:asi01'],
4406
+ },
4048
4407
  ];
4049
4408
  // =============================================================================
4050
4409
  // TEMPLATES METADATA
@@ -4651,6 +5010,56 @@ export const GUARDRAILS_TEMPLATES_JSON = `{
4651
5010
  "surface:process-response",
4652
5011
  "detection:rule"
4653
5012
  ]
5013
+ },
5014
+ {
5015
+ "id": "security.multi-turn-trajectory",
5016
+ "name": "Multi-Turn Trajectory Escalation",
5017
+ "description": "Block a conversation whose trajectory is an attack even when no single message is: fires on the gap between the multi-turn model and the single-turn classifier.",
5018
+ "category": "security",
5019
+ "file": "multi_turn_trajectory.cedar",
5020
+ "severity": "critical",
5021
+ "tags": [
5022
+ "category:security",
5023
+ "threat:injection",
5024
+ "threat:jailbreak",
5025
+ "detection:ml",
5026
+ "scope:multi-turn",
5027
+ "owasp:llm01",
5028
+ "owasp:llm02"
5029
+ ]
5030
+ },
5031
+ {
5032
+ "id": "agent-security.session-risk-accumulation",
5033
+ "name": "Session Risk Accumulation",
5034
+ "description": "Block the privileged action a probing conversation is working toward, using the session's high-water mark, cumulative risk, and count of threat turns.",
5035
+ "category": "agent-security",
5036
+ "file": "session_risk_accumulation.cedar",
5037
+ "severity": "critical",
5038
+ "tags": [
5039
+ "category:agent-security",
5040
+ "threat:escalation",
5041
+ "detection:aggregate",
5042
+ "surface:call-tool",
5043
+ "scope:multi-turn",
5044
+ "owasp:asi01",
5045
+ "owasp:asi04"
5046
+ ]
5047
+ },
5048
+ {
5049
+ "id": "agent-identity.dual-attribution",
5050
+ "name": "Dual Attribution",
5051
+ "description": "Block privileged agent actions that cannot be attributed to a human. Deploy in monitor mode first; a service key is unverified until the agent is adopted.",
5052
+ "category": "agent-identity",
5053
+ "file": "dual_attribution.cedar",
5054
+ "severity": "critical",
5055
+ "tags": [
5056
+ "category:agent-identity",
5057
+ "detection:rule",
5058
+ "surface:call-tool",
5059
+ "scope:per-agent",
5060
+ "posture:deny-default",
5061
+ "owasp:asi01"
5062
+ ]
4654
5063
  }
4655
5064
  ]
4656
5065
  }
package/dist/parser.d.ts CHANGED
@@ -25,7 +25,8 @@ export interface ParseResult {
25
25
  *
26
26
  * Uses the official cedar-wasm engine for parsing, ensuring correctness.
27
27
  * Policies with features that can't be represented as PolicyRule (e.g.,
28
- * unless clauses, complex expressions) are returned in the unstructured array.
28
+ * template slots) are returned in the unstructured array. Cedar `unless`
29
+ * clauses are represented as negated condition expressions.
29
30
  *
30
31
  * @param cedarText - Cedar policy text to parse
31
32
  * @returns ParseResult with structured rules, unstructured policies, and errors
package/dist/parser.js CHANGED
@@ -25,7 +25,8 @@ function normalizeEntityRef(ref) {
25
25
  *
26
26
  * Uses the official cedar-wasm engine for parsing, ensuring correctness.
27
27
  * Policies with features that can't be represented as PolicyRule (e.g.,
28
- * unless clauses, complex expressions) are returned in the unstructured array.
28
+ * template slots) are returned in the unstructured array. Cedar `unless`
29
+ * clauses are represented as negated condition expressions.
29
30
  *
30
31
  * @param cedarText - Cedar policy text to parse
31
32
  * @returns ParseResult with structured rules, unstructured policies, and errors
@@ -154,12 +155,6 @@ function cedarJsonToRule(policy, policyId, index, originalText) {
154
155
  * Check if a Cedar policy can be represented as PolicyRule
155
156
  */
156
157
  function canRepresentAsRule(policy) {
157
- // Unless clauses can't be represented
158
- for (const cond of policy.conditions) {
159
- if (cond.kind === "unless") {
160
- return false;
161
- }
162
- }
163
158
  // Template slots can't be represented
164
159
  if (hasSlot(policy.principal) || hasSlot(policy.resource)) {
165
160
  return false;
@@ -289,27 +284,38 @@ function mapActionScope(scope) {
289
284
  function mapConditions(conditions, originalText) {
290
285
  const result = [];
291
286
  let hasUnmapped = false;
292
- // Collect condition expressions from all when clauses
287
+ // Collect condition expressions from all when/unless clauses.
293
288
  const expressions = [];
289
+ // A policy may carry several `when` clauses, which Cedar ANDs together.
290
+ // Each clause must recover ITS OWN text: a previous version always read the
291
+ // first clause, so a second clause that fell through to the `raw` fallback
292
+ // was stored as a duplicate of the first and its real content was lost.
293
+ const clauses = originalText ? extractConditionClauses(originalText) : [];
294
+ let clauseIndex = 0;
294
295
  for (const cond of conditions) {
295
- if (cond.kind !== "when") {
296
- continue;
297
- }
296
+ const clauseText = clauses[clauseIndex]?.text;
297
+ clauseIndex++;
298
298
  // Flat mapping (backward compat)
299
299
  const parsed = mapConditionBody(cond.body);
300
- if (parsed.condition) {
300
+ if (cond.kind === "when" && parsed.condition) {
301
301
  result.push(parsed.condition);
302
302
  }
303
- else if (parsed.raw) {
303
+ else if (parsed.raw || cond.kind === "unless") {
304
304
  hasUnmapped = true;
305
305
  }
306
- // Recursive mapping (new)
307
- expressions.push(mapConditionBodyToExpression(cond.body, originalText));
306
+ const expression = mapConditionBodyToExpression(cond.body, clauseText);
307
+ expressions.push(cond.kind === "unless" ? { kind: 'not', child: expression } : expression);
308
308
  }
309
- // Extract readable Cedar condition text instead of storing JSON AST
309
+ // Extract readable Cedar condition text instead of storing JSON AST.
310
+ // With several clauses, join them — `when { A } when { B }` is `A && B`, so
311
+ // collapsing them into one guard keeps the regenerated policy equivalent
312
+ // rather than silently dropping every clause after the first.
310
313
  let rawCondition;
311
- if (hasUnmapped && originalText) {
312
- rawCondition = extractWhenClause(originalText);
314
+ if (hasUnmapped && clauses.length > 0) {
315
+ const equivalent = clauses.map(({ kind, text }) => kind === 'unless' ? `!(${text})` : `(${text})`);
316
+ rawCondition = clauses.length === 1 && clauses[0].kind === 'when'
317
+ ? clauses[0].text
318
+ : equivalent.join(" && ");
313
319
  }
314
320
  // Build the final condition expression
315
321
  let conditionExpression;
@@ -326,29 +332,105 @@ function mapConditions(conditions, originalText) {
326
332
  };
327
333
  }
328
334
  /**
329
- * Extract the readable condition text from a Cedar policy's when clause.
330
- * Given: `forbid (...)\nwhen { context.path like "/etc/*" };`
331
- * Returns: `context.path like "/etc/*"`
335
+ * Extract the readable condition text of every `when` clause, in source order.
336
+ *
337
+ * Given: `forbid (...)\nwhen { context.path like "/etc/*" }\nwhen { resource in X };`
338
+ * Returns: `['context.path like "/etc/*"', 'resource in X']`
339
+ *
340
+ * Scanning is string-literal aware, which matters for correctness rather than
341
+ * tidiness — a naive brace count mis-reads a clause in two ways, and this text
342
+ * is re-emitted into policy Cedar via `rawCondition`:
343
+ *
344
+ * when { context.path like "/etc/}" && context.a == 1 }
345
+ * → a `}` inside the literal ends the clause early, yielding text that is
346
+ * cut mid-string and is not valid Cedar.
347
+ * when { context.msg == "when { x" }
348
+ * → the `{` inside the literal unbalances the count, so the scan runs past
349
+ * the clause and swallows the `;` and whatever follows.
350
+ *
351
+ * The builder's dangerous-pattern guard rejects the second shape, but a
352
+ * rejected rawCondition with no flat conditions to fall back on emits the rule
353
+ * with NO `when` clause at all — an unconditional permit or forbid. Getting the
354
+ * boundaries right is what keeps that path unreachable.
355
+ *
356
+ * Brace depth still applies outside literals, so record literals (`{ a: 1 }`)
357
+ * inside a clause do not terminate it early.
332
358
  */
333
- function extractWhenClause(cedarText) {
334
- const whenPrefix = "when {";
335
- const idx = cedarText.indexOf(whenPrefix);
336
- if (idx < 0) {
337
- return "";
359
+ function extractWhenClauses(cedarText) {
360
+ return extractConditionClauses(cedarText)
361
+ .filter(({ kind }) => kind === 'when')
362
+ .map(({ text }) => text);
363
+ }
364
+ function extractConditionClauses(cedarText) {
365
+ const result = [];
366
+ let i = 0;
367
+ let inString = false;
368
+ while (i < cedarText.length) {
369
+ const ch = cedarText[i];
370
+ if (inString) {
371
+ if (ch === "\\") {
372
+ i += 2;
373
+ continue;
374
+ }
375
+ if (ch === '"')
376
+ inString = false;
377
+ i++;
378
+ continue;
379
+ }
380
+ if (ch === '"') {
381
+ inString = true;
382
+ i++;
383
+ continue;
384
+ }
385
+ const precededByIdent = i > 0 && /[A-Za-z0-9_]/.test(cedarText[i - 1]);
386
+ const kind = cedarText.startsWith('when {', i)
387
+ ? 'when'
388
+ : cedarText.startsWith('unless {', i)
389
+ ? 'unless'
390
+ : undefined;
391
+ if (!precededByIdent && kind) {
392
+ const start = i + `${kind} {`.length;
393
+ const end = findClauseEnd(cedarText, start);
394
+ result.push({ kind, text: cedarText.substring(start, end).trim() });
395
+ i = Math.min(end + 1, cedarText.length);
396
+ continue;
397
+ }
398
+ i++;
338
399
  }
339
- const body = cedarText.substring(idx + whenPrefix.length);
400
+ return result;
401
+ }
402
+ /**
403
+ * Index of the `}` closing a clause body that starts at `start`, or the end of
404
+ * the text when the braces never balance. String-literal aware — see
405
+ * `extractWhenClauses`.
406
+ */
407
+ function findClauseEnd(cedarText, start) {
340
408
  let depth = 1;
341
- for (let i = 0; i < body.length; i++) {
342
- if (body[i] === "{")
409
+ let inString = false;
410
+ for (let i = start; i < cedarText.length; i++) {
411
+ const ch = cedarText[i];
412
+ if (inString) {
413
+ if (ch === "\\") {
414
+ i++;
415
+ continue;
416
+ }
417
+ if (ch === '"')
418
+ inString = false;
419
+ continue;
420
+ }
421
+ if (ch === '"') {
422
+ inString = true;
423
+ continue;
424
+ }
425
+ if (ch === "{")
343
426
  depth++;
344
- if (body[i] === "}") {
427
+ if (ch === "}") {
345
428
  depth--;
346
- if (depth === 0) {
347
- return body.substring(0, i).trim();
348
- }
429
+ if (depth === 0)
430
+ return i;
349
431
  }
350
432
  }
351
- return body.trim();
433
+ return cedarText.length;
352
434
  }
353
435
  /**
354
436
  * Map a Cedar expression body to PolicyCondition
@@ -388,8 +470,13 @@ function mapConditionBody(body) {
388
470
  /**
389
471
  * Recursively walk the Cedar JSON AST to build a ConditionExpression tree.
390
472
  * Flattens binary && / || chains into n-ary and/or nodes.
473
+ *
474
+ * `clauseText` is the source text of the `when` clause this expression came
475
+ * from — already extracted by the caller, never the whole policy. It is only
476
+ * used for the `raw` fallback below, so a clause the walker cannot model keeps
477
+ * its own text instead of borrowing another clause's.
391
478
  */
392
- function mapConditionBodyToExpression(expr, originalText) {
479
+ function mapConditionBodyToExpression(expr, clauseText) {
393
480
  // Logical AND — flatten binary chain
394
481
  if (expr["&&"]) {
395
482
  const children = flattenBinaryChain("&&", expr);
@@ -402,7 +489,7 @@ function mapConditionBodyToExpression(expr, originalText) {
402
489
  }
403
490
  // Negation
404
491
  if (expr["!"]) {
405
- const child = mapConditionBodyToExpression(expr["!"].arg, originalText);
492
+ const child = mapConditionBodyToExpression(expr["!"].arg, clauseText);
406
493
  return { kind: 'not', child };
407
494
  }
408
495
  // Has (existence check): { has: { left: { Var: "context" }, attr: "field" } }
@@ -434,7 +521,7 @@ function mapConditionBodyToExpression(expr, originalText) {
434
521
  return mapped;
435
522
  }
436
523
  // Fallback — extract readable text if possible
437
- const text = originalText ? extractWhenClause(originalText) : JSON.stringify(expr);
524
+ const text = clauseText ?? JSON.stringify(expr);
438
525
  return { kind: 'raw', text };
439
526
  }
440
527
  /**