AGENT NETWORK · DOCS
教程

教程四:远程执行运维命令

shell 模块:三道闸门,从零到可随时收回

场景与前置条件

你手上有几台开发板或测试机,它们跑着 anet daemon。你想从网络的另一端触发 "重启业务进程"、"读最近 200 行日志"、"烧一版固件"这类操作,并拿回回显, 而且这份权限要能随时收回,不需要重启被控端。

这就是 shell 模块的用途。它是套件里唯一一个在宿主机上执行命令的模块, 因此它的默认状态与其它模块相反:不装、不配、不列名单,三者缺一都执行不了。

开始之前需要:

  • 两台机器(或同一台机器上的两个身份,见本页末尾的"本机排练")。 一台是被控端,要装 shell 变体;一台是调用端,装默认版即可。
  • 两台都能出站访问同一个 hub。本页用官方 hub https://hub.agentnetwork.org.cn。
  • 被控端的 ~/.anet/config.json 你能编辑,并且有 sudo(名单文件放在 /etc/anet 下)。

最小镜像(debian:12 这类)要先补三样,否则安装脚本本身就跑不起来, 或者 HTTPS 直接失败:

apt-get update && apt-get install -y curl ca-certificates procps

procps 是因为 uptime、ps 这类顺手会写进命令表的东西不在最小镜像里。

三道闸门

shell 模块的授权被拆成三个互相独立的决定。任何一道没过,命令都不会执行。

闸门位于哪里默认值谁决定怎么核实
1 · 编译期二进制里有没有链接 module/shell不在(加法 tag,-tags shell 才编进去)谁装/构建了这台机器anet version 的 modules: 行
2 · 配置块~/.anet/config.json 的 modules.shell无(不注册任何能力,不执行任何命令)被控端的运营者anet status 的 caps 字段(下一次 hub-register 之后才更新)
3 · 名单modules.shell 里的 allow 或 allow_file空(空名单拒绝所有,不是放行所有)被控端的运营者发起一次真实调用

三道之外还有两个默认关闭的开关,后面会分别说明:

  • allow_arbitrary —— 是否开放 shell.exec(执行调用方给的任意命令)。默认 false。
  • allow_local —— 是否放行不带调用方身份的调用。默认 false。

第一道之所以做成加法 tag(默认不在),而其余九个由构建标签管辖的子系统都是减法 tag (默认在、-tags no_<name> 去掉),原因是两个方向出错的代价不对称:减法 tag 写错,后果是 某人想去掉的模块没去掉;加法 tag 写错,后果是命令执行能力进了所有没要过它的构建。 需要靠"记得去掉"来保护的人,恰好是没听说过这个 tag 的人。

判据是符号数,方向与减法 tag 相反,由 CI 的 optin job 两个方向都验:

go build -o /tmp/a ./cmd/anet             && go tool nm /tmp/a | grep -c module/shell   # 必须 0
go build -tags shell -o /tmp/b ./cmd/anet && go tool nm /tmp/b | grep -c module/shell   # 必须 > 0

动手:从零到一次远程执行

在被控端装 shell 变体,并自己核对装到的是它

--shell 换的是另一个二进制,不是同一个二进制上的一个选项:

curl -fsSL https://agentnetwork.org.cn/install.sh | sh -s -- --shell \
  --hub https://hub.agentnetwork.org.cn \
  --name $(hostname)

装完立刻核对。这不是一句承诺,而是一条可以自己查的输出:

anet version
anet 0.1.7 (commit …, built …)
modules: anetlink,blackboard,cas,mcp,org,p2p,service,shell,taskboard,x402

默认版这一行里没有 shell:

modules: anetlink,blackboard,cas,mcp,org,p2p,service,taskboard,x402

modules: 那行读的是二进制里实际链接进来的模块注册表(module.Compiled()), 不是构建时刻进去的一个字符串 —— 一个模块出现在这行里,是因为它的 init() 跑过了, 而 init() 跑过是因为链接器把它留下了。不信任这个输出的话,直接查二进制:

strings "$(command -v anet)" | grep -c 'shell\.run@'     # 默认版 0,shell 版 1
go tool nm "$(command -v anet)" | grep -c module/shell    # 默认版 0,shell 版 > 0(需装 Go)

发布的二进制保留符号表,就是为了让第二条能跑。

装成默认版而配了 modules.shell 的话,daemon 会在启动时报错,不会静默忽略:

module "shell" is configured but not compiled into this build (it needs -tags shell, which the default build does not use)

在被控端写 modules.shell 配置块

编辑 ~/.anet/config.json,加一个 modules.shell。只有 commands 里逐条列出的 命令能被调用:

{
  "modules": {
    "shell": {
      "commands": {
        "uptime":      { "run": "uptime", "description": "负载与运行时长" },
        "disk":        { "run": "df -h" },
        "tail-log":    { "run": "journalctl -u myapp -n 200 --no-pager" },
        "restart-app": { "run": "systemctl restart myapp", "description": "重启业务进程" },
        "flash":       { "run": "/opt/tools/flash.sh", "args": true, "timeout_s": 600 }
      },
      "allow_file": "/etc/anet/shell-allow",
      "timeout_s": 60,
      "max_output_bytes": 65536
    }
  }
}

对应的能力 id 是 shell.run@uptime、shell.run@flash 这种形式。 全部字段见下面的"配置字段全表"。

两条会让 daemon 启动失败的写法,先说在前面:

  • commands 为空且 allow_arbitrary 不为 true —— 这个模块什么都不提供,加载期直接报错。
  • allow 与 allow_file 同时给出 —— "谁能调用"有两个来源,比能正确的数量多一个,加载期报错。

建一个空的名单文件

sudo mkdir -p /etc/anet
sudo touch /etc/anet/shell-allow
sudo chown "$USER" /etc/anet/shell-allow   # daemon 以谁的身份跑,就要能读它
sudo chmod 600 /etc/anet/shell-allow

先留空,不要急着往里写。 空名单拒绝所有远程调用,这是正确的起点。

那条 chown 不是可选的:sudo touch 建出来的是 root:root,配上 600 之后 以你自己身份跑的 daemon 读不了它。读不了不等于空 —— module/shell/shell.go 的 Start 把"文件在、但打不开"当作启动错误, daemon 直接起不来,报 shell: allow_file: … permission denied。 (只有"文件不存在"才等同空名单。)daemon 以 root 跑时(见下面"开机自启"一节), root:root 600 本来就对,这条 chown 不用做。

重启节点

anet stop && anet up

重启让新配置生效,注册不用另做:daemon 每次启动都会跑一次 refreshRegistration, 用 withServedCapabilities 把本节点实际提供的能力 id 合进去重新注册。 所以重启之后别人就能 anet find --cap shell.run@uptime 找到这台机器。

反过来撤掉一条命令时要多做一步:显式注册会把这份并集写回 config.json 的 caps, 删掉 modules.shell 再重启并不会让旧的 shell.run@… 从目录里消失, 还要把 config.json 的 caps 数组里那几条一起删掉(或直接 anet hub-leave)。

要顺便改展示名,再显式注册一次:

anet hub-register https://hub.agentnetwork.org.cn --name $(hostname)

启动时名单为空,daemon 的 stderr 会打一行提醒,写进 ~/.anet/daemon.log:

anet shell: no caller is allowed — set allow or allow_file, or this module will refuse every call

这一行现在是预期的。

先验证空名单确实拒绝所有

到调用端,拿被控端的 AID(在被控端跑 anet status,读 aid 字段),然后:

anet delegate <被控端AID> --capability shell.run@uptime
anet results

anet results 打印的是一个 results 数组;下面这段是其中一条的 result 字段, 它本身是一段 JSON 文本。回来的应该是 UNAVAILABLE,而不是 uptime 的输出:

{
  "capability": "shell.run@uptime",
  "status": "UNAVAILABLE",
  "verifiable": false,
  "message": "this node does not accept commands from bafyrei……"
}

拒绝的措辞是明说的:被拒的调用方应该知道自己不在名单上,而不是被留着猜这个能力 存不存在 —— hub 目录已经告诉过它这个能力存在了。

名单文件不存在和名单文件为空在这里是同一件事:都是拒绝所有。 删掉整个文件是"一次性全撤"的正当做法,所以它必须表现得像一个空文件, 而不是像一个坏掉的节点。

拿到调用方的 AID,写进名单 —— 不重启

在调用端这台机器上取它自己的 AID:

anet status

读 aid 字段;或者直接提取:

anet status | grep -o 'bafyrei[a-z0-9]*' | head -1

回到被控端,把这个 AID 追加进名单文件。文件是一行一个 AID,空行与 # 起的注释被忽略, 所以可以顺手写下每一行是谁:

echo '# 我的笔记本,2026-09-03 加' | sudo tee -a /etc/anet/shell-allow
echo 'bafyrei……调用端的AID'      | sudo tee -a /etc/anet/shell-allow

不需要重启,不需要重新注册。 allow_file 在每一次调用前重读,不缓存。 写进去,下一次调用就生效。

再从调用端调一次:

anet delegate <被控端AID> --capability shell.run@uptime
anet results

这次应该拿到 OK 和回显。字段名取自实现(internal/daemon/capability.go 的 capabilityResult 与 provider.Provenance),metrics 与 evidence 是 map, 所以键按字母序输出;下面的具体数值是示意,不是某一次实测:

{
  "capability": "shell.run@uptime",
  "status": "OK",
  "verifiable": true,
  "metrics": { "duration_ms": 12, "exit_code": 0, "output_bytes": 68 },
  "evidence": {
    "auth_trust": 0,
    "latency_ms": 12,
    "native_ack": true,
    "observed_state": " 14:22:31 up 6 days,  3:11,  1 user,  load average: 0.08, 0.12, 0.09",
    "protocol": "shell",
    "requested": "shell.run@uptime",
    "verify_trust": 2
  }
}

回显在 evidence.observed_state 里。带参数的命令这样调(仅当该命令声明了 "args": true):

anet delegate <被控端AID> --capability shell.run@flash --args '{"argv":["--image","/tmp/fw.bin"]}'

收权:删一行,或删整个文件

# 撤一个调用方
sudo sed -i '/bafyrei……调用端的AID/d' /etc/anet/shell-allow
 
# 或者一次性全撤
sudo rm /etc/anet/shell-allow

下一次调用即被拒,不用重启。 加回去同样是下一次调用即恢复 —— 收权不是一趟 需要重启才能撤销的单程票。

allow(内联列表)没有这个性质:它在配置里,改完要 anet stop && anet up。 生产上用 allow_file。想收权的那一刻,通常不是想重启"正要收权的那个东西"的那一刻。

彻底不干了:

anet hub-leave
anet stop

hub-leave 只是让这个节点在 hub 上不再可投递,已经产生的证据留着。

配置字段全表

以下字段全部来自 ANet/module/shell/shell.go 的 Config 与 Command 结构。

字段类型默认含义
commands.<name>.runstring必填命令行,经解释器执行(见 shell 字段)
commands.<name>.descriptionstring空能力卡上给对方看的说明;为空时 shell.list 用 run 顶替
commands.<name>.argsboolfalse是否允许调用方追加参数(args.argv)
commands.<name>.dirstringdaemon 的工作目录这条命令的工作目录
commands.<name>.timeout_sint取全局值这条命令的超时,覆盖全局 timeout_s
allowstring 列表空允许调用的 AID 内联列表。空 = 拒绝所有;改动需重启
allow_filestring空允许调用的 AID 文件,一行一个,# 起注释;每次调用重读
allow_localboolfalse是否放行不带调用方身份(CallerAID 为空)的调用
allow_arbitraryboolfalse是否开放 shell.exec(任意命令)
timeout_sint30全局超时秒数;上限 86400(24 小时),超出在加载期报错。0.1.9 之前是 1800,而且 daemon 还会在 60 秒无条件截断 —— 见长任务
max_output_bytesint65536回显字节上限;上限 4194304(4 MiB),超出在加载期报错
shellstring/bin/sh解释器

命令名的限制:不能为空,不能含空格、制表符、@ 或 / —— 因为它会成为能力 id shell.run@<name> 的一部分。run 不能是空白。

关于 allow_local:CallerAID 的零值就是空字符串。把"空"当作"本机、可信"意味着 将来任何一处新调用点漏填这个字段,都会静默绕过整份名单。默认拒绝让同一个疏忽 表现为一次看得见的拒绝,而不是一次看不见的放行。它需要自己的开关,这个开关默认关。

这个模块暴露哪些能力

能力 id行为
shell.list报告本节点定义了哪些命令、名单上有几条、全局超时是多少
shell.run@<name>执行一条已定义的命令
shell.exec执行调用方给的任意命令(args.command,可选 args.dir);仅在 allow_arbitrary 为 true 时存在

能力列表是排序后通告的,所以卡片上的顺序跨重启稳定 —— 一份自己会重排的列表会让每张卡 看起来都变过。

allow_arbitrary 关着时,shell.exec 既不出现在能力卡上,也不接受按名调用。开着时, daemon 启动会打一行明确的警告到 ~/.anet/daemon.log:

anet shell: allow_arbitrary is ON — an allowed caller can run any command as the user this daemon runs as

行为约定

这一节的每一条都是实现行为,不是建议;标注了 daemon 的那条不在模块里。

  • 不提权。 命令以 daemon 自己的用户身份运行。本模块不调 sudo、不 setuid。 要 root 就要让 daemon 以 root 运行 —— 那是安装期的决定,写在 unit 文件里、看得见, 不是这个模块能授予的东西。以非 root 身份跑 systemctl restart 这类命令,能不能做成 完全取决于该用户的权限;做不成时退出码与 stderr 原样回给调用方。(systemctl、 journalctl 在非 root 下的具体失败形态与目标机器的 polkit 配置有关,本页没有实测。)

  • 参数不会变成命令。 args: true 时,调用方给的 argv 每一个元素被单引号包裹 (内嵌单引号转义为 '\'')后追加到命令行末尾。命令行归运营者,参数归网络, 两者不能混成一个东西。argv 不是字符串列表时,这次调用直接报错,不执行。

  • 没声明 args 却带了 argv,是错误而不是忽略。 静默丢掉参数意味着执行了 一件与调用方所要求的不同的事,然后报成功。

  • args 里出现别的键,同样是错误。 shell.run@<name> 只认 argv 这一个键, shell.exec 只认 command 与 dir;别的键一律拒绝并点名:

    shell: uptime: unknown argument "arguments" (this call takes: argv)

    这条补的是同一个漏洞的一般情形:把 argv 写成 args 或 arguments 的调用方, 参数被丢掉、命令照跑、结果报 OK —— 一次没按要求做却自称成功的执行。

  • 超时杀整个进程组。 命令跑在自己的进程组里,超时时 SIGKILL 发给整个组。 只杀 shell 会把它启动的子进程(一次构建、一次烧录、一个循环)留在机器上, 而没有任何句柄能再找到它们。

  • 逃出进程组的子进程最多再等 3 秒。 有些命令会自己 setsid,它们持有的输出管道 会让这次等待永远不结束。WaitDelay 把这个等待封顶,而不是让一次调用永久挂起。

  • 回显有上限,截断时写明。 超过 max_output_bytes 的部分被截断,并在输出末尾追加 一行 [output truncated at <N> bytes],不静默截。超限之后的写入仍然被接收(丢弃), 这样命令不会因为管道关闭而被 EPIPE 打死 —— 否则"输出太多"会伪装成"命令崩了"。

  • 诚实效果状态。 退出码 0 → OK;非 0 → FAILED,message 是 exit <码>, stderr 在 evidence.observed_state 里;超时被杀 → FAILED,message 是 killed after <超时>。不会出现"被杀了但报 OK、输出为空"这种组合。 命令根本没能启动(exec 失败)走的是另一条路径:模块不构造效果,而是返回错误 shell: start: …;daemon 把 provider 返回的错误统一记成 FAILED,message 就是 这句错误文本。所以调用方两种情况都看到 FAILED,区别在于没能启动的那次不带 metrics.exit_code、也不带 evidence —— 本节点没有退出状态可报。

  • verify_trust 是 2(V2),不是 4。 本节点读回了退出码和这条命令打印的内容, 这是它观察到的;命令声称的那件事在世界上是否真的发生了,它没有观察到。

  • 每次执行、以及名单判定产生的每次拒绝,都在应答前上证据链。

事件何时写载荷字段
anet.shell.command每次执行后、把结果回给调用方之前,无论成败caller capability command exit_code killed duration_ms output_bytes
anet.shell.refused调用方不在名单上,或调用没带身份而 allow_local 关着caller capability reason

有一种拒绝不写事件:名单文件存在但读不了。此时本节点连"谁在名单上"都答不出来, 它回 UNAVAILABLE 并且不落记录(module/shell/provider.go 的 Invoke), 所以"谁在敲门"的复查看不到这一类。

被拒的记录是更值得留的那一条:名单外的 AID 只会出现在这里,而拒绝如果不留痕, 想复查"谁在敲门"的运营者就没有东西可查。查看方式:

anet evidence --type anet.shell.refused --limit 20
anet evidence --type anet.shell.command --limit 20

开机自启:User= 决定命令以谁的身份执行

anet up 起的进程不跨重启。真机上用 systemd:

sudo tee /etc/systemd/system/anet.service >/dev/null <<EOF
[Unit]
Description=ANet daemon
After=network-online.target
Wants=network-online.target
 
[Service]
Type=simple
User=$USER
Environment=ANET_DATA_DIR=$HOME/.anet
ExecStart=$HOME/.local/bin/anet daemon
Restart=on-failure
RestartSec=5
 
[Install]
WantedBy=multi-user.target
EOF
 
anet stop
sudo systemctl daemon-reload
sudo systemctl enable --now anet
systemctl status anet --no-pager

User= 决定命令以谁的身份执行。User=你自己 的 daemon 只能跑你自己能跑的东西; 要跑 root 级运维命令,就要把 User= 设成 root,并把 ANET_DATA_DIR 改到 /root/.anet。

把 User= 设成 root 之后,名单里的每一个 AID 都能在这台机器上以 root 跑你在 commands 里列出的那几条命令。

模块不提权,也拦不住这件事 —— 提权与否是你在 unit 文件里做的决定,模块只是照做。 名单要按这个前提来写:allow_file 里的每一行,等于一次 root 权限的授予, 范围是 commands 的那几条,期限是这一行还在文件里的时间。

已知取舍

四条,都是有意选的,写在这里是因为它们各自有代价。

一 · run 经 shell 执行,运营者写进去的内容不受任何限制。 这样运营者可以直接写平时敲的命令(管道、重定向、&&),代价是 run 里的内容 没有任何校验。这条线划在"运营者写的"和"网络传来的"之间,只有后者是被引号隔离的那一侧。 不接受这个取舍的话,把命令包成脚本、run 只写脚本路径 —— 代价是配置文件不再显示 这台机器实际会做什么。

二 · 命令名会进 hub 目录。 节点向 hub 通告的是它实际会应答的能力 id,所以 shell.run@restart-app 这类名字对 任何能浏览目录的人可见。这不给出访问权(名单仍然拦着),但确实公开了这台机器 可以被要求做什么。不希望公开的命令,名字就不要带信息。

三 · shell.list 只把名单条数回给远程调用方,不回 AID 列表。 一个被接受的调用方不该顺带拿到本节点信任的其余机器名单 —— 那会把"一个被接受的调用方" 变成"一份其他运营者的通讯录"。本机调用(CallerAID 为空,需要 allow_local)拿到 完整列表,那是运营者在读自己的配置。

四 · 回显会落到本节点的证据链上,尽管 shell 模块自己没写它。

  • 位置:ANet/internal/daemon/capability.go 的 deliverCapabilityResult, 写入 anet.capability.effect 事件时带上了 evidence,其中包含 observed_state。
  • 行为:ANet/module/shell/provider.go 写的 anet.shell.command 事件不含回显, 但 daemon 在交付能力结果时写的那条事件含。
  • 影响:命令打印的任何内容(可能包括日志里的敏感串)会留在被控端的 ~/.anet/evidence.ael.jsonl 里。ANet/docs/SHELL-zh.md 里"回显本身不入链" 这句只对模块自己写的那条事件成立,对整条链不成立。
  • 发现方式:为本页核对源码时,对照两处 RecordEvidence / ledger.Append 调用点。

排查

现象原因与处理
daemon 启动即报 module "shell" is configured but not compiled into this build (it needs -tags shell…)装的是默认变体。重跑安装带 --shell
daemon 启动即报 shell: no commands declared and allow_arbitrary is offcommands 为空且没开 allow_arbitrary,这个配置块什么都不提供
daemon 启动即报 shell: set either allow or allow_file, not both两个名单来源同时给了,删掉一个
daemon 启动即报 shell: allow_file: … permission denied名单文件在,但 daemon 的运行身份打不开它。chown 给该用户,或让 daemon 以读得了它的身份运行
调用回 UNAVAILABLE,消息 does not accept commands from …调用方 AID 不在名单里。检查 allow_file 的路径、内容与 daemon 用户的读权限
调用回 UNAVAILABLE,消息 carry no caller identity这次调用没有带调用方身份。要允许这种调用需显式配 "allow_local": true
调用回 UNAVAILABLE,消息 cannot read its own allowlist名单文件存在但读不了(权限/IO)。注意"文件不存在"不走这条,它等同空名单
调用回 FAILED,消息 exit <码>命令真的失败了。stderr 在 evidence.observed_state 里
调用回 FAILED,消息 killed after …超时,整个进程组已被杀。调 commands.<name>.timeout_s
调用回 FAILED,消息 command "x" does not take arguments该命令没声明 "args": true,却传了 argv
anet find --cap shell.run@… 找不到这台机器配置改了没重启(模块只在启动时构建),或 daemon 起来时 hub 不可达 —— 启动时那次注册刷新失败只写一行日志。看 anet logs 里有没有 could not refresh this node's registration,修好之后重启,或跑一次 anet hub-register …
调用回 UNAVAILABLE,消息 this node does not serve <cap>这台机器上没有任何 provider 服务这个 id:命令名拼错,或 allow_arbitrary 关着而你按名请求了 shell.exec。命令没有执行。用 shell.list 核对能力 id

这条应答是 0.1.7 补上的。 此前能力解析不出时 tryCapabilityPaid 只返回 false, 委派落到 auto-reply;没配 auto-reply 的节点对该次委派永不作答,请求方拿到的是超时, 与"节点宕了"无法区分。现在没配 auto-reply 的节点直接答 UNAVAILABLE; 配了 auto-reply 的节点仍按原样把它交给 agent 处理 —— 那个 id 可能确实是 agent 能做的事。

发现方式是 ANet/scripts/joint-shell.sh 第 7 组:在 allow_arbitrary 关闭的情况下 按名请求 shell.exec。

节点自己的日志在 ~/.anet/daemon.log,也可以 anet logs 100。

这一页背后验过什么

ANet/scripts/joint-shell.sh 起一个 hub 加两个 daemon,provider 用 -tags shell 构建,跨进程走真实委派路径。脚本里有 19 处 ok 断言点,最后一处就是"未服务的能力 是否作答";这条修好之后一次完整运行打印的是 19 passed。它验的是单元测试验不了的那些:

  • 名单文件不存在时拒绝,走的是真实网络路径而不是构造出来的 provider.Call。
  • 名单上的调用方能在另一台机器上执行命令,且以 daemon 的用户身份执行(不是 root)。
  • 到达模块的 CallerAID 是验签后的身份,不是调用方自称的值。 这是关键的一条:整套授权都压在这个字段上,而单元测试断言的是测试文件自己写进去的值。 只有真委派能证明这件事,脚本靠"provider 的证据链里记着 requester 的 AID"来验。
  • 参数里的 shell 元字符(hi; id、&& id、$(id))原样作为文本回来,没有被执行。
  • 非零退出回 FAILED 且 stderr 完整。
  • 删掉名单行后下一次调用即被拒(不重启),加回后即恢复,拒绝记录上了链。
  • shell.exec 在 allow_arbitrary 关闭时不出现在 hub 目录里,按名调用也不执行。

本机排练(可选)

不想动两台机器的话,可以在一台机器上用两个身份排练,但要注意: 两个身份共用同一个二进制,所以被控端那个身份要生效,这个二进制必须是 shell 变体。

anet id new board          # 建一个新身份并后台启动;数据目录 ~/.anet/ids/board
anet id ls                 # 看 AID、控制口、数据目录

anet id new 会立刻把这个身份起起来,而它此时还没有 modules.shell。所以顺序是: 把 modules.shell 写进 ~/.anet/ids/board/config.json(名单里填默认身份的 AID), 再 anet --id board stop && anet --id board up 让配置生效,然后 anet --id board hub-register <hub-url> --name board 把能力清单折进注册。 之后用 anet --id board <cmd> 操作被控端、用裸 anet <cmd> 操作调用端。 委派仍然走 hub 中继,两个身份都要 hub-register。

接下来