# Audit log cho agent: ghi cả người dùng lẫn agent làm thay họ

> Khi agent làm sai trên hệ thống của khách hàng, một log chỉ ghi tên người dùng sẽ không trả lời được câu hỏi đầu tiên ai cũng đặt ra: ai đã làm việc này.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-audit-log-cho-agent-ai-lam-gi-thay-ai/

Thử hình dung một tình huống: agent hỗ trợ của khách hàng vừa đóng 40 ticket lẽ ra phải để mở. Log của hệ thống ticket ghi người thực hiện là trưởng nhóm hỗ trợ.

Thực tế anh ấy chỉ gõ một câu yêu cầu, còn agent tự chọn tool, tự điền tham số và chạy. Khi khách hàng hỏi ai chịu trách nhiệm, bạn không có bằng chứng nào để trả lời.

Lỗi nằm ở log, không phải ở model. Tài liệu bảo mật của Model Context Protocol mô tả đúng hiện tượng này: khi token bị chuyển thẳng qua (passthrough), log phía sau có thể ghi nhận request như thể đến từ một nguồn khác với một danh tính khác.

Bài hướng dẫn này dựng một audit log trả lời được ba câu hỏi: ai kích hoạt, agent nào hành động thay họ, và đã gọi tool nào với tham số gì.

Để làm theo, bạn chỉ cần Python 3 và một dự án Claude Code thử nghiệm để gắn hook. Các đoạn code dưới đây đã được giản lược để dễ học. Trước khi đưa vào môi trường thật, hãy đối chiếu cú pháp cấu hình hook với tài liệu Claude Code.

## Schema đúng bắt đầu từ bốn câu hỏi

OWASP Logging Cheat Sheet đặt ra yêu cầu nền: mỗi bản ghi phải trả lời được khi nào, ở đâu, ai và cái gì, và phải có đủ thông tin cho việc giám sát, phân tích về sau. Với agent, câu hỏi "ai" khó hơn hẳn, vì luôn có ít nhất hai danh tính cùng tham gia vào một hành động.

RFC 8693 (OAuth 2.0 Token Exchange) phân biệt rõ hai cơ chế. Với impersonation, bên hành động nhận toàn bộ quyền của người gốc và log không còn tách được hai bên. Với delegation, agent vẫn giữ danh tính riêng. Vì vậy schema của bạn cần hai trường riêng, `on_behalf_of` cho người dùng và `actor` cho agent.

| Câu hỏi | Trường trong bản ghi | Lấy từ đâu |
|---|---|---|
| Khi nào | `when` (UTC, ISO 8601) | Đồng hồ máy chủ |
| Ở đâu | `host`, `session_id` | Máy chạy, dữ liệu JSON của hook |
| Ai | `on_behalf_of`, `actor`, `actor_chain` | Claim `sub` và `act` trong token |
| Cái gì | `tool_name`, `tool_use_id`, tham số | Dữ liệu JSON của hook |

**Cách kiểm tra:** đưa bảng này cho một đồng nghiệp và hỏi họ có trả lời được "agent nào đã đóng 40 ticket" chỉ từ các trường trên không. Nếu không được, schema vẫn còn thiếu.

## Bước 1: bắt từng lần gọi tool bằng hook

Hook của Claude Code chạy ở mỗi lần gọi tool và nhận một gói JSON có `session_id`, `tool_name`, `tool_input` và `tool_use_id`. Bốn trường này đủ để biết tool nào được gọi, với tham số gì và trong phiên nào. Tài liệu hooks có sẵn một ví dụ PreToolUse ghi nối vào file log và dùng matcher để lọc các tool MCP, và đoạn code dưới đây dựa trên mẫu đó.

```python
#!/usr/bin/env python3
# audit_hook.py - bản giản lược để học
import json, sys, os, hashlib, datetime

LOG = os.path.expanduser("~/agent-audit.jsonl")
event = json.load(sys.stdin)  # gói JSON hook nhận được

tool_input = event.get("tool_input") or {}
record = {
    "when": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    "where": {"host": os.uname().nodename,
              "session_id": event.get("session_id")},
    "what": {"tool_name": event.get("tool_name"),
             "tool_use_id": event.get("tool_use_id"),
             "input_keys": sorted(tool_input.keys())},
}
```

Hãy đăng ký script này làm hook PreToolUse. Nếu chỉ muốn theo dõi tool MCP, thêm matcher như ví dụ trong tài liệu. **Cách kiểm tra:** cho agent gọi một tool bất kỳ rồi mở file log. Bạn phải thấy `tool_use_id` khớp với lần gọi vừa chạy.

## Bước 2: tách người dùng khỏi agent

Hook cho bạn biết cái gì đã xảy ra, nhưng câu trả lời cho "ai" nằm ở token. RFC 8693 định nghĩa claim `act` trong JWT để thể hiện rằng có delegation: `sub` vẫn là người dùng, còn `act` cho biết ai đang hành động. Khi một agent gọi một agent khác, các `act` lồng nhau thành một chuỗi.

```python
def who_from_claims(claims: dict) -> dict:
    chain, act = [], claims.get("act")
    while act:                      # act ngoài cùng = actor hiện tại
        chain.append(act.get("sub"))
        act = act.get("act")        # act lồng bên trong = actor trước đó
    return {"on_behalf_of": claims.get("sub"),
            "actor": chain[0] if chain else None,
            "actor_chain": chain}
```

Có một chi tiết dễ bỏ qua. RFC 8693 nói các actor trước đó trong chuỗi `act` lồng nhau chỉ mang tính thông tin, tức token không ép buộc gì với chúng. Muốn sau này truy lại được chuỗi đó thì chính log phải lưu nó. Đó là lý do có trường `actor_chain`.

Hàm này phải chạy ở phía server nhận request, vì token nằm ở đó. MCP khuyến nghị server chỉ chấp nhận token được cấp cho chính nó, vì token passthrough làm việc điều tra sự cố và kiểm toán khó hơn.

Nếu bạn để token của người dùng đi xuyên qua nhiều tầng, `actor` sẽ luôn rỗng và bạn quay lại đúng tình huống 40 ticket. **Cách kiểm tra:** giải mã một token thật mà agent đang dùng để gọi API, rồi xem `sub` là ai trước khi kết luận.

Nếu `sub` là người dùng nhưng không có claim `act`, token không thể hiện delegation nào: agent đang impersonation hoặc chuyển thẳng token của người dùng, và log phía sau sẽ không tách được hai bên. Còn nếu `sub` chính là agent, nghĩa là agent đang dùng token của riêng nó, thì việc thiếu `act` là bình thường vì không có ai được làm thay.

Trường hợp sau ghi đúng `actor`, nhưng token không nói gì về người dùng đã kích hoạt. Khi đó `on_behalf_of` phải lấy từ phiên làm việc của người dùng và ghi riêng, nếu không bản ghi sẽ chỉ còn một nửa câu trả lời cho câu hỏi "ai".

**Điểm mấu chốt:** Log chỉ ghi tên người dùng thì không bao giờ cho bạn biết agent đã làm gì thay họ.

## Bước 3: coi mọi tham số là văn bản không đáng tin

Tham số của tool do model sinh ra, và model có thể đã bị prompt injection điều khiển. OWASP yêu cầu làm sạch toàn bộ dữ liệu sự kiện trước khi ghi để chống log injection.

Thử hình dung tham số chứa ký tự xuống dòng kèm một dòng JSON giả: nếu bạn ghi thẳng chuỗi đó, kẻ tấn công vừa tự tạo được một bản ghi trong log của bạn.

Cách phòng thủ đơn giản là luôn ghi bằng `json.dumps`. Hàm này escape ký tự xuống dòng nên mỗi bản ghi chắc chắn nằm trên đúng một dòng. Vấn đề thứ hai là dữ liệu cá nhân. Quy ước GenAI của OpenTelemetry cảnh báo rằng nội dung message nhiều khả năng chứa PII, nên chỉ ghi nội dung khi được bật rõ ràng.

```python
if os.environ.get("AUDIT_LOG_CONTENT") == "1":   # opt-in
    record["what"]["input"] = {k: str(v)[:500] for k, v in tool_input.items()}
```

Mặc định, log chỉ giữ tên các khoá tham số. Như vậy vẫn đủ để biết agent gọi tool `delete` với khoá `ticket_id` mà không lưu email khách hàng. Giới hạn 500 ký tự là con số minh hoạ, hãy chọn theo nhu cầu của bạn.

## Bước 4: làm cho log khó bị sửa lén

Một audit log mà ai đó sửa được thì không còn là bằng chứng. OWASP khuyên tích hợp cơ chế phát hiện khi bản ghi bị sửa hoặc bị xoá, và sao chép log sang kho chỉ đọc càng sớm càng tốt. Một cách đơn giản là nối chuỗi hash: mỗi dòng giữ hash của dòng trước nó.

```python
def last_hash():
    try:
        with open(LOG, "rb") as f:
            return json.loads(f.readlines()[-1])["hash"]
    except (FileNotFoundError, IndexError):
        return "0" * 64

record["prev"] = last_hash()
body = json.dumps(record, sort_keys=True, ensure_ascii=False)
record["hash"] = hashlib.sha256(body.encode()).hexdigest()
with open(LOG, "a", encoding="utf-8") as f:
    f.write(json.dumps(record, ensure_ascii=False) + "\n")
```

Bản giản lược này chưa khoá file khi nhiều hook ghi cùng lúc, và nó chỉ giúp phát hiện chỉnh sửa chứ không ngăn được. Phần ngăn chặn đến từ việc đẩy log sang kho chỉ đọc. Để kiểm tra, bạn cần một script đi lại từ đầu file và tính lại từng hash theo đúng cách hook đã tính.

```python
#!/usr/bin/env python3
# verify_chain.py - tính lại chuỗi hash từ dòng đầu tiên
import json, hashlib, os, sys

LOG = os.path.expanduser("~/agent-audit.jsonl")
prev = "0" * 64
with open(LOG, encoding="utf-8") as f:
    for n, line in enumerate(f, 1):
        rec = json.loads(line)
        h = rec.pop("hash")
        body = json.dumps(rec, sort_keys=True, ensure_ascii=False)
        if rec.get("prev") != prev or hashlib.sha256(body.encode()).hexdigest() != h:
            sys.exit(f"Đứt chuỗi tại dòng {n}")
        prev = h
print("Chuỗi nguyên vẹn")
```

**Cách kiểm tra:** chạy `python3 verify_chain.py` khi log còn nguyên, bạn phải thấy "Chuỗi nguyên vẹn". Sau đó sửa tay một ký tự ở dòng thứ năm và chạy lại: script phải báo đứt chuỗi đúng tại dòng 5. Nếu bạn xoá hẳn một dòng, trường `prev` của dòng kế tiếp sẽ không khớp và script cũng dừng ở đó.

## Bước 5: ghi cả lúc quyền được nới rộng

Hành động nguy hiểm thường đi kèm một lần xin thêm quyền. MCP khuyến nghị ghi các sự kiện nâng scope, gồm scope được yêu cầu và phần scope thực sự được cấp, kèm correlation ID. Tài liệu đó cũng cảnh báo rằng scope gộp quá rộng làm audit trail nhiễu và che mất ý định của người dùng.

Hãy dùng chung một correlation ID cho sự kiện cấp quyền và các lần gọi tool diễn ra sau đó. Khi điều tra, bạn sẽ đọc được trọn câu chuyện: người dùng yêu cầu gì, agent xin quyền gì, được cấp đến đâu, rồi đã dùng quyền đó vào việc gì.

## Những lỗi thường gặp

Lỗi phổ biến nhất là ghi `sub` mà không ghi `act`, khiến mọi hành động của agent trông như người dùng tự làm. Lỗi thứ hai là bật ghi toàn bộ nội dung prompt "cho dễ debug", và log trở thành kho PII không ai kiểm soát.

Lỗi thứ ba là để log trên cùng máy với agent, nơi agent có quyền ghi file, nên chính agent cũng có thể sửa được log của mình.

Nếu bạn muốn ghép vào hệ thống observability có sẵn, quy ước GenAI của OpenTelemetry định nghĩa thao tác `invoke_agent` và một thuộc tính định danh agent. Dùng chung tên trường với quy ước này giúp đội vận hành của khách hàng đọc được log mà không cần bạn giải thích.

## Kỹ năng này xuất hiện ở đâu ngoài thực tế?

Thử hình dung đội bảo mật của khách hàng hỏi bạn sẽ truy lại hành động của agent thế nào khi có sự cố.

Nếu bạn mở ra được schema bốn câu hỏi, giải thích chuỗi `act` và chạy thử script phát hiện chỉnh sửa ngay trong buổi họp, cuộc trao đổi sẽ đi vào chi tiết thay vì dừng ở lời hứa.

Một FDE chỉ nói "chúng tôi có log" thì chưa trả lời được gì.

Khi đọc mô tả công việc FDE, hãy để ý các cụm như "enterprise security review", "compliance" hay "agent governance". Trong CV, đừng viết "đã làm logging". Hãy viết rằng bạn đã thiết kế audit trail tách người dùng khỏi agent theo RFC 8693 và kiểm chứng được tính toàn vẹn bằng chuỗi hash, rồi mang theo file log để cho nhà tuyển dụng xem khi họ hỏi tiếp.

Agent càng được giao nhiều việc thì câu hỏi "ai đã làm việc này" càng hay được đặt ra. Câu trả lời thuyết phục nhất là một file log đã nằm sẵn trong kho chỉ đọc từ trước sự cố.

**Thử ngay tuần này:**

- Cài hook audit trong bài vào một dự án Claude Code thử nghiệm, chạy 10 lần gọi tool rồi sửa tay một dòng log và chạy verify_chain.py để xem nó báo lỗi
- Lấy một token mà agent của bạn đang dùng để gọi API, giải mã ra và kiểm tra sub là ai và bên cạnh có claim act hay không
- Viết một truy vấn trên log trả lời câu hỏi: trong 24 giờ qua, agent nào đã gọi tool ghi dữ liệu thay cho người dùng X

## Nguồn

- [Logging - OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)

- [OAuth 2.0 Token Exchange (RFC 8693)](https://www.rfc-editor.org/rfc/rfc8693.html)

- [Security Best Practices - Model Context Protocol](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices)

- [Hooks reference - Claude Code Docs](https://code.claude.com/docs/en/hooks)

- [Semantic Conventions for GenAI agent and framework spans](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md)
