Security
Least Privilege AI Agent: Credential, Token, dan Secret
Oktober 202611 menit baca

Artinya setiap tool yang bisa dipanggil agent hanya mendapat akses yang dibutuhkan untuk satu tugasnya, tidak lebih. Entri OWASP LLM06 Excessive Agency menggambarkan kegagalannya sebagai fungsionalitas berlebih, permission berlebih, dan otonomi berlebih. Least privilege membatasi ketiganya, sehingga prompt injection yang berhasil hanya bisa melakukan apa yang diizinkan credential sempit tersebut.
Apa pun yang masuk ke context model bisa diulang, diringkas, atau dieksfiltrasi oleh instruksi injection. Panduan remote MCP OpenAI mengingatkan bahwa server berbahaya bisa mengeksfiltrasi data sensitif dari apa pun yang ada di context. Biarkan model menyebut aksinya, lalu kode executor Anda di luar model yang memasang credential.
Anda mencantumkan domain, nama placeholder, dan nilai secret di dalam network_policy milik tool shell. Model dan container hanya melihat placeholder seperti $API_KEY, dan sidecar mengganti nilai aslinya hanya untuk request ke domain yang disetujui. Akses keluar juga memerlukan allow list organisasi di dashboard serta network_policy eksplisit di request.
Tidak. Responses API tidak menyimpan nilai field authorization dan tidak menampilkannya di objek Response, sehingga Anda harus mengirimnya di setiap request. Dengan begitu token tetap tersimpan di store milik Anda sendiri, dengan scope user yang sedang aktif.
Token passthrough terjadi ketika MCP server menerima token client tanpa memeriksa bahwa token itu diterbitkan untuk server tersebut, lalu meneruskannya apa adanya ke API downstream. Spesifikasi MCP 2026-07-28 mewajibkan server memvalidasi audience token dan melarang menerima atau meneruskan token lain. Passthrough merusak audit trail dan membuat token curian bisa memakai server sebagai proxy.

Ringkasan Utama
Least privilege untuk AI agent berarti merancang credential dengan asumsi bahwa prompt injection pasti berhasil. Jauhkan semua secret dari context model, ganti credential di luar model memakai placeholder, kirim OAuth token yang terikat audience di setiap request, beri setiap tool identitas dengan scope sempit, dan wajibkan approval manusia sebelum tool apa pun melakukan write.
Bayangkan sebuah agent helpdesk accounts payable yang terhubung ke ERP. Agent ini membaca invoice vendor, menjawab pertanyaan status, dan membuat draft pembayaran. Suatu sore masuk PDF dari vendor yang berisi satu baris teks putih di atas latar putih, menyuruh agent mengekspor daftar master vendor ke alamat luar. Apakah baris itu berakhir menjadi insiden hampir tidak ditentukan oleh prompt. Yang menentukan adalah apa saja yang bisa dijangkau credential agent tersebut, dan apakah ada secret yang pernah berada di tempat yang bisa dibaca model.
Artikel ini membahas lapisan identitas dan secret pada agent, bagian yang membatasi kerusakan setelah injection berhasil lolos. Rujukannya adalah entri OWASP LLM06 Excessive Agency, panduan hosted shell dan remote MCP dari OpenAI, serta spesifikasi authorization MCP 2026-07-28, dan ditutup dengan checklist review yang bisa dijalankan pada agent mana pun sebelum masuk production. Sandboxing dan deteksi injection adalah topik terpisah; pertanyaan di sini lebih sempit: ketika model berhasil dikelabui, apa yang sebenarnya bisa ia lakukan?
OWASP menyebut kegagalan ini Excessive Agency dan membaginya menjadi tiga akar masalah: fungsionalitas berlebih, permission berlebih, dan otonomi berlebih. Masing-masing berpasangan langsung dengan satu keputusan credential, dan karena itulah lapisan identitas menjadi penentu keamanan agent. Filter dan classifier mengurangi seberapa sering injection berhasil; credential menentukan berapa besar kerugiannya ketika itu terjadi.
| Akar masalah OWASP | Wujudnya pada agent | Kontrol credential yang membatasinya |
|---|---|---|
| Fungsionalitas berlebih | Peringkas mailbox yang juga bisa mengirim email, atau pembaca ERP yang menyediakan tool query generik | Sediakan hanya tool yang bernama jelas; filter remote MCP server dengan allowed_tools agar tool yang tidak dipakai tidak pernah diimpor |
| Permission berlebih | Satu API key admin dipakai bersama oleh semua tool yang dipanggil agent | Satu service account atau OAuth scope per tool, read-only secara default, scope write diminta hanya lewat step-up |
| Otonomi berlebih | Pembayaran di-posting atau record dihapus tanpa ada yang memeriksa | Approval gate di setiap tool write, diputuskan oleh kode dan manusia, bukan oleh model |
Contoh dari OWASP layak diingat karena sangat biasa: asisten yang dibuat untuk meringkas email ternyata juga punya izin mengirim email, lalu sebuah email berisi injection menyuruhnya meneruskan pesan sensitif ke penyerang. Perbaikan yang dicantumkan semuanya berupa perubahan permission, bukan perubahan prompt. Hapus kemampuan mengirim, autentikasi dengan OAuth scope read-only, atau minta user menyetujui setiap pengiriman.
Panduan remote MCP OpenAI menyatakan masalahnya dengan lugas: server yang berbahaya bisa mengeksfiltrasi data sensitif dari apa pun yang masuk ke context model. Hal yang sama berlaku untuk halaman web beracun, PDF berbahaya, atau model yang sedang bingung. Jadi aturan pertamanya bersifat struktural. API key yang ditulis di system prompt, deskripsi tool, atau argumen tool adalah key yang bisa diulang oleh model. Model cukup menyebut aksi; executor Anda, yang berjalan di luar model, yang memutuskan credential mana yang dipakai aksi tersebut.
// Wrong: the API key is part of the prompt. Anything in context can be
// repeated, summarised or sent somewhere by an injected instruction.
const tools = [{
type: "function",
name: "query_erp",
description: `Call https://erp.example.co.id/api with header X-Api-Key: ${process.env.ERP_KEY}`,
parameters: { type: "object", properties: { path: { type: "string" } } },
}];
// Right: the model only names an action and its arguments.
// The executor - your code, outside the model - chooses the credential.
const CREDENTIAL_FOR_TOOL = {
read_invoice: "erp/svc-agent-invoice-reader", // read-only account
draft_payment: "erp/svc-agent-ap-drafter", // may create drafts, never post
} as const;
type ToolName = keyof typeof CREDENTIAL_FOR_TOOL;
export async function executeTool(name: ToolName, args: unknown) {
const secretRef = CREDENTIAL_FOR_TOOL[name];
if (!secretRef) throw new Error(`no credential mapped for tool ${name}`);
// getSecret is your own seam over Vault / a cloud secret manager.
const token = await getSecret(secretRef);
const res = await callErp(name, args, token);
// The tool result goes back into context, so it is a leak path too:
// drop echoed headers, signed URLs and anything shaped like a token.
return redactSecrets(res);
}Pemetaan dari nama tool ke referensi secret adalah baris paling berguna di seluruh desain ini. Credential menjadi properti tool, bukan properti percakapan, sehingga input secerdik apa pun tidak bisa membuat read_invoice berjalan dengan akun drafting. Pemetaan ini juga memberi Anda satu file untuk direview ketika ada yang bertanya apa saja yang bisa disentuh agent.
Hasil tool juga merupakan jalur kebocoran. Error ERP yang menampilkan ulang request header, response debug yang memuat nilai Authorization, atau URL download pre-signed semuanya mengalir kembali ke context, tempat instruksi injection berikutnya bisa menemukannya. Redaksi output tool sebelum dikembalikan, dan jangan pernah biarkan tool shell mencetak environment variable miliknya sendiri.
Agent yang mengeksekusi kode membuat aturan pertama lebih sulit, karena kode yang ditulis model tetap perlu autentikasi ke suatu tempat. Hosted shell OpenAI menjawabnya dengan domain_secrets. Setiap entri berisi domain tujuan, nama yang mudah dikenali, dan nilai secret. Model dan runtime hanya melihat placeholder seperti $ERP_TOKEN; sidecar auth-translation memasang nilai aslinya hanya untuk tujuan yang disetujui, dan dokumentasinya menyatakan nilai mentah tidak disimpan di server API dan tidak muncul di context yang terlihat oleh model.
import OpenAI from "openai";
const client = new OpenAI();
const response = await client.responses.create({
model: process.env.AGENT_MODEL!,
input: "Fetch today's open purchase orders and summarise them by vendor.",
tools: [
{
type: "shell",
environment: {
type: "container_auto",
// No network_policy = no outbound network at all (the default).
network_policy: {
type: "allowlist",
// Must be a subset of the org allow list set in the dashboard,
// or the request fails instead of silently widening access.
allowed_domains: ["erp-api.example.co.id"],
domain_secrets: [
{
domain: "erp-api.example.co.id",
name: "ERP_TOKEN",
// The model and the shell only ever see $ERP_TOKEN.
// The sidecar swaps in the real value for this domain only.
value: process.env.ERP_READONLY_TOKEN!,
},
],
},
},
},
],
});Sisi jaringan sama pentingnya dengan sisi secret. Container hosted tidak punya akses jaringan keluar secara default. Untuk mengaktifkannya, admin harus lebih dulu mengatur allow list organisasi di dashboard, dan request juga harus menetapkan network_policy secara eksplisit. Daftar org menentukan seluruh domain yang bisa dijangkau, daftar di level request hanya bisa mempersempitnya, dan request yang menyebut domain di luar daftar org akan gagal alih-alih diam-diam memperluas akses.
OpenAI juga mengingatkan bahwa allowlist itu sendiri membuka jalur eksfiltrasi: izinkan hanya domain yang Anda percaya dan tidak bisa dipakai penyerang untuk menerima data. Situs paste umum atau object store yang menerima upload anonim akan mematahkan semua kontrol lain di halaman ini. Panduan yang sama menyarankan pencatatan host yang diminta setiap session dan tujuan yang benar-benar dijangkau, log yang akan Anda butuhkan setelah insiden.
Remote MCP memindahkan credential melintasi batas kepercayaan, dan kedua sisinya punya aturan. Di sisi OpenAI, Responses API tidak menyimpan nilai yang Anda kirim di field authorization dan tidak menampilkannya di objek Response, sehingga Anda wajib mengirimnya di setiap request. Ini justru fitur: token tinggal di token store Anda, dengan scope milik user yang benar-benar sedang aktif, dan tidak ada salinan berumur panjang di sisi platform.
// Client side: the token belongs to the human user, is bound to ONE
// MCP server (RFC 8707 resource = its canonical URI), and is resent on
// every call because the Responses API does not store it.
const MCP_URL = "https://mcp.erp.example.co.id/mcp";
const userToken = await tokens.getAccessToken({ userId, resource: MCP_URL });
await client.responses.create({
model: process.env.AGENT_MODEL!,
input,
tools: [{
type: "mcp",
server_label: "erp",
server_url: MCP_URL,
authorization: userToken,
allowed_tools: ["read_invoice", "draft_payment"],
require_approval: { never: { tool_names: ["read_invoice"] } },
}],
});
// Server side: accept only tokens minted FOR this server.
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://id.example.co.id/.well-known/jwks.json"));
export async function authenticate(req: Request) {
const bearer = req.headers.get("authorization")?.replace(/^Bearer /, "");
if (!bearer) return challenge(401, "invoices:read");
const { payload } = await jwtVerify(bearer, JWKS, {
issuer: "https://id.example.co.id",
audience: MCP_URL, // a token for any other API is rejected here
});
// Never forward 'bearer' to the ERP. Exchange or use this server's own
// credential downstream - token passthrough is forbidden by the spec.
return payload;
}Spesifikasi authorization MCP 2026-07-28 menambahkan aturan audience. Client wajib mengirim parameter resource dari RFC 8707, berisi canonical URI server, di authorization request maupun token request. Server wajib memvalidasi bahwa token memang diterbitkan khusus untuknya, hanya menerima token yang valid untuk resource miliknya sendiri, dan tidak boleh menerima atau meneruskan token lain. Halaman security best practices menyebut pola terlarang ini token passthrough: meneruskan token client apa adanya ke API downstream merusak audit trail dan menjadikan server sebagai confused deputy.
Rilis yang sama memperkuat alur terhadap mix-up attack. Client mencatat issuer yang diharapkan sebelum redirect dan memvalidasi parameter iss dari RFC 9207 sebelum mengirim authorization code ke token endpoint mana pun, sehingga code dari server yang jujur tidak bisa ditukar di server yang jahat. PKCE saja tidak cukup, karena client akan menyerahkan code_verifier ke token endpoint milik penyerang. Client ID Metadata Documents menggantikan Dynamic Client Registration sebagai jalur registrasi yang direkomendasikan, dan DCR dipertahankan hanya demi kompatibilitas mundur.
Satu agent tidak seharusnya menjadi satu principal. Beri setiap tool service account atau OAuth scope sendiri, seukuran satu kata kerja yang dijalankan tool itu. Pada contoh accounts payable, pembagian identitasnya seperti ini:
| Tool | Identitas | Scope | Skenario terburuk jika terkena injection |
|---|---|---|---|
| read_invoice | Token delegasi milik user | invoices:read | Membaca invoice yang memang sudah bisa dilihat user |
| draft_payment | Token user setelah step-up | payments:draft | Membuat draft yang tetap harus dirilis oleh manusia |
| lookup_vendor | Service account read-only | vendors:read hanya untuk field nama dan status | Mengetahui nama vendor, bukan data rekening bank |
| post_payment | Tidak diberikan ke agent | Tidak ada | Tidak ada; posting tetap menjadi aksi manusia di ERP |
MCP menyediakan mekanisme untuk menjaga scope awal tetap kecil. Spesifikasinya meminta server mencantumkan scope yang dibutuhkan di challenge WWW-Authenticate dan meminta client memperlakukannya sebagai acuan resmi. Ketika token kekurangan scope saat runtime, server menjawab 403 dengan error insufficient_scope, lalu client melakukan otorisasi ulang dengan gabungan scope yang sudah dimiliki dan scope yang diminta challenge.
# The agent's token carries invoices:read only. It tries to draft a payment:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="payments:draft",
resource_metadata="https://mcp.erp.example.co.id/.well-known/oauth-protected-resource",
error_description="Drafting a payment needs payments:draft"
# The client re-authorises with the UNION (invoices:read payments:draft),
# the user sees exactly one new permission on the consent screen,
# and the elevation is logged with a correlation id.
# payments:post is never requested by the agent's client at all.Halaman best practices mencantumkan kesalahan yang membatalkan semua ini: memublikasikan semua scope di scopes_supported, scope wildcard atau serba-bisa seperti all atau full-access, menggabungkan privilege yang tidak berhubungan demi menghindari prompt berikutnya, dan menganggap scope yang tertera di token sudah cukup tanpa logika otorisasi di sisi server. Poin terakhir paling penting untuk pekerjaan ERP, karena aturan sebenarnya biasanya di level baris: user ini hanya boleh melihat invoice untuk company code miliknya sendiri.
Scope membatasi apa yang mungkin terjadi; approval memutuskan apa yang benar-benar terjadi. Responses API menyediakan require_approval pada tool MCP dengan nilai never, always, atau objek berisi daftar nama tool yang boleh melewati approval. Pasangkan dengan allowed_tools agar tool destruktif dari server tidak pernah diimpor sejak awal. Setiap panggilan yang tertunda lalu datang sebagai item mcp_approval_request, dan kode Anda menjawab dengan mcp_approval_response berisi id request dan nilai boolean.
// The approval decision is made by code and a human, never by the model.
const AUTO_APPROVE = new Set(["read_invoice"]);
const MAX_UNREVIEWED_IDR = 0; // every payment draft goes to a person
for (const item of response.output) {
if (item.type !== "mcp_approval_request") continue;
const args = JSON.parse(item.arguments);
const needsHuman =
!AUTO_APPROVE.has(item.name) ||
(item.name === "draft_payment" && args.amountIdr > MAX_UNREVIEWED_IDR);
const approve = needsHuman
? await approvals.ask({ user: userId, tool: item.name, args }) // UI, chat, email
: true;
await audit.log({ requestId: item.id, tool: item.name, args, approve });
pending.push({
type: "mcp_approval_response",
approval_request_id: item.id,
approve,
});
}Simpan keputusannya di luar model. Aturan seperti setiap draft pembayaran butuh persetujuan orang, atau nominal di atas batas rupiah tertentu butuh approver kedua, adalah kode biasa dengan baris audit di belakangnya. Bertanya kepada model apakah sebuah panggilan terlihat aman sama saja dengan mengembalikan keputusan ke komponen yang baru saja dikuasai penyerang.
Gunakan allowlist tool read yang boleh melewati approval sebagai default, bukan blocklist tool write yang membutuhkannya. Tool baru yang ditambahkan ke MCP server bulan depan akan langsung tertahan approval, bukan diam-diam disetujui otomatis.
Jalankan ini sebelum agent mendapat credential production, dan ulangi setiap kali agent mendapat tool baru:
Poin enam dan delapan paling sering dilewati tim, padahal keduanya yang paling berarti di hari terburuk. Jika satu token yang bocor membuka semuanya, atau jika me-revoke token itu membuat seluruh helpdesk mati, desainnya masih berupa satu admin key bersama dengan langkah tambahan.
Pertahanan prompt menentukan seberapa sering agent tertipu; credential menentukan apa yang bisa dilakukan agent yang tertipu. Simpan secret di tempat yang tidak bisa dibaca model, ikat setiap token ke satu user dan satu server, beri setiap tool identitas tersempit yang cukup untuk tugasnya, dan tempatkan manusia di antara agent dan setiap write yang tidak bisa dibatalkan.
Sumber