智能体需要的是完成当前任务的权限,而不是可以长期复用的身份。本文用 Node.js 实现一个 GitHub App 凭证代理,并说明如何把同一套设计扩展到云平台和内部 API。
为什么不能把长期密钥交给智能体
智能体执行环境会读取提示词、仓库内容、命令输出和第三方响应,还可能调用 shell、插件或浏览器。只要长期密钥以环境变量、配置文件或提示词形式进入该环境,它就可能出现在日志、异常堆栈、模型上下文、缓存文件及恶意工具输出中。
更关键的是,长期密钥通常同时扩大了时间和权限两个维度的风险:任务结束后仍然有效,并且可以访问本次任务不需要的资源。即使随后修复提示词注入问题,已经泄漏的密钥仍可被独立使用。
凭证代理把信任边界拆成两层:编排器向智能体签发短期任务声明,代理验证任务声明后,再向目标平台交换一个权限更窄的凭证。GitHub App 私钥、云平台工作负载身份或内部签名密钥只存在于代理侧。
| 目标系统 | 代理持有的身份 | 下发给任务的凭证 | 收窄方式 |
|---|---|---|---|
| GitHub | GitHub App 私钥 | Installation access token | 仓库列表、permissions、过期时间 |
| AWS | 允许调用 STS 的工作负载身份 | 临时访问密钥 | Role、session policy、duration |
| 内部 API | 服务端签名密钥 | 短期 JWT | audience、scope、资源 ID |
短期并不等于安全。代理如果允许调用方自行指定任意仓库、角色或 scope,只是把长期万能密钥变成了短期万能密钥。因此,凭证交换必须绑定任务身份,并在服务端执行权限交集。
授权链路与策略模型
一次签发可以采用以下链路:
- 编排器创建任务,生成不可复用的
task_id。 - 编排器用自己的私钥签发任务 JWT,写入执行者、受众、允许的仓库和权限,过期时间控制在几分钟内。
- 智能体携带任务 JWT 请求凭证代理,并声明本次实际需要的权限。
- 代理验证签名、
aud、exp和路径中的任务 ID,然后计算“平台上限、任务授权、当前请求”三者的交集。 - 代理调用 GitHub API 换取 installation token,记录审计事件并返回结果。
任务 JWT 可以包含如下声明:
{
"sub": "agent:code-reviewer",
"aud": "credential-broker",
"task_id": "review-8421",
"scopes": [
"github:repo:acme/docs",
"github:perm:contents:read",
"github:perm:issues:write"
]
}
这里的 scope 是代理自己的授权语言,不应直接透传成第三方参数。代理仍需维护允许的权限名称和级别,拒绝未知字段,避免调用方利用目标 API 新增的能力绕过策略。
实现可运行的 GitHub 凭证代理
示例要求 Node.js 20 以上,并使用真实的 GitHub App installation token API。先安装依赖并生成一组用于任务 JWT 的 RSA 密钥:
npm init -y
npm install express jose
openssl genpkey -algorithm RSA -out task-private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in task-private.pem -out task-public.pem
创建 server.mjs:
import express from "express";
import { createPrivateKey, createPublicKey, timingSafeEqual } from "node:crypto";
import { readFile } from "node:fs/promises";
import { SignJWT, jwtVerify } from "jose";
const required = [
"GITHUB_APP_ID",
"GITHUB_INSTALLATION_ID",
"GITHUB_OWNER",
"GITHUB_APP_PRIVATE_KEY_FILE",
"TASK_PUBLIC_KEY_FILE",
"ADMIN_REVOKE_TOKEN"
];
for (const name of required) {
if (!process.env[name]) throw new Error(`Missing environment variable: ${name}`);
}
const githubKey = createPrivateKey(
await readFile(process.env.GITHUB_APP_PRIVATE_KEY_FILE, "utf8")
);
const taskKey = createPublicKey(
await readFile(process.env.TASK_PUBLIC_KEY_FILE, "utf8")
);
const issued = new Map();
const app = express();
app.use(express.json({ limit: "16kb" }));
function audit(event, fields) {
console.log(JSON.stringify({ time: new Date().toISOString(), event, ...fields }));
}
function bearer(req) {
const value = req.get("authorization") || "";
if (!value.startsWith("Bearer ")) throw new Error("missing bearer token");
return value.slice(7);
}
async function githubAppJwt() {
const now = Math.floor(Date.now() / 1000);
return new SignJWT({})
.setProtectedHeader({ alg: "RS256" })
.setIssuer(process.env.GITHUB_APP_ID)
.setIssuedAt(now - 60)
.setExpirationTime(now + 540)
.sign(githubKey);
}
async function github(path, options = {}) {
const response = await fetch(`https://api.github.com${path}`, {
...options,
headers: {
Accept: "application/vnd.github+json",
Authorization: options.token
? `Bearer ${options.token}`
: `Bearer ${await githubAppJwt()}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json"
}
});
if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub ${response.status}: ${text.slice(0, 300)}`);
}
return response.status === 204 ? null : response.json();
}
app.post("/v1/tasks/:taskId/github-token", async (req, res) => {
try {
const { payload } = await jwtVerify(bearer(req), taskKey, {
audience: "credential-broker",
algorithms: ["RS256"]
});
if (payload.task_id !== req.params.taskId) {
return res.status(403).json({ error: "task mismatch" });
}
const repository = req.body.repository;
const permissions = req.body.permissions;
const match = /^([^/]+)\/([^/]+)$/.exec(repository || "");
if (!match || match[1] !== process.env.GITHUB_OWNER) {
return res.status(400).json({ error: "invalid repository" });
}
if (!permissions || Array.isArray(permissions)) {
return res.status(400).json({ error: "invalid permissions" });
}
const allowedLevels = {
contents: new Set(["read"]),
issues: new Set(["read", "write"])
};
const scopes = new Set(Array.isArray(payload.scopes) ? payload.scopes : []);
if (!scopes.has(`github:repo:${repository}`)) {
return res.status(403).json({ error: "repository not authorized" });
}
for (const [name, level] of Object.entries(permissions)) {
if (!allowedLevels[name]?.has(level)) {
return res.status(403).json({ error: `permission rejected: ${name}:${level}` });
}
if (!scopes.has(`github:perm:${name}:${level}`)) {
return res.status(403).json({ error: `permission not in task: ${name}:${level}` });
}
}
const existing = issued.get(req.params.taskId);
if (existing && existing.expiresAt > Date.now()) {
return res.status(409).json({ error: "active credential already exists" });
}
const result = await github(
`/app/installations/${process.env.GITHUB_INSTALLATION_ID}/access_tokens`,
{
method: "POST",
body: JSON.stringify({ repositories: [match[2]], permissions })
}
);
issued.set(req.params.taskId, {
token: result.token,
expiresAt: Date.parse(result.expires_at)
});
audit("credential.issued", {
taskId: req.params.taskId,
actor: payload.sub,
repository,
permissions,
expiresAt: result.expires_at
});
res.json({ token: result.token, expires_at: result.expires_at });
} catch (error) {
audit("credential.denied", { taskId: req.params.taskId, reason: error.message });
res.status(401).json({ error: "request rejected" });
}
});
app.delete("/v1/tasks/:taskId/credential", async (req, res) => {
const supplied = Buffer.from(req.get("x-admin-token") || "");
const expected = Buffer.from(process.env.ADMIN_REVOKE_TOKEN);
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
return res.status(401).json({ error: "unauthorized" });
}
const record = issued.get(req.params.taskId);
if (!record) return res.status(404).json({ error: "credential not found" });
await github("/installation/token", { method: "DELETE", token: record.token });
issued.delete(req.params.taskId);
audit("credential.revoked", { taskId: req.params.taskId });
res.status(204).end();
});
app.listen(3000, () => console.log("credential broker listening on :3000"));
GitHub App 必须安装到目标组织,并预先获得相应的 repository permissions。代理只能在 App 权限上限之内继续收窄,不能凭空增加权限。启动时让 GITHUB_APP_PRIVATE_KEY_FILE 指向 GitHub App 私钥文件,TASK_PUBLIC_KEY_FILE 指向刚生成的公钥;生产环境则应从密钥管理服务加载,而不是把私钥放进镜像。
签发任务声明与调用代理
下面的 mint-task.mjs 代表编排器。它使用任务私钥签发五分钟有效的声明;这把私钥同样不应进入智能体容器。
import { createPrivateKey } from "node:crypto";
import { readFile } from "node:fs/promises";
import { SignJWT } from "jose";
const key = createPrivateKey(await readFile("task-private.pem", "utf8"));
const token = await new SignJWT({
task_id: "review-8421",
scopes: [
"github:repo:acme/docs",
"github:perm:contents:read"
]
})
.setProtectedHeader({ alg: "RS256" })
.setSubject("agent:code-reviewer")
.setAudience("credential-broker")
.setIssuedAt()
.setExpirationTime("5m")
.sign(key);
console.log(token);
智能体把输出作为 Bearer token,请求 acme/docs 的只读凭证:
TASK_JWT=$(node mint-task.mjs)
curl -sS http://localhost:3000/v1/tasks/review-8421/github-token \
-H "Authorization: Bearer $TASK_JWT" \
-H "Content-Type: application/json" \
-d '{"repository":"acme/docs","permissions":{"contents":"read"}}'
不要在日志中记录响应体或 Authorization 头。更严格的实现可以让凭证停留在同机 sidecar 中,由 sidecar 代发 GitHub 请求,智能体只拿到一个本地会话句柄,从而进一步缩小令牌暴露面。
审计、过期与紧急吊销
审计记录至少应关联 task_id、执行者、目标资源、最终权限、签发时间、过期时间和结果,但不能包含令牌原文。task_id 还应进入智能体运行日志和业务变更记录,这样才能从一次 GitHub 写操作反查具体任务。
示例将令牌暂存在代理内存中,以便调用 GitHub 的 DELETE /installation/token 执行紧急吊销。单实例演示可以这样工作,生产环境需要使用带 TTL 的加密存储,或让持有令牌的 sidecar 接收吊销指令。仅保存令牌哈希无法调用上游吊销接口。
还要区分三类终止条件:正常完成时主动吊销,任务超时时停止执行并吊销,检测到提示词注入、异常出站请求或权限越界时立即吊销。网络层应限制智能体只能访问代理或指定 API,避免它绕过代理寻找其他身份端点。
总结
智能体的授权单位应当是任务,而不是机器或团队共享账号。可靠的最小授权链路包含以下要点:长期密钥留在代理或密钥管理服务中;任务声明具有明确的受众、资源、权限和短过期时间;代理对平台上限、任务授权和当前请求取交集;所有签发与拒绝都通过 task_id 关联审计;正常结束、异常行为和人员操作都能触发吊销。
GitHub App installation token、云平台 STS 和内部短期 JWT 的接口不同,但工程原则一致:先建立可验证的任务身份,再交换目标系统凭证,并确保智能体拿到的权限只够完成眼前的一次工作。
评论