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

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:
Agent Zero
2026-09-18 12:04:58 +02:00
parent c9a5a6e198
commit fdc4e36d14
3 changed files with 106 additions and 6 deletions
+45
View File
@@ -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)
+46
View File
@@ -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(