@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.
- package/_schemas/guardrails/templates/dual_attribution.cedar +143 -48
- package/_schemas/guardrails/templates/templates.json +3 -2
- package/dist/aarm-annotation.d.ts +10 -0
- package/dist/aarm-annotation.js +26 -1
- package/dist/aarm-annotations.gen.js +22 -1
- package/dist/guardrails-defaults.gen.js +149 -53
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/step-up-role-conflict.d.ts +49 -0
- package/dist/step-up-role-conflict.js +117 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.js +3 -0
- package/package.json +1 -1
|
@@ -1,22 +1,77 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
2
|
// Dual Attribution
|
|
3
3
|
// =============================================================================
|
|
4
|
-
//
|
|
5
|
-
// agent did this?" is half an answer; the other half
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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`
|
|
13
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
|
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
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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.
|
|
45
|
-
@name("
|
|
46
|
-
@description("
|
|
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,
|
|
49
|
-
@
|
|
50
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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:
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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": "
|
|
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. */
|
package/dist/aarm-annotation.js
CHANGED
|
@@ -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
|
-
//
|
|
3898
|
-
// agent did this?" is half an answer; the other half
|
|
3899
|
-
//
|
|
3900
|
-
//
|
|
3901
|
-
//
|
|
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\`
|
|
3906
|
-
//
|
|
3907
|
-
//
|
|
3908
|
-
//
|
|
3909
|
-
//
|
|
3910
|
-
//
|
|
3911
|
-
//
|
|
3912
|
-
//
|
|
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
|
|
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
|
|
3933
|
-
//
|
|
3934
|
-
//
|
|
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.
|
|
3938
|
-
@name("
|
|
3939
|
-
@description("
|
|
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,
|
|
3942
|
-
@
|
|
3943
|
-
|
|
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
|
-
|
|
3953
|
-
|
|
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:
|
|
3982
|
-
//
|
|
3983
|
-
//
|
|
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: '
|
|
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": "
|
|
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';
|