一个 authorized_keys 字段,解决了多 Agent 系统的身份问题
多智能体源码协作系列 · 第 2 篇 / 共 8 篇
上一篇(第 1 篇 · 问题定义):CSDN
本文所有路径、席位名、项目名均为泛化示例,可直接复制使用。
一、结论先行
在自建 git 服务器上给多个 AI Agent 席位做身份隔离,不需要 OAuth、不需要自建 CA、不需要数据库驱动的 token 服务。用 OpenSSH 的authorized_keys选项,一条配置就能把身份钉死在连接层:
command="{git-root}/bin/gate-shell",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,environment="ES_SEAT=seat-a",environment="ES_PROJS=proj-alpha,proj-beta" ssh-ed25519 {pubkey-material} seat-a效果:
- 客户端无法伪造身份(身份由服务端在连接建立时注入);
- 客户端无法执行任意命令(强制命令,客户端说什么都跑门房脚本);
- 客户端无法越权访问其他项目(连接层白名单,连不上就推不进来);
- 每一次尝试——放行或拒绝——都在
access.log留一行(席位 / 来源 / 结果 / 明细); - 后面的服务端钩子(pre-receive)可以无条件信任"席位"这个变量。
前置条件:
| 项 | 要求 |
|---|---|
| OpenSSH | 任意现役发行版(本文实测于 OpenSSH 10.0p2)。必须开启PermitUserEnvironment yes,否则environment=不生效,见第五节末尾 |
| git | 服务端为裸仓(--bare),并已建 git 系统用户 |
| 目录约定 | git 根{git-root},裸仓放{git-root}/repos/{proj}.git,脚本放{git-root}/bin/ |
| 客户端 | 每个 Agent 席位一把独立 ed25519 密钥 |
二、为什么不走 OAuth / 自建 CA
一句话:SSH 本来就是一条成熟、可信、被运维了二十多年的身份通道,缺的不是身份系统,是把"身份"和"这次连接"绑定的那一层。
| 方案 | 要引入什么 | 为什么不选 |
|---|---|---|
| OAuth / SSO 代理 | identity provider + git 适配层 | 为内部协作系统引入整套 SSO,运维成本超过问题本身 |
| 自建 CA + 客户端证书 | 签发 / 吊销 / 轮换全套 | 证书生命周期是长周期负担,收益与本场景不匹配 |
| 数据库 token 服务 | 服务端 + 签发校验接口 | 等于自己造一个身份系统,还得保证它高可用 |
三、准备
先记住这张表:后文命令里要你替换的配置占位符都在这里,一次性换完,不要边看边猜。
表外还有三个花括号是行文示意、不是配置项:{proj}填仓名、{topic}填分支名、{client-ip}是日志里打码后的来源地址。
| 占位符 | 替换成 | 注意 |
|---|---|---|
{git-root} | git 用户的家目录,如/srv/git | 路径不要带空格(command=里含空格要转义,见坑 1) |
{hub-host} | 服务器地址(IP 或域名) | 客户端命令与~/.ssh/config的HostName |
seat-a | 席别名,建议seat-{角色} | 出现在密钥注释、ES_SEAT、authorized_keys末列 |
proj-alpha,proj-beta | 该席位可访问的仓名(不是路径、不带.git) | 写错这一列的表现是「所有仓都被拒」 |
{pubkey-material} | cat ~/.ssh/id_ed25519_seat_a.pub输出的第二列 | 第一列是ssh-ed25519、第三列是注释,都不进这一行 |
ES_SEAT/ES_PROJS不在这张表里,因为它们是变量名、不是占位符。
这两个名字在全文共31 处(ES_SEAT13 处、ES_PROJS18 处),必须完全一致:
要么一处都不改(推荐),要么脚本和authorized_keys里同步改名。
只改一边的后果很隐蔽——实测三种情况:
做法 ssh -T回显原样照抄 hi seat-a · projects: proj-alpha,proj-beta✅只改 authorized_keys里的变量名hi unknown · projects: none脚本改了 ES_SEAT、漏改ES_PROJShi seat-a · projects: none第三行最坑:身份显示对了,看着像配好了,但一 push 就
denied empty whitelist、所有仓全拒。
这时你会去查仓名拼写,而真正的原因是变量名只改了一半。
3.1 建 git 系统用户、目录与裸仓
# 建议独立用户,避免与登录用户混用sudouseradd-m-d{git-root}-s/bin/bashgitsudomkdir-p{git-root}/bin{git-root}/repos{git-root}/var{git-root}/.sshsudochown-Rgit:git{git-root}sudochmod700{git-root}/.ssh# 裸仓必须先建出来,否则门房放行了 git 也找不到仓sudo-ugitgitinit--bare{git-root}/repos/proj-alpha.gitsudo-ugitgitinit--bare{git-root}/repos/proj-beta.git3.2 生成席位密钥(一把密钥 = 一个席位)
ssh-keygen-ted25519-C"seat-a"-f~/.ssh/id_ed25519_seat_a密钥本体只留在客户端,服务器上只放公钥。
3.3 客户端~/.ssh/config:一机多把密钥怎么选
一台机器上跑多个 Agent 席位时,用Host别名绑定密钥,别在每条命令后拼-i(迟早会拼错,拼错的表现是"用错身份"而不是报错):
Host hub-seat-a HostName {hub-host} User git IdentityFile ~/.ssh/id_ed25519_seat_a Host hub-seat-b HostName {hub-host} User git IdentityFile ~/.ssh/id_ed25519_seat_b之后git remote add origin hub-seat-a:proj-alpha.git就固定走 seat-a 那把密钥,ssh -T hub-seat-a也能直接自检。
注意这里的 remote URL 是相对路径(
proj-alpha.git),门房收到的就是git-receive-pack 'proj-alpha.git'。如果你写全路径(/srv/git/proj-alpha.git),门房收到的也是全路径——两种都行,靠的正是脚本里那两步剥路径、剥后缀。
四、门房脚本
保存为{git-root}/bin/gate-shell:
#!/bin/sh# {git-root}/bin/gate-shell —— SSH 强制命令:钉身份 + 限仓库 + 留痕set-euorig="${SSH_ORIGINAL_COMMAND:-}"# ★ 必须带 :- :ssh -T 不带命令时这个变量根本不存在# 审计:放行与拒绝都留痕(只记放行 ⇒ 试探行为一条都看不见)log(){printf'%s\t%s\t%s\t%s\t%s\n'"$(date-u+%FT%TZ)""${ES_SEAT:-unknown}"\"$(printf'%s'"${SSH_CLIENT:-}"|cut-d' '-f1)""$1""$2"\>>"{git-root}/var/access.log"}deny(){log DENY"$1";echo"gate: denied$1">&2;exit1;}# 0) 空命令(ssh -T 不带命令)⇒ 只回显服务端注入的字段,不执行任何东西if[-z"$orig"];thenecho"hi${ES_SEAT:-unknown}· projects:${ES_PROJS:-none}· source recorded"log IDENT"-"exit0fi# 1) 只放行 git 的两种协议命令case"$orig"ingit-upload-pack*|git-receive-pack*);;*)deny"command:$orig";;esac# 2) 白名单为空 ⇒ 直接拒(漏配 environment="ES_PROJS=..." 时报错可辨认)[-n"${ES_PROJS:-}"]||deny"empty whitelist for${ES_SEAT:-unknown}"# 3) 提取仓库路径,校验是否在本席位白名单内(ES_PROJS 由服务端注入)repo=$(printf'%s'"$orig"|sed"s/^git-[a-z]*-pack '//; s/'.*$//")name=${repo##*/}# ★ 白名单里存的是仓名,比较前必须先剥掉路径name=${name%.git}# ★ 再剥 .git:git 送的是 proj-alpha.git,白名单写的是 proj-alphacase",${ES_PROJS:-},"in*",$name,"*);;*)deny"repo for${ES_SEAT:-unknown}:$repo";;esac# 4) 放行后交给真正的 git(exec 替换进程,无残留父进程)log ALLOW"$orig"execgit-shell-c"$orig"sudochowngit:git{git-root}/bin/gate-shellsudochmod755{git-root}/bin/gate-shellsudotouch{git-root}/var/access.log&&sudochowngit:git{git-root}/var/access.log要点
| 设计 | 原因 |
|---|---|
身份读ES_SEAT(服务端注入) | 客户端自报可伪造;environment=由 sshd 写入,客户端覆盖不了 |
orig="${SSH_ORIGINAL_COMMAND:-}" | set -u下变量未定义会直接中止脚本;而ssh -T不带命令时它根本不存在(不是空串)。git push永远带命令,所以去掉:-后推送一切正常、自检全线失败 |
放行前先case匹配命令 | 强制命令是"无视客户端想干什么",这里再收窄到只剩 git 协议 |
| 白名单匹配前先剥两层 | git-receive-pack送来的是/srv/git/proj-alpha.git(全路径 + 后缀),白名单里写的是proj-alpha(仓名)⇒ 先${repo##*/}剥路径、再${name%.git}剥后缀,才比得上 |
白名单用,包一层再匹配 | 避免proj-a误匹配proj-ab(",$ES_PROJS,"+*",$name,"*) |
| 白名单为空直接拒 | 漏配environment="ES_PROJS=..."时给出denied empty whitelist,一眼分辨是"没配白名单"还是"仓不对" |
审计取SSH_CLIENT | 同样是服务端变量,可回答"是不是从异常位置连进来的" |
deny()先写日志再退出 | 只记放行 ⇒ 试十次你看到一片安静。拒绝也必须入账,否则这个文件回答不了"谁试过什么" |
exec git-shell | 替换进程,避免残留父进程被回跳利用 |
这里有一个开关必须打开:
environment=选项默认不生效(sshd_config(5)明确写:环境处理由PermitUserEnvironment控制,默认为no)。不配这一步,门房拿到的ES_SEAT/ES_PROJS是空的。实测对照与配置步骤见本节末尾。
为什么敢给"空命令"开口子:这一支只回显服务端自己注入的两个字段(席位、白名单),连同来源 IP 一起写进access.log但不回显;它不碰仓库、不执行客户端指定的命令、不exec git-shell。唯一的信息副作用是往日志加一行IDENT。而这两样客户端本来就知道——它知道自己连的是哪个Host、要碰哪个仓。所以这个口子不泄露信息,却让"我到底以谁的身份连上了"变成一个可以一条命令确认的事实。
set -eu是双刃:-e让门房不带半套校验往下走(这是好事),但-u会把"变量不存在"也当成错误——而ssh -T时SSH_ORIGINAL_COMMAND恰恰不存在。
五、写 authorized_keys
sudovi{git-root}/.ssh/authorized_keys写入(格式模板,请替换成自己的实际值):
command="{git-root}/bin/gate-shell",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,environment="ES_SEAT=seat-a",environment="ES_PROJS=proj-alpha,proj-beta" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... seat-a逐字段说明
| 字段 | 作用 | 去掉会怎样 |
|---|---|---|
command="..." | 强制执行门房脚本 | 客户端可执行任意命令 |
no-pty | 不分配交互终端 | Agent 可能卡在等待交互(见坑 2) |
no-port-forwarding | 禁端口转发 | 可借这条通道代理进内网 |
no-agent-forwarding | 禁 agent 转发 | 可借用宿主机上的其他密钥 |
no-X11-forwarding | 禁 X11 | 缩攻击面 |
environment="ES_SEAT=..." | 注入席位身份 | 提交者身份不可区分 |
environment="ES_PROJS=..." | 注入项目白名单 | 可跨项目 push |
语法坑(很常见)
- 选项之间逗号紧邻、不要空格;
command="..."内的路径不要含空格(有空格必须转义,容易出错 ⇒ 直接用无空格路径);- 一行只能有一把公钥;
- 文件权限必须是
600,目录700,属主git。
sudochowngit:git{git-root}/.ssh/authorized_keyssudochmod600{git-root}/.ssh/authorized_keys★ 必须打开的一个开关:PermitUserEnvironment
这是全文唯一一处「不配就完全跑不通」的地方。
authorized_keys里的environment=选项默认不生效。sshd_config(5)的原话是:环境处理「由PermitUserEnvironment控制,默认为no」。只写environment="ES_SEAT=..."而不改 sshd 配置,门房读到的变量是空的。
我们做了一组对照实验,同一把密钥、同一份authorized_keys,只改这一个开关:
PermitUserEnvironment | 门房读到的变量 |
|---|---|
no(默认) | SEAT=[(empty)]PROJS=[(empty)] |
yes | SEAT=[seat-test]PROJS=[proj-a] |
空值的后果很刁钻:ES_SEAT空 ⇒ 日志里的席位是unknown;ES_PROJS空 ⇒ 脚本的denied empty whitelist分支直接拒掉所有仓。现象看上去是「门禁太严」,根因却是身份根本没注入进来。
配置只需一行:
sudotee/etc/ssh/sshd_config.d/99-git-gate.conf>/dev/null<<'EOF' PermitUserEnvironment yes EOFsudosshd-t&&sudosystemctl reloadssh# Debian/Ubuntu 服务名是 ssh;RHEL 系是 sshd它有一个副作用,必须知道:开启后 sshd 不只处理authorized_keys里的environment=,也会处理 git 用户家目录下的~/.ssh/environment——sshd_config(5)把两者写在同一条规则里。而同一份手册也警告:启用环境处理「在某些配置下可能让用户绕过访问限制(例如通过LD_PRELOAD)」。
两条缓解一起做:
- git 用户的 home 与
~/.ssh/只对 git 可写,席位没有任何途径去写~/.ssh/environment; - 这些密钥已经带
no-pty与command=强制命令,席位能执行的只剩门房脚本。
不想承担这个副作用的替代方案:把身份从
environment=挪进command=的参数——command="{git-root}/bin/gate-shell seat-a proj-alpha,proj-beta",脚本改成从$1$2读。
这样完全不需要PermitUserEnvironment。代价是每个席位一行不同的command=,配置要按席位渲染,
参数里的逗号与引号也得自己处理。我们选前者的理由很简单:environment=让「身份」与「命令」分离,
门房脚本对所有席位是同一份,加一个席位只加一行environment=。
六、验证(三条命令 + 预期输出)
以下输出是把本文脚本放进沙箱实跑得到的({git-root}换成沙箱路径,来源 IP 已打码):
# 1) 身份自证:应回显席位与可访问项目$ssh-Tgit@{hub-host}hi seat-a · projects: proj-alpha,proj-beta ·sourcerecorded# 2) 命令收敛:越权命令应被门房拒绝$ssh-Tgit@{hub-host}"cat /etc/passwd"gate: denied command:cat/etc/passwd# 3) 越权仓库:白名单外的仓应被拒$gitpush git@{hub-host}:proj-gamma.git main gate: denied repoforseat-a: proj-gamma.git三次尝试都会在access.log留一行,结果字段分别是IDENT/DENY/DENY:
2026-11-01T11:22:00Z seat-a {client-ip} IDENT - 2026-11-01T11:22:01Z seat-a {client-ip} DENY command: cat /etc/passwd 2026-11-01T11:22:02Z seat-a {client-ip} DENY repo for seat-a: proj-gamma.git如果你看到第 2、3 条如期被拒、但access.log里没有对应记录,说明deny分支只exit 1没写日志——这是很多人写门房时的第一版形态,也是这个文件后来变成"没人看的死日志"的最常见原因。
验证失败时的排查顺序:
| 现象 | 先查 |
|---|---|
| 回显仍是旧身份 | authorized_keys是否被改成多行/有语法错误 →sshd -t看配置是否报错 |
environment变量为空 | AuthorizedKeysFile路径是否对得上;sshd -T | grep authorizedkeysfile |
| 连不上 | ~/.ssh权限、/etc/ssh/sshd_config的PubkeyAuthentication、SELinux(restorecon -Rv {git-root}) |
ssh -T报unbound variable | 脚本里写的是orig="$SSH_ORIGINAL_COMMAND"。set -u下该变量在ssh -T时根本不存在(不是空串)⇒ 改成${SSH_ORIGINAL_COMMAND:-}。git push不受影响,所以这条能瞒很久 |
gate: denied empty whitelist | ① sshd 没开PermitUserEnvironment yes,变量根本没注入;② 这把密钥漏了environment="ES_PROJS=..."。先查 ①:sshd -T | grep permituserenvironment |
ssh -T回显hi unknown · projects: none | 两个environment=都没生效(同上);打开PermitUserEnvironment yes后systemctl reload ssh |
| 白名单里的仓也被拒 | 拿全路径比了仓名($repo是/srv/git/x.git,ES_PROJS里是x),或者剥了路径忘了剥后缀。两行都要:${repo##*/}+${name%.git}(这条不报错,只表现为「门禁太严」) |
| 客户端静默卡住 | 多半是no-pty+ 交互式 git(见坑 2) |
七、四个实战坑
坑 1:command=里的路径含空格
现象:整行解析失败,表现为"authorized_keys 里有这行但没生效"。
处理:脚本路径一律用无空格、无特殊字符的路径;参数全部走environment传。
坑 2:加了no-pty之后,依赖交互式 git 的 Agent 静默卡死
现象:任务不报错,但就是不动;日志里停在等待终端。
处理:把该流程改成非交互(例如把需要输入凭据的步骤提前预置),或给这类席位单独开一把不带no-pty的密钥,仅用于交互操作。
坑 3:误以为SendEnv可以传身份SendEnv+ 服务端AcceptEnv是客户端→服务端的透传,客户端可以随意指定。
处理:身份永远走environment=;SendEnv只用于透传与身份无关的普通变量。
坑 4:手改 authorized_keys 导致漏项
现象:新席位上线后少了某个environment=限制。
处理:用配置管理或脚本统一渲染,不要手改;并把"每把密钥的选项计数一致"纳入巡检(见第八节)。
八、与 pre-receive 联动(最小配置)
SSH 层解决"你是谁",还需要一道"你推的东西对不对":
# 全局挂载,避免新仓漏挂 hooks/sudo-ugitgitconfig--globalcore.hooksPath{git-root}/hookssudomkdir-p{git-root}/hooks关键点:门房注入的ES_SEAT/ES_PROJS会一路传到pre-receive的环境里,所以钩子可以按席位做差异化判据(例如"某个席位只允许改测试文件")。
被拒时客户端看到:
$ git push hub-seat-a main remote: ✗ refs/heads/main:main 受保护,只有 integrator 身份可推。 remote: 请推到 refs/heads/feat/seat-a-{topic}。 remote: remote: 本次推送已被拒绝(未做任何改动)。 ! [remote rejected] main -> main (pre-receive hook declined)三句各管一层:谁(seat-a这个身份)、能推哪(只能推自己的 feat 命名空间)、失败的边界(整包拒绝,未做任何改动)。
为什么一定要全局core.hooksPath而不是逐仓挂hooks/:逐仓挂的方式下,新建的裸仓默认没有钩子——漏挂的仓等于没有门禁,而且没人会发现(因为一切顺利)。
九、30 分钟落地 checklist
- 建 git 系统用户 + 目录结构(bin / repos / var / .ssh)
- 建裸仓(
git init --bare)——门房放行之后才轮到 git 找仓,仓不存在照样失败 - 每个 Agent 席位生成独立ed25519 密钥,并在客户端
~/.ssh/config里绑Host别名 - 部署门房脚本
gate-shell(chmod 755) - 开
PermitUserEnvironment yes并 reload sshd——不开则两个environment=全部失效,门房读到的变量为空 - 写
authorized_keys(模板套用 + 权限600) - 配
core.hooksPath+ 放pre-receive - 验证三条:身份自证 / 越权命令被拒 / 越权仓库被拒——并且三条都要在
access.log留下IDENT/DENY记录 - 把
access.log接入你已有的日志系统,按结果字段分开统计(只记放行等于没有审计) - 把"密钥选项计数一致"纳入定期巡检
十、常见问题速查
| 症状 | 原因 | 处理 |
|---|---|---|
Permission denied (publickey) | 公钥没进 authorized_keys / 权限错 /PubkeyAuthentication no | 逐项查文件、权限、sshd 配置 |
gate: denied command: | 客户端发的是非 git 协议命令 | 正常拦截,确认调用方只用 git |
gate: denied repo for ... | 目标仓不在该席位的ES_PROJS内 | 加白名单;别手改authorized_keys,用配置管理或脚本统一渲染(见坑 4) |
| 所有仓都被拒,含白名单内的 | ① 比对对象是全路径 +.git后缀,白名单存的是仓名;② 密钥漏配ES_PROJS(文案是denied empty whitelist) | ① 先${repo##*/}剥路径,再${name%.git}剥后缀;② 补environment="ES_PROJS=..." |
ssh -T报unbound variable | set -u撞上$SSH_ORIGINAL_COMMAND——ssh -T不带命令时它不存在 | 改成${SSH_ORIGINAL_COMMAND:-} |
客户端env设了但服务端读不到 | 用SendEnv传的,被覆盖 | 身份改用environment= |
environment变量为空 / 席位显示unknown | sshd 没开PermitUserEnvironment yes(默认no) | sshd -T | grep permituserenvironment应为yes,改完 reload |
| 交互式操作卡死 | no-pty | 改非交互,或另开一把不带no-pty的密钥 |
| 审计日志没有记录 | 路径权限 / git 用户不可写 | chown git:git目录与日志文件 |
小结
Agent 提交代码用的是人的身份,是审计失效的根因;而修好它不需要新建身份系统——一个command=加两个environment=,在连接层把身份钉死,客户端绕不过去。
这一层做好之后,后面所有机制(门禁判据、协作账本归属、审计统计、异常检测)才有可信的归属基础。
顺带还白拿了一个东西:审计。谁、从哪来、想做什么、放行还是拒——四样都在同一行日志里,而且客户端一个字段都伪造不了。很多人是在出事之后才回头补日志的,而这里它是副产物。
下一篇(第 3 篇):《门禁该不该"阻断"?两种哲学的正面交锋》——我们为什么选择 push 即拒,而不是"只提醒、不拦截",以及这个选择让我们付出了什么代价。每周三更,欢迎关注追更。