Skip to main content
Back to Blog

Claude Code vá lỗ hổng symlink trong plugin. Trình validate của nó vẫn cho qua sạch

Claude Code 2.1.257 vá lỗi plugin đọc file ngoài thư mục qua symlink. Tôi dựng lại trò đó trên 2.1.258: khoảng trống không nằm ở loại component mà ở hình dạng symlink — thư mục là symlink thì validator cảnh báo đủ cả bốn loại, còn file symlink nằm trong commands/ hay agents/ thì im lặng hoàn toàn. Với plugin có manifest đầy đủ, claude plugin validate trả về ✔ Validation passed sạch bong.

Claude Code vá lỗ hổng symlink trong plugin. Trình validate của nó vẫn cho qua sạch

Tượng bán thân cổ điển với một dải đá tạc ngang che kín hai mắt, chữ PASSED lớn bên trái

Ngày 1 tháng 9 năm 2026, Claude Code 2.1.257 vá một lỗi cho phép plugin đọc file nằm ngoài thư mục của chính nó, thông qua một component được khai báo bằng symlink. Tôi dựng lại đúng trò đó từ đầu, trên máy của mình, với đúng bản binary đã có bản vá.

Bản vá là thật. Nhưng claude plugin validate — công cụ bạn chạy trước khi quyết định có tin một plugin của người lạ hay không — cấp cho một plugin có commands/leak.md trỏ thẳng ra ~/.ssh/id_rsa một dòng ✔ Validation passed. Không phải "passed with warnings". Sạch bong, không một cảnh báo nào.

Và khoảng trống không nằm ở chỗ tôi tưởng lúc mới bắt đầu. Nó không phải "hai trong bốn loại component bị bỏ quên" — đó là câu tôi suýt viết, và nó sai. Ranh giới thật nằm giữa hai hình dạng symlink khác nhau, cắt ngang cả bốn loại component. Phần dưới là toàn bộ đường đi tới kết luận đó, kèm output dán nguyên, không sửa một ký tự.

Bản vá ngày 1 tháng 9 nói gì

Changelog chính thức của Claude Code, mục 2.1.257:

Fixed plugins being able to read files outside their own directory through a declared command, agent, skill, hooks or other component path that is a symlink; such paths are now refused with an error

Dịch ý: một plugin khai báo component — một skill, một command, một định nghĩa agent, một file cấu hình hooks — nhưng thay vì đường dẫn đó trỏ tới file thật nằm trong thư mục plugin, nó là một symlink trỏ đi nơi khác trên đĩa. Nếu bộ loader đi theo symlink mà không kiểm xem nó đáp xuống đâu, plugin đọc được bất cứ thứ gì tiến trình đọc được.

Cùng release đó còn có một dòng khác, cùng chủ đề:

Added a Containment Escape rule to auto mode so cloud metadata-credential fetches, egress evasion, and cross-tenant reach are no longer auto-approved unless your environment marks them expected

Hai thay đổi siết chặt, cùng một ngày, cùng một tinh thần: đừng để thứ đang chạy trong sandbox lặng lẽ với tay ra ngoài ranh giới của nó. Tôi sẽ quay lại dòng thứ hai ở cuối bài, vì nó là lý do tôi không kiểm được trọn vẹn dòng thứ nhất.

Về mốc thời gian, để khỏi phải đoán: 2.1.257 lên npm registry lúc 2026-09-01T17:15:33Z, 2.1.258 lên lúc 2026-09-01T22:25:07Z — cùng ngày, cách nhau hơn năm tiếng.

Điểm dễ bị đánh giá thấp ở đây là component của plugin không phải dữ liệu bị động. Một command, một skill, một định nghĩa agent đều là file markdown, và nội dung của chúng trở thành prompt — nó chảy vào ngữ cảnh của phiên làm việc.

Nghĩa là "đọc file ngoài thư mục" không dừng ở việc một tiến trình mở nhầm file. Nội dung file đó vào tới ngữ cảnh của model, rồi từ đó có thể đi tiếp vào transcript, vào một tool call, vào một request mạng — bất cứ đâu mà phiên đó có quyền đi. Một commands/deploy.md là symlink trỏ tới .env không chỉ rò rỉ file đó cho tiến trình; nó dán file đó vào chỗ mà model đọc được và có thể được thuyết phục để chuyển tiếp.

Thêm nữa, con đường plugin đến tay bạn thường là marketplace, tức là bạn cài code của người lạ. Quy trình phòng thủ hợp lý cho chuyện đó là: clone về, chạy claude plugin validate, đọc cảnh báo, rồi mới quyết định. validate được thiết kế đúng cho vai trò đó — nó là kiểm tra tĩnh, không gọi model, không cần dựng session thật, nên chạy nó lên một plugin lạ là an toàn. Đó chính là lý do khoảng trống trong nó đáng nói hơn khoảng trống ở bất kỳ chỗ nào khác: đây là bước mà người ta thật sự dựa vào để ra quyết định tin hay không.

Vì sao tôi kiểm được chuyện này từ đây

Pipeline xuất bản blog này là một CMS Next.js + Prisma tự host, nằm sau một MCP server có OAuth scope. Con agent soạn draft cho blog nói chuyện với nó qua đúng cái server đó. Nói cách khác, hệ thống plugin mà changelog ngày 1 tháng 9 đang nhắc tới chính là hệ thống plugin mà quy trình này chạy trên đó.

Nếu tôi cắm thêm một plugin Claude Code tự viết vào mảng AI Product Development, hoặc mở rộng bộ tooling đang giữ cho Kiwi chạy, thì đây đúng là ranh giới bảo mật mà tôi sẽ phải tin. Nên thay vì đọc changelog rồi gật đầu đi tiếp, tôi cài đúng bản có bản vá vào một thư mục tạm và kiểm.

$ claude --version
2.1.258 (Claude Code)

Một patch release sau bản vá. Tốt — thứ tôi tìm thấy là hiện trạng, không phải đồ cũ.

Dựng cái bẫy

Changelog nêu bốn loại component: commands, agents, skills, hooks. Tôi dựng mỗi loại một plugin nhỏ, mỗi cái có .claude-plugin/plugin.json tử tế và một component mà file — hoặc cả thư mục — là symlink trỏ ra ngoài hẳn thư mục plugin.

$ ls -la evil-plugin/skills
total 8
drwxr-xr-x@ 4 beru  wheel  128 Sep  3 00:01 .
drwxr-xr-x@ 4 beru  wheel  128 Sep  3 00:01 ..
lrwxr-xr-x@ 1 beru  wheel   46 Sep  3 00:01 leak.md -> /tmp/plugin-symlink-test/outside-secret/id_rsa
-rw-r--r--@ 1 beru  wheel   67 Sep  3 00:01 real-skill.md

outside-secret/id_rsa là file giả nằm ngoài evil-plugin/, đóng vai "thứ bạn không muốn một plugin đọc được". real-skill.md là component thật, đóng vai đối chứng nằm ngay cạnh — nếu validator im lặng với cả hai thì tôi biết mình dựng sai fixture chứ không phải tìm ra lỗi.

Hai đầu cột Corinth giống hệt nhau, cột trái đứng trên thân cột có rãnh, cột phải bên dưới trống không

Chỗ validator lên tiếng: skills và hooks

claude plugin validate là kiểm tra tĩnh của chính CLI, không gọi model. Với skills, nó bắt được ngay, và nói một câu chính xác hơn tôi tưởng:

$ claude plugin validate ./evil-plugin

Validating plugin manifest: /private/tmp/plugin-symlink-test/evil-plugin/.claude-plugin/plugin.json

⚠ Found 1 warning:

  ❯ author: No author information provided. Consider adding author details for plugin attribution

Validating skill: /private/tmp/plugin-symlink-test/evil-plugin/skills

⚠ Found 1 warning:

  ❯ directory: 1 entry here is a symlink and was not read — components are read without following symlinks. A session loading this plugin does follow them, so validate the real paths separately.

✔ Validation passed with warnings

Đọc kỹ câu cảnh báo: validator từ chối đi theo symlink, nhưng một session thật khi load plugin thì đi theo. Đó đúng là khoảng trống mà changelog nói bản vá ngày 1 tháng 9 đã bịt ở thời điểm load — "such paths are now refused with an error". Bản thân câu cảnh báo được viết trước hoặc độc lập với bản vá đó, nên nó vẫn dặn bạn tự kiểm đường dẫn thật.

Thay cả thư mục skills/ bằng một symlink, thay vì chỉ một file bên trong:

$ claude plugin validate ./dir-symlink-plugin

Validating skill: /private/tmp/plugin-symlink-test/dir-symlink-plugin/skills

⚠ Found 1 warning:

  ❯ directory: This directory is a symlink and nothing in it was read — component directories are read without following symlinks. A session loading this plugin does follow it, so validate the real directory separately.

✔ Validation passed with warnings

Cùng cách xử lý, cùng lời dặn, khác câu chữ. Rồi tới hooks, chỗ chi tiết nhất trong cả bốn loại:

$ claude plugin validate ./multi-plugin

Validating hooks: /private/tmp/plugin-symlink-test/multi-plugin/hooks/hooks.json

⚠ Found 1 warning:

  ❯ directory: hooks.json is not a regular file (a symlink, a FIFO, a directory) — hooks are read without following symlinks and are capped at 1048576 bytes. The plugin loader has neither limit and fails the whole plugin on bad hook config, so validate the real file separately.

✔ Validation passed with warnings

Câu đó đáng ngồi lại một lát. Validator giới hạn file hooks ở 1 MB và không đi theo symlink, nhưng "the plugin loader has neither limit and fails the whole plugin on bad hook config". Tức là một hooks.json hỏng hoặc độc — symlink hay không — không chỉ làm hỏng một component, nó kéo sập cả plugin ngay lúc load. Đây là chỗ duy nhất tôi thấy sự khác biệt giữa giới hạn của validator và giới hạn của loader được ghi ra thành chữ.

Hooks là loại được chăm kỹ nhất

Câu cảnh báo trên khai ra một con số cụ thể — 1048576 bytes, đúng 1 MiB — nên tôi kiểm luôn xem nó có thật không, bằng một hooks.json hợp lệ về cú pháp nhưng nhỉnh hơn ngưỡng đó một chút:

$ ls -l big-hooks/hooks/hooks.json
1048622 big-hooks/hooks/hooks.json

$ claude plugin validate ./big-hooks

Validating hooks: /private/tmp/plugin-symlink-test/big-hooks/hooks/hooks.json

⚠ Found 1 warning:

  ❯ directory: hooks.json is past the size cap and was not read — hooks are read without following symlinks and are capped at 1048576 bytes. The plugin loader has neither limit and fails the whole plugin on bad hook config, so validate the real file separately.

✔ Validation passed with warnings

Con số là thật, và được thực thi. 1048622 byte, dư 46 byte so với ngưỡng, và file bị bỏ qua kèm một câu cảnh báo riêng — khác hẳn câu dành cho symlink. Và nếu thay cả thư mục hooks/ bằng symlink:

$ claude plugin validate ./dirsym-hooks

Validating hooks: /private/tmp/plugin-symlink-test/dirsym-hooks/hooks/hooks.json

⚠ Found 1 warning:

  ❯ directory: The hooks directory is a symlink and was not read — hooks are read without following symlinks and are capped at 1048576 bytes. The plugin loader has neither limit and fails the whole plugin on bad hook config, so validate the real file separately.

✔ Validation passed with warnings

Lại một câu chữ riêng nữa. Cộng lại, hooks có ba nhánh kiểm tách bạch với ba thông báo khác nhau: file là symlink, thư mục là symlink, file quá cỡ. Đó là loại component được phủ kỹ nhất trong cả bốn. Hãy giữ chi tiết này trong đầu — tới cuối bài nó sẽ trở thành bằng chứng cho việc chuyện gì đang thật sự xảy ra.

Bàn tay tượng cổ điển đang chỉ, đầu ngón trỏ bị cắt phẳng nhẵn thành một mặt vuông

Cảnh báo này kiểm hình dạng, không kiểm đích đến

Trước khi đi tiếp, một câu hỏi phải trả lời: cảnh báo ở trên có thật sự biết symlink trỏ ra ngoài, hay nó chỉ thấy "đây là symlink" rồi kêu?

Tôi dựng một plugin dùng symlink hoàn toàn lương thiện — trỏ vào chính bên trong plugin, kiểu alias cho một skill dùng chung, chuyện maintainer làm suốt:

$ claude plugin validate ./benign-inside

Validating skill: /private/tmp/plugin-symlink-test/benign-inside/skills

⚠ Found 1 warning:

  ❯ directory: 1 entry here is a symlink and was not read — components are read without following symlinks. A session loading this plugin does follow them, so validate the real paths separately.

✔ Validation passed with warnings

Cùng một cảnh báo, cùng một câu chữ, dù symlink này không đi đâu cả. Rồi tới trường hợp dứt khoát hơn — một symlink gãy, trỏ tới file không tồn tại:

$ claude plugin validate ./dangling

Validating skill: /private/tmp/plugin-symlink-test/dangling/skills

⚠ Found 1 warning:

  ❯ directory: 1 entry here is a symlink and was not read — components are read without following symlinks. A session loading this plugin does follow them, so validate the real paths separately.

✔ Validation passed with warnings

Vẫn y hệt. Nếu check có resolve đích đến, một symlink gãy phải cho ra thông báo khác — "không tồn tại", hoặc ít nhất là một câu khác. Nó không. Vậy đây là kiểm tra thuần hình dạng: thấy entry là symlink thì cảnh báo, hết, không bao giờ hỏi nó trỏ đi đâu.

Điều đó hợp lý về mặt an toàn — resolve một đường dẫn do kẻ tấn công điều khiển chính là hành vi bạn đang muốn tránh. Nhưng nó có hệ quả thực tế: cảnh báo này có tỷ lệ báo động giả thật sự. Một repo dùng symlink nội bộ hợp pháp sẽ luôn thấy nó. Và cảnh báo nào luôn xuất hiện thì người ta học cách lướt qua. Đúng lúc cảnh báo đó thật sự quan trọng, nó đã bị đào tạo thành tiếng ồn.

Chỗ validator im lặng: commands và agents

Skills và hooks đều có cảnh báo bằng chữ. Commands và agents thì không. Trước khi kết luận, tôi kiểm xem có phải do tôi dựng fixture sai không — bằng một lỗi tầm thường, thiếu frontmatter, thay vì symlink:

$ claude plugin validate ./check-cmds

Validating agent: /private/tmp/plugin-symlink-test/check-cmds/agents/broken.md

⚠ Found 1 warning:

  ❯ frontmatter: No frontmatter block found. Add YAML frontmatter between --- delimiters at the top of the file to set description and other metadata.

Validating command: /private/tmp/plugin-symlink-test/check-cmds/commands/broken.md

⚠ Found 1 warning:

  ❯ frontmatter: No frontmatter block found. Add YAML frontmatter between --- delimiters at the top of the file to set description and other metadata.

✔ Validation passed with warnings

Validator có mở và đọc file command lẫn agent. Nó bắt thiếu frontmatter ở cả hai, không do dự. Vậy thì cô lập trường hợp symlink xuống một component duy nhất, mỗi loại một plugin, không có gì khác gây nhiễu:

$ claude plugin validate ./only-symlink-cmd

Validating plugin manifest: /private/tmp/plugin-symlink-test/only-symlink-cmd/.claude-plugin/plugin.json

⚠ Found 1 warning:

  ❯ author: No author information provided. Consider adding author details for plugin attribution

✔ Validation passed with warnings

Cảnh báo duy nhất là dòng thiếu author mà plugin nào không khai cũng nhận. Không một chữ nào về chuyện file command là symlink, không một chữ nào về chuyện nó trỏ đi đâu. Bản only-symlink-agent cho kết quả giống hệt.

Đặt symlink sâu hơn một cấp, trong thư mục con của commands/, cũng vậy:

$ claude plugin validate ./sub-cmd

Validating plugin manifest: /private/tmp/plugin-symlink-test/sub-cmd/.claude-plugin/plugin.json

✔ Validation passed

Hàng bốn cột cổ điển, hai cột trái chạm xong hoàn chỉnh, hai cột phải còn nằm dở trong khối đá thô

Ranh giới thật không nằm ở loại component

Tới đây kết luận dễ dãi sẽ là "hai trong bốn loại component bị bỏ sót". Nó sai, và sai theo hướng làm câu chuyện nhẹ đi so với sự thật.

Vì nếu để cả thư mục commands/ là symlink — chứ không phải một file bên trong nó — validator lại lên tiếng đầy đủ:

$ claude plugin validate ./dirsym-cmd

Validating command: /private/tmp/plugin-symlink-test/dirsym-cmd/commands

⚠ Found 1 warning:

  ❯ directory: This directory is a symlink and nothing in it was read — component directories are read without following symlinks. A session loading this plugin does follow it, so validate the real directory separately.

✔ Validation passed with warnings

agents/ cũng y hệt. Vậy ranh giới thật không phải giữa các loại component, mà giữa hai hình dạng symlink:

Hình dạng symlinkskillshookscommandsagents
Cả thư mục component là symlinkcảnh báocảnh báocảnh báocảnh báo
Một file bên trong thư mục là symlinkcảnh báocảnh báoim lặngim lặng
File symlink trong thư mục conim lặngim lặng

Hàng đầu tiên phủ kín cả bốn cột. Không một loại component nào bị bỏ quên ở mức thư mục. Cái thiếu là một nhánh code — vòng quét từng entry bên trong thư mục — mới chỉ có ở skills và hooks.

Cách diễn đạt này quan trọng vì hai lý do. Thứ nhất, nó đổi lời khuyên ở cuối bài: nếu bạn nghĩ vấn đề là "commands và agents không được kiểm", bạn sẽ đi kiểm sai chỗ và bỏ sót đúng cái mà công cụ đã bắt được cho bạn. Thứ hai, nó nói lên bản chất của thiếu sót: đây không phải một loại component bị quên khi thiết kế, mà là một vòng lặp chưa được nhân bản sang chỗ thứ ba và thứ tư. Loại lỗi đó có tính chất khác hẳn — nó không phải lỗ hổng trong mô hình bảo mật, nó là công việc đang làm dở.

Còn chính manifest thì sao

Nếu vòng quét entry là thứ còn thiếu, thì file duy nhất chắc chắn được đọc trong mọi lần validate — .claude-plugin/plugin.json — được đối xử thế nào khi chính nó là symlink?

$ claude plugin validate ./manifest-sym

Validating plugin manifest: /private/tmp/plugin-symlink-test/manifest-sym/.claude-plugin/plugin.json

✔ Validation passed

Sạch. Validator đọc manifest qua symlink, phân tích nội dung bên kia, và không nói gì về việc nó vừa đi ra ngoài thư mục plugin. Changelog dùng cụm "or other component path", nên có thể tranh luận manifest có tính là component hay không. Nhưng về mặt hình dạng thì nó là đúng cái mà cảnh báo kia được viết ra để chỉ tên — và ở đây không có cảnh báo nào cả.

Tấm giấy chứng nhận trong sạch

Ở các fixture trên, phần lớn plugin của tôi thiếu author, nên chúng vẫn in ra ✔ Validation passed with warnings. Có một dòng vàng trên màn hình, dù nội dung chẳng liên quan gì tới symlink. Người đọc lướt qua vẫn thấy "à, có cảnh báo, để xem kỹ".

Nên tôi dựng cái đáng sợ hơn: một plugin có manifest hoàn chỉnh — có author, có description — và trong commands/ có một command thật thà nằm cạnh một command là symlink trỏ ra ngoài.

$ claude plugin validate ./mixed-cmd

Validating plugin manifest: /private/tmp/plugin-symlink-test/mixed-cmd/.claude-plugin/plugin.json

✔ Validation passed

Không cảnh báo. Không phải "passed with warnings". Sạch. Một plugin mà commands/leak.md trỏ tới file bất kỳ trên đĩa nhận được giấy chứng nhận trong sạch tuyệt đối. Bản mixed-agent cho kết quả y hệt.

Đây mới là điều đáng nói, và nó khác hẳn "validator cảnh báo thiếu". Hãy đặt vào tình huống thật: ai đó đẩy một plugin lên marketplace. Người đó có động cơ làm nó trông chuyên nghiệp — manifest đầy đủ, mô tả tử tế, vài command thật sự hữu ích. Bạn clone về, chạy claude plugin validate, và nhận đúng một dòng ✔ Validation passed — cùng một output, không sai một ký tự, với cái bạn nhận được từ một plugin hoàn toàn lương thiện.

Không có tín hiệu nào để phân biệt. Chính sự chỉn chu của gói khiến nó im lặng hơn: một plugin cẩu thả ít nhất còn để lại dòng author khiến bạn khựng lại một giây.

Phần tôi không kiểm được: runtime

Validate là phân tích tĩnh. Bản vá thật nằm ở chỗ load plugin vào một session sống. Và đây là chỗ tôi phải dừng lại.

--plugin-dir nạp một plugin "chỉ cho session này", nhưng nó chỉ tồn tại trên câu lệnh claude chính, không phải trên các lệnh con. claude plugin details — lệnh duy nhất hiện component inventory của một plugin mà không cần dựng session thật — không nhận cờ đó:

$ claude plugin details --plugin-dir ./mixed-cmd mixed-cmd
error: unknown option '--plugin-dir'

plugin details chỉ làm việc với plugin đã cài. Nghĩa là không có đường nào quan sát hành vi của loader mà không thật sự cài plugin độc vào môi trường của mình rồi mở một session — thứ tôi không định làm trên cái máy đang giữ khoá deploy.

Nên phát biểu trung thực về kết quả của tôi là thế này. Khoảng trống của trình phân tích tĩnh đã được xác minh đầy đủ, tái lập được, và cụ thể ở phiên bản 2.1.258. Còn bản vá runtime — liệu một session sống có thật sự từ chối component symlink kèm lỗi hay không — tôi không có bằng chứng theo chiều nào cả. Changelog nói có. Tôi không kiểm được từ chỗ tôi đứng, và tôi không đoán thay.

Điều này cũng đáng nói riêng: hai thứ đó độc lập với nhau. Bản vá runtime có thể hoàn hảo, và khoảng trống trong validate vẫn là vấn đề thật — vì validate là bước con người dùng để ra quyết định trước khi runtime có cơ hội bảo vệ ai.

Tượng herm có đầu người râu dài đứng chắn trong một ô cửa đá

Cái rule tám tiếng tuổi

Còn một chi tiết nữa. Trong lúc tìm cách dựng session thật để quan sát loader, hướng đi tự nhiên là gỡ biến môi trường đánh dấu phiên hiện tại là phiên con, rồi chạy lại xem có gì khác. Đúng kiểu thí nghiệm tương thích buồn tẻ.

Đó cũng đúng là hình dạng của "egress evasion" trong mắt một classifier. Gỡ biến đánh dấu sandbox, cụ thể để xem chuyện gì xảy ra khi nó không còn — về mặt hành vi, không phân biệt được với việc dò ranh giới containment. Và rule "Containment Escape" trong auto mode, thứ vừa ship cùng release với bản vá symlink, tồn tại chính xác để chặn hình dạng đó.

Tính từ lúc 2.1.257 lên npm — 17:15Z ngày 1 tháng 9 — cái rule đó mới tám tiếng tuổi. Nó chặn một thao tác mà ý định hoàn toàn lành, từ một góc hoàn toàn khác với thứ tôi đang thật sự kiểm. Tôi cho đó là kết quả đúng: một rule bảo mật chỉ chặn khi ý đồ xấu thì không phải rule bảo mật, nó là gợi ý. Cái giá phải trả là những ngày như hôm nay, khi nó chắn ngang một thí nghiệm lương thiện — và cái giá đó là hợp lý.

Vậy phải làm gì

Nếu bạn viết hoặc review plugin cho Claude Code, kết luận không phải "bản vá không chạy". Tôi không có bằng chứng nào cho chuyện đó. Nó hẹp hơn và dùng được ngay: đừng coi một lượt claude plugin validate sạch là bằng chứng rằng commands/agents/ không có trò symlink, ít nhất trên 2.1.258. Đặc biệt là với plugin được đóng gói tử tế, vì lúc ấy output sạch hoàn toàn, không còn cả dòng author để bạn khựng lại.

Tự kiểm hai thư mục đó trước khi tin plugin của người khác. Nhưng phải kiểm đúng cách. Lệnh trực giác đầu tiên là thế này, và nó thiếu:

$ find . -path '*/commands/*' -type l -o -path '*/agents/*' -type l
./mixed-cmd/commands/leak.md
./only-symlink-cmd/commands/run.md
./mixed-agent/agents/leak.md

Ba kết quả. Nó bỏ sót dirsym-cmd/commands, vì */commands/* không khớp với chính ./commands. Trớ trêu là đó lại đúng là trường hợp plugin validate cảnh báo — nên nếu bạn tin lệnh này, bạn vừa đổi một khoảng trống lấy một khoảng trống khác. Bản đúng bắt cả hai hình dạng:

$ find . \( -path '*/commands*' -o -path '*/agents*' \) -type l
./mixed-cmd/commands/leak.md
./only-symlink-cmd/commands/run.md
./mixed-agent/agents/leak.md
./dirsym-cmd/commands

Bốn kết quả. Bất cứ thứ gì hiện ra ở đây là một component mà nội dung thật nằm ở chỗ khác trên đĩa. Với dạng thư mục, validate sẽ nói cho bạn. Với dạng file bên trong commands/ hoặc agents/, nó sẽ không — và đó là dạng bạn phải tự tìm.

Nếu muốn chặt hơn nữa, kiểm luôn cả manifest, vì như phần trên đã cho thấy, plugin.json là symlink thì không ai nói gì cả:

$ find . -path '*/.claude-plugin/*' -type l
./manifest-sym/.claude-plugin/plugin.json

Tôi chạy lệnh đó lên máy mình

Viết xong đoạn trên thì phải tự soi. Tôi chạy đúng lệnh vừa khuyến nghị lên ~/.claude — chỗ chứa skills, commands và plugin của chính tôi.

Tám component là symlink. Bốn trong số đó trỏ ra ngoài hẳn ~/.claude: hai command trỏ vào thư mục cài đặt của một bộ tooling, một skill trỏ vào một repo khác trong home, và một skill trỏ thẳng vào bên trong một application bundle ở /Applications. Bốn cái còn lại là symlink nội bộ, kiểu skill này alias sang skill kia.

Không cái nào là tấn công. Tất cả đều do tôi tự cài, và mỗi cái đều có lý do hợp lệ — đó chính là cách nhiều bộ công cụ tự gắn mình vào Claude Code: cài ở một chỗ, rồi thả một symlink vào ~/.claude để nó xuất hiện. Đây là mẫu hình phổ biến, không phải chuyện lạ.

Và đó mới là điều đáng chú ý. Trên một máy làm việc bình thường, "component là symlink trỏ ra ngoài thư mục của nó" là trạng thái mặc định, không phải dấu hiệu bất thường. Nghĩa là cảnh báo thuần hình dạng ở phần trước sẽ kêu liên tục trong đời thực, và người dùng sẽ học cách bỏ qua nó trong vòng một tuần. Đồng thời, một symlink độc thả vào giữa đám đó trông không khác gì hàng xóm của nó.

Bài học rút ra không phải "hãy xoá hết symlink đi". Là: danh sách này ngắn, đọc hết trong ba mươi giây, và bạn nên biết từng dòng trong đó trỏ đi đâu. Tôi thì chưa từng nhìn nó trước hôm nay.

Bức tranh ghép lại

Xếp mọi thứ đã đo theo mức độ được phủ, thứ tự hiện ra rất rõ. Hooks có ba nhánh kiểm riêng biệt với ba câu thông báo riêng: file symlink, thư mục symlink, file quá cỡ. Skills có hai: file symlink, thư mục symlink. Commands và agents có một: thư mục symlink. Manifest không có nhánh nào.

Đó không phải hình dạng của một mô hình bảo mật có lỗ thủng. Đó là hình dạng của một tính năng đang được nhân rộng dần: viết cho hooks trước, chép sang skills, rồi dừng. Nếu là lỗi thiết kế, mức phủ sẽ lởm chởm ngẫu nhiên; đằng này nó xếp thành một cái thang đều đặn.

Cách đọc đó cũng dự đoán được điều sắp tới — commands và agents nhiều khả năng sẽ nhận đúng vòng quét đó ở một trong vài release tới. Nhưng "sắp có" không bảo vệ được ai hôm nay, và khoảng cách giữa chuyện changelog nói gì với chuyện công cụ kiểm được gì rơi đúng vào bước mà con người đang dựa vào để ra quyết định.

Nếu tôi được chọn một thay đổi duy nhất, tôi sẽ không xin thêm cảnh báo. Tôi xin plugin validate có một dòng tổng kết nói rõ nó đã kiểm những gì — kiểu "đã quét N file trong 4 thư mục component, bỏ qua 2 symlink". Vì vấn đề thật sự của ✔ Validation passed không phải nó sai, mà là nó không phân biệt được "tôi đã kiểm và mọi thứ ổn" với "tôi không kiểm chỗ đó". Hai câu ấy trông giống hệt nhau trên màn hình, và chỉ một trong hai là điều bạn đang cần biết.

Nhìn tổng thể, đây là dấu vết của một bản vá bảo mật được ship theo từng bước. Đó là cách ship hợp lý, và tôi không đọc nó thành cẩu thả — phần lớn hardening thật sự diễn ra đúng kiểu này, từng mảnh một, chứ không phải một cú hoàn hảo. Chỉ là nó chưa phủ hết bốn loại component, trong khi câu changelog đọc lên thì như đã phủ hết rồi.

Tôi sẽ chạy lại đúng bộ test này ở lần release tiếp theo có đụng tới plugin validation, và cập nhật vào đây nếu khoảng trống được lấp.