1466 words
7 minutes
沙箱里的 git 为什么推不动:nobody、消失的 home 和只读的 .git

沙箱里的 git 为什么推不动:nobody、消失的 home 和只读的 .git#

上一篇文章讲过 mini-agent 的 pi 沙箱(bubblewrap + env 白名单)。当时把”写博客文件”这条路打通了——把博客 posts 目录加进 rw_paths,pi 在沙箱里写文章没问题。

但很快发现一个别扭的事:我在 Telegram 里让 agent “把博客目录的 commit 推掉”,pi 在沙箱里执行 git push,直接报错失败。写文件行,git 网络操作不行。这篇文章记录排查过程——最后挖出来四个独立的问题,少修一个都跑不通。

先复现,别猜#

第一步永远是用最小成本复现。我直接把服务启动时构建的那条 bwrap 命令原样拿出来,把末尾的 pi 换成 git:

Terminal window
bwrap --die-with-parent --dev /dev \
--ro-bind /usr /usr --ro-bind /etc /etc \
--ro-bind /home/worker/Web/SGJki-s-blog /home/worker/Web/SGJki-s-blog \
--bind /home/worker/Web/SGJki-s-blog/src/content/posts ...(略) \
--clearenv --setenv HOME /home/worker ... \
-- git -C <博客仓库> push --dry-run

稳定复现:

Bad owner or permissions on /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf
fatal: Could not read from remote repository.

报错本身就很说明问题:ssh 在检查配置文件权限时翻脸了。但这个文件在宿主机上好好的,宿主机 git push 一切正常。那为什么沙箱里就 “Bad owner”?

问题一:root 的文件在沙箱里变成了 nobody#

对比同一个文件在宿主机和沙箱里的 stat 结果:

Terminal window
# 宿主机
$ stat -L -c '%U %a' /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf
root 644 # ssh 满意:owner 是 root
# bwrap 沙箱内
$ bwrap ... -- stat -L -c '%U %a' /etc/ssh/...
nobody 644 # ssh 翻脸:owner 既不是我也不是 root

原因:非特权 bwrap 必须靠 user namespace 工作,而 userns 里只映射了当前用户一个 uid。所有 root 拥有的文件,在沙箱里看 owner 都是 nobody(内核的 overflowuid 机制)。平时这无所谓——系统文件本来就只读。但 ssh 有个洁癖:它读系统配置时会校验 owner 必须是 root 或当前用户,是 nobody 就拒绝加载,而且是 fatal。

文件还是那个文件,权限位也没变,纯粹是”看起来”的 owner 变了。宿主机上永远复现不了,只有进沙箱才能看到。

这个 ownership 问题在 userns 里没法修(你改不了内核的映射行为),只能绕:让 git 用的 ssh 别去读系统配置。

argv += ["--setenv", "GIT_SSH_COMMAND", "ssh -F /dev/null"]

-F /dev/null 让 ssh 跳过 /etc/ssh/ssh_config 及其 include。用户级配置我本来就没有,什么都没损失。

问题二:沙箱里根本没有 home#

绕过第一关后还有第二关。沙箱的 bind 是精确到路径的:~/.pi、博客 posts 目录、/tmp……注意,/home/worker 这个 home 目录本身并没有整体挂进去,只有被点名的几个子路径在沙箱里存在。

所以 ~/.ssh 在沙箱里不存在。没有私钥,就算 ssh 跑起来也是 Permission denied (publickey)。同理 ~/.gitconfig 也不存在,commit 会报 “Please tell me who you are”。

修法是把这两个加进 ro 路径(只读足够,ssh 只是读私钥、git 只是读身份):

paths = [
# ...原有的...
Path.home() / ".ssh",
Path.home() / ".gitconfig",
]

这里有个权衡值得说清楚:ro-bind ~/.ssh 意味着沙箱里的 pi 能读到 SSH 私钥。这是”让 pi 能 push”的固有代价——push 就需要 key,没有别的办法。mini-agent 自己的 API 凭据(CLAUDE_API_KEY 之类)依然通过 env 白名单隔离,不受影响。

问题三:.git 是只读的#

还有第三关。之前为了保护博客仓库,沙箱把整个仓库根目录 ro-bind,只把 posts/ 子目录 rw-bind(后绑定的覆盖先绑定的)。写文章够了,但 git 不行:

  • git pull --rebase 要写 .git 里的 refs、index、rebase 状态
  • git push 成功后也要回写本地的 refs/remotes/origin/main
  • git commit 要写 objects 和 index

.git 只读,这些全部失败。修法很直接——仓库根保持 ro,但把 .git 单独 rw-bind,利用”后绑定覆盖先绑定”的规则:

sandbox_rw_paths.append(str(blog_path.resolve())) # posts 目录
git_dir = _blog_repo_root(blog_path.resolve()) / ".git"
if git_dir.is_dir():
sandbox_rw_paths.append(str(git_dir)) # .git 可写

效果:仓库里除 .gitposts/ 之外全部只读,git 又完全够用。

验证:四层修完一次通过#

用服务实际构建的 bwrap argv(不是手搓的近似版),在沙箱里连跑三个命令:

== pull: rc=0 Already up to date.
== empty-commit: rc=0 [main 24a5873] sandbox write test ← 写 .git 成功
== push-dry: rc=0 To github.com:SGJki/SGJki-s-blog.git 5944257..24a5873

全绿(测试 commit 已 reset 清理)。

顺手补的一个洞:BlogSync 的 “nothing to commit”#

排查中还发现一个相关的设计缺口。BlogSyncTask 是沙箱外的兜底同步:watchdog 盯博客目录,防抖后自动 add → commit → pull --rebase → push。但原逻辑里 commit 报 “nothing to commit” 就直接 return 了——不检查是否有未推送的 commit

这意味着:如果 commit 是别人创建的(pi 在沙箱里提交的,或者我手动提交的),BlogSync 醒来一看”没新东西可提交”就走了,那些 commit 永远躺在本地。我这次卡住就有这个因素。

修法:nothing to commit 时多问一句 git rev-list --count @{u}..HEAD,有未推送 commit 就继续走 pull/push,没有才跳过。从此不管 commit 是谁创建的,防抖窗口过后都会被推上去。

总结#

这次故障表面是”沙箱没权限”,实际是四层叠加:

问题修法
ssh 配置userns 里 root 文件显示为 nobody,ssh 拒绝系统配置GIT_SSH_COMMAND=ssh -F /dev/null
认证~/.ssh 没挂进沙箱ro-bind
commit 身份~/.gitconfig 没挂进沙箱ro-bind
git 写操作仓库 ro-bind 导致 .git 只读单独 rw-bind .git

最有教育意义的是第一层:同一个文件、同样的权限位,宿主机和沙箱里 stat 出来的 owner 不一样。userns 的 uid 映射平时无感,一旦遇到像 ssh 这样校验 owner 的程序就会暴雷——而且错误信息只说 “Bad owner or permissions”,不会告诉你”在你看不见的维度里,root 变成了 nobody”。

沙箱里的 git 为什么推不动:nobody、消失的 home 和只读的 .git
https://sgjki547.top/posts/2026-08-01-沙箱里的-git-为什么推不动/
Author
SGJki
Published at
2026-08-01
License
CC BY-NC-SA 4.0