Skip to content

Commit b954f51

Browse files
feat: add key/value allowlisting to OperationContext (#181)
## Summary - Adds key/value allowlisting to `OperationContext.__post_init__` so that only known keys (`app`, `skill`, `agent`) and their respective allowlisted values are accepted - Prevents PII from entering the User-Agent header even from standalone callers outside the plugin (e.g., `--context "ssn=123-45-6789"` is now rejected at the SDK layer) ## What's validated | Key | Validation | Example valid | Example rejected | |---|---|---|---| | `app` | Must match `<name>/<version>` format | `dataverse-skills/1.5.0` | `noslash` | | `skill` | Must be one of 7 known skills | `dv-data` | `not-a-skill` | | `agent` | Must be one of 5 known agents | `claude-code` | `not-an-agent` | | Unknown keys | Rejected outright | — | `ssn=123-45-6789` | ## Changes | File | Change | |---|---| | `src/.../core/config.py` | Added `_ALLOWED_KEYS`, `_ALLOWED_SKILLS`, `_ALLOWED_AGENTS`, `_APP_PATTERN`; key/value validation loop in `__post_init__` | | `tests/unit/test_operation_context.py` | 6 new tests: unknown key, unknown skill, unknown agent, PII in valid format, invalid app format | ## Test plan - [x] `pytest tests/unit/test_operation_context.py -v` — 25 passed - [x] `pytest tests/unit/ -v` — 1394 passed, 0 regressions - [x] `black src tests --check` — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 91ada39 commit b954f51

2 files changed

Lines changed: 65 additions & 4 deletions

File tree

src/PowerPlatform/Dataverse/core/config.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,49 @@
2323
# Values: alphanumeric, hyphens, underscores, dots, slashes.
2424
_CONTEXT_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$")
2525

26+
# Allowed keys and their value patterns for PII prevention.
27+
# Only these keys are accepted; unknown keys are rejected.
28+
_ALLOWED_KEYS = frozenset({"app", "skill", "agent"})
29+
_ALLOWED_SKILLS = frozenset(
30+
{
31+
"dv-connect",
32+
"dv-data",
33+
"dv-query",
34+
"dv-metadata",
35+
"dv-solution",
36+
"dv-admin",
37+
"dv-security",
38+
"unknown",
39+
}
40+
)
41+
_ALLOWED_AGENTS = frozenset(
42+
{
43+
"claude-code",
44+
"copilot",
45+
"cursor",
46+
"codex",
47+
"unknown",
48+
}
49+
)
50+
# app values: must start with a known prefix followed by /<semver-like>
51+
_APP_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_./-]+$")
52+
2653

2754
@dataclass(frozen=True)
2855
class OperationContext:
2956
"""Caller-defined context appended to outbound ``User-Agent`` headers.
3057
3158
The context string is validated to be semicolon-separated ``key=value`` pairs
32-
(e.g. ``"app=myapp/1.0;agent=claude-code"``). Free-form text, email
33-
addresses, and other potentially sensitive strings are rejected.
59+
using only allowed keys (``app``, ``skill``, ``agent``) with values from
60+
closed allowlists. Free-form text, email addresses, PII, and unknown keys
61+
are rejected.
3462
3563
:param user_agent_context: Attribution string in ``key=value;key=value`` format.
3664
:type user_agent_context: :class:`str`
3765
38-
:raises ValueError: If the string is empty, contains control characters, or
39-
does not match the required ``key=value`` format.
66+
:raises ValueError: If the string is empty, contains control characters,
67+
does not match the required ``key=value`` format, or uses unknown
68+
keys/values.
4069
"""
4170

4271
user_agent_context: str
@@ -54,6 +83,17 @@ def __post_init__(self) -> None:
5483
"Keys and values may contain alphanumerics, hyphens, underscores, "
5584
"dots, and slashes."
5685
)
86+
# Key/value allowlist validation
87+
for pair in val.split(";"):
88+
key, _, value = pair.partition("=")
89+
if key not in _ALLOWED_KEYS:
90+
raise ValueError(f"Unknown operation_context key '{key}'. " f"Allowed keys: {sorted(_ALLOWED_KEYS)}")
91+
if key == "skill" and value not in _ALLOWED_SKILLS:
92+
raise ValueError(f"Unknown skill '{value}'. Allowed: {sorted(_ALLOWED_SKILLS)}")
93+
if key == "agent" and value not in _ALLOWED_AGENTS:
94+
raise ValueError(f"Unknown agent '{value}'. Allowed: {sorted(_ALLOWED_AGENTS)}")
95+
if key == "app" and not _APP_PATTERN.match(value):
96+
raise ValueError(f"Invalid app value '{value}'. Expected format: '<name>/<version>'.")
5797

5898

5999
@dataclass(frozen=True)

tests/unit/test_operation_context.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,27 @@ def test_reject_no_equals(self):
5353
with self.assertRaises(ValueError):
5454
OperationContext(user_agent_context="justaplainstring")
5555

56+
def test_reject_unknown_key(self):
57+
with self.assertRaises(ValueError):
58+
OperationContext(user_agent_context="ssn=123-45-6789")
59+
60+
def test_reject_unknown_skill(self):
61+
with self.assertRaises(ValueError):
62+
OperationContext(user_agent_context="app=test/1.0;skill=not-a-real-skill;agent=claude-code")
63+
64+
def test_reject_unknown_agent(self):
65+
with self.assertRaises(ValueError):
66+
OperationContext(user_agent_context="app=test/1.0;skill=dv-data;agent=not-a-real-agent")
67+
68+
def test_reject_pii_in_valid_key_format(self):
69+
"""Even structurally valid key=value should fail if key is not in allowlist."""
70+
with self.assertRaises(ValueError):
71+
OperationContext(user_agent_context="name=john;password=secret123")
72+
73+
def test_reject_invalid_app_format(self):
74+
with self.assertRaises(ValueError):
75+
OperationContext(user_agent_context="app=noslash")
76+
5677

5778
class TestOperationContextConfig(unittest.TestCase):
5879
"""Tests for operation_context on DataverseConfig."""

0 commit comments

Comments
 (0)