0

AI agent bấm nhầm nút thì sao? Xây lớp "gác cổng" cho tool calling theo OWASP LLM06 (kèm code Node.js)

Khi LLM chỉ sinh văn bản, nếu trả lời sai thì người đọc vẫn còn cơ hội lọc lại. Khi LLM thành agent và được quyền gọi tool như gửi email, hoàn tiền hay sửa dữ liệu, một câu trả lời sai sẽ thành một hành động thật.

Lý do agent làm sai thường không phức tạp:

Model "bịa" ra tham số không tồn tại (hallucination). Dữ liệu agent đọc (email, trang web, tài liệu) có chứa lệnh ẩn (prompt injection), khiến agent làm việc người dùng không yêu cầu. Developer cấp cho agent nhiều quyền hơn mức cần, vì "cho tiện".

Bài này đi từ khuyến nghị của OWASP tới một module khoảng 60 dòng, không dùng thư viện. Module nằm giữa LLM và hệ thống thật, quyết định lời gọi tool nào được chạy. Toàn bộ code đều có test chạy được.

1. OWASP gọi tên vấn đề này là "Excessive Agency"

Trong OWASP Top 10 for LLM Applications 2025, rủi ro LLM06:2025 Excessive Agency xuất phát từ 3 nguyên nhân:

image.png

Các biện pháp OWASP đề xuất có thể tóm lại thành 4 ý:

  1. Chỉ cho agent gọi số tool tối thiểu.
  2. Cấp quyền tối thiểu cho hệ thống phía sau.
  3. Yêu cầu người duyệt với hành động có tác động lớn.
  4. Kiểm tra quyền ở hệ thống phía sau, không để LLM tự quyết một hành động có được phép hay không (complete mediation).

2. Nguyên tắc thiết kế: LLM đề xuất, code quyết định

LLM chỉ trả ra một đề xuất gọi tool dưới dạng JSON. Đề xuất đó phải đi qua một lớp gác cổng trước khi chạm tới hệ thống thật:

image.png

Mỗi tool được khai báo mức rủi ro:

read: chỉ đọc, chạy ngay. write: có ghi hoặc gửi ra ngoài, cần người duyệt. critical: tác động tiền bạc hoặc không đảo ngược được, cần người duyệt và hệ thống phía sau kiểm tra lại.

3. Code: agent-guard.mjs

Chạy được với Node.js 18 trở lên, không cần cài gói nào:

js
// Lớp "gác cổng" giữa LLM và hệ thống thật.
// LLM chỉ ĐỀ XUẤT lời gọi tool; code này mới là nơi quyết định có chạy hay không.

function validate(schema, args) {
  const errors = [];
  if (typeof args !== "object" || args === null) return ["args phải là object"];

  for (const key of Object.keys(args)) {
    if (!(key in schema)) errors.push(`Tham số lạ: ${key}`); // chặn field "cài cắm"
  }
  for (const [key, rule] of Object.entries(schema)) {
    const v = args[key];
    if (v === undefined) {
      if (!rule.optional) errors.push(`Thiếu tham số: ${key}`);
      continue;
    }
    if (typeof v !== rule.type) { errors.push(`${key} phải là ${rule.type}`); continue; }
    if (rule.pattern && !rule.pattern.test(v)) errors.push(`${key} sai định dạng`);
    if (rule.maxLength && v.length > rule.maxLength) errors.push(`${key} quá dài`);
    if (rule.enum && !rule.enum.includes(v)) errors.push(`${key} không hợp lệ`);
    if (rule.min !== undefined && v < rule.min) errors.push(`${key} nhỏ hơn ${rule.min}`);
    if (rule.max !== undefined && v > rule.max) errors.push(`${key} lớn hơn ${rule.max}`);
  }
  return errors;
}

export function createExecutor({ tools, approve, log = () => {} }) {
  const done = new Map(); // idempotency: mỗi call.id chỉ chạy 1 lần

  return async function execute(call) {
    const { id, name, args } = call;
    const deny = (error) => { log({ id, name, status: "denied", error }); return { ok: false, error }; };

    if (done.has(id)) return done.get(id);

    const tool = tools[name];
    if (!tool) return deny(`Tool không tồn tại: ${name}`);

    const errors = validate(tool.params, args);
    if (errors.length) return deny(errors.join("; "));

    // Mọi thao tác ghi đều cần người duyệt
    if (tool.risk !== "read") {
      const approved = await approve({ name, args, risk: tool.risk });
      if (!approved) return deny("Người duyệt từ chối");
    }

    try {
      const result = { ok: true, result: await tool.run(args) };
      done.set(id, result);
      log({ id, name, status: "done" });
      return result;
    } catch (e) {
      return deny(e.message); // lỗi nghiệp vụ từ hệ thống phía sau
    }
  };
}

Có 3 điểm nhỏ nhưng quan trọng:

Chặn tham số lạ. Nếu prompt injection khiến model thêm bcc: attacker@... vào lời gọi gửi email, schema sẽ chặn lại dù các tham số còn lại đều hợp lệ. Idempotency theo call.id. Agent có thể "thử lại" cùng một lời gọi khi gặp timeout. Thao tác gửi email hay hoàn tiền không được phép chạy 2 lần. Bắt lỗi từ hệ thống phía sau và trả về cho agent dưới dạng { ok: false, error }. Nhờ vậy agent biết vì sao bị từ chối, thay vì làm sập luồng xử lý.

4. Khai báo tool và test

File agent-guard.test.mjs khai báo 3 tool với 3 mức rủi ro khác nhau, sau đó thử từng kiểu lời gọi "xấu":

js
import assert from "node:assert/strict";
import { createExecutor } from "./agent-guard.mjs";

// "Database" giả lập
const orders = { DH000001: { total: 300000, refunded: 0 } };
const sent = [];

const tools = {
  get_order: {
    risk: "read",
    params: { orderId: { type: "string", pattern: /^DH\d{6}$/ } },
    run: ({ orderId }) => orders[orderId] ?? null,
  },
  send_email: {
    risk: "write",
    params: {
      to: { type: "string", pattern: /^[^@\s]+@[^@\s]+\.[^@\s]+$/ },
      subject: { type: "string", maxLength: 120 },
      body: { type: "string", maxLength: 2000 },
    },
    run: (args) => { sent.push(args); return "sent"; },
  },
  refund_order: {
    risk: "critical",
    params: {
      orderId: { type: "string", pattern: /^DH\d{6}$/ },
      amount: { type: "number", min: 1 },
    },
    // Kiểm tra quyền ở hệ thống phía sau, không tin vào LLM
    run: ({ orderId, amount }) => {
      const o = orders[orderId];
      if (!o) throw new Error("Không có đơn hàng");
      if (o.refunded + amount > o.total) throw new Error("Vượt quá số tiền đơn hàng");
      o.refunded += amount;
      return { refunded: o.refunded };
    },
  },
};

const logs = [];
let approvals = 0;
let approveAnswer = true;
const execute = createExecutor({
  tools,
  approve: async () => { approvals++; return approveAnswer; },
  log: (e) => logs.push(e),
});

// 1. Tool chỉ đọc chạy ngay, không cần duyệt
let r = await execute({ id: "c1", name: "get_order", args: { orderId: "DH000001" } });
assert.equal(r.ok, true);
assert.equal(approvals, 0);

// 2. Tool không tồn tại bị chặn
r = await execute({ id: "c2", name: "delete_database", args: {} });
assert.equal(r.ok, false);

// 3. Tham số lạ (ví dụ do prompt injection chèn vào) bị chặn
r = await execute({ id: "c3", name: "send_email", args: { to: "a@b.vn", subject: "Hi", body: "x", bcc: "attacker@evil.com" } });
assert.match(r.error, /Tham số lạ: bcc/);

// 4. Sai định dạng bị chặn
r = await execute({ id: "c4", name: "get_order", args: { orderId: "1 OR 1=1" } });
assert.match(r.error, /sai định dạng/);

// 5. Thao tác ghi cần duyệt; bị từ chối thì không chạy
approveAnswer = false;
r = await execute({ id: "c5", name: "send_email", args: { to: "a@b.vn", subject: "Hi", body: "x" } });
assert.equal(r.ok, false);
assert.equal(sent.length, 0);

// 6. Được duyệt thì chạy, và gọi lại cùng id không chạy lần 2
approveAnswer = true;
r = await execute({ id: "c6", name: "send_email", args: { to: "a@b.vn", subject: "Hi", body: "x" } });
assert.equal(r.ok, true);
await execute({ id: "c6", name: "send_email", args: { to: "a@b.vn", subject: "Hi", body: "x" } });
assert.equal(sent.length, 1);

// 7. Dù người duyệt bấm OK, hệ thống phía sau vẫn chặn hoàn tiền vượt số tiền đơn
r = await execute({ id: "c7", name: "refund_order", args: { orderId: "DH000001", amount: 500000 } });
assert.match(r.error, /Vượt quá/);
r = await execute({ id: "c8", name: "refund_order", args: { orderId: "DH000001", amount: 100000 } });
assert.deepEqual(r, { ok: true, result: { refunded: 100000 } });

console.log("OK: 7 nhóm test đều pass");
bash
node agent-guard.test.mjs
OK: 7 nhóm test đều pass

Đối chiếu test với khuyến nghị của OWASP:

image.png Test 7 là test quan trọng nhất: người duyệt cũng có thể sai, nên quy tắc nghiệp vụ phải nằm ở hệ thống, không nằm ở prompt.

5. Khi đưa lên môi trường thật

Module trên là bản tối giản để minh họa. Khi dùng thật, nên cân nhắc thêm:

  • Lưu done vào database với khóa duy nhất theo call.id, thay vì dùng Map trong bộ nhớ.
  • Đưa bước duyệt ra kênh mà người duyệt thật sự dùng (dashboard nội bộ, chat), kèm nội dung "sẽ làm gì, với ai, bao nhiêu" thật rõ.
  • Agent dùng quyền của người dùng đang chat, không dùng một tài khoản quản trị chung.
  • Ghi log đầy đủ cả lời gọi bị từ chối, để biết agent đang bị "dụ" làm gì.
  • Giới hạn tần suất với các tool ghi, tránh agent gọi lặp liên tục.

Tóm lại, agent càng được giao nhiều việc thì lớp gác cổng càng phải chặt. A.I chuẩn bị, con người duyệt, hệ thống kiểm tra lần cuối, rồi mới thực hiện.

Bạn đang cho agent của mình quyền gì, và đã từng gặp tình huống agent "làm quá tay" chưa? Chia sẻ ở phần bình luận nhé.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí