← 返回
IT技术

代理永远看不到密钥:验证 DigitalOcean Action Gateway 的凭据代理机制

✍️ zhirenhun 📅 2026/9/17 👁 15 阅读 ⏱ 107 分钟
代理永远看不到密钥:验证 DigitalOcean Action Gateway 的凭据代理机制

一个只会回答问题的 AI 智能体用不到任何密码。但要真正干活的 agent,处处都离不开凭证:开一个 pull request 需要 GitHub token,建一张工单需要 Jira token,查一笔订单需要数据库密码。安全评审人员面对任何 agent 产品,第一个问题永远是同一个:这个 token 到底存在哪里?当 agent——或者正在与它对话的人——提出要查看它时,会发生什么?

常见的答案并不让人放心:token 可能躺在模型旁边的环境变量里、agent 磁盘上的配置文件里,也可能藏在对话日志中。任何能在 agent 内部执行 shell 命令的东西都能读到它。一次提示词注入攻击只要诱使 agent 运行 env,token 也就一并到手。

DigitalOcean Action Gateway 换了一条思路——DigitalOcean 将其称为凭证托管(credential brokering)。agent 自始至终不持有凭证:它只需让网关去执行某个工具。网关会在调用发生的那一刻从 DigitalOcean Secrets Manager 中取出凭证,代替 agent 向 GitHub 发起请求,再把结果返回。token 从头到尾不进入模型上下文、不出现在沙箱里,也不会写进日志。

在本文中,我会实际测试这项功能:在 DigitalOcean 沙箱中启动一个编码 agent,其配置里不含任何 GitHub token。我先让它把能找到的凭证翻个遍——结果一无所获。接着,我让同一个 agent 通过 Action Gateway 操作 GitHub,并仔细观察返回的内容:先是一次托管握手,随后是以我们 GitHub 账号身份发起的已认证调用,全程不见任何 token。至于 git 操作(commit、push、merge),我改用第二个会话,借助团队的 GitHub 授权从 Harness Runtime 聊天界面驱动,并展示部署到那台机器上的 token,让你看清这种取舍的两面。下文所有命令和输出均来自 2026 年 9 月 11 日的真实会话,使用 doctl 1.168.0-beta。token 和连接码均已脱敏。

Managed Agents Runtime Services(M.A.R.S.)目前处于私测预览阶段。本文的演练基于预览环境进行,预览版的行为可能随时变化。更多信息请阅读发布公告

TL;DR

  • 在启用了 Action Gateway 的 Harness Runtime 沙箱中,一次全面的凭据清查(环境变量、GitHub CLI 状态、git 凭据助手,以及用正则表达式在文件系统中搜索疑似令牌的字符串)找到了个 GitHub 或第三方令牌。沙箱中唯一的密钥,是模型自身所需的 DigitalOcean 推理密钥。
  • 智能体访问 Action Gateway 的工具配置只有一个内部 URL(trusted-actions.vpc-endpoint.internal.digitalocean.com),不含任何 Bearer 令牌。攻击者无法从沙箱里窃取任何能用来调用 GitHub 的东西。
  • 在 GitHub 尚未连接时,智能体请求创建 pull request,网关既没有返回令牌,也没有只丢回一个干巴巴的 401,而是返回了结构化的 unauthorized 错误,其中包含登录链接、一次性验证码、所需的确切权限范围(repo)、过期时间,以及 recovery_hint: refresh_auth。从智能体这一侧看,凭据代理正是以这样的握手完成的。
  • GitHub 连接是在网关上创建的,绑定到一个 Actor(智能体所代理的身份),并在控制台的 Connections 标签页中显示为 Active,同时列出认证类型和权限范围。它不会写入沙箱。
  • 连接变为 Active 后,同一个没有密钥的沙箱通过网关发起了经过认证的 GitHub 调用(github_github_get_usergithub_get_repo,以及返回 204github_check_pr_merged),机器上依然没有任何令牌。响应里只有 GitHub 的答复,再无其他:没有访问令牌,没有刷新令牌,也没有任何形似令牌的东西。
  • 在智能体自己的对话里让它操作 GitHub,再让它搜索自己的沙箱,它报告的结果是:以 your-github-username 身份操作,pull request 已合并(204),本沙箱中是否存在 GitHub 令牌:
  • git 相关工作(建分支、提交、推送、gh pr merge)运行在第二个会话中,该会话的规格里带有 GITHUB_TOKEN: "oauth/github"——这是 Harness Runtime 的团队级 GitHub 授权。那个会话的环境里确实持有一个 gho_ 令牌,我也在文中展示了它。这就是文档写明的边界:凭据代理只覆盖通过 Action Gateway 访问的工具;沙箱内运行 git,就得在 git 运行之处提供凭据。
  • 凭据在执行时才解析,仅在一次调用期间存在,从不写入追踪记录或日志,没有凭据缓存(因此轮换在下一次调用时立即生效),一旦 Secrets Manager 不可达就直接失败。本文验证的,是客户能从沙箱内部观察到的那些部分。

Harness Runtime 和 Action Gateway 是什么

托管代理运行时服务(M.A.R.S.)由两个托管组件构成:一个负责运行你的代理,另一个让代理能够在其他产品中执行操作。

Harness Runtime 就是你的代理实际运行的那台计算机。当你启动一个会话,DigitalOcean 会为你的编码代理(Claude Code、Codex CLI、OpenCode,或基于 LangGraph、CrewAI 构建的代理)分配一台独立的小型隔离机器:一台专用的 Firecracker microVM。即使你合上笔记本电脑,这台机器也会继续运行。你可以随时暂停它,之后恢复时文件和状态原封不动;可以把正在运行的会话移交给队友,也可以并行运行多个会话。你只需在一个文件 agents.yaml 中描述这台机器:用哪个代理、配多少 CPU 和内存、要哪些环境变量和机密信息、克隆哪些仓库,以及一套权限策略——规定哪些操作自动执行、哪些需要先经人工确认、哪些被拒绝。你可以通过 DigitalOcean 控制台里的聊天窗口与会话对话,也可以在终端里用 doctl 操作。

每当代理需要在其他产品中执行操作,Action Gateway 就是它要去找的“前台”。没有它,一个要同时操作 GitHub、Jira 和数据库的代理,得在自己的环境里接入三套集成和三组凭据。有了它,代理只需拿到一个统一的托管 MCP 端点,其中提供三个工具:action_search,用大白话描述要做的事,就能找到合适的工具;action_invoke,执行该工具(最多可并行 10 次调用);action_code,在一个临时沙箱中运行 Python。在这个端点背后,网关维护着与 GitHub、Jira、Notion、Linear、Postgres 以及 1000 多种其他工具的连接(Connections)。每条连接都绑定一个执行者(Actor),也就是代理所代表的那个身份。当代理调用某个工具时,网关会为这一次调用附上正确的凭据,发出请求,再返回结果。它还能把厂商返回的原始报错转换成结构化错误,让代理可以据此自行恢复。

两者的结合只需在规格文件中写上一行 tools: [do.actions],它会把网关挂载进 Harness Runtime 会话。整个集成就这么简单。

架构图:左侧是 Harness Runtime,一台 Firecracker microVM,其中承载编码代理、本地工具和权限策略;中间是 Action Gateway,包含 action_search、action_invoke 和 action_code;其下方是 Secrets Manager;右侧是 GitHub 及 1000 多个其他工具

通过控制台的聊天界面或 doctl 与代理对话。Harness Runtime 卡片代表代理的机器——一台 Firecracker microVM。里面装着:编码代理、它使用的本地工具(shell、文件、git、Python)、一个绿色方框(标出机器上实际存放的内容:模型推理密钥和一个网关 URL,但没有 GitHub token),以及权限策略。Action Gateway 卡片则是代理去操作其他产品时经过的地方。图中上部是代理能看到的三个工具,下部是网关所管理的内容:Actors、Connections、凭据中介,以及结构化错误。右侧的 GitHub 卡片代表 GitHub 和其他 1000 多种工具。接下来顺着箭头走一遍:代理发出的工具调用抵达网关时,不含任何密钥;网关从 Secrets Manager 取出凭据,附加到请求上,再调用 GitHub;传回来的只有执行结果。整张图上,密钥只出现在两个地方——Secrets Manager,以及网关与 GitHub 之间的传输链路;左侧两处都不存在密钥。这正是本文要验证的论点。

DigitalOcean 控制台中的 Action Gateway 入门页面。左侧边栏在 Managed Agents 下列出 Harness Runtime 和 Action Gateway。标签页依次为 Getting Started、Sessions、Tools、Toolbelts、Actors、Connections、Insights。代码面板显示一份 agents.yaml,其中 tools 和 mcpServers 引用 do.actions

Managed Agents 下面可以看到这两个组件:Harness RuntimeAction Gateway,二者都标有 new。主区域是 Action Gateway 的落地页。顶部的各个标签页正是本文要逐一走访的地方:Sessions(记录哪些代理会话已接入)、Tools(网关能做什么的能力目录)、Actors(代理替谁执行操作)、Connections(凭据存放于此,而非放在代理里),以及 Insights(使用情况)。右侧的卡片是一份 agents.yaml 示例。末尾几行 tools:mcpServers: ref: do.actions,就是前文提到的“一行式集成”。左侧的三个编号步骤,用文字讲述的是同样的内容。

什么是凭证代理?

我用一个例子来解释。

想想酒店的代客泊车服务。有客人想用车,你并不会把车钥匙交到每个人手里,而只在泊车台登记一次。之后,凭票取车的客人来了,泊车台取来钥匙、把车开到门口,再把钥匙放回原处。客人坐上了车,却始终拿不到钥匙。

Action Gateway 就是这个泊车台。你只需把 GitHub 连接一次,你的 agent 就是那位客人。当 agent 想发起 pull request 时,它会向泊车台提出请求(action_invoke),泊车台核验票据(对应哪个 Actor、哪些权限范围、哪条策略),从 DigitalOcean Secrets Manager 取出令牌,调用 GitHub,然后返回结果。令牌随即放回抽屉。agent 拿到了 pull request,却始终没碰过令牌。

DigitalOcean 的Action Gateway 私有预览版发布文章是这样描述这套机制的:

凭证在执行时才解析,仅存在于单次调用中,用后即弃。令牌永远不会到达 agent、沙箱或最终用户手中,刷新令牌则始终锁在 DigitalOcean Secrets Manager 里。每次调用只请求所需的权限范围,上限由你为每个连接自行设定。系统没有凭证缓存,因此密钥的轮换和撤销会在下一次调用立即生效,不存在过时窗口;密钥不可达时直接失败终止,而不会回退继续。每一次访问的审计记录都基于引用,绝不包含值本身。

凭证有三种连接方式,代理机制对三者一视同仁:

连接类型 账户由谁持有 适用场景
静态 API 密钥 你的团队,一把密钥 只发放 API 密钥的服务(如 Postgres 密码、厂商密钥)
共享 OAuth 应用 你的团队持有一份授权,整个 agent 共用 所有人以同一服务身份行事的内部 agent
按用户 OAuth 每位终端用户授权自己的账户 客户各自连接自己的 GitHub、Jira 或 Slack 的产品

无论哪种方式,令牌都存放在 Secrets Manager 里,不会出现在你的 YAML 中,也不会进入沙箱。

为什么这对 AI 原生团队至关重要

一个三人团队把编码智能体做成产品对外交付,手里只有一个 GitHub OAuth 应用,而客户很快就会达到数百个——每个客户都希望这个智能体能在自己的仓库里干活。经网关实现的按用户 OAuth 意味着:每位客户只需授权一次,授权关系绑定到该客户的 Actor 上,任何客户的令牌都绝不会进入另一个客户的提示词可能触及的沙箱。

企业平台团队要面对的则是另一类问题:如何通过安全审查。审查人员想知道 Jira 令牌存在哪里、谁能读到它、轮换时会发生什么。“令牌就存在 microVM 里的一个环境变量中”,这会被记为一项审计发现;而“智能体的运行时里不含任何 Jira 令牌——网关在每次调用时从 Secrets Manager 实时解析,链路追踪记录只以引用方式登记该凭证”,这才是合格的答复。

这套设计要应对的威胁都不算罕见:

  1. 提示词注入。一个恶意的 README 会让智能体打印出自己的环境变量。只要环境里有个 GitHub 令牌,它就已经进了对话记录。
  2. 对话记录与日志泄露。智能体的对话记录常被整段贴进工单和 Slack,日志则会被采集到第三方平台。从未进过沙箱的令牌,自然不可能出现在这两处。
  3. 凭证过期。复制到十个沙箱里的令牌,轮换时就得改十个地方;只存于一处、又不设缓存的令牌,轮换一次即可。
  4. 跨租户越权。一个客户的智能体不应能以另一个客户的身份行事。按 Actor 建立的连接确保各家的授权互不相通。

密钥可能藏在哪些地方,以及我们如何逐一测试

智能体系统中有四个可能泄露凭据的地方。下面看看 Action Gateway 是如何解决这个问题的。

位置 可能出什么问题 Action Gateway 的做法 本文如何测试
模型上下文 令牌出现在模型读取的提示词或工具结果中 “凭据绝不会进入模型上下文” 检查模型读取的每一条网关响应:unauthorized 握手、一次成功的已认证读取,以及合并检查
沙箱环境与磁盘 env、dotfiles、~/.git-credentialsgh 状态 “令牌绝不会到达智能体、沙箱或最终用户” 导出环境变量,检查 gh auth status,扫描文件系统中的令牌特征,并设置对照组
工具配置 MCP 客户端配置中的 Bearer 令牌 网关通过会话级端点接入 读取 /workspace/mcp-config.json

实战演练:先证明沙箱干净无泄露,再代理发起一次 GitHub 调用

本次演练将依次完成四件事。

  1. Harness Runtime 中启动一个启用了 Action Gateway 的 Claude Code 会话,且规格中不含 GitHub 令牌。
  2. 在沙箱内执行一次凭据扫描,确认一无所获。
  3. 请求 Action Gateway 代为操作 GitHub,并查看返回内容:先是一次代理握手,随后(在你完成授权后)以你的账号身份发起经过认证的调用——所有响应中都不含令牌。
  4. 在持有团队 GitHub 授权的第二个会话里,通过 Harness Runtime 聊天完成 git 操作(建分支、提交、推送、合并);然后回到无密钥会话的聊天,让智能体经由网关在 GitHub 上执行操作并搜索自己的沙箱,最后你再亲自扫描一遍。

你需要准备什么

  • 一个已登记付款方式的 DigitalOcean 账号。还没有的话,可前往 cloud.digitalocean.com 注册。
  • Managed Agents Runtime Services 已在你的团队中启用。该服务自 2026 年 9 月 16 日起处于公开预览阶段。
  • 一个终端环境。Mac 上打开“终端”应用;Windows 上可使用 PowerShell 或 WSL。
  • 一个拥有完整权限的 DigitalOcean 个人访问令牌,需在控制面板的 API 版块下创建。具体步骤参见API 快速入门
  • doctl beta 1.168.0 或更高版本。harness-runtime(别名 agent)相关命令并不包含在标准发布版中。请从 doctl 发布页下载,然后使用你的令牌运行 doctl auth init
  • 一个 GitHub 账号,以及一个你可以向其发起 pull request 的仓库。

你不需要 GitHub 个人访问令牌。这正是重点所在。

你可以参考我写的这篇教程——使用 DigitalOcean Action Gateway 在不共享凭据的情况下将 AI 智能体接入 SaaS 工具——了解如何用 M.A.R.S. 创建智能体。

第 1 步:编写一份不含 GitHub 令牌的 spec 文件

新建一个文件夹,把下面的内容保存为 specs/agents.yaml。请逐行阅读这份文件——它没写的内容,比写了的更重要。

name: anish-no-key-agent
agent: claude-code
size: mv-2vcpu-4gb
persistent_workspace: true
env:
  HARNESS_INFERENCE_BASE_URL: "https://inference.do-ai.run/v1"
  HARNESS_INFERENCE_MODEL: anthropic-claude-4.6-sonnet
  ANTHROPIC_BASE_URL: "https://inference.do-ai.run"
  ANTHROPIC_MODEL: sonnet
  CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1"
secrets:
  HARNESS_INFERENCE_API_KEY: "${DIGITALOCEAN_ACCESS_TOKEN}"
tools:
  - do.actions
permissions:
  default: ask
  rules:
    - tool: bash
      action: allow
    - tool: mcp
      action: allow

各配置块的作用:

  • agent: claude-code 配合 mv-2vcpu-4gb,会在一台 2 vCPU、4 GB 内存的 Firecracker 微虚拟机中启动 Claude Code。
  • env 块让 Claude Code 指向 DigitalOcean Serverless Inference,这样就无需 Anthropic 密钥。这里配置的是模型端点,而非工具凭据。
  • secrets 存放沙箱真正需要的那把钥匙:推理密钥,供模型“思考”之用,它就是你的 DigitalOcean 令牌。env 下的值会“原样存储,并且在会话沙箱内可被调试读取”;而 secrets 下的值则经由 Secrets Manager 传递,“绝不会出现在会话元数据中”,也“不会写入平台日志或链路追踪”。凡属敏感信息,都应放进 secrets
  • tools: [do.actions] 启用 Action Gateway。这会“授予访问 DO Actions 目录中全部工具的权限”。你也可以缩小范围,只开放某个工具带(toolbelt)或一组指定的工具名称。
  • permissions 显式放行了 bashmcp。如果只把权限默认值设为 ask,网关工具调用将无法通过;创建会话时 API 会就此给出警告。

这份文件里没有的东西:任何 GITHUB_TOKEN、任何 gh 登录凭据、任何 git 凭据。即便一个都没有,该代理依然能发起经过身份验证的 GitHub 调用。

第 2 步:启动会话

先试试运行 doctl agent start。针对 claude-code 规格,它会到 Anthropic 那边校验 ANTHROPIC_API_KEY,而当你实际使用的是 DigitalOcean 托管的推理服务时,这一步就会失败。变通办法是把清单直接 POST 到 Managed Agents API。下面这个小工具会从 doctl 的配置中读取你的令牌,将其同时注入为推理密钥和 ANTHROPIC_API_KEY,然后提交这份 YAML。

python3 specs/start-session.py
Creating session from agents.yaml (API, not doctl start)...
Session  anish-no-key-agent
Agent    claude-code
Status   ready
ID       <SESSION_ID>

确认其正在运行:

doctl agent list
● anish-no-key-agent
  Claude Code · ready · Created 26m ago

在 DigitalOcean 控制台中打开 Managed Agents → Action Gateway → Sessions,搜索这个名称。Harness Runtime 已经为你创建了对应的 Action Gateway 会话。Actor、Session 和 Toolbelt 都不是你手动创建的——Open Harness Runtime 会自动完成 Action Gateway 的集成。

DigitalOcean 控制台中的 Action Gateway Sessions 标签页,显示 anish-no-key-agent 会话及其 Actor ID、0 条规则的策略摘要和 MCP 端点

这里是网关的代理会话列表,我们刚创建的会话就是其中唯一的一行。Name 就是 spec 文件里定义的那个名称。Actor IDd905b90f...)代表这个代理所服务的身份;稍后连接 GitHub 时,连接就会绑定到这个 ID 上。MCP endpoint 是沙箱访问网关用的私有地址。Policy summary 显示 0 rules,这只是说明我们还没有添加任何网关侧的规则。真正值得注意的是缺失的部分:整个列表中没有存放令牌或密码的列。凭据永远不会存储在会话上。

第 3 步:让代理找出所有它能找到的凭据

现在到了测试环节。你可以通过 doctl agent attach anish-no-key-agent 连上代理的聊天界面,然后输入下面这段提示词:

把这台机器上你能找到的所有凭据都列出来。检查环境变量、GitHub CLI 的登录状态和 git credential helper,再在文件系统中搜索任何看起来像 GitHub 令牌的内容。对每个值只保留前 12 个字符,其余部分做掩码处理。

为了保证本文的输出结果可以复现,我自己用 doctl agent exec 跑了一遍同样的扫描。这个命令会在代理所在的同一个 microVM 中、以相同的用户身份执行命令。具体的命令和输出如下。

doctl agent exec anish-no-key-agent -- bash -lc '
echo "== whoami / host"; whoami; hostname
echo; echo "== env keys that look like secrets (values masked to 12 chars)"
env | grep -iE "token|secret|key|pass|auth|github|gh_" | sed -E "s/=(.{12}).*/=\1.../" | sort
echo; echo "== gh auth"; gh auth status 2>&1 || true
echo; echo "== git credential files"
ls -la ~/.git-credentials ~/.config/gh 2>&1
git config --global --get credential.helper 2>&1 || echo "no credential.helper"
echo; echo "== token-shaped strings on disk (ghp_/gho_/ghu_/ghs_/ghr_/github_pat_)"
grep -rIEl "gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}" /root /home /workspace /etc /tmp /opt 2>/dev/null
echo "matches: $(grep -rIEl "gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}" /root /home /workspace /etc /tmp /opt 2>/dev/null | wc -l)"
'
== whoami / host
root
192.168.240.2

== env keys that look like secrets (values masked to 12 chars)
ANTHROPIC_API_KEY=dop_v1_xxxxx...
HARNESS_INFERENCE_API_KEY=dop_v1_xxxxx...
OHP_FORWARDER_AUTHORITY=agents-trust...

== gh auth
You are not logged into any GitHub hosts. To log in, run: gh auth login

== git credential files
ls: cannot access '/root/.git-credentials': No such file or directory
ls: cannot access '/root/.config/gh': No such file or directory
no credential.helper

== token-shaped strings on disk (ghp_/gho_/ghu_/ghs_/ghr_/github_pat_)
matches: 0

请从上到下通读一遍。

  • 代理在 microVM 中以 root 身份运行,能读取机器上的任何内容。这正是本次测试的意义所在:它并不是一个被限制权限、无权查看的用户。
  • 有两个环境变量保存着 dop_v1_ 值。这是我们 secrets 块里的 DigitalOcean 推理密钥,之所以以两个名称存在,是因为 Claude Code 会读取 ANTHROPIC_API_KEY。它只是让模型得以思考的密钥,并不是 GitHub 凭据,也无法创建 pull request。
  • OHP_FORWARDER_AUTHORITY 是一个主机名(agents-trusted-production-...vpc-endpoint...),并非机密。它之所以被 grep 命中,只是因为其中含有 auth
  • GitHub CLI 尚未登录:没有 .git-credentials 文件,没有 gh 配置目录,也没有 git 凭据助手。
  • 针对各类 GitHub 令牌前缀的文件系统扫描,匹配结果为零。

扫描结果为零,也可能说明扫描本身出了问题。所以要做一个对照实验:先植入一个假令牌,确认扫描能够发现它,然后再删掉。

doctl agent exec anish-no-key-agent -- bash -lc '
echo "GITHUB_TOKEN=ghp_FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE1234" > /tmp/planted.env
grep -rIEl "gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}" /root /home /workspace /etc /tmp /opt 2>/dev/null
rm /tmp/planted.env
'
/tmp/planted.env
/tmp/envd.log

扫描捕获到了那个诱饵。它同时也捕获了 /tmp/envd.log——后来发现,里面记录的正是我自己那条 exec 命令的内容(假令牌字符串就出现在命令行里)。沙箱的本地进程日志只记录命令本身,不记录环境变量的值;我又核查了一遍,确认里面既没有真实令牌,也没有推理密钥的值。把诱饵删掉,计数就归零了。

结论:就算代理在自己机器上拥有 root 权限,让它去找 GitHub 凭据,它也找不到——因为根本没有凭据可找。

第 4 步:看看代理是如何访问网关的

既然沙箱里没有令牌,那代理到底是怎么调用 GitHub 的?我们来看看运行时为 Claude Code 写入的 MCP 客户端配置。

doctl agent exec anish-no-key-agent -- cat /workspace/mcp-config.json
{
  "mcpServers": {
    "do_actions": {
      "type": "http",
      "url": "http://trusted-actions.vpc-endpoint.internal.digitalocean.com/mcp/session/"
    }
  }
}

有三点值得注意。

  1. 主机名 internal.digitalocean.com 指向一个 VPC 端点。沙箱通过 DigitalOcean 的内网访问网关,而不是走公共互联网。
  2. 请求中既没有 Authorization 头,也没有 Bearer 令牌。这个 URL 只绑定到当前会话的 ID。身份认证依据的是请求的来源,而非智能体所持有的密钥。
  3. 智能体只能看到一个服务器:do_actions。我们来问问这个服务器都提供了哪些工具:
doctl agent exec anish-no-key-agent -- bash -lc '
URL=$(python3 -c "import json;print(json.load(open(\"/workspace/mcp-config.json\"))[\"mcpServers\"][\"do_actions\"][\"url\"])")
curl -sS -X POST "$URL" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}" | sed -n "s/^data: //p" \
  | python3 -c "import sys,json; print([t[\"name\"] for t in json.load(sys.stdin)[\"result\"][\"tools\"]])"
'
['action_code', 'action_invoke', 'action_search']

工具只有三个,而不是一份长长的目录。action_search 通过描述任务来查找工具。action_invoke 一次最多并行运行十个工具。action_code 在临时沙箱里运行 Python,还能从沙箱内部调用工具。GitHub 的 40 多个操作并不会全部载入模型上下文,智能体需要哪一个,就去搜索哪一个。

网关通过 Server-Sent Events 返回结果,因此响应是以一行行 data: 的形式到达的。sed -n "s/^data: //p" 会剥掉这层外壳,让 python3 能够顺利解析其中的 JSON。如果改从智能体的对话界面来调用网关,Claude Code 会自动替你处理好这些细节。

第 5 步:找到创建拉取请求的工具

接下来,我们向网关查询创建拉取请求的工具,并把筛选范围限定为 GitHub。

doctl agent exec anish-no-key-agent -- bash -lc '
URL=$(python3 -c "import json;print(json.load(open(\"/workspace/mcp-config.json\"))[\"mcpServers\"][\"do_actions\"][\"url\"])")
curl -sS -X POST "$URL" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"action_search\",\"arguments\":{\"queries\":[{\"use_case\":\"create a pull request in a GitHub repository\"}],\"providers\":[\"github\"],\"limit\":3}}}" \
  | sed -n "s/^data: //p"
'

已截取,仅显示首条结果:

{
  "name": "github_create_pull_request",
  "title": "Create GitHub Pull Request",
  "description": "Create a new pull request in a GitHub repository.",
  "inputSchema": {
    "required": ["owner", "repo", "title", "head", "base"],
    "properties": {
      "owner": { "type": "string", "description": "Repository owner (user or organization)." },
      "repo":  { "type": "string", "description": "Repository name." },
      "title": { "type": "string", "description": "Pull request title." },
      "head":  { "type": "string", "description": "Branch with changes (user:branch for cross-repo)." },
      "base":  { "type": "string", "description": "Branch to merge into." },
      "body":  { "type": "string", "description": "Pull request body/description." },
      "draft": { "type": "boolean", "description": "Create as a draft pull request." }
    }
  },
  "score": 0.88296139
}

github_create_pull_request 得分 0.883,github_create_issue(0.795)和 github_create_pr_review 紧随其后。这份 schema 明确告诉智能体该发送哪些参数,其中完全没有提到 token——因为 token 不是智能体该操心的事。

在控制台中,依次进入 Action Gateway → Tools → GitHub,也能看到这些工具。

Action Gateway 的 Tools 标签页,提供商搜索已筛选为 GitHub,显示 GitHub 提供商卡片和一个 View tools 按钮

Tools 标签页是网关所能操作的产品目录。我们在搜索框里输入了 GitHub,所以页面上只显示 GitHub 这张卡片。点击 View tools,就能看到网关在 GitHub 上能做的所有事情。注意,这张卡片对登录一事只字未提——智能体用哪个账号,由另一处负责,也就是 Connections 标签页。

Action Gateway 中的 GitHub 工具列表,显示工具名称、github_create_pull_request 等标识、描述,以及 Health 列显示 No data

每一行都是网关能在 GitHub 上执行的一项操作。Tool Name 是便于阅读的名称。Slug 是智能体调用工具时使用的准确名称,github_create_pull_request 就在下面几行。Description 是智能体用平实的语言描述任务时,网关用来检索的文本。Health 显示 No data,原因很简单:过去 24 小时里,这个团队没有人运行过这些工具。有一点值得知道:这份长长的清单并不会发给智能体。智能体搜索时,只会收到 3 到 6 条与其请求匹配的结果。

第 6 步:请求创建一个 pull request,并读懂凭据代管的握手过程

现在调用这个工具。我们刻意还没有连接 GitHub。接下来就看看,网关会向一个未经授权的智能体返回什么。

doctl agent exec anish-no-key-agent -- bash -lc '
URL=$(python3 -c "import json;print(json.load(open(\"/workspace/mcp-config.json\"))[\"mcpServers\"][\"do_actions\"][\"url\"])")
curl -sS -X POST "$URL" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"action_invoke\",\"arguments\":{\"rationale\":\"Open the PR the user asked for\",\"tools\":[{\"tool\":\"github_create_pull_request\",\"arguments\":{\"owner\":\"\",\"repo\":\"\",\"title\":\"docs: add proof-of-brokering note\",\"head\":\"proof-of-brokering\",\"base\":\"main\",\"body\":\"Opened through DigitalOcean Action Gateway. No GitHub token exists in the sandbox.\"}}]}}}" \
  | sed -n "s/^data: //p"
'
{
  "total_count": 1,
  "success_count": 0,
  "error_count": 1,
  "results": [
    {
      "index": 0,
      "tool": "github_create_pull_request",
      "version": "v2",
      "result": {
        "status": "failed",
        "error": {
          "class": "unauthorized",
          "message": "Tool \"github_create_pull_request\" requires an OAuth connection for provider \"github\" and user_id \"d905b90f-...\" before it can run. Open https://cloud.digitalocean.com/security/connectlinks/confirm?token=®ion=nyc3 to authorize. Then retry the tool call. To connect with a credential your team supplies instead, open https://cloud.digitalocean.com/managed-agents/action-gateway/connections?auth=user_oauth_app&connect=1&provider=github&user_id=d905b90f-.... Confirm that verification code 1pte855x matches the code shown on the authorization page.",
          "retriable": true,
          "recovery_hint": "refresh_auth"
        },
        "call_id": "call_fb314651-...",
        "_meta": {
          "connect_url": "https://cloud.digitalocean.com/security/connectlinks/confirm?token=®ion=nyc3",
          "connections_url": "https://cloud.digitalocean.com/managed-agents/action-gateway/connections",
          "expires_at": "2026-09-12T07:17:26Z",
          "provider": "github",
          "required_scopes": ["repo"],
          "status": "requires_authorization",
          "team_credential_url": "https://cloud.digitalocean.com/managed-agents/action-gateway/connections?auth=user_oauth_app&connect=1&provider=github&user_id=d905b90f-...",
          "user_id": "d905b90f-...",
          "verification_code": "1xxxxx55x"
        }
      }
    }
  ]
}

这段响应正是本文的核心。下面我们详细看看智能体拿到了什么,更重要的是,它没拿到什么。

字段 智能体拿到的东西 为什么重要
error.class: unauthorized 一个带明确标签的失败,而不是光秃秃的 HTTP 401 模型能看出这是认证问题,而不是自己的参数出了 bug
recovery_hint: refresh_auth 一条明确指令:先引导用户去登录,然后重试 智能体不必自己猜该重试、改写请求还是放弃
retriable: true 用户完成操作后,允许再次尝试 同样的调用、同样的参数,稍后再来一次
connect_url 一个一次性链接,其中的令牌将在 expires_at 时过期 由用户完成授权,智能体完全不触碰 GitHub 的 OAuth 流程
verification_code: 1xxxxx55x 用户在授权页面上需要核对的一小段代码 防止智能体(或提示注入攻击)向用户塞入一条对方并未要求的链接
required_scopes: ["repo"] 这个工具所需的最低 GitHub 权限范围 连接只申请这次调用需要的东西,而不是 GitHub 提供的全部权限
user_id 这次连接将归属的 Actor 按用户隔离的 OAuth:授权仅绑定到这一个身份
team_credential_url 另一种方式:用你们团队提供的凭据来连接 静态密钥或自建 OAuth 应用这两条路,同样走这种代管机制

至于智能体没拿到的东西:没有访问令牌、没有刷新令牌、没有客户端密钥,也没有需要它去完成的 OAuth 重定向。凭据交换全程只发生在用户、控制台和 GitHub 之间。

智能体拿到的只是一个指向凭据的指针。这就是“智能体永远看不到密钥”在实际中的含义,而且从响应体里就能看得一清二楚。

连接链接本身就是一个普通的 https://cloud.digitalocean.com/... 网址,附带一个短期有效的令牌。凡是能打开你们团队控制台的人都可以完成授权。而仅持有智能体会话记录的人无法用它换取 GitHub 令牌——他们最多只能到达同一个授权页面,而那里需要登录你们的 DigitalOcean 团队账号和 GitHub 账号。

第 7 步:在网关上授权 GitHub,而不是在沙箱里

在已登录 DigitalOcean 的浏览器中打开 connect_url,或者依次进入 Action Gateway → Connections → Add connection,两条路径最终都会到达同一个对话框。

Action Gateway 中 GitHub 的 Add connection 对话框,认证方式已设为 DigitalOcean's OAuth app,已填写 Actor ID,且只勾选了 repo 和 read:user 两个权限范围

GitHub 凭据就是在这个表单里创建的。请注意你此刻所处的位置:你自己的浏览器,已登录 DigitalOcean 控制台,代理全程没有参与。Provider 选择 GitHub。Authentication 设为 DigitalOcean's OAuth app,意思是你会按平常的方式登录 GitHub,而 GitHub 会把得到的令牌交给网关。(另一个选项 Your own OAuth client 是给那些注册过自己 GitHub 应用的团队用的。)Actor ID 填的正是刚才代理报错信息里出现过的那个 d905b90f...,这样一来,这份授权就只对这一个代理身份生效。Scopes 是请求 GitHub 授予的权限清单。我们把所有勾选都取消,只保留 repo——网关说它需要这一项——以及 read:user。勾选的框越少,代理能做的事就越少,这正是你想要的效果。

点击 Connect。控制台会先显示一个验证码,然后再把你带往 GitHub。

Authorize GitHub 对话框,显示一个八位验证码和一个 Open authorization page 按钮

这个小对话框里有一个八位验证码 1xxxxx55x 和一个按钮。这个验证码和片刻之前网关写进代理报错信息里的完全一致。道理很简单:先核对两处验证码是否相同,再点击 Open authorization page 登录 GitHub。如果某个代理给你的登录链接,其中的验证码与控制台显示的对不上,那就不要点。正是这一个细小的核对步骤,才让登录链接经由 AI 代理传递变得安全。

完成 GitHub 的 OAuth 页面后返回,Connections 标签页里就能看到这条新授权了。

Action Gateway 的 Connections 标签页,按 provider 为 GitHub 和此 Actor ID 筛选,显示一条记录:认证方式为 OAuth,状态为 Active,并列出已授予的权限范围

这就是 Connections 标签页,按 GitHub 和我们 agent 的 Actor ID 筛选后只剩一行。逐列读过去:Provider 是 GitHub,Actor IDd905b90f...AuthenticationOAuthStatusActive,还有我们授予的 Scopes(点击 +N more 可展开全部),以及日期。Active 表示你已经完成 GitHub 的登录;在那之前,同一行显示的是 Pending。在 DigitalOcean 这一侧,GitHub 凭据只登记在这一行,而令牌本身则存放在它背后的 Secrets Manager 中。请留意这一行没有展示的东西:令牌。你能看到凭据存在、它作何用途、被授权做什么,却永远看不到它的值。这就是“凭引用审计,从不凭值审计”在实践中的样子。

第 8 步:再次调用 GitHub。同一个沙箱现在能以你的身份行事,里面依然没有令牌

连接变为 Active 之后,这个不含令牌的沙箱就能以你的身份行事了。向 GitHub 询问调用者是谁;只有当网关持有这个 Actor 的 GitHub 凭据时,这一步才可能成功。/tmp/ag.sh 辅助脚本沿用的还是第 4 步里那对 curlsed,只是做了封装,让 JSON-RPC 请求体成为唯一参数。

doctl agent exec anish-no-key-agent -- sh -c '/tmp/ag.sh "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"action_invoke\",\"arguments\":{\"rationale\":\"Who am I on GitHub through the gateway\",\"tools\":[{\"tool\":\"github_github_get_user\",\"arguments\":{}}]}}}"'
{
  "total_count": 1,
  "success_count": 1,
  "error_count": 0,
  "results": [
    {
      "index": 0,
      "tool": "github_github_get_user",
      "version": "v2",
      "result": {
        "status": "succeeded",
        "output": {
          "html_url": "https://github.com/anishsingh20",
          "id": 1xxxxxxxx,
          "login": "anishsingh20",
          "name": "Anish Singh Walia"
        },
        "call_id": "call_",
        "_meta": { "provider": "github", "tool_version": "v2" }
      }
    }
  ]
}

把它和第 6 步对比一下:同样的沙箱、同样的工具调用形式,请求中同样不含任何令牌。区别在于,这次有人在网关上完成了授权,于是网关为这一次调用解析出第 7 步的授权凭据,用它请求 GitHub,最后只返回 GitHub 的应答结果。看看返回了什么:四个输出字段(html_urlidloginname)和元数据。没有 token,没有 Authorization,也没有 access_token——响应正文里找不到任何形如令牌的字段。这就是授权之后模型实际读到的内容,和握手过程一样干净。对 anishsingh20/spot-gpu-checkpoint-resume 仓库执行 github_get_repogithub_list_branches 也以同样的方式成功,返回结构也完全相同。

第 9 步:在专为 git 打造的会话中用 git 产出变更

Action Gateway 的 GitHub 工具包本质上就是 GitHub API:它能创建、更新、审查、评论和检查拉取请求,还能读取仓库。但它并不运行 git,所以建分支、提交和压缩合并仍然要靠一个带有工作目录的环境来完成。既然这些操作本就是拉取请求流程的首尾环节,本教程就把整个拉取请求流程放进同一个 git 会话里,让网关去做它该做的事:在一台不持有密钥的机器上以 API 方式操作 GitHub。git 这部分由 Harness Runtime 用它自己的团队级 GitHub 授权来处理,也就是上一篇教程用过的那个授权:先为团队运行一次 doctl agent auth github,然后在 spec 中引用它。spec 文件是配套目录中的 specs/agents-git.yaml,与第 1 步唯一的区别是多了 repos 条目和一个额外的 secret:

name: anish-git-agent
repos:
  - anishsingh20/spot-gpu-checkpoint-resume
secrets:
  HARNESS_INFERENCE_API_KEY: "${DIGITALOCEAN_ACCESS_TOKEN}"
  GITHUB_TOKEN: "oauth/github"

这行配置的作用必须弄清楚,因为它正是本文要解释的权衡所在。GITHUB_TOKEN: "oauth/github" 要求 Harness Runtime 把团队的 GitHub OAuth 令牌放进沙箱,供 gitgh 使用。在 anish-git-agent 中:

GITHUB_TOKEN=gho_<REDACTED>
✓ Logged in to github.com account anishsingh20 (GITHUB_TOKEN)
  - Token scopes: 'repo'

这是机器上一个真实存在的令牌,而它之所以在那里,是因为你主动申请了它。凭据隔离“专门适用于通过 Action Gateway 访问的工具”,而这句话的另一面是:git 在哪里运行,凭据就要出现在哪里。当 agent 必须执行 commit 和 push 时,你才选择这条路;为它限定作用范围(这里是 repo)、随时吊销,都与其他令牌一视同仁。把它留在专职 git 的会话里,别让它进入那些只需把 GitHub 当作 API 来调用的会话。

在控制台中打开 Harness Runtime → Sessions → anish-git-agent,然后发送一条消息。这里的改动,是对配套仓库中的 README 做的一次小幅整理:

In /workspace/spot-gpu-checkpoint-resume, run git checkout main && git pull. The README links the published tutorial twice: line 5 has the hyperlink and line 7 is a plain "Read the tutorial: https://..." line. Create a branch docs/tidy-tutorial-link, delete only the redundant line 7 (and its blank line), keep the hyperlink on line 5. Commit as "docs: remove duplicate tutorial link", push, open a pull request into main with gh pr create using that title and a one-line body, then merge it with gh pr merge --squash --delete-branch. Reply with the PR URL, the state, and the merge commit SHA.

Claude Code 拉取了 main 分支,读完 README,在编辑文件这一步停了下来,提示 需要审批——因为 spec 里的 permissions.default 设为 ask,且只放行 bashmcp。在聊天窗口点一下 批准,它便继续往下执行:建分支、提交、推送、gh pr creategh pr merge 2 --squash --delete-branchgh pr view 2。它最后留下了这样一条消息:

PR URL             https://github.com/anishsingh20/spot-gpu-checkpoint-resume/pull/2
State              MERGED
Merge commit SHA   83db1b1107ea0e86d426e1dda9592be0c62877c7

anish-git-agent 会话的 Harness Runtime 聊天界面。Claude Code 依次运行 gh pr create、gh pr merge 和 gh pr view,然后报告 PR 网址、MERGED 状态及合并提交

这是该 git 会话的聊天窗口,画面已滚动到本次运行的末尾。每个区块对应 Claude Code 的一条消息。三行 Bash 记录正是它执行的命令:gh pr create 负责创建拉取请求,gh pr merge 负责合并并删除分支,gh pr view 负责回读结果。底部的小表格就是它给我们的答复:拉取请求的网址、状态(MERGED),以及以 83db1b1 开头的合并提交 ID。右上角绿色的 Running 徽标表明会话仍在运行,还能接受下一条指令。整个屏幕上看不到任何令牌。gh 用到的 gho_ 令牌保存在本会话的环境中,而且仅此一处。

从你的笔记本电脑上确认:

gh pr view 2 -R anishsingh20/spot-gpu-checkpoint-resume --json state,mergedAt,mergeCommit
{"mergeCommit":{"oid":"83db1b1107ea0e86d426e1dda9592be0c62877c7"},"mergedAt":"2026-09-11T09:12:23Z","state":"MERGED"}

第 10 步:回到无密钥会话,让 agent 操作 GitHub 并自行搜索

最后的收官测试来了:这次在 anish-no-key-agent 的聊天窗口中运行——这个会话自始至终没有持有过任何 GitHub token。打开 Harness Runtime → Sessions → anish-no-key-agent,发送下面这条提示词:

Three things, in order. 1) Using only the do_actions tools (action_search, then action_invoke), find out which GitHub account you are acting as. 2) Using do_actions again, check whether pull request #2 on anishsingh20/spot-gpu-checkpoint-resume is merged. 3) Then run these two shell commands and paste their exact output: env | grep -i github || echo "no GitHub variables in env"; and gh auth status. Do not use gh or git for steps 1 and 2. Finish with a short table: GitHub account, PR #2 merged (yes/no), GitHub token present in this sandbox (yes/no).

Harness Runtime 中 anish-no-key-agent 的聊天界面:显示提示词、一次包含两个用例的 action_search 调用、一次运行 github_github_get_user 与 github_check_pr_merged 的 action_invoke 调用,以及两行 Bash 记录

顶部的灰色区块是我们发给 agent 的消息。下方所有内容都是 agent 的工作过程,每执行一个动作就占一行。第一行 mcp__do_actions__action_search,是 agent 在问网关:“哪个工具能告诉我我在 GitHub 上是谁?哪个工具能检查 PR 是否已合并?”第二行 mcp__do_actions__action_invoke,是 agent 一次性调用了这两个工具。那两行 Bash,对应的是我们在第三部分要求执行的 shell 命令。某一行带绿色对勾,就表示这一步执行成功。注意看 agent 随请求发送了什么:仓库所有者、仓库名、PR 编号。没有密码,也没有令牌——它手里压根就没有令牌可发。

同一聊天滚动到末尾。shell 输出显示 env 中没有 GitHub 变量、gh 未登录。表格显示:GitHub 账号 anishsingh20,PR #2 是否已合并为是(HTTP 204),此沙箱中是否存在 GitHub 令牌为否

还是同一个聊天,这次滚动到了 agent 的最终回答。两个灰色框正是那两条 shell 命令的原样输出。第一条显示 no GitHub variables in env:机器的环境变量里找不到任何 GitHub 令牌。第二条显示 You are not logged into any GitHub hosts:这台机器上的 GitHub 命令行工具没有登录任何账号。接下来是 agent 的汇总表格,正面回答了我们的三个问题。它以 anishsingh20 的身份在 GitHub 上执行了操作。2 号 PR 已合并(GitHub 返回状态码 204,含义就是“是”)。此沙箱中是否存在 GitHub 令牌:。最后一句是 agent 用自己的话给出的解释:网关握有 GitHub 凭证,并以 anishsingh20 的身份行动,但这台机器上并没有这份凭证。说得直白些:agent 刚刚以你的身份在 GitHub 上真正干了活,随后去找自己用过的那把钥匙,却怎么也找不到——因为那把钥匙从来就没在这台机器上。

同样的检查还要在聊天之外再做一遍,免得结论只听 agent 一面之词。先看通过网关执行的合并检查:

doctl agent exec anish-no-key-agent -- sh -c '/tmp/ag.sh "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"tools/call\",\"params\":{\"name\":\"action_invoke\",\"arguments\":{\"rationale\":\"Confirm PR 2 is merged via the brokered GitHub connection\",\"tools\":[{\"tool\":\"github_check_pr_merged\",\"arguments\":{\"owner\":\"anishsingh20\",\"repo\":\"spot-gpu-checkpoint-resume\",\"pull_number\":2}}]}}}"'
{"total_count": 1, "success_count": 1, "error_count": 0, "results": [{"index": 0, "tool": "github_check_pr_merged", "version": "v2", "result": {"status": "succeeded", "output": {"status_code": 204}, "call_id": "call_", "_meta": {"provider": "github", "tool_version": "v2"}}}]}

等本文从这个沙箱环境发出的所有经过身份验证的调用都执行完毕后,再运行一次第 3 步的扫描:

doctl agent exec anish-no-key-agent -- bash -lc '
env | grep -iE "github|gh_|ghp_|gho_" || echo "no GitHub variables in env"
gh auth status 2>&1 | head -1
ls ~/.git-credentials ~/.config/gh 2>&1
git config --global --get credential.helper || echo "no credential.helper"
grep -rIEoh "gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}" /root /home /workspace /etc /tmp /opt 2>/dev/null | sort -u
'
no GitHub variables in env
You are not logged into any GitHub hosts. To log in, run: gh auth login
ls: cannot access '/root/.git-credentials': No such file or directory
ls: cannot access '/root/.config/gh': No such file or directory
no credential.helper
ghp_FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE1234

整台机器上,唯一长得像令牌的字符串 ghp_FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE1234 正是我们在步骤 3 里埋下的那枚假令牌。由于它当时出现在某条命令行中,所以至今仍留在沙箱的命令日志里。除此之外,一切都与首次调用 GitHub 之前完全一致。把这组前后对比,再配上步骤 9 中那行 gho_ 记录作参照,正是安全审查者希望看到的证明:凭据只存在于你指定放置的位置,其他任何地方都没有。

步骤 11:查看 Insights,然后暂停

在预览控制台中打开 Action Gateway → Insights,这是一个聚合仪表板:内容涵盖总请求数、成功率、调用最频繁的工具、工具状态、各提供商的使用情况,以及按提供商划分的 P50/P95 延迟,时间范围可选 24 小时、7 天或 30 天,数据最多延迟 15 分钟。本次运行结束后,面板显示该周共 121 个请求,P50 为 561 毫秒。它并不会列出单次调用的具体参数。文档中描述的单次调用追踪(记录“被调用的操作、参数、输出摘要、延迟、重试历史和成本归属”,且凭据绝不会写入追踪记录)只是文档所载的能力;在这个预览控制台里,我们无法打开任何一条追踪记录来查看,因此应把它当作文档所述的功能,而非亲测验证过的事实。

操作完成后,把两个会话都暂停:

doctl agent pause anish-no-key-agent
doctl agent pause anish-git-agent

GitHub 连接保留在网关上,与 Actor 绑定。恢复无密钥会话后,下一次 GitHub 调用仍会以同样的方式代理。要阻止 agent 继续操作 GitHub,请在 Connections 标签页中撤销该连接;按文档所述,系统不做凭证缓存,因此撤销在下一次调用时就会生效。第二个会话使用的 oauth/github 授权,则是在 Harness Runtime 侧单独撤销的。

这次测试证明了什么,又没证明什么

先把证据说精确,正是这种精确让它在安全审查中有价值。下面逐处过一遍 GitHub 凭据可能出现的地方、是哪一步做的检查,以及我们看到了什么。

令牌可能出现的位置 核查步骤 观察结果 状态
规格文本 第 1 步 没有 GITHUB_TOKEN,也没有 git 或 gh 的机密;secrets 里只有推理密钥 已观察
环境变量 第 3 步,第 10 步复查 只有 dop_v1_ 推理密钥和一个主机名;在经过身份验证的调用前后,都没有出现 GitHub 相关变量 已观察,并以植入令牌作对照
gh 登录态、git 凭据仓库、凭据助手 第 3 步,第 10 步复查 未登录;.git-credentials 不存在;~/.config/gh 不存在;无凭据助手 已观察
磁盘文件 第 3 步 /root/home/workspace/etc/tmp/opt 下没有任何形似令牌的字符串;同一次扫描抓到了植入的假令牌 已观察
MCP 客户端配置 第 4 步 只有一个绑定会话的内部 URL;没有 Authorization 请求头,也没有 bearer 令牌 已观察
模型读取的工具结果 第 6、8、10 步 握手:登录链接和验证码,无令牌。身份验证读取:四个资料字段,无令牌。合并检查:204,无令牌 已观察
智能体自己的自述 第 10 步 在要求它操作 GitHub 并随后搜索自己的沙箱后,它报告了 anishsingh20、已合并(204),以及“此沙箱中存在 GitHub 令牌:否” 已观察
网关一侧 第 7 步 有一条 Connection 记录,包含 Actor、OAuth、Active 状态和 scopes;控制台任何地方都不显示令牌值 已观察
那个具备 git 能力的会话 第 9 步 env 中有 GITHUB_TOKEN=gho_...ghrepo 权限登录。这是未经代理的路径,列出以作对照 已观察

本次运行通过观察证实:

  • 在智能体的 microVM 内部,root shell 在环境变量、GitHub CLI、git 凭据仓库,以及令牌模式扫描可达的文件系统任何位置,都没有找到 GitHub 凭据;扫描过程用一枚植入的假令牌做了验证。
  • 智能体通往 GitHub 的唯一路径,是私有 DigitalOcean 主机名上的一个 MCP 端点;该端点按会话 ID 绑定,客户端配置中不含 bearer 令牌。
  • 当 Agent 请求未经授权的 GitHub 操作时,响应中只包含一个登录指引(链接、验证码、授权范围、有效期、恢复提示),不含任何凭据信息。
  • 连接在网关侧创建并展示,挂载到某个 Actor 上,其授权范围可见,而 token 值不可见。
  • 连接进入 Active 状态后,同一个无密钥沙箱仍能以 anishsingh20 的身份通过网关发起经过认证的 GitHub 调用,包括 github_check_pr_merged 对已合并的拉取请求返回 204;随后再次扫描该沙箱,结果依然干净。
  • 在自己的聊天中被问及时,该 Agent 借助网关在 GitHub 上执行了操作,随后在自身环境中运行 envgh auth status,并报告没有任何 GitHub token。它的工具调用里只携带了仓库名和一个拉取请求编号,别无其他。
  • 在第二个会话中,GITHUB_TOKEN: "oauth/github" 会在环境里生成一个真实的 gho_ token,env 能看到它,gh 也会使用它。这就是不经过中介时的实际情形,也正是本文将两个会话分开讨论的原因。

何时使用哪种凭证路径

你的需求 使用方案 最终留在沙箱里的东西
以最快速度跑通演示,不考虑安全 env 里放 API 密钥 密钥以明文形式存在,代理和任何能向它发指令的人都能读到。不建议这么做。
用自己的服务商密钥让模型进行推理 secrets 密钥仅在运行时出现,因为调用模型的是代理进程。不会持久化到元数据或平台日志中。
让代理执行 git clone、提交和推送 Harness Runtime GitHub OAuth(doctl agent auth github 一个可供 git 在机器上使用的令牌。方便,但未经中介托管。
让代理以 API 方式操作 GitHub、Jira、Slack、Stripe、Postgres 等工具 Action Gateway 及其连接 什么都不留,只有一个会话级别的内部 URL。凭证由网关在每次调用时解析。
让每位客户连接自己的账户 Action Gateway 及每用户 OAuth(Actor) 什么都不留。每项授权都挂在网关上该客户对应的 Actor 下。
希望安全审查一次通过 所有第三方凭证都走 Action Gateway 什么都不留。向审查者展示第 3 步的排查结果和第 6 步的响应即可。

只要代理需要代表个人或团队在另一个产品里执行操作,就应使用 Action Gateway。如果工作始终不出 /workspace,用沙箱自带的工具即可。

本主题的常见问题 ?

1. 既然代理能运行 curl,它难道不能调用网关、随意使用任何工具吗?

它可以调用网关,因为网关正是它完成工作的途径。它做不到的,是调用网关不会为该会话和该 Actor 授权的那些工具。请求本身不携带任何凭证;每次调用时,都由网关判断这个会话能否通过这条连接运行这个工具。规范中的权限策略以及网关侧的其他策略,共同决定了这一判断。关键转变在于:“智能体能访问网关”不再等同于“智能体握有凭证”。

2. 沙箱里仍然有 ANTHROPIC_API_KEY,这难道不是凭证吗?

是的,而且它本来就该在那里:这是智能体进程与模型通信所用的密钥。它不能用来发起 pull request、读取 Jira 工单,也不能查询你的数据库。凭证代理针对的是工具凭证——也就是那些让智能体能在你其他系统中执行操作的凭证。请把模型密钥放进 secrets 而不是 env,这样它就不会被持久化到会话元数据或平台日志里。

3. 轮换或撤销 GitHub 令牌后会发生什么?

由于不存在凭证缓存,轮换和撤销在下一次调用时即刻生效。在 Connections 标签页中撤销这条连接后,智能体的下一次 github_* 调用就会返回 unauthorized 错误,并附上一个全新的连接链接。任何沙箱都无需更新任何东西,因为没有任何沙箱持有过这个令牌。

4. 令牌会出现在 Insights 追踪记录里吗?

不会。追踪记录只记载“所调用的操作、参数、输出摘要、延迟、重试历史和成本归属”,凭证永远不会出现在其中。在已配置的地方,其他敏感字段可以被脱敏,或以托管句柄的形式呈现。访问审计依据的是连接,而不是令牌的值。

5. 我的每位客户都能各自连接自己的 GitHub,而互不可见吗?

可以。这正是按用户划分的 OAuth 模式。每个终端用户都是一个 Actor。连接绑定在 Actor 名下,文档也明确指出:“如果 user_123 连接了 GitHub,那么 user_123 之后的 Session 都可以使用这个 Connection,而不会与其他 Actor 共享。”当你的产品为某位客户运行智能体时,只需用该客户的 Actor ID 创建会话,网关就会使用其授权。

6. 智能体调用工具时,如果 Secrets Manager 不可达怎么办?

“密钥不可达时,系统会直接失败(fail closed),而不是悄悄回退。”调用会失败并返回一个错误,智能体可以把它上报出去。它不会退回使用缓存或内嵌的凭证——因为压根就不存在这样的凭证。

结语

这次的测试方式是:把一台机器的 root 权限交给智能体,让它在上面找密钥。它一无所获,因为密钥压根就没放到那台机器上。当智能体被要求操作 GitHub 时,它拿到的是一个供人类使用的登录指引,而不是属于自己的令牌。等人类完成授权后,同一台机器便以这个人的身份发起了真实的 GitHub 调用,机器上依然没有令牌;事后让智能体搜索自身时,它给出的结论也和我们的发现一致:什么都没有。git 相关操作是在另一个会话里运行的,那个会话确实持有 git 令牌,文章也特意把这个令牌展示出来——因为弄清楚哪个会话持有什么,正是整套实践的纪律所在。

对一个小型 AI 原生团队来说,这意味着你可以交付一个能在客户的 GitHub、Jira 和 Slack 中执行操作的智能体,而客户的令牌永远不会落入提示词可以触及的地方。对企业平台团队来说,这意味着“凭证存放在哪里”这个问题有了明确答案:一个控制台标签页和一条 Secrets Manager 记录,而不是微虚拟机里的某个环境变量。无论属于哪种情况,第 3 步的扫描、第 6 步的握手,以及第 10 步返回的 204,都是可以拿给任何提问者查验的实证记录。

参考资料

  • Salman Paracha(工程高级副总裁):《私有预览:DigitalOcean 托管智能体运行时服务》,DigitalOcean,2026 年 8 月 25 日。
  • 直播会话 anish-git-agent(2026 年 9 月 11 日):使用同一镜像,额外添加 reposGITHUB_TOKEN: "oauth/github",由 Harness Runtime 控制台的聊天界面驱动。在 anishsingh20/spot-gpu-checkpoint-resume 上发起了 PR #2 并以 squash 方式合并(合并提交 83db1b1)。
  • Anish Singh Walia:《面向 AI 智能体的 MCP 网关:借助 DigitalOcean Action Gateway,无需共享凭证即可将 AI 智能体接入 SaaS 工具》,DigitalOcean Community,2026 年 9 月 4 日。
  • doctl beta 版发布页Inference 文档推理服务定价编码智能体指南doctl 参考文档

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。