Trong bài "Vì sao AI agent nên thôi xin phép", tôi gây sự với cái permission prompt. Bài đó là cú đấm. Bài này là phần kỹ thuật đằng sau, vì "thôi hỏi" chỉ đứng được khi có thứ gì đó tốt hơn đứng vào chỗ cái prompt.

Bản lười của một agent tự chủ thì dễ làm lắm: đưa model cái shell, tắt hết xác nhận, rồi cầu nguyện. Tôi không có hứng với kiểu đó, và bạn cũng không nên có. Câu hỏi tôi thật sự quan tâm khó hơn: làm sao để agent tự làm việc trong vài phút hay vài tiếng, mà người dùng vẫn là người có quyền quyết định cuối cùng với những gì xảy ra trên máy của họ?

Câu trả lời của tôi, sau rất nhiều lần viết lại trong codebase ClarkCant, là quyền quyết định không phải một hộp thoại. Nó là những thứ nằm trong tay người dùng: policy, khả năng dừng, bản ghi những gì đã xảy ra, quyền chốt mọi thứ còn chưa chắc chắn, và những giới hạn không gì vượt qua được. Dưới đây là từng thứ, nó nằm ở đâu trong code, và nó còn yếu ở đâu.

Người quyết định cuối cùng là một vai trò, không phải một prompt

Bắt đầu từ chuyện ai được quyết. Trong ClarkCant chỉ có user principal mới duyệt được một thao tác; điều này được cưỡng chế trong contract của grant, không phải ở UI. Open interfaces còn làm rõ hơn: cố tình không có tool duyệt nào trên MCP. Một AI client gọi được "approve" thì có thể tự duyệt hành động của chính nó, nên mọi route ghi lại quyết định của con người đều trả 403 PERSON_ONLY khi đi qua MCP hay các relay chung.

Vậy là người dùng không bị hỏi về mọi thứ. Nhưng những việc chỉ con người được làm, duyệt, quyết định cài đặt, tin một máy đã ghép nối, cấp grant, chốt một kết quả chưa rõ, thì không thứ gì khác làm thay được. Đó là "người quyết định cuối cùng" trong thực tế.

Con đường mọi hiệu lực đi qua trong ClarkCant. Yêu cầu của bạn tới preflight của host, nơi kiểm tra thư mục thuộc quyền node và gắn deadline cùng trần output. Nếu hợp lệ, execution policy quyết định từ chối, hỏi hay chạy. Nếu hỏi, con người quyết và khi được duyệt thì chạy. Nếu được phép, guardrail Jev có thể cho phép hoặc thu hẹp, không bao giờ nới, rồi hiệu lực chạy với secret được tiêm đúng lúc. Việc chạy tạo ra evidence và một dòng audit chỉ ghi thêm. Nếu mất phản hồi, kết quả là unknown, task chuyển sang uncertain, và chỉ con người được chốt. Stop giết process group của việc đang chạy bất cứ lúc nào.

Policy: một quyết định, khai báo một lần

Cho tới ngày 19/09/2026, mỗi chỗ trong ClarkCant tự quyết có hỏi hay không. Giờ chỉ còn một hàm, decideExecution trong packages/core/src/execution-policy.ts, và mọi hiệu lực đều đi qua nó: một lệnh, một hành động widget không chỉ đổi cách hiển thị, một lần cài đặt. Thứ tự được ghi ngay đầu file: lệnh cấm, rồi ranh giới cứng, rồi quy tắc, rồi mức tự chủ.

"Từ chối tất cả" ở cấp node thắng mọi thứ. Tiếp theo là ranh giới cứng: quyền của hệ điều hành, OAuth, quyền thiết bị của trình duyệt hay màn hình consent của nhà cung cấp đều được hỏi ở mọi mức, vì đó không phải quyết định của ứng dụng này. Rồi tới quy tắc theo từng loại hiệu lực bạn đặt, nơi "từ chối" luôn thắng. Cuối cùng là mức: Tự chủ (mặc định), Có rào, Hỏi mỗi lần.

Mức Tự chủ có một cổng rủi ro. Nếu agent tự quyết ghi ra ngoài máy, phá huỷ thứ gì đó, tiêu tiền, liên lạc, hay ghi âm ghi hình, nó sẽ hỏi, vì không có chỉ dẫn nào của con người bao trùm việc đó. Việc do node ghép nối giao sang chỉ chạy trong phạm vi chủ node này cho phép peer đó. Và một mức mà bản build không nhận ra thì fail closed: câu trả lời trung thực cho "tôi làm được không?" là không, chứ không phải đoán.

Thực thi có giới hạn và cách ly

Policy quyết ai được hỏi. Preflight quyết cái gì làm được, và nó chạy trước khi bất kỳ model nào được hỏi. Trong apps/runtime/src/preflight.ts, mọi lệnh phải nằm trong một thư mục node sở hữu, thư mục đó phải tồn tại, và thao tác mang theo ngân sách: với run_command là hai phút và 8.000 byte output mỗi stream. Capability nào node chưa discover thì không gọi được, đề xuất có nói gì cũng vậy.

Jev, lớp guardrail, đứng sau đó và được xác định rõ là lớp phán đoán, không phải ranh giới bảo mật. Nó được cho phép, từ chối, hỏi lại cho rõ, hoặc thu hẹp. Nếu câu trả lời của nó nới bất cứ thứ gì, deadline dài hơn, output lớn hơn, thư mục ngoài chỗ host đã xác định, applyGuardrailConstraints từ chối cả câu trả lời thay vì lặng lẽ kẹp lại.

Cách ly ở đây là thứ cụ thể chứ không phải khẩu hiệu. Process con chỉ thừa hưởng môi trường theo allowlist, nên SSH_AUTH_SOCK, mấy biến AWS_*, GITHUB_TOKEN và key của provider bị bỏ đi. Mỗi process con chạy trong process group riêng để lệnh dừng chạm tới cả process cháu. Worker của task được dispatch không có shell: lệnh của nó đi qua kênh IPC về host và đi đúng preflight, policy, guardrail và audit như mọi lệnh khác; chỗ nào đường đó cần approval card thì broker từ chối luôn, vì việc nền không bao giờ nên ngồi chờ một câu hỏi không ai nhìn thấy. Task trình duyệt có profile Playwright riêng nằm trong thư mục dữ liệu của node, không bao giờ là trình duyệt của bạn, chỉ được vào những site bạn nêu tên, địa chỉ mạng nội bộ bị từ chối, và profile bị xoá khi chạy xong.

Tài liệu cũng thẳng thắn rằng không cái nào ở trên là sandbox hoàn hảo. Nó nói rõ đừng quảng cáo lease như sandbox, và browser context không phải ranh giới bảo mật.

Trích mã nguồn apps/runtime/src/preflight.ts, dòng 436 tới 452, phần đầu của applyGuardrailConstraints. Với mỗi ràng buộc guardrail trả về, timeout dài hơn mức host đã đặt bị từ chối với mã GUARDRAIL_WIDENS, kèm thông báo rằng guardrail xin thêm thời gian và nó chỉ được thu hẹp; timeout ngắn hơn thì được áp dụng. Trần output và thư mục làm việc theo đúng khuôn đó.

Giới hạn tài nguyên

Một agent được chạy không ai trông cần những giới hạn không phụ thuộc vào việc nó có ngoan hay không. Việc nền mặc định chạy tối đa 3 việc cùng lúc (chọn 1, 3 hoặc 5 trong Settings), hàng đợi tối đa 10, và cuộc trò chuyện chính không bao giờ bị tính vào đó. Worker của task nếu không tự đặt ngân sách sẽ nhận 500.000 token và 10 phút, chỉnh được qua CC_WORKER_MAX_TOKENS và CC_WORKER_MAX_WALL_CLOCK_MS. Hết giờ thì worker bị dừng và task nói rõ điều đó.

Một lưu ý trung thực từ tài liệu: token được đếm khi một lượt kết thúc, nên worker có thể vượt ngân sách token tối đa một lượt. Giữa các máy, ngân sách được lấy giao như mọi thứ khác. Grant từ node ghép nối chỉ bị thu hẹp khi giữ thêm grant khác, và grant không đặt ngân sách byte cho file trả về thì cho phép không byte nào, chứ không phải vô hạn.

Giới hạn mặc định trong ClarkCant: 3 việc nền chạy cùng lúc (chọn 1, 3 hoặc 5 trong Settings), hàng đợi việc nền 10, ngân sách token của worker 500.000 token (CC_WORKER_MAX_TOKENS), ngân sách thời gian của worker 10 phút (CC_WORKER_MAX_WALL_CLOCK_MS), deadline 2 phút cho run_command và 8.000 byte output mỗi stream.

Dữ liệu
[
  {
    "id": "bg",
    "label": "Việc nền chạy cùng lúc",
    "value": 3,
    "hint": "chọn 1, 3 hoặc 5 trong Settings"
  },
  {
    "id": "queue",
    "label": "Hàng đợi việc nền",
    "value": 10
  },
  {
    "id": "tokens",
    "label": "Ngân sách token của worker",
    "value": 500000,
    "unit": "token",
    "hint": "CC_WORKER_MAX_TOKENS"
  },
  {
    "id": "wall",
    "label": "Ngân sách thời gian của worker",
    "value": 10,
    "unit": "phút",
    "hint": "CC_WORKER_MAX_WALL_CLOCK_MS"
  },
  {
    "id": "cmd",
    "label": "Deadline của run_command",
    "value": 2,
    "unit": "phút"
  },
  {
    "id": "out",
    "label": "Output run_command mỗi stream",
    "value": 8000,
    "unit": "byte"
  }
]

Nguồn gốc: ai yêu cầu, và thứ này từ đâu ra

Tự chủ xoay quanh một câu hỏi: đây là ý định của ai? ClarkCant ghi lại điều đó trên mọi task. Interactive là con người hỏi trong cuộc trò chuyện. Delegated là node ghép nối giao sang theo một grant. Persistent là automation con người đã thiết lập trước, chỉ trong phạm vi hiệu lực họ giao. System là việc của chính node, không ai yêu cầu, và policy đối xử với nó đúng như vậy.

Trong các lượt interactive, node còn ghi lại tin nhắn đến từ đâu: con người, một AI client qua MCP, một relay, hay một chương trình gọi HTTP API. Mặc định chương trình được đối xử như bạn, và chính dòng chữ trong Settings nói thẳng rằng chương trình đang giữ token của node thì vẫn đổi được setting đó. Nếu bạn bật tuỳ chọn, các hiệu lực rủi ro do chương trình yêu cầu sẽ chờ bạn.

Nguồn gốc còn áp dụng cho những gì đi vào model. Hướng dẫn của project trong thư mục .clarkcant được đóng khung là dữ liệu, không cấp quyền gì. Nội dung trang web từ task trình duyệt tới model như dữ liệu, không bao giờ như chỉ dẫn. Tín hiệu từ node ghép nối được ghi theo bên gửi mà kênh đã xác thực gọi tên, không theo bên mà tin nhắn tự nhận. Và nguyên tắc thiết kế widget là không gì được gọi là "live" khi nó chỉ là snapshot hay dữ liệu mẫu.

Audit: nông một cách có chủ đích

Audit trail trong packages/storage/src/audit.ts trả lời câu bạn hỏi sau khi có chuyện bất ngờ: cái gì đã chạy, lúc nào, kết thúc ra sao. Không có gì trong module đó sửa hay xoá một dòng. Nó ghi lệnh, mỗi lần dùng secret theo tên và bên dùng, các lần duyệt, các lần dừng, worker được khởi động trên model nào và key lấy từ đâu, và vài loại khác. Nó không bao giờ ghi giá trị. Comment trong code nói gọn: secret xuất hiện trong audit là secret đã bị lộ.

Nó cũng không chịu làm nhoè kết quả. Lệnh bị dừng được ghi là stopped, không phải failed, vì một audit trail không phân biệt được hai thứ đó sẽ đánh lừa người đọc sau này. Trên giao diện, Settings có danh sách "Việc đã chạy không hỏi", đó là chỗ mức Tự chủ phải để lại dấu vết cho bạn.

Đảo ngược được, ở chỗ làm được thật

Tôi rất muốn nói mọi thứ đều có Undo. Không phải vậy, và tài liệu thiết kế cấm hứa Undo cho những hành động bên ngoài không đảo ngược được. Còn cái gì đã có thì là thật. Mỗi lần ghi preference đều giữ lại giá trị bị thay thế, nên undo quay về đúng cái đã có, kể cả xoá dòng đó nếu trước đó chưa có gì. Thông báo đã ẩn trong inbox khôi phục được trong năm phút, sau đó server trả UNDO_EXPIRED chứ không giả vờ. Cài một package tạo ra các generation bất biến, và rollback để generation trước tiếp tục chạy.

Thu hồi một máy đã ghép nối sẽ chặn các thao tác sau đó của nó. Nó không rollback những hiệu lực đã xảy ra, và tài liệu nói đúng như vậy.

Không chắc chắn là một trạng thái nghỉ

Đây là phần tôi tự hào nhất, và cũng là phần kém hào nhoáng nhất. Mọi hiệu lực ra bên ngoài đều đi qua một ledger: prepared, submitted, rồi confirmed, failed hoặc unknown. Nếu mất phản hồi xác nhận, hiệu lực là unknown, và unknown không bao giờ bị retry thành thành công hay thất bại. Task chuyển sang uncertain, inbox nhận một thông báo nêu rõ chuyện gì đã xảy ra, và chỉ con người được chốt. Trong task trình duyệt, khi một lần submit đang unknown thì không cú click nào khác của task đó tới được trang, nên nút không bao giờ bị bấm hai lần.

Thành công cũng theo kỷ luật đó. Đường duy nhất vào succeeded là bước xác minh có evidence được ghi lại: exit status, phiên bản file, biên nhận API, trạng thái trình duyệt quan sát được. Worker rảnh tay, model kết thúc lượt hay socket đóng lại đều không phải thành công. Tài liệu kiến trúc còn ghi: không có số test thì đừng bịa ra số test. Đó là ý tôi khi nói Clark phải biết nói "tôi không biết". Nó không phải tính cách. Nó là một trạng thái trong state machine.

Trích mã nguồn packages/contracts/src/effects.ts, dòng 70 tới 77. Các chuyển trạng thái hợp lệ: prepared thành submitted hoặc failed; submitted thành confirmed, failed hoặc unknown; unknown, chỉ đạt tới bằng cách quan sát hệ thống bên ngoài, thành confirmed hoặc failed; confirmed và failed là trạng thái cuối.

Khôi phục

Máy thì sập, laptop thì gập. Sau khi khởi động lại, apps/runtime/src/work-recovery.ts báo mọi việc dang dở đúng một lần, trong đúng cuộc trò chuyện đã yêu cầu nó. Process mồ côi chỉ bị dừng khi lần boot máy và thời điểm kernel khởi động pid đó khớp với bản ghi, nên một pid đã bị tái sử dụng sẽ không bao giờ bị đụng tới. Việc nền không có hiệu lực được chạy lại một lần, chỉ ở mức Tự chủ và chỉ trong vòng một ngày. Việc có hiệu lực thì không bao giờ chạy lại: nó chuyển sang uncertain và chờ con người.

Stop là nửa còn lại. POST /stop giết process group của mọi lệnh, worker và terminal đang chạy, ngắt các lượt và huỷ việc nền đang xếp hàng. Bạn cũng có thể chỉ cần nhờ Clark liệt kê hay dừng việc. Peer nào không trả lời quá 10 phút sẽ có một thông báo nói rõ đang nợ gì và nên làm gì, chứ không phải một cái spinner quay mãi.

Bảng ánh xạ các cơ chế kiểm soát tới vị trí trong codebase ClarkCant và trạng thái. Execution policy: packages/core/src/execution-policy.ts, đã có, mặc định Tự chủ. Preflight của host: apps/runtime/src/preflight.ts, đã có. Guardrail Jev chỉ thu hẹp: preflight.ts và jev-decider.ts, đã có, mặc định fail-open. Secret broker: apps/runtime/src/secret-broker.ts, đã có, backend keychain của OS làm sau. Allowlist môi trường cho process con: apps/runtime/src/child-env.ts, đã có. Audit trail chỉ ghi thêm: packages/storage/src/audit.ts, đã có. Stop: POST /stop và work-supervisor.ts, đã có. Undo và rollback: preferences.ts và install-lifecycle.ts, một phần, gồm preference, thông báo và generation của package. Effect ledger có unknown: packages/contracts/src/effects.ts, đã có. Trạng thái task chỉ thành công khi có evidence: packages/contracts/src/tasks.ts, đã có. Khôi phục sau khi khởi động lại: work-recovery.ts, đã có. Ngân sách worker và việc nền: env-file.ts và work-supervisor.ts, đã có. Grant giữa các node lấy giao: packages/contracts/src/grants.ts, đã có, nhưng kênh peer chưa có TLS.

Dữ liệu
[
  {
    "id": "policy",
    "control": "Execution policy",
    "where": "packages/core/src/execution-policy.ts (decideExecution)",
    "status": "Đã có. Mặc định Tự chủ"
  },
  {
    "id": "preflight",
    "control": "Preflight của host: thư mục sở hữu, tồn tại, deadline, trần output",
    "where": "apps/runtime/src/preflight.ts",
    "status": "Đã có"
  },
  {
    "id": "guardrail",
    "control": "Guardrail Jev, chỉ thu hẹp",
    "where": "applyGuardrailConstraints trong preflight.ts, jev-decider.ts",
    "status": "Đã có. Mặc định fail-open"
  },
  {
    "id": "secrets",
    "control": "Secret broker, secret chỉ là metadata",
    "where": "apps/runtime/src/secret-broker.ts",
    "status": "Đã có. Backend keychain của OS làm sau"
  },
  {
    "id": "env",
    "control": "Allowlist môi trường cho process con",
    "where": "apps/runtime/src/child-env.ts",
    "status": "Đã có"
  },
  {
    "id": "audit",
    "control": "Audit trail chỉ ghi thêm",
    "where": "packages/storage/src/audit.ts (audit_log)",
    "status": "Đã có"
  },
  {
    "id": "stop",
    "control": "Stop cho mọi việc đang chạy",
    "where": "POST /stop, apps/runtime/src/work-supervisor.ts",
    "status": "Đã có"
  },
  {
    "id": "undo",
    "control": "Undo và rollback",
    "where": "packages/core/src/preferences.ts, install-lifecycle.ts",
    "status": "Một phần: preference, thông báo, generation của package"
  },
  {
    "id": "effects",
    "control": "Effect ledger có trạng thái unknown",
    "where": "packages/contracts/src/effects.ts",
    "status": "Đã có"
  },
  {
    "id": "tasks",
    "control": "Trạng thái task, chỉ thành công khi có evidence",
    "where": "packages/contracts/src/tasks.ts",
    "status": "Đã có"
  },
  {
    "id": "recovery",
    "control": "Khôi phục sau khi khởi động lại",
    "where": "apps/runtime/src/work-recovery.ts",
    "status": "Đã có"
  },
  {
    "id": "budgets",
    "control": "Ngân sách cho worker và việc nền",
    "where": "packages/pi-adapter/src/env-file.ts, work-supervisor.ts",
    "status": "Đã có"
  },
  {
    "id": "grants",
    "control": "Grant giữa các node lấy giao",
    "where": "packages/contracts/src/grants.ts",
    "status": "Đã có. Kênh peer chưa có TLS"
  }
]

Những gì nó vẫn chưa phải

ClarkCant đang là bootstrap, chưa phải release, và sổ conformance trong repo không đánh dấu thứ gì là hoàn tất. Cụ thể vài điểm. Guardrail mặc định fail-open khi không gọi được; preflight và vùng cách ly vẫn giữ, và bạn chuyển được sang dừng lại. Kênh giữa các node ghép nối chưa có TLS: xác minh key là so fingerprint qua HTTP thường. Epoch fencing ở thời điểm một hiệu lực được submit chưa được nối. Task trình duyệt chưa có human takeover hay live preview, và Computer Use mới làm một phần, còn chờ native binding có chữ ký. Prompt injection vẫn có thể lái model; cách ly giới hạn thiệt hại chứ không chứng minh được là an toàn.

Tôi thà tự công bố danh sách này còn hơn để bạn tự phát hiện ra.

Bảng trạng thái các cơ chế kiểm soát của ClarkCant theo tài liệu trong repo. Đã có và có test: một execution policy cho mọi hiệu lực, preflight và cách ly của host, audit trail chỉ ghi thêm, Stop cho mọi việc đang chạy, và kết quả unknown cùng task uncertain. Một phần: Undo và rollback (preference, ẩn thông báo và generation của package, không có cho hành động bên ngoài), khi guardrail không gọi được (mặc định fail-open, preflight vẫn giữ), và Computer Use (còn chờ native binding có chữ ký). Chưa có: TLS giữa các node ghép nối (xác minh key là so fingerprint qua HTTP thường), epoch fencing lúc submit, và human takeover hay live preview cho task trình duyệt.

Kiểm soát là những gì bạn làm được sau đó

Permission prompt cho bạn cảm giác kiểm soát trước khi hành động xảy ra. Cái tôi muốn là sự kiểm soát còn đứng vững trong lúc và sau khi nó xảy ra: một policy bạn tự chọn, những ranh giới model không cãi được, một bản ghi bạn đọc được, một nút Stop dừng thật, một Undo không bao giờ nói dối, và một agent biết nói "chưa rõ" thay vì "xong rồi" khi nó không biết.

Đó không phải agent YOLO. Nó giống một đồng nghiệp tốt hơn: bạn giao việc, họ làm, và khi có gì nằm ngoài cái bạn nhờ hoặc họ không chắc chuyện gì đã xảy ra, họ quay lại hỏi bạn. Có thể tôi chưa cân đúng ở vài chỗ. Nói tôi nghe bạn cần cơ chế nào nhất trước khi để agent chạy trong lúc bạn ngủ.

Bạn cần cơ chế nào nhất trước khi để agent chạy mà không ai trông?