首页
/ 让 Bash Hook 跨发行版可移植:get-shit-done 社区钩子的 `!/usr/bin/env bash` 修复深度解析

让 Bash Hook 跨发行版可移植:get-shit-done 社区钩子的 `!/usr/bin/env bash` 修复深度解析

2026-09-07 11:07:54作者:田桥桑Industrious

在 NixOS、极简 Alpine 镜像等不按传统布局放置 bash 的发行版上,一段看似无关紧要的 shebang(#!/bin/bash)会让整个 Hook 机制静默失效。get-shit-done 通过一次发布记录于 .changeset/portable-bash-shebang-hooks.md 的修复(PR #3194),将三个可选启用的社区 .sh Hook 统一切换为 #!/usr/bin/env bash,并从安装器注释层面纠正了“POSIX 保证 PATH 中有 bash”这一错误依据。阅读本文,你将理解 shebang 与“以 bash 为运行器调用脚本”两种执行路径的本质差异,掌握跨发行版可移植 Hook 的编写约定,并了解 get-shit-done 安装器如何接线这些钩子、为何该缺陷在默认路径下长期潜伏。

一、问题背景:三个社区 .sh Hook 是什么

get-shit-done 仓库在 hooks 目录下维护了一批面向 Claude Code 的钩子脚本。其中三个基于 Bash 的钩子属于“可选(OPT-IN)”的社区功能,需要用户在 .planning/config.json 中显式开启 "hooks": { "community": true } 才会生效(钩子自身也会先读取该配置,未开启时直接静默退出):

钩子文件 挂载点 职责
hooks/gsd-phase-boundary.sh PostToolUse 检测 .planning/ 规划文件的写入,在工作流外修改规划文件时输出提醒
hooks/gsd-session-state.sh SessionStart 每次会话启动时注入项目状态提醒(输出 STATE.md 头部)
hooks/gsd-validate-commit.sh PreToolUse 校验 git commit 消息是否符合 Conventional Commits 规范,不合规则退出码 2 阻断

这三个脚本都遵循同一套脚本风格:用 Node.js(而非 jq)解析 JSON 事件负载,因为“在 GSD 项目中 Node 总是可用的”;同时通过 hooks/lib/git-cmd.js 之类的共享库做命令分类,规避朴素正则的漏判。本次修复涉及的就是这三个文件的第一行。

二、缺陷根因:POSIX 只保证 /bin/sh,不保证 /bin/bash

修复前,这三个脚本以 #!/bin/bash 开头。它依赖一个默认假设:bash 一定安装于 /bin/bash

#!/bin/bash 是绝对路径 shebang,内核在 execve 时直接按字面路径去加载解释器。当目标机器上 bash 并不存在于该绝对位置时,直接执行脚本会立即失败。而这样的发行版和运行环境真实存在:

  • NixOS:包管理器将软件安装进 /nix/store 的哈希路径,/bin/bash 默认并不存在;
  • 极简 Alpine 镜像:Alpine 默认 shell 是 BusyBox ash(位于 /bin/sh),bash 需另行安装且不一定落在 /bin/bash
  • 部分容器运行时 / 精简 Dockerfile:同样只保证最小 POSIX 布局。

从规范层面看,POSIX 标准只保证 /bin/sh 存在,从不保证 /bin/bash 存在。因此只要脚本可能被“直接执行”,#!/bin/bash 就是不可移植的写法。

三、修复方案:与仓库既有约定对齐的 #!/usr/bin/env bash

修复内容本身只有一行级改动:三个社区 Hook 的 shebang 统一改为

#!/usr/bin/env bash

/usr/bin/env 同样是绝对路径,但它几乎在所有 Unix 系系统上都存在(POSIX 亦要求 /usr/bin/env 可用),其职责是在当前 PATH 中查找 bash 并加载第一个命中的解释器。这样:

  • 在 bash 位于 /usr/bin/bash/usr/local/bin/bash 或经 NixOS 的 ~/.nix-profile/bin 注入 PATH 时都能正确解析;
  • 她本应成为的语义是“我要 bash,且要在运行环境的 PATH 中找它”,而非“我硬编码要求 bash 必须住在 /bin”。

更关键的是,这一约定并不是本次新发明的:仓库 scripts 下的 shell 脚本早已统一采用 #!/usr/bin/env bash,例如 scripts/base64-scan.shscripts/prompt-injection-scan.sh。本次修复实际上是让 hooks 目录向既有约定看齐,消除两套 shebang 风格的漂移。事实上 hooks 目录内部已经存在同款先例——hooks/gsd-graphify-update.sh 与其依赖的 hooks/lib/gsd-graphify-rebuild.sh 一开始就是 #!/usr/bin/env bash,本次修复使三个社区钩子与这些兄弟脚本保持一致。

发布侧,该改动以 type: Fixedpr: 3194 的记录进入变更集,并在 docs/RELEASE-v1.41.0.md 的 v1.41.0 发布说明中以“Community .sh hooks use #!/usr/bin/env bash”条目对外公示。

四、为什么默认安装路径下该缺陷是“潜伏”的

本次变更集特别点出一个容易让开发者困惑的现象:在 get-shit-done 的默认安装路径下,这个 bug 并不会立刻爆发。原因在于 Claude Code 的接线方式。

bin/install.js 中的 buildHookCommand 函数(定义于 L1138-L1176)。它的职责是构造最终写入 settings.json 的 hook 命令,核心逻辑是:

function buildHookCommand(configDir, hookName, opts) {
  if (!opts) opts = {};
  // POSIX .sh hooks run under PATH-resolved `bash`: POSIX guarantees /bin/sh
  // but not /bin/bash, and distros like NixOS do not ship /bin/bash by default.
  // ...
  const nodeRunner = resolveNodeRunner();
  const runner = hookName.endsWith('.sh') ? resolveBashRunner(opts) : nodeRunner;
  // Runner resolvers return null when the executable path is unavailable.
  // Fall through with null so callers can skip registration with a warning
  // instead of emitting a command that recreates the original hook failure.
  if (runner === null) return null;
  // ...
}

关键点在于:对于 .sh 后缀的钩子,安装器并不是让脚本靠自身 shebang 去启动,而是显式以 bash <路径> 的方式调用——把脚本作为 bash 的一个参数传入。此时脚本第一行的 shebang 对内核而言只是一行注释,真正被 PATH 解析的是命令行里的裸 bash。只要系统 PATH 中存在 bash(绝大多数发行版都有),这条命令就能正常工作,脚本自身的 #!/bin/bash 是否正确根本不参与执行。

这正是变更集把它定性为 latent(潜伏性缺陷) 的原因:在 Claude Code 默认的 bash <path> 接线方式下,它被完全掩盖。但一旦脚本被绕过安装器直接执行,缺陷立刻显形:

  • 测试场景:像 tests/sh-hook-paths.test.cjs 这类回归测试,若直接 chmod +x 后运行钩子验证行为,就会在 NixOS/Alpine 上得到 No such file or directory
  • 未来安装器变更:如果后续某版本把钩子改成直接以绝对脚本路径 spawn(不经 bash 前缀),那么此前被掩盖的问题会在所有受影响发行版上集中爆发;
  • 手动调试:用户自己 ./hooks/gsd-validate-commit.sh 跑一遍排查问题时,得到的是解释器缺失的报错而非业务逻辑错误,极具误导性。

修复的意义因此在于消除一个埋在未来路径下的地雷,而非修补一条当前已坏的默认链路。

五、安装器注释的纠正:POSIX 路径保证是错误依据

变更集还提到一处注释级修正:buildHookCommand 顶部原有注释以“POSIX 保证标准 PATH”作为裸 bash 可用的依据,这一理由并不成立。本次更新后的注释(见 bin/install.js#L1140-L1147)明确了两件事:

  1. .sh 钩子运行器是 PATH 解析的裸 bash:POSIX 保证的是 /bin/sh,因此不能把“存在 bash”当作系统标配,这也是为什么需要 resolveBashRunner 去显式探测可用的 bash 路径;
  2. .js 钩子与 .sh 钩子的运行器解析策略必须不同.js 钩子仍需要绝对化的 node 路径(resolveNodeRunner),因为 GUI 启动的运行时进程 PATH 极简,可能不包含 nvm/Homebrew/Volta 注入的 node 二进制目录(对应 issue #2979)。也就是说,“解释器是否经 PATH 解析”取决于运行器类型,不能一概而论。

从源码结构还可以推断,resolveBashRunner 的候选解析逻辑支持通过 GSD_BASH_PATH 环境变量注入自定义 bash 路径(见 bin/install.js#L718),并针对 Windows 下从 PowerShell/cmd 环境启动、裸 bash 不在 PATH 的场景显式解析 Git Bash;解析不到时会返回 null,调用方选择跳过注册并告警,而不是安装一条注定失败的钩子——这与本次 shebang 修复共享同一个设计哲学:宁可放弃注册,也不要装下一个已知会坏的配置

六、回归保障与工程启示

从源码确认修复现状

仓库当前状态可作为修复落地的实证:

给 Hook 开发者的三条可移植性准则

  1. shebang 首选 #!/usr/bin/env bash,永远别写 #!/bin/bash:后者假设解释器的绝对安装位置,只在内核直启脚本时才生效;前者让解释器跟随运行环境 PATH,兼容 NixOS、Alpine 与容器场景;
  2. 区分“解释器接线”与“脚本直启”两种执行模型:当宿主(如 Claude Code)以 bash <path> 显式调用时,shebang 退化为注释,问题被掩盖;凡是可能被直接执行、被测试直接调用的脚本,shebang 就必须从一开始就正确;
  3. 不要把 POSIX 的“最小编排”承诺当充分条件:POSIX 只保证 /bin/sh;声称“bash 可用”就必须给出可探测、可配置的解析路径(如 GSD_BASH_PATH 这类注入点),解析不到时优雅跳过而非注册坏配置。

七、小结

一次 one-line 的 shebang 变更,背后是一整套关于解释器解析机制的工程判断:get-shit-done 将三个社区 .sh 钩子从“硬编码 /bin/bash”迁移到“PATH 解析的 /usr/bin/env bash”,与 scripts 目录既有约定对齐;同时借安装器注释修正,把“.sh 钩子由 PATH 解析的裸 bash 启动、.js 钩子仍需绝对 node 路径”的接线语义固化下来,消除了未来安装器变更可能引爆的跨发行版隐患。理解这条修复链,等于同时掌握了 Claude Code hook 的注册机制、POSIX shebang 的边界,以及可移植 shell 脚本的编写纪律。

<输出文章>

在 NixOS、极简 Alpine 镜像等不按传统布局放置 bash 的发行版上,一段看似无关紧要的 shebang(#!/bin/bash)会让整个 Hook 机制静默失效。get-shit-done 通过一次记录于 .changeset/portable-bash-shebang-hooks.md 的修复(PR #3194),将三个可选启用的社区 .sh Hook 统一切换为 #!/usr/bin/env bash,并从安装器注释层面纠正了“POSIX 保证 PATH 中有 bash”这一错误依据。阅读本文,你将理解 shebang 与“以 bash 为运行器调用脚本”两种执行路径的本质差异,掌握跨发行版可移植 Hook 的编写约定,并了解 get-shit-done 安装器如何接线这些钩子、为何该缺陷在默认路径下长期潜伏。

一、问题背景:三个社区 .sh Hook 是什么

get-shit-done 仓库在 hooks 目录下维护了一批面向 Claude Code 的钩子脚本。其中三个基于 Bash 的钩子属于“可选(OPT-IN)”的社区功能,需要用户在 .planning/config.json 中显式开启 "hooks": { "community": true } 才会生效(钩子自身也会先读取该配置,未开启时直接静默退出):

钩子文件 挂载点 职责
hooks/gsd-phase-boundary.sh PostToolUse 检测 .planning/ 规划文件的写入,在工作流外修改规划文件时输出提醒
hooks/gsd-session-state.sh SessionStart 每次会话启动时注入项目状态提醒(输出 STATE.md 头部)
hooks/gsd-validate-commit.sh PreToolUse 校验 git commit 消息是否符合 Conventional Commits 规范,不合规则退出码 2 阻断

这三个脚本都遵循同一套脚本风格:用 Node.js(而非 jq)解析 JSON 事件负载,因为“在 GSD 项目中 Node 总是可用的”;同时通过 hooks/lib 下的共享模块做命令分类,规避朴素正则的漏判。本次修复涉及的就是这三个脚本的第一行。

二、缺陷根因:POSIX 只保证 /bin/sh,不保证 /bin/bash

修复前,这三个脚本以 #!/bin/bash 开头。它依赖一个默认假设:bash 一定安装于 /bin/bash

#!/bin/bash 是绝对路径 shebang,内核在 execve 时直接按字面路径去加载解释器。当目标机器上 bash 并不存在于该绝对位置时,直接执行脚本会立即失败。而这样的发行版和运行环境真实存在:

  • NixOS:包管理器将软件安装进 /nix/store 的哈希路径,/bin/bash 默认并不存在;
  • 极简 Alpine 镜像:Alpine 默认 shell 是 BusyBox ash(位于 /bin/sh),bash 需另行安装且不一定落在 /bin/bash
  • 部分容器运行时 / 精简 Dockerfile:同样只保证最小 POSIX 布局。

从规范层面看,POSIX 标准只保证 /bin/sh 存在,从不保证 /bin/bash 存在。因此只要脚本可能被“直接执行”,#!/bin/bash 就是不可移植的写法。

三、修复方案:与仓库既有约定对齐的 #!/usr/bin/env bash

修复内容本身只有一行级改动:三个社区 Hook 的 shebang 统一改为

#!/usr/bin/env bash

/usr/bin/env 同样依赖绝对路径,但它在几乎所有 Unix 系系统上都存在,其职责是在当前 PATH 中查找 bash 并加载第一个命中的解释器。这样:

  • 在 bash 位于 /usr/bin/bash/usr/local/bin/bash,或经 NixOS 的 ~/.nix-profile/bin 注入 PATH 时都能正确解析;
  • 语义从“硬编码要求 bash 住在 /bin”变成“我需要 bash,并在运行环境的 PATH 中找到它”。

更关键的是,这一约定并非本次新发明:仓库 scripts 下的 shell 脚本早已统一采用 #!/usr/bin/env bash,例如 scripts/base64-scan.sh。本次修复实际上是让 hooks 目录向既有约定看齐,消除两套 shebang 风格的漂移。事实上 hooks 目录内部已有同款先例——hooks/gsd-graphify-update.sh 及其依赖的 hooks/lib/gsd-graphify-rebuild.sh 从一开始就是 #!/usr/bin/env bash,本次改动使三个社区钩子与这些兄弟脚本保持一致。

发布侧,该改动以 type: Fixedpr: 3194 的记录进入变更集,并在 docs/RELEASE-v1.41.0.md 的 v1.41.0 发布说明中以 “Community .sh hooks use #!/usr/bin/env bash” 条目对外公示。

四、为什么默认安装路径下该缺陷是“潜伏”的

本次变更集特别点出一个容易让开发者困惑的现象:在 get-shit-done 的默认安装路径下,这个 bug 并不会立刻爆发。原因在于 Claude Code 的接线方式。

bin/install.js 中的 buildHookCommand 函数(定义于 L1138-L1176),其职责是构造最终写入 settings.json 的 hook 命令,核心分支如下(略作精简):

function buildHookCommand(configDir, hookName, opts) {
  // POSIX .sh hooks run under PATH-resolved `bash`: POSIX guarantees /bin/sh
  // but not /bin/bash, and distros like NixOS do not ship /bin/bash by default.
  const nodeRunner = resolveNodeRunner();
  const runner = hookName.endsWith('.sh') ? resolveBashRunner(opts) : nodeRunner;
  // Runner resolvers return null when the executable path is unavailable;
  // callers then skip registration with a warning instead of emitting
  // a command that recreates the original hook failure.
  if (runner === null) return null;
  // … 拼接 $HOME-relative 或绝对路径(Windows-safe 正斜杠形式)的命令
}

关键点在于:对于 .sh 后缀的钩子,安装器并不让脚本靠自身 shebang 启动,而是显式以 bash <path> 的方式调用——把脚本作为 bash 的一个参数传入。此时脚本第一行的 shebang 对内核对(当 bash 读取脚本时)只是一行注释,真正被 PATH 解析的是命令行里的裸 bash。只要系统 PATH 中存在 bash(绝大多数发行版都有),这条命令就能正常执行,脚本自身的 #!/bin/bash 是否正确根本不参与运行。

这正是变更集将其定性为 latent(潜伏性缺陷) 的原因:在 Claude Code 默认的 bash <path> 接线方式下,它被完全掩盖。但一旦脚本被绕过安装器直接执行,缺陷立刻显形:

  • 测试场景:像 tests/sh-hook-paths.test.cjs 这类回归测试若直接运行钩子文件验证行为,就会在 NixOS/Alpine 上得到解释器缺失的错误;
  • 未来安装器变更:如果后续版本把钩子改成以脚本绝对路径直接 spawn(不再经 bash 前缀),被掩盖的问题会在受影响发行版上集中爆发;
  • 手动调试:用户自行执行 ./hooks/gsd-validate-commit.sh 排查问题时,得到的是 No such file or directory 类报错而非业务逻辑信息,极具误导性。

修复的意义因此在于拆除一个埋在未来路径下的地雷,而非修补一条当前已坏的默认链路。

五、安装器注释的纠正:POSIX 路径保证是错误依据

变更集还提到一处注释级修正:buildHookCommand 顶部的原注释曾以“POSIX 保证标准 PATH”作为裸 bash 可用的依据,这一理由并不成立。更新后的注释(见 bin/install.js#L1140-L1147)明确了两件事:

  1. .sh 钩子的运行器是 PATH 解析的裸 bash:POSIX 保证的是 /bin/sh,不能把“存在 bash”当作系统标配,因此需要 resolveBashRunner 显式探测可用的 bash 路径;
  2. .js.sh 钩子的运行器解析策略必须不同.js 钩子仍需要绝对化的 node 路径(resolveNodeRunner),因为 GUI 启动的运行时进程 PATH 极简,可能不包含 nvm/Homebrew/Volta 注入的 node 二进制目录(对应 issue #2979)。也就是说,“解释器是否经 PATH 解析”取决于运行器类型,不能一概而论。

从源码结构还可以推断,resolveBashRunner 的候选解析支持通过 GSD_BASH_PATH 环境变量注入自定义 bash 路径(见 bin/install.js#L718),并针对 Windows 下从 PowerShell/cmd 环境启动、裸 bash 不在 PATH 的场景显式解析 Git Bash;解析不到时返回 null,调用方选择跳过注册并告警,而不是安装一条注定失败的钩子。这与本次 shebang 修复共享同一个设计哲学:宁可放弃注册,也不要装下一个已知会坏的配置

六、回归保障与工程启示

从仓库源码确认修复现状

给 Hook 开发者的三条可移植性准则

  1. shebang 首选 #!/usr/bin/env bash,勿写 #!/bin/bash:后者假设解释器的绝对安装位置,只在脚本被内核直启时才生效;前者让解释器跟随运行环境 PATH,兼容 NixOS、Alpine 与容器场景;
  2. 区分“解释器接线”与“脚本直启”两种执行模型:当宿主(如 Claude Code)以 bash <path> 显式调用时,shebang 只是注释,缺陷会被掩盖;凡是可能被直接执行、被测试直接调用的脚本,shebang 必须从一开始就正确;
  3. 不要把 POSIX 的最小编排承诺当充分条件:POSIX 只保证 /bin/sh;声称“bash 可用”就必须提供可探测、可配置的解析路径(如 GSD_BASH_PATH 这类注入点),解析不到时优雅跳过而非注册坏配置。

七、小结

一次 one-line 的 shebang 变更,背后是一整套关于解释器解析机制的工程判断:get-shit-done 将三个社区 .sh 钩子从“硬编码 /bin/bash”迁移到“PATH 解析的 /usr/bin/env bash”,与 scripts 目录既有约定对齐;同时借安装器注释修正,把“.sh 钩子由 PATH 解析的裸 bash 启动、.js 钩子仍需绝对 node 路径”的接线语义固化下来,消除了未来安装器变更可能引爆的跨发行版隐患。理解这条修复链,等于同时掌握了 Claude Code hook 的注册机制、POSIX shebang 的边界,以及可移植 shell 脚本的编写纪律。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389