自建 Runner 形同裸奔!CI/CD 安全大坑千万别踩

2026 全员推行一 Job 一 Workspace,现在已是行业标配

Cloud Mac AI Stack · L1  ·  2026.06.09  ·  约 11 分钟  ·  runbook · 含可抄配置

自建 self-hosted runner CI/CD 安全隔离与一 Job 一 Workspace 行业标配示意

很多团队在 Cloud Mac 或 Mac mini 上挂了 self-hosted GitHub Runner 之后,以为「不排队、能跑 xcodebuild」就算过关——排队与 TCO 篇解决的是这一层。

真正的大坑在下一层:共享 workspace。共用 DerivedData、全局依赖缓存、一把 PAT 同时给 CI 和 Agent 用——在只有维护者改 workflow 时,这只是少等几分钟构建;接上 fork PR、恶意 post step,以及 Claude Code / OpenHands 自动改 .github/workflows 之后,自建 runner 形同裸奔:上一个 job 落的密钥,下一个 job 仍读得到。

2026 年行业里正在对齐的底线也在这里:一 Job 一 Workspace——每个 job 独占目录、结束后清理,并配合 token 轮换。本篇是 Cloud Mac AI Stack · L1 专题第三篇(前置:① 执行引擎 · ② 排队与取舍):先讲 CI/CD 安全大坑从哪来,再给你可抄 runbook。专题目录见 § L1 连载

读本篇前 · L1 专题与 Stack 入口

L0 底座买还是租 Cloud Mac · 迁云端 AI 工作站

L1 专题(建议顺序读)① 执行引擎② 排队与 TCO③ 本篇 · CI/CD 安全与一 Job 一 Workspace

同栈常一起出现(L3–L5):L3 决策首讲 · MCP 安装 · Ollama 并行排班 · OpenHands

对比图:以前怎么配 vs 2026 行业标配 · 坑踩在哪

很多团队从托管 macos-latest 迁到 self-hosted 时,只继承了「不排队」(见 L1 ②),却没继承托管机的「每 job 一块新磁盘」。下面这张总览表 + 示意图,标出以前觉得「省事」、现在看是「裸奔」的位置。

必看 · 核心对比

以前「省事」= 现在「裸奔」——七条对照一眼看清

以前常见做法 2026 行业标配 踩坑后果

维度 以前常见做法(2024 前后) 2026 行业标配 踩坑后果
Job 工作目录 多个 job 共用 _work,不删 run 目录 一 Job 一 Workspace,job 结束即清 Job B 读到 Job A 的 .env、脚本植入物
构建缓存 共享 DerivedData / 全局 actions/cache key 仓库级 cache key + 定期 prune 缓存投毒;fork PR 扫描全局缓存
凭证 一把 PAT 通吃 CI + Agent;secrets 轮换但不删盘 CI / MCP 分 token;30–90 天轮换 + 清 workspace 磁盘上 PAT 副本长期有效
签名材料 临时 keychain 落在 $HOME,无 post 删除 job 级 keychain,if: always() 销毁 下一 job 或恶意 step 导出证书
谁改 workflow 默认只有 2–3 个维护者 维护者 + fork PR + Agent 改 CI 攻击面从「人」扩到「半自主进程」
Runner 分工 一台 Mac 接所有仓库、所有 PR 生产 / 预发 / fork 用不同 label 低信任 workflow 摸到生产签名环境
托管 vs 自建错觉 「自建 = 自己的机器,怎么快怎么来」 自建 = 自己画安全边界(托管 VM 自动隔离) 以为上了 self-hosted 就更安全,实则更裸奔

以前为什么能忍? 私有仓库、无 fork PR、日更 CI 只有内部 job——共享盘只是少等 3–8 分钟。 2026 为什么必须改? 同一台 Cloud Mac 上往往还有 Agent、MCP 与多仓库 job,任何一条旧做法都会把「省事」变成安全事件。

示意图 · 以前:共享 workspace 时坑在哪(跨 job 泄漏)
  同一台 self-hosted runner(同一 macOS 用户)

  Job A · nightly 签名
       │
       ├─► 解压 *.mobileprovision、临时 keychain
       ├─► 写入 ~/Library/Developer/Xcode/DerivedData   ← 以前觉得「提速」
       └─► PAT 写入 ~/.netrc(脚本偷懒)                ← 以前觉得「省事」
                │
                │  未清理 · _work 与缓存残留  Job B · 白天 fork PR「修文档」
       │
       └─► post 步骤扫描 _work / DerivedData / .netrc  ← 坑:读到 Job A 残留

       结果:自建 runner 形同裸奔(托管 macos-latest 每 job 会丢整块 VM 磁盘)
示意图 · 现在:一 Job 一 Workspace(2026 标配)
  Job A  ──►  workspace A(_work/.../run-id)  ──►  if: always() 清理  ──►  ✓
  Job B  ──►  workspace B(全新目录)          ──►  if: always() 清理  ──►  ✓
                │
                ├─ 敏感缓存:带 repo 前缀的 cache key,或纳入 cron prune
                ├─ PAT:CI 与 MCP 分 token,定期轮换
                └─ 高信任 / 低信任 job:不同 runner label(必要时第二台 Cloud Mac)

       结果:Fact 层可审计;与「Claude Code 生产 Diff,Runner 生产 Fact」一致

一眼对照 · 以前 vs 现在

  • 以前踩坑:把 self-hosted 当「更快的服务器」,共享一切能共享的
  • 现在标配:把 self-hosted 当「你要负责消毒的执行环境
  • 最大误区:从 macos-latest 迁过来时,只迁了 CPU,没迁隔离

裸奔从哪来:自建 Runner 最常见三类 CI/CD 安全大坑

托管 macos-latest 每次 job 近似一块新磁盘,所以很多团队第一次自建 runner 时误以为「机器是自己的,怎么省事怎么来」。self-hosted 的真实代价是:安全边界要你自己画——这也是 L1 专题第二篇 里「不排队」之外的隐性账单。

下面三类误配,我们在 Cloud Mac 客户现场见过不止一次;任意踩中两条,就值得立刻上「一 Job 一 Workspace」。

2026 为什么全员推行一 Job 一 Workspace

不是 GitHub 突然发了新规定,而是攻击面变大了:执行 workflow 的不只有维护者,还有 fork PR、供应链脚本,以及同机上的 AI Agent。下面三条,解释了为什么「共享盘提速」在 2026 已经站不住脚。

1. 跨 job 污染:上一个 job 的密钥,下一个 job 还能读到

self-hosted runner 的 _work 目录在 job 之间默认不会自动消毒。job A 解压的签名证书、脚本写的 .envpost 步骤落下的 .netrc,job B 完全可能通过相对路径或符号链接读到。2026 年攻击面更大:Claude CodeOpenHands 还能在同机改 .github/workflows——你要审计的不只是 diff,还有改 CI 时磁盘上还有什么残留

2. 全局缓存投毒:DerivedData / npm 缓存变成「公共后门」

共享 ~/Library/Developer/Xcode/DerivedData 或宽口径 actions/cache key,在内部仓库里也许还能忍;一旦接 fork PR,恶意 step 可在 post 扫描全局缓存——托管 VM 会销毁,self-hosted 不清理就是持久攻击面。iOS 团队典型翻车:nightly 签名 job 与白天「文档修复」PR 共用同一 runner。

3. 长期 PAT 落盘:只轮换 secrets 不够,磁盘上的副本还在

许多 Cloud Mac 栈里,同一把 GitHub PAT 既给 MCP 拉仓库,又给 Runner 推制品。Agent 侧一旦误暴露,CI 侧也跟着沦陷。只轮换 GitHub UI 里的 secrets 而不清 workspace,等于只换了锁芯、没收回已经写在磁盘上的副本

Stack 分工 · Fact 层不能裸奔

系列 Slogan(出自L1 专题开篇):Claude Code 生产 Diff,GitHub Runner 生产 Fact。 Diff 再漂亮,Fact 若在「脏 workspace」里跑绿,merge 之后照样爆雷。L4 MCP 最小权限 管 Agent token;L1 本篇管磁盘与 job 边界——两层都要做,不能只选其一。

3
常见 CI/CD 安全大坑
1
Job 级 workspace 边界
2026
行业标配元年

先对齐模型:GitHub Actions 在 runner 上留下什么

很多人把「workspace」理解成 checkout 出来的 git 目录——其实只是一部分。一次 macOS job 结束后,磁盘上可能还存在:

路径 / 对象 典型内容 不清理的风险
_work/<repo>/<run-id>/ checkout、构建产物、测试输出 下一 job 读取源码外的生成物、恶意植入脚本
~/.npm~/Library/Caches 依赖与工具缓存 缓存投毒、跨仓库依赖混淆
DerivedData.swiftpm Xcode / Swift 构建缓存 泄露符号、嵌入旧版签名配置
临时 keychain、*.mobileprovision 签名材料 高危:下一 job 或恶意 step 导出证书
环境变量注入文件、.netrc CI 脚本写的凭证 明文 PAT 长期留存

托管 runner 在 job 结束时会丢弃整块磁盘;self-hosted 不会。Agent 栈里更糟:Claude Code 的会话文件、Ollama 权重、Runner 的 _work 可能共用同一用户家目录(同机排班见L2 并行调度)——「一 job 一 workspace」的意思,是 job 开始时假设环境已被 Agent 或其它 job 弄脏,结束时只保留应跨 job 存在的层(通常是没有)。

行业标配落地:「一 Job 一 Workspace」指什么

可以拆成四层,从易到难:

  1. 目录隔离:每个 job 使用独立的 RUNNER_TEMP / run 目录;禁止脚本写死 /tmp/shared 或仓库外的「团队公用文件夹」。
  2. 进程边界:同一 runner 进程可以串行跑多个 job,但不得在 job 之间共享未清理的全局状态(例如 export 进 ~/.zshrc 的 API key)。
  3. 凭证边界:签名用的临时 keychain 在 job 的 post 里删除;secrets 只通过环境变量注入,不落盘;若必须落盘,路径必须在 run 目录内且随 job 删除。
  4. 运维边界:为高风险仓库配置仅跑受信 workflow 的专用 runner(label 分离),与跑 fork PR 的 runner 物理分开——这在 Cloud Mac 上通常意味着第二台节点,而不是同一台 Mac 上赌清理脚本永远正确。

与「ephemeral runner」的关系

GitHub 企业版提供的 ephemeral self-hosted runner 会在每个 job 后退出进程并换新实例,本质上把「一 job 一 workspace」自动化。若你用的是常驻 runner(Cloud Mac 上最常见),就要用脚本 + workflow 约定手动达到相近效果。

token 轮换:为什么不能只清目录

workspace 清理解决「文件残留」;token 轮换解决「即使文件被拷走,拷贝也会过期」。建议至少轮换三类凭证:

  • Runner 注册 token:移除并重新注册 runner(或按组织策略轮换),避免旧机上的注册状态被滥用。
  • 用于 CI 的 GitHub PAT / App:scope 最小化(只读仓库 vs 写 packages),与MCP 侧 PAT 策略分开管理,避免「一个 token 通吃 Agent + CI」。
  • Apple 签名与第三方 API key:使用短期凭证或每次 job 从 secrets 注入,禁止写进 runner 用户主目录的 plist。

轮换频率没有银弹:私有仓库、无 fork PR、workflow 仅维护者可改,90 天一轮通常够用;若接开源 contributor 或 Agent 自动提交 workflow,建议缩短到 30 天,并在每次安全事件后立即移除 runner 重新注册。

Runbook:把「一 Job 一 Workspace」写进 workflow

下列片段可直接贴进现有 pipeline,把行业标配落成可审计步骤。假设 macOS self-hosted runner,路径按 GitHub 默认 _work 布局。若同一 Cloud Mac 上还跑 Claude Code,优先让 Runner 使用独立 macOS 用户。MCP 连通见 安装教程

workflow · job 结束清理(示意)
# .github/workflows/ios-ci.yml 片段
jobs:
  build:
    runs-on: [self-hosted, macos, cloud-mac]
    steps:
      - uses: actions/checkout@v4
      - name: Build iOS
        run: xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' build
      - name: Scrub workspace (always)
        if: always()
        run: |
          rm -rf "$RUNNER_TEMP"/*
          rm -rf "$GITHUB_WORKSPACE"/build
          security delete-keychain ci_temp.keychain-db 2>/dev/null || true
#!/usr/bin/env bash
# /usr/local/bin/runner-prune-work.sh · 每日 03:00 cron
set -euo pipefail
WORK_ROOT="${HOME}/actions-runner/_work"
# 删除超过 48h 的 run 目录(按 mtime)
find "$WORK_ROOT" -mindepth 3 -maxdepth 3 -type d -mtime +2 -exec rm -rf {} +
# 可选:修剪 DerivedData 中超过 7 天的条目
find ~/Library/Developer/Xcode/DerivedData -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true

验证方式:故意在两个连续 job 里打印 ls -la "$GITHUB_WORKSPACE/.." 与关键缓存路径,确认第二个 job 看不到第一个 job 写入的标记文件(例如在 post 里 touch /tmp/job-marker-$GITHUB_RUN_ID 并检查是否残留)。

常见误配

在 workflow 里用 actions/cache 缓存未签名的第三方二进制到全局 cache key,却未限制仓库范围——这相当于在 runner 上建「跨 job、跨仓库」的共享层。要么收紧 cache key 与分支,要么把缓存目录纳入 prune 脚本。

Cloud Mac 同机部署:Agent + Runner 的安全分界线

典型 Cloud Mac AI Stack 是:Claude Code(Diff)+ Runner(Fact)+ 可选 Ollama 同机。省排队、省 git pull,但若共用不可清理的全局目录,CI 裸奔会牵连 Agent 会话

  • 用户分离:Runner 用 runner 用户,Agent 用开发者用户;禁止在同一 ~/.zshrc 混放 ANTHROPIC_API_KEY 与签名密钥。
  • Agent 工作区 ≠ CI workspace:Claude Code 的 project 目录不要指向 Runner 的 _work;Agent 生成的 patch 走 git,不直接写 CI 缓存树。
  • 内存争用 ≠ 可共享磁盘:Ollama 与 Runner 抢内存见 并行排班;Swap 高不能成为「继续共享 DerivedData」的借口。
  • 出口 IP 与 label:能访问内网预发的 runner 不要同时接 fork PR;Agent 自动提交的 workflow 先走低权限 label,人工晋升后再碰生产 runner。

什么时候可以暂时不做(以及何时必须立刻做)

场景 可否暂缓 说明
私有 monorepo、仅 2–3 维护者、无 fork PR 短期可 仍建议每月 manual prune + 季度 token 轮换
开源仓库接受外部 PR 跑 Actions 不可 应专用 runner 或改托管 macOS
已接 Claude Code / OpenHands / MCP 写仓库 不可 默认一 Job 一 Workspace,禁止共享敏感缓存
签名证书在 CI 解密 不可 必须 job 级 keychain + post 删除

上线前检查清单(可打印)

  • 每个 job 有 if: always() 的清理 step 或等价宿主机 prune
  • 临时 keychain / 签名文件不落在 $HOME 永久路径
  • 高风险仓库与低信任 workflow 使用不同 runner label
  • Runner 注册 token 与 CI PAT 有轮换日历(建议 30–90 天)
  • 新 contributor 的首个 workflow PR 走人工 review,而非直接命中生产 runner
  • Agent 与 CI 使用不同 PAT / App,禁止一把 token 通吃 MCP 与 Runner
  • L1 专题第二篇对照:② 解决「慢」,③ 本篇解决「自建 runner 是否裸奔」

L1 专题连载 · 与 Stack 各层怎么接

本篇收束 L1(Fact 层) 安全线:从「为什么要有 Runner」→「值不值得自建」→「2026 一 Job 一 Workspace 行业标配怎么落地」。建议按表顺序读;纵向回 L0,横向接 L3–L5。

篇目 主题 状态
· 执行引擎 为什么 Runner 是 Cloud Mac AI Stack 的 L1(Diff → Fact) 已发布
· 排队与 TCO macOS CI 排队时间 · self-hosted vs macos-latest 已发布
· 本篇 自建 Runner 安全 · 一 Job 一 Workspace 行业标配 已发布
· OpenClaw 流水线 Runner 执行 step · OpenClaw 编排触发与回执(L1 延伸) 已发布

Stack 纵向衔接(每层一篇入口即可):

读完 L1 三连后,若你已在 Cloud Mac 上同时跑 Agent 与 CI,下一层通常是补全 L3 Diff 层决策L3 ① · 权限交出与何时正式接入(与vs Cursor工作站实测分工不同)——再往后是 L6 端到端闭环地图(排期中)。

常见问题

Q:一 Job 一 Workspace 会不会让 CI 更慢?
会冷启动变慢——这正是当年共享盘的原因。2026 的合理做法是:只删敏感物与本 job 目录,可再生缓存用带仓库前缀的 cache key,而不是永不清理的全局 DerivedData。

Q:自建 runner 算裸奔吗?什么情况下算?
若多个 job / 多个仓库共用同一用户家目录里的签名材料、.netrc 或宽口径全局缓存,且没有 if: always() 清理——在接 fork PR 或 Agent 改 workflow 时,就算裸奔

Q:能不能只收紧 MCP 权限,继续共享 runner 磁盘?
不够。MCP 最小权限 管 Agent 调工具;Runner 侧仍要管磁盘上已存在的文件。恶意 fork workflow 不走 MCP,照样能扫 _work 残留。

Q:GitHub 官方有没有一键开关?
托管 runner 近似「一键」;self-hosted 需组合 workflow post、宿主机 cron、以及可选的 ephemeral 模式。没有单条 actions/checkout 参数能替代全套边界。

Q:和 OpenClaw 编排层怎么分工?
OpenClaw 管触发顺序与回执;Runner 管逐步执行。隔离策略写在 workflow 与 runner 宿主机,不应假设 OpenClaw 会替你做磁盘清理(见 L1 专题④ · OpenClaw 流水线)。

Q:L1 专题要从哪篇开始读?
建议 ① 执行引擎② 排队③ 本篇。完整表见 § L1 连载

L1 专题已三连 · 下一层 Diff 决策

Fact 层做完,先过 L3 门禁

L1 解决「CI 在什么环境里跑 Fact」。下一步是 L3 决策首讲:你不是在用 Claude Code,是把代码权限全盘交出——什么时候才该让 Agent 正式接入。

阅读 L3 决策首讲
Cloud Mac 查看 M4 套餐