SLOPSHOPPER

pdpa-thai

Helps reduce personal data sent to Claude: redacts detected Thai and international personal data before sending and masks it on screen. Detection runs locally…

newrowsguardcommandtoaststatus
★ 1v0.3.0MITupdated 2026-10-08Boom-Vitt/boombignose-mods/pdpa-thai
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pdpa-thai
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ pdpa-thai │ ⏺ Read(src/auth.ts) │ PDPA: ปกปิดข้อมูลส่วนบุคคล 1 │ ⎿ Read 6 lines │ รายการก่อนส่งให้ Claude │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭────────────────────────────────────────────╮ ⏺ Bash(bun test) │ pdpa-thai │ ⎿ 3 pass, 1 fail │ PDPA: ปกปิดข้อมูลส่วนบุคคล 1 │ │ รายการก่อนส่งให้ Claude │ ● Done. refresh now rejects expired claims and logs an audit event. ╰────────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM › /pdpa-guard ⎿ pdpa-thai: โหมดปัจจุบัน redact: ปกปิดข้อมูลส่วนบุคคลก่อนส่งให้ Claude (ค่าเริ่มต้น) ⎿ pdpa-thai: เปลี่ยนด้วย /pdpa-guard redact | block | off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ pdpa-thai: PDPA: redact
README

boombignose-mods

มอด (mod) สำหรับ Claude Code: ตัวช่วยลดความเสี่ยงในการส่งข้อมูลส่วนบุคคลให้โมเดล (pdpa-thai) แถบแสดงการใช้ context (context-bar) แผงรายการ agent (agents-panel) และเวิร์กโฟลว์ multi-agent (boom-big-nose-workflow)

License: MIT tests For Claude Code

ไทย · English

ติดตั้ง · มอดในชุดนี้ · pdpa-thai · ข้อจำกัด · เชื่อถือได้แค่ไหน · คำถามที่พบบ่อย · PDPA กับมอดนี้ · กฎการตรวจจับ

ข้อควรทราบ โครงการนี้เป็นโครงการชุมชนที่ไม่เป็นทางการ และมีผู้ดูแลเพียงคนเดียว ไม่มีความเกี่ยวข้องกับ Anthropic, สำนักงานคณะกรรมการคุ้มครองข้อมูลส่วนบุคคล (PDPC), สำนักงานพัฒนาธุรกรรมทางอิเล็กทรอนิกส์ (ETDA), สำนักงานพัฒนารัฐบาลดิจิทัล (DGA), กระทรวงดิจิทัลเพื่อเศรษฐกิจและสังคม (MDES) หรือหน่วยงานรัฐใดของไทย และไม่ได้รับการสนับสนุนหรือการรับรองจากองค์กรและหน่วยงานเหล่านี้ ชื่อ pdpa-thai คำสั่ง /pdpa-guard และข้อความ PDPA: <โหมด> ในแถบสถานะ อ้างถึงพระราชบัญญัติคุ้มครองข้อมูลส่วนบุคคลเพียงเพราะกฎการตรวจจับออกแบบตามชนิดข้อมูลที่กฎหมายนั้นคุ้มครอง ไม่ได้บ่งชี้ว่าปฏิบัติตามกฎหมายแล้ว ส่วน Claude และ Claude Code เป็นผลิตภัณฑ์ของ Anthropic การกล่าวถึงชื่อเหล่านี้มีไว้เพียงเพื่อบอกว่ามอดในชุดนี้ใช้งานร่วมกับผลิตภัณฑ์ใด

เอกสารนี้ไม่ใช่คำแนะนำทางกฎหมาย สำหรับคำถามทางกฎหมาย โปรดปรึกษาทนายความที่มีคุณสมบัติเหมาะสม หรือเจ้าหน้าที่คุ้มครองข้อมูลส่วนบุคคล (Data Protection Officer, DPO) ขององค์กร และติดตามการตีความอย่างเป็นทางการจาก PDPC ซอฟต์แวร์นี้ให้ใช้ตามสภาพ (as is) โดยไม่มีการรับประกันใด ๆ ตาม สัญญาอนุญาต MIT การตรวจจับใช้นิพจน์ปรกติ (regular expression) ซึ่งไม่รับประกันผล (best-effort) จึงจะมีข้อมูลที่ตรวจไม่พบ และจะมีข้อความทั่วไปบางส่วนถูกปกปิดโดยไม่จำเป็น

โครงการนี้คืออะไร และมีไว้ทำไม

ทุกอย่างในบทสนทนาของ Claude Code ถูกส่งไปให้โมเดลประมวลผล ได้แก่ ข้อความที่พิมพ์ ไฟล์ที่ให้ Claude อ่าน ผลลัพธ์ของคำสั่ง และไฟล์ CLAUDE.md ดังนั้นเมื่อทำงานกับข้อมูลลูกค้า ใบสมัครงาน หรือบันทึกของระบบ (log) ข้อมูลส่วนบุคคล เช่น เลขประจำตัวประชาชน หมายเลขโทรศัพท์ หรืออีเมล จึงอาจติดไปกับคำขอโดยไม่ได้ตั้งใจ

pdpa-thai ตรวจหาข้อมูลส่วนบุคคลรูปแบบที่พบบ่อย ทั้งรูปแบบเฉพาะของไทยและรูปแบบสากล โดยทำงานภายในโปรเซสของ Claude Code แล้วแทนค่าที่พบด้วยป้ายแทนค่า (placeholder) ก่อนที่ Claude Code จะส่งบทสนทนาออกไป มอดนี้ออกแบบมาเพื่อช่วยลดความเสี่ยงเท่านั้น การติดตั้งมอดนี้ไม่ได้ทำให้องค์กรปฏิบัติตามพระราชบัญญัติคุ้มครองข้อมูลส่วนบุคคล พ.ศ. 2562 (PDPA) หรือกฎหมายอื่นใด และไม่ทดแทนการประเมินโดยผู้เชี่ยวชาญ

มอดอื่นเป็นเครื่องมือช่วยงานทั่วไปที่ไม่เกี่ยวกับ PDPA

ติดตั้ง

ต้องใช้ Claude Code เวอร์ชันที่รองรับปลั๊กอินแบบ hook module ทดสอบแล้วกับ Claude Code 2.1.289 เท่านั้น API ของ hook ยังอยู่ในระยะทดลองใช้ (early access) ดังนั้นเวอร์ชันก่อนหน้าหรือใหม่กว่าอาจข้ามการป้องกันบางส่วนหรือทั้งหมดโดยไม่มีการแจ้งเตือน ส่วนป้าย (badge) ของ CI แสดงเพียงว่าชุดทดสอบหน่วย (unit test) ผ่านบน Claude Code เวอร์ชันล่าสุด

claude plugin marketplace add Boom-Vitt/boombignose-mods
claude plugin install pdpa-thai@boombignose-mods
claude plugin install context-bar@boombignose-mods
claude plugin install agents-panel@boombignose-mods
claude plugin install boom-big-nose-workflow@boombignose-mods

แต่ละมอดทำงานแยกกัน เลือกติดตั้งเฉพาะมอดที่ต้องการได้

ใน Codex ติดตั้งได้เฉพาะ boom-big-nose-workflow เพราะมอดอื่นใช้ความสามารถที่มีเฉพาะใน Claude Code:

codex plugin marketplace add Boom-Vitt/boombignose-mods
codex plugin add boom-big-nose-workflow@boombignose-mods

อัปเดตเป็นเวอร์ชันล่าสุด:

claude plugin marketplace update boombignose-mods
claude plugin update pdpa-thai@boombignose-mods

จากนั้นเริ่ม Claude Code ใหม่เพื่อให้การอัปเดตมีผล

ปิดการทำงานชั่วคราว หรือถอนการติดตั้ง:

claude plugin disable pdpa-thai@boombignose-mods
claude plugin uninstall pdpa-thai@boombignose-mods

มอดในชุดนี้

มอดเวอร์ชันคำสั่งหน้าที่
pdpa-thai0.3.0`/pdpa-guard [redact\block\off], /pdpa-blur [on\off\record]`ปกปิดข้อมูลส่วนบุคคลที่ตรวจพบก่อนส่งให้ Claude และพรางข้อมูลบนหน้าจอ (วางเมาส์เพื่อดู หรือวางเมาส์แล้วไม่แสดงค่าจริงในโหมดบันทึกหน้าจอ)
context-bar0.4.0/context-barแถบเหนือช่องพิมพ์ แสดงการใช้ context window แยกตามหมวด และเวลาที่เหลือของ prompt cache
agents-panel0.1.0/agents-panelแผงด้านข้างแสดง agent ของโปรเจกต์ ของผู้ใช้ และของปลั๊กอิน พร้อมปุ่มเรียกใช้
boom-big-nose-workflow0.5.0ไม่ต้องใช้คำสั่ง (บอกเป้าหมายได้เลย) หรือ /bbn-plan, /bbn-review, /bbn-merge และคำสั่ง /bbn-* อื่นเวิร์กโฟลว์ BBN แบบอัตโนมัติ: บอกเป้าหมายเป็นภาษาธรรมดา แล้ว Claude Opus/Sonnet/Haiku กับ Codex CLI วางแผน แยก worktree ต่อ stream เขียนโค้ด รีวิว และ merge เข้า base ในเครื่องผ่าน gate โดยไม่ต้องพิมพ์ slash command (README, แผนภาพ)

/context-bar และ /agents-panel เป็นคำสั่งสลับเปิดและปิด /pdpa-blur สลับเปิดและปิดได้เช่นกัน หรือรับ on, off หรือ record ส่วน /pdpa-guard รับชื่อโหมด

  • context-bar นับถอยหลังโดยถือว่า prompt cache มีอายุ 5 นาที (เป็นค่าคงที่ในโค้ด)
  • ปุ่ม ▶ run ของ agents-panel เริ่ม subagent ตัวนั้นด้วยพรอมต์ (prompt) คงที่ Run the <name> agent on the current project. subagent นั้นทำงานเหมือน agent ทั่วไป คือส่งบทสนทนาไปยังผู้ให้บริการโมเดลที่ตั้งค่าไว้ (โดยค่าเริ่มต้นคือ Anthropic) และทำงานกับโปรเจกต์ได้ภายใต้สิทธิ์ (permission) ที่ตั้งไว้ตามปกติ

ประวัติการเปลี่ยนแปลงของแต่ละมอดอยู่ใน CHANGELOG.md

pdpa-thai

กลไกการทำงาน 3 ชั้น

  1. ตัวป้องกันขาออก (outbound guard) ตรวจและปกปิดข้อมูลก่อนส่งให้โมเดล ครอบคลุม
  2. พรอมต์ที่ผู้ใช้พิมพ์
  3. ข้อความที่บทสนทนาเก็บไว้ (conversation row) เช่น ข้อความในผลลัพธ์ของเครื่องมือ (tool result) และ context ที่ hook อื่นเพิ่มเข้ามา
  4. ข้อความที่แนบไปกับคำขอ เช่น ไฟล์ที่อ้างถึงด้วย @ ไฟล์ CLAUDE.md และ context block อื่น

มอดแก้ไขเฉพาะบล็อกข้อความ (text block) และข้อความในผลลัพธ์ของเครื่องมือ บล็อกชนิดอื่น เช่น รูปภาพหรือเอกสาร ถูกส่งไปตามเดิม

  1. การปฏิเสธการเรียกเครื่องมือ (tool call) ขณะที่โหมดไม่ใช่ off หากการเรียกเครื่องมือของ Claude มีป้ายแทนค่าที่มอดสร้างขึ้นในเซสชัน (session) นี้ตรงตามรูปแบบเดิม มอดจะปฏิเสธการเรียกนั้นและแจ้งให้ Claude ขอค่าจริงจากผู้ใช้ ซึ่งช่วยลดโอกาสที่ป้ายแทนค่าจะถูกเขียนลงไฟล์หรือคำสั่งจริง การตรวจนี้จับไม่ได้หาก Claude ดัดแปลงป้ายแทนค่า หรือป้ายแทนค่ามาจากเซสชันก่อนหน้า
  2. การพรางบนหน้าจอ (hover blur) ข้อมูลที่ตรวจพบซึ่งยังแสดงอยู่ในข้อความของผู้ใช้และของ Claude จะแสดงเป็นแถบสีเทา วางเมาส์เพื่อดูค่าจริง ในโหมด redact พรอมต์ของผู้ใช้แสดงเป็นป้ายแทนค่าอยู่แล้ว บทสนทนาเก็บเฉพาะพรอมต์ที่ปกปิดแล้ว และมอดไม่ได้เก็บสำเนาค่าเดิมไว้ การพรางจึงมีผลหลักกับข้อความของ Claude และข้อความที่ส่งขณะปิดการป้องกัน (off) การพรางเปิดใช้งานโดยค่าเริ่มต้น และทำงานเฉพาะบน terminal และแอปเดสก์ท็อป สำหรับการบันทึกหน้าจอหรือแชร์หน้าจอ ใช้ /pdpa-blur record เพื่อคงการพรางไว้และหยุดการแสดงค่าจริงเมื่อวางเมาส์ ดูหัวข้อ การบันทึกหรือแชร์หน้าจอ

โหมด

โหมดการทำงาน
redact (ค่าเริ่มต้น)แทนค่าที่ตรวจพบด้วยป้ายแทนค่าก่อนส่งให้ Claude
blockไม่ส่งพรอมต์ที่มีข้อมูลส่วนบุคคลที่ตรวจพบ ให้แก้ไขพรอมต์โดยนำข้อมูลนั้นออกแล้วส่งใหม่ ส่วนผลลัพธ์ของเครื่องมือ ไฟล์แนบ และ context ยังถูกปกปิดเหมือนโหมด redact
offปิดทั้งการปกปิดและการปฏิเสธการเรียกเครื่องมือ (การพรางบนหน้าจอควบคุมแยกด้วย /pdpa-blur)

โหมดและการตั้งค่าการพรางไม่ได้บันทึกลงดิสก์ และจะเริ่มที่โหมด redact พร้อมเปิดการพรางและปิดโหมดบันทึกหน้าจอทุกครั้งที่เริ่ม Claude Code ส่วน /clear จะคงโหมดปัจจุบันไว้หรือไม่นั้น ผู้ดูแลโครงการยังไม่ได้ตรวจสอบ (ดูโหมดปัจจุบันได้โดยใช้ /pdpa-guard โดยไม่ระบุอาร์กิวเมนต์ หรือดูที่แถบสถานะ)

คำสั่ง

/pdpa-guard           แสดงโหมดปัจจุบัน
/pdpa-guard redact    ปกปิดก่อนส่ง (ค่าเริ่มต้น)
/pdpa-guard block     ไม่ส่งพรอมต์ที่มีข้อมูลส่วนบุคคลที่ตรวจพบ
/pdpa-guard off       ปิดการป้องกัน
/pdpa-blur            สลับการพรางบนหน้าจอ (เปิดหากปิดอยู่ ปิดหากเปิดอยู่หรืออยู่ในโหมดบันทึกหน้าจอ)
/pdpa-blur on         เปิดการพราง วางเมาส์เพื่อดูค่าจริง
/pdpa-blur off        ปิดการพราง
/pdpa-blur record     โหมดบันทึกหน้าจอ: เปิดการพราง และวางเมาส์แล้วไม่แสดงค่าจริง

อาร์กิวเมนต์อื่นของ /pdpa-blur จะไม่เปลี่ยนการตั้งค่าใด และแสดงเพียงบรรทัดวิธีใช้ (usage)

แถบสถานะ (status line) แสดง PDPA: <โหมด> และต่อท้ายด้วย · REC เมื่ออยู่ในโหมดบันทึกหน้าจอ (เช่น PDPA: redact · REC) และเมื่อมอดปกปิดพรอมต์หรือข้อความที่บทสนทนาเก็บไว้ หรือไม่ส่งพรอมต์ จะมีข้อความแจ้งเตือน (toast) บอกจำนวนรายการที่พบ ส่วนการปกปิดใน CLAUDE.md, context block อื่น และไฟล์แนบ เช่น ไฟล์ที่อ้างถึงด้วย @ จะไม่มีการแจ้งเตือน ข้อความแจ้งเตือนนับเฉพาะรายการที่ตรงกับกฎ การไม่มีข้อความแจ้งเตือนไม่ได้หมายความว่าไม่มีข้อมูลส่วนบุคคลถูกส่งออกไป และการมีข้อความแจ้งเตือนก็ไม่ได้หมายความว่าข้อความนั้นไม่มีข้อมูลส่วนบุคคลเหลืออยู่แล้ว

ตรวจสอบว่ามอดทำงานอยู่ หลังเริ่ม Claude Code แถบสถานะควรแสดง PDPA: redact หรือ PDPA: block หากไม่แสดง แสดงว่ามอดไม่ได้โหลดและไม่มีการปกปิดใด ๆ ให้ใช้ claude --debug เพื่อดูสาเหตุ การทำงานแบบไม่มีหน้าจอโต้ตอบ (headless) เช่น claude -p ไม่มีแถบสถานะ จึงควรตรวจผลลัพธ์ของ debug ก่อนพึ่งพาการป้องกันในกรณีนั้น

การบันทึกหรือแชร์หน้าจอ

/pdpa-blur record เปิดโหมดบันทึกหน้าจอ (recording mode) สำหรับการบันทึกวิดีโอหน้าจอและการแชร์หน้าจอ การพรางยังเปิดอยู่ และการวางเมาส์จะไม่เปิดค่าที่พรางไว้ตลอดเวลาที่อยู่ในโหมดนี้ โหมดนี้ใช้ได้เฉพาะบน terminal และแอปเดสก์ท็อปเช่นเดียวกับการพรางแบบปกติ ไม่ได้บันทึกลงดิสก์ และทุกครั้งที่เริ่ม Claude Code การพรางจะเปิดอยู่และโหมดบันทึกหน้าจอจะปิดอยู่ ออกจากโหมดนี้ด้วย /pdpa-blur on หรือ /pdpa-blur off ควรหยุดบันทึกก่อนออกจากโหมดนี้ เพราะ /pdpa-blur on จะกลับมาแสดงค่าจริงเมื่อวางเมาส์ และ /pdpa-blur โดยไม่ระบุอาร์กิวเมนต์จะปิดการพราง นอกจากนี้เมื่อเริ่ม Claude Code ใหม่ โหมดบันทึกหน้าจอจะปิดอยู่ จึงควรตรวจแถบสถานะทุกครั้งหลังเริ่มใหม่หรือกลับมาทำงานต่อ (resume)

โหมดบันทึกหน้าจอพรางเฉพาะค่าที่ตรวจพบในข้อความของผู้ใช้และข้อความของ Claude ส่วนสิ่งอื่นที่ Claude Code แสดง เช่น การเรียกเครื่องมือ (tool call) และผลลัพธ์ ส่วนต่างของไฟล์ (diff) การคิดของโมเดล (thinking) ข้อความแจ้งเตือน และแผงอื่น จะแสดงตามปกติ

โหมดบันทึกหน้าจอไม่ครอบคลุม

  • ข้อมูลส่วนบุคคลที่กฎตรวจไม่พบ เช่น ชื่อที่ไม่มีป้ายกำกับหรือคำนำหน้าชื่อ (ดูหัวข้อ ข้อจำกัด) ซึ่งจะแสดงตามจริงโดยไม่ถูกพราง
  • ข้อความที่กำลังพิมพ์ในช่องพรอมต์ก่อนกดส่ง (ตัวป้องกันแทนค่าเมื่อกดส่งเท่านั้น)
  • การเรียกเครื่องมือ ผลลัพธ์ของเครื่องมือ และผลลัพธ์ของคำสั่งบนหน้าจอ
  • ข้อความต้นฉบับที่ปรากฏบนหน้าจอด้วยเหตุอื่น เช่น ไฟล์ที่เปิดอยู่ในหน้าต่างอื่น
  • การเลือกข้อความ ซึ่งอาจทำให้ข้อความที่พรางไว้อ่านได้บนหน้าจอ และการคัดลอกซึ่งยังได้ข้อความจริง

ในโหมด redact พรอมต์ที่ผู้ใช้ส่งแล้วแสดงเป็นป้ายแทนค่าอยู่แล้ว การพรางจึงมีผลหลักกับข้อความของ Claude และข้อความที่ส่งขณะปิดการป้องกัน (off) ข้อจำกัดอื่นของการพรางในหัวข้อ ข้อจำกัด ยังคงมีผล

ก่อนบันทึกหรือแชร์หน้าจอ

  1. ตั้งตัวป้องกันเป็นโหมด redact หรือ block (/pdpa-guard redact)
  2. สั่ง /pdpa-blur record และตรวจว่าแถบสถานะลงท้ายด้วย · REC
  3. ตรวจวิดีโอที่บันทึกไว้ก่อนแชร์

การพรางนี้เป็นเพียงการปิดบังทางสายตา ไม่ใช่หลักประกัน

ตัวอย่างก่อนและหลัง

ข้อความที่ผู้ใช้พิมพ์ (ค่าจริงแสดงเป็นคำอธิบายในวงเล็บมุม):

ช่วยร่างอีเมลแจ้งลูกค้า เบอร์ <เบอร์มือถือ 10 หลัก> อีเมล <อีเมลของลูกค้า>

ข้อความที่ Claude ได้รับในโหมด redact:

ช่วยร่างอีเมลแจ้งลูกค้า เบอร์ [REDACTED:PHONE_1~k3x9q] อีเมล [REDACTED:EMAIL_1~k3x9q]

รูปแบบของป้ายแทนค่าคือ [REDACTED:<ชนิด>_<ลำดับ>~<ส่วนต่อท้ายประจำเซสชัน>]

  • ชนิดข้อมูลบอก Claude ว่าตำแหน่งนั้นเป็นข้อมูลชนิดใด Claude จึงยังเขียนคำตอบที่อ้างถึงป้ายแทนค่าได้
  • ค่าเดียวกันที่ปรากฏซ้ำในข้อความชุดเดียวกัน (พรอมต์ ผลลัพธ์ของเครื่องมือหนึ่งรายการ ไฟล์แนบหนึ่งไฟล์ หรือกลุ่ม context block) ได้ป้ายแทนค่าเดียวกัน ลำดับเริ่มนับใหม่ในแต่ละชุด ดังนั้น PHONE_1 ในพรอมต์กับ PHONE_1 ในไฟล์ที่อ้างถึงด้วย @ หรือในผลลัพธ์ของเครื่องมือครั้งถัดไป อาจไม่ใช่หมายเลขเดียวกัน
  • ส่วนต่อท้ายประจำเซสชัน (session suffix) 5 อักขระช่วยให้มอดแยกป้ายแทนค่าที่มอดสร้างขึ้นจริงในเซสชันนี้ ออกจากข้อความที่เพียงกล่าวถึงรูปแบบของป้ายแทนค่า เช่น เอกสารฉบับนี้
  • การปกปิดย้อนกลับไม่ได้ มอดไม่มีตารางสำหรับแปลงป้ายแทนค่ากลับเป็นค่าเดิม

ข้อมูลที่ตรวจจับ

ชนิดสิ่งที่ตรวจ
THAI_IDเลขประจำตัวประชาชน 13 หลักซึ่งมีหลักตรวจสอบ (check digit) ถูกต้องตามสูตร mod 11
CARDเลขบัตรชำระเงิน (payment card) 13-19 หลักที่ผ่านการตรวจแบบ Luhn
PHONEหมายเลขโทรศัพท์มือถือและโทรศัพท์พื้นฐานของไทย รวมถึงรูปแบบ +66 และ 0066
EMAILที่อยู่อีเมล ยกเว้นอีเมลที่ใช้โดเมน example.com, example.org หรือ example.net โดยตรง (อีเมลที่ใช้โดเมนย่อยของโดเมนเหล่านี้ยังถูกปกปิด)
IPที่อยู่ IPv4 ยกเว้น 127.x.x.x และ 0.0.0.0
SECRETโทเค็น (token) ที่ขึ้นต้นด้วยคำนำหน้า (prefix) ที่รู้จัก ค่าหลัง Bearer และค่าหลังป้ายกำกับ (label) เช่น password, secret, token, api_key, รหัสผ่าน ทั้งนี้มอดนี้ไม่ใช่เครื่องมือสแกนความลับ (secret scanner) ตรวจพบเฉพาะรูปแบบที่ระบุไว้ใน DETECTION และข้ามค่าที่มีลักษณะเป็นโค้ด
PASSPORTเลขหนังสือเดินทางที่อยู่ถัดจากป้ายกำกับ เช่น passport, เลขที่หนังสือเดินทาง, พาสปอร์ต
BANK_ACCOUNTตัวเลข 10-15 หลักที่อยู่ถัดจากป้ายกำกับ เช่น เลขที่บัญชี, บัญชี, account no., พร้อมเพย์, PromptPay
DOBวันที่แบบตัวเลข (คั่นด้วย / . หรือ -) ที่อยู่ถัดจากป้ายกำกับ เช่น วันเกิด, date of birth, DOB ส่วนวันที่ที่เขียนชื่อเดือนเป็นตัวอักษรจะตรวจไม่พบ
SENSITIVEค่าหลังป้ายกำกับของหมวดข้อมูลอ่อนไหวส่วนใหญ่ตามมาตรา 26 เฉพาะเมื่อเขียนในรูป ป้ายกำกับ: ค่า เป็นภาษาไทยหรืออังกฤษ เช่น ศาสนา, ข้อมูลสุขภาพ, ประวัติอาชญากรรม ทั้งนี้ไม่ครอบคลุมถ้อยคำรูปแบบอื่น และไม่ครอบคลุมข้อมูลอื่นที่คณะกรรมการคุ้มครองข้อมูลส่วนบุคคลอาจประกาศกำหนด
ADDRESSค่าที่เขียนในรูป ป้ายกำกับ: ค่า โดยใช้ป้ายกำกับ ที่อยู่, home address หรือ mailing address (ป้ายกำกับ address: อย่างเดียวจะตรวจไม่พบ)
NAMEค่าที่เขียนในรูป ป้ายกำกับ: ค่า โดยใช้ป้ายกำกับ เช่น ชื่อ, นามสกุล, full name, surname รวมถึงชื่อที่ตามหลังคำนำหน้าชื่อ นาย, นาง, นางสาว, น.ส., ด.ช., ด.ญ., Mr, Mrs, Ms, Miss, Dr

สำหรับ SENSITIVE, ADDRESS และ NAME รูป ป้ายกำกับ: ค่า รวมถึง ป้ายกำกับ = ค่า เครื่องหมายทวิภาคแบบเต็มความกว้าง (:) และคีย์ JSON ในเครื่องหมายคำพูด เช่น "label": "value" ค่าหลังป้ายกำกับจะสิ้นสุดที่เครื่องหมายจุลภาค (,) อัฒภาค (;) เครื่องหมายคำพูด } หรือการขึ้นบรรทัดใหม่ที่พบก่อน ส่วนที่เหลือของที่อยู่หรือรายการจะถูกส่งไปตามที่เขียน และค่าที่ขึ้นต้นด้วย [ หรือ { (อาร์เรย์หรืออ็อบเจกต์ JSON) จะไม่ตรงกับกฎกลุ่มนี้เลย จึงไม่มีส่วนใดในค่านั้นถูกแทนค่า เว้นแต่กฎอื่น เช่น PHONE หรือ EMAIL ตรวจพบ

ทุกกฎอ่านเลขไทย (๐-๙) เหมือนเลขอารบิก

หมวดข้อมูลอ่อนไหวอ้างอิงมาตรา 26 แห่งพระราชบัญญัติคุ้มครองข้อมูลส่วนบุคคล พ.ศ. 2562 (ฉบับ PDF ที่ PDPC เผยแพร่, ตรวจสอบเมื่อ 4 ตุลาคม 2569) รายการป้ายกำกับทั้งหมด ข้อยกเว้น และการตรวจจับผิด (false positive) ที่ทราบ อยู่ใน docs/DETECTION.md

ข้อมูลของผู้ใช้ไปที่ใด

  • การตรวจจับและการปกปิดทำงานภายในโปรเซสของ Claude Code (บนเครื่องของผู้ใช้เมื่อใช้งานในเครื่อง หรือบนเครื่องที่เซสชันคลาวด์หรือเซสชันระยะไกลทำงานอยู่) ในเซสชันคลาวด์หรือเซสชันระยะไกล ค่าเดิมอยู่บนเครื่องนั้นแล้วก่อนที่มอดจะตรวจ มอดเปลี่ยนเฉพาะสิ่งที่ส่งต่อจากเครื่องนั้นไปให้โมเดล
  • pdpa-thai และ context-bar ไม่เรียกเครือข่าย โปรเซสภายนอก โมเดล หรือ MCP ด้วยตัวเอง ส่วน agents-panel เริ่ม subagent ผ่าน $.agent.spawn เฉพาะเมื่อผู้ใช้กดปุ่ม ▶ run และ subagent นั้นทำงานเหมือน agent ทั่วไป
  • boom-big-nose-workflow ไม่มีโค้ดฮุก คำสั่งของมอดรันสคริปต์ของมอดเอง (git, node, claude mcp list และ gh สำหรับการตรวจการล็อกอินใน /bbn-doctor และ /bbn-merge --apply --pr) เริ่ม subagent ในบทบาทเขียนโค้ด รวมโค้ด และรีวิว ซึ่งทำงานเหมือน agent ทั่วไป ถ้าติดตั้ง Codex CLI ไว้ bbn-codex.sh จะรัน Codex CLI ของผู้ใช้ ซึ่งเรียก OpenAI ด้วยการล็อกอินของผู้ใช้เอง และเพิ่มเซิร์ฟเวอร์ MCP ระยะไกล 2 ตัว คือ Context7 (https://mcp.context7.com/mcp) และ Perplexity (https://api.perplexity.ai/mcp ต้อง sign in ก่อน) คำค้นที่ agent ส่งไปยังเซิร์ฟเวอร์ที่เชื่อมต่ออยู่จะไปถึงบริการเหล่านั้น
  • ทุกครั้งที่มีการ push และ pull request CI จะทำการตรวจเฉพาะในเครื่อง (local-only) 2 รายการ ได้แก่ การค้นหาข้อความในโค้ดฮุกของทุกมอดว่ามีการเรียกเครือข่าย โปรเซส โมเดล หรือ MCP หรือ fetch( หรือไม่ และการตรวจว่าการเรียกเอนจินทุกรายการในบรรทัด calls: ที่ claude plugin validate pdpa-thai แสดง เป็นการเรียกกลุ่ม state, ui, clock หรือ command เท่านั้น การตรวจทั้งสองไม่ได้พิสูจน์สิ่งใดเกี่ยวกับมอดอื่นหรือตัว Claude Code เอง รายละเอียดของการตรวจทั้งสองและสิ่งที่ไม่ครอบคลุมอธิบายไว้แห่งเดียวในหัวข้อ สิ่งที่ CI ตรวจ
  • บทสนทนาที่ปกปิดแล้วยังถูกส่งไปยังผู้ให้บริการโมเดลที่ตั้งค่าไว้ (โดยค่าเริ่มต้นคือ Anthropic หรือผู้ให้บริการอื่น เช่น Amazon Bedrock หรือ Google Cloud Vertex AI) เช่นเดียวกับบทสนทนา Claude Code ทั่วไป
  • มอดนี้ไม่ได้เปลี่ยนวิธีที่ผู้ให้บริการโมเดลที่ตั้งค่าไว้ (โดยค่าเริ่มต้นคือ Anthropic) จัดเก็บ เก็บรักษา หรือใช้ข้อมูลที่ได้รับ ซึ่งเป็นไปตามข้อตกลงและการตั้งค่าระหว่างผู้ใช้หรือองค์กรกับผู้ให้บริการนั้น
  • การปกปิดมีผลกับสำเนาที่โมเดลอ่าน ค่าที่ตรวจพบแต่ละจุดถูกแทนด้วยป้ายแทนค่าก่อนส่งให้โมเดล แต่ค่าเดียวกันอาจยังถูกส่งออกไปหากปรากฏในส่วนที่มอดไม่ได้แก้ไข (ดูหัวข้อ ข้อจำกัด) และข้อมูลที่กฎตรวจไม่พบจะถูกส่งไปตามเดิม
  • การปกปิดไม่ใช่การทำให้เป็นข้อมูลนิรนาม (anonymisation) บทสนทนาที่ปกปิดแล้วอาจยังระบุตัวบุคคลได้ทั้งทางตรงหรือทางอ้อม ซึ่งเป็นลักษณะของข้อมูลส่วนบุคคลตามนิยามในมาตรา 6 ของ PDPA (ฉบับ PDF ที่ PDPC เผยแพร่, ตรวจสอบเมื่อ 4 ตุลาคม 2569) จึงอาจยังเป็นข้อมูลส่วนบุคคล และประเด็น เช่น ฐานทางกฎหมายในการประมวลผลและการส่งหรือโอนข้อมูลไปยังต่างประเทศ อาจยังต้องพิจารณา ควรปรึกษาทนายความที่มีคุณสมบัติเหมาะสมหรือ DPO ขององค์กร
  • มอดนี้แก้ไขเฉพาะสิ่งที่ Claude Code ส่งให้โมเดล ช่องทางอื่นเป็นช่องทางแยกต่างหาก ไม่อยู่ในขอบเขตของมอด และอาจได้รับค่าเดิม ได้แก่ Remote Control (ซึ่งส่งต่อผ่านบริการของ Anthropic ไปยัง claude.ai หรือแอป Claude) hook แบบคำสั่ง (command hook) ในการตั้งค่าของผู้ใช้ ระบบรวบรวม telemetry ที่องค์กรตั้งค่าไว้ และรายงานที่ผู้ใช้ส่งให้ Anthropic

ข้อจำกัด

  • กฎทั้งหมดเป็นนิพจน์ปรกติที่ไม่รับประกันผล จะมีข้อมูลที่ตรวจไม่พบ และจะมีข้อความทั่วไปบางส่วนถูกปกปิด
  • มอดนี้ไม่ใช่เครื่องมือสแกนความลับ (secret scanner) ตรวจพบเฉพาะรูปแบบโทเค็นและป้ายกำกับที่ระบุไว้ใน DETECTION และข้ามค่าที่มีลักษณะเป็นโค้ด จึงไม่ควรนำความลับเข้ามาในเซสชัน และควรใช้เครื่องมือสแกนความลับโดยเฉพาะ
  • ค่าหลังป้ายกำกับ (SENSITIVE, ADDRESS, NAME) จะสิ้นสุดที่เครื่องหมายจุลภาค อัฒภาค เครื่องหมายคำพูด } หรือการขึ้นบรรทัดใหม่ที่พบก่อน ส่วนที่เหลือของที่อยู่หรือรายการจะถูกส่งไปตามที่เขียน ค่าหลังป้ายกำกับที่ขึ้นต้นด้วย [ หรือ { (อาร์เรย์หรืออ็อบเจกต์ JSON) จะไม่ตรงกับกฎกลุ่มนี้เลย มีเพียงกฎอื่น เช่น PHONE หรือ EMAIL ที่ยังอาจแทนค่าบางส่วนในนั้นได้
  • ชื่อและที่อยู่ที่ไม่มีป้ายกำกับหรือคำนำหน้าชื่อ เช่น ชื่อที่ปรากฏกลางประโยค จะตรวจไม่พบ
  • ไม่ตรวจเนื้อหาในรูปภาพและเอกสารที่แนบเป็นบล็อกแยก
  • Claude Code ไม่เปิดให้มอดแก้ไขเนื้อหาบางส่วน จึงไม่มีการปกปิดในส่วนนั้น ได้แก่ กระบวนการคิดของโมเดล (thinking) และค่าที่ Claude ส่งให้เครื่องมือ (tool_use input)
  • ประวัติการสนทนาที่เปิดต่อ (resume) และทรานสคริปต์ (transcript) ของ subagent ที่บันทึกไว้แล้ว มอดจะไม่ตรวจซ้ำ ส่วนที่ไม่ได้ปกปิดในขณะบันทึกจะถูกส่งไปตามเดิม
  • ผลลัพธ์ของเครื่องมือแบบมีโครงสร้างและเนื้อหาของไฟล์แนบ (เช่น ไฟล์ที่อ้างถึงด้วย @) ที่ Claude Code เก็บไว้ยังคงเป็นค่าเดิมในทรานสคริปต์ และการแสดงผลลัพธ์ของเครื่องมือบนหน้าจอไม่ถูกพราง
  • ประวัติพรอมต์ของ Claude Code เอง (ที่เรียกกลับด้วยปุ่มลูกศรขึ้น) อยู่นอกการควบคุมของมอด และอาจเก็บข้อความที่ผู้ใช้พิมพ์ไว้บนเครื่องโดยไม่ปกปิด
  • ไม่ตรวจพรอมต์ระบบ (system prompt) เช่น ข้อมูลสภาพแวดล้อม คำแนะนำของเซิร์ฟเวอร์ MCP และส่วนที่ปลั๊กอินอื่นเพิ่มเข้าไป รวมถึงคำอธิบายของเครื่องมือและ skill
  • ไม่มีการปกปิดใน 3 ส่วนต่อไปนี้ ได้แก่ คำตอบของ Claude ข้อความสรุปที่ได้จากการบีบอัดบทสนทนา (compaction) และข้อความแจ้งจากระบบ (notice)
  • ค่าที่ถูกแบ่งไว้ในหลายข้อความอาจหลุดการตรวจจับ
  • หาก hook ของมอดเกิดข้อผิดพลาด (throw) หรือใช้เวลาเกินที่ Claude Code กำหนดต่อ hook หนึ่งตัว Claude Code จะข้าม hook นั้นและส่งข้อความไปตามเดิมโดยไม่ปกปิดและไม่มีการแจ้งเตือน (fail-open)
  • หากมอดไม่ได้โหลด จะไม่มีการปกปิดใด ๆ และสัญญาณเดียวคือไม่มีรายการ PDPA: บนแถบสถานะ ให้ใช้ claude --debug เพื่อดูสาเหตุ ทั้งนี้การทำงานแบบ headless (เช่น claude -p) ไม่มีแถบสถานะ
  • API ของ hook ยังอยู่ในระยะทดลองใช้ (early access) มอดนี้ทดสอบแล้วกับ Claude Code 2.1.289 เท่านั้น เวอร์ชันก่อนหน้าหรือใหม่กว่าอาจข้ามการป้องกันบางส่วนหรือทั้งหมดโดยไม่มีการแจ้งเตือน และป้าย (badge) ของ CI แสดงเพียงว่าชุดทดสอบหน่วย (unit test) ผ่านบน Claude Code เวอร์ชันล่าสุด
  • hook ของปลั๊กอินอื่นที่ทำงานก่อนมอดนี้อาจเห็นข้อความต้นฉบับ
  • ผลลัพธ์ของเครื่องมือและข้อความอื่นในบทสนทนาอาจแสดงในรูปแบบเดิมชั่วครู่ก่อนถูกปกปิด ทั้งบนหน้าจอของผู้ใช้และปลายทางที่เซสชันถูกส่งต่อไปแสดง เช่น Remote Control (ซึ่งส่งต่อผ่านบริการของ Anthropic ไปยัง claude.ai หรือแอป Claude) หรือ SDK stream
  • มอดนี้แก้ไขเฉพาะสิ่งที่ Claude Code ส่งให้โมเดล ช่องทางอื่นเป็นช่องทางแยกต่างหาก ไม่อยู่ในขอบเขตของมอด และอาจได้รับค่าเดิม ได้แก่ Remote Control (ซึ่งส่งต่อผ่านบริการของ Anthropic ไปยัง claude.ai หรือแอป Claude) hook แบบคำสั่ง (command hook) ในการตั้งค่าของผู้ใช้ ระบบรวบรวม telemetry ที่องค์กรตั้งค่าไว้ และรายงานที่ผู้ใช้ส่งให้ Anthropic
  • การพรางบนหน้าจอเป็นเพียงการปิดบังด้วยสี การคัดลอก การเลือกข้อความ โปรแกรมอ่านหน้าจอ การค้นหาใน terminal และการบันทึกหรือ log ของ terminal (เช่น tmux หรือ asciinema) ยังได้ค่าจริง ส่วนวิดีโอบันทึกหน้าจอจะเห็นค่าจริงเมื่อวางเมาส์บนข้อความนั้น เว้นแต่เปิดโหมดบันทึกหน้าจอ (/pdpa-blur record) ไว้ ทั้งนี้โหมดบันทึกหน้าจอไม่ครอบคลุมช่องพรอมต์ก่อนกดส่ง ผลลัพธ์ของเครื่องมือหรือคำสั่ง และข้อความต้นฉบับที่แสดงอยู่ที่อื่น (ดูหัวข้อ การบันทึกหรือแชร์หน้าจอ) terminal ที่ปรับความต่างของสีอัตโนมัติ (เช่น terminal ใน VS Code ซึ่งเปิดการตั้งค่า minimum contrast ratio ไว้โดยค่าเริ่มต้น) อาจแสดงข้อความที่พรางไว้ให้อ่านได้ การพรางใช้ได้เฉพาะ terminal และแอปเดสก์ท็อป และไม่พรางข้อความที่ยาวเกิน 100,000 ตัวอักษร
  • การปฏิเสธการเรียกเครื่องมือจับเฉพาะป้ายแทนค่าของเซสชันนี้ที่เขียนตรงตามรูปแบบเดิม และไม่ทำงานในโหมด off
  • ข้อความแจ้งเตือนนับเฉพาะรายการที่ตรงกับกฎ การไม่มีข้อความแจ้งเตือนไม่ได้หมายความว่าไม่มีข้อมูลส่วนบุคคลถูกส่งออกไป และการมีข้อความแจ้งเตือนก็ไม่ได้หมายความว่าข้อความนั้นไม่มีข้อมูลส่วนบุคคลเหลืออยู่แล้ว
  • Claude เห็นเพียงป้ายแทนค่า จึงใช้ค่าจริงทำงานไม่ได้ และการปกปิดย้อนกลับไม่ได้ การปิดการป้องกันจึงไม่ได้คืนค่าที่ถูกปกปิดไปแล้ว หากงานต้องใช้ค่าจริง ให้ใช้ /pdpa-guard off แล้วส่งค่าจริงอีกครั้ง และเปิดกลับด้วย /pdpa-guard redact เมื่องานนั้นเสร็จ ระหว่างที่ปิดอยู่ มอดจะไม่ปฏิเสธการเรียกเครื่องมือที่มีป้ายแทนค่าเดิม จึงควรตรวจสิ่งที่ Claude เขียนลงไฟล์
  • ข้อมูลที่ส่งออกไประหว่างปิดการป้องกัน (off) จะคงอยู่ในบทสนทนาตามเดิม การเปิดการป้องกันกลับไม่ได้ปกปิดย้อนหลัง และข้อมูลนั้นจะถูกส่งซ้ำไปกับทุกคำขอถัดไปในบทสนทนาเดียวกัน หากต้องการตัดออก ให้ใช้ /clear หรือเริ่มเซสชันใหม่
  • CLAUDE.md, context block อื่น และไฟล์แนบ จะคงผลจากครั้งแรกที่ Claude Code เตรียมเนื้อหานั้นไว้ การเปลี่ยนโหมดภายหลังจึงไม่มีผลกับเนื้อหาที่เตรียมไว้แล้ว ทั้งนี้ context block จะถูกเตรียมใหม่หลังใช้ /clear หรือหลังการบีบอัดบทสนทนา
  • CLAUDE.md และ context block อื่นถูกปกปิดด้วย Claude จึงไม่เห็นค่าจริงที่ผู้ใช้ตั้งใจเขียนไว้ เช่น อีเมลของผู้ใช้ใน CLAUDE.md
  • ข้อมูลที่เปิดเผยต่อสาธารณะอยู่แล้ว เช่น หมายเลขโทรศัพท์ติดต่อของหน่วยงาน ก็ถูกปกปิดหากตรงกับรูปแบบ
  • มอดนี้ช่วยลดข้อมูลที่ถูกส่งออกไป แต่ไม่ได้ทำให้ Claude Code หยุดส่งบทสนทนา (ที่ปกปิดแล้ว) ไปยังผู้ให้บริการโมเดลที่ตั้งค่าไว้ (โดยค่าเริ่มต้นคือ Anthropic)
  • มอดทำงานแยกตามผู้ใช้และตามเครื่อง และผู้ใช้ปิดได้ทุกเมื่อ (/pdpa-guard off หรือ claude plugin disable) จึงบังคับใช้จากส่วนกลางไม่ได้ และมอดไม่เก็บบันทึกการ
Source 3 files
hooks/register.tsx 189 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { findSensitive, issuedTag, newTags, plan, redact, scrub } from './detect'
5
6// Every row the conversation keeps passes session.append first, so rewriting there keeps
7// detected values out of the request and the stored row. Request-only text (@file attachments,
8// memory files, context blocks) has its own hooks below. Best effort: regexes, not a DPO.
9// See README Limits for what this does not cover.
10const guardMode = atom({ plugin: 'pdpa-thai', key: 'guardMode' } as const, 'redact')
11const isBlurOn = atom({ plugin: 'pdpa-thai', key: 'isBlurOn' } as const, true)
12// recording mode: the mask stays on and hover no longer reveals it
13const isRecording = atom({ plugin: 'pdpa-thai', key: 'isRecording' } as const, false)
14const tagSuffix = atom({ plugin: 'pdpa-thai', key: 'tagSuffix' } as const, '')
15
16const MODES = ['redact', 'block', 'off'] as const
17type Mode = (typeof MODES)[number]
18
19const HELP: Record<Mode, string> = {
20  redact: 'redact: ปกปิดข้อมูลส่วนบุคคลก่อนส่งให้ Claude (ค่าเริ่มต้น)',
21  block: 'block: ไม่ส่งพรอมต์ที่มีข้อมูลส่วนบุคคล และปกปิดผลลัพธ์ของเครื่องมือ',
22  off: 'off: ปิดการป้องกัน',
23}
24
25// ponytail: a cell UI cannot blur pixels, so the real text is drawn grey-on-grey (width and
26// wrapping stay stable) and hover swaps in readable colours. Raw colours, not theme keys, so
27// it reads on light and dark. Selecting and copying still yields the real text.
28const BLUR = { color: '#6b7280', backgroundColor: '#6b7280' }
29const REVEAL = { color: '#ffffff', backgroundColor: '#b45309' }
30const DISPLAY_MAX = 100_000
31
32// one random-enough suffix per session, kept in state so a hot reload keeps it; it marks the
33// tags this session issued. update() keeps an existing value, so concurrent first calls agree.
34async function suffix($: EngineInterface) {
35  const fresh = (await $.clock.now()).toString(36).slice(-5)
36  await update($, tagSuffix, cur => cur || fresh)
37  return read($, tagSuffix)
38}
39
40async function showStatus($: EngineInterface) {
41  $.ui.status(`PDPA: ${await read($, guardMode)}${(await read($, isRecording)) ? ' · REC' : ''}`)
42}
43
44// the tree for a row holding personal data, or null to leave the engine's own drawing
45function draw($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], text: string, id: string, isLocked: boolean) {
46  if (text.length > DISPLAY_MAX) return null
47  const spans = findSensitive(text)
48  if (spans.length === 0) return null
49
50  const { Box, Markdown, Text } = $.ui.resolve(e)
51  // hover groups are shared across the whole surface, so the scope carries the row's id
52  const scope = id.slice(0, 40)
53
54  return (
55    <Box flexDirection="column">
56      {plan(text, spans).map((chunk, i) =>
57        'md' in chunk ? (
58          <Markdown key={`m${i}`} text={chunk.md} />
59        ) : (
60          <Box key={`l${i}`} flexWrap="wrap">
61            {chunk.pieces.length === 0 && <Text> </Text>}
62            {chunk.pieces.map((p, j) =>
63              p.hidden ? (
64                <Text key={`p${j}`} {...BLUR} {...(isLocked ? {} : { hover: { scope: `${scope}:${i}-${j}`, ...REVEAL } })}>
65                  {p.text}
66                </Text>
67              ) : (
68                <Text key={`p${j}`}>{p.text}</Text>
69              ),
70            )}
71          </Box>
72        ),
73      )}
74    </Box>
75  )
76}
77
78export const register: Register = on => {
79  on('session.start', async ($, e, next) => {
80    await $.command.register({
81      name: 'pdpa-guard',
82      description: 'PDPA guard: redact | block | off (no argument shows the current mode)',
83    })
84    await $.command.register({
85      name: 'pdpa-blur',
86      description: 'PDPA blur in the transcript: on | off | record (record = no hover reveal, for screen recording)',
87    })
88    await showStatus($)
89    return next(e)
90  })
91
92  on('command.run', { command: 'pdpa-guard' }, async ($, e) => {
93    const arg = e.args.trim().toLowerCase()
94    const next = MODES.find(m => m === arg)
95    if (next) {
96      await update($, guardMode, () => next)
97      await showStatus($)
98      return { text: `โหมดป้องกัน PDPA → ${HELP[next]}` }
99    }
100    const mode = (await read($, guardMode)) as Mode
101    return { text: `โหมดปัจจุบัน ${HELP[mode]}\nเปลี่ยนด้วย /pdpa-guard ${MODES.join(' | ')}` }
102  })
103
104  // the prompt first, so even the queue record of what you typed is clean
105  on('prompt.submit', async ($, e, next) => {
106    const mode = await read($, guardMode)
107    if (mode === 'off') return next(e)
108    const { text, found } = redact(e.text, newTags(await suffix($)))
109    if (found === 0) return next(e)
110    if (mode === 'block') {
111      $.ui.toast(`PDPA: พบข้อมูลส่วนบุคคล ${found} รายการ ไม่ส่งพรอมต์นี้ให้ Claude`)
112      return { drop: 'PDPA guard blocked this prompt: it contains personal data. Edit it, or run /pdpa-guard redact | off.' }
113    }
114    $.ui.toast(`PDPA: ปกปิดข้อมูลส่วนบุคคล ${found} รายการก่อนส่งให้ Claude`)
115    return next({ ...e, text })
116  })
117
118  // everything the conversation keeps: tool results, hook context, deliveries. Not the model's
119  // own responses or compaction summaries (written by the model from text already redacted, so
120  // nothing new reaches it) and not notices (the model never reads them).
121  on('session.append', async ($, e, next) => {
122    if (e.door === 'response' || e.door === 'compaction' || e.door === 'notice') return next(e)
123    if ((await read($, guardMode)) === 'off') return next(e)
124    const hits = { n: 0 }
125    const content = scrub(e.message.content, hits, newTags(await suffix($))) as typeof e.message.content
126    if (hits.n === 0) return next(e)
127    $.ui.toast(`PDPA: ปกปิดข้อมูลส่วนบุคคล ${hits.n} รายการก่อนส่งให้ Claude`)
128    return next({ ...e, message: { ...e.message, content } })
129  })
130
131  // text that only rides a request and never becomes a row: @file mentions, edited files,
132  // memory (CLAUDE.md) and the other context blocks
133  on('prompt.attachment', async ($, e, next) => {
134    if ((await read($, guardMode)) === 'off') return next(e)
135    const { text, found } = redact(e.text, newTags(await suffix($)))
136    return found === 0 ? next(e) : next({ ...e, text })
137  })
138
139  on('prompt.context', async ($, e, next) => {
140    if ((await read($, guardMode)) === 'off') return next(e)
141    const tags = newTags(await suffix($))
142    return next({ ...e, blocks: e.blocks.map(b => ({ ...b, text: redact(b.text, tags).text })) })
143  })
144
145  // the model only ever sees tags for personal data; using one of this session's tags as a real
146  // value in a call would corrupt the user's work, so refuse and say why
147  on('tool.call', async ($, e, next) => {
148    if ((await read($, guardMode)) === 'off') return next(e)
149    return issuedTag(await suffix($)).test(JSON.stringify(e))
150      ? { deny: 'PDPA guard hides personal data from you behind placeholder tags. Do not use a tag as a real value; ask the user for it, or have them run /pdpa-guard off.' }
151      : next(e)
152  })
153
154  on('command.run', { command: 'pdpa-blur' }, async ($, e) => {
155    const arg = e.args.trim().toLowerCase()
156    const wasShown = (await read($, isBlurOn)) || (await read($, isRecording))
157    const want = arg === 'on' || arg === 'off' || arg === 'record' ? arg : arg === '' ? (wasShown ? 'off' : 'on') : null
158    if (want === null) return { text: 'Usage: /pdpa-blur [on | off | record]  (no argument toggles on/off)' }
159    await update($, isBlurOn, () => want !== 'off')
160    await update($, isRecording, () => want === 'record')
161    await showStatus($)
162    return {
163      text: {
164        on: 'PDPA blur on. Hover a grey block to reveal it.',
165        off: 'PDPA blur off.',
166        record:
167          'PDPA blur: recording mode. Matches in messages stay masked and hover does not reveal them. ' +
168          'Not covered: the prompt box while you type, tool output, command output. Turn off with /pdpa-blur off.',
169      }[want],
170    }
171  })
172
173  // read the toggle first so every row subscribes and redraws when it flips; no hover exists on
174  // the other surfaces, and a block that cannot be revealed is worse than none
175  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
176    const isActive = await read($, isBlurOn)
177    const isLocked = await read($, isRecording)
178    if (!isActive || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
179    return draw($, e, e.props.text, e.requestId, isLocked) ?? next(e)
180  })
181
182  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
183    const isActive = await read($, isBlurOn)
184    const isLocked = await read($, isRecording)
185    if (!isActive || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
186    return draw($, e, e.props.text, e.requestId, isLocked) ?? next(e)
187  })
188}
189
hooks/detect.ts 285 lines
1// What counts as sensitive follows Thailand's PDPA B.E. 2562 (checked 2026-10-04):
2//   s.6  personal data: anything identifying a natural person directly or indirectly
3//   s.26 sensitive data: ethnicity, race, political opinion, creed/religion/philosophy, sexual
4//        behaviour, criminal record, health, disability, trade union, genetic, biometric
5// https://www.pdpc.or.th/wp-content/uploads/2023/12/1_Personal-Data-Protection-2562.pdf
6// A regex cannot read s.26 from prose, so those are caught as `label: value` only. Best effort,
7// not legal advice. Every scan here is linear in the input: this runs on every tool result.
8
9export type Kind =
10  | 'THAI_ID' | 'CARD' | 'PHONE' | 'EMAIL' | 'IP' | 'SECRET' | 'PASSPORT'
11  | 'BANK_ACCOUNT' | 'DOB' | 'SENSITIVE' | 'ADDRESS' | 'NAME'
12export type Span = [start: number, end: number, kind: Kind]
13export type Piece = { text: string; hidden: boolean }
14export type Chunk = { md: string } | { pieces: Piece[] }
15
16type Hit = [number, number]
17type Rule = { kind: Kind; find: (s: string) => Iterable<Hit> }
18
19// Rules run on a shadow of the text with the same length: Thai digits become Arabic and exotic
20// horizontal spaces become ' ', so offsets still index the original.
21const norm = (t: string) =>
22  t
23    .replace(/[๐-๙]/g, c => String.fromCharCode(c.charCodeAt(0) - 0x0e50 + 48))
24    .replace(/[   -    \t]/g, ' ')
25
26const digits = (s: string) => s.replace(/\D/g, '')
27
28// mod-11 check digit of the 13-digit Thai national ID
29const thaiId = (s: string) => {
30  const d = digits(s)
31  if (d.length !== 13) return false
32  let sum = 0
33  for (let i = 0; i < 12; i++) sum += Number(d[i]) * (13 - i)
34  return (11 - (sum % 11)) % 10 === Number(d[12])
35}
36
37const luhn = (d: string) => {
38  let sum = 0
39  for (let i = 0; i < d.length; i++) {
40    let n = Number(d[d.length - 1 - i])
41    if (i % 2) n = n * 2 > 9 ? n * 2 - 9 : n * 2
42    sum += n
43  }
44  return sum % 10 === 0
45}
46
47// +66 / 0066 / trunk 0, then 8 digits (landline: 2 Bangkok, 3-7 provinces) or 9 (mobile 6/8/9)
48const thaiPhone = (s: string) => {
49  const d = digits(s)
50  const n = d.startsWith('0066') ? d.slice(4) : s.startsWith('+') && d.startsWith('66') ? d.slice(2) : d.startsWith('0') ? d.slice(1) : ''
51  return (n.length === 8 && /^[2-7]/.test(n)) || (n.length === 9 && /^[689]/.test(n))
52}
53
54const ipv4 = (s: string) => {
55  const p = s.split('.').map(Number)
56  return p.every(n => n <= 255) && p[0] !== 127 && s !== '0.0.0.0'
57}
58
59// a value that is code, not a secret: a type name, a dotted path, a call, a template, a shell var
60const CODE_VALUE =
61  /^(?:string|number|boolean|any|unknown|undefined|null|true|false|void|never|object|str|int|bool|None|Optional.*|Record.*|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+(?:[(\[].*)?|[A-Za-z_$][\w$]*\(.*|\$\{.*|\$[A-Za-z_]\w*|\{\{.*|<.*>|%\(.*|\*+|\.{3})$/
62
63function re(kind: Kind, regex: RegExp, o: { groups?: number[]; ok?: (v: string) => boolean } = {}): Rule {
64  return {
65    kind,
66    *find(s) {
67      for (const m of s.matchAll(regex)) {
68        const at = (o.groups ?? [0]).map(g => m.indices?.[g]).find(Boolean)
69        if (at && at[1] > at[0] && (!o.ok || o.ok(s.slice(at[0], at[1])))) yield [at[0], at[1]]
70      }
71    },
72  }
73}
74
75// the longest run of 13-19 digits (groups split by space or -) that passes Luhn, cut at a group
76// boundary so a trailing stray digit cannot hide a real card
77function* cards(s: string): Iterable<Hit> {
78  for (const m of s.matchAll(/(?<!\d)\d(?:[ -]?\d){12,21}(?!\d)/g)) {
79    const base = m.index
80    const text = m[0]
81    const starts: number[] = [0]
82    const ends: number[] = []
83    for (let i = 0; i < text.length; i++) {
84      if (!/\d/.test(text[i])) starts.push(i + 1)
85      else if (i + 1 === text.length || !/\d/.test(text[i + 1])) ends.push(i + 1)
86    }
87    let best: Hit | null = null
88    for (const a of starts) {
89      for (const b of ends) {
90        const d = digits(text.slice(a, b))
91        if (b > a && d.length >= 13 && d.length <= 19 && luhn(d) && (!best || b - a > best[1] - best[0])) best = [a, b]
92      }
93    }
94    if (best) yield [base + best[0], base + best[1]]
95  }
96}
97
98// a linear scan outward from each '@', so no input makes it quadratic
99function* emails(s: string): Iterable<Hit> {
100  const local = /[A-Za-z0-9._%+-]/
101  const host = /[A-Za-z0-9.-]/
102  for (let at = s.indexOf('@'); at !== -1; at = s.indexOf('@', at + 1)) {
103    let a = at
104    while (a > 0 && at - a < 64 && local.test(s[a - 1])) a--
105    let b = at + 1
106    while (b < s.length && b - at < 255 && host.test(s[b])) b++
107    while (b > at + 1 && /[.-]/.test(s[b - 1])) b--
108    const dom = s.slice(at + 1, b)
109    if (a < at && /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/.test(dom) && !/^example\.(?:com|org|net)$/i.test(dom)) yield [a, b]
110  }
111}
112
113const COLON = `["']?[ \\t]*[::=][ \\t]*["']?`
114// the value of `label: value`: starts at a non-blank (keeps scanning linear), runs to a delimiter,
115// trailing blanks excluded; a value opening with [ or { (an array or object) is not matched
116const VALUE = `([^\\s,;"'}\\[{](?:[^\\n,;"'}]*[^\\s,;"'}])?)`
117const labelled = (kind: Kind, labels: string) =>
118  re(kind, new RegExp(`(?:${labels})${COLON}${VALUE}`, 'gdi'), { groups: [1] })
119
120const SENSITIVE_LABEL =
121  'เชื้อชาติ|เผ่าพันธุ์|ศาสนา|ความเชื่อ|ลัทธิ|ความคิดเห็นทางการเมือง|พฤติกรรมทางเพศ|ประวัติอาชญากรรม|โรคประจำตัว|ประวัติการรักษา|ข้อมูลสุขภาพ|ความพิการ|สหภาพแรงงาน|ข้อมูลพันธุกรรม|ข้อมูลชีวภาพ|' +
122  'race|ethnicity|religion|political(?: opinion)?|sexual (?:orientation|behaviou?r)|criminal (?:record|history)|medical history|diagnosis|health (?:condition|data)|disability|trade union|genetic(?: data)?|biometric(?: data)?'
123
124// นาย/นาง/นางสาว/ด.ช./ด.ญ. + name, minus the compounds that merely start with a title
125const NOT_A_NAME = 'จ้าง|หน้า|ก(?:รัฐ|เทศ|สมาคม|ฯ)|ทะเบียน|ธนาคาร|ช่าง|อำเภอ|แพทย์|ตำรวจ|พยาบาล|งาม|ฟ้า|ท้าย|ท่า|เหมือง|ห้าง|ประกัน'
126
127const RULES: Rule[] = [
128  re('THAI_ID', /(?<!\d)\d[ -]?\d{4}[ -]?\d{5}[ -]?\d{2}[ -]?\d(?!\d)/gd, { ok: thaiId }),
129  { kind: 'CARD', find: cards },
130  re('PHONE', /(?<!\d)(?:\+66|0066|0)[ -]?\d(?:[ -]?\d){7,8}(?!\d)/gd, { ok: thaiPhone }),
131  { kind: 'EMAIL', find: emails },
132  re('IP', /(?<![\d.vV])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])/gd, { ok: ipv4 }),
133  // credentials and tokens
134  re('SECRET', /\b(?:sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/gd),
135  re('SECRET', /\bBearer[ \t]+([A-Za-z0-9._~+/=-]{20,})/gdi, { groups: [1] }),
136  re(
137    'SECRET',
138    /(?:password|passwd|pwd|passphrase|secret|token|api[_-]?key|access[_-]?key|รหัสผ่าน)["']?[ \t]*[:=][ \t]*(?:"((?:[^"\\\n]|\\.)*)"|'((?:[^'\\\n]|\\.)*)'|([^\s"',;]+))/gdi,
139    { groups: [1, 2, 3], ok: v => !CODE_VALUE.test(v) },
140  ),
141  // identifiers that only make sense next to their label
142  re('PASSPORT', /(?:passport|เลขที่หนังสือเดินทาง|เลขพาสปอร์ต|พาสปอร์ต)\D{0,20}?([A-Z]{1,2}\d{6,8})\b/gdi, { groups: [1] }),
143  re('BANK_ACCOUNT', /(?:เลขที่บัญชี|หมายเลขบัญชี|บัญชี|account[ \t]*(?:no\.?|number|#)|acct\.?|พร้อมเพย์|promptpay)\D{0,15}((?:\d[ -]?){9,14}\d)/gdi, { groups: [1] }),
144  re('DOB', /(?:วันเกิด|date of birth|birth[ ]?date|dob)["']?[ \t]*(?:[::][ \t]*)?["']?(\d{1,4}[/.-]\d{1,2}[/.-]\d{1,4})/gdi, { groups: [1] }),
145  // s.26 sensitive categories, and name/address, as `label: value`
146  labelled('SENSITIVE', SENSITIVE_LABEL),
147  labelled('ADDRESS', 'ที่อยู่|home address|mailing address'),
148  labelled('NAME', 'ชื่อ-?นามสกุล|ชื่อ-สกุล|ชื่อจริง|นามสกุล|ชื่อ|full[ _]?name|first[ _]?name|last[ _]?name|surname'),
149  // titled names
150  re('NAME', new RegExp(`(?<![ก-๙])(?:นางสาว|นาง|นาย|น\\.ส\\.|ด\\.ช\\.|ด\\.ญ\\.)(?!${NOT_A_NAME})[ \\t]*[ก-ฮเแโใไ][ก-๙]{1,30}(?:[ \\t]+[ก-ฮเแโใไ][ก-๙]{1,30})?`, 'gd')),
151  re('NAME', /\b(?:Mr|Mrs|Ms|Miss|Dr)\.?[ \t]+[A-Z][a-z]+(?:[ \t]+[A-Z][a-z]+)?/gd),
152]
153
154// The tag that replaces a value: kind, a number, and a per-session suffix. The suffix makes a tag
155// this session issued recognisable, so only a real tag is refused in a tool call, not a document
156// or test that merely mentions the format.
157const TAG = 'RE' + 'DACTED'
158const PLACEHOLDER = new RegExp(`\\[${TAG}:([A-Z_]+)_(\\d+)(?:~[a-z0-9]+)?\\]`, 'g')
159
160export const issuedTag = (sfx: string) => new RegExp(`\\[${TAG}:[A-Z_]+_\\d+~${sfx}\\]`)
161
162export function findSensitive(text: string): Span[] {
163  const s = norm(text)
164  const found: Span[] = []
165  for (const rule of RULES) for (const [a, b] of rule.find(s)) found.push([a, b, rule.kind])
166  found.sort((x, y) => x[0] - y[0] || y[1] - x[1])
167
168  // a tag from an earlier pass is never re-detected, so redaction is idempotent
169  const held = [...s.matchAll(PLACEHOLDER)].map(m => [m.index, m.index + m[0].length])
170  const free = held.length ? found.filter(([a, b]) => !held.some(([ha, hb]) => a < hb && b > ha)) : found
171
172  const merged: Span[] = []
173  for (const sp of free) {
174    const last = merged[merged.length - 1]
175    if (last && sp[0] < last[1]) last[1] = Math.max(last[1], sp[1])
176    else merged.push([sp[0], sp[1], sp[2]])
177  }
178  return merged
179}
180
181export type Tags = { sfx: string; seen: Map<string, number>; counts: Partial<Record<Kind, number>> }
182export const newTags = (sfx = ''): Tags => ({ sfx, seen: new Map(), counts: {} })
183
184// Replaces each span with a tag the model can still refer to (PHONE_1); the same value gets the
185// same tag across everything sharing one `tags`. Irreversible on purpose: the mod keeps no
186// mapping back to the original value. Numbers already present in the text are reserved, never reused.
187export function redact(text: string, tags: Tags = newTags()): { text: string; found: number } {
188  const spans = findSensitive(text)
189  if (spans.length === 0) return { text, found: 0 }
190  const s = norm(text)
191  for (const m of s.matchAll(PLACEHOLDER)) {
192    const kind = m[1] as Kind
193    tags.counts[kind] = Math.max(tags.counts[kind] ?? 0, Number(m[2]))
194  }
195  let out = ''
196  let at = 0
197  for (const [a, b, kind] of spans) {
198    const key = `${kind}:${s.slice(a, b)}`
199    let n = tags.seen.get(key)
200    if (!n) {
201      n = tags.counts[kind] = (tags.counts[kind] ?? 0) + 1
202      tags.seen.set(key, n)
203    }
204    out += `${text.slice(at, a)}[${TAG}:${kind}_${n}${tags.sfx ? `~${tags.sfx}` : ''}]`
205    at = b
206  }
207  return { text: out + text.slice(at), found: spans.length }
208}
209
210function pieces(text: string, ls: number, le: number, spans: Span[]): Piece[] {
211  const out: Piece[] = []
212  let at = ls
213  for (const [s, e] of spans) {
214    if (e <= ls || s >= le) continue
215    const a = Math.max(s, ls)
216    const b = Math.min(e, le)
217    if (a > at) out.push({ text: text.slice(at, a), hidden: false })
218    out.push({ text: text.slice(a, b), hidden: true })
219    at = b
220  }
221  if (at < le) out.push({ text: text.slice(at, le), hidden: false })
222  return out
223}
224
225// Clean stretches stay Markdown; a line holding a span (or a whole code fence holding one) is
226// drawn as plain pieces so each hidden span can carry its own hover. Fences are never split.
227export function plan(text: string, spans: Span[]): Chunk[] {
228  const units: { s: number; e: number; lines: [number, number][] }[] = []
229  let at = 0
230  let fence: (typeof units)[number] | null = null
231  for (const line of text.split('\n')) {
232    const span: [number, number] = [at, at + line.length]
233    at += line.length + 1
234    const isFence = /^\s*(```|~~~)/.test(line)
235    if (fence) {
236      fence.lines.push(span)
237      fence.e = span[1]
238      if (isFence) fence = null
239    } else if (isFence) {
240      fence = { s: span[0], e: span[1], lines: [span] }
241      units.push(fence)
242    } else units.push({ s: span[0], e: span[1], lines: [span] })
243  }
244
245  const out: Chunk[] = []
246  let cleanFrom: number | null = null
247  let cleanTo = 0
248  const flush = () => {
249    if (cleanFrom !== null) out.push({ md: text.slice(cleanFrom, cleanTo) })
250    cleanFrom = null
251  }
252  for (const u of units) {
253    if (spans.some(([s, e]) => s < u.e && e > u.s)) {
254      flush()
255      for (const [ls, le] of u.lines) out.push({ pieces: pieces(text, ls, le, spans) })
256    } else {
257      cleanFrom ??= u.s
258      cleanTo = u.e
259    }
260  }
261  flush()
262  return out
263}
264
265export type Hits = { n: number }
266
267// a row's content is a string or blocks; only text and tool_result text are rewritten. One tag
268// table per row, so a value repeated across blocks keeps one tag and numbers never collide.
269export function scrub(content: unknown, hits: Hits, tags: Tags = newTags()): unknown {
270  if (typeof content === 'string') {
271    const r = redact(content, tags)
272    hits.n += r.found
273    return r.text
274  }
275  if (!Array.isArray(content)) return content
276  return content.map(block => {
277    if (block?.type === 'text' && typeof block.text === 'string') {
278      const r = redact(block.text, tags)
279      hits.n += r.found
280      return { ...block, text: r.text }
281    }
282    return block?.type === 'tool_result' ? { ...block, content: scrub(block.content, hits, tags) } : block
283  })
284}
285
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'pdpa-thai': { isBlurOn: boolean; isRecording: boolean; guardMode: 'redact' | 'block' | 'off'; tagSuffix: string }
4  }
5}
6