Skip to content

Commit 2aade12

Browse files
committed
add allow and deny mode sequence enforcement
1 parent 82d331c commit 2aade12

28 files changed

Lines changed: 528 additions & 340 deletions

.github/workflows/ci.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ jobs:
1616

1717
steps:
1818
- name: Checkout
19-
uses: actions/checkout@v4
19+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
2020

2121
- name: Setup Python
22-
uses: actions/setup-python@v5
22+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
2323
with:
2424
python-version: ${{ matrix.python-version }}
2525
cache: 'pip'
@@ -45,10 +45,10 @@ jobs:
4545
continue-on-error: true
4646
steps:
4747
- name: Checkout
48-
uses: actions/checkout@v4
48+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
4949

5050
- name: Setup Python
51-
uses: actions/setup-python@v5
51+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
5252
with:
5353
python-version: '3.11'
5454
cache: 'pip'
@@ -72,10 +72,10 @@ jobs:
7272
needs: [ test ]
7373
steps:
7474
- name: Checkout
75-
uses: actions/checkout@v4
75+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
7676

7777
- name: Setup Python
78-
uses: actions/setup-python@v5
78+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
7979
with:
8080
python-version: '3.11'
8181
cache: 'pip'
@@ -95,7 +95,7 @@ jobs:
9595
python -m build
9696
9797
- name: Upload dist artifacts
98-
uses: actions/upload-artifact@v4
98+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
9999
with:
100100
name: dist
101-
path: dist/*
101+
path: dist/*

.github/workflows/release.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ jobs:
1414

1515
steps:
1616
- name: Checkout
17-
uses: actions/checkout@v4
17+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1818

1919
- name: Setup Python
20-
uses: actions/setup-python@v5
20+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
2121
with:
2222
python-version: '3.11'
2323
cache: 'pip'
@@ -42,8 +42,8 @@ jobs:
4242
4343
- name: Publish to PyPI
4444
if: startsWith(github.ref, 'refs/tags/v')
45-
uses: pypa/gh-action-pypi-publish@release/v1
46-
with:
47-
user: __token__
48-
password: ${{ secrets.PYPI_API_TOKEN }}
49-
skip-existing: true
45+
env:
46+
TWINE_USERNAME: __token__
47+
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
48+
run: |
49+
twine upload --skip-existing dist/*

d2/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
"""
66
Artoo SDK: A client-side library for enforcing RBAC on LLM tools.
77
8-
Public API is stable as of 1.2.0. Backward-incompatible changes will only occur
8+
Public API is stable as of 1.2.1. Backward-incompatible changes will only occur
99
in a new major version. Deprecated symbols (if any) will be retained for at least
1010
one minor version and warned via release notes before removal.
1111
"""
1212

13-
__version__ = "1.2.0"
13+
__version__ = "1.2.1"
1414

1515
# Export the simple setter alongside the context-manager variant
1616
from .context import (

d2/decorator.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,12 @@ async def check_and_run():
239239
user_context = get_user_context()
240240

241241
if not is_allowed:
242+
user_roles = list(user_context.roles) if user_context and user_context.roles else []
242243
error = PermissionDeniedError(
243244
tool_id=effective_tool_id,
244245
user_id=user_context.user_id if user_context else "unknown",
245246
roles=user_context.roles if user_context else [],
247+
reason=f"rbac_denied: role_missing (present roles: {user_roles})",
246248
)
247249
# Record a denied tool invocation outcome for observability
248250
tool_invocation_total.add(1, {"tool_id": effective_tool_id, "status": "denied"})
@@ -264,7 +266,7 @@ async def check_and_run():
264266
return await _handle_permission_denied(validation_error)
265267

266268
# Layer 3: Sequence enforcement
267-
sequence_rules = await manager.get_sequence_rules()
269+
sequence_mode, sequence_rules = await manager.get_sequence_rules()
268270
if sequence_rules:
269271
sequence_start = time.perf_counter()
270272
from .runtime.sequence import SequenceValidator
@@ -275,7 +277,8 @@ async def check_and_run():
275277
sequence_error = validator.validate_sequence(
276278
current_history=user_context.call_history if user_context else (),
277279
next_tool_id=effective_tool_id,
278-
sequence_rules=sequence_rules
280+
sequence_rules=sequence_rules,
281+
mode=sequence_mode
279282
)
280283
sequence_duration_ms = (time.perf_counter() - sequence_start) * 1000.0
281284
guardrail_latency_ms.record(sequence_duration_ms, {
@@ -669,10 +672,12 @@ async def async_wrapper(*args, **kwargs):
669672
user_context = get_user_context()
670673

671674
if not is_allowed:
675+
user_roles = list(user_context.roles) if user_context and user_context.roles else []
672676
error = PermissionDeniedError(
673677
tool_id=effective_tool_id,
674678
user_id=user_context.user_id if user_context else "unknown",
675679
roles=user_context.roles if user_context else [],
680+
reason=f"rbac_denied: role_missing (present roles: {user_roles})",
676681
)
677682
# Record a denied tool invocation outcome for observability
678683
tool_invocation_total.add(1, {"tool_id": effective_tool_id, "status": "denied"})
@@ -693,7 +698,7 @@ async def async_wrapper(*args, **kwargs):
693698
return await _handle_permission_denied(validation_error)
694699

695700
# Layer 3: Sequence enforcement
696-
sequence_rules = await manager.get_sequence_rules()
701+
sequence_mode, sequence_rules = await manager.get_sequence_rules()
697702
if sequence_rules:
698703
sequence_start = time.perf_counter()
699704
from .runtime.sequence import SequenceValidator
@@ -704,7 +709,8 @@ async def async_wrapper(*args, **kwargs):
704709
sequence_error = validator.validate_sequence(
705710
current_history=user_context.call_history if user_context else (),
706711
next_tool_id=effective_tool_id,
707-
sequence_rules=sequence_rules
712+
sequence_rules=sequence_rules,
713+
mode=sequence_mode
708714
)
709715
sequence_duration_ms = (time.perf_counter() - sequence_start) * 1000.0
710716
guardrail_latency_ms.record(sequence_duration_ms, {

d2/policy/bundle.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ class PolicyBundle:
2929
etag: Optional[str] = None # For analytics and caching
3030
tool_to_roles: Dict[str, Set[str]] = field(default_factory=dict, repr=False)
3131
tool_conditions: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict, repr=False)
32-
role_to_sequences: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict, repr=False)
32+
# Map role -> SequenceRules object (holds rules list + mode)
33+
role_to_sequences: Dict[str, Any] = field(default_factory=dict, repr=False)
3334
# Data flow tracking: which labels each tool produces
3435
tool_labels: Dict[str, Set[str]] = field(default_factory=dict, repr=False)
3536
# Data flow tracking: which labels block which tools
@@ -119,14 +120,31 @@ def __post_init__(self):
119120
)
120121

121122
# Parse sequence rules (apply to this specific role)
122-
sequence_rules = policy.get("sequence", [])
123-
if sequence_rules:
124-
logger.debug("Processing %d sequence rules for role '%s'", len(sequence_rules), role)
123+
sequence_raw = policy.get("sequence", [])
124+
125+
# Handle both legacy list format and new nested format
126+
if isinstance(sequence_raw, dict):
127+
# New format: {"mode": "deny", "rules": [...]}
128+
mode = sequence_raw.get("mode")
129+
rules_list = sequence_raw.get("rules", [])
130+
else:
131+
# Legacy format: list of rules directly, default to allow mode
132+
mode = "allow"
133+
rules_list = sequence_raw if isinstance(sequence_raw, list) else []
134+
135+
# Store sequence rules if they exist OR if mode is "deny" (empty deny = block all)
136+
if rules_list or (mode == "deny"):
137+
logger.debug("Processing %d sequence rules for role '%s' (mode=%s)", len(rules_list), role, mode)
125138
# Validate @group references without expanding (lazy expansion at runtime)
126139
tool_groups = self._extract_tool_groups(policy_content)
127-
validated_rules = self._validate_sequence_rules(sequence_rules, tool_groups)
128-
self.role_to_sequences[role] = validated_rules
129-
logger.debug("Validated %d sequence rules for role '%s' (lazy @group expansion)", len(validated_rules), role)
140+
validated_rules = self._validate_sequence_rules(rules_list, tool_groups)
141+
142+
# Store as simple dict to avoid pickling issues with dataclasses
143+
self.role_to_sequences[role] = {
144+
"mode": mode,
145+
"rules": validated_rules
146+
}
147+
logger.debug("Validated %d sequence rules for role '%s'", len(validated_rules), role)
130148

131149
logger.debug("Final tool_to_roles mapping: %s", dict(self.tool_to_roles))
132150
logger.debug("Final role_to_sequences mapping: %s", dict(self.role_to_sequences))
@@ -140,16 +158,17 @@ def all_known_tools(self) -> Set[str]:
140158

141159
return set(self.tool_to_roles.keys())
142160

143-
def get_sequence_rules(self, role: str) -> List[Dict[str, Any]]:
144-
"""Get sequence rules for a specific role.
161+
def get_sequence_rules(self, role: str) -> Dict[str, Any]:
162+
"""Get sequence configuration for a specific role.
145163
146164
Args:
147165
role: Role name
148166
149167
Returns:
150-
List of sequence rules (may contain @group references for lazy expansion)
168+
Dict with 'mode' (str) and 'rules' (List[Dict]) keys.
169+
Returns None if no sequence rules defined for role.
151170
"""
152-
return self.role_to_sequences.get(role, [])
171+
return self.role_to_sequences.get(role)
153172

154173
def get_tool_groups(self) -> Dict[str, List[str]]:
155174
"""Get tool_groups from policy metadata for lazy sequence expansion.

d2/policy/manager.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -579,38 +579,52 @@ def _collect_conditions(tool_identifier: str):
579579

580580
return applicable
581581

582-
async def get_sequence_rules(self) -> List[Dict[str, Any]]:
583-
"""Retrieve sequence rules for the current user's roles.
582+
async def get_sequence_rules(self) -> tuple[Optional[str], List[Dict[str, Any]]]:
583+
"""Retrieve sequence rules and mode for the current user's roles.
584584
585585
Returns:
586-
List of sequence rule dictionaries (deny patterns with reasons)
586+
Tuple of (mode, rules_list)
587+
Mode is "deny" if any role uses deny mode, otherwise "allow" (or None if no rules).
587588
588589
Example:
589-
[
590-
{"deny": ["database.read", "web.request"], "reason": "Exfiltration"},
591-
{"deny": ["secrets.get", "web.request"], "reason": "Secret leak"}
592-
]
590+
("allow", [{"deny": ["database.read", "web.request"], "reason": "Exfiltration"}])
593591
"""
594592
await self._init_complete.wait()
595593
bundle = self._get_bundle()
596594
user_context = get_user_context()
597595

598596
if user_context is None or not user_context.roles:
599-
return []
597+
return None, []
600598

601599
user_roles = set(user_context.roles)
602600

603601
# Admin wildcard role bypasses sequence enforcement
604602
if "*" in user_roles:
605-
return []
603+
return None, []
606604

607-
# Collect all sequence rules for user's roles
605+
# Collect all sequence rules and determine effective mode
608606
all_rules: List[Dict[str, Any]] = []
607+
effective_mode = "allow"
608+
has_rules = False
609+
609610
for role in user_roles:
610-
role_rules = bundle.role_to_sequences.get(role, [])
611-
all_rules.extend(role_rules)
611+
sequence_data = bundle.get_sequence_rules(role)
612+
if sequence_data:
613+
has_rules = True
614+
role_mode = sequence_data.get("mode", "allow")
615+
role_rules = sequence_data.get("rules", [])
616+
617+
# If any role uses 'deny' mode (allowlist), the whole check
618+
# shifts to 'deny' mode for maximum security (least privilege).
619+
if role_mode.lower() == "deny":
620+
effective_mode = "deny"
621+
622+
all_rules.extend(role_rules)
623+
624+
if not has_rules:
625+
return None, []
612626

613-
return all_rules
627+
return effective_mode, all_rules
614628

615629
async def is_tool_in_policy_async(self, tool_id: str) -> bool:
616630
"""Async: Checks if a tool ID is defined in the policy at all."""

d2/runtime/sequence.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,27 @@ def validate_sequence(
127127
continue
128128

129129
# Check if the proposed sequence matches the pattern
130-
if self._matches_pattern(proposed_sequence, pattern, effective_mode):
130+
# Check for match
131+
match = False
132+
133+
if effective_mode == "deny" and is_allow:
134+
# In deny mode, we need strict control to prevent arbitrary extensions.
135+
# A sequence is allowed if:
136+
# 1. It is a valid prefix of an allowed pattern (progressing step-by-step)
137+
# e.g. [A] matches start of [A, B]
138+
if self._is_prefix_match(proposed_sequence, pattern):
139+
match = True
140+
# 2. It completes an allowed pattern (allowing gaps/subsequences)
141+
# But it MUST end with the pattern's last element to prevent extension
142+
# e.g. [A, ... B] is allowed, but [A, B, ... C] is not
143+
elif self._matches_pattern(proposed_sequence, pattern, effective_mode):
144+
if self._tool_matches_element(next_tool_id, pattern[-1]):
145+
match = True
146+
else:
147+
# In allow mode (or deny rules), use standard subsequence matching (supports gaps)
148+
match = self._matches_pattern(proposed_sequence, pattern, effective_mode)
149+
150+
if match:
131151
if is_allow:
132152
# Allow rule matches
133153
has_matching_allow = True
@@ -168,6 +188,19 @@ def validate_sequence(
168188
)
169189

170190
return None
191+
192+
def _is_prefix_match(self, sequence: list[str], pattern: list[str]) -> bool:
193+
"""Check if sequence is a valid prefix of the pattern (strict order, no gaps).
194+
195+
Used in deny mode to allow partial progress through an allowed sequence.
196+
"""
197+
if len(sequence) > len(pattern):
198+
return False
199+
200+
for i, tool in enumerate(sequence):
201+
if not self._tool_matches_element(tool, pattern[i]):
202+
return False
203+
return True
171204

172205
def _matches_pattern(
173206
self,

examples/data_flow_demo.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,4 @@ async def main():
381381
if __name__ == "__main__":
382382
asyncio.run(main())
383383

384+

examples/sequence_modes_demo.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,4 @@ async def main():
295295

296296

297297

298+

examples/sequence_modes_policy.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
metadata:
55
name: "sequence-modes-demo"
66
description: "Demonstrates allow mode (blocklist) and deny mode (allowlist) for sequences"
7-
expires: "2026-12-31T23:59:59+00:00"
7+
expires: "2026-12-26T23:59:59+00:00"
88

99
# Define tool groups for use in sequence rules
1010
tool_groups:
@@ -311,3 +311,4 @@ policies:
311311

312312

313313

314+

0 commit comments

Comments
 (0)