ข้าม​ไป​ยัง​เนื้อหา

รักษา output: อย่า​เชื่อ​คำ​ตอบ​ของ model (LLM05)

บท4 สร้าง guard ตัว​แรก​ที่​ห่อ นอก UseFunctionInvocation() แล้ว​รักษา input ของ​ผู้​ใช้ ก่อน loop จะ dispatch tool ได้ นั่น​คือ​ครึ่ง​ซ้าย​ของ wrapper บท​นี้​หัน​ไป​ครึ่ง​ขวา — จังหวะ​ที่ loop จบ​และ output กำลัง​จะ​ไหล​ออก และ​มัน​คือ​จังหวะ​ที่​หลาย​คน​มอง​ข้าม​ที่สุด เพราะ​รู้สึก​ว่า “output ก็​มา​จาก model ของ​เรา​เอง จะ​น่า​กลัว​อะไร”

คำ​ตอบ​คือ: output ของ model ไม่ใช่ ของ​เรา มัน​คือ​สิ่ง​ที่​ผู้​โจมตี คุม​ได้ — เพราะ​เนื้อหา​ที่​ป้อน​เข้าไป (input ของ​ผู้​ใช้, ผล​ที่ tool คืน​มา, memory/retrieval ของ #16) ล้วน​มี​คน​อื่น​เป็นต้นเรื่อง​ได้​ทั้งนั้น เมื่อ injection ลอด​ชั้น input ของ​บท4 เข้า​มา​ได้ (และ honesty spine ของ​คอร์ส​ยืนยัน​ว่า​มัน​ลอด​ได้​เสมอ) ร่องรอย​ของ​มัน​จะ​โผล่​ที่ output — คำ​สั่ง refund ที่ model ไม่​ควร​เสนอ, ข้อมูล​ลูกค้า​คน​อื่น​ที่​หลุด​ออก​มา, secret ที่​ติด​มา​กับ tool result บท​นี้​วาง​สาม​ชั้น​ดัก​ตรง​นี้: validate → redact → egress

📦 code ตัวอย่าง

code guard ใน​บท​นี้​ต่อยอด agent Order ตัว​เดิม​จาก​คอร์ส #15 (context layer จาก #16, resilient runtime จาก #17) — repo kaen-food-ordering (กำลัง​จัด​ทำ) เรา​ยัง​ห่อ​ชั้น guard คร่อม IChatClient ตัว​เดิม ไม่​เพิ่ม tool หรือ store ใหม่ ทุก​กติกา API ฝั่ง​แชต​จาก #15 ยัง​ยึด​เดิม โดย​เฉพาะ​ข้อ​ที่​จ่าย​คืน​หนัก​ที่สุด​ใน​บท​นี้: อ่าน​คำ​ตอบ​ผ่าน ChatResponse.Messages[].Contents[] ไม่ใช่ ChatResponse.Text — เพราะ redactor ที่ scrub แค่ .Text จะ​พลาด secret ที่​ซ่อน​ใน tool-call argument และ tool result

Improper Output HandlingImproper Output HandlingOWASP LLM05 — การ validate/sanitize/handle output ของ model ไม่​พอ 'ก่อน' ส่ง​ต่อ​ไป component และ​ระบบ​อื่น หลัก​คือ zero-trust: 'treat the model as any other user' ปฏิบัติ​ต่อ output เป็น untrusted — validate ก่อน​ป้อน​เข้า function backend, encode/escape ตาม​บริบท​ปลายทาง ('HTML encoding for web content, SQL escaping for database queries') ไม่​เคย​ส่ง output ดิบ​เข้า refund/DB call ตรง ๆProcess คือ​ความ​เสี่ยง LLM05 ใน​แผนที่ OWASP — และ​นิยาม​ของ​มัน​ชี้​ตรง​ไป​ที่ ช่องว่าง ที่​บท​นี้​อุด (S5, verbatim):

“Improper Output Handling refers specifically to insufficient validation, sanitization, and handling of the outputs generated by large language models before they are passed downstream to other components and systems.”

keyword คือ “before they are passed downstream” — ก่อน​ที่ output จะ​ถูก​ส่ง​ต่อ​ไป​ยัง​ส่วน​อื่น: ก่อน issueRefund จะ​รัน argument ที่ model เสนอ, ก่อน​ข้อความ​จะ render บน UI, ก่อน​อะไร​จะ​ลง log หรือ DB จุดยืน​ที่ OWASP สั่งไว้ตรงๆ คือ zero-trust — ปฏิบัติ​กับ model เหมือน​เป็น user แปลก​หน้า​คน​หนึ่ง (S5, verbatim):

“Treat the model as any other user, adopting a zero-trust approach, and apply proper input validation on responses coming from the model to backend functions.”

นี่​คือ​มุมกลับ​ของ​บท4 อย่าง​สวยงาม: บท4 validate input จาก​ผู้​ใช้ ก่อน​เข้า model บท​นี้ validate output จาก model ก่อน​เข้า backend — เพราะ​จาก​มุม​ของ issueRefund แล้ว model ก็​คือ “ผู้​ใช้” ที่​กำลัง​ยื่น​คำ​สั่ง​เข้า​มา​เช่น​กัน Output GuardrailOutput Guardrailชั้น guard ที่​รักษา output ของ model 'ก่อน' ไหล​ไป downstream (ก่อน issueRefund รัน​บน argument, ก่อน render UI, ก่อน​ลง log/DB) — structured-output validation (deserialize เป็น T แล้ว validate เช่น refund amount เป็น​บวก​และ ≤ order total), PII/secret redaction ที่​วน​ทั่ว Contents[], tool-output screening และ context-aware encoding ที่ sink ⚠️ streaming redaction เป็น best-effort เท่านั้น (secret คร่อม2 chunk ได้) — buffer เป็น ChatResponse เต็ม​ก่อน scrub ถ้า​ต้อง​เข้มงวดArchitecture คือ wrapper ครึ่ง​ขวา​นี้: code deterministic ที่​รัน หลัง base.GetResponseAsync คืน​ค่า — หลัง loop ของ UseFunctionInvocation() resolve เสร็จ

บท​นี้​วาง​สาม​ชั้น ทุก​ชั้น​สร้าง​ได้​จริง​ใน C# และ​ทุก​ชั้น ไม่​สมบูรณ์ — นั่น​คือ​เหตุผล​ที่​ต้อง​มี​ครบ​สาม ไม่ใช่​เลือก​อัน​เดียว

อย่า​ปล่อย​ให้ model ส่ง free-form text เข้า issueRefund โดยตรง บังคับ​ให้​มัน​ตอบ​เป็น schema ที่​เข้มงวด แล้ว validate ด้วย code — orderId ตรง format, จำนวน​เงิน​เป็น decimal บวก​และ ไม่​เกิน​ยอด​ออเดอร์​จริง วิธี​บังคับ schema คือ GetResponseAsync<T> (structured output จาก ChatClientStructuredOutputExtensions, S33):

using Microsoft.Extensions.AI;
// จำกัดสิ่งที่ model "พูด" ได้เรื่อง refund ให้อยู่ใน schema เดียว
public record RefundProposal(string OrderId, decimal Amount, string Reason);
var proposal = await client.GetResponseAsync<RefundProposal>(messages, options, ct);
// ❌ version ดิบ: ส่ง proposal.Result เข้า issueRefund ตรง ๆ — เชื่อ output โดยไม่ validate
// ✅ validate แบบ deterministic ก่อนเสมอ:
if (!OrderId.IsValid(proposal.Result.OrderId))
throw new GuardrailTrip("bad orderId");
if (proposal.Result.Amount <= 0m || proposal.Result.Amount > orderTotal)
throw new GuardrailTrip("amount out of bounds");

การ​กำหนด schema ไม่ใช่​แค่​เรื่อง​ความ​สะดวก — มัน​คือ​การ “Define and Validate Output Formats” ที่ OWASP LLM01 แนะนำ​ไว้ (S3) injection ที่​พยายาม​หลอก​ให้ model คืน refund 9999.00 บน​ออเดอร์ 250 บาท​จะ​ตก​ด่าน​นี้​ด้วย code ไม่​ต้อง​พึ่ง​ว่า model จะ “ฉลาด​พอ” ที่​จะ​ปฏิเสธ​เอง

ชั้น​นี้​คือ​หัวใจ​ของ PII RedactionPII Redactionการ​รักษา​ข้อมูล​ส่วน​บุคคล (email, เลข​บัตร/PAN, เบอร์​โทร, ที่​อยู่, รูปแบบ API-key) ออก​จาก​ข้อความ​ก่อน​ออก​จาก process — ทั้ง reply ถึง​ผู้​ใช้​และ log ⚠️ security corollary (invariant #5): ต้อง​วน​ทั่ว Messages[].Contents[] ต่อ message ไม่ใช่​แค่ convenience string .Text เพราะ secret ซ่อน​ใน tool-call arguments / tool results ได้​ด้วย เป็น​หนึ่ง​ชั้น จับ​ได้​เฉพาะ pattern ที่ enumerate ไว้ — format exfil แปลก​ใหม่​ยัง​ลอด​ได้Process และ​คือ​จุด​ที่ corollary ด้าน​ความ​ปลอดภัย​ของ invariant #5 จ่าย​คืน​เต็มๆ OWASP LLM02 ให้​ตัวอย่าง​ความ​ล้มเหลว canonical ของ agent แบบ Order พอดี (S4, verbatim):

“A user receives a response containing another user’s personal data due to inadequate data sanitization.”

redactor วน​ทุก TextContent แล้ว scrub รูปแบบ​ที่ enumerate ไว้ — email, เลข​บัตร/PAN, เบอร์​โทร, ที่​อยู่, รูปทรง API key — ก่อน ข้อความ​ออก​จาก process ทั้ง​ฝั่ง reply ให้​ผู้​ใช้​และ​ฝั่ง log สิ่ง​ที่​พลาด​กัน​บ่อย​คือ​วน​แค่ .Text:

static void Sanitize(ChatResponse response)
{
foreach (var msg in response.Messages)
for (int i = 0; i < msg.Contents.Count; i++)
if (msg.Contents[i] is TextContent t)
msg.Contents[i] = new TextContent(PiiRedactor.Scrub(t.Text));
// สำคัญ: secret ไม่ได้อยู่แค่ใน TextContent — ต้องตรวจ
// FunctionCallContent.Arguments และ FunctionResultContent.Result ด้วย
// redactor ที่ scrub แค่ response.Text (convenience concat) จะพลาดมันทั้งหมด
}

ทำไม​ต้อง​วน Contents[] ไม่ใช่ .Text? เพราะ ChatResponse.Text เป็น​แค่ convenience concatenation ของ text block — secret ที่ agent ส่ง​เป็น argument ของ tool call (เช่น token ที่​ติด​มา​ใน issueRefund argument) หรือ​ที่​โผล่​ใน tool result จะ ไม่ อยู่​ใน .Text เลย redactor ที่​มอง​แค่ .Text จึง​ปล่อย​ให้​ความ​ลับ​หลุด​ผ่าน​ช่อง​ที่​มัน​มอง​ไม่​เห็น — นี่​คือ invariant ที่ “ship-wrong-code-if-you-miss-it” ของ​ทั้ง​คอร์ส

นี่​คือ​ด่าน​ที่​รับมือ indirect injection โดยตรง getOrder/getDeliveryStatus คืน​ข้อมูล​จาก DB/third-party ที่​ผู้​อื่น​แก้​ได้ ก่อน​ป้อน​กลับ​เข้า model ต้อง​ทำ​สอง​อย่าง (S13):

หนึ่ง — ส่ง​เนื้อหา untrusted เฉพาะ​ใน tool_result block แบบ JSON-encoded เท่านั้น ไม่ splice เข้า system prompt หรือ user text ธรรมดา (JSON escaping ให้ delimiter ที่​ชัดเจน ผู้​โจมตี​จึง​ปิด quote เพื่อ “break out” ไป​เป็น instruction ไม่​ได้) สอง — screen ผล​ของ tool ด้วย classifier เบาๆ ก่อน model ลงมือ (S13, verbatim):

“Screen tool outputs before Claude acts on them. Apply the same lightweight-model screening pattern you use for user input to the content your tools return… only return the content as a tool_result block if the screen reports no injection attempt.”

สังเกต​ว่า​นี่​คือ pattern เดียว​กับ input screen ของ​บท4 แค่​ย้าย​มา​ใช้​กับ ผล​ของ tool — reuse ได้​เลย ถ้า screen รายงาน​ว่า​พบ injection ก็​คืน summary ที่​ถูก​ตัดทอน​หรือ error แทน​เนื้อหา​ดิบ

output ที่​ผ่าน​สาม​ชั้น​แล้ว​ยัง​ต้อง​ถูก encode ให้​ถูก​กับ​ที่​ที่​มัน​จะ​ไป​ลง OWASP LLM05 สั่งไว้ตรงๆ (S5, verbatim):

“Implement context-aware output encoding based on where the LLM output will be used (e.g., HTML encoding for web content, SQL escaping for database queries).”

reply ที่​จะ render บน​เว็บ​ต้อง HTML-encode (กัน XSS ที่ model ถูก​หลอก​ให้​ผลิต <script>), query ต้อง parameterize เสมอ — จุด​นี้​คือ​วินัย output-handling พื้นฐาน​ที่​ไม่​เปลี่ยน​ไป​เพราะ​ต้นทาง​เป็น LLM มัน​ยิ่ง ต้อง ทำ​เพราะ​ต้นทาง​เป็น LLM ที่​ผู้​โจมตี​คุม​ได้

caveat: streaming redact ได้​แค่ best-effort

redact ทีละ chunk บน ChatResponseUpdate เป็น best-effort — secret หนึ่ง​ก้อน​อาจ​ถูก​หั่น​คร่อม2 chunk แล้ว​ลอด regex ที่​มอง​ที​ละ chunk ไป​ได้ ถ้า​ต้องการ redact แบบ​เข้มงวด ให้ buffer stream ทั้ง​ก้อน​เป็น ChatResponse ก่อน​แล้ว​ค่อย scrub ที​เดียว — แลก latency กับ​ความ​ปลอดภัย:

ChatResponse full = await updates.ToChatResponseAsync(); // buffer stream ทั้งก้อน (updates เป็น IAsyncEnumerable)
Sanitize(full); // scrub ครั้งเดียว เห็นข้อความครบ

พูดตรงๆ กับ​ผู้​ใช้​ปลายทาง: ถ้า​เลือก stream เพื่อ UX ก็​ยอมรับ​ว่า redaction หลวม​ลง ถ้า​เลือก​ความ​เข้มงวด​ก็​ยอมรับ​ว่า reply มา​เป็น​ก้อน​ช้า​ลง — ไม่มี​ทาง​ได้​ทั้ง​สอง​อย่าง​เต็ม​ร้อย

ย้ำ mental model จาก​บท1/บท4 — guard คือ wrapper และ ลำดับ​คือ​การ​ตัดสิน​ใจ​ด้าน​ความ​ปลอดภัย​ที่ load-bearing (S33): guard ห่อ นอก UseFunctionInvocation() ดังนั้น input ถูก validate ก่อน loop dispatch tool ได้ (บท4) และ output ถูก scrub หลัง loop resolve เสร็จ (บท​นี้) โครง DelegatingChatClient ตัว​เดิม​จาก​บท4 แบก​ทั้ง​สอง​ครึ่ง​ไว้​แล้ว:

public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken ct = default)
{
InputGuard.Validate(messages); // บท4 — ก่อน loop
var response = await base.GetResponseAsync(messages, options, ct) // loop ของ #15 หมุนที่นี่
.ConfigureAwait(false);
OutputGuard.Sanitize(response); // บทนี้ — หลัง loop: validate + redact ทั่ว Contents[]
return response;
}
IChatClient client = anthropic.AsIChatClient("claude-opus-4-8") // beta bridge, bare id (invariant #4/#6)
.AsBuilder()
.UseGuardrails() // OUTERMOST: เห็น input ก่อน, เห็น output หลังสุด
.UseFunctionInvocation() // loop #15: getOrder/getDeliveryStatus/issueRefund
.Build();

ทุก invariant ฝั่ง​แชต​จาก #15 ยัง​ยึด​เดิม: header x-api-key ไม่ใช่ Authorization: Bearer · anthropic-version: 2023-06-01 · ห้าม​ส่ง temperature/top_p/top_k · model id เปล่า claude-opus-4-8 · อ่าน​ผ่าน Messages[].Contents[] ไม่ใช่ .Text

managed layer เป็น​ทาง​เลือก ไม่ใช่​ทาง​บังคับ

มี classifier สำเร็จรูป​ที่​ช่วย screen output ได้: Azure AI Content Safety (Prompt Shields สำหรับ input/XPIA; หมวด classic ผ่าน Azure.AI.ContentSafety 1.0.0 GA แต่ Prompt Shields ใหม่​กว่า SDK GA จึง​ต้อง​เรียก​ผ่าน REST/preview), Llama Guard 3 (classify ทั้ง input และ response), OpenAI Moderation / NeMo Guardrails ทั้งหมด​นี้ ลด ความ​เสี่ยง แต่​ไม่มี​ตัว​ไหน​แทน defense-in-depth ได้ — เป็น​ชั้น​เสริม ไม่ใช่​ชั้น​เดียว

flowchart TD
  OUT["output ของ model<br/>ถือว่า untrusted (คุมโดยผู้โจมตีได้)"]
  OUT --> V["ชั้น 1 · validate โครงสร้าง<br/>structured output + ตรวจ schema/cap"]
  V -->|"ผ่าน schema"| R["ชั้น 2 · redact<br/>วนทุก content ลบ PII และ secret"]
  V -->|"ผิด schema หรือเกิน cap"| TRIP["tripwire<br/>ปฏิเสธ + log"]
  R --> E["ชั้น 3 · egress control<br/>screen ผลของ tool ก่อน model ลงมือ"]
  E -->|"สะอาด"| SINK["sink ปลายทาง<br/>reply · log · issueRefund + encode ตาม context"]
  E -->|"พบ injection"| TRIP

  classDef danger fill:#b91c1c,stroke:#7f1d1d,color:#f8fafc;
  classDef guard fill:#475569,stroke:#1e293b,color:#f8fafc;
  class OUT,TRIP danger;
  class V,R,E guard;

คำ​บรรยาย​ภาพ: output ที่​ออก​จาก loop ถือ​เป็น untrusted (ช่อง​แดง​บน) แล้ว​ไหล​ผ่าน​สาม​ชั้น guard สี​เทา — validate โครงสร้าง, redact PII/secret ทั่ว​ทุก content, egress control ที่ screen ผล​ของ tool — ก่อน​ถึง sink ปลายทาง​ที่​ยัง encode ตาม context อีก​ชั้น สิ่ง​ที่​ผิด schema/เกิน cap หรือ​พบ injection จะ​ตกลง tripwire (ช่อง​แดง​ล่าง) ไม่มี​ชั้น​ใด​ชั้น​เดียว​สมบูรณ์ แต่​ซ้อน​กัน​จึง​ลด​ความ​เสี่ยง​และ​กัน​ความ​เสียหาย

  • vs #14 (designing MCP servers) — #14 รักษา tool-server / protocol MCP บท​นี้​คือ agent-level output defense ที่​ยืน​ได้ แม้ tool/server เชื่อถือ​ได้​เต็ม​ร้อย — เพราะ data ที่ tool สะอาด​คืน​มา​ก็​เป็น​ผู้​โจมตี​ได้ (indirect injection) egress control ชั้น 3 คือ​ด่าน​ที่​รับมือ​เรื่อง​นี้​ที่​ระดับ agent
  • vs #17 (robustness) — #17 validate รูปทรง ของ output เพื่อ​ความ ถูกต้อง (parse ไม่​พัง, field ครบ) บท​นี้ validate output เดียวกัน​ใน​ฐานะ ศัตรู — schema ที่ valid แต่ refund เกิน​ยอด ผ่าน #17 แต่​ต้อง​ตก​ด่าน​นี้
  • vs #13 (evals) — redaction ที่​นี่ ลงมือ ตัด​ข้อมูล​ออก​จริง ไม่ใช่ วัด อัตรา​การ​รั่ว การ​วัด​ว่า redactor พลาด​กี่​เปอร์เซ็นต์​คือ​งาน​ของ eval harness ใน #13 — บท​นี้​ผลิต​เคส​ให้ #13 ไป​วัด ไม่​สอน scoring ซ้ำ

output ของ model คือ​สิ่ง​ที่​ผู้​โจมตี​คุม​ได้ — LLM05 สั่ง​ให้ zero-trust มัน​เหมือน user แปลก​หน้า บท​นี้​วาง​สาม​ชั้น​ก่อน output ถึง​ปลายทาง: validate โครงสร้าง​ด้วย structured output + ตรวจ cap ด้วย code, redact PII/secret โดยวน​ทั่ว Contents[] (ไม่ใช่​แค่ .Text — corollary ที่​พลาด​ไม่​ได้), และ egress control ที่ screen ผล​ของ tool ก่อน model ลงมือ ปิด​ท้าย​ด้วย encode ตาม context ที่ sink และ​ยอม​รับตรงๆ ว่า streaming redact ได้​แค่ best-effort

แต่ validation จับ​ได้​แค่ รูปทรง ที่​เรา​คาด​ไว้ redaction จับ​ได้​แค่ pattern ที่​เรา enumerate — รูปแบบ exfil ใหม่ หรือ refund ที่ valid ตาม schema แต่​ยัง​ผิด​เจตนา ก็​ยัง​ลอด​ได้ นั่น​คือ​เหตุผล​ว่า​ทำไม​บท6 ถึง​ต้อง​มา: least-privilege + human-approval gate ที่ หด blast radius เมื่อ detection ของ​บท4/5 พลาด — เพราะ Anthropic วัด​การ​ป้องกัน​ที่​ดี​ที่สุด​ของ​ตัวเอง​แล้ว​ยัง​สรุป​ว่า (S14, verbatim):

“A 1% attack success rate — while a significant improvement — still represents meaningful risk. No browser agent is immune to prompt injection.”

ความ​ปลอดภัย​คือ​กระบวนการ ไม่ใช่ checkbox — output guard ลด ความ​เสี่ยง​และ ดัก สิ่ง​ที่​ลอด​ชั้น input มา แต่​ไม่ กำจัด ภัย บท6 จะ กัน ความ​เสียหาย​ที่​เหลือ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 5

ข้อ 1 / 3

ทำไม redactor ของบทนี้ต้องวน response.Messages[].Contents[] ไม่ใช่ scrub แค่ ChatResponse.Text?