执行器设计与沙箱评估
状态:🔶 设计先行(T607,2026-08-08)——契约与威胁建模落盘,供 T603 实现
ContainerExecutor;独立沙箱容器实现留 M7。 背景决策见 todo-archive.md §M6 规划决策(Q1 执行深度 A/B 双模式、Q4 沙箱=A、Q5 Action 触发=B)。 安全评估(2026-08-14):评估结论、治理决议与不可简化的安全基线见 沙箱与恶意依赖防护治理;本文档 §2.2 的 M6 缓解——USER 降权已修复(C38,2026-08-14)、外联日志已实现(C40/T805,2026-08-14),登记 backlog C38/C40。 A 模式 push + PR 闭环(2026-08-20,C53 实施):A 模式 fix / fix-and-pr 完成后新增推送修复分支到远程 + 创建 PR 两条链路,结束了 M6 阶段"修复结果仅在本地临时目录"问题;新的状态机 dispatched 语义(PR 失败但分支已推)、runUrl 兜底为 branch URL、workDir 保留 24h 供诊断 由本文档 §8 记录。
1. 定位
平台 = 控制面(触发器/调度器 + 结果展示);修复执行 = 数据面(真实 clone、改文件、跑质量门)。 数据面通过 Executor 抽象与平台解耦,执行后端可插拔:
| 执行后端 | 隔离级别 | 工具链来源 | M6 状态 | M7 状态 |
|---|---|---|---|---|
平台容器内子进程(ContainerExecutor) | 进程级(容器即沙箱) | 平台镜像内置 git/node/pnpm | ✅ 实现(T603) | 保留 |
独立沙箱容器(SandboxExecutor) | 容器级(每任务/每仓库容器) | 同一镜像或精简镜像 | 🔶 设计(本文档) | 实现(backlog C26) |
GitHub Action(ActionTriggerExecutor) | GitHub 托管环境 | 目标仓库自带(action.yml 引用) | ✅ 触发实现(T607)+ 结果回填(C25) | 保留 |
本地临时目录(LocalExecutor) | 无隔离(开发调试用) | 宿主工具链 | —(仅开发) | — |
2. 威胁建模:恶意依赖升级
2.1 风险面
dependfix 的核心动作是升级第三方依赖,本质是"拉取并执行不可信代码":
| 攻击面 | 场景 | 影响 |
|---|---|---|
| install scripts / postinstall | 恶意包在 pnpm install 时执行 preinstall/install/postinstall 脚本(供应链投毒主流路径,如 event-stream、ua-parser-js 案例) | 容器内任意代码执行 |
| 构建链投毒 | 依赖被攻破后,构建脚本(prepare/build 钩子)篡改产物 | 产物不可信,污染后续依赖方 |
| 凭据泄露 | 恶意脚本读取进程环境(GITHUB_TOKEN、ENCRYPTION_KEY、AUTH_SECRET 等)或工作目录中的凭据文件,外传 | 凭据泄露 → 仓库/平台被接管 |
| 网络外联 | 恶意脚本访问外部网络(回传数据、下载第二阶段 payload) | 数据泄露、横向扩散 |
| 文件系统破坏 | 恶意脚本删除/篡改工作目录或平台文件 | 平台数据损坏 |
| 资源耗尽 | 无限循环、磁盘写满(依赖下载/日志膨胀) | DoS,平台不可用 |
| 提权逃逸 | 容器内进程以 root 运行且宿主机无防护时尝试逃逸 | 宿主接管 |
2.2 风险定级与缓解
| 风险 | 等级 | M6 缓解(ContainerExecutor 必做) | M7 增强(SandboxExecutor) |
|---|---|---|---|
| install scripts 代码执行 | 高(本工具必然触发) | ① 非 root 用户运行(镜像 USER 降权)② 独立临时工作目录 ③ 超时/资源上限 ④ 执行结果白名单回传 | 独立容器 + 网络出站限制(默认 deny,白名单 registry 域名) |
| 凭据泄露 | 高(执行环境持有平台密钥) | ① 凭据仅解密到执行进程内存,绝不落盘 ② 环境变量最小集注入(只传本仓库所需 token)③ 平台密钥(ENCRYPTION_KEY/AUTH_SECRET)不传入执行子进程 | 每任务独立密钥、无宿主 env 继承 |
| 网络外联 | 中 | 记录执行期外联日志(备查);M6 容器内默认放行(registry 需要) | 出站白名单(npm/pnpm registry + GitHub API) |
| 文件系统破坏 | 中 | 工作目录限定在平台数据卷下的 runs/{runId}/ 临时目录,执行后清理 | 只读根文件系统 + tmpfs 工作目录 |
| 资源耗尽 | 中 | 子进程超时(默认 30 分钟可配)+ 磁盘配额随数据卷 | cgroup 内存/CPU 限制 |
| 提权逃逸 | 低(单租户自托管) | 非 root + 不挂载 docker.sock + 容器只读部分 | 独立容器 + seccomp/apparmor 加固 |
M6 结论:平台容器即沙箱(进程级隔离)可接受——单租户自托管场景下威胁模型以"恶意依赖脚本"为主,通过非 root + 临时目录 + 凭据最小化 + 超时四项缓解即可达安全基线;更高隔离(网络出站限制、每任务容器)登记 backlog C26,M7 随 BullMQ worker 模型实现。
⚠️ 2026-08-14 评估修正:M6 四项缓解中的"非 root 运行(镜像
USER降权)"已修复(C38,2026-08-14)——entrypoint 降权方案(dependfix 用户 uid 100 + chown 数据卷 + su-exec),本地实证通过;"记录执行期外联日志"未实现——登记 C40。C26 独立沙箱提级为 M7 前置(并发共享容器交叉污染,见 治理文档 §3 路径 D)。实证补充:容器内 git/pnpm 工具链从未安装(本文档声称"平台镜像内置 git/node/pnpm"与实际不符,仅 node 存在)——已修复(C45/T801,2026-08-14):git + pnpm 11.18.0 + workspace node_modules 打包,容器内 fix 全链路实证通过。
3. Executor 接口契约
执行后端可插拔的统一接口(T603 ContainerExecutor 与 T607 ActionTriggerExecutor 均实现此契约):
// apps/platform/server/services/executor/types.ts(T603 落地)
/** 执行器类型标识 */
export type ExecutorKind = 'container' | 'sandbox' | 'github-action' | 'local'
/** 执行上下文:平台侧组装,不携带任何平台密钥(凭据单独解密传递) */
export interface ScanExecutorContext {
/** 平台侧 runId(对应 ScanRun.id) */
runId: string
/** 目标仓库(Repository 实体切片) */
repository: {
owner: string
name: string
defaultBranch: string
packageManager?: 'pnpm' | 'npm' | 'yarn'
/** ActionTriggerExecutor 使用:目标 workflow 文件名(仓库内路径,如 `.github/workflows/security-auto-fix.yml`) */
actionWorkflowFile?: string
}
/** 复用 cli 的 RuntimeConfig(mode/severityThreshold/repositories 等) */
config: RuntimeConfig
/** 解密后的凭据(仅本次执行内存中持有,用后即弃) */
credential?: { token: string }
/** 工作目录:容器执行 = 数据卷下 runs/{runId}/;action 触发 = 不需要 */
workDir: string
}
/** 执行结果:结构化回传(T603 落库 ScanRun/ScanResult 的数据源) */
export interface ScanExecutorResult {
exitCode: number
/** cli 的 RunResult(repositories/alerts/actions/errors/summary) */
result?: RunResult
/** 执行级失败(非业务失败:超时/环境缺失/触发失败等) */
error?: { code: string; message: string }
startedAt: string
finishedAt: string
}
/** 执行后端统一契约:平台 scan-orchestrator 只依赖此接口,不感知具体实现 */
export interface ScanExecutor {
readonly kind: ExecutorKind
/** 执行前可用性探测(如容器内工具链存在性 / action 仓库权限校验) */
isAvailable(): Promise<boolean>
execute(ctx: ScanExecutorContext): Promise<ScanExecutorResult>
}契约要点
- 凭据最小化:
credential由平台 credential service 在调用 execute 前解密,仅注入本次执行;ScanExecutorContext不携带平台级密钥(ENCRYPTION_KEY/AUTH_SECRET永不进入执行进程)。凭据来源单一:复用RuntimeConfig时,其githubToken/alertsToken字段必须由 credential service 解密结果填充,禁止从平台存储二次读取。 - 结果结构对齐 cli:
result直接复用RunResult(@dependfix/core类型),T603 落库无需二次映射。 - 执行失败与业务失败分离:
exitCode+result表示业务结果(扫描/修复产出);error表示执行基础设施失败(环境缺失、超时、触发被拒),T603 据此置ScanRun.status = 'failed'。 - 可插拔路由:平台按 Repository 配置(
executorKind字段,M6 默认container;配置了actionWorkflowFile且显式选择时路由github-action)选择执行器。
4. ActionTriggerExecutor(T607 实现)
4.1 触发流程
平台(B 模式)→ POST /api/repos/{id}/scan { executor: 'github-action' }
→ credential 解密(需 actions: write 权限)
→ POST /repos/{owner}/{name}/actions/workflows/{workflowFile}/dispatches
{ ref: defaultBranch, inputs: { mode, severity-threshold, ... } }
→ 返回 { ok: true, dispatchId, runUrl }(GitHub 不返回 run id,需轮询 run 列表定位)
→ ScanRun.status = 'dispatched'(触发成功但结果未就绪;结果回填见 C25:轮询 run 完成 → 下载 artifact 解析落库)4.2 权限要求
| 项 | 要求 |
|---|---|
| GitHub 凭据 | 需 actions: write 权限(classic PAT 勾选 workflow scope;fine-grained PAT 配 Actions: write) |
| 目标 workflow | 仓库内已存在(actionWorkflowFile 声明),且 on: workflow_dispatch 已声明 |
| 触发输入 | 与 action.yml inputs 对齐:mode(默认 fix-and-pr)、severity-threshold(默认 high)、repos、max-alerts-per-repository 等,最大 10 个字符串输入(GitHub 限制) |
4.3 触发结果判定
GitHub dispatches API 成功返回 204 即触发受理,但不返回 run id;实现需在触发后轮询 /repos/{owner}/{name}/actions/runs?event=workflow_dispatch(带短退避,如 5s×3)定位本次 run 并返回 runUrl(供 UI 展示跳转)。轮询失败不视为扫描失败(仅 runUrl 缺失),ScanRun 保持 dispatched 状态。
4.4 错误处理
| 场景 | 行为 |
|---|---|
凭据无 actions: write 权限 | 触发返回 403 → error.code = 'trigger_forbidden',ScanRun → failed |
| workflow 文件不存在 | 404 → error.code = 'workflow_not_found',提示在仓库配置中修正 actionWorkflowFile |
| 目标仓库未配置该 workflow | 预检(触发前 GET workflow 确认存在),缺失即拒绝,避免无谓 404 |
5. B 模式(GitHub Action 降级)接入评估
决策背景:M6 规划 Q5=B——平台对已配置 action 的仓库触发
workflow_dispatch,作为服务器配置较低时的降级路径。
5.1 使用方式
- 目标仓库添加依赖:在其
.github/workflows/security-auto-fix.yml中uses: dependfix/dependfix@v1(复用根 action.yml,M2 已落地),on: workflow_dispatch声明。 - 平台仓库配置:
actionWorkflowFile填入该文件路径,凭据选择具备actions: write的 Credential。 - 用户触发:平台 Web UI 点击扫描(执行后端选择 GitHub Action)→ 平台触发 dispatch → 用户跳转目标仓库 Actions 页面查看执行。
5.2 体验评估
| 维度 | 评估 |
|---|---|
| 优点 | 平台零工具链依赖(服务器配置低也无需内置 git/node/pnpm);执行环境由 GitHub 托管、隔离性好(恶意依赖脚本跑在 GitHub runner 上);无需维护执行镜像 |
| 缺点 | ActionResultFetcher 轮询 run 完成 + 下载 dependfix-report-{runId} artifact 解析回填);执行延迟高(runner 排队 + checkout + install,通常分钟级);依赖目标仓库已配置 workflow(接入前置成本) |
| 触发可靠性 | workflow_dispatch 无排队保障,rate limit 5000/h 内足够;对私有仓库需 token 有该仓库访问权 + actions: write |
5.3 成本评估
| 项 | A 模式(平台容器) | B 模式(GitHub Action) |
|---|---|---|
| 计算成本 | 平台服务器(已部署则边际成本≈0) | 目标仓库 Actions minutes(私有仓库计费;公共仓库免费) |
| 工具链维护 | 平台镜像内置(Dockerfile 已含 git/pnpm) | 无(GitHub runner 自带) |
| 结果获取 | 直接回填(T603) | 自动拉取(C25 已实现:artifact 下载)或人工查看 |
结论:B 模式作为降级路径保留——适合"平台服务器资源受限 + 目标仓库已配置 action"场景;M6 实现触发(ActionTriggerExecutor)与结果回填(ActionResultFetcher,C25 已实现);默认路径仍是 A 模式 ContainerExecutor(T603)。
同步等待边界:M6 同步执行模型下,B 模式结果回填的 fetcher 轮询最长 30 分钟(
runTimeoutMs可配)——HTTP 请求会同步挂起至该上限。反向代理默认超时(如 Nginx 60s)可能先断开,用户侧表现为"扫描无响应";此时已有降级路径(result_fetch_failed→dispatched+ runUrl 提示跳转查看,action 实际已在目标仓库运行)。M7 T702 队列化后 B 模式改为异步回填,消除同步阻塞。
6. 相关文档
- todo-archive.md §M6 规划决策:Q1/Q4/Q5 决策依据
- backlog.md C25 Action 触发结果回填:结果回填实现记录
- backlog.md C26 独立沙箱容器执行实现:独立沙箱容器 backlog 登记 + 2026-08-19 决策
- todo-archive-phases-m10-c53-c59c61.md §M10 C26 实施规划:T1001-T1004 子任务拆解与验收要点(2026-08-20 收口归档)
- sandbox-security-governance.md §5 治理决议 G5:并发共享容器交叉污染登记
- 架构设计:平台分层与 Executor 定位
- 安全设计:凭据加密存储与最小化
- github-action-workflow.md:Action 入口(M2 落地)
7. Sandbox 执行器设计
状态:🔶 设计落盘(M10,2026-08-19 决策会议 / 2026-08-20 收口归档)——T1001-T1004 实施规划已在 todo-archive-phases-m10-c53-c59c61.md §M10 落地;本文档定义接口契约与部署形态,详细任务拆解见 todo-archive 实施规划。 决策依据:Docker rootless mode + 应用层白名单代理 + cgroup v2 双层;Executor 抽象不与 rootless 强绑定;自托管 docker-compose 优先;与
ContainerExecutor并存保留单机场景。一手调研依据见 todo-archive-phases-m10-c53-c59c61.md §M10 决策依据。
7.1 抽象边界(不强绑定 Docker rootless)
SandboxExecutor 通过 §3 接口契约实现,不与具体 OCI runtime 强绑定——Runtime 形态作为配置项(SANDBOX_RUNTIME / Repository 字段)注入,避免今后切 Sysbox(--runtime=sysbox-runc)、Kata(--runtime=kata-runtime)等需要重写业务代码:
Repository.executorKind = 'sandbox' → SandboxExecutor 路由
↓
scan-orchestrator 解析 → SandboxExecutor.execute(ctx)
↓
SandboxRuntimeAdapter (interface, DI)
├─ runtime=DockerRootless → docker run --user=100:100 --memory=... --cpus=... sandbox-image:tag
├─ runtime=Sysbox → docker run --runtime=sysbox-runc ...
└─ runtime=Kata → docker run --runtime=kata-runtime ...(backlog 登记,非 M10 目标)接口预览(T1001 实施时落定):
// apps/platform/server/services/executor/sandbox-runtime-adapter.ts(新建)
export interface SandboxRuntimeAdapter {
/** 启动 sandbox 容器并返回 wait/stop 接口 */
spawn(opts: SandboxSpawnOpts): Promise<SandboxHandle>
/** 探测当前 runtime 可用性(启动期自检用) */
isAvailable(): Promise<boolean>
}
export interface SandboxSpawnOpts {
image: string // 复用平台镜像 tags(T1001-1)
user: string // '100:100'(T1001)
cgroupLimits?: { memoryMb: number; cpu: number } // 透传 Repository.sandboxLimits
workDirBindMount: string // /tmp/runs/{runId} → /workspace
networkEgressPolicy: 'allowlist' // 白名单拦截代理对接(T1002)
envSubset: NodeJS.ProcessEnv // 仅解密后的 exec token(T1002 域名校验前置)
}
export interface SandboxHandle {
containerId: string
stop(signal?: NodeJS.Signals): Promise<void>
waitForExit(): Promise<{ exitCode: number; stdout: string; stderr: string }>
}RuntimeAdapter 不变量:业务侧只依赖 SandboxRuntimeAdapter 接口,与 docker run / podman run / ctr run(containerd CLI)解耦。当前默认实现为 DockerRootlessAdapter,对应 --user=100:100 --memory=2g --cpus=1.0。切 Sysbox 路径仅替换 adapter 实现。
7.2 镜像策略
复用 apps/platform/Dockerfile runtime 阶段(T801 已落地 git + pnpm 11.18.0 工具链;C45 修复),不维护双镜像。Sandbox 容器启动命令与平台容器内执行 DependfixApp.run() 等价,差异仅在 UID/cgroup/网络隔离边界。镜像 tag 通过 apps/platform/docker tag 复用(与 C30 Publish Docker CI 链路解耦——CI 发布的镜像不可被 sandbox 直接拉,本场景使用平台内置镜像)。
7.3 部署形态
自托管 docker-compose(M10 目标,唯一交付形态):
apps/platform/docker-compose.yml增加sandbox-daemon服务(rootless Docker daemon 容器,挂载data/runs共享卷,映射 unix socket 给 platform 容器)apps/platform/Dockerfile不变(T801/C38 已落地,非 root + 工具链)- platform 容器通过
DOCKER_HOST=unix:///var/run/docker.sock(容器内 socket 路径,与 rootless daemon 共享)
反模式登记(绝对不可用):
- 挂宿主
docker.sock直连:违反 sandbox-security-governance.md §3 路径 D - DinD
--privileged:恶意脚本等效宿主 root(CVE-2019-5736 runc 逃逸) - 平台容器启动 rootless daemon 自身作为 sandbox(破坏"独立 PID/Mount namespace"目的)
K8s + Helm Chart:仅在本节末子目登记为 backlog(不属 M10 范围)——见 §7.5。
7.4 与 ContainerExecutor 并存
按 todo-archive-phases-m10-c53-c59c61.md §M10 D6 决策(Q6 并存):两 Executor 同时注册,默认 container(向后兼容单机场景不破坏):
| 触发条件 | 走向 | 备注 |
|---|---|---|
Repository.executorKind = undefined | ContainerExecutor | M6 默认,单机/无 rootless 场景仍可用 |
Repository.executorKind = 'container' | ContainerExecutor | 显式声明,与 M6 一致 |
Repository.executorKind = 'sandbox' + SandboxRuntimeAdapter 可用 | SandboxExecutor | M10 目标 |
Repository.executorKind = 'sandbox' + adapter 启动时不可用(isAvailable() 返回 false) | ContainerExecutor 降级执行 → degraded 状态 | A 场景(配置层降级):业务结果完整,UI info 提示「未启用 rootless,已自动使用平台容器」 |
Repository.executorKind = 'sandbox' + adapter 启动可用但 execute() 抛 errno(ENOENT/ENOTCONN/EACCES/ECONNREFUSED) | 不静默降级 → error.code = 'sandbox_unavailable' → failed 状态 | B 场景(环境中途变化):业务未完成,UI warn 告警「沙箱运行时不可用,环境配置可能已变化」 |
Repository.executorKind = 'github-action' | ActionTriggerExecutor | M6 已有 |
CLI 启动时(@dependfix/cli entrypoint)探测 SandboxRuntimeAdapter 可用性;不可用时输出 [sandbox] warn 提示管理员启动 rootless daemon,但不阻断运行(旧路径仍可用)。
A/B 场景语义差异详见下文 §7.8 降级状态机契约——核心是「启动时降级(配置偏离)→ degraded + info」与「运行时降级(环境异常)→ failed + warn」的边界区分。
7.5 K8s + Helm 部署预留(非 M10 范围)
状态:🔶 backlogging,待真实 K8s 部署需求出现时评估(用户 2026-08-19 决策:"仅做规划,等真有需求时再实现")。
触发条件:
- 真实多租户/企业部署需要 K8s 编排
- 至少 1 个外部用户提出 K8s 部署请求
- dependfix 1.0.0 正式发布前后纳入发行矩阵
预留接口:SandboxRuntimeAdapter 抽象兼容 K8s(通过 Kubernetes RuntimeClass + Pod sandbox securityContext 实现,无需 runc/dockerd 依赖)。Repository.executorKind='sandbox' 在 K8s 场景下走 KubernetesRuntimeAdapter(未来 TBD),接口签名保持 §7.1 不变。
Helm Chart 留 backlog:需 values.yaml(sandbox resource limits 默认 / RBAC 不挂 docker.sock / PodSecurityContext 非 root)/ templates/deployment.yaml(rootless daemon sidecar)/ templates/servicemonitor.yaml(sandbox-security-governance.md §7 验收持续治理)。
7.6 验收对照(链接权威条款)
实施时按 sandbox-security-governance.md §4 安全基线 与 安全规范 §5.3 逐项核验:
- 非 root 执行 → SandboxRuntimeAdapter 注入
--user=100:100(C38 路径延续) - 超时兜底 → T802 单命令超时(已落地)+ SandboxHandle.waitForExit 透传外层 30 分钟超时
- 资源与网络 → T1002 白名单拦截代理 + T1003 cgroup v2
- 工作目录隔离 →
runs/{runId}/临时目录 + bind-mount + 执行后 cleanup - 新执行后端威胁建模评审 → sandbox-security-governance.md §4.4 已要求;T1001 提交 Review Gate 时同节点触发 Code Auditor 复核
- 规范单点声明 → 不在本节重复 security.md §5.3 条款,仅挂引用
7.7 设计反例(绝对不可行)
| 反例 | 风险 | 登记 |
|---|---|---|
SandboxRuntimeAdapter 内部硬编码 docker run(而非参数化 runtime) | 强绑定 docker;切 Sysbox/Kata 需重写 | T1001 Review Gate 必查 |
| SandboxExecutor 工作目录 bind-mount 宿主路径(非 run-scoped tmp) | 跨 run 数据残留 | T1001 Review Gate 必查 |
Sandbox 镜像走 caomeiyouren/dependfix:latest(CI 发布镜像) | sandbox 与平台二进制版本漂移风险 | T1001 镜像策略段禁止 |
默认 executorKind='sandbox' | 单机场景破坏 | T1001 路由默认 'container' |
7.8 降级状态机契约(degraded vs failed)
状态:🔶 M11 阶段登记(2026-08-20),T1005-C 实施中。背景:sandbox 路由在「启动时不可用」与「运行时不可用」两种场景下的语义边界——前者是配置偏离(业务完整),后者是真实异常(业务未完成)。统一归为
failed会丢失降级路径的成功信息,统一归为degraded会掩盖运行时异常,故引入独立状态分流。
7.8.1 两种降级场景的语义边界
| 场景 | 触发条件 | 业务结果 | 状态 | UI 严重度 |
|---|---|---|---|---|
| A. 启动时降级(配置偏离) | executorKind === 'sandbox' + sandbox.isAvailable() === false | ✅ ContainerExecutor 跑成功,summaryJson / runUrl 完整 | degraded | info(蓝色提示) |
| B. 运行时降级(环境异常) | executorKind === 'sandbox' + sandbox.isAvailable() === true + sandbox.execute() 内部抛 errno | ❌ 不降级避免掩盖真实错误,result 为 undefined | failed(error.code = 'sandbox_unavailable') | warn(黄色告警) |
核心区别:
failed= 「你让我做的事,没做成」(业务结果为空)degraded= 「你让我做的事,做成了,但走的路不是你想要的那条」(业务结果完整,仅路径偏离)
为什么 B 场景不静默降级回 ContainerExecutor? ——避免掩盖「环境容器中途变化」的真实异常。降级会让管理员错过 docker daemon / cgroup / user namespace 状态变化的告警信号,违背 sandbox 治理的「环境异常必须可观测」原则。
7.8.2 状态机决策函数契约
scan-run-state.ts 的 resolveScanRunState 在原签名基础上新增 degradedReason? 参数:
export const resolveScanRunState = (
executorKind: 'container' | 'github-action' | 'sandbox',
error: { code: string, message: string } | undefined,
result: RunResult | undefined,
/** A 场景降级信号(仅 sandbox 路由启动时不可用触发) */
degradedReason?: { code: string, message: string },
): ScanRunStateDecisionScanRunStateDecision.status union 新增 'degraded':
export interface ScanRunStateDecision {
status: 'completed' | 'failed' | 'dispatched' | 'degraded'
errorJson?: { code: string, message: string } | null
}A 模式块新增分支(必须在 pr_creation_failed 分支之后、其他错误分支之前):
// A 模式块:启动时降级 → degraded(业务完整 + 路径偏离)
if (result && degradedReason) {
return { status: 'degraded', errorJson: degradedReason }
}
// 其他错误(含 sandbox_unavailable 运行时失败)→ failed
if (error && !result) {
return { status: 'failed' }
}7.8.3 orchestrator 降级信号传递
scan-orchestrator.service.ts 在 sandbox 路由块维护 degradedReason 内部变量:
if (executorKind === 'sandbox') {
let degradedReason: { code: string, message: string } | undefined
const sandbox = new SandboxExecutor({ ... })
if (await sandbox.isAvailable()) {
// 启动可用 → 走 sandbox(可能 B 场景:execute 抛 errno → catch → sandbox_unavailable)
const execResult = await sandbox.execute(ctx)
result = execResult.result
error = execResult.error
} else {
// A 场景:启动时降级 → 记录降级原因 + 走 ContainerExecutor
degradedReason = {
code: 'sandbox_unavailable',
message: '沙箱执行器启动时不可用(无 rootless daemon / user namespace 受限),已自动降级到平台容器',
}
const executor = new ContainerExecutor({ ... })
const execResult = await executor.execute(ctx)
result = execResult.result
error = execResult.error
runUrl = execResult.runUrl ?? null
}
// 状态机决策透传 degradedReason
const decision = resolveScanRunState(executorKind, error, result, degradedReason)
// 新增 degraded 分支写 errorJson + summaryJson + runUrl
}7.8.4 ScanRun 状态机扩展
ScanRunStatus enum 新增 'degraded' 终态(与 completed / failed / dispatched 并列):
| 状态 | 业务结果 | 落库字段 | UI 严重度 | 聚合计入 |
|---|---|---|---|---|
completed | 完整 | summaryJson + runUrl | success | completedCount + alertsTotal + fixedCount |
dispatched | 主要副作用已落库 | runUrl + errorJson | info | finishedCount |
degraded | 完整(路径偏离) | summaryJson + runUrl + errorJson(sandbox_unavailable 降级原因) | info(蓝色) | degradedCount(独立计)+ alertsTotal + fixedCount |
failed | 空 | errorJson | danger(红) | failedCount |
关键设计决策:
degraded的 ScanResult 参与 severityCounts 统计(业务结果完整,与completed等价口径)batch-aggregate.ts新增degradedCount字段,与failedCount独立计数TERMINAL_STATUSES加入'degraded'(聚合判定「终态」)
7.8.5 已知 backlog
- 环境容器变化告警(已登记 backlog,未实施):B 场景(运行时失败)当前仅 UI warn 提示 + 平台日志 stderr;未来若引入 audit log 设计 + 通知渠道(邮件 / Slack / Webhook),可推送「沙箱执行器运行时不可用」告警给管理员。当前阶段仅 stderr + UI 提示,登记 backlog 详见 backlog.md §C-ENV-CHANGE-ALERT。
8. A 模式 push + PR 推送机制
状态:✅ 设计落盘(C53,2026-08-20 实施)——A 模式(
ContainerExecutor)fix / fix-and-pr 完成后新增推送修复分支到远程 + 创建 PR 两条链路,落盘 commit83ec736/46b7c15/3ed8303。2026-09-04 修订(M25 事故修复):C53 原始设计假定
app.run()内部只做本地修复 + commit,push / PR 由平台承担。但实际上引擎的fix-and-pr模式自带createFixBranch → pushBranch → createPullRequest完整链路,且pushBranch走裸git push不带凭据——容器内git -c http.extraheader=... clone也不会把 extraheader 写入 .git/config(实测git -c是 git 级 flag,非 clone 子命令),导致 push 必然缺凭据失败。修复后:引擎降级为
mode: 'fix' + commit: true(仅本地修复+commit),push + PR 全部走平台 [platform-delivery] 模块,保留引擎的 dedup / supersede 决策。详细根因与方案见 archive/todo-archive-phases-m25.md §M25(M25 已 2026-09-08 归档)。
8.1 流程变更(C53 → M25 修复后差异)
C53 阶段(修复前):
ContainerExecutor.execute()
↓
fix-and-pr 模式 + app.run() 成功(exitCode === 0)
↓
[引擎内部] createFixBranch + pushBranch + createPullRequest ← 推 push 必然失败(容器无凭据)
↓
平台 fallback: pushFixBranch(branch, workDir, token) ← exitCode≠0 整段被跳过
↓
status = completed(误报)M25 阶段(修复后):
ContainerExecutor.execute()
↓
clone + preRunHead = rev-parse HEAD
↓
app.run() in mode='fix' + commit=true ← 引擎仅做本地修复+commit
↓
hasNewCommit = (postRunHead !== preRunHead) ← 严格判定(no-op 扫描不产生空 push)
↓
fix 模式:pushFixBranch(current branch, workDir, token) → runUrl = branch URL
↓
fix-and-pr 模式:
├─ planFixAndPrDelivery(octokit, ...) ← 复用引擎 computeFixFingerprint + findDependfixOpenPR + computeFixAndPrPlan
│ ├─ skip: 同指纹 PR 已存在 → runUrl = existing PR URL(幂等交付)
│ └─ create: 推进 push + create + close supersede
↓
createFixBranch(plan.branchName, workDir)
↓
pushFixBranchWithCredential(plan.branchName, workDir, token) ← http.extraheader 注入 token
↓
createPullRequest(...) → runUrl = PR.htmlUrl
↓
closeSupersededPRs(...) ← best-effort;失败仅 warn
↓
失败兜底(结构化 PlatformDeliveryError):
├─ push_failed: failed + workDir 立即清理
└─ pr_creation_failed: dispatched + workDir 保留 24h 供诊断8.2 状态机扩展(与 B 模式 dispatched 语义对齐 + M25 引擎交付类识别)
C53 引入 A 模式 dispatched 三分支(scan-run-state.ts),M25 新增引擎交付类 category 识别:
| 场景 | error.code | result.errors category | status | runUrl | workDir 处理 |
|---|---|---|---|---|---|
| 修复成功 + push 成功 + PR 成功 | — | — | completed | PR URL | finally 立即清理 |
| 修复成功 + push 成功 + PR 失败 | pr_creation_failed | — | dispatched | branch URL(兜底) | moveToPending 保留 24h |
| 修复成功 + push 失败 | push_failed | — | failed | null | finally 立即清理 |
| 引擎内部交付失败(commit / verify / rollback / FATAL) | engine_delivery_failed | COMMIT_FAILED / VERIFICATION_FAILED / ROLLBACK_FAILED / FATAL | failed | null | finally 立即清理 |
| 引擎进程级 exitCode=2 + result 存在 | engine_exit_2 | — | failed | null | finally 立即清理 |
| 修复成功 + 修复动作执行失败 | execution_failed | — | failed | null | finally 立即清理 |
| 执行超时 | execution_timeout | — | failed | null | finally 立即清理 |
| report-only | — | — | completed | null | finally 立即清理 |
错误码 pr_creation_failed 命名与 B 模式已有的 result_fetch_failed / run_url_not_resolved 对齐——表达"副作用已落库(分支已推)+ 最终操作未完成"。 错误码 engine_delivery_failed 是 M25 新增的进程内识别——引擎在 fix / fix-and-pr 流程走到交付阶段失败(commit / PR / verify / rollback / FATAL),result 仍可能存在(部分仓库成功 + 部分失败)但远程未完整落地。原行为 result 存在即 completed 会让"已修复 8 但无 PR"误报(见 2026-09-04 03:28 AM 事故)。
8.3 关键代码点
| 函数 / 模块 | 位置 | 职责 |
|---|---|---|
extractBranchName(workDir) | container-executor.ts | 读 git rev-parse --abbrev-ref HEAD;detached HEAD 抛错(仅 fix 模式推默认分支时使用) |
pushFixBranch(branch, workDir, token?) | container-executor.ts | git push origin <branch>,token 走 http.extraheader(base64 basic auth),避免进 argv/URL(仅 fix 模式使用) |
createFixBranch(branchName, workDir) | @dependfix/engine | git checkout -b 创建/切换修复分支(fix-and-pr 模式从 HEAD 拉新分支,引擎导出复用) |
readHeadSha(workDir) | container-executor.ts | 读 git rev-parse HEAD,返回 SHA 或 null(用于 hasNewCommit 判定) |
checkHasNewCommit(workDir, preRunHead) | container-executor.ts | 比较修复前后 HEAD SHA,引擎 commit 后才返回 true |
| platform-delivery 模块 | executor/platform-delivery.ts | M25 新增:平台侧 fix-and-pr 完整交付单元(plan / push / create / close supersede) |
planFixAndPrDelivery(octokit, owner, repo, result) | platform-delivery.ts | 复用引擎 computeFixFingerprint + findDependfixOpenPR + computeFixAndPrPlan,返回 skip / create 决策 |
pushFixBranchWithCredential(branch, workDir, token) | platform-delivery.ts | 与 pushFixBranch 等价,独立导出便于单测;失败抛 PlatformDeliveryError(push_failed) |
deliverFixAndPr(ctx) | platform-delivery.ts | 编排 push + create + close supersede;失败抛 PlatformDeliveryError(pr_creation_failed / supersede_failed) |
PlatformDeliveryError | platform-delivery.ts | 带 code + branchPushed 状态的结构化错误(区分 push_failed vs pr_creation_failed) |
moveToPending(workDir, runId, pendingRoot, retentionMs) | container-executor.ts | 移动 workDir 到 _pending/{runId}/ + 写 .meta.json(含 expiresAt) |
cleanupRemoteBranch(branch, workDir, token?) | container-executor.ts | best-effort 远程分支清理;当前 不主动调用(保留远程分支供用户手动开 PR) |
8.4 凭据权限阶(重要安全考量)
C53 引入 A 模式 fix-and-pr 链路完成的关键代价是 凭据权限面扩大:
| 模式 | 所需 Token 权限 |
|---|---|
| A 模式 report-only | security-events: read(拉告警) |
| A 模式 fix(仅 commit) | contents: write(push 到默认分支 / 修复分支) |
| A 模式 fix-and-pr | contents: write + pull-requests: write(开 PR) |
| B 模式(GitHub Action) | actions: read + write(仅触发 workflow + 拉结果) |
A 模式 fix-and-pr 要求 wide-scope PAT(classic PAT 勾选 repo 或 fine-grained PAT 显式授权 Contents: write + Pull requests: write),与 B 模式的窄权限形成鲜明对比。
安全优势(M25 修复后):
- 修复前:引擎在容器内裸
git push无凭据 → 失败 + workDir 保留 24h(含 .git/config 未持久化 extraheader,但其他 token 落盘风险存在) - 修复后:平台
pushFixBranchWithCredential走git -c http.extraheader=...一次性注入,token 不写 .git/config;即使 pr_creation_failed workDir 保留 24h,无 token 落盘
安全建议:
- 默认推荐 B 模式(目标仓库已配置 action 时,自动降级为
actions: read + write,权限面最窄) - A 模式 fix-and-pr 适用于"自托管平台 + 强可控 PAT"场景(如专用 CI 账户)
- 平台 UI 触发时显式提示当前所选凭据的权限范围(待 C28 设计落地)
完整凭据安全条款见 security.md §5.3 修复执行安全(凭据基线 + 权限阶)。
8.5 后续 backlog(依赖追踪)
- stale-cleanup 任务:moveToPending 写入的
_pending/{runId}/当前无定时清理机制;登记后续阶段(M11 后段或 M12),按.meta.json的expiresAt字段扫描删除 sanitizeErrorMessage补充Authorization: token xxx模式:当前实现不覆盖 GitHub REST API 实际形态;C53-2 RG-W2 登记后续 patch- A 模式 dispatched UI 提示:用户看到 dispatched 状态需明确"PR 创建失败,分支已推,可手动开 PR"——当前 UI 通用 dispatched 提示,需后续优化
- 录入 M11 阶段:C53 + T1005 sandbox 路由 + C28 security.md 凭据设计 共同组成 M11(业务可见性 + 沙箱落地 + 安全文档)阶段