← ブログに戻る

sudoers引数制限の4段階:*は空白を跨ぐ

別の機械に入って root の要る作業をさせたい場面は、思っているより多いです。CI から設定ファイルを配る。手元の Claude Code や Codex を、GPU の載った別の PC の上で走らせる。1台の検証機に何人かで入って作業する。

3つとも、最初に詰まるのは同じ場所です。sudo が実行のたびにパスワードを聞きます。人が打っているなら答えればいいのですが、CI にもエージェントにもキーボードの前に座っている人がいません。

それで /etc/sudoers にこう書きたくなります。

deploy ALL=(root) NOPASSWD: ALL

左から、deploy というユーザーが、どのホストから入っても、root として、パスワードを聞かれずに、ALL(あらゆるコマンド)を実行できる、と読みます。1行で詰まりが消えるので、まずこれを書きます。

そしてこれを書いた時点で、その経路を踏まれたら機械の root ごと持っていかれます。

削るのは最後の ALL です。sudoers ではコマンド名だけでなく引数まで書けます、という話を私はTailscale で Pi に CI デプロイ:権限3層CI から SSH で配る権限の削り方の2本で書きました。方向は今も変えていません。ただ、書き方によっては、読んだときに思う範囲よりずっと広く通ります。手元で確かめたら自分の書いた例がそうだったので、絞り方を整理し直します。

削り方には4段あります。コマンド名を列挙する → 引数まで書く → 引数を取らせない → スクリプトを digest で固定する。下の段ほど許す範囲が狭くなり、下の段ほど書く量と更新の手間が増えます。全部やる必要はなく、どこで止めるかは sudo を打つのが人間か CI かエージェントかで変わります。

段階1: コマンド名を列挙する

ALL を消して、実行させたいコマンドだけを並べます。

Cmnd_Alias DEPLOY = /usr/bin/install, /usr/bin/systemctl

deploy ALL=(root) NOPASSWD: DEPLOY

ここで止めると、ほとんど何も絞れていません。sudoers(5) は「単純なファイル名だけを書いた場合、ユーザーは好きな引数でそのコマンドを実行できる」と書いています。install を引数なしで許すのは、任意のファイルを任意の場所に root 権限で置ける、という意味です。systemctl も同じで、ユニットファイルを書ける状態と組み合わさると任意のコマンドが root で走ります。

引数を書かずに許して安全なコマンドは、ALL を書くのとあまり変わらない、くらいに考えたほうが事故が少ないです。

段階2: 引数まで書く — 効きますが、狭くはなりません

引数まで書けます。私が書いていたのはこの形です。

Cmnd_Alias DEPLOY = \
    /usr/bin/install -o root -g root -m 0644 /tmp/staging-*/*.yml /etc/myapp/*.yml, \
    /usr/bin/systemctl reload myapp

deploy ALL=(root) NOPASSWD: DEPLOY

読むと「staging ディレクトリから /etc/myapp/.yml を置くだけ」に見えます。置き先も取り出し元もパスで閉じてあるように見えます。

見えるだけでした。sudoers(5) の Wildcards 節に、こう書いてあります。

Command line arguments are matched as a single, concatenated string. This mean a wildcard character such as ’?’ or ’*’ will match across word boundaries, which may be unexpected.

sudo が見るのは、引数を全部つないだ1本の文字列です。だから * は引数と引数のあいだの空白を跨ぎます。同じ節はもう1つ書いています。パス名の部分と違って、引数の中では / もワイルドカードに一致します

man ページ自身が挙げている例が分かりやすいです。

%operator ALL = /bin/cat /var/log/messages*

これは sudo cat /var/log/messages.1 を許すつもりの行ですが、sudo cat /var/log/messages /etc/shadow も通ります。* が空白を跨いで /etc/shadow まで飲み込むからです。

sudoers は引数を1本に連結した文字列として照合するため、* が空白と / を跨いで別のオプションやパスまで飲み込む

手元で試した4件

自分の書いた規則がどこまで通るのか、fnmatch に同じパターンと引数列を渡して確かめました。sudo が照合に使っているのと同じ関数に、同じ文字列を渡しています。sudo 本体を動かした結果ではありません。

パターンは上の install の行の引数部分です。

-o root -g root -m 0644 /tmp/staging-*/*.yml /etc/myapp/*.yml

4件試して、4件とも一致しました。表の引数列は、頭の -o root -g root -m 0644 を省いて書いています。

渡した引数列 (先頭の固定部分は省略)結果
/tmp/staging-1/app.yml /etc/myapp/app.yml — 想定どおりの呼び方一致
/tmp/staging-1/evil.yml -t /etc/cron.d /tmp/staging-1/z.yml /etc/myapp/app.yml — 途中に別のオプションと別の source を挿し込む一致
/tmp/staging-1/evil.yml /etc/myapp/../../root/x.yml — 置き先を /etc/myapp/ の外へ出す一致
/tmp/staging-1/../../etc/shadow.yml /etc/myapp/app.yml — 取り出し元を staging の外へ出す一致

2件目が効きます。install-t は置き先ディレクトリを指定するオプションで、GNU の install はオプションと非オプション引数の順序を入れ替えて解釈します。つまり後ろに書いたオプションが、前に書いたパスの意味を変えられます

これを組むと、-m 0644 と書いてあるモードも上書きできます。手元で実際に走らせた1行です (/etc は触らず、作業用ディレクトリで再現しています)。

$ install -m 0644 staging-1/evil.sh -m 0755 -t profile.d staging-1/pad.yml myapp/c.yml
$ ls -l profile.d/
-rwxr-xr-x 1 iris iris 2 c.yml
-rwxr-xr-x 1 iris iris 5 evil.sh
-rwxr-xr-x 1 iris iris 4 pad.yml

-m 0644 を書いた後ろに -m 0755 を足すと後ろが勝ち、-t を足すと置き先が変わります。そしてこの引数列を /tmp/etc のパスに直したものは、さきほどのパターンに一致します。

-o root -g root -m 0644 /tmp/staging-1/evil.sh -m 0755 -t /etc/profile.d /tmp/staging-1/pad.yml /etc/myapp/c.yml

/etc/profile.d/ に置かれた .sh は、次に誰かがログインシェルを開いた時点で読まれます。/etc/sudoers そのものは拡張子が邪魔をして上書きできませんが、「置ける場所は staging と /etc/myapp/ の内側に閉じている」という読み方は成り立ちませんでした。

前の記事で「引数まで書けばそこは塞がります」と書いたのは、狭すぎる範囲だけを見た書き方でした。

sudoers(5) はこの節の結びで、身も蓋もないことを書いています。

It is often better to do command line processing outside of the sudoers file in a scripting language for anything non-trivial.

引数の検査は sudoers でやるな、スクリプトでやれ、ということです。段階3がこれです。

なお、ワイルドカードを1つも使わずにフルパスで列挙できるなら、段階2でも十分に硬いです。/usr/bin/systemctl reload myapp のように可変部分がない行は、引数が完全一致しなければ通りません。

危ないのは可変部分をワイルドカードで表現した瞬間です。

段階3: 引数を取らせない

sudoers には「引数なしでのみ実行を許す」書き方があります。コマンドの後ろに "" を1つ置きます。

Cmnd_Alias AGENT = /usr/local/sbin/deploy-myapp ""

こう書くと sudo deploy-myapp は通り、sudo deploy-myapp --anything は通りません。可変部分がゼロなので、段階2の問題そのものが消えます。

代わりに、可変部分はスクリプトの中に移ります。

#!/bin/bash
# /usr/local/sbin/deploy-myapp  (root:root 0755)
set -euo pipefail

SRC=/var/lib/myapp-staging/config.yml
DST=/etc/myapp/config.yml

[[ -f "$SRC" ]] || { echo "staging に config がありません" >&2; exit 1; }
/usr/bin/myapp-validate "$SRC"

install -o root -g root -m 0644 "$SRC" "$DST"
systemctl reload myapp

置き場所も置き先もスクリプトの中で固定されているので、呼ぶ側が変えられるのは「staging に何を置いたか」だけになります。ファイルの中身を差し替えられる点は変わりませんが、任意の場所に任意のモードで置く経路は消えます。副産物として、監査する対象が1箇所に集まります。sudoers の1行を睨んで「この * はどこまで一致するのか」を考える代わりに、スクリプトを読めば済みます。スクリプトなのでテストも書けます。

呼ぶ側が引数を渡したい場合でも、sudoers に通すのは避けたほうが楽です。引数はファイル経由か環境変数経由でスクリプトに渡し、スクリプトの中で realpath などを使って許可した範囲に入っているかを判定します。この判定は sudoers のワイルドカードでは書けません。

段階4: スクリプト自体を固定する

段階3にはまだ穴があります。/usr/local/sbin/deploy-myapp を書き換えられる人がいれば、その人はスクリプトの中身を好きにできます。sudoers はパスしか見ていないからです。

sudo 1.8.7 以降は、コマンドに SHA-2 のダイジェストを添えられます。

Cmnd_Alias AGENT = \
    sha256:1a07fcbfce75554fb5631f9b4d53cfebf97852e774440549569272bbfa78ac48 \
        /usr/local/sbin/deploy-myapp ""

agent ALL=(root) NOPASSWD: AGENT

ダイジェストが合わないと規則そのものが一致しないので、差し替えられたスクリプトは sudo の側で弾かれます。値は sha256sumopenssl dgst -sha256 で出せます。

2点、条件があります。sudoers(5) は「ユーザーがそのコマンド自体に書き込めるなら、ダイジェストを検査した後・実行する前に差し替えられる可能性がある」と警告しています。スクリプトと、それが置いてあるディレクトリの両方を root 所有にしておかないと意味が薄れます。もう1つは運用のほうで、スクリプトを直すたびに sudoers の側も書き換える必要があります。更新が頻繁なものに付けると、そのうち誰かが digest を消します。滅多に変わらない入口にだけ付けるのが現実的です。

sudoers の絞り方を4段階に分けた表。ALL から、コマンド名の列挙、引数の固定、引数なしラッパー、digest による固定へと進むにつれて、防げる範囲と運用コストが両方増える

sudo では閉じられないもの

4段階を全部やっても、sudo の外に残るものがあります。

許したコマンドが別のコマンドを起動できるなら、そこで全部漏れます。 sudoers(5) に Preventing shell escapes という節があり、シェル、エディタ、ページャ、メール、端末エミュレータが名指しされています。find -exectar --to-command、ユニットファイルを書ける状態の systemctl も同じ性質です。エディタを root で開かせたいなら sudoedit のほうを使います。sudo vim は選びません。ユーザー権限でコピーを編集させ、書き戻しだけを root でやる仕組みなので、エディタからシェルに抜けても root にはなりません。

同じ形は sudo の外にもあります。 Claude Code や Codex に .env を読ませたくなくて、ファイル読み取りの deny ルールを書いたとします。それでもコマンドを1つ許してあれば、その出力から中身が取れます。許可の単位が「どのコマンドを実行してよいか」なのに、守りたいのは「何に手が届くか」なので、2つがずれます。sudoers で find を許すと -exec で何でも起動できるのと、ずれ方が同じです。エージェント側でこれをどう塞ぐかは「とりあえず全部許可」でClaude Codeを動かすと、.envの秘密がそのままAnthropicに渡る話に書きました。

! で引き算しても止まりません。 bill ALL = ALL, !SU, !SHELLS のような書き方について、man ページは「コマンドを別名にコピーして実行すれば簡単に回避できる。この種の制限は良くて advisory と考えるべき」と書いています。許可リストで書く。禁止リストは書かない。この一般則がここでも効きます。

NOEXEC はラッパースクリプトには使えません。 sudo には、実行したプログラムがさらに別のプログラムを起動するのを止める NOEXEC タグがあります。単体のバイナリには効きますが、シェルスクリプトは installsystemctl を起動して仕事をするので、段階3のラッパーに付けると動かなくなります。noexec が向くのは、そもそも子プロセスを作らないはずのコマンドです。

ここまでは全部「止められない」話です。ただ、止められないことと、気づけないことは別です。sudo が決められるのは実行を許すかどうかまでですが、許したあとに何が起きたかなら記録できます。何をされたかは、I/O ログで後から追えます。 log_output を有効にすると sudo が擬似端末の下でコマンドを走らせて出力を記録し、sudoreplay で再生できます。log_subcmds を足すと、シェルエスケープで起動された子コマンドもイベントとして残ります。シェルに抜けた先も、! で止められなかったコマンドも、通った跡は残せます。

用途別に、どこまで書くか

CI から配る

引数のパターンが決まっているので、段階2でフルパス列挙できるなら段階2で足ります。可変部分が出てきたら段階3に移ります。sudoers 単体で完結させようとしないほうが楽です。CI の場合は、VPN のアクセス制御リスト・SSH の鍵・sudoers の3つを別々に revoke できる形にしておくと、どれか1つを疑ったときに他を再設定せずに済みます。その組み方はTailscale で Pi に CI デプロイ:権限3層に書きました。

別の PC で AI エージェントを動かす

Claude Code や Codex を、GPU の載った別のマシンや検証用のマシンで走らせて SSH で入る、という使い方があります。ここが sudoers の絞りがいちばん効く場面です。

理由は、コマンド列を組み立てるのが人間ではないからです。CI なら実行される行はワークフローに書いてあります。エージェントは状況に応じて自分で組み立てるので、引数のパターンを事前に列挙しきれません。段階2のワイルドカードで表現しようとすると、どんどん広い * を書くことになります。

扱いやすいのは、sudo をゼロで始めることです。エージェント用のユーザーには sudoers の行を1つも書かず、詰まった操作が出てきたら、その操作だけを引数なしのラッパーとして足していきます。systemctl restart したいなら restart 専用のスクリプトを1本、ログを見たいなら読み取り専用のスクリプトを1本。数が増えてきたら、それがそのまま「このエージェントに何を許したか」の一覧になります。

エージェントに bash -csh -c を1つでも許すと、他の行が全部無意味になります。 上の shell escapes と同じ話ですが、エージェントは人間より高い頻度でシェル経由の実行を組み立てるので、踏みやすさが違います。

エージェント自体が攻撃の道具になった例も出ています。2025年8月の Nx パッケージ改ざん (s1ngularity) では、postinstall スクリプトが開発機に入っている Claude Code / Gemini CLI / Amazon Q を承認スキップのフラグ付きで呼び出し、認証情報の探索と持ち出しをやらせています。これは sudo を経由した事例ではありませんが、そのマシンに置いてあるエージェントが、そのマシンの権限で他人の指示に従いうる、という形は同じです。

sudo の外側では、SSH の側でも閉じられます。~/.ssh/authorized_keys の鍵に restrict を付けるとポート転送やエージェント転送や PTY 割り当てが止まり、command="..." を付けると、その鍵で入ってきた接続では指定したコマンドしか走りません。エージェント用の鍵を人間用の鍵と分けておくと、この2つを鍵ごとに書き分けられます。

エージェント用のユーザーには sudoers の行を1つも書かずに始め、詰まった操作だけを引数を取らないラッパーとして足していく。増えた行がそのまま「何を許したか」の一覧になる。ただし bash -c を1つ許すと左の3つは意味を失い、鍵の側でも restrict と command= で閉じる

1台に複数人が SSH する

人間が相手のときは、絞る目的が少し変わります。事故を止めるのと、誰が何をしたかを残すのが主になります。

Runas_Spec を使うと、root 以外の別のユーザーとして実行させられます。アプリの操作なら root である必要はないことが多いです。

%dev ALL=(appuser) NOPASSWD: /usr/bin/tail -n 200 /var/log/myapp/app.log
%ops ALL=(root) /usr/local/sbin/deploy-myapp ""

ログを見たいだけの人に root を渡す必要はありません。可変部分がないので、この行はワイルドカードなしで書き切れます。

%dev のようにグループで書いておくと、人の出入りは usermod だけで済みます。sudoers を触るのは権限の形が変わるときだけになります。

記録の側では log_outputuse_pty を有効にして、sudoreplay で再生できる状態にしておきます。これが効くのは、共有アカウントに sudo が付いていない場合だけです。全員が同じユーザーで入っていると、sudo のログにも同じ名前しか残りません。パスワードを聞く設定を残すなら timestamp_timeout も見ておきます。既定では一度認証すると同じ端末でしばらく聞かれません。席を外す運用が混ざるなら短くします。

人間が複数人で入る機械では、Runas_Spec で root ではなくサービスユーザーとして実行させ、log_output と use_pty で記録して sudoreplay で再生できるようにする。前提は共有アカウントに sudo を付けないことで、付けると記録にも同じ名前しか残らない

書いた後に確かめる

最後がこれで、いちばん抜けやすいところです。

visudo -c は文法しか見ていません。手元で試したのが以下です。systemctlsystemclt と綴り間違え、存在しないバイナリのパスも混ぜてあります。

Cmnd_Alias DEPLOY = \
    /usr/bin/systemctl reload myapp, \
    /usr/bin/systemclt reload myapp, \
    /usr/bin/myapp-does-not-exist --check

deploy ALL=(root) NOPASSWD: DEPLOY
$ visudo -c -f try-sudoers
try-sudoers: 正しく構文解析されました
$ echo $?
0

綴り間違いも、実在しないパスも、そのまま通ります。パスやオプションの並びが1文字違えば、文法は通ったまま実行時に弾かれます。

確認は機械の上で2つやります。1つは、そのユーザーに対して最終的に何が許可されているかを sudo 自身に解決させることです。root で sudo -l -U <ユーザー> を打ちます (-U は root か ALL を持つユーザーしか使えません)。当人として sudo -l を打っても同じものが見えます。

もう1つは、許可したコマンドを1つずつ sudo -n <コマンド全体> で打って、パスワードを聞かれずに通ることを見ることです。-n を付けておくと、聞かれた時点で失敗して返ってくるので、入力待ちで止まりません。

sudoers に書いたことは、sudo できることの証拠になりません。段階を上げるほど書く量が増えるので、確認の手間も一緒に増えます。ここを飛ばすと、絞ったつもりで壊れているか、絞ったつもりで広いかのどちらかになります。

今回私が踏んだのは後者でした。


この記事は sudo の側から権限を削る話でした。エージェント側で同じことをどう組むか (AGENTS.md や CLAUDE.md の設計、hooks によるフィードバックループ、詰まったところを自分で足していく仕組み) は、1冊にまとめました。

ハーネス・エンジニアリング 関連書籍 ハーネス・エンジニアリング ハーネスエンジニアリング 入門 | AGENTS.md 設計・hooks 実装・AIエージェント運用の体系書 書籍ページを見る →