---
title: "Quyền nằm trên đường đi, không nằm trong tool"
description: "Một lời gọi tool đi qua bảy chặng trước khi chạm tới thân tool. Chính sách, hook và sandbox sống trên đường đi đó, và thân tool không cần biết gì về chúng."
pubDate: 2026-09-26
author: tusgino
tags: [ai-engineering, architecture, security, agents]
series:
  id: tu-llm-den-harness
  title: "Từ LLM đến Harness"
  order: 8
cover: /images/covers/mot-tool-call-di-qua-bay-chang.svg
coverAlt: "Bảy ô vuông nối nhau, ô giữa đậm là thân tool, sáu ô kia có gạch gác phía trên"
faq:
  - q: "Vì sao không kiểm tra quyền ngay trong từng tool?"
    a: "Vì mỗi tool sẽ tự quyết định một kiểu, và bạn không có chỗ nào để trả lời câu hỏi toàn cục. Đặt chính sách trên đường đi thì tool không cần biết gì về nó."
  - q: "Approval có phải một chặng riêng không?"
    a: "Không. Approval là một listener của chặng đầu tiên. Chặng đó là một waterfall cho phép allow, deny, cancel hoặc ask."
  - q: "Guard có chặn được lời gọi không?"
    a: "Có, nhưng guard chỉ giảm được quyền. Nó trả về lý do để từ chối, và không guard nào ép cho qua được lời gọi mà guard khác đã chặn."
  - q: "Hook có sửa được tham số trước khi chạy không?"
    a: "Không, và đây là chủ ý. Tham số đã được ghi log và hiển thị cho người dùng, nên sửa chúng sẽ làm lịch sử, kiểm toán, giao diện và thực thi lệch nhau."
---

<TLDR>Một lời gọi tool đi qua bảy chặng trước khi chạm thân tool. Chính sách, hook và sandbox nằm ở đó — và thân tool không cần biết gì về chúng.</TLDR>

## Chỗ đặt chính sách

Có hai cách để một harness biết một lời gọi tool có được phép chạy hay không.

Cách thứ nhất: mỗi tool tự kiểm tra. Tool đọc file thì tự hỏi đường dẫn này có nằm trong vùng cho phép không. Tool chạy lệnh thì tự hỏi lệnh này có bị cấm không.

Cách thứ hai: không tool nào kiểm tra gì cả. Có một đường ống, và chính sách sống trên đường ống đó.

Cách thứ nhất dễ bắt đầu và hỏng dần. Khi bạn có hai mươi tool, bạn có hai mươi bản sao của cùng một logic quyền, mỗi bản lệch nhau một chút. Muốn đổi chính sách, bạn sửa hai mươi chỗ. Và không có chỗ nào trả lời được câu hỏi "hệ thống này đang cho phép những gì".

Harness tôi đọc chọn cách thứ hai. Một lời gọi đi qua **bảy chặng**:

| # | Chặng | Làm gì |
|---|---|---|
| 1 | `tools/pre-execute` | Waterfall cho phép allow / deny / cancel / ask |
| 2 | Guard đơn điệu | Chỉ giảm được quyền, không tăng |
| 3 | `tools/execute` | Wrapper quanh lời gọi: timeout, retry, số đo |
| 4 | `projectContent` | Dựng nội dung trả về model |
| 5 | `tools/post-execute` | Xem hoặc thay kết quả |
| 6 | `finalizeContent` | Chốt nội dung lần cuối |
| 7 | `tools/result` | Kết quả cuối, bất biến |

Chặng 4 đến 7 nằm **sau** khi tool đã chạy. Chỉ ba chặng đầu gác trước cửa.

Và chỗ này quan trọng: **approval không phải một chặng riêng**. Nó chỉ là một listener của chặng 1. Chặng 1 hoạt động như một chuỗi middleware (`waterfall`) — mỗi listener nhận `(exec, next)` và có thể gọi `next()` để chuyển tiếp, hoặc trả về quyết định để cắt ngang.

## Guard chỉ giảm được quyền

Chi tiết thiết kế tôi thấy hay nhất trong bài này nằm ở kiểu trả về của guard.

Một guard không trả về `allow`. Nó trả về `undefined` hoặc một chuỗi lý do:

```ts title="packages/core/tools/src/index.ts"
/**
 * A monotonic execution guard evaluated after every `tools/pre-execute`
 * …
 * returning `undefined` leaves it unchanged. Because guards have no allow
 * result, a later guard cannot undo an earlier denial.
 */
guard(guard: ToolGuard): () => void
```

Đọc vế thứ hai: **vì guard không có kết quả allow, một guard sau không thể hoàn tác lệnh từ chối của guard trước.**

Đây là một quyết định về kiểu dữ liệu, không phải về thứ tự thực thi. Bạn không cần tin rằng các guard được gọi đúng thứ tự. Ngay cả khi chúng bị gọi sai thứ tự, kết quả vẫn an toàn.

Cái tên cho tính chất này là **monotonic** — đơn điệu. Nó chỉ đi một chiều.

## Tham số thì không được sửa

Đây là ràng buộc làm tôi dừng lại, và là thứ tôi chưa thấy ở harness khác.

Một hook chặn được lời gọi. Nó thêm được lý do, thêm được thông tin cho người dùng. Nhưng nó **không sửa được tham số**:

<Quote cite="dsh, docs/subsystems/tools.md">Arguments cannot be rewritten because history, audit, UI, and execution must agree.</Quote>

Một câu, bốn bên. Tham số đã được ghi vào log trước khi chính sách chạy. Chúng đã được hiển thị cho người dùng trong giao diện xin phép. Nếu một hook sửa chúng giữa đường, thì cái đã ghi, cái đã hiện, và cái sắp chạy là ba thứ khác nhau.

Nghĩ tới một ca cụ thể: người dùng bấm "cho phép" khi thấy lệnh `rm -rf ./build`. Một hook đổi tham số thành `rm -rf /`. Log ghi cái đầu, người dùng duyệt cái đầu, và hệ thống chạy cái sau.

Nên luật là: muốn đổi tham số, bạn phải từ chối lời gọi và để model gọi lại với tham số mới. Lời gọi mới đi qua toàn bộ đường ống, và được ghi log như một lời gọi mới.

## Ngăn lặp: nhắc, không chặn

Harness có một guard chuyên cho việc model gọi lặp. Nó đáng đọc vì nó cố ý **không** chặn:

```yaml title="cấu hình guard chống lặp, lấy từ README của plugin"
- name: '@deepseek-ai/dsh-repeat-tool-reminder'
  config:
    thresholds: [3, 5, 8]        # nhắc ở lần lặp thứ 3, 5, và 8
    exclude: [todo_write]        # không bao giờ theo dõi các tool này
    argumentsPreviewChars: 500   # số ký tự tham số hiện trong nhắc chi tiết
```

Ba ngưỡng, và cả ba đều là **nhắc**. Lần thứ ba model nhận một message ngắn. Lần thứ năm và thứ tám nhận bản chi tiết, có tên tool và tham số lặp lại.

Tài liệu nói rõ vì sao chọn nhắc thay vì chặn. Nó hợp lý khi model chạy tự chủ dài và vòng lặp kẹt là thứ bạn muốn phá bằng lời khuyên. Nó không hợp lý khi lặp là chính đáng và cần chạy yên.

Và có một giới hạn được nói thẳng. Nó chỉ bắt được lặp **chính xác**: cùng tool, cùng tham số, bất kể thứ tự thuộc tính. Còn biến thể gần giống thì không.

Cấu hình sai thì **fail lúc khởi động**, không đổi hành vi im lặng. Ba trường hợp bị từ chối: danh sách ngưỡng rỗng, một ngưỡng dưới 2, hoặc một ngưỡng trùng.

```diagram
type: flowchart
title: Bảy chặng của một lời gọi tool
desc: Ba chặng gác trước khi tool chạy, bốn chặng xử lý và định dạng kết quả gửi lại model
nodes:
  - id: p1
    label: 1. tools/pre-execute
    sub: allow / deny / ask
    focal: true
  - id: p2
    label: 2. Guard đơn điệu
    sub: Chỉ có thể giảm quyền
  - id: p3
    label: 3. tools/execute
    sub: Wrapper chạy thân tool
  - id: p4
    label: 4. projectContent
    sub: Dựng nội dung thô
  - id: p5
    label: 5. tools/post-execute
    sub: Hook xem hoặc thay kết quả
  - id: p6
    label: 6. finalizeContent
    sub: Chốt nội dung lần cuối
  - id: p7
    label: 7. tools/result
    sub: Kết quả bất biến ghi vào log
    focal: true
edges:
  - [p1, p2]
  - [p2, p3]
  - [p3, p4]
  - [p4, p5]
  - [p5, p6]
  - [p6, p7]
```

## Hai thứ không tồn tại

Đọc tài liệu để tìm một thứ và không thấy nó cũng là một phát hiện.

**Không có ngân sách tool tổng.** Tôi tìm một giới hạn kiểu "tối đa N lời gọi tool mỗi turn" và không có. Có timeout cho từng tool — `dsh-tool-web` mặc định 30.000 ms — nhưng không có trần cho cả phiên.

**Không có retry mặc định cho tool.** Chặng 3 là chỗ *để* bạn gắn retry, không phải chỗ đã có retry. Tài liệu mô tả nó là around-dispatch waterfall cho timeout, retry, hoặc số đo — nghĩa là bạn tự viết nếu cần.

Cả hai đều nhất quán với quyết định ở bài 4: harness không tự đặt ngân sách, nó đưa ra chỗ để bạn đặt.

## Cái giá

Mỗi lời gọi đi qua bảy chặng, kể cả những lời gọi chắc chắn được phép.

Bảy chặng nghĩa là bảy cơ hội để một listener viết sai làm hỏng một lời gọi bình thường. Và vì tham số không sửa được, listener phát hiện tham số sai chỉ có một cách xử lý. Nó phải từ chối, và bắt model gọi lại. Chậm hơn sửa tại chỗ.

Đổi lại: một chỗ để đặt chính sách, một chỗ để kiểm toán, và một thân tool không biết gì về quyền.

<Callout type="warn" title="Một lời gọi bị chặn vẫn phải trả về kết quả đọc được">
Nếu bạn chặn một lời gọi và trả về chuỗi rỗng hoặc một lỗi mơ hồ, model sẽ không hiểu chuyện gì xảy ra và gọi lại. Lý do từ chối phải nói được vì sao, và phải nói bằng ngôn ngữ model dùng được. Chặn mà không giải thích là biến một lần từ chối thành một vòng lặp.
</Callout>

## Làm được ngay

<Steps>
<Step title="Đừng để tool tự kiểm tra quyền">Nếu mỗi tool tự quyết định, bạn không có chỗ nào trả lời được câu hỏi toàn cục. Đặt chính sách trên đường đi và giữ thân tool sạch.</Step>
<Step title="Cho guard kiểu trả về một chiều">Nếu guard không có kết quả allow, thứ tự gọi không còn là vấn đề an toàn. Bạn xoá một lớp bug bằng cách chọn kiểu.</Step>
<Step title="Chốt tham số trước khi chính sách chạy">Ghi log và hiển thị tham số trước, rồi mới hỏi. Sửa tham số sau khi đã hỏi là làm lịch sử và thực thi lệch nhau.</Step>
<Step title="Chặn thì phải kèm lý do đọc được">Một lời gọi bị chặn mà không giải thích sẽ khiến model thử lại. Lý do từ chối là một phần của kết quả, không phải ghi chú thêm.</Step>
</Steps>

## Nguyên tắc

Chính sách đặt trong từng tool là chính sách bạn không kiểm toán được. Đặt nó trên đường đi, và thân tool trở thành thứ đơn giản nhất trong hệ thống.

<Quote cite="tusgino">Một lời gọi tool bị chặn vẫn phải trả về kết quả đọc được. Nếu không, model sẽ thử lại mãi — và bạn đã biến một lần từ chối thành một vòng lặp.</Quote>

<KeyTakeaways>
- Bảy chặng: ba gác trước khi tool chạy, bốn xử lý kết quả sau đó.
- Approval là listener của `tools/pre-execute`, không phải một chặng riêng.
- Guard chỉ giảm được quyền — không có kết quả allow, nên guard sau không hoàn tác được guard trước.
- Tham số không sửa được, vì lịch sử, kiểm toán, giao diện và thực thi phải khớp nhau.
- Không có ngân sách tool tổng và không có retry mặc định; harness đưa chỗ, không đặt sẵn.
</KeyTakeaways>
