From 028aa0ffff2b8ce23609f7b79815538a3293e5ed Mon Sep 17 00:00:00 2001 From: shivamarora <122318437+arorashivam96@users.noreply.github.com> Date: Wed, 13 May 2026 13:06:50 -0700 Subject: [PATCH] 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 / format Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PowerPlatform/Dataverse/core/config.py | 47 ++++++++++++++++++++-- tests/unit/test_operation_context.py | 21 ++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/PowerPlatform/Dataverse/core/config.py b/src/PowerPlatform/Dataverse/core/config.py index ed161048..ccde08f1 100644 --- a/src/PowerPlatform/Dataverse/core/config.py +++ b/src/PowerPlatform/Dataverse/core/config.py @@ -23,20 +23,48 @@ # Values: alphanumeric, hyphens, underscores, dots, slashes. _CONTEXT_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$") +# Allowed keys and their value patterns for PII prevention. +# Only these keys are accepted; unknown keys are rejected. +_ALLOWED_KEYS = frozenset({"app", "skill", "agent"}) +_ALLOWED_SKILLS = frozenset( + { + "dv-connect", + "dv-data", + "dv-query", + "dv-metadata", + "dv-solution", + "dv-admin", + "dv-security", + } +) +_ALLOWED_AGENTS = frozenset( + { + "claude-code", + "copilot", + "cursor", + "codex", + "unknown", + } +) +# app values: must start with a known prefix followed by / +_APP_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_./-]+$") + @dataclass(frozen=True) class OperationContext: """Caller-defined context appended to outbound ``User-Agent`` headers. The context string is validated to be semicolon-separated ``key=value`` pairs - (e.g. ``"app=myapp/1.0;agent=claude-code"``). Free-form text, email - addresses, and other potentially sensitive strings are rejected. + using only allowed keys (``app``, ``skill``, ``agent``) with values from + closed allowlists. Free-form text, email addresses, PII, and unknown keys + are rejected. :param user_agent_context: Attribution string in ``key=value;key=value`` format. :type user_agent_context: :class:`str` - :raises ValueError: If the string is empty, contains control characters, or - does not match the required ``key=value`` format. + :raises ValueError: If the string is empty, contains control characters, + does not match the required ``key=value`` format, or uses unknown + keys/values. """ user_agent_context: str @@ -54,6 +82,17 @@ def __post_init__(self) -> None: "Keys and values may contain alphanumerics, hyphens, underscores, " "dots, and slashes." ) + # Key/value allowlist validation + for pair in val.split(";"): + key, _, value = pair.partition("=") + if key not in _ALLOWED_KEYS: + raise ValueError(f"Unknown operation_context key '{key}'. " f"Allowed keys: {sorted(_ALLOWED_KEYS)}") + if key == "skill" and value not in _ALLOWED_SKILLS: + raise ValueError(f"Unknown skill '{value}'. Allowed: {sorted(_ALLOWED_SKILLS)}") + if key == "agent" and value not in _ALLOWED_AGENTS: + raise ValueError(f"Unknown agent '{value}'. Allowed: {sorted(_ALLOWED_AGENTS)}") + if key == "app" and not _APP_PATTERN.match(value): + raise ValueError(f"Invalid app value '{value}'. Expected format: '/'.") @dataclass(frozen=True) diff --git a/tests/unit/test_operation_context.py b/tests/unit/test_operation_context.py index 26db0a36..8952d1a4 100644 --- a/tests/unit/test_operation_context.py +++ b/tests/unit/test_operation_context.py @@ -53,6 +53,27 @@ def test_reject_no_equals(self): with self.assertRaises(ValueError): OperationContext(user_agent_context="justaplainstring") + def test_reject_unknown_key(self): + with self.assertRaises(ValueError): + OperationContext(user_agent_context="ssn=123-45-6789") + + def test_reject_unknown_skill(self): + with self.assertRaises(ValueError): + OperationContext(user_agent_context="app=test/1.0;skill=not-a-real-skill;agent=claude-code") + + def test_reject_unknown_agent(self): + with self.assertRaises(ValueError): + OperationContext(user_agent_context="app=test/1.0;skill=dv-data;agent=not-a-real-agent") + + def test_reject_pii_in_valid_key_format(self): + """Even structurally valid key=value should fail if key is not in allowlist.""" + with self.assertRaises(ValueError): + OperationContext(user_agent_context="name=john;password=secret123") + + def test_reject_invalid_app_format(self): + with self.assertRaises(ValueError): + OperationContext(user_agent_context="app=noslash") + class TestOperationContextConfig(unittest.TestCase): """Tests for operation_context on DataverseConfig."""