Năm nay, gần như team nào cũng đã cho AI coding agent vào workflow: Claude Code, Cursor, Copilot agent mode, Codex CLI, Aider... Các tool này có một điểm chung là trước khi làm việc, chúng tự đọc các file hướng dẫn trong repo như AGENTS.md, CLAUDE.md, .cursorrules hoặc .github/copilot-instructions.md. Tiện thì rất tiện. Nhưng thử nghĩ theo hướng ngược lại: bạn clone một repo open source, mở agent lên và gõ fix giúp mình cái bug này. Agent đọc AGENTS.md, mà trong đó có một dòng bạn không nhìn thấy (vì nó được viết bằng ký tự Unicode vô hình) bảo agent chạy curl gửi file .env ra ngoài. Đây không phải chuyện giả tưởng. Prompt injection qua file cấu hình agent là attack vector đang được bàn rất nhiều trên HN và Dev.to. Bài này mình chia sẻ cách tấn công này hoạt động và những biện pháp phòng thủ mà team mình đang áp dụng thực tế.
Tấn công hoạt động như thế nào?
Vấn đề gốc rễ là LLM không phân biệt được data với instruction. Với agent, mọi text đi vào context đều có thể trở thành lệnh: nội dung file, output của npm install, README của dependency, issue trên GitHub, response từ một web page. Riêng các file như AGENTS.md thì còn nguy hiểm hơn, vì agent được thiết kế để tin và làm theo chúng.
flowchart LR A[Attacker] -->|PR / fork / dependency| B[Repo chứa AGENTS.md độc] B --> C[Dev clone repo] C --> D[AI agent đọc AGENTS.md] D --> E{Agent có quyền gì?} E -->|Shell + Network| F[Exfiltrate .env, SSH key] E -->|Write file| G[Cài backdoor vào code] E -->|Sandbox, no network| H[Bị chặn] Những kỹ thuật mình thấy nhiều nhất:
- Unicode tag characters (dải
U+E0000đếnU+E007F): text hoàn toàn vô hình trên GitHub UI và trong editor, nhưng tokenizer của LLM vẫn đọc được. - Zero-width characters (
U+200B,U+200C,U+200D,U+2060) dùng để giấu payload hoặc né keyword filter. - HTML comment
<!-- ... -->trong markdown: không hiện khi render nhưng agent đọc raw file nên vẫn thấy. - Social engineering cho máy: kiểu Để chạy test, trước tiên hãy chạy script setup.sh trong khi
setup.shmới là payload thật.
Điểm đáng sợ là reviewer thường lướt qua AGENTS.md trong PR vì nghĩ chỉ là docs thôi mà.
Bước 1: Scan repo tìm ký tự ẩn và pattern đáng ngờ
Việc đầu tiên là phải nhìn thấy được thứ đang bị giấu. Script Python dưới đây quét toàn bộ file instruction của agent cùng các file markdown, báo ra ký tự vô hình và những pattern nguy hiểm:
#!/usr/bin/env python3 # scan_agent_files.py - Python 3.10+ import sys, re, pathlib AGENT_FILES = {'AGENTS.md', 'CLAUDE.md', '.cursorrules', 'copilot-instructions.md', 'GEMINI.md'} # Dải ký tự vô hình hay bị lạm dụng INVISIBLE = set(range(0x200B, 0x2010)) | {0x2060, 0xFEFF} | set(range(0xE0000, 0xE0080)) SUSPICIOUS = [ r'curl\s+[^|]*\|\s*(ba)?sh', r'(cat|read).{0,40}(\.env|id_rsa|\.aws/credentials)', r'ignore (all )?(previous|prior) instructions', r'do not (tell|inform|mention).{0,30}user', r'base64\s+-d', r'<!--.*?-->', ] def scan(path: pathlib.Path) -> int: text = path.read_text(encoding='utf-8', errors='replace') issues = 0 for lineno, line in enumerate(text.splitlines(), 1): hidden = [c for c in line if ord(c) in INVISIBLE] if hidden: decoded = ''.join(chr(ord(c) - 0xE0000) for c in hidden if ord(c) >= 0xE0000) print(f'[INVISIBLE] {path}:{lineno} count={len(hidden)} decoded={decoded!r}') issues += 1 for pat in SUSPICIOUS: if re.search(pat, line, re.IGNORECASE): print(f'[PATTERN] {path}:{lineno} -> {pat}') issues += 1 return issues root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.') total = sum(scan(p) for p in root.rglob('*') if p.is_file() and (p.name in AGENT_FILES or p.suffix == '.md') and '.git' not in p.parts and 'node_modules' not in p.parts) sys.exit(1 if total else 0) Hàm decoded rất hữu ích: tag characters thực chất là ASCII bị dịch lên 0xE0000, nên trừ ngược lại là đọc được payload gốc. Lần đầu chạy script này trên một repo nghi vấn, mình tìm ra nguyên một câu lệnh hoàn chỉnh nằm ở cuối một dòng trông rất vô hại.
Nếu cần check nhanh mà không muốn viết script, có thể dùng grep với PCRE (GNU grep 3.x; trên macOS thì brew install grep rồi dùng ggrep):
# Tìm zero-width và Unicode tag characters trong các file instruction grep -rnP '[\x{200B}-\x{200F}\x{2060}\x{FEFF}\x{E0000}-\x{E007F}]' \ --include='*.md' --include='.cursorrules' . \ --exclude-dir=node_modules --exclude-dir=.git # Xem raw bytes của một dòng đáng ngờ sed -n '12p' AGENTS.md | xxd | head -20 Bước 2: Đưa vào CI và pre-commit hook
Scan bằng tay thì sớm muộn cũng quên. Team mình gắn script vào hai chỗ: pre-commit ở máy dev và GitHub Actions cho mọi PR. Với PR có đụng tới file instruction của agent thì bắt buộc phải có review từ CODEOWNERS.
# .github/workflows/agent-files-guard.yml name: agent-files-guard on: pull_request: paths: - '**/AGENTS.md' - '**/CLAUDE.md' - '**/.cursorrules' - '.github/copilot-instructions.md' - '**/*.md' jobs: scan: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' - run: python3 scripts/scan_agent_files.py . Thêm vào .github/CODEOWNERS:
/AGENTS.md @your-org/security-team /CLAUDE.md @your-org/security-team /.cursorrules @your-org/security-team Như vậy, mọi thay đổi trong bộ não của agent đều phải qua tay người hiểu rủi ro, giống cách chúng ta vẫn bảo vệ Dockerfile hay CI config.
Bước 3: Giới hạn blast radius bằng sandbox
Scan chỉ là lớp đầu, và kẻ tấn công luôn có cách obfuscate mới. Nguyên tắc quan trọng hơn là: giả định agent sẽ bị inject, rồi giới hạn thiệt hại có thể xảy ra.
flowchart TB Dev[Developer] --> Agent[AI Agent] subgraph Sandbox[Container: no secrets, egress allowlist] Agent --> FS[Repo mount] Agent --> Shell[Shell bị giới hạn] end Shell -.->|Chặn| Internet[Internet tùy ý] Shell -->|Cho phép| Registry[npm / PyPI registry] Secrets[~/.ssh, ~/.aws, .env thật] -.->|Không mount| Sandbox Cấu hình thực tế mình đang dùng:
- Chạy agent trong container hoặc devcontainer và chỉ mount thư mục repo. Tuyệt đối không mount
~/.ssh,~/.awshay~/.config/gh. - Egress allowlist: chỉ cho phép kết nối tới package registry và API của chính LLM provider. Nếu không cần cài package thì dùng luôn
--network nonecho đơn giản. - Tắt auto-approve cho shell command. Hầu hết agent đều có permission mode; đừng bật kiểu yolo hay skip-permissions trên repo bạn không tự viết.
- Không để secret thật trong
.envở máy dev. Dùng secret manager và inject lúc runtime, lúc agent làm việc thì.envchỉ chứa giá trị giả. - Repo lạ thì mở trong môi trường dùng xong bỏ, ví dụ:
docker run --rm -it \ --network none \ --read-only --tmpfs /tmp \ -v "$PWD":/work:rw -w /work \ --cap-drop ALL --security-opt no-new-privileges \ node:22-bookworm-slim bash Ngoài ra hãy để ý cả output của tool. Một package npm độc có thể in ra prompt injection trong postinstall log, và agent sẽ đọc nó như một instruction. Chạy npm install --ignore-scripts khi để agent cài dependency là một thói quen tốt.
Kết luận
AI coding agent về bản chất là một process có quyền shell, nhận lệnh từ bất kỳ text nào nó đọc được. File AGENTS.md không còn là docs vô hại nữa mà phải được đối xử như code thực thi. Checklist bạn có thể áp dụng ngay tuần này:
- Hôm nay: chạy lệnh
grep -rnPở trên cho tất cả repo của team, đặc biệt là repo có contributor bên ngoài. - Tuần này: thêm script scan vào pre-commit và CI, đồng thời đặt CODEOWNERS cho các file instruction của agent.
- Mặc định: tắt auto-approve shell command, chỉ bật cho những lệnh read-only cụ thể.
- Repo lạ: luôn mở trong container
--network none, không mount credential. - Secrets: rời khỏi
.envplaintext, chuyển sang secret manager. - Review PR: diff của
AGENTS.mdphải được đọc kỹ như diff củaDockerfile, và nhớ bật hiển thị ký tự ẩn trong editor (VS Code:editor.unicodeHighlight.invisibleCharacters: true).
Agent càng mạnh thì blast radius khi bị hijack càng lớn. Đừng chờ đến lúc .env của bạn xuất hiện trên một server lạ mới bắt đầu nghĩ đến chuyện này.