Skip to content

Shell

shell 工具在你机器上执行 shell 命令。

Run ls -la to list files in the current directory

安全模型

Shell 命令分为三档:

安全(直接执行)

没有破坏潜力的简单命令:

  • ls、cat、head、tail
  • git status、git log、git diff
  • npm install、pip install
  • docker ps、docker compose logs
  • rm -r build/(仅相对路径)

危险(需要审批)

可能修改系统状态或销毁数据的命令:

  • rm -r /absolute/path(绝对路径),以及 rm -rf ../outside
  • chmod 777、chown root
  • 不带 WHERE 的 DROP TABLE、DELETE FROM
  • systemctl stop、pkill -9
  • git reset --hard、git push --force
  • sed -i、覆盖敏感文件
  • cp ~/.ssh/id_rsa …、cat ~/.ssh/id_rsa、cat .env、ln -s /etc/…
  • cp … /etc/…、mv … /usr/local/bin/…

凭据规则同时覆盖读和写两侧:cat ~/.ssh/id_rsa 和 cat .env 无论在哪儿都会被问,因为 src/.env 和 ~/.env 是同一个秘密。公钥(id_rsa.pub)、shell 配置备份 (cp ~/.bashrc /tmp/)和 .env.example 模板不是秘密,保持放行。

检测到危险命令时,Nova 会在执行前请你批准或拒绝。

硬性(无条件阻止)

绝不允许的命令:

  • rm -rf /、mkfs、dd of=/dev/sd*
  • fork 炸弹、kill -1
  • shutdown、reboot、poweroff

配置规则

上面几档是默认值。它们在 ~/.nova/permissions.json 里调整 —— 这个文件首次运行时创建, 所有键都在并带说明,所以有东西可读、可改:

{
  "shell": {
    "allow": ["git push *", "killall *", "docker compose *"],
    "disable": ["git force push (rewrites remote history)"],
    "ask": [
      { "match": "\\bnpm\\s+publish\\b", "description": "publishes a package" }
    ]
  }
}
键 形态 效果
allow 前缀列表 预先批准所有以该前缀开头的命令。* 是唯一的通配符,其他都是字面量。
disable 规则描述列表 关掉一条内置规则。名字就是弹窗显示的理由。
ask {match, description} 列表 新增一条规则。match 是 Python 正则,description 是审批弹窗显示的内容。

这些列表按固定顺序查询,先匹配者胜:

block  ->  allow  ->  工作区范围  ->  ask  ->  allow(无规则匹配)

allow 排在 ask 之上,这样你能预先批准默认会拦下的东西。block 排在两者之上:一条 存在的理由是「任何东西都不能撤销它」的规则,绝不能有可达的豁免途径,所以没有任何配置能 放行 rm -rf /。

四件要知道的事:

  • 格式错误的条目会被跳过,而不是致命。 一个坏正则或错误的 JSON 结构会被记录并忽略, 文件其余部分照常生效。安全文件里的一个笔误,不能让 Agent 什么都跑不了。
  • 文件每进程只读一次。 改动在重启后生效。
  • 出厂默认放行一切。 内置规则仍然生效,但在你加键之前,没有东西被预先批准,也没有 工具被闸住。
  • disable 拿掉的是一整个概念。 两条规则可以共用一个描述 —— pkill -9 和 killall -9 都是「force kill processes」—— 所以禁用那个描述会同时移除两条,一次被 记住的批准也会同时覆盖两条。

规则是针对什么匹配的

规则是正则,但不是对整条命令行匹配:这一行会被解析,每条规则针对该行运行的每一条命令 匹配,和 Claude Code、OpenCode 读它们 bash 规则的方式一样。即使配了 allow: ["npm test *"],npm test && git reset --hard 也会被问 —— 因为 allow 前缀是 某条命令的前缀,不是它后面接的任何东西的许可。一行里有多条命令被标记时,最严的判定获胜 —— 任何一个 ask 就 ask。

引号里的片段会被忽略,除非引号本身就是载荷。 git commit -m "docs: why git push --force is dangerous" 会执行; psql -c "DROP TABLE users" 会被问。那些引号参数就是载荷的程序(SQL 客户端、解释器、 bash -lc、rm 的操作数)在 patterns.py 里列为 PAYLOAD_PROGRAMS,而带引号的重定向 目标(echo x > "/etc/passwd")无论怎么加引号都是操作数。Heredoc 主体出于同样的理由被 遮蔽:写一份引用了 rm -rf / 的文档不等于运行它。

如果 shell 语法无法安装、或读不懂某一行,就改由行级模式来回答 —— 包括管道规则的替代 实现,所以 tree-sitter 缺失时 curl … | bash 仍然被拒。那条路径比解析路径更宽,会对它 看不懂的复合行发问,这是刻意的取舍:一次弹窗,而不是一次执行。

审批

危险命令会暂停当前轮次并发问。用「always」批准会记录一条授权,键由三样东西构成: 触发的规则、命令族,以及存在时的行内脚本。

单靠规则太宽 —— 一条规则覆盖的命令之间并不等价 —— 所以命令族把它收窄到你看到的那条命令, 用的是和 OpenCode 一样的前缀表:git push * 和 git checkout * 是两个不同的族,尽管它们 的规则很像。

命令族命名的是一个程序,而不是程序被要求做什么,对管道进解释器来说仍然太宽。每个 curl … | python3 -c "…" 都属族 curl * python3 *,所以一次针对某段脚本的授权,过去会 授权同一规则下的任何其他脚本 —— 包括会运行它下载内容的那种。因此脚本成为键的一部分, 「always」的意思是「记住这段脚本」。对话框在按钮上方说明了这一点。

授权在本次会话剩余时间里覆盖那个键,不会带到其他会话。当会话无法识别时,它也不会被 记录:没有东西可以给它定作用域,而一条适用于每个无法识别上下文的授权,是你从未给过的 授权。代价是多一次弹窗。

授权存在内存里。它们不跨重启,所以「always allow」在 Nova 重启后需要重新授予。

在伸手去用管道之前,有合适的专用工具时优先用它:web_fetch 读页面,jq 过滤 JSON, 两者都不触发规则。把抓来的字节变成代码的行内脚本会触发 —— 而这正是值得避开、而不是值得 回答的形态。一个只用 json.load 解析 JSON 的管道本来就是放行的,所以这个习惯比这次弹窗 更重要。

弹窗永远不会自己过期。它把当前轮次挂住,每 15 秒一次心跳保持连接,直到你回答 —— 因为你 慢了就自动拒绝,是一种错误的失败方式。

工作区范围

Codex 把两个问题分开:沙箱决定 Agent 能做什么,审批只决定它什么时候必须在边界上停下。 Nova 没有沙箱,所以近似地做了边界那一半:一条只修改工作区内文件的命令,不问直接跑。

被覆盖的命令是那些参数是路径、且不做别的事的命令 —— mkdir、touch、cp、mv、rm、 rmdir、ln、install :并且只有当它们命名的每一个路径都解析到工作区内时。

rm -rf ~/project/build          放行  (区内)
rm -rf build/output             放行  (相对路径,相对工作区解析)
rm -rf ~/other/build            询问  (区外)
rm -rf ../outside               询问  (穿越到工作区外)
rm -rf ~/project/build && ...   询问  (链式命令不分析)
rm -rf ~/project/*              询问  (通配符在运行时展开)
rm -rf "$TARGET"                询问  (路径不可知)

相对路径相对工作区解析,因为那才是 shell 运行所在的目录 —— 不是守护进程的工作目录, 在服务器上那是它恰好启动的地方。

豁免属于只做一件事的命令。复合行上的每条命令会分别和规则匹配,但豁免本身只给单命令行: 一条链的两半各自都在工作区内,仍然会被问,因为判断错误的代价是一次没人被问过的递归删除。

分析无法界定的任何东西都会落到普通规则上,所以最坏情况是一次弹窗,而不是一条无人看管的 命令。被阻止的命令永远不会被软化。

作用域里没有工作区时,什么都不豁免。

工具权限

Shell 不是唯一能对你机器动手的东西。write 改文件,web_fetch 发请求,MCP 工具做它服务 端决定的事 —— 在 tools 块存在之前,这些都不弹窗。

同一个 permissions.json 承载它:

{
  "tools": {
    "*": "ask",
    "read": "allow",
    "edit": "deny",
    "web_fetch": "ask",
    "mcp__github__*": "deny"
  }
}
效果 结果
allow 不打断你直接运行(未配置时的默认)
ask 暂停当前轮次并发问
deny 工具不被注册,模型根本看不到它

解析取最长的匹配名,所以结果不取决于键恰好被写成的顺序:精确名字胜过命名空间,而 命名空间之间更长的前缀获胜。只有结尾的 * 才让一个名字成为命名空间,所以 "read" 拒绝 的是 read 而不是 read_file。* 是兜底,所以上面的例子对除读取之外的一切发问,拒绝 编辑,并完全拒绝一个 MCP 服务。

deny 在注册时移除工具,而不是在分发时拒绝它。schema 里的一个工具,就是模型会去尝试的 工具;调用时再拒绝,仍然花一个来回,仍然给提示词注入一个可瞄准的目标。

Shell 被排除在外 —— 它按自己那套规则判定,再叠一层工具策略只会重复这个决定。

凭据和环境路径即使在工具本身被放行时也会发问:对 .env、~/.ssh/…、~/.aws/… 或 ~/.kube/… 的 read,无论在哪儿都会被问,因为 src/.env 和 ~/.env 是同一个秘密。 这类弹窗不提供「记住」:授权以工具为键,而对普通文件的「always」不该花在读秘密上。 ~/.nova/config.json 也算 —— 它按它所配置的东西命名,里面放着提供方 API key。

Nova 自己的目录不是逃出工作区的通道,所以它们也不发问:~/.nova/skills/... 下某个技能的 脚本,是你安装的能力,不是你项目之外的路径。豁免覆盖 skills、agents、hooks 和 Nova 自己的 workspace 目录,而且按路径落到哪里判断 —— 在 skills/ 下植入的、指回配置的 符号链接会被问。home 里其他所有东西继续发问:数据库、会话、日志。配置的 ask 永远不会被 这个豁免清掉,就像工作区边界永远清不掉它一样。

读是自由的,写是有闸的

工作区边界只对那些会修改的工具发问 —— write 和 edit。只读的工具可以命名磁盘上 任何位置的路径,包括工作区之外,这也是所有可比 Agent 的落点:Codex 的沙箱刻意「允许 Codex 几乎在任何地方读文件,而在你的工作区内写文件」,Claude Code 的默认配置允许读取和 搜索,工作目录之外的弹窗只作为一个可选设置提供。grep 是搜索工具,不是逃逸。

读取仍然会问的是凭据路径,那些 Agent 每一个也都会问。所以规则是一句话:

在任意位置读             -> 放行(除非是凭据)
在工作区外写             -> 询问
凭据路径,任何工具       -> 询问,且永远不可记住

要拿回更严的姿态 —— 工作区外的读也要问,也就是 Claude Code 出厂 blockReadsOutsideWorkingDirectories 的做法 —— 设置:

{ "block_reads_outside_workspace": true }

它和这里其他键一样每进程读一次,且只有字面量 true 才启用。

格式错误的 tools 块会保持一切放行。这里 fail-closed 会直接禁用 Agent 的工具,比不应用 所要求的东西更糟。

权限模式

一个键选择整个文件运行在什么姿态下:

{ "mode": "ask" }
模式 需要审批的命令会变成
ask(默认) 暂停,并询问用户
acceptEdits 当它只修改工作区内的文件时放行
dontAsk 拒绝,错误里点名匹配到的规则

dontAsk 是为没有人在键盘前的运行准备的 —— CI、定时任务、一个连着但没人管的机器人。 一个永远等一个没人会点的弹窗的轮次,以最不可见的方式失败:什么都不报错,什么都不推进。 改为拒绝,给了模型一个可以汇报的东西。

acceptEdits

这个模式适合那种在本地文件里翻搅、每两次编辑之间都要被问一次 chmod 的会话。它会降级 关于路径指向哪里的 ask 规则 —— chmod 777 run.sh、chown -R user src/ :前提是 命令命名的每个路径都解析到工作区内,其他一切原样不动:

  • 凭据在每种模式下都继续发问。 cat .env、cat src/.env、cat ~/.ssh/id_rsa、 cp ~/.ssh/id_rsa /tmp/... 以及对这些路径的写,无论什么模式都会被问。工作区边界对 「一个路径是什么」什么都不说。
  • 工作区之外的路径继续发问,无论命令多普通:chmod 777 ../outside.sh、 chmod 777 /etc/passwd、cp payload /etc、ln -s /etc/hosts ./h。
  • 关于程序做什么的规则继续发问:git reset --hard、git clean -fd、 docker compose down、bash -lc、heredoc 脚本。
  • 复合行继续按它的各部分发问:chmod 777 run.sh && git reset --hard 会问,因为第二 条命令不是工作区内的修改。单条命令是单位,和本文件其他地方一致。
  • block 不会被降级。 rm -rf / 从来就不是交给人的东西。

降级不携带授权,所以不会记住任何新东西:一次放行不是用户给过的许可,也没有东西可以给它 做键。复核器继续运行,因为这个模式背后仍然有人。

它不是一条绕过通道。这个模式拒绝的命令,是人在别的模式下本来也必须批准的命令,所以 失败是一次干净的拒绝,而不是一次静默执行:

  • allow 条目照常运行。把命令放进 shell.allow(或 tools),它在 dontAsk 下和在 ask 下一样工作。
  • 授权仍然算数。 在一个会话里批准一次 git push --force,之后的 force push 即使在 dontAsk 下也会运行 —— 那是这个进程持有的唯一一个人工决定,模式忘记它等于把它扔掉。
  • block 和拒绝不受影响。 两者都从来不是交给人的东西。
  • 模型复核器不运行。 一个放行命令的复核器会让命令运行,而这个模式存在的意义正是拒绝 它(Codex 也做了同样的配对:approval_policy = "never" 让自动复核无事可审)。

模式像规则一样每进程读一次。格式错误的值读作 ask:一个把询问关掉的笔误,是唯一一种没有 补救的失败。

本文件借鉴的那些工具里还有别的模式 —— Claude Code 的 acceptEdits 和 plan、Codex 的 沙箱预设 —— 这里没有实现。bypassPermissions 是刻意不做的:没有操作系统级沙箱,它会跳过 Nova 唯一的边界。

模型复核

config.json 里的 approval_review 块把一个模型放在审批弹窗前面。规则标记的动作会先给 模型看,一个自信的 approve 会放行它。

{
  "approval_review": { "enabled": true }
}

provider 和 model 可选,默认用 Agent 自己的。Agent 跑大模型时把它们指向更小的模型 —— 复核器只需要回答一个词,拿 Agent 的额度去花是这笔交易错误的一端:

{
  "approval_review": {
    "enabled": true,
    "provider": "opencode_zen_free",
    "model": "space-bunny-free"
  }
}

和 permissions.json 不同,这里的格式错误值在启动时就失败,而不是被忽略:"enabled": "true" 被静默读成「关」会看起来像功能不工作,被读成「开」会在没被要求的情况下把一个模型 放到审批路径上。

三种结果,不是两种:

判定 效果
approve 动作不打断你直接运行
deny 你仍然会被问。 模型不能自己拒绝
escalate 和以前一样问你

escalate 才是重点。二元的 approve/deny 会逼模型对一个正则都无法决定的事情表示自信; escalate 是它说「我不知道」的方式。每种失败也都落在这里 —— 没有提供方、超时、输出解析 不了 —— 因为在安全判断上 fail-open 是错误的方向。

动作作为不可信数据展示给复核器,放在它自己的块里,并剥掉 shell 注释:命令是 Agent 在读了 一个网页或文件之后写的,所以一个在正确位置写着 approve 的页面,否则就能在对「跑什么」的 表决里有一票。判定从回复的最后一行读取,所以从动作内部回显出来的判定不算答案。对于工具 调用,复核器看到的是真实参数 —— 文件路径或 URL —— 而不是模型对自己这次调用写的描述。

复核器坐在规则后面,而不是前面,所以没有任何东西标记的命令不花任何代价。

它默认关闭。复核是审批路径上的一次模型调用,而一个把你本来会拒绝的东西放行的复核器,是比 一次弹窗更糟的失败。

复核器失败时 —— 没有提供方、超时、崩溃 —— 会问你,和 escalate 完全一样。一个异常不是 一次批准。

同一个复核器覆盖 tools 块里设为 ask 的工具,所以启用它会同时移除两个轴上的弹窗。被 阻止的命令永远不被复核,子 Agent 也永远不被复核 —— 它没有可发问的对象,这一点正是在复核器 之前检查的。

超时

Shell 命令有可配置的超时(默认 120 秒)。长时间运行的命令可以用 Escape 中断。