Securitysecuritysecretsloggingtracingsanitisation

Secrets and Untrusted Output

Secrets must never enter the model’s context, traces or logs, and every byte returned by a tool must be treated as untrusted data and labelled before it re-enters the loop.

Interview question
Progress

Never put secrets in context

The context window is the least secure place in your system. Everything in it can be echoed by the model, captured by tracing, written to a prompt cache, and sent to a third-party provider. If an API key, database password, signed URL or customer token is in the context, assume it is public. There is no “the model will not repeat it” guarantee; Prompt Injection makes sure of that.

The fix is structural. The model gets tools, and tools hold credentials in their closure or in the tool server. The model never needs to know the key to call lookup_shipment. If a tool result contains a secret (a webhook URL with an embedded token, a config file), the result validation layer redacts it before it becomes a message.

  • System prompt: no keys, no internal URLs, no customer identifiers you would not paste in a public issue.
  • Tool arguments: the model never passes credentials; the tool resolves them from the session (Permissions, Authentication and Authorisation).
  • Tool results: redact patterns matching keys, tokens, Authorization headers, connection strings.
  • Files: read_file denies .env, key material and credential stores by path, in code.

Leakage through logs and traces

Observability captures everything by design (Tracing Agents). A trace span for an LLM call stores the full prompt and completion; a tool span stores arguments and results. That is exactly what you want for debugging and exactly what turns your tracing backend into a secret store. The same is true for prompt caches, eval datasets built from production traffic, and “share this conversation” features.

Treat trace storage as a data system with its own classification. Redact at the SDK boundary before the span is exported, not in the dashboard. Keep a short retention for raw prompts and a longer one for metrics. Restrict who can read spans that contain user content.

  • Redact at export: a span processor that masks secrets and PII before the span leaves the process.
  • Field-level policy: mark tool arguments and results as sensitive by default; opt fields in to full capture.
  • Separate metrics from content: token counts, latency and error rates never need the prompt text.
  • Retention: raw prompt and completion bodies expire in days; aggregate metrics can live for years.

Sanitising and marking untrusted output

Every tool result is an input from a source you do not control (Indirect Prompt Injection). Before it re-enters the context, it passes through a sanitiser in code: strip control characters and invisible Unicode, drop HTML comments and hidden elements, truncate to a size budget (Token Budgets), redact secrets, and wrap the remainder in a labelled envelope that names the source.

Marking does two things. It gives the model a prior that the span is data, which helps with the unsophisticated attacks, and it gives your own code a structural handle: a session that has seen an <untrusted> block from an external source can switch into a restricted-tools mode, and traces can show exactly which span introduced a suspicious instruction.

A sanitiser that redacts, strips and labels tool output before it becomes a message.
1import re, unicodedata
2
3SECRET_PATTERNS = [
4 re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*\S+"),
5 re.compile(r"sk-[A-Za-z0-9]{20,}"),
6 re.compile(r"(?i)authorization:\s*bearer\s+\S+"),
7]
8
9def sanitize(source: str, text: str, max_chars: int = 8000) -> str:
10 text = "".join(ch for ch in text if unicodedata.category(ch)[0] != "C" or ch in "\n\t")
11 text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
12 for pat in SECRET_PATTERNS:
13 text = pat.sub("[REDACTED]", text)
14 if len(text) > max_chars:
15 text = text[:max_chars] + f"\n[truncated {len(text) - max_chars} chars]"
16 return f"<untrusted source=\"{source}\">\n{text}\n</untrusted>"

Handling the final answer

The model’s final message is also untrusted output. It may contain a secret quoted from a tool result the sanitiser missed, a URL that exfiltrates via a rendered image, or markdown that executes as HTML in a careless client. Run the same secret scan on the final answer, render markdown with a safe renderer that does not load remote images, and never eval or template model text into anything privileged.

Key points

  • Anything in the context window can be echoed, traced, cached or sent to a provider; secrets must never be there.
  • Tools hold credentials; the model calls tools by name and never handles keys.
  • Traces and prompt caches are secret stores unless you redact at export and restrict access.
  • Sanitise every tool result in code: strip hidden content, redact secrets, truncate, and label the source.
  • The final answer is untrusted too: scan it, render it safely, never execute it.

When to use — and when not to

Use it when
  • Any agent with tracing enabled in production.
  • Any agent whose tools read files, web pages, emails or third-party API responses.
  • Any product with a “share conversation” or “export chat” feature.
Avoid it when
  • Do not redact so aggressively in development that traces become useless; use environment-specific policies.
  • Do not rely on the sanitiser alone to stop injection; it is one layer of Security Overview: Guardrails and the Threat Model.
  • Do not skip trace redaction because “only engineers can see the dashboard”; engineers leave and dashboards leak.

Failure modes

  • A database URL with password appears in the system prompt and is returned to a user who asked for it.
  • Tracing exports full prompts to a SaaS backend with company-wide read access.
  • A read_file tool returns .env and the model summarises it helpfully.
  • Hidden HTML comments in a fetched page survive into the context and steer the agent.
  • The chat client renders a markdown image whose URL carries session data to an attacker.