news 2026/11/4 12:15:25

一个 authorized_keys 字段,解决了多 Agent 系统的身份问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一个 authorized_keys 字段,解决了多 Agent 系统的身份问题

一个 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.git

3.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)]
yesSEAT=[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 variableset -u撞上$SSH_ORIGINAL_COMMAND——ssh -T不带命令时它不存在改成${SSH_ORIGINAL_COMMAND:-}
客户端env设了但服务端读不到用SendEnv传的,被覆盖身份改用environment=
environment变量为空 / 席位显示unknownsshd 没开PermitUserEnvironment yes(默认no)sshd -T | grep permituserenvironment应为yes,改完 reload
交互式操作卡死no-pty改非交互,或另开一把不带no-pty的密钥
审计日志没有记录路径权限 / git 用户不可写chown git:git目录与日志文件

小结

Agent 提交代码用的是人的身份,是审计失效的根因;而修好它不需要新建身份系统——一个command=加两个environment=,在连接层把身份钉死,客户端绕不过去。

这一层做好之后,后面所有机制(门禁判据、协作账本归属、审计统计、异常检测)才有可信的归属基础。

顺带还白拿了一个东西:审计。谁、从哪来、想做什么、放行还是拒——四样都在同一行日志里,而且客户端一个字段都伪造不了。很多人是在出事之后才回头补日志的,而这里它是副产物。

下一篇(第 3 篇):《门禁该不该"阻断"?两种哲学的正面交锋》——我们为什么选择 push 即拒,而不是"只提醒、不拦截",以及这个选择让我们付出了什么代价。每周三更,欢迎关注追更。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 12:15:09

真绝了,国产模型 Token 自由了!

大家好&#xff0c;我是二哥呀。 前几天我不是去大学讲课了嘛&#xff0c;问了很多同学&#xff0c;都说顶级模型太贵了&#xff0c;根本用不起&#xff0c;他们想要更多的赛博鸡蛋。 这不&#xff0c;正巧赶上 1024 开发者节&#xff0c;讯飞发布了全新的星火 X2.6&#xff08…

作者头像 李华
网站建设 2026/11/4 12:14:39

emacs 文件管理Dired, the Directory Editor

Dired&#xff1a;目录编辑器 Dired 会创建一个 Emacs Buffer&#xff0c;其中包含某个目录的文件列表&#xff0c;并且还可以选择性地包含该目录下的一些子目录的内容。 你可以使用 Emacs 的常规命令在这个 Buffer 中移动和浏览&#xff0c;也可以使用 Dired 专用命令对列表中…

作者头像 李华
网站建设 2026/11/4 12:13:39

Kestrel_VLLM - 30 · MTP 头适配:十五个张量与一个不存在的 oracle

30 MTP 头适配&#xff1a;十五个张量与一个不存在的 oracle 一句话&#xff1a;Qwen3.5-27B 除 64 层主干外还自带一个模型内投机草稿头 MTP&#xff08;Multi-Token Prediction&#xff0c;多 token 预测&#xff09;——它不在主干里、没有官方实现可对照&#xff0c;却决定…

作者头像 李华
网站建设 2026/11/4 12:13:26

破解 FAU 端面 1 微米崩缺检测的景深困局

1. 高分辨率与浅景深 要看清 FAU&#xff08;Fiber Array Unit&#xff0c;光纤阵列单元&#xff09;端面上 1 微米级的崩缺&#xff0c;相机必须配高倍率镜头。这是光学检测里最朴素的常识&#xff1a;分辨率越高&#xff0c;能分辨的细节越小。 但高倍率镜头的光学特性决定了…

作者头像 李华
网站建设 2026/11/4 12:12:58

【硬件方案选型调研】全链路AI语音硬件、芯片嵌入式方案

导随着端侧AI、语音交互在消费AIoT设备快速落地&#xff0c;不少硬件开发者在项目中会遇到音频算法调试、蓝牙固件开发、大模型接入、私有化设备对接、量产稳定性等一系列难题。市面上很多方案只能单独提供芯片或者上层SDK&#xff0c;缺少从底层驱动到整机落地的连贯交付能力。…

作者头像 李华