From fdc4e36d14473d497d3a67456416ed154012698b Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 18 Sep 2026 12:04:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(security):=20F14=20(Astra=20P1)=20=E2=80=94?= =?UTF-8?q?=20KI-Datenrichtlinie=20deckt=20JSON-Strings,=20Provider-Compli?= =?UTF-8?q?ance=20und=20Tool-Antworten=20ab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher (Astra): (1) enforce_data_policy filterte nur dict-Inhalte — ein JSON-String mit smtp_password passierte ungefiltert (Astra-Repro). (2) agent_runner rief die Policy mit db=None auf — Provider-Compliance (Datenresidenz/erlaubte Datenklassen) wurde NIE geladen. (3) Werkzeugantworten entstehen INNERHALB der ReAct-Schleife — die Policy lief nur davor, Tool-Ergebnisse erreichten den Provider ungefiltert. Fix: - data_policy.py: _filter_json_string_content — JSON-serialisierte Strings werden geparst, durch dieselbe dict-Filterung geleitet und zurueckserialisiert; Nicht-JSON-Strings bleiben unveraendert - agent_runner.py: echte DB-Session (Factory + Tenant-Kontext) statt db=None — Provider-Compliance wird tatsaechlich geladen - agent_loop.py: _filter_observation — jede Tool-Observation wird VOR dem Feed-Back in die LLM-Konversation durch die SENSITIVE_FIELDS-Filterung geleitet (JSON geparst, sensible Felder entfernt, zurueckserialisiert) Abnahme (Astra): Gesperrte Felder fehlen am Provider-Eingang sowohl im Startkontext (durch echte Compliance-Session) als auch nach Werkzeugaufrufen (Observation-Filter) — erfuellt. Verifikation: test_agent_loop + test_phase_f_agents 57 passed/3 skipped (dokumentierte F11-Verweise), Syntax + ruff clean. --- app/ai/agent_loop.py | 45 ++++++++++++++++++ app/ai/data_policy.py | 46 +++++++++++++++++++ .../builtins/automation/agent_runner.py | 21 ++++++--- 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/app/ai/agent_loop.py b/app/ai/agent_loop.py index c3c3197..91bf91c 100644 --- a/app/ai/agent_loop.py +++ b/app/ai/agent_loop.py @@ -139,6 +139,48 @@ async def _execute_tool( return f"Error: {exc}" +def _filter_observation(observation: str) -> str: + """F14 (Astra P1): sanitize a tool observation before it re-enters + the LLM conversation. + + Tool responses are raw data (CRM records, mail payloads, settings) and + may contain sensitive fields (smtp_password, api keys, ...). The data + policy runs BEFORE the loop — observations arise INSIDE it and used to + reach the provider verbatim. Parse JSON observations and strip + sensitive fields (same SENSITIVE_FIELDS set as the data policy). + """ + stripped = observation.strip() + if not stripped.startswith(("{", "[")): + return observation + try: + import json + + parsed = json.loads(stripped) + except (json.JSONDecodeError, ValueError): + return observation + + from app.core.sensitive_data import SENSITIVE_FIELDS + + sensitive_names: set[str] = set() + for fields in SENSITIVE_FIELDS.values(): + sensitive_names |= fields + + def _strip(data: Any) -> Any: + if isinstance(data, dict): + return { + k: _strip(v) + for k, v in data.items() + if k not in sensitive_names + } + if isinstance(data, list): + return [_strip(v) for v in data] + return data + + import json + + return json.dumps(_strip(parsed)) + + def _extract_allowed_tool_names(tools: list[dict[str, Any]] | None) -> set[str]: """Extract the tool names actually offered to the LLM. @@ -543,6 +585,9 @@ async def run_react_loop( observation = json.dumps({"error": f"Approval required but failed to create request: {e}"}) else: observation = await _execute_tool(tool_registry, tool_name, args, tool_context) + # F14 (Astra P1): tool responses are raw data — sanitize the + # observation before it re-enters the LLM conversation. + observation = _filter_observation(observation) observations.append(observation) # Audit every tool call (real or simulated) diff --git a/app/ai/data_policy.py b/app/ai/data_policy.py index 1003bfd..670f2c1 100644 --- a/app/ai/data_policy.py +++ b/app/ai/data_policy.py @@ -82,6 +82,14 @@ async def enforce_data_policy( else c for c in content ] + elif isinstance(content, str): + # F14 (Astra P1): JSON-serialized strings passed through + # UNFILTERED before — a payload like '{"smtp_password": ...}' + # reached the provider verbatim. Parse, filter, re-serialize; + # non-JSON strings stay unchanged (plain prose is fine). + content = _filter_json_string_content( + content, metadata, compliance, agent_definition + ) new_msg = dict(msg) new_msg["content"] = content filtered.append(new_msg) @@ -89,6 +97,44 @@ async def enforce_data_policy( return filtered +def _filter_json_string_content( + content: str, + metadata: AIUseCaseMetadata, + compliance: dict[str, Any] | None, + agent_definition: Any, +) -> str: + """Filter a JSON-serialized string payload (F14). + + Tries to parse the string as a JSON object/array and runs the SAME + dict-level filtering on it. Non-JSON strings are returned unchanged. + """ + stripped = content.strip() + if not stripped.startswith(("{", "[")): + return content + try: + import json + + parsed = json.loads(stripped) + except (json.JSONDecodeError, ValueError): + return content # not JSON — plain string content is not a leak vector + if isinstance(parsed, dict): + filtered = _filter_dict_content(parsed, metadata, compliance, agent_definition) + import json + + return json.dumps(filtered) + if isinstance(parsed, list): + filtered = [ + _filter_dict_content(item, metadata, compliance, agent_definition) + if isinstance(item, dict) + else item + for item in parsed + ] + import json + + return json.dumps(filtered) + return content + + def _filter_dict_content( data: dict[str, Any], metadata: AIUseCaseMetadata, diff --git a/app/plugins/builtins/automation/agent_runner.py b/app/plugins/builtins/automation/agent_runner.py index 4df72eb..a5c3f3d 100644 --- a/app/plugins/builtins/automation/agent_runner.py +++ b/app/plugins/builtins/automation/agent_runner.py @@ -238,13 +238,22 @@ async def run_agent( ) # ── Enforce data policy: filter sensitive fields from messages (Punkt 4) ── + # F14 (Astra P1): pass a REAL DB session so provider compliance + # (data residency / allowed data classes) is actually loaded — + # previously db=None silently skipped the compliance check. from app.ai.data_policy import enforce_data_policy - messages = await enforce_data_policy( - db=None, - tenant_id=agent.tenant_id, - messages=messages, - agent_definition=agent, - ) + from app.core.db import get_session_factory as _dp_factory + from app.core.db import set_tenant_context as _dp_set_tenant + + _factory = _dp_factory() + async with _factory() as _dp_db: + await _dp_set_tenant(_dp_db, agent.tenant_id) + messages = await enforce_data_policy( + db=_dp_db, + tenant_id=agent.tenant_id, + messages=messages, + agent_definition=agent, + ) react_result: ReActResult = await asyncio.wait_for( run_react_loop(