@highflame/policy 2.3.0 → 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.
@@ -1,22 +1,77 @@
1
1
  // =============================================================================
2
2
  // Dual Attribution
3
3
  // =============================================================================
4
- // Blocks privileged agent actions that cannot be attributed to a human. "Which
5
- // agent did this?" is half an answer; the other half is "on whose behalf?" An
6
- // agent is not an accountable party — the person who pointed it at the work
7
- // is. Shield projects both sides of that pair, so the requirement can be a
8
- // policy rather than a reporting convention nobody enforces.
4
+ // Routes privileged agent actions that no human answers for to explicit
5
+ // human approval. "Which agent did this?" is half an answer; the other half
6
+ // is "on whose behalf?" When an agent acts for a person, that person is the
7
+ // accountable party. When it acts for nobody (an autonomous agent, a
8
+ // workload, an api_key-rooted token), its sensitive tool calls run only when
9
+ // someone explicitly authorizes them, never silently and never by default.
9
10
  //
10
11
  // The agent comes from the authenticated identity, never from the request
11
12
  // body: agent_id, agent_type, agent_trust_level, agent_framework. The human
12
- // comes from the identity claims on the credential, as the `principal` record
13
- // — its `act_sub` field names the party the agent is acting for.
13
+ // comes from the identity claims on the credential, as the `principal`
14
+ // record. Its `act_sub` field names the human the agent acts for, and is
15
+ // absent when there is none: legacy tokens carry the delegating parent (or
16
+ // the api key's creator), while rfc8693 tokens carry it only when the
17
+ // delegation chain is rooted in a user. Autonomous, workload and
18
+ // api_key-rooted rfc8693 tokens have no act_sub.
14
19
  //
15
- // Deploy in MONITOR mode first. Section 2 blocks unverified agents, and a
16
- // service key authenticates as unverified until the agent is registered and
17
- // adopted in Studio. Monitor records what each rule would have blocked on
18
- // every event without blocking anything; register the agent, then switch to
19
- // enforce.
20
+ // How an agent call with no acting human is authorized: it needs an
21
+ // explicit approval OR an explicit grant.
22
+ // 1. Approval. Section 1 permits the call but suspends it for step-up
23
+ // approval (@step_up_required, AARM R4, CAP-ENF-004): Shield issues an
24
+ // OpenID CIBA challenge and the call runs only after an approver with
25
+ // the named AuthN role approves it; the timeout denies it (fail-closed).
26
+ // 2. Grants. Allow/deny rows in Studio's agent access table compile to
27
+ // permits (grants) and forbids (restrictions) that Shield evaluates per
28
+ // agent. A restriction always denies. Section 1 carries
29
+ // @step_up_unless_granted, so when an allow grant explicitly matches
30
+ // the call under an ENFORCING access posture, Shield waives the
31
+ // challenge and the call runs (recorded as step_up_waived_by_grant).
32
+ // Under the default shadow posture a grant is only observed, so it
33
+ // waives nothing and approval decides. With no matching grant the call
34
+ // is challenged under either posture, not denied for lack of a grant.
35
+ // The waiver needs every step-up policy matching the call to carry
36
+ // @step_up_unless_granted: another step-up policy without it still
37
+ // challenges. Shield releases older than the grant waiver ignore the
38
+ // annotation: there a no-human call needs the approval even when granted,
39
+ // and under an enforcing posture an ungranted one is denied outright.
40
+ //
41
+ // A call carrying an acting human (act_sub present) is untouched by
42
+ // Section 1, unless the agent is autonomous and not first_party.
43
+ //
44
+ // Customize Section 1 before deploying:
45
+ // - @step_up_unless_granted: remove it to require the approval even for
46
+ // calls an allow grant covers (grant AND approval).
47
+ // - role: must exist in authn.roles ("security_oncall" is a placeholder;
48
+ // unknown roles reject the policy at deploy time). Another step-up
49
+ // policy matching the same call must name the same role, or Shield
50
+ // denies the call (one approval cannot satisfy two roles).
51
+ // - timeout_seconds: this template sets 3600 (1h); when omitted the
52
+ // default is 86400 (24h). Bounds [60, 604800].
53
+ //
54
+ // Deploy this policy in ENFORCE mode. Section 1's approval gate exists only
55
+ // in enforce mode: in monitor or alert mode Shield issues no challenge and
56
+ // passes Section 1's Allow through unchanged. Section 1 is a permit, so in
57
+ // a policy set without the baseline permit (organization.permit-baseline)
58
+ // it is what lifts the implicit deny for these calls. In enforce mode that
59
+ // lift is gated on approval (or a grant); in monitor mode the same calls
60
+ // would be allowed with no approval at all, a widening, not an observation.
61
+ //
62
+ // Only Section 2 is safe to observe first. It blocks unverified agents, and
63
+ // a service key authenticates as unverified until the agent is registered
64
+ // and adopted in Studio. Either register and adopt the agents before
65
+ // deploying, or move Section 2 into its own policy, deploy that one in
66
+ // MONITOR mode to see what it would block, and switch it to enforce once
67
+ // the agents are adopted. Mode is per policy, so never put this whole
68
+ // template in monitor to trial Section 2.
69
+ //
70
+ // Change note: Section 3 (autonomous agents must be first_party) used to be
71
+ // a hard block. It is now folded into Section 1, so a non-first-party
72
+ // autonomous agent's sensitive call is routed to approval (or a grant)
73
+ // instead of being denied. To restore the hard block, add the forbid kept
74
+ // in the Section 3 comment below; it overrides approvals and grants.
20
75
  //
21
76
  // Detection layers:
22
77
  // - agent identity (authentication layer, always available)
@@ -24,7 +79,8 @@
24
79
  //
25
80
  // Context keys consumed:
26
81
  // - agent_id, agent_type, agent_trust_level: String
27
- // - principal: record — act_sub names the accountable human
82
+ // - principal: record; act_sub names the acting human, absent when the
83
+ // agent acts for nobody
28
84
  // - tool_is_sensitive: Bool
29
85
  // - tool_category: String
30
86
  //
@@ -36,33 +92,69 @@
36
92
  // =============================================================================
37
93
 
38
94
  // ---------------------------------------------------------------------------
39
- // Section 1: No unattributed privileged action
40
- // `unless` because the rule must fire when attribution is ABSENT, and an
41
- // absent field cannot be compared — only tested for.
95
+ // Section 1: No-human privileged action requires approval
96
+ // ENFORCE mode only: in monitor mode this permit allows the call with no
97
+ // approval (see the header).
98
+ // Matches when attribution is ABSENT (an absent field cannot be compared,
99
+ // only tested for), and also for autonomous agents that are not first_party
100
+ // even when a human is attached: nobody watches an autonomous agent in real
101
+ // time, and a legacy api_key token names the key's creator as act_sub
102
+ // whether or not that person is directing the call.
103
+ //
104
+ // Cedar is deny-overrides and Shield issues step-up only when the decision
105
+ // is Allow, so this permit must not coexist with a forbid on the same calls:
106
+ // the forbid would deny every one of them and no approval would be asked.
107
+ // To hard-block instead of gating on approval, replace this rule with:
108
+ //
109
+ // @id("agent-identity.require-principal-for-sensitive-tools")
110
+ // @name("Block sensitive agent tools without a principal")
111
+ // @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.")
112
+ // @severity("critical")
113
+ // @tags("category:agent-identity,detection:rule,scope:per-agent,surface:call-tool,owasp:asi01,posture:deny-default")
114
+ // @reject_message("Tool execution blocked: this agent invoked a sensitive tool with no accountable human attached.")
115
+ // forbid (
116
+ // principal,
117
+ // action == Guardrails::Action::"call_tool",
118
+ // resource
119
+ // )
120
+ // when {
121
+ // context has agent_id && context.agent_id != "" &&
122
+ // context has tool_is_sensitive && context.tool_is_sensitive == true
123
+ // }
124
+ // unless {
125
+ // context has principal && context.principal has act_sub
126
+ // };
42
127
  // ---------------------------------------------------------------------------
43
128
 
44
- @id("agent-identity.require-principal-for-sensitive-tools")
45
- @name("Block sensitive agent tools without a principal")
46
- @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.")
129
+ @id("agent-identity.step-up-sensitive-tools-without-principal")
130
+ @name("Permit no-human sensitive agent tools with step-up approval")
131
+ @description("Permits call_tool on a sensitive tool by an agent with no acting human (no act_sub), or an autonomous agent that is not first_party, but suspends it until an approver with the configured AuthN role approves, unless an enforcing allow grant explicitly covers the call.")
47
132
  @severity("critical")
48
- @tags("category:agent-identity,detection:rule,surface:call-tool,scope:per-agent,posture:deny-default,owasp:asi01")
49
- @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.")
50
- forbid (
133
+ @tags("category:agent-identity,detection:rule,scope:per-agent,surface:call-tool,posture:deny-default,owasp:asi01,step-up")
134
+ @step_up_required("role=security_oncall,timeout_seconds=3600")
135
+ @step_up_unless_granted
136
+ permit (
51
137
  principal,
52
138
  action == Guardrails::Action::"call_tool",
53
139
  resource
54
140
  )
55
141
  when {
56
142
  context has agent_id && context.agent_id != "" &&
57
- context has tool_is_sensitive && context.tool_is_sensitive == true
58
- }
59
- unless {
60
- context has principal && context.principal has act_sub
143
+ context has tool_is_sensitive && context.tool_is_sensitive == true &&
144
+ (
145
+ !(context has principal && context.principal has act_sub) ||
146
+ (
147
+ context has agent_type && context.agent_type == "autonomous" &&
148
+ !(context has agent_trust_level && context.agent_trust_level == "first_party")
149
+ )
150
+ )
61
151
  };
62
152
 
63
153
  // ---------------------------------------------------------------------------
64
154
  // Section 2: Trust level gates the blast radius
65
155
  // Start in monitor: a service key is unverified until the agent is adopted.
156
+ // Unverified agents are blocked outright, approval or not: registration and
157
+ // adoption come before any per-call authorization.
66
158
  // ---------------------------------------------------------------------------
67
159
 
68
160
  @id("agent-identity.block-unverified-agent-sensitive-tools")
@@ -85,26 +177,29 @@ when {
85
177
  };
86
178
 
87
179
  // ---------------------------------------------------------------------------
88
- // Section 3: Autonomous agents get a higher bar
89
- // Nobody watches an autonomous agent in real time, so a sensitive tool call
90
- // from one requires first-party trust rather than merely "not unverified".
180
+ // Section 3 (folded into Section 1): autonomous agents need first-party trust
181
+ // This template used to forbid sensitive tool calls from autonomous agents
182
+ // that are not first-party. A forbid would deny the very calls Section 1
183
+ // routes to approval, so the condition now lives in Section 1 and those
184
+ // calls require approval instead of being blocked. To hard-block them anyway
185
+ // (overriding approvals and grants), add:
186
+ //
187
+ // @id("agent-identity.restrict-autonomous-agent-sensitive-tools")
188
+ // @name("Block non-first-party autonomous agent tool use")
189
+ // @description("Blocks call_tool on a sensitive tool when agent_type is autonomous unless agent_trust_level is first_party.")
190
+ // @severity("high")
191
+ // @tags("category:agent-identity,threat:escalation,detection:rule,surface:call-tool,scope:per-agent,owasp:asi04")
192
+ // @reject_message("Tool execution blocked: autonomous agents must be first-party to call sensitive tools.")
193
+ // forbid (
194
+ // principal,
195
+ // action == Guardrails::Action::"call_tool",
196
+ // resource
197
+ // )
198
+ // when {
199
+ // context has agent_type && context.agent_type == "autonomous" &&
200
+ // context has tool_is_sensitive && context.tool_is_sensitive == true
201
+ // }
202
+ // unless {
203
+ // context has agent_trust_level && context.agent_trust_level == "first_party"
204
+ // };
91
205
  // ---------------------------------------------------------------------------
92
-
93
- @id("agent-identity.restrict-autonomous-agent-sensitive-tools")
94
- @name("Block non-first-party autonomous agent tool use")
95
- @description("Blocks call_tool on a sensitive tool when agent_type is autonomous unless agent_trust_level is first_party.")
96
- @severity("high")
97
- @tags("category:agent-identity,threat:escalation,detection:rule,surface:call-tool,scope:per-agent,owasp:asi04")
98
- @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.")
99
- forbid (
100
- principal,
101
- action == Guardrails::Action::"call_tool",
102
- resource
103
- )
104
- when {
105
- context has agent_type && context.agent_type == "autonomous" &&
106
- context has tool_is_sensitive && context.tool_is_sensitive == true
107
- }
108
- unless {
109
- context has agent_trust_level && context.agent_trust_level == "first_party"
110
- };
@@ -637,7 +637,7 @@
637
637
  {
638
638
  "id": "agent-identity.dual-attribution",
639
639
  "name": "Dual Attribution",
640
- "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.",
640
+ "description": "Require human approval or an explicit allow grant for sensitive agent tool calls that no human answers for (no acting human, e.g. autonomous or workload agents); calls with an acting human pass. Customize the approver role. Deploy in enforce mode: the approval gate does not exist in monitor mode. Adopt agents first (a service key is unverified until then), or trial the unverified-agent block as its own monitor-mode policy.",
641
641
  "category": "agent-identity",
642
642
  "file": "dual_attribution.cedar",
643
643
  "severity": "critical",
@@ -647,7 +647,8 @@
647
647
  "surface:call-tool",
648
648
  "scope:per-agent",
649
649
  "posture:deny-default",
650
- "owasp:asi01"
650
+ "owasp:asi01",
651
+ "step-up"
651
652
  ]
652
653
  },
653
654
  {
@@ -92,6 +92,15 @@ export interface StepUpScopeDirective {
92
92
  */
93
93
  export interface StepUpFourEyesDirective {
94
94
  }
95
+ /**
96
+ * Parsed form of `@step_up_unless_granted`. Marker: an explicit agent grant
97
+ * satisfies this policy's step-up approval. When the call is explicitly
98
+ * allowed by an enforcing allow grant, Shield waives the challenge, provided
99
+ * every step-up policy matching the call carries it. Written bare or as
100
+ * `@step_up_unless_granted("true")`. Only valid with `@step_up_required`.
101
+ */
102
+ export interface StepUpUnlessGrantedDirective {
103
+ }
95
104
  /**
96
105
  * Parsed form of `@single_use`. The allow produced by the permit carrying it
97
106
  * can be spent once. Only valid on a permit (see validateAARMAnnotationsForEffect).
@@ -113,6 +122,7 @@ export interface AARMDirectives {
113
122
  redactionStrategy?: RedactionStrategyDirective;
114
123
  stepUpScope?: StepUpScopeDirective;
115
124
  stepUpFourEyes?: StepUpFourEyesDirective;
125
+ stepUpUnlessGranted?: StepUpUnlessGrantedDirective;
116
126
  singleUse?: SingleUseDirective;
117
127
  }
118
128
  /** True iff at least one AARM directive was parsed. */
@@ -48,6 +48,7 @@ export function hasAnyAARMDirective(d) {
48
48
  d.redactionStrategy !== undefined ||
49
49
  d.stepUpScope !== undefined ||
50
50
  d.stepUpFourEyes !== undefined ||
51
+ d.stepUpUnlessGranted !== undefined ||
51
52
  d.singleUse !== undefined);
52
53
  }
53
54
  /**
@@ -166,6 +167,14 @@ export function parseAARMAnnotations(raw) {
166
167
  directives.stepUpFourEyes = r.directive;
167
168
  break;
168
169
  }
170
+ case 'step_up_unless_granted': {
171
+ const r = buildStepUpUnlessGranted(def, params, value);
172
+ if (r.error)
173
+ errors.push(r.error);
174
+ else
175
+ directives.stepUpUnlessGranted = r.directive;
176
+ break;
177
+ }
169
178
  case 'single_use': {
170
179
  const r = buildSingleUse(def, params, value);
171
180
  if (r.error)
@@ -189,7 +198,7 @@ export function parseAARMAnnotations(raw) {
189
198
  // Without it the policy would permit outright while its author believes a
190
199
  // human approval is required, so reject rather than ignore.
191
200
  if (!directives.stepUpRequired) {
192
- for (const key of ['step_up_four_eyes', 'step_up_scope']) {
201
+ for (const key of ['step_up_four_eyes', 'step_up_scope', 'step_up_unless_granted']) {
193
202
  if (key in raw && !errors.some((e) => e.key === key)) {
194
203
  errors.push(new AARMAnnotationError({
195
204
  key,
@@ -449,6 +458,22 @@ function buildStepUpFourEyes(def, params, raw) {
449
458
  }
450
459
  return { directive: {} };
451
460
  }
461
+ function buildStepUpUnlessGranted(def, params, raw) {
462
+ // Bare or "true" only, as for four-eyes: "false" would read as present
463
+ // (grant waives the approval) to a consumer checking presence.
464
+ const v = params.enabled;
465
+ if (v !== undefined && v !== '' && v !== 'true') {
466
+ return {
467
+ error: new AARMAnnotationError({
468
+ key: def.key,
469
+ parameter: 'enabled',
470
+ rawValue: raw,
471
+ reason: 'value must be omitted or "true"',
472
+ }),
473
+ };
474
+ }
475
+ return { directive: {} };
476
+ }
452
477
  function buildSingleUse(def, params, raw) {
453
478
  const gDef = mustLookupParam(def, 'granularity');
454
479
  const granularity = params.granularity || (gDef.default?.kind === 'string' ? gDef.default.value : 'tool');
@@ -129,7 +129,7 @@ export const AARM_ANNOTATIONS = [
129
129
  },
130
130
  {
131
131
  key: 'step_up_required',
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).',
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). One approver role per matching call: when two or more enforce-mode step-up policies of the same effect match one call and name different roles (\"self\" excepted, since any named role outranks owner approval), no single approval satisfies them, so Shield denies the call with step_up_internal_error. Policy authoring lints this as a warning (step_up_role_conflict).',
133
133
  aarmRequirement: 'R4',
134
134
  promotesCapability: 'CAP-ENF-004',
135
135
  decisionEffect: 'step_up',
@@ -181,6 +181,27 @@ export const AARM_ANNOTATIONS = [
181
181
  },
182
182
  ],
183
183
  },
184
+ {
185
+ key: 'step_up_unless_granted',
186
+ description: 'Let an explicit agent grant satisfy this policy\'s step-up approval. When the call is explicitly allowed by an enforcing allow grant on the agent\'s access table, Shield waives the challenge; otherwise the approval is required as usual. If another step-up policy matching the same call lacks this annotation, the challenge is still issued. Restrictions (deny grants) always deny. Bare (value-less) or @step_up_unless_granted(\"true\"); no other value is legal. Requires @step_up_required on the same policy.',
187
+ aarmRequirement: 'R4',
188
+ promotesCapability: '',
189
+ decisionEffect: '',
190
+ parameters: [
191
+ {
192
+ name: 'enabled',
193
+ type: 'bool',
194
+ required: false,
195
+ positional: false,
196
+ 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.',
197
+ default: { kind: 'bool', value: true },
198
+ min: null,
199
+ max: null,
200
+ pattern: '',
201
+ valueSource: '',
202
+ },
203
+ ],
204
+ },
184
205
  ];
185
206
  /**
186
207
  * O(1) lookup over AARM_ANNOTATIONS. Use this when validating raw
@@ -3894,22 +3894,77 @@ when {
3894
3894
  const GUARDRAILS_AGENT_IDENTITY_DUAL_ATTRIBUTION_CEDAR = `// =============================================================================
3895
3895
  // Dual Attribution
3896
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.
3897
+ // Routes privileged agent actions that no human answers for to explicit
3898
+ // human approval. "Which agent did this?" is half an answer; the other half
3899
+ // is "on whose behalf?" When an agent acts for a person, that person is the
3900
+ // accountable party. When it acts for nobody (an autonomous agent, a
3901
+ // workload, an api_key-rooted token), its sensitive tool calls run only when
3902
+ // someone explicitly authorizes them, never silently and never by default.
3902
3903
  //
3903
3904
  // The agent comes from the authenticated identity, never from the request
3904
3905
  // 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.
3906
+ // comes from the identity claims on the credential, as the \`principal\`
3907
+ // record. Its \`act_sub\` field names the human the agent acts for, and is
3908
+ // absent when there is none: legacy tokens carry the delegating parent (or
3909
+ // the api key's creator), while rfc8693 tokens carry it only when the
3910
+ // delegation chain is rooted in a user. Autonomous, workload and
3911
+ // api_key-rooted rfc8693 tokens have no act_sub.
3912
+ //
3913
+ // How an agent call with no acting human is authorized: it needs an
3914
+ // explicit approval OR an explicit grant.
3915
+ // 1. Approval. Section 1 permits the call but suspends it for step-up
3916
+ // approval (@step_up_required, AARM R4, CAP-ENF-004): Shield issues an
3917
+ // OpenID CIBA challenge and the call runs only after an approver with
3918
+ // the named AuthN role approves it; the timeout denies it (fail-closed).
3919
+ // 2. Grants. Allow/deny rows in Studio's agent access table compile to
3920
+ // permits (grants) and forbids (restrictions) that Shield evaluates per
3921
+ // agent. A restriction always denies. Section 1 carries
3922
+ // @step_up_unless_granted, so when an allow grant explicitly matches
3923
+ // the call under an ENFORCING access posture, Shield waives the
3924
+ // challenge and the call runs (recorded as step_up_waived_by_grant).
3925
+ // Under the default shadow posture a grant is only observed, so it
3926
+ // waives nothing and approval decides. With no matching grant the call
3927
+ // is challenged under either posture, not denied for lack of a grant.
3928
+ // The waiver needs every step-up policy matching the call to carry
3929
+ // @step_up_unless_granted: another step-up policy without it still
3930
+ // challenges. Shield releases older than the grant waiver ignore the
3931
+ // annotation: there a no-human call needs the approval even when granted,
3932
+ // and under an enforcing posture an ungranted one is denied outright.
3933
+ //
3934
+ // A call carrying an acting human (act_sub present) is untouched by
3935
+ // Section 1, unless the agent is autonomous and not first_party.
3936
+ //
3937
+ // Customize Section 1 before deploying:
3938
+ // - @step_up_unless_granted: remove it to require the approval even for
3939
+ // calls an allow grant covers (grant AND approval).
3940
+ // - role: must exist in authn.roles ("security_oncall" is a placeholder;
3941
+ // unknown roles reject the policy at deploy time). Another step-up
3942
+ // policy matching the same call must name the same role, or Shield
3943
+ // denies the call (one approval cannot satisfy two roles).
3944
+ // - timeout_seconds: this template sets 3600 (1h); when omitted the
3945
+ // default is 86400 (24h). Bounds [60, 604800].
3946
+ //
3947
+ // Deploy this policy in ENFORCE mode. Section 1's approval gate exists only
3948
+ // in enforce mode: in monitor or alert mode Shield issues no challenge and
3949
+ // passes Section 1's Allow through unchanged. Section 1 is a permit, so in
3950
+ // a policy set without the baseline permit (organization.permit-baseline)
3951
+ // it is what lifts the implicit deny for these calls. In enforce mode that
3952
+ // lift is gated on approval (or a grant); in monitor mode the same calls
3953
+ // would be allowed with no approval at all, a widening, not an observation.
3954
+ //
3955
+ // Only Section 2 is safe to observe first. It blocks unverified agents, and
3956
+ // a service key authenticates as unverified until the agent is registered
3957
+ // and adopted in Studio. Either register and adopt the agents before
3958
+ // deploying, or move Section 2 into its own policy, deploy that one in
3959
+ // MONITOR mode to see what it would block, and switch it to enforce once
3960
+ // the agents are adopted. Mode is per policy, so never put this whole
3961
+ // template in monitor to trial Section 2.
3962
+ //
3963
+ // Change note: Section 3 (autonomous agents must be first_party) used to be
3964
+ // a hard block. It is now folded into Section 1, so a non-first-party
3965
+ // autonomous agent's sensitive call is routed to approval (or a grant)
3966
+ // instead of being denied. To restore the hard block, add the forbid kept
3967
+ // in the Section 3 comment below; it overrides approvals and grants.
3913
3968
  //
3914
3969
  // Detection layers:
3915
3970
  // - agent identity (authentication layer, always available)
@@ -3917,7 +3972,8 @@ const GUARDRAILS_AGENT_IDENTITY_DUAL_ATTRIBUTION_CEDAR = `// ===================
3917
3972
  //
3918
3973
  // Context keys consumed:
3919
3974
  // - agent_id, agent_type, agent_trust_level: String
3920
- // - principal: record — act_sub names the accountable human
3975
+ // - principal: record; act_sub names the acting human, absent when the
3976
+ // agent acts for nobody
3921
3977
  // - tool_is_sensitive: Bool
3922
3978
  // - tool_category: String
3923
3979
  //
@@ -3929,33 +3985,69 @@ const GUARDRAILS_AGENT_IDENTITY_DUAL_ATTRIBUTION_CEDAR = `// ===================
3929
3985
  // =============================================================================
3930
3986
 
3931
3987
  // ---------------------------------------------------------------------------
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.
3988
+ // Section 1: No-human privileged action requires approval
3989
+ // ENFORCE mode only: in monitor mode this permit allows the call with no
3990
+ // approval (see the header).
3991
+ // Matches when attribution is ABSENT (an absent field cannot be compared,
3992
+ // only tested for), and also for autonomous agents that are not first_party
3993
+ // even when a human is attached: nobody watches an autonomous agent in real
3994
+ // time, and a legacy api_key token names the key's creator as act_sub
3995
+ // whether or not that person is directing the call.
3996
+ //
3997
+ // Cedar is deny-overrides and Shield issues step-up only when the decision
3998
+ // is Allow, so this permit must not coexist with a forbid on the same calls:
3999
+ // the forbid would deny every one of them and no approval would be asked.
4000
+ // To hard-block instead of gating on approval, replace this rule with:
4001
+ //
4002
+ // @id("agent-identity.require-principal-for-sensitive-tools")
4003
+ // @name("Block sensitive agent tools without a principal")
4004
+ // @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.")
4005
+ // @severity("critical")
4006
+ // @tags("category:agent-identity,detection:rule,scope:per-agent,surface:call-tool,owasp:asi01,posture:deny-default")
4007
+ // @reject_message("Tool execution blocked: this agent invoked a sensitive tool with no accountable human attached.")
4008
+ // forbid (
4009
+ // principal,
4010
+ // action == Guardrails::Action::"call_tool",
4011
+ // resource
4012
+ // )
4013
+ // when {
4014
+ // context has agent_id && context.agent_id != "" &&
4015
+ // context has tool_is_sensitive && context.tool_is_sensitive == true
4016
+ // }
4017
+ // unless {
4018
+ // context has principal && context.principal has act_sub
4019
+ // };
3935
4020
  // ---------------------------------------------------------------------------
3936
4021
 
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.")
4022
+ @id("agent-identity.step-up-sensitive-tools-without-principal")
4023
+ @name("Permit no-human sensitive agent tools with step-up approval")
4024
+ @description("Permits call_tool on a sensitive tool by an agent with no acting human (no act_sub), or an autonomous agent that is not first_party, but suspends it until an approver with the configured AuthN role approves, unless an enforcing allow grant explicitly covers the call.")
3940
4025
  @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 (
4026
+ @tags("category:agent-identity,detection:rule,scope:per-agent,surface:call-tool,posture:deny-default,owasp:asi01,step-up")
4027
+ @step_up_required("role=security_oncall,timeout_seconds=3600")
4028
+ @step_up_unless_granted
4029
+ permit (
3944
4030
  principal,
3945
4031
  action == Guardrails::Action::"call_tool",
3946
4032
  resource
3947
4033
  )
3948
4034
  when {
3949
4035
  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
4036
+ context has tool_is_sensitive && context.tool_is_sensitive == true &&
4037
+ (
4038
+ !(context has principal && context.principal has act_sub) ||
4039
+ (
4040
+ context has agent_type && context.agent_type == "autonomous" &&
4041
+ !(context has agent_trust_level && context.agent_trust_level == "first_party")
4042
+ )
4043
+ )
3954
4044
  };
3955
4045
 
3956
4046
  // ---------------------------------------------------------------------------
3957
4047
  // Section 2: Trust level gates the blast radius
3958
4048
  // Start in monitor: a service key is unverified until the agent is adopted.
4049
+ // Unverified agents are blocked outright, approval or not: registration and
4050
+ // adoption come before any per-call authorization.
3959
4051
  // ---------------------------------------------------------------------------
3960
4052
 
3961
4053
  @id("agent-identity.block-unverified-agent-sensitive-tools")
@@ -3978,29 +4070,32 @@ when {
3978
4070
  };
3979
4071
 
3980
4072
  // ---------------------------------------------------------------------------
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".
4073
+ // Section 3 (folded into Section 1): autonomous agents need first-party trust
4074
+ // This template used to forbid sensitive tool calls from autonomous agents
4075
+ // that are not first-party. A forbid would deny the very calls Section 1
4076
+ // routes to approval, so the condition now lives in Section 1 and those
4077
+ // calls require approval instead of being blocked. To hard-block them anyway
4078
+ // (overriding approvals and grants), add:
4079
+ //
4080
+ // @id("agent-identity.restrict-autonomous-agent-sensitive-tools")
4081
+ // @name("Block non-first-party autonomous agent tool use")
4082
+ // @description("Blocks call_tool on a sensitive tool when agent_type is autonomous unless agent_trust_level is first_party.")
4083
+ // @severity("high")
4084
+ // @tags("category:agent-identity,threat:escalation,detection:rule,surface:call-tool,scope:per-agent,owasp:asi04")
4085
+ // @reject_message("Tool execution blocked: autonomous agents must be first-party to call sensitive tools.")
4086
+ // forbid (
4087
+ // principal,
4088
+ // action == Guardrails::Action::"call_tool",
4089
+ // resource
4090
+ // )
4091
+ // when {
4092
+ // context has agent_type && context.agent_type == "autonomous" &&
4093
+ // context has tool_is_sensitive && context.tool_is_sensitive == true
4094
+ // }
4095
+ // unless {
4096
+ // context has agent_trust_level && context.agent_trust_level == "first_party"
4097
+ // };
3984
4098
  // ---------------------------------------------------------------------------
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
4099
  `;
4005
4100
  const GUARDRAILS_AGENT_IDENTITY_BLOCK_UNADOPTED_AGENT_SENSITIVE_TOOLS_CEDAR = `// =============================================================================
4006
4101
  // Unadopted Agents: No Sensitive Tool Calls
@@ -4518,11 +4613,11 @@ export const GUARDRAILS_TEMPLATES = [
4518
4613
  {
4519
4614
  id: 'agent-identity.dual-attribution',
4520
4615
  name: 'Dual Attribution',
4521
- 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.',
4616
+ description: 'Require human approval or an explicit allow grant for sensitive agent tool calls that no human answers for (no acting human, e.g. autonomous or workload agents); calls with an acting human pass. Customize the approver role. Deploy in enforce mode: the approval gate does not exist in monitor mode. Adopt agents first (a service key is unverified until then), or trial the unverified-agent block as its own monitor-mode policy.',
4522
4617
  category: 'agent-identity',
4523
4618
  cedarText: GUARDRAILS_AGENT_IDENTITY_DUAL_ATTRIBUTION_CEDAR,
4524
4619
  severity: 'critical',
4525
- tags: ['category:agent-identity', 'detection:rule', 'surface:call-tool', 'scope:per-agent', 'posture:deny-default', 'owasp:asi01'],
4620
+ tags: ['category:agent-identity', 'detection:rule', 'surface:call-tool', 'scope:per-agent', 'posture:deny-default', 'owasp:asi01', 'step-up'],
4526
4621
  },
4527
4622
  {
4528
4623
  id: 'agent-identity.block-unadopted-agent-sensitive-tools',
@@ -5186,7 +5281,7 @@ export const GUARDRAILS_TEMPLATES_JSON = `{
5186
5281
  {
5187
5282
  "id": "agent-identity.dual-attribution",
5188
5283
  "name": "Dual Attribution",
5189
- "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.",
5284
+ "description": "Require human approval or an explicit allow grant for sensitive agent tool calls that no human answers for (no acting human, e.g. autonomous or workload agents); calls with an acting human pass. Customize the approver role. Deploy in enforce mode: the approval gate does not exist in monitor mode. Adopt agents first (a service key is unverified until then), or trial the unverified-agent block as its own monitor-mode policy.",
5190
5285
  "category": "agent-identity",
5191
5286
  "file": "dual_attribution.cedar",
5192
5287
  "severity": "critical",
@@ -5196,7 +5291,8 @@ export const GUARDRAILS_TEMPLATES_JSON = `{
5196
5291
  "surface:call-tool",
5197
5292
  "scope:per-agent",
5198
5293
  "posture:deny-default",
5199
- "owasp:asi01"
5294
+ "owasp:asi01",
5295
+ "step-up"
5200
5296
  ]
5201
5297
  },
5202
5298
  {
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './category-layers.gen.js';
6
6
  export * from './decision-effects.gen.js';
7
7
  export * from './aarm-annotations.gen.js';
8
8
  export * from './aarm-annotation.js';
9
+ export * from './step-up-role-conflict.js';
9
10
  export * from './privilege-catalog.gen.js';
10
11
  export * from './engine.js';
11
12
  export * from './builder.js';
package/dist/index.js CHANGED
@@ -17,6 +17,9 @@ export * from './aarm-annotations.gen.js';
17
17
  // AARM annotation parser/validator (typed parse + fail-closed validation,
18
18
  // parity with Go's aarm_annotation.go).
19
19
  export * from './aarm-annotation.js';
20
+ // Policy-set lint: warns when step-up policies naming different approver
21
+ // roles can match the same call (Shield denies with step_up_internal_error).
22
+ export * from './step-up-role-conflict.js';
20
23
  // Role -> capability privilege catalog (privilege_scope minted into
21
24
  // the trusted-service JWT; checked by role-conditioned Cedar policies).
22
25
  export * from './privilege-catalog.gen.js';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Authoring-time lint for @step_up_required role conflicts across a policy set.
3
+ *
4
+ * Shield picks one approver role per call. When two enforce-mode step-up
5
+ * policies of the same effect match one call and name different roles, no
6
+ * single approval satisfies both, so Shield denies the call with
7
+ * step_up_internal_error (highflame-shield internal/cedar/annotations.go,
8
+ * resolveStepUpRole). This lint warns about such pairs before they ship.
9
+ *
10
+ * It is a WARNING, not an error: the policy set is valid Cedar and the
11
+ * conflict only bites on calls both policies match.
12
+ *
13
+ * Parity: packages/go/step_up_role_conflict.go.
14
+ */
15
+ import type { PolicyRule } from './builder.js';
16
+ /** Stable code carried by every StepUpRoleConflictWarning. */
17
+ export declare const STEP_UP_ROLE_CONFLICT_CODE = "step_up_role_conflict";
18
+ /** Owner-approval role. Any named role outranks it, so it never conflicts. */
19
+ export declare const STEP_UP_ROLE_SELF = "self";
20
+ /** Two step-up policies whose roles differ and whose scopes could match the same call. */
21
+ export interface StepUpRoleConflictWarning {
22
+ code: typeof STEP_UP_ROLE_CONFLICT_CODE;
23
+ message: string;
24
+ ruleIdA: string;
25
+ roleA: string;
26
+ ruleIdB: string;
27
+ roleB: string;
28
+ }
29
+ /**
30
+ * Returns one warning per pair of enabled rules that Shield would treat as a
31
+ * role conflict if both matched one call.
32
+ *
33
+ * The pair rule mirrors Shield's resolveStepUpRole:
34
+ * - both rules carry a valid @step_up_required;
35
+ * - both have the same effect (a forbid's step-up outranks a permit's, so a
36
+ * forbid/permit pair never conflicts);
37
+ * - neither role is "self" (a named role outranks owner approval);
38
+ * - the roles differ (exact, case-sensitive comparison).
39
+ *
40
+ * Overlap heuristic (conservative; exact Cedar satisfiability is out of
41
+ * scope): the pair warns unless the scope heads clearly cannot match the same
42
+ * request. Heads are disjoint when the literal action sets do not intersect
43
+ * (no "*"), or when principal or resource are both exact/`is` constraints on
44
+ * different entity types, or on the same type with different entity IDs.
45
+ * `in` (hierarchy) constraints and when/unless conditions are assumed to
46
+ * overlap. Per-policy mode is not visible in Cedar text, so observe-only
47
+ * (monitor/alert) policies, which Shield excludes, can still warn here.
48
+ */
49
+ export declare function lintStepUpRoleConflicts(rules: readonly PolicyRule[]): StepUpRoleConflictWarning[];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Authoring-time lint for @step_up_required role conflicts across a policy set.
3
+ *
4
+ * Shield picks one approver role per call. When two enforce-mode step-up
5
+ * policies of the same effect match one call and name different roles, no
6
+ * single approval satisfies both, so Shield denies the call with
7
+ * step_up_internal_error (highflame-shield internal/cedar/annotations.go,
8
+ * resolveStepUpRole). This lint warns about such pairs before they ship.
9
+ *
10
+ * It is a WARNING, not an error: the policy set is valid Cedar and the
11
+ * conflict only bites on calls both policies match.
12
+ *
13
+ * Parity: packages/go/step_up_role_conflict.go.
14
+ */
15
+ import { parseAARMAnnotations } from './aarm-annotation.js';
16
+ /** Stable code carried by every StepUpRoleConflictWarning. */
17
+ export const STEP_UP_ROLE_CONFLICT_CODE = 'step_up_role_conflict';
18
+ /** Owner-approval role. Any named role outranks it, so it never conflicts. */
19
+ export const STEP_UP_ROLE_SELF = 'self';
20
+ /**
21
+ * Returns one warning per pair of enabled rules that Shield would treat as a
22
+ * role conflict if both matched one call.
23
+ *
24
+ * The pair rule mirrors Shield's resolveStepUpRole:
25
+ * - both rules carry a valid @step_up_required;
26
+ * - both have the same effect (a forbid's step-up outranks a permit's, so a
27
+ * forbid/permit pair never conflicts);
28
+ * - neither role is "self" (a named role outranks owner approval);
29
+ * - the roles differ (exact, case-sensitive comparison).
30
+ *
31
+ * Overlap heuristic (conservative; exact Cedar satisfiability is out of
32
+ * scope): the pair warns unless the scope heads clearly cannot match the same
33
+ * request. Heads are disjoint when the literal action sets do not intersect
34
+ * (no "*"), or when principal or resource are both exact/`is` constraints on
35
+ * different entity types, or on the same type with different entity IDs.
36
+ * `in` (hierarchy) constraints and when/unless conditions are assumed to
37
+ * overlap. Per-policy mode is not visible in Cedar text, so observe-only
38
+ * (monitor/alert) policies, which Shield excludes, can still warn here.
39
+ */
40
+ export function lintStepUpRoleConflicts(rules) {
41
+ const candidates = [];
42
+ for (const rule of rules) {
43
+ if (!rule.enabled || !rule.customAnnotations)
44
+ continue;
45
+ const role = parseAARMAnnotations(rule.customAnnotations).directives.stepUpRequired?.role;
46
+ if (!role || role === STEP_UP_ROLE_SELF)
47
+ continue;
48
+ candidates.push({ rule, role });
49
+ }
50
+ const warnings = [];
51
+ for (let i = 0; i < candidates.length; i++) {
52
+ for (let j = i + 1; j < candidates.length; j++) {
53
+ const a = candidates[i];
54
+ const b = candidates[j];
55
+ if (a.role === b.role || a.rule.effect !== b.rule.effect)
56
+ continue;
57
+ if (!actionsCanOverlap(a.rule.action, b.rule.action) ||
58
+ !entitiesCanOverlap(a.rule.principal, b.rule.principal) ||
59
+ !entitiesCanOverlap(a.rule.resource, b.rule.resource)) {
60
+ continue;
61
+ }
62
+ const idA = ruleLabel(a.rule);
63
+ const idB = ruleLabel(b.rule);
64
+ warnings.push({
65
+ code: STEP_UP_ROLE_CONFLICT_CODE,
66
+ message: `Rules "${idA}" (@step_up_required("${a.role}")) and "${idB}" (@step_up_required("${b.role}")) ` +
67
+ 'can match the same call but require different approver roles; Shield allows one role per call ' +
68
+ 'and denies such calls with step_up_internal_error',
69
+ ruleIdA: idA,
70
+ roleA: a.role,
71
+ ruleIdB: idB,
72
+ roleB: b.role,
73
+ });
74
+ }
75
+ }
76
+ return warnings;
77
+ }
78
+ function ruleLabel(rule) {
79
+ return rule.annotations?.id || rule.annotations?.name || '';
80
+ }
81
+ /** Literal action set, or null for a wildcard (or unrecognized shape). */
82
+ function actionSet(action) {
83
+ const list = typeof action === 'string' ? [action] : Array.isArray(action) ? action : null;
84
+ if (!list || list.length === 0)
85
+ return null;
86
+ const set = new Set();
87
+ for (const a of list) {
88
+ if (typeof a !== 'string' || a === '*' || a === '')
89
+ return null;
90
+ set.add(a);
91
+ }
92
+ return set;
93
+ }
94
+ function actionsCanOverlap(a, b) {
95
+ const sa = actionSet(a);
96
+ const sb = actionSet(b);
97
+ if (!sa || !sb)
98
+ return true;
99
+ for (const x of sa)
100
+ if (sb.has(x))
101
+ return true;
102
+ return false;
103
+ }
104
+ /**
105
+ * Whether two principal/resource constraints could both match one entity.
106
+ * null means any entity. An `in` constraint names an ancestor, whose type says
107
+ * nothing about the matched entity's type, so it is assumed to overlap.
108
+ */
109
+ function entitiesCanOverlap(a, b) {
110
+ if (!a || !b || a.operator === 'in' || b.operator === 'in')
111
+ return true;
112
+ if (a.type !== b.type)
113
+ return false;
114
+ if (a.id && b.id && a.id !== b.id)
115
+ return false;
116
+ return true;
117
+ }
package/dist/types.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './category-layers.gen.js';
6
6
  export * from './decision-effects.gen.js';
7
7
  export * from './aarm-annotations.gen.js';
8
8
  export * from './aarm-annotation.js';
9
+ export * from './step-up-role-conflict.js';
9
10
  export * from './privilege-catalog.gen.js';
10
11
  export * from './builder.js';
11
12
  export * from './errors.js';
package/dist/types.js CHANGED
@@ -19,6 +19,9 @@ export * from './aarm-annotations.gen.js';
19
19
  // AARM annotation parser/validator (browser-safe — typed parse + fail-closed
20
20
  // validation; Studio lints with the exact rules Shield runs at sync time).
21
21
  export * from './aarm-annotation.js';
22
+ // Policy-set lint: warns when step-up policies naming different approver
23
+ // roles can match the same call (Shield denies with step_up_internal_error).
24
+ export * from './step-up-role-conflict.js';
22
25
  // Role -> capability privilege catalog (browser-safe — Studio surfaces
23
26
  // the default scope per role; checked by role-conditioned Cedar policies).
24
27
  export * from './privilege-catalog.gen.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@highflame/policy",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "engines": {
5
5
  "node": ">=18"
6
6
  },