fix(security): F14 (Astra P1) — KI-Datenrichtlinie deckt JSON-Strings, Provider-Compliance und Tool-Antworten ab
Check Cross-Plugin Imports / check (push) Waiting to run
Check Cross-Plugin Imports / check (push) Waiting to run
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user