本地 AI 代理和 CI 任务需要不同的访问模型
本地 AI 代理和 CI 任务需要不同的访问模型,因为人工授权、工作负载身份和凭据暴露会带来不同的风险。

开发者工作站和构建运行器都可能执行 shell 命令、调用 API 和推送代码。把它们当成同一种安全环境,最终很容易让团队把部署凭据放在一个既没有人、也没有服务能够妥善追责的位置。
本地 AI 代理运行在有人值守的环境中。人可以看到提示,检查差异,拒绝异常请求,并撤销行为异常的进程。CI 任务则由某个事件触发,例如推送、拉取请求、标签、定时运行或手动调度。任务需要一种与该事件绑定、可以由机器验证的权限。它不能等开发者为每次调用背书,也不应该因为工作流恰好运行,就继承开发者长期拥有的访问权。
这不是学术上的区别。它决定代理是否会收到秘密,令牌存在几分钟还是几个月,审计记录必须说明什么,以及被攻破的依赖是否可能演变成生产事故。
有人在场,会改变授权的含义
工作站上的授权可以成为有意义的安全控制,因为人在现场,能够判断眼前的操作。授权信息必须足够准确地描述调用者,这样判断才有价值。“代理想发起一个 HTTP 请求”提供的证据很差。“一个由此授权机构签名、在本次会话中启动的进程,想使用生产部署凭据”才给操作人员提供了明确的接受或拒绝依据。
这种模式有一个明确边界:授权的意义在于,人要为一个正在运行的进程负责。授权不能替代身份。如果恶意进程能够冒充可信调用者、隐藏目标地址,或在任务改变后继续使用原来的授权,那么提示框就只剩下表面功夫。
在开发者机器上,我希望分别确认三件事:
- 受保护的存储在本地用户解锁前始终保持锁定;
- 新进程的第一次请求需要作出会话决定;
- 敏感凭据可以要求每次使用都作出决定。
这些控制回答的是不同问题。锁定状态说明是否允许发生任何操作。会话授权说明该进程能否在本次运行期间执行操作。每次调用授权说明某个凭据是否过于重要,不能随意重复使用。团队常常把这三者合并成一个“允许代理访问”按钮,之后才发现,这个按钮批准的范围远远超过开发者的本意。
本地代理还应该收到结果,而不是凭据材料。如果代理需要查询 API,可以由代理之外的组件附加凭据、发起请求,再把响应返回给代理。这样,代理中的提示注入就不能轻易把 API 密钥打印到终端、补丁或聊天记录中。秘密一旦进入代理上下文,再进行遮蔽,并不等于受到了同等保护。代理可能在遮蔽程序看到之前就对秘密进行编码、发送到其他主机,或把它用于请求。
Sallyport 在 macOS 上采用这种有人值守的模式:保险库始终位于绝对防护之后,并且可以要求进程会话授权,或要求每次使用选定凭据时都授权。开发者在场时,这种设计很合理。对于无人值守的运行器,它却不是合适的基础机制。
CI 任务需要经得起检查的身份
CI 任务无法随时提供人的意图。它需要工作负载身份,也就是根据任务的可验证事实派生出的身份,而不是根据复制到任务环境中的秘密获得的身份。
对于部署工作流,这些事实通常包括 CI 签发方、仓库或项目、提交或引用、工作流身份、环境以及预期受众。目标服务会检查签名断言,再将它交换为短期凭据。这正是 OIDC 联合身份的价值:运行器无需在秘密存储中携带可重复使用的云密钥,也能证明任务来自哪里。
GitHub Actions 通过 OIDC 令牌端点和 id-token: write 权限说明了这种模式。这个权限名称很容易被误解。它允许工作流请求身份令牌,但本身并不授予部署权限。云角色或目标服务仍然必须拒绝签发方、受众、主体及其他声明不符合预期工作流的令牌。
许多配置正是在后半部分失败的。如果一个角色接受来自某个仓库的任何令牌,就等于把过多权限交给了该仓库中所有符合条件的工作流。文档预览、发布工作流和生产部署不应该仅仅因为共享同一个源代码仓库,就拥有等价权限。
使用声明,让角色描述一种明确的任务类别。具体声明语法取决于 CI 提供商和云平台,但策略应该回答几个简单问题:
- 哪个仓库可以请求这个角色?
- 哪个工作流文件或受保护环境可以请求它?
- 哪个分支、标签或发布条件可以请求它?
- 断言必须声明什么受众?
- 签发的凭据可以保持可用多长时间?
不要写入自己没有在真实令牌中检查过的策略条件。在安全的测试环境中打印声明,将它们与信任策略比较,并测试拒绝场景。人们会测试成功部署,却把更危险的路径当成理所当然,例如来自不受信任分支的拉取请求。
秘密和身份解决的是不同问题
秘密证明的是持有权。身份断言则说明请求访问的工作负载是什么。两者最后都可能变成承载令牌,但它们会造成完全不同的失败路径。
保存的 CI 秘密通常不知道任务为什么会收到它。如果工作流可以读取 DEPLOY_TOKEN,那么被修改的脚本、被攻破的 action、恶意拉取请求路径或日志命令,就能在该令牌权限允许的任何地方使用它。轮换可以缩短秘密保持有效的时间,却不能限制每次使用时的上下文。
短期联合身份不会自动让 CI 变得安全。被攻破的任务仍然可以在令牌有效期内使用合法令牌。它带来的好处是影响范围更小:攻击者必须运行一个符合条件的任务,满足签发方和声明规则,并且在凭据过期前采取行动。你也可以撤销或修改接受该身份的角色,而不必找出每一份复制的秘密。
不要把短期凭据误认为低权限凭据。一个有效期十分钟、却可以删除所有生产数据库的令牌,仍然不可接受。时间限制减少持久性,授权范围限制损害。两者都需要。
本地代理访问面临相反的问题。开发者可能在多个仓库和任务中使用同一个本地工具,因此,一个权限过大的 API 密钥会成为被攻破代理的诱人目标。最安全的本地设计是让密钥留在代理之外,只允许代理执行用户批准的特定 API 调用或 SSH 命令。如果外部服务支持细粒度令牌,也应在那里使用。请求离开机器后,受保护的保险库无法弥补一个权限过大的令牌。
两种环境共用一个令牌,是最危险的便利
在 CI 中使用开发者的个人令牌很常见,因为它能快速让受阻的部署继续运行。但这也是抹去责任边界的糟糕方式之一。
个人令牌通常拥有超出流水线需求的权限。它可能属于一名会换团队、离开公司、从笔记本电脑使用令牌的员工,权限结构反映的是个人成员身份,而不是部署职责。当它出现在 CI 中时,审计日志可以显示是这个令牌执行了操作,却无法如实说明操作来自开发者还是发布任务。
反过来的错误也会发生。团队把 CI 部署秘密暴露给本地代理,让它“测试同样的事情”。这样一来,交互式代码生成就获得了无人值守的生产能力,而且检查可能比发布工作流还少。事故响应也会变得非常困难:是代理使用了凭据,是 shell 脚本使用了凭据,还是复制出来的值泄露到了另一个工具?
为每种环境设置独立的授权边界。本地开发者可以对开发端点拥有可交互、可撤销的访问权。发布任务可以获得一个仅限受保护生产环境的联合角色。拉取请求任务则可以完全没有写权限。这些不是需要消除的不便,而是日后解释某项操作为何获准时所依赖的证据。
一个实用的命名规则是,让凭据名称同时体现操作者和用途。ci-release-prod-deploy 比 deploy-token 向审查者传达更多信息。更好的做法是,CI 任务根本不保存这个名称的令牌,而是请求一个与身份绑定的角色,并让信任策略表达同样的用途。
运行器状态会把临时访问变成残留物
任务即使使用临时凭据,也可能留下永久问题。常见泄露位置包括日志、shell 跟踪、缓存的主目录、 Docker 层、工作区构件以及第三方 action 写入的文件。
自托管运行器尤其需要警惕,因为它们可能在任务之间保留状态。检出不受信任代码的任务可能植入被修改的可执行文件、改动共享缓存、查看遗留工作区,或等待特权工作流重新使用主机。如果任务共享同一个操作系统账户、容器套接字或文件系统,仅按仓库名称隔离并没有帮助。
临时运行器会在任务结束后消失,因此可以消除大量残留问题。但它不能取代对工作流输入的控制。即使使用全新机器,一个特权任务只要运行来自不受信任拉取请求的脚本,仍然是在把权限交给不受信任的代码。
GitHub Actions 警告称,pull_request_target 会在基础仓库的上下文中运行,并可能访问秘密或写权限。这个事件有正当用途:维护者可能需要标记或评论来自 fork 的拉取请求。问题出在工作流由该事件触发后,又检出拉取请求的头部提交并执行其中脚本。此时,工作流把受信任凭据和攻击者控制的代码结合在了一起。
保持模式简单可靠:
- 运行不受信任的拉取请求代码时,不授予部署权限。
- 只有经过审查的引用和受控工作流路径,才能访问受保护环境。
- 在流程允许的情况下,将第三方 action 固定到不可变的提交引用。
- 不要让秘密材料进入缓存、构件和诊断输出。
- 任务结束后销毁敏感的运行器实例。
第一项和第四项抓住的真实事故,往往比复杂的令牌命名约定更多。权限范围设计得再完美,如果 shell 命令把令牌打印出来,或构件打包了配置文件,它仍然会泄露。
部署身份应该能在一个文件中看清
工作流应当明确展示它如何请求外部权限。下面的 GitHub Actions 示例只在部署任务中请求 OIDC 身份,并声明生产环境。示例中没有保存的云凭据。
name: deploy
on:
push:
tags:
- "v*"
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@<full-commit-sha>
- name: Request deployment identity
run: |
token=$(curl -sS \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=deploy.example.internal")
test -n "$token"
- name: Deploy
run: ./scripts/deploy.sh
实际交换通常会使用 action 或云 CLI,而不是把响应保存到 shell 变量中。这里要展示的是请求的形态:GitHub 提供一次性请求令牌和 URL,工作流请求一个特定受众,之后由目标决定该身份能否承担某个角色。运行器绝不能把得到的身份令牌当作通用 API 凭据。
云端信任策略必须拒绝声明无法证明这个发布上下文的令牌。不要照搬提供商示例后就停止。提供商示例通常从宽泛条件开始,以便适用于更多用户。角色能够改变生产环境之前,应收紧仓库、环境、分支或标签以及受众限制。
如果目标无法直接验证 OIDC,就在它前面放置一个小型凭据代理。代理验证 CI 断言,将声明映射到范围狭窄的操作,签发临时目标凭据,并记录映射关系。不要因为目标存在这个限制,就把永久目标秘密放进每一个需要该服务的工作流。
SSH 最能体现这种差异
SSH 密钥不适合在 CI 中共享。一把复制到 CI 秘密中的私钥,可以从任何获准读取它的任务进行身份验证,而它的公钥通常无法告诉目标,是哪个仓库版本请求了连接。强制命令、来源限制和独立账户可以减少损害,但核心身份仍然是一把可重复使用的私钥。
本地工作可能需要 SSH 的交互式拦截,因为开发者可以看到代理想连接的具体主机。私钥应留在受保护的存储中,代理应请求执行特定命令,而不是获得对密钥的直接访问。主机仍然必须执行自己的账户权限和命令限制。本地授权控制的是发起点,不能让危险的远程命令变得安全。
对于 CI,如果 SSH 证书颁发机构和目标主机群支持,优先使用短期 SSH 证书。任务通过工作负载联合身份请求一个有效期短、主体受限,必要时还带有强制命令的证书。目标验证的是证书颁发机构,而不是永远接受一把复制来的私钥。
如果无法使用证书,就为每类部署使用独立部署账户和专用私钥。在 authorized_keys 和服务器端限制该账户。最基本的限制如下:
command="/usr/local/bin/receive-release",no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA... ci-release
这行配置会阻止密钥打开任意交互式 shell,并强制服务器运行一个接收程序。但它本身无法识别源代码仓库。若发布决策依赖仓库上下文,还应配合代理签发的短期密钥或其他经过验证的工作负载信号。
不要为了让 SSH 在 CI 中运行,就在脚本里设置 StrictHostKeyChecking=no。这会关闭服务器身份检查,而运行器恰恰很容易成为流量重定向点。通过受控机制提供已知主机密钥,谨慎轮换,并在主机验证意外发生变化时让任务失败。
审计记录必须回答不同的问题
只写“部署成功”的操作日志属于运行输出,还不够构成安全证据。你需要重建谁或什么获得了权限、发起了什么请求,以及记录本身之后是否被修改。
对于有人值守的本地代理,应记录本地进程身份、会话开始时间、授权决定、凭据标签或操作类别、目标地址、请求时间和结果。不要记录秘密值或完整的敏感载荷。进程签名或代码签名机构比任意进程名称更有用,因为名称很容易复制。
对于 CI,应记录 CI 提供商、运行 ID、仓库、工作流引用、提交 SHA、可获得时触发运行的操作者、运行器类别、OIDC 主体和受众、目标角色、可以安全保留的操作参数以及结果。这样调查人员才能区分带标签的发布、手动重新运行的任务、受信任工作流和意外的宽泛授权。
把授权日志和应用日志分开。应用日志可能在日常运维中被修改、抽样或删除。授权轨迹需要追加写入特性和独立验证。保留哈希链并根据预期记录进行验证,可以发现序列是否被修改;但它不能阻止攻击者阻止未来记录产生。因此,在设计允许的情况下,应将记录发送到被攻破机器之外。
Sallyport 会把会话日志和单独的操作日志写入一个只写不可读、加密并采用哈希链的审计日志,sp audit verify 无需保险库密钥即可离线检查哈希链。对于有人值守的代理操作,这是一项有用证据。CI 则应在签发和接受其身份的系统中,生成等价的、与工作负载绑定的记录。
授权疲劳是本地设计失败
每次调用授权可以保护高影响力的本地凭据,但如果每次无害读取都弹出提示,人们就会习惯于不阅读提示便接受。控制措施一旦变成例行仪式,攻击者只需要等待日常操作出现。
对于难以撤销的操作,使用每次调用授权,例如生产环境写入、DNS 修改、组织范围管理、源代码强制推送或针对敏感主机的命令。对于开发者可以在一次代理运行中合理委托的重复性低风险操作,使用会话授权。保险库锁定应保持独立,这样即使旧会话授权仍然存在,锁定的机器也会拒绝所有操作。
CI 也有自己的授权疲劳:每个任务都出现人工审批门,最后大家因为发布时机太重要而照常批准。生产部署可以使用受保护环境审批,但审批对象应当是明确识别的发布构件和目标。审批不能弥补接收不受信任代码的工作流、声明范围过宽的角色,或状态未知的运行器。
一个实际的检查方式是问一个令人不安的问题:如果这个授权一个月内都被自动点击,可能会运行什么?对于本地代理,应缩小获准操作范围,直到答案可以接受。对于 CI,应从普通机器操作中移除人工审批,改为把权限绑定到任务身份。
在下一次请求凭据前,先分开两条路径
当代理或流水线请求访问时,先对调用者分类,再选择秘密机制。现场是否有人能够检查操作?调用者是否是一个可以验证声明的可重复工作负载?能否只向调用者返回有限结果,而不是凭据?目标权限是否恰好对应一个明确任务?
如果调用者是本地代理,就在代理上下文之外保护凭据,保留有意义的授权路径,并以开发者可以检查的形式记录每次操作。如果调用者是 CI,就使用短期联合身份,让角色绑定到工作流声明,隔离运行器,并记录授权调用所依据的工作负载事实。
不要因为共享令牌今天更快,就让它抹掉这条边界。下一次事故会迫使你在调查究竟是人、代理还是运行器执行了操作的同时,重新建立这条边界。
常见问题
为什么 AI 编程代理和 CI 流水线应该使用不同的凭据?
本地代理运行在开发者的工作会话中,人可以检查请求并中断进程。CI 任务则会在源代码事件发生后无人值守地运行,因此权限必须来自范围严格限制的工作负载身份,并随任务结束而失效。
可以让 CI 和本地代理使用同一套授权流程吗?
不应该。人工授权提示只能证明有人在某个时间点点击了按钮,无法说明任务对应的仓库、提交、工作流或目标云角色。交互式本地工作使用人工授权,CI 使用与工作负载绑定的授权。
长期有效的 API 密钥在 CI 中是否有合理用途?
通常不值得。长期有效的秘密会让任何被攻破的运行器、日志泄露、缓存归档或恶意依赖都变成持久的访问通道。更好的做法是根据 OIDC 断言签发短期令牌,并限制仓库、引用、工作流和受众。
CI 中的 OIDC 工作负载身份是什么?
OIDC 工作负载令牌是 CI 提供商针对正在运行的任务所作的身份断言。云服务或秘密管理服务会验证这个断言,然后为特定角色签发短期凭据,而不是要求任务携带保存好的云端秘密。
本地 AI 代理是否应该看到 API 密钥?
如果可以,最好让它完全拿不到凭据。为本地代理提供一个操作接口,由代理之外的组件执行请求并返回结果,同时把底层 API 或 SSH 材料保存在受保护的本地存储中。
如何防止 CI 任务访问错误的环境?
不是。工作流应明确列出需要访问的每个外部目标,每个目标也应拥有独立的身份和权限。通用部署令牌看似方便,但一旦文档构建被攻破,它可能就能部署到生产环境。
自托管 CI 运行器最大的安全风险是什么?
把运行器状态视为有害残留。敏感任务使用临时运行器,固定依赖版本,避免恢复包含秘密的目录,清理日志,并假设特权任务可能被检出的代码诱导。
如何审计 CI 凭据?
先检查凭据来源,再检查受众、仓库或项目声明、分支或环境限制、有效期和目标权限。如果无法解释这些字段,说明令牌获得的信任超出了任务应有的范围。
代理操作的审计日志应该记录什么?
把两类记录分开。交互式会话日志应标明本地进程和每次获准的操作;CI 记录应将每次操作绑定到提供商运行 ID、仓库版本、工作流、运行器类别和签发的角色。只有一条“成功”消息不能算审计记录。
Sallyport 能在 Linux CI 运行器中运行吗?
请使用托管云身份、专用部署代理,或通过短期联合身份保护的严格限权服务账户。Sallyport 面向的是有人值守的 macOS 场景,不是无头 CI 凭据服务。