智能体需要的是完成当前任务的权限,而不是可以长期复用的身份。本文用 Node.js 实现一个 GitHub App 凭证代理,并说明如何把同一套设计扩展到云平台和内部 API。

为什么不能把长期密钥交给智能体

智能体执行环境会读取提示词、仓库内容、命令输出和第三方响应,还可能调用 shell、插件或浏览器。只要长期密钥以环境变量、配置文件或提示词形式进入该环境,它就可能出现在日志、异常堆栈、模型上下文、缓存文件及恶意工具输出中。

更关键的是,长期密钥通常同时扩大了时间和权限两个维度的风险:任务结束后仍然有效,并且可以访问本次任务不需要的资源。即使随后修复提示词注入问题,已经泄漏的密钥仍可被独立使用。

凭证代理把信任边界拆成两层:编排器向智能体签发短期任务声明,代理验证任务声明后,再向目标平台交换一个权限更窄的凭证。GitHub App 私钥、云平台工作负载身份或内部签名密钥只存在于代理侧。

目标系统代理持有的身份下发给任务的凭证收窄方式
GitHubGitHub App 私钥Installation access token仓库列表、permissions、过期时间
AWS允许调用 STS 的工作负载身份临时访问密钥Role、session policy、duration
内部 API服务端签名密钥短期 JWTaudience、scope、资源 ID

短期并不等于安全。代理如果允许调用方自行指定任意仓库、角色或 scope,只是把长期万能密钥变成了短期万能密钥。因此,凭证交换必须绑定任务身份,并在服务端执行权限交集。

授权链路与策略模型

一次签发可以采用以下链路:

  1. 编排器创建任务,生成不可复用的 task_id
  2. 编排器用自己的私钥签发任务 JWT,写入执行者、受众、允许的仓库和权限,过期时间控制在几分钟内。
  3. 智能体携带任务 JWT 请求凭证代理,并声明本次实际需要的权限。
  4. 代理验证签名、audexp 和路径中的任务 ID,然后计算“平台上限、任务授权、当前请求”三者的交集。
  5. 代理调用 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 的接口不同,但工程原则一致:先建立可验证的任务身份,再交换目标系统凭证,并确保智能体拿到的权限只够完成眼前的一次工作。