Skip to content

Commit 028aa0f

Browse files
arorashivam96claude
andcommitted
feat: add key/value allowlisting to OperationContext
Validate that operation_context keys are from {app, skill, agent} and values match their respective allowlists. Unknown keys like 'ssn' or 'name' are rejected with ValueError. This prevents PII from entering the User-Agent header even from standalone callers outside the plugin. - skill values must be in the 7 known Dataverse skills - agent values must be in {claude-code, copilot, cursor, codex, unknown} - app values must match <name>/<version> format Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5eae887 commit 028aa0f

2 files changed

Lines changed: 64 additions & 4 deletions

File tree

src/PowerPlatform/Dataverse/core/config.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,48 @@
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+
}
39+
)
40+
_ALLOWED_AGENTS = frozenset(
41+
{
42+
"claude-code",
43+
"copilot",
44+
"cursor",
45+
"codex",
46+
"unknown",
47+
}
48+
)
49+
# app values: must start with a known prefix followed by /<semver-like>
50+
_APP_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_./-]+$")
51+
2652

2753
@dataclass(frozen=True)
2854
class OperationContext:
2955
"""Caller-defined context appended to outbound ``User-Agent`` headers.
3056
3157
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.
58+
using only allowed keys (``app``, ``skill``, ``agent``) with values from
59+
closed allowlists. Free-form text, email addresses, PII, and unknown keys
60+
are rejected.
3461
3562
:param user_agent_context: Attribution string in ``key=value;key=value`` format.
3663
:type user_agent_context: :class:`str`
3764
38-
:raises ValueError: If the string is empty, contains control characters, or
39-
does not match the required ``key=value`` format.
65+
:raises ValueError: If the string is empty, contains control characters,
66+
does not match the required ``key=value`` format, or uses unknown
67+
keys/values.
4068
"""
4169

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

5897

5998
@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)