direnv:把目录环境变成可审查的 Shell 差量
开发者从 API 项目切到前端仓库,终端里的 PATH、JAVA_HOME 和代理地址却还停留在上一个项目。测试偶尔连错服务,退出终端重开又恢复正常。团队起初把它当成“个人 Shell 配置混乱”,真正的问题却是环境只有装载动作,没有与目录绑定的卸载动作,也没有证据说明哪份配置刚刚改变了当前 Shell。
direnv 在每次提示符出现前检查当前目录,找到最近的 .envrc,确认它仍被允许执行,再在子进程里求值并把环境差量交给当前 Shell。进入项目时增加或修改变量,离开时恢复旧值;配置内容改变后,下一次 hook 或 direnv 命令会拒绝复用旧授权。它适合管理目录级环境投影,不负责安装完整操作系统,也不提供密钥加密、租约和撤销能力。
先让 hook 真正在每个提示符前工作
direnv 是一个单一可执行文件,但“命令存在”和“目录切换会自动生效”是两件事。Linux 可优先使用发行版包,macOS 可使用 Homebrew;Nix、Guix 和官方 release 二进制也能提供可执行文件。团队应固定一种受更新策略管理的入口,并用版本命令记录实际基线:
direnv version
command -v direnv包管理器中的版本可能落后于上游发行版。升级前应对照 安装说明 和 release 页面,尤其要检查 stdlib helper 是否已进入目标版本。下面的行为以 2.37.1 为明确基线,不使用只存在于后续文档的 helper。
安装完成后,把 hook 放进 Shell 启动文件。Bash 的常用写法是:
eval "$(direnv hook bash)"Zsh 将 bash 换成 zsh;Fish、Nushell 和 PowerShell 应使用 Shell hook 文档 中与自身 Shell 匹配的输出格式。Bash 与 Zsh 中,hook 应放在会改写 prompt 的框架之后,否则后加载的 prompt 插件可能覆盖它。重开 Shell 后用一个隔离目录确认自动装载,而不是只检查启动文件文本:
lab="$(mktemp -d)"
cd "$lab"
printf '%s\n' 'strict_env' 'export DIRENV_LAB=ready' > .envrc
direnv allow .
printf '%s\n' "$DIRENV_LAB"
cd /
test -z "${DIRENV_LAB-}"进入目录后应看到 direnv 报告增加 DIRENV_LAB,变量值为 ready;离开后变量应消失。若 direnv exec "$lab" sh -c 'printf "%s\n" "$DIRENV_LAB"' 有值,而交互式 cd 后没有值,配置和授权大概率正常,故障集中在 hook 未加载、加载顺序错误或当前终端没有重读启动文件。
原生 Windows 的 Shell、路径和文件监听行为需要单独兼容验证。需要稳定复现实验时,Windows 团队可把基线放在 WSL2 的 Linux 发行版内,并同时记录发行版、Shell 与仓库所在文件系统;不要把 PowerShell hook 存在直接等同于所有 Unix 行为完全一致。
一次目录切换实际经过了什么
hook 并不把 .envrc 直接 source 到当前 Shell。每次提示符前,它调用 direnv export <shell>;direnv 从当前目录向父目录寻找第一份 .envrc,检查路径与内容对应的允许状态,然后启动 Bash 子进程,依次装载内置 stdlib、用户级扩展和项目 RC。子进程求值结束后,direnv 比较求值前后的导出环境,编码成目标 Shell 能执行的增删改语句,再由 hook 应用到父 Shell。
这条链解释了三个常见误判。第一,.envrc 使用 Bash 语法,即使当前交互 Shell 是 Zsh 或 Fish;第二,Shell 函数和 alias 不是环境变量,无法通过 diff 回写父 Shell;第三,离开目录时并不是执行一段任意“卸载脚本”,而是用先前保存的环境关系恢复变量。把安装工具、启动数据库或修改全局文件写进 .envrc,既会拖慢提示符,也没有可靠的对称回滚。
目录发现同样影响信任。如果当前目录没有 .envrc,direnv 会向上找到最近的一份,这很适合 monorepo 的共享环境,也意味着在深层目录执行命令时仍可能运行仓库根脚本。诊断时先看:
direnv status重点核对发现到的 RC 路径、允许状态和 watched paths。若加载了错误的父目录配置,应调整目录结构或在子项目提供明确的 .envrc,而不是靠开发者记住每次手工覆盖变量。
allow 允许的是内容,不是一个永远可信的目录
.envrc 是可执行 Bash。direnv allow . 的正确含义是“我审查并允许当前路径下的当前内容”,不是“这个仓库以后出现的所有脚本都可信”。direnv 会把允许状态与 RC 的路径和内容身份绑定;路径或内容改变后,旧允许状态不能继续证明新文件可信。允许记录保存在 ${XDG_DATA_HOME:-$HOME/.local/share}/direnv/allow,但它是工具状态,不是团队应该复制、提交或手工伪造的信任清单。
用下面的正反实验观察这个状态变化。所有内容都使用无害变量,目录结束后可整体删除:
lab="$(mktemp -d)"
cd "$lab"
printf '%s\n' 'strict_env' 'export FAMILY38_VALUE=allowed' > .envrc
direnv allow .
test "$(direnv exec . sh -c 'printf %s "$FAMILY38_VALUE"')" = allowed
direnv status
printf '%s\n' 'strict_env' 'export FAMILY38_VALUE=changed' > .envrc
set +e
direnv exec . sh -c 'printf %s "$FAMILY38_VALUE"'
rc=$?
set -e
test "$rc" -ne 0第一次执行应输出 allowed。修改文件后,第二次命令应非零退出并出现 blocked 或未允许的提示;此时没有加载 changed,证明旧授权没有跨内容复用。审查 diff 后再次执行 direnv allow . 才会建立新授权。
撤销不能只靠删除 .envrc,因为当前 Shell 可能仍保留已经导出的值。应先离开目录,再显式撤销授权,并用一次失败执行留下证据:
direnv deny "$lab"
set +e
direnv exec "$lab" sh -c 'exit 0'
rc=$?
set -e
test "$rc" -ne 0direnv deny 的公开语义是撤销目标 RC 的授权;不要依赖 allow 数据目录的内部文件名或布局实现撤销。恢复时必须重新审查当前文件,再运行 direnv allow。出现“文件看起来没变却一直 blocked”时,先用 direnv status 核对实际 RC、允许状态和真实路径,再检查符号链接、仓库移动或挂载点是否改变了被审查对象。
[whitelist].exact 和 [whitelist].prefix 可以绕过逐文件授权,但 prefix 白名单会让任何能写入受信目录的人在当前用户权限下执行代码。共享源码根、下载目录和多人可写目录都不应配置 prefix。自动化确实需要免交互时,应缩到文件所有权受控的 exact path,并由团队模板 owner 定期复核。
把项目接入压缩成可审查的几行
一个常见项目只需要声明工具路径、非敏感默认值和少量被观察文件:
strict_env
PATH_add bin
export APP_ENV=development
export API_BASE_URL=http://localhost:8080
dotenv_if_exists .env.local
watch_file toolchain.versionstrict_env 让未定义变量和失败命令尽早中止,避免 .envrc 半成功后留下难解释的差量。在 2.37.1 中它还不是默认行为,所以应显式写出。PATH_add bin 把项目脚本目录加到 PATH 前部;dotenv_if_exists 只在文件存在时解析键值;watch_file 把真正决定环境的配置加入重载条件。
仓库可提交 .envrc、.env.example 和工具版本文件,将 .env.local 写进 .gitignore。项目脚本统一从 bin/ 暴露后,开发者在仓库内调用 bin/test、bin/lint 或对应短命令,CI 则使用同一脚本入口。这样 direnv 只负责投影环境,语言依赖仍由各自 lock file 固定,数据库等长生命周期服务仍由专门的本地编排管理。
接入后至少验证四个不变量:进入目录后变量和 PATH 出现,离开后恢复;修改 .envrc 必须重新授权;修改被 watch 的数据文件会在下一提示符重算;未配置 hook 的非交互环境仍可通过 direnv exec 得到同样的环境。
direnv exec . sh -c '
test "$APP_ENV" = development
test "${PATH%%:*}" = "$PWD/bin"
printf "API=%s\n" "$API_BASE_URL"
'这里的 direnv exec 很适合编辑器任务、Git hook 和 CI,因为它不依赖交互式 prompt。它仍会执行已允许的 .envrc,因此自动化环境必须先建立明确授权,不能通过关闭安全检查来省略评审。
dotenv 降低录入成本,也扩大继承面
dotenv 不是执行 Bash,而是由 direnv 的 dotenv parser 读取键值并导出;它比 source .env 更容易跨 Shell,也会自动 watch 对应文件。但值一旦进入当前 Shell,所有后续子进程都可能继承它,包括测试、构建器、编辑器、调试器和意外的日志命令。
全局启用 load_dotenv = true 会扩大自动发现面。同一目录同时存在 .envrc 与 .env 时,.envrc 优先;团队更容易审查的做法是保留一份很短的 .envrc,显式调用 dotenv_if_exists .env.local。.env.local 的写权限也要受控,因为修改数据文件虽然会触发 reload,却不会像修改主 .envrc 那样要求重新审查代码。
长期令牌、云密钥和生产连接串不应依赖 direnv 保存。密钥管理器应在运行命令时发放短期凭据,并将作用域缩到目标进程;direnv 可以传递一个无敏感性的 profile 名或 socket 路径。排障时不要运行 env 全量打印,也不要把 direnv export 输出上传到构建 artifact。
watch 是下一提示符检查,不是文件事件服务
watch_file 把路径加入 direnv 的观察集合。变化不会被常驻 daemon 立即推送,而是在下一次 hook 执行时被发现并重新求值。因此“文件已经改了,变量还没变”先按一次 Enter 触发新 prompt,再用 direnv status 检查 watched path。仍无变化时执行:
direnv reload强制 reload 后恢复,说明故障位于自动触发链,而不是变量求值本身。观察只负责决定何时重算,不等于对被观察文件做授权,也不能拿来证明内容身份;判断依据应是目标版本的 direnv status 与实际正反实验,不能把某一版本的内部时间戳实现写成跨版本契约。
可以用无敏感值验证 dotenv 与 watch:
printf '%s\n' 'DIRENV_COLOR=blue' > .env.local
printf '%s\n' 'node-22' > toolchain.version
direnv allow .
direnv status
printf '%s\n' 'DIRENV_COLOR=green' > .env.local
direnv reload
test "$DIRENV_COLOR" = greenwatch_dir 会在 RC 求值时递归枚举目录并加入观察列表,成本随路径数量增长。不要观察 node_modules、构建输出、vendor 树或日志目录;每个 prompt 扫描成千上万条路径,交互延迟会成为所有开发者共同承担的成本。优先只观察 manifest、lock 和少量工具配置,并记录冷加载与稳定 prompt 的耗时趋势。
source_env* 会把主文件的信任传播出去
monorepo 常把共享变量放在 .envrc.shared,然后在子项目中写:
strict_env
source_env_if_exists .envrc.shared这里有一个容易遗漏的安全转折:source_env、source_env_if_exists、source_up 和 source_up_if_exists 装载的文件不会经过独立的 direnv allow 流程。helper 会把被引入文件加入 watch,因此文件变化会触发重算;但普通 watch 只触发 reload,不会把共享文件提升成独立授权对象。主 .envrc 获得允许后,被 source 文件的后续修改可以在下一次 reload 执行。
可用反向实验确认:先让 .envrc.shared 只导出 SHARED_VALUE=one,允许主文件;再把共享文件改为导出 SHARED_VALUE=two 并触发 reload。值会改变,却不会出现针对共享文件的新授权请求。这不是安全检查失效,而是 helper 的既定信任模型。
因此,共享脚本必须由同一信任主体维护,不能位于其他用户可写目录,也不能由下载任务覆盖。direnv 2.38.0 及以上可按 stdlib 的 require_allowed 语义 为 lockfile 生成脚本等高风险输入加入 require_allowed path...:被列入的文件修改、删除或集合新增后,都必须重新运行 direnv allow;团队若仍停留在更早版本,就不能假装拥有这层保证,应升级或把共享可执行输入合并回主 .envrc。远程脚本若确实需要执行,应使用带 SRI hash 的 source_url 固定内容,并把 hash 变化作为代码评审事件;跟随 branch URL 或无校验下载会把网络响应直接变成代码执行输入。
用户级 $XDG_CONFIG_HOME/direnv/direnvrc 与 lib/*.sh 也会在项目 RC 之前加载。两名开发者拥有不同个人 helper 时,同一仓库可能产生不同结果。故障证据应同时包含 direnv version、direnv status、目标 Shell、项目 .envrc 和用户扩展清单,不能只比较仓库文件。
让 direnv 触发 Nix,而不是在每个提示符重新发明构建流程
已有 shell.nix 或 default.nix 的项目可以使用 use nix,Flake 项目可以使用 use flake。两者的责任分工很清楚:Nix 解析并构建工具闭包,direnv 只在目录切换时应用 Nix 导出的环境差量。flake.lock、devenv.lock 或语言 lock 才是依赖身份,.envrc 不是包版本锁。
strict_env
watch_file flake.nix flake.lock
use flake第一次进入可能下载或构建依赖,后续加载通常命中 Nix store 与 .direnv 中的集成缓存。正向证据应同时包含 direnv status、nix flake metadata 的锁定 revision,以及 direnv exec . <tool> --version;只看到 prompt 前缀变化不能证明工具闭包正确。反向实验可在临时分支修改 flake.lock,确认下一提示符触发重算且工具版本或 store path 按预期变化,再恢复 lock 并重新验证。不要在真实业务分支用 nix flake update 制造测试差量。
若加载日志出现 experimental Nix feature 'flakes' is disabled,故障在 Nix 能力开关,不在 direnv hook;团队应通过受管 Nix 配置统一启用 nix-command flakes,并记录 daemon 与用户配置的优先级。若求值阶段读取仓库内文件,该文件可能被复制到 Nix store,.gitignore 也不是保密边界;真实 .env、私钥和令牌必须留在求值图之外,由运行时 secret provider 注入。代理或企业 CA 导致 input fetch 失败时,保留 fetch URL、TLS 错误和 substituter 配置,不能用反复 direnv allow 掩盖网络层故障。
devenv 项目不应复制一段会漂移的远程脚本。devenv 1.4 及以上可按 direnv 集成入口 在 .envrc 中使用本机 CLI 输出的脚本:
eval "$(devenv direnvrc)"
use devenv需要更强供应链控制时,可以把 direnvrc 固定到具体 devenv tag,并让 source_url 校验 SRI hash;hash 更新必须和 devenv 版本升级一起评审。devenv 2.x 还提供原生 devenv hook,它通过子 Shell 自动激活;direnv 则原地修改当前 Shell。团队应选定一种自动激活入口,避免两个 hook 同时管理同一目录,造成重复构建、嵌套提示符或难以判断的卸载顺序。
从现象回到第一证据
进入目录毫无反应时,先用无敏感性的标记变量运行 direnv exec . sh -c 'printf "APP_ENV=%s\n" "${APP_ENV-}"'。它若能装载,检查 hook 和 prompt 插件顺序;它若提示 blocked,检查 allow/deny;它若发现了错误路径,检查向上发现链。不要用 env 或 direnv export 全量打印环境,也不要一开始删除所有状态,否则会泄露凭据或抹掉最有价值的判断依据。
变量离开目录后仍存在时,先确认它是否在进入项目之前就存在。direnv 只恢复旧值;若同名变量来自 Shell 启动文件,离开后恢复是正确行为。用一个全新 Shell 记录进入前、进入后和离开后的 printenv NAME,才能区分卸载失败与基线本来就有值。
提示符越来越慢时,给 .envrc 的每个外部命令计时,并检查 watch_dir 数量。下载、包安装、网络探测和编译应移出 .envrc,放进显式初始化脚本或有缓存的环境构建工具;RC 只读取已生成的稳定结果。团队可把“稳定 prompt 耗时不随仓库规模单调增长”作为趋势门槛,而不是制定脱离机器与文件系统的固定毫秒数。
dotenv 更新不生效时,确认 helper 是否真的 watch 了目标路径,再检查生成工具是否原子替换文件、是否保留 mtime。direnv reload 后恢复说明是 watch 触发问题;reload 后仍旧值则继续检查解析格式、重复键和后加载脚本覆盖。
清理、回滚与退出
撤回项目改动时,先离开目录,再删除或恢复 .envrc 与项目级样例配置。若目录不再可信,执行 direnv deny /path/to/project;若只是新版本有误,恢复经评审的 .envrc 后重新 direnv allow。不要手工复制旧 allow 文件来绕过内容检查。
实验目录可这样清理:
cd /
direnv deny "$lab" 2>/dev/null || true
rm -rf "$lab"
unset lab项目中的 .direnv 可能保存 layout 创建的虚拟环境等产物。删除它通常会迫使下次进入重建,但不会撤销外部服务、用户级缓存或远端凭据;清理前应确认目录只含可再生数据。卸载 direnv 时还要从 Shell 启动文件移除 hook,否则每次启动都会出现 command not found。包管理器卸载、hook 删除、用户 allow/deny 数据清理是三个独立动作。
什么时候停在 direnv,什么时候升级环境模型
当差异主要是目录级变量、工具路径和短小激活逻辑时,direnv 的成本最低:单个可执行文件、没有常驻服务、退出目录自动恢复。它与语言版本管理器、Nix devShell 或 devenv 配合得很好,负责“何时激活”,让后者负责“环境怎样构建”。
当 .envrc 开始安装系统包、启动多个服务、编排初始化任务或复制大批二进制时,问题已经超出环境差量。此时应把构建和服务状态交给带 lock、缓存与生命周期模型的工具,只在 .envrc 留一个受审查的激活入口。若要求不同内核、强文件系统隔离或生产级调度,则应进入 VM、容器或远程工作区,而不是继续加长 Bash。
团队治理的关键对象不是一份通用模板,而是信任与成本的 owner:谁能修改 .envrc 和被 source 文件,谁评审 allow 变化,谁维护用户 helper 基线,谁处理代理与 CA,谁轮换运行时凭据,谁观测 prompt 延迟和 .direnv 磁盘。把这些责任写进仓库维护规则后,direnv 才会从个人便利工具变成可解释、可退出的目录环境入口。
