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

มอด (mod) สำหรับ Claude Code: ตัวช่วยลดความเสี่ยงในการส่งข้อมูลส่วนบุคคลให้โมเดล (pdpa-thai) แถบแสดงการใช้ context (context-bar) แผงรายการ agent (agents-panel) และเวิร์กโฟลว์ multi-agent (boom-big-nose-workflow)
ไทย · 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-thai | 0.3.0 | `/pdpa-guard [redact\ | block\ | off], /pdpa-blur [on\ | off\ | record]` | ปกปิดข้อมูลส่วนบุคคลที่ตรวจพบก่อนส่งให้ Claude และพรางข้อมูลบนหน้าจอ (วางเมาส์เพื่อดู หรือวางเมาส์แล้วไม่แสดงค่าจริงในโหมดบันทึกหน้าจอ) |
context-bar | 0.4.0 | /context-bar | แถบเหนือช่องพิมพ์ แสดงการใช้ context window แยกตามหมวด และเวลาที่เหลือของ prompt cache | ||||
agents-panel | 0.1.0 | /agents-panel | แผงด้านข้างแสดง agent ของโปรเจกต์ ของผู้ใช้ และของปลั๊กอิน พร้อมปุ่มเรียกใช้ | ||||
boom-big-nose-workflow | 0.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
@ ไฟล์ CLAUDE.md และ context block อื่นมอดแก้ไขเฉพาะบล็อกข้อความ (text block) และข้อความในผลลัพธ์ของเครื่องมือ บล็อกชนิดอื่น เช่น รูปภาพหรือเอกสาร ถูกส่งไปตามเดิม
off หากการเรียกเครื่องมือของ Claude มีป้ายแทนค่าที่มอดสร้างขึ้นในเซสชัน (session) นี้ตรงตามรูปแบบเดิม มอดจะปฏิเสธการเรียกนั้นและแจ้งให้ Claude ขอค่าจริงจากผู้ใช้ ซึ่งช่วยลดโอกาสที่ป้ายแทนค่าจะถูกเขียนลงไฟล์หรือคำสั่งจริง การตรวจนี้จับไม่ได้หาก 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) ข้อจำกัดอื่นของการพรางในหัวข้อ ข้อจำกัด ยังคงมีผล
ก่อนบันทึกหรือแชร์หน้าจอ
redact หรือ block (/pdpa-guard redact)/pdpa-blur record และตรวจว่าแถบสถานะลงท้ายด้วย · RECการพรางนี้เป็นเพียงการปิดบังทางสายตา ไม่ใช่หลักประกัน
ข้อความที่ผู้ใช้พิมพ์ (ค่าจริงแสดงเป็นคำอธิบายในวงเล็บมุม):
ช่วยร่างอีเมลแจ้งลูกค้า เบอร์ <เบอร์มือถือ 10 หลัก> อีเมล <อีเมลของลูกค้า>
ข้อความที่ Claude ได้รับในโหมด redact:
ช่วยร่างอีเมลแจ้งลูกค้า เบอร์ [REDACTED:PHONE_1~k3x9q] อีเมล [REDACTED:EMAIL_1~k3x9q]
รูปแบบของป้ายแทนค่าคือ [REDACTED:<ชนิด>_<ลำดับ>~<ส่วนต่อท้ายประจำเซสชัน>]
PHONE_1 ในพรอมต์กับ PHONE_1 ในไฟล์ที่อ้างถึงด้วย @ หรือในผลลัพธ์ของเครื่องมือครั้งถัดไป อาจไม่ใช่หมายเลขเดียวกัน| ชนิด | สิ่งที่ตรวจ |
|---|---|
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
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 ส่งไปยังเซิร์ฟเวอร์ที่เชื่อมต่ออยู่จะไปถึงบริการเหล่านั้นfetch( หรือไม่ และการตรวจว่าการเรียกเอนจินทุกรายการในบรรทัด calls: ที่ claude plugin validate pdpa-thai แสดง เป็นการเรียกกลุ่ม state, ui, clock หรือ command เท่านั้น การตรวจทั้งสองไม่ได้พิสูจน์สิ่งใดเกี่ยวกับมอดอื่นหรือตัว Claude Code เอง รายละเอียดของการตรวจทั้งสองและสิ่งที่ไม่ครอบคลุมอธิบายไว้แห่งเดียวในหัวข้อ สิ่งที่ CI ตรวจSENSITIVE, ADDRESS, NAME) จะสิ้นสุดที่เครื่องหมายจุลภาค อัฒภาค เครื่องหมายคำพูด } หรือการขึ้นบรรทัดใหม่ที่พบก่อน ส่วนที่เหลือของที่อยู่หรือรายการจะถูกส่งไปตามที่เขียน ค่าหลังป้ายกำกับที่ขึ้นต้นด้วย [ หรือ { (อาร์เรย์หรืออ็อบเจกต์ JSON) จะไม่ตรงกับกฎกลุ่มนี้เลย มีเพียงกฎอื่น เช่น PHONE หรือ EMAIL ที่ยังอาจแทนค่าบางส่วนในนั้นได้@) ที่ Claude Code เก็บไว้ยังคงเป็นค่าเดิมในทรานสคริปต์ และการแสดงผลลัพธ์ของเครื่องมือบนหน้าจอไม่ถูกพรางPDPA: บนแถบสถานะ ให้ใช้ claude --debug เพื่อดูสาเหตุ ทั้งนี้การทำงานแบบ headless (เช่น claude -p) ไม่มีแถบสถานะ/pdpa-blur record) ไว้ ทั้งนี้โหมดบันทึกหน้าจอไม่ครอบคลุมช่องพรอมต์ก่อนกดส่ง ผลลัพธ์ของเครื่องมือหรือคำสั่ง และข้อความต้นฉบับที่แสดงอยู่ที่อื่น (ดูหัวข้อ การบันทึกหรือแชร์หน้าจอ) terminal ที่ปรับความต่างของสีอัตโนมัติ (เช่น terminal ใน VS Code ซึ่งเปิดการตั้งค่า minimum contrast ratio ไว้โดยค่าเริ่มต้น) อาจแสดงข้อความที่พรางไว้ให้อ่านได้ การพรางใช้ได้เฉพาะ terminal และแอปเดสก์ท็อป และไม่พรางข้อความที่ยาวเกิน 100,000 ตัวอักษรoff/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/pdpa-guard off หรือ claude plugin disable) จึงบังคับใช้จากส่วนกลางไม่ได้ และมอดไม่เก็บบันทึกการhooks/register.tsx 189 lines1import { 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}
189hooks/detect.ts 285 lines1// 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}
285types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'pdpa-thai': { isBlurOn: boolean; isRecording: boolean; guardMode: 'redact' | 'block' | 'off'; tagSuffix: string }
4 }
5}
6