Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

One Markdown File Can Hijack Your AI Coding Agent: Defending Against Prompt Injection in AGENTS.md, CLAUDE.md and .cursorrules

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

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 đến U+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.sh mớ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:

  1. Chạy agent trong container hoặc devcontainer và chỉ mount thư mục repo. Tuyệt đối không mount ~/.ssh, ~/.aws hay ~/.config/gh.
  2. 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 none cho đơn giản.
  3. 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.
  4. 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ì .env chỉ chứa giá trị giả.
  5. 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 .env plaintext, chuyển sang secret manager.
  • Review PR: diff của AGENTS.md phải được đọc kỹ như diff của Dockerfile, 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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.