mizdra's blog

ぽよぐらみんぐ

「Node.js は偶数バージョンが LTS」ではなくなる

長らく Node.js では偶数バージョン (v22, v24 など) が LTS としてメンテナンスされてました。しかし 2027/4 リリース予定の v27 以降はそうではなくなります。Node.js 公式ブログで、v27 以降に取り入れられる新しいリリーススケジュールについて説明されています。

かいつまんで説明すると:

  • major リリースは年2回から年1回となる
  • 奇数番号だけでなく全てのリリースが LTS となる
  • 早期テスト目的で利用されてた奇数リリースは alpha チャンネルで代替される
    • alpha 期間は約半年確保
  • v27 から新スケジュールで運用開始
  • 暦に合わせた番号のバージョンがリリースされる
    • 2027 年なら v27、2028 年なら v28...

公式ブログの説明によると Node.js メンテナーのメンテナンスやリリースの負担軽減が主な目的のようですが、我々ユーザによっても分かりやすくなって良いですね。

サービスに利用する Node.js では奇数バージョンの利用を避ける運用をしていたケースが多かったと思いますが、今後はそういう運用も変えていくことになりそうです。

追記: そもそも「LTS」の定義について

「全バージョンが LTS なら、何と比較して Long Term なサポートなんやねん」と思った人もいるかもしれません。ちょっと混乱するのですが、Node.js における「LTS」は major バージョンに割り当てられるステータスのようなものです。新たな major バージョンがリリースされると、まず半年間 Current というステータスになって、その後 30ヶ月間 LTS になります。Current では新機能がどんどん実装されていきますが、LTS ではセキュリティ修正などを主に受け付ける期間になります *1

これは余談ですが、LTS はセキュリティ修正以外も受け付けることがあります。最近だと LTS であった Node.js 20 に require(ESM) が backport されています。

安全に backport できること、エコシステムが ESM 移行を進めるために重要であることなどを理由に、LTS バージョンに backport されたようです。実際このおかげで ESM 移行がより進んでいますから、ありがたいことですね。

*1:v27 からは Current の前に Alpha というステータスが半年間追加されます。

npm package 実装用のテンプレートを更新した

npm-package-template」という、id:mizdra が爆速で npm package を実装するための開発テンプレートがある。以前ブログで紹介した。

github.com

www.mizdra.net

毎年ちょっとした変更を加えているのだけど、今年は色々な事情があって大きな変更を加えた。高速な formatter/linter が登場したり、npmjs.com へのサプライチェーン攻撃が流行ったり、Coding Agent が普及したり... 時代が変化してるので、それに合わせたテンプレートに変えてみた。

ざっと要点を書き出すと以下のような変更をした。

  • Prettier から Oxfmt に移行
  • ESLint から Oxlint に移行
  • 型チェックは oxlint --type-aware --type-check でやる
  • npm-script に lin-fix を追加
  • AGENTS.md/CLAUDE.md を追加
  • tsconfig.base.json を更新
  • Trusted Publishing でリリースする workflow を追加
  • GitHub Actions のバージョンを pinning
  • GitHub リポジトリの基本的な設定をするためのシェルスクリプトを追加

Oxfmt/Oxlint

Oxfmt/Oxlint が普段使いしても良いレベルに成熟してきたので、Prettier/ESLint に乗り換えてみた。Oxlint 向けには @mizdra/oxlint-config という Shareable Config を作って、それを参照する形にしてる。Oxfmt も Oxlint も特に大きな不満なく動いていていい感じ。

Oxfmt は Shareable Config を実現するための仕組みがないので、設定をパッケージ化せずにベタ書きしてる。けど公式リポジトリを見ると oxfmt.config.ts という形式をサポートする issue が立ってたので、近い将来解決するかも。

多分 oxfmt.config.ts が実装されたら、以下のようなコードで Shareable Config を参照できるようになるはず。

// oxfmt.config.ts
export * from '@mizdra/oxfmt-config';

型チェックは oxlint --type-aware --type-check でやる

TypeScript の型チェックは今まで tsc --noEmit でやってたけど、最近 Oxlint に --type-check というオプションが生えたのでこれを使ってみた。

oxc.rs

このオプションを使うと、なんと lint 結果と一緒に TypeScript のエラーを報告してくれる。Oxlint は型情報を使った lint rule を実行するために内部で TypeScript の型チェックをしていて、その結果をそのまま吐くという仕組みになってる。ESLint 時代はこういうのできなかったので便利になったなあと思う。

ちなみに Oxlint は型チェックに tsgo を fork した tsgolint を使ってるので、型チェック自体も爆速で終わるようになってる。速くて嬉しい。

npm-script に lin-fix を追加

lint-fix": "eslint . --fix" みたいなやつのこと。今までの id:mizdra はまあ npx eslint . --fix と打てば良いだけだし無くても良い派だったのだけど、Coding Agent 向けにはあったら便利そうなので追加してみた。

AGENTS.md/CLAUDE.md

どのリポジトリにもあったら良いものということで、npm-script の使い方や commit message の規則、PR に付けるタグのルールを書いたファイルを置いておいた。AGENTS.md が本体で、CLAUDE.md はそれへの symlink にしてる。symlink できるだけ使いたくない気持ちはあるけどこれ以外良い方法を思いつかなかった。

tsconfig.base.json を更新

"types": ["node"]"noUncheckedSideEffectImports": true を追加したり。以前このブログで紹介した tsconfig.json の推奨オプションをベースに、TypeScript 5.9 で推奨となったオプション を追加しただけ。

近々リリースされる TypeScript 6.0 では "strict": true がデフォルトになったりと色々変わるそうなので、また推奨される tsconfig.json のオプションが変わりそうだなと思ってる。TypeScript 6.0 がリリースされたら見直したい。

Trusted Publishing でリリースする workflow を追加

npm の Trusted Publishing が 2025/7 に GA したので、それを使ったリリース workflow を追加した。

GitHub Actions のバージョンを pinning

サプライチェーン攻撃のリスクを軽減するため、pinact run で GitHub Actions のバージョンを固定した。

GitHub リポジトリの基本的な設定をするためのシェルスクリプトを追加

自分のOSSリポジトリにGitHubのセキュリティ設定を入れ、自分用の手順書を作った - $shibayu36->blog; で紹介されている設定を一発で行うシェルスクリプトを用意した。手でちまちま設定するの面倒だなと思ったのでカッとなって作った。

一部抜粋して紹介。

OWNER=$(gh repo view --json owner -q .owner.login)
REPO=$(gh repo view --json name  -q .name)
# Setup common repository settings
gh repo edit \
  --delete-branch-on-merge \
  --enable-auto-merge \
  --enable-discussions=false \
  --enable-projects=false \
  --enable-secret-scanning \
  --enable-secret-scanning-push-protection \
  --enable-wiki=false
# Enable Code scanning
gh api -X PATCH /repos/$OWNER/$REPO/code-scanning/default-setup -f state=configured
# Enable immutable releases
gh api -X PUT /repos/$OWNER/$REPO/immutable-releases
# Setup rulesets
DEFAULT_BRANCH_PROTECTION=$(gh api /repos/mizdra/npm-package-template/rulesets/13184851)
VERSION_TAG_PROTECTION=$(gh api /repos/mizdra/npm-package-template/rulesets/13184887)
gh api -X POST /repos/$OWNER/$REPO/rulesets --input - <<< $DEFAULT_BRANCH_PROTECTION
gh api -X POST /repos/$OWNER/$REPO/rulesets --input - <<< $VERSION_TAG_PROTECTION
# Require actions to be pinned to a full-length commit SHA
gh api -X PUT /repos/$OWNER/$REPO/actions/permissions -F enabled=true -F sha_pinning_required=true

いくつかの設定は gh repo edit でできるのでそれで設定して、残りは gh api で REST API 叩いて設定してる。欲しかった REST API 全部存在してて GitHub すごい。

ruleset の設定はちょっとハックっぽいことをしてて、mizdra/npm-package-template リポジトリの ruleset を export して、それを新しいリポジトリに import してる。これが一番短く書けるのでこうしてる。mizdra/npm-package-template は公開リポジトリで、公開リポジトリの ruleset は誰でも REST API で読み出せるので、一応このスクリプト自体は誰でも実行できると思う。

あと先ほどのコードには書いてなかったけど、id:mizdra はライセンスファイルの更新をするスクリプトも追加してる。最近 id:anatofuz さんが gh コマンドで gitignore ファイルを生成できると呟いていて、それをみて gh コマンドのリファレンス読んだらライセンスファイルを生成するコマンドを発見したので、それを使ってみている。

# Change license
gh repo license view mit | sed "s/\[year\]/$(date +%Y)/;s/\[fullname\]/mizdra/" > LICENSE
npm pkg set license=MIT && npm i

GitHub のラベルの設定をするスクリプトも追加した。github-label-setup自分向けの label preset を使ってる。

# Setup labels
GITHUB_TOKEN=$(gh auth token) npx \
  -p @azu/github-label-setup \
  -p @mizdra/github-label-presets \
  github-label-setup \
  --labels @mizdra/github-label-presets

おわりに

npm-package-template は CC0-1.0 でライセンスしてるので真似したり参考にしてみてください。

@.css-modules-kit/ts-plugin を Emacs で動かすまでの覚書

@.css-modules-kit/ts-plugin を Neovim で動かすまでの覚書 - mizdra's blog の Emacs 版。

Emacs のインストール

macOS なら brew install --cask emacs でインストールできる *1

一応 https://emacsformacosx.com/ から dmg も落とせるけど、dmg からインストールすると emacs コマンドがパスの通ったディレクトリに配置されなくて、ちょっと面倒だった。brew install --cask emacs であれば emacs コマンドもパス通ったディレクトリに配置してくれる。

設定ファイルの置き場所

~/.config/emacs があれば ~/.config/emacs/init.el に、なければ ~/.emacs/init.el に配置される。~/.config/emacs~/.emacs の両方がある時は後者が優先される。

ちなみに emacs --init-directory=~/.config/emacs-test . のようにして Emacs を起動すると、~/.config/emacs-test が設定ファイルの置き場所として使われる。デバッグ目的で初期設定の Emacs を起動したい時や、設定を切り替えたい時に便利。

Emacs Lisp の書き方

init.el は Emacs Lisp で書く。以下の公式ドキュメントを読んで書き方を学ぶと良い。

実際に実行しながら挙動を確かめたいなら、Emacs で M-: (print "Hello World!") などと打ち込むと良い。Emacs Lisp を実行できる。あとは *scratch* バッファに記入して C-j で実行するのも便利。以下のドキュメントに色々方法がまとまってる。

Mode という概念について

Emacs にはエディタの動作を切り替える仕組みがあり、これを「Mode」と呼ぶ。Mode には major mode と minor mode の二種類がある。major mode は特定のファイルタイプに特化した機能を提供するもので、1つのファイルにつき 1つだけ割り当てられる。一方 minor mode は1つのファイルに複数割り当て可能なもの。

例えば typescript-ts-mode という major mode がある。これは TypeScript 向けにシンタックスハイライトなどを提供したり、forward-sexp / backward-sexp などのコマンドによる文字移動を TypeScript に特化した挙動にする。

一方 Eglot (Emacs 組み込みの Language Client) は major mode ではなく、minor mode として動作する。Language Client が特定のファイルに紐づいて動作するわけではなく、エディタ横断で動作する類のもののため、minor mode として実装されている。

major mode は VS Code でいうところの Language Identifiers のような役割も兼ねている。この major mode の名前を使って、Language Server との紐付けを行うことになる。

major mode について

Emacs 29 以降では以下の major mode が組み込まれており、基本的にはこれらを使うのが良い。

  • js-ts-mode: JavaScript 向けの major mode
  • typescript-ts-mode: TypeScript 向けの major mode
  • tsx-ts-mode: TSX 向けの major mode
  • css-ts-mode: CSS 向けの major mode

ちなみに *-ts-modets の部分は tree-sitter の略。tree-sitter を使ってパースをして AST-aware なシンタックスハイライトをしている実装になってて、このような名前になってる。

補足: major mode の名前に -ts- が付いている理由

何故わざわざ -ts- 付けているのか疑問に思うかもしれないが、どうやら歴史的経緯によるものらしい。元々 Emacs には組み込みや 3rd-party で tree-sitter 実装ではない major mode (js-modetypescript-mode) が存在していた。ただそれらの major mode では正規表現を使ってコードを解析しており、ユーザの期待通りのシンタックスハイライトなどを実現できない問題があったようだ。そこで Emacs 29 になって tree-sitter をベースにした major mode が実装された。

しかし古い major mode と新しい major mode は本質的に挙動が異なるものである。突然 js-mode が tree-sitter ベースになると、古い major mode の挙動に依存した init.el が壊れてしまう。またそもそも Emacs のビルドには tree-sitter は必須ではなく、オプショナルである。tree-sitter がない場合も Emacs が機能するようになっていないといけない。後方互換性、そして tree-sitter なしで Emacs がビルドされているケースに対応するため、tree-sitter ベースの major mode はオプトイン (既存の major mode と名前を分けてデフォルトでは有効化しない) とされているようだった。

tree-sitter ベースの major mode を有効化する

tree-sitter ベースの major mode はオプトインで、デフォルトでは有効化されていない。そのためまずはそれらを有効化する設定を書く必要がある。init.el を以下のように編集すれば良い。

;; tree-sitter のセットアップと構文定義のインストール
(use-package treesit
  :ensure nil
  :config
  (setq treesit-font-lock-level 4)
  (setq treesit-language-source-alist
        (append
         '((javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src")
           (typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src")
           (tsx        "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src")
           (css        "https://github.com/tree-sitter/tree-sitter-css" "master" "src"))
         treesit-language-source-alist))
  (add-hook 'emacs-startup-hook
    (lambda ()
      (dolist (lang (mapcar #'car treesit-language-source-alist))
        (unless (treesit-language-available-p lang)
          (treesit-install-language-grammar lang))))))

;; major mode の設定
(add-to-list 'auto-mode-alist '("\\.js\\'"  . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.jsx\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.ts\\'"  . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode))
(add-to-list 'auto-mode-alist '("\\.css\\'" . css-ts-mode))

実は Emacs には tree-sitter の構文定義の情報が組み込まれていない。そのため、ただ *.tstypescript-ts-mode を紐づけてもシンタックスハイライトなどが効かない。そこで構文定義を外部からインストールしてくる必要がある。それが設定の前半部分。後半部分が major mode の紐付け。

これで JavaScript/TypeScript/CSS のシンタックスハイライトが機能するようになる。

補足: 構文定義が Emacs に組み込まれていない理由

tree-sitter の構文定義くらい Emacs に組み込んであったら良いのだが、色々理由があってできてないらしい。構文定義のビルドには Node.js と node-gyp が必要なため、Emacs のビルドにそれらが必須になるのは困るとか。構文定義のビルド方法の詳細に Emacs が依存したくないとか。

補足: jsx-ts-mode がない理由

*.tsxtsx-ts-mode に紐づけるのに *.jsxjsx-ts-mode に紐付けないのがびっくりするかも (id:mizdra はびっくりした)。何故こうしているかというと、Emacs には jsx-ts-mode なる major mode が存在しないから。というかそもそも JSX 用の tree-sitter の構文定義が存在しない。JavaScript と JSX で統一されているらしい。

Emacs 側の議論はちゃんと追ってないけど、多分 tree-sitter の構文定義が統一されているので major mode も統一しているのだろう。

Language Server の設定

先ほど少し触れたが、Emacs には Eglot という Language Client が組み込まれている。major mode と Language Server の起動コマンドの組のリストからなる eglot-server-programs という変数があり、この定義に従って Language Server が起動される。ちなみにデフォルトでは JavaScript と TypeScript では typescript-language-server が Language Server の実装として使われる。

Eglot は minor mode であり、デフォルトでは起動されない。特定の major mode が ON になったら、Eglot を起動するような設定を書く必要がある。

まずは Language Server をインストールしておく。

$ npm i -g typescript-language-server typescript

そして use-package:hookeglot-ensure を使って以下のような設定を書く。

;; tree-sitter のセットアップと構文定義のインストール
(use-package treesit
  :ensure nil
  :config
  (setq treesit-font-lock-level 4)
  (setq treesit-language-source-alist
        (append
         '((javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src")
           (typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src")
           (tsx        "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src")
           (css        "https://github.com/tree-sitter/tree-sitter-css" "master" "src"))
         treesit-language-source-alist))
  (add-hook 'emacs-startup-hook
    (lambda ()
      (dolist (lang (mapcar #'car treesit-language-source-alist))
        (unless (treesit-language-available-p lang)
          (treesit-install-language-grammar lang))))))

;; major mode の設定
(add-to-list 'auto-mode-alist '("\\.js\\'"  . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.jsx\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.ts\\'"  . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode))
(add-to-list 'auto-mode-alist '("\\.css\\'" . css-ts-mode))

;; Language Server のセットアップ
(use-package eglot
  :ensure nil
  :hook ((js-ts-mode         . eglot-ensure)
         (typescript-ts-mode . eglot-ensure)
         (tsx-ts-mode        . eglot-ensure)))

これで JavaScript/TypeScript で Language Server が起動するようになったはず。

@css-modules-kit/ts-plugin のセットアップ

@css-modules-kit/ts-plugin は TypeScript Language Service Plugin なので、TypeScript の Language Server に読み込ませる、というステップが必要になる。

まずは @css-modules-kit/ts-plugin をインストールしておく。

npm i -g @css-modules-kit/ts-plugin

そして init.el を以下のように書き換える。

;; tree-sitter のセットアップと構文定義のインストール
(use-package treesit
  :ensure nil
  :config
  (setq treesit-font-lock-level 4)
  (setq treesit-language-source-alist
        (append
         '((javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src")
           (typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src")
           (tsx        "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src")
           (css        "https://github.com/tree-sitter/tree-sitter-css" "master" "src"))
         treesit-language-source-alist))
  (add-hook 'emacs-startup-hook
    (lambda ()
      (dolist (lang (mapcar #'car treesit-language-source-alist))
        (unless (treesit-language-available-p lang)
          (treesit-install-language-grammar lang))))))

;; major mode の設定
(add-to-list 'auto-mode-alist '("\\.js\\'"  . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cjs\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.jsx\\'" . js-ts-mode))
(add-to-list 'auto-mode-alist '("\\.ts\\'"  . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode))
(add-to-list 'auto-mode-alist '("\\.css\\'" . css-ts-mode))

;; Language Server のセットアップ
(use-package eglot
  :ensure nil
  :hook ((js-ts-mode         . eglot-ensure)
         (typescript-ts-mode . eglot-ensure)
         (tsx-ts-mode        . eglot-ensure)
         (css-ts-mode        . eglot-ensure))
  :config
  (require 'subr-x) ;; string-trim
  (setq npm-root
        (string-trim (shell-command-to-string "npm root -g")))
  (add-to-list
    'eglot-server-programs
    `(((js-ts-mode :language-id "javascript")
       (typescript-ts-mode :language-id "typescript")
       (tsx-ts-mode :language-id "typescriptreact")
       (css-ts-mode :language-id "css"))
      . ("typescript-language-server" "--stdio"
          :initializationOptions
          ((plugins
            . [((name      . "@css-modules-kit/ts-plugin")
                (location  . ,npm-root)
                (languages . ["css"]))]))))))

やってることは Neovim の時とほぼ同じ。CSS でも typescript-language-server が起動されるようにしつつ、@css-modules-kit/ts-plugin を plugin として読み込ませている。:hook(css-ts-mode . eglot-ensure) を足しているのもポイント。これがないとそもそも CSS で Eglot が起動してくれない。

厳密には eglot-server-programs にデフォルトの JavaScript/TypeScript の Language Server を起動する設定が残っているから、それを新しいもので置換したほうが行儀は良いと思う。ただ Eglot は eglot-server-programs の先頭からリストを探索して major mode に一致するものがあったらそれを使う挙動になっている。そのため、add-to-list で先頭に設定を追加してしまえば、古いものがリスト内の残っていても特に問題がない。

という訳でこれで @css-modules-kit/ts-plugin が Emacs でも動くようになった。めでたしめでたし。

youtu.be

おまけ: CSS Language Server と共存できない問題について

勘の良い人は気づいたかもしれないが、Eglot は 1 つの major mode に複数の Language Server を起動することができない。eglot-server-programs の先頭に定義されている Language Server が優先して起動されることになる。

つまり今回 id:mizdra が紹介した init.el では、*.css に対して TypeScript/CSS 両方の Language Server を起動できない。TypeScript の Language Server だけが起動される。そのため @css-modules-kit/ts-plugin が提供する言語機能 (クラスセレクターの Go to Definition や Find All References など) は機能するが、CSS の Language Server が提供する言語機能 (プロパティの補完など) は機能しない。

この問題については長らく Eglot の discussion で議論されていたのだけど、つい最近になって Eglot ではサポートしないという方針になったようだった。

代わりに https://github.com/joaotavora/rassumfrassumhttps://github.com/thefrontside/lspx のような LSP マルチプレクサの使用を推奨している模様。まあ気持ちはわかる... わかるが Emacs 組み込みのパッケージなのだから組み込みで Multiple Server サポートしてほしいなー。

@css-modules-kit/ts-plugin を使いつつ、*.css に対して TypeScript/CSS 両方の Language Server の両方を起動するなら多分以下のような設定になるはず。

;; Language Server のセットアップ
(use-package eglot
  :ensure nil
  :hook ((js-ts-mode         . eglot-ensure)
         (typescript-ts-mode . eglot-ensure)
         (tsx-ts-mode        . eglot-ensure)
         (css-ts-mode        . eglot-ensure))
  :config
  (require 'subr-x) ;; string-trim
  (setq npm-root
        (string-trim (shell-command-to-string "npm root -g")))
  (add-to-list
    'eglot-server-programs
    `(((js-ts-mode :language-id "javascript")
       (typescript-ts-mode :language-id "typescript")
       (tsx-ts-mode :language-id "typescriptreact")
       (css-ts-mode :language-id "css"))
      . ("lspx" "--lsp" "typescript-language-server --stdio" "--lsp" "vscode-css-language-server"
          :initializationOptions
          ((plugins
            . [((name      . "@css-modules-kit/ts-plugin")
                (location  . ,npm-root)
                (languages . ["css"]))]))))))

けどこれ、vscode-css-language-server にも initializationOptions が渡ってしまうし、そもそも vscode-css-language-server*.ts の LSP Request が飛んでしまうし、全然ダメな気がする。lspx では上手くいかなさそう。rassumfrassum もドキュメント読む限りはこういうケースには対応してなさそう。打つ手なし...

まあ CSS の Language Server が提供する言語機能 (プロパティの補完など) は機能しないのは諦めてもらうということで...

おまけ: Language Server のデバッグテクニック

TypeScript Language Service Plugin や typescript-language-server が出力するログを見るには、tsserver.log を出力すると良い。TSS_LOG 環境変数を設定して Emacs を起動すれば良い。

TSS_LOG="-level verbose -file /tmp/tsserver.log" emacs -nw .

*1:Nonfree systems という見出しの下に macOS が書いてあって GNU らしさがある。

@.css-modules-kit/ts-plugin を Neovim で動かすまでの覚書

id:mizdraCSS Modules Kit という CSS Modules のためのツールキットを作っている。そのツールキットの中に @css-modules-kit/ts-plugin というものがある。CSS Modules の言語機能を提供するための TypeScript Language Service Plugin である。

一応これは任意のエディタで動かせるように作ってる。実際、VS Code / Zed などで動くことを確認してる。しかし、Neovim で動くことはまだ確認できてなかった。いや、動くはずなんだけど Neovim の使い方がさっぱりわからなくて... 何度か挑戦したけど、LSP の設定の仕方とか、plugin のインストールとかが難しくて挫折した。あとキーバインドが全然わからん。completion のウインドウってどうやって出すの? そもそも TypeScript Language Service Plugin が読み込まれているか確認するために tsserver.log を見たいのだけど、どうやったら見れるの?

...という状態だったのだけど、この度 Claude に色々聞きながらようやく @css-modules-kit/ts-plugin を動かすことができた。忘れないように覚えたことを記事にまとめておく。

Neovim のインストール

macOS なら Homebrew でインストールできる。

インストールしたら nvim で起動できるはず。

設定ファイルの置き場所

OS によって設定ファイルの置き場所は異なるが、macOS なら ~/.config/nvim$XDG_CONFIG_HOME/nvim になる。$XDG_CONFIG_HOME がセットされてない時は前者が、セットされてる時は後者が使われる。

ここに init.viminit.lua といった vimrc と呼ばれる設定ファイルを置くと、それが起動時に実行されるらしい。世の中的には init.lua で書くのが主流っぽい。

ちなみに NVIM_APPNAME=nvim-test nvim のようにして Neovim を起動すると、~/.config/nvim-test が設定ファイルの置き場所として使われる。デバッグ目的で初期設定の Neovim を起動したい時や、設定を切り替えたい時に便利。

Lua の書き方

init.lua は Lua で書く。以下の公式ドキュメントを読んで書き方を学ぶと良い。

LLM に「<あなたがよく使う言語> を書く人向けに Lua の記法や作法、違いを簡潔に教えてもらえますか?」と聞くのも良い。

実際に実行しながら挙動を確かめたいなら、Neovim のノーマルモードで :lua print("Hello!") などと打ち込むと良い。Lua を実行できる。

Neovim の LSP サポートについて

Neovim は LSP のサポートを組み込んでいる。ただし、デフォルトではどの Language Server も起動されないようになっている。ユーザが Language Server を起動するための設定が必要になる。

まず Language Server をインストールする必要がある。TypeScript の Language Server は typescript-language-servervtsls の二種類がある。今回は typescript-language-server を使ってみる。以下のコマンドでインストールできる。

npm i -g typescript-language-server typescript

そして init.lua に以下のような設定を書く。

-- 補完を有効にするための設定
vim.cmd[[set completeopt+=menuone,noselect,popup]]
vim.api.nvim_create_autocmd('LspAttach', {
  callback = function(ev)
    local client = vim.lsp.get_client_by_id(ev.data.client_id)
    if client and client:supports_method('textDocument/completion') then
      vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true })
    end
  end,
})

-- TypeScript の Language Server の設定
vim.lsp.config('ts_ls', {
  init_options = { hostInfo = 'neovim' },
  cmd = { 'typescript-language-server', '--stdio' },
  filetypes = {
    'javascript',
    'javascriptreact',
    'javascript.jsx',
    'typescript',
    'typescriptreact',
    'typescript.tsx',
  },
  root_markers = { 'tsconfig.json', 'jsconfig.json', 'package.json', '.git' },
})

-- TypeScript の Language Server を有効に
vim.lsp.enable('ts_ls')

何故か補完を有効にするには vim.api.nvim_create_autocmd が必要らしい。デフォルトで補完有効になってて良い気がするけど、なんでそうなってないんでしょうね。

VS Code ユーザなら以下のように CTRL-Space で補完ウインドウが出るようにしておくと便利だと思う。

vim.keymap.set('i', '<c-space>', function()
  vim.lsp.completion.get()
end)

その他 LSP に関する設定は以下を参照。LSP 関連のキーバインドもここに書いてある。

nvim-lspconfig

世の中の人皆同じような設定を書くので、よく使われる Language Server の共通設定がまとめられた nvim-lspconfig Package が存在する。これを使うのがデファクトらしい。

早速 nvim-lspconfig の使い方を紹介したいところだが、その前に「Plugin」と「Package」という用語について説明する。

「Plugin」と「Package」

どちらも Vim に由来する用語である。「Plugin」は Vim スクリプトファイルをまとめたもので、「Package」は Plugin を含むディレクトリを指す。

Package は ~/.config/nvim/pack/ に以下のようなディレクトリ構成で配置される。

~/.config/nvim/pack/
├─ nvim/
│  ├─ start/
│  │  ├─ start-pkg-1/
│  │  │  └─ plugin/
│  │  │     └─ start-pkg-1.lua
│  │  └─ start-pkg-2/
│  │     └─ plugin/
│  │        └─ start-pkg-2.lua
│  └─ opt/
│     ├─ opt-pkg-1/
│     │  └─ plugin/
│     │     └─ opt-pkg-1.lua
│     └─ opt-pkg-2/
│        └─ plugin/
│           └─ opt-pkg-2.lua
└─ multiple-plugins-pkg/
   └─ start/
      ├─ plugin-1/
      │  └─ multiple-plugins-pkg-plugin-1.lua
      └─ plugin-2/
         └─ multiple-plugins-pkg-plugin-2.lua

pack/nvim/start/* にあるのが「"start" package」と呼ばれるもので、Neovim の起動時に自動で実行される *1pack/nvim/opt/* にあるのが「"opt" package」で、:packadd した時に実行される。ちなみに Package は Plugin を複数持つこともできて、その例が pack/multiple-plugins-pkg である *2。なんだかややこしいが、pack/* にあるものも、pack/*/{start,opt}/* にあるものも Package と呼ぶらしい? とにかく Plugin を含むディレクトリなら Package と呼ぶ世界観に見える。id:mizdra の理解が間違っていたら誰かツッコんでほしい!

nvim-lspconfig のインストールとセットアップ

話を戻すと、nvim-lspconfig は Package である。よってこれをインストールする必要がある。nvim-lspconfig の README にインストール方法がいくつか書かれているので、そのどれかの方法でインストールすれば良い。

ミニマムにやりたいなら git clonevim.pack を使う方法が良い。後者は Neovim の組み込みの Plugin Manager *3 なのだが、まだ実験的な機能で stable では使えない。そこで今回は git clone を使ってインストールしてみる。

git clone https://github.com/neovim/nvim-lspconfig ~/.config/nvim/pack/nvim/start/nvim-lspconfig

これで LSP の設定を以下のように記述できる。

-- 補完を有効にするための設定
vim.cmd[[set completeopt+=menuone,noselect,popup]]
vim.api.nvim_create_autocmd('LspAttach', {
  callback = function(ev)
    local client = vim.lsp.get_client_by_id(ev.data.client_id)
    if client and client:supports_method('textDocument/completion') then
      vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true })
    end
  end,
})

-- TypeScript の Language Server を有効に
vim.lsp.enable('ts_ls')

これだけみると、「nvim-lspconfig から提供される Language Server の設定はどこで読み込んでるんだ?」とびっくりするかも (id:mizdra はびっくりした)。実は vim.lsp.enable('ts_ls') を実行した時に読み込まれている。Package 内の lsp/* から Language Server の設定が自動で探索される挙動となってて、結果として nvim-lspconfig/lsp/ts_ls.lua が読み込まれている。

@css-modules-kit/ts-plugin のセットアップ

@css-modules-kit/ts-plugin は TypeScript Language Service Plugin なので、TypeScript の Language Server に読み込ませる、というステップが必要になる。

まずは @css-modules-kit/ts-plugin をインストールしておく。

npm i -g @css-modules-kit/ts-plugin

そして以下のように init.lua を書く。

-- 補完を有効にするための設定
vim.cmd[[set completeopt+=menuone,noselect,popup]]
vim.api.nvim_create_autocmd('LspAttach', {
  callback = function(ev)
    local client = vim.lsp.get_client_by_id(ev.data.client_id)
    if client and client:supports_method('textDocument/completion') then
      vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true })
    end
  end,
})

-- @css-modules-kit/ts-plugin を使うための設定
local npm_root = vim.trim(vim.fn.system('npm root -g'))
local ts_ls_default = vim.lsp.config.ts_ls
vim.lsp.config('ts_ls', {
  filetypes = vim.list_extend(ts_ls_default.filetypes, { 'css' }),
  init_options = {
    plugins = {
      {
        name = '@css-modules-kit/ts-plugin',
        location = npm_root,
        languages = { 'css' },
      },
    },
  },
})

-- TypeScript の Language Server を有効に
vim.lsp.enable('ts_ls')

やっていることとしては、

  • @css-modules-kit/ts-plugin を plugin として読み込むように
    • init_options.plugins の部分
    • これで *.ts,*.tsx 上で CSS Modules 向けの言語機能が使えるようになる
  • CSS ファイル の Language Server として typescript-language-server が使われるように
    • filetypes = ... の部分
    • これで *.css 上で CSS Modules 向けの言語機能が使えるようになる

filetypes = ... の部分がないと *.css に対する LSP の Request (textDocument/definitiontextDocument/rename) が一切 typescript-language-server に届かず、CSS 上で色々な言語機能が機能しなくなってしまう。*.ts, *.css どちらの LSP Request も受け取れるように、このような設定が必要になる。

これで @css-modules-kit/ts-plugin が Neovim でも動くようになった。めでたしめでたし。

youtu.be

おまけ: vim.lsp.config の挙動

vim.lsp.config('ts_ls', {...}) と書くと、Pacakge 経由で読み込まれた config などといい感じにマージされる仕様となってる。

マージは vim.tbl_deep_extend() で行われる。テーブル (JavaScript のオブジェクトに相当) 同士をディープマージしてくれる。テーブルは再起的に辿ってマージしてくれるけど、配列はたどってくれないので、注意が必要 *4

今回書いた設定で filetypes: vim.list_extend(ts_ls_default.filetypes, { 'css' }) と書いたのはそのため。filetypes: { 'css' } だと typescript-language-server が *.ts の Language Server として使われなくなってしまう。

おまけ: Language Server のデバッグテクニック

Neovim で :checkhealth と打つと実際に読み込まれた Language Server の起動オプションを表示できる。うまく設定できてるのか確かめる時に便利。

:checkhealth を実行した様子

TypeScript Language Service Plugin や typescript-language-server が出力するログを見るには、tsserver.log を出力すると良い。TSS_LOG 環境変数を設定して Neovim を起動すれば良い。

TSS_LOG="-level verbose -file /tmp/tsserver.log" nvim .

*1:https://neovim.io/doc/user/pack.html#_creating-vim-packages でそう呼ばれてる

*2:公式ドキュメントでも https://neovim.io/doc/user/pack.html#_creating-vim-packages でこうした Package を紹介してる。

*3:ところでなんで「Package Manager」と呼ばないのでしょうね?

*4:ちなみに Lua において配列はテーブルの特殊系 (数値をインデックスに持つテーブル) で、厳密には配列もテーブルである。vim.tbl_deep_extend ではテーブルのインデックスを見て、配列っぽいテーブルなら再起的に辿らず、それ以外のテーブルなら辿る挙動になってる。ref: https://github.com/neovim/neovim/blob/6b4ec2264e1d8ba027b85f3883d532c5068be92a/runtime/lua/vim/_core/shared.lua#L552

インストールする VS Code 拡張機能を減らした

かつては VS Code 拡張機能をインストールしまくっていて 65 個あったけど、最近はできるだけインストールしないようにしてる。数が多いとセキュリティリスク高まるので。

今インストールしてるのは以下の 24 個:

alefragnani.bookmarks
anthropic.claude-code
dbaeumer.vscode-eslint
esbenp.prettier-vscode
github.codespaces
github.copilot
github.copilot-chat
github.vscode-pull-request-github
golang.go
hediet.vscode-drawio
intellsmi.comment-translate
mizdra.css-modules-kit-vscode
monokai.theme-monokai-pro-vscode
ms-ceintl.vscode-language-pack-ja
ms-vscode-remote.remote-containers
ms-vsliveshare.vsliveshare
oxc.oxc-vscode
pnp.polacode
redhat.vscode-yaml
rust-lang.rust-analyzer
streetsidesoftware.code-spell-checker
stylelint.vscode-stylelint
vadimcn.vscode-lldb
vitest.explorer

普段は JavaScript 書きまくっているので、それ関連の拡張機能が多め。ここ数年で変わったこととしては、Rust で書かれた JavaScript 向けツール (Oxc, Rolldown, Biome, Turbopack, ...) が増えたために rust-lang.rust-analyzervadimcn.vscode-lldb を使うようになったこと。ツールのコードよく読みにいくので、これがないと困る。Go で書かれたツール (typescript-go, esbuild) もよく遭遇するので、golang.go もインストールしてる。

intellsmi.comment-translate は最近 mizchi さんの記事見てインストールしたけど、めっちゃ便利だった。設定はこんな感じでやってる。source"Copilot" にしているのがポイント。

{
  "commentTranslate.targetLanguage": "ja",
  "commentTranslate.hover.concise": true,
  "commentTranslate.multiLineMerge": true,
  "commentTranslate.source": "Copilot"
}

github.vscode-pull-request-github は色々便利な機能があるのでインストールしてる。選択してる行の GitHub 上での URL がコピーできる機能とか。Control+Command+g に keybind 設定してよく叩いてる。

zenn.dev

以前は inline で git blame を表示するために eamodio.gitlens をインストールしていたけど、最近は VS Code に同等の機能が実装されてる。"git.blame.editorDecoration.enabled": true で有効にできる。eamodio.gitlens はアンインストールして今はこれを使ってる。

たまーに Python や Swift など他の言語のリポジトリを触ることになって、色々追加で拡張機能を入れないといけなくなった時は、VS Code の Profile を新たに作ってそっちにインストールすることで凌いでる。こんな感じで、Settings や Keyboard Shortcuts は Default Profile のものを引き継ぐけど、Extension だけ別にしてる。

Default Profile の拡張機能を引き継ぎつつ、新しい Profile 向けに専用の拡張機能をインストールする...みたいなことはできないので、そこがネックではある。今は新しい Profile を作成したあと、Default Profile にあったものでどうしても必要なものを新しい Profile にもインストールし直してる。面倒ではあるけどまあ何とかなってる。


拡張機能のリストは code --list-extensions で出力できます。皆さんもこの際に整理してみてはいかがでしょうか。

css-modules-kit の内部設計: CSS Modules のパースについて

css-modules-kit は CSS Modules のためのツールセットです。何ができるのか、どんな設計で作られているのかは以下の記事を見てください。

www.mizdra.net

www.mizdra.net

この記事では css-modules-kit の内部設計について紹介してみます。今回は CSS Modules のパースについてです。

3種類のツールと core パッケージについて

css-modules-kit は codegen, ts-plugin, linter-plugin (eslint-plugin or stylelint-plugin) の 3 種類のツールから構成される。それぞれ独立したパッケージになってるのだけど、コア部分は core に切り出されてて、3 種類のツールから依存されてる。

.module.css のパーサー

core には色々なロジックや utility が置かれているのだけど、その中に .module.css のパーサーがある。packages/core/src/parser にそのコードがある。

内部では postcss で AST に変換して、それを走査して必要な情報を集めて返してる。一般的に「パーサー」というと AST を返すものをイメージするけど、それとはちょっと違う。css-modules-kit においては AST から余計な情報を削ぎ落としたデータ構造である CSSModule をパーサーから返してる。

CSSModule の構造は packages/core/src/type.ts で定義されてる。

// https://github.com/mizdra/css-modules-kit/blob/89f11a54af1dc86b344e151b63bc2708486c31bb/packages/core/src/type.ts#L98-L121
export interface CSSModule {
  fileName: string;
  text: string;
  /** `.foo {}` や `@keyframes bar {}` などで定義されるトークンの情報  */
  localTokens: Token[];
  /**
   * `@import './other.module.css';` や `@value val from './other.module.css'` などを使った
   * トークンの import 文の情報。今回の説明は省略。
   */
  tokenImporters: TokenImporter[];
  /** パース時に検出された構文エラーなど。今回の説明は省略。 */
  diagnostics: DiagnosticWithLocation[];
}
export interface Token {
  name: string;
  loc: Location;
  declarationLoc?: Location;
}
export interface Location {
  start: Position;
  end: Position;
}
export interface Position {
  line: number;
  column: number;
  offset: number;
}

「Token」という謎の用語があるけど、これは css-modules-kit の内部用語で、.module.css から export されるアイテムを表してる。クラス名とか @keyframes で定義されたキーフレームの名前とか、@value val: #123; による変数とか。export されるのはクラス名だけではないので、「Token」という用語を導入している。

それを踏まえて型を見てもらうと分かるが、トークンの名前や位置情報、構文エラーなどの情報が入ってる。それ以外の情報、例えばプロパティ に関するものは全く持たない。

独自のデータ構造を返している理由

生の postcss の AST を返さずに、独自のデータ構造を返しているのには色々な理由がある。

理由1: CSS Modules として .tsx 側に export されるもの以外の情報が不要だから

単に不要なものは持たないほうが、シンプルになって codegen などから扱いやすい。それはそう。

理由2: postcss に依存しないようにしたかったから

postcss に非依存にすることで、postcss 以外の CSS パーサーに後から差し替え可能にしたかった。

というのも今主要な CSS パーサーには 「postcss」「csstree」「lightningcss」の 3 つがある。css-modules-kit を最初に作るときにどれを使うか悩んだのだけど、結局 postcss を採用した。postcss が優れているからとかではなくて、単に id:mizdra が以前作った happy-css-modules というツールの内部で postcss で CSS Modules をパースしていて、そのコードを参考に実装できたから。

postcss はそこそこ枯れていて良いのだけど、まあまあ微妙なところがある。例えばパフォーマンスや後方互換性を理由にプロパティの value やセレクター部分のパースが本体ではサポートされてなくて、別のライブラリに頼らないといけない。

ここ最近のサプライチェーン攻撃の流行を考えるとあんまり依存関係増やしたくはなくて、困ってる。csstree なら value 部分もセレクターもそれだけでパースできるみたいなので、その点を考えると csstree のほうが良いなーと思ってる。

lightningcss は Rust 製で速くて良いと思うけど、パフォーマンスを理由にパーサーの API が Node.js 向けに提供されてない。Rust 向けにだけ提供されてる。もし css-modules-kit で使うなら、AST にして CSSModule 型のデータを生成するところまでを Rust で書いて、それを Node.js に転送する、みたいな実装をしないといけない。やったらできるけど面倒だし、Rust <=> Node.js 間のデータ転送にコストが掛かるので、速くなるのかよくわからない。

postcss から csstree なり lightningcss なり乗り換えるとしても、ちゃんと互換性を維持したまま乗り換えられるかは正直不透明だなと思う。css-modules-kit が欲しい情報が、AST に含まれていないと安全に移行できないと思う。クラス名やキーフレーム名の位置情報を正確に取得できるのかとかね。postcss も最初は一部情報が欠けてて、id:mizdra が PR 送って直していたりしたくらいなので、csstree やlightningcss もきっと同じようなことになるだろう。とにかく面倒な予感がする。

とまあパーサーを差し替えたい気持ちはあるけど、色々な理由があって進んでない。けど将来的に差し替えられるように、データ構造は工夫してる。

typescript-go が来たら、それを受けて css-modules-kit も Go にしましょうみたいな機運が出てくるかもなとは思ってて、そうしたら lightningcss なり検討するかも。まあまだ先の話だなと思う。

普段使いできる保護レイヤー「restricted shell」の紹介

これは はてなエンジニア Advent Calendar 2025 1日目の記事です。


はてなでフロントエンドエキスパートをしている id:mizdra です。普段は JavaScript を書いてて、趣味で色々なツールを作ってます。

ところで最近、npmjs.com へのサプライチェーン攻撃が話題ですね。以前から npmjs.com ではサプライチェーン攻撃が発生していましたが、今年は規模が大きいものが頻繁に発生しています。

マルウェアをインストールしない・実行しないことが一番ではあるのですが、昨今の npmjs.com への攻撃の様子を踏まえると、そうも言っていられません。そのため、何個も保護レイヤーを設ける「多層防御」という考えが重要です。マルウェアをインストールしない・実行しないための対策とは別に、仮にマルウェアが実行されても、その影響を抑える対策があると良いでしょう。

最も手軽な対策は、ファイルシステム上に機密情報を置かないことです。具体的には、以下のようなものです。

どれも比較的無理なくできるものなので、ぜひやってみると良いでしょう。

一方で、Sandbox を利用するという対策もあります。

  • Development Container (a.k.a. Dev Container) を使う
    • Docker コンテナの中で開発する
  • ブラウザベースの開発環境を使う
    • GitHub Codespaces, Stackblitz, vscode.dev など

Sandbox 内には、そのリポジトリのファイルしか mount されません。そのため、マルウェアの影響をそのリポジトリの中だけに制限できます *1

Sandbox を使った対策の欠点

Sandbox は便利な反面、導入が面倒な技術です。例えば、ホスト側からポートにアクセスできるよう明示的な許可が必要です。ボリュームの mount の設定も必要です。Development Container の場合は、中でシェルを起動して作業できるようになってますから、そのシェルから使う ツール類の設定なども必要でしょう。場合によっては、アプリケーション側のコードの改修も必要かもしれません。

Sandbox は導入と運用にそれなりのコストが掛かってしまいます。マルウェアに対する良い対策だと分かっていても、中々導入は難しいのです。

「restricted shell」の紹介

とはいえ、やはり Sandbox 相当のものは欲しいです。そこで Sandbox と同じような効果が得られて、もっと手軽に使えるものを作ってみました。それが「restricted shell」です。はじめに言っておきますが、macOS 限定、かつ ghq でリポジトリを管理しているユーザ限定です。

restricted shell が動作する様子。

リポジトリを cd すると、restricted shell という制限の掛かったサブシェルが起動します。その中では今いるリポジトリから他のリポジトリのファイルを読み取れなくなります。

他のリポジトリのファイルの読み取りが禁止されるだけで、それ以外の制限はありません。そのため、以下のようなことができます。

  • npm install できる
    • ネットワークアクセスは自由
  • ^R でシェルの履歴が見れる
    • ホームディレクトリ配下のファイルは読み取り制限なし
  • プロセスも起動し放題

Sandbox というよりは、非常に緩い保護レイヤーのようなものです。普段使いできるように、どのリポジトリでも使えるように、意図的に緩い制限にしています。

とはいえ制限を解除して作業したいこともあるでしょう。そこで脱出ハッチとして exit で restricted shell を終了できるようにしてます。また、rsh で再度 restricted shell を起動することも可能です。

exit で restricted shell を終了、rsh で再度起動する様子。

あとサブシェル (restricted shell) から抜ける時に、サブシェルの履歴とカレントディレクトリを親シェルに引き継ぐようにしてます。そのおかげで、restricted shell を終了してもシームレスに作業を継続できます。

普段使いするものなので、使い勝手には拘って設計してます。

仕組み

Apple Seatbelt という技術を使っています。macOS に搭載されているセキュリティ保護機能で、ルールベースでプロセスに制限をかけられます。

yuzuhara.hatenablog.jp

blog.syum.ai

今回はこの Apple Seatbelt で、特定のディレクトリへのアクセスが制限されたサブシェルを立ち上げています。Apple Seatbelt による制限は、親プロセスから子プロセスへと継承されます。そのため、サブシェル内から起動されるあらゆるプロセス (ls .. など) が制限を継承し、ls .. などがエラーでコケるようになります。

実装

実装は Apple Seatbelt の設定ファイル、.zshrc、プロンプトの設定 (starship.toml) に分かれています。まず Apple Seatbelt の設定ファイルから。

;; .config/sandbox/restricted-shell.sb
(version 1)
(allow default)

(deny file-read*
  (subpath (param "GHQ_ROOT"))
)

(allow file-read*
  (subpath (param "REPOSITORY"))
)

;; starship がリポジトリの親ディレクトリのメタデータを読みに行くようなので、例外的に許可。
;; 本当はもうちょっと厳しくしたい。
(allow file-read-metadata
  (subpath (param "GHQ_ROOT"))
)

REPOSITORY が今 cd してるリポジトリのパスを表すパラメータで、GHQ_ROOT が ghq のルートディレクトリを表すパラメータです。一括で GHQ_ROOT の読み取りを禁止し、その上で REPOSITORY を例外的に許可してます。

.zshrc は以下の通り。

PREV_REPOSITORY=""
function start-restricted-shell-if-needed() {
  local ghq_root="$(ghq root)"
  local repository=""
  [[ "$PWD" =~ ^$ghq_root/[^/]+/[^/]+/[^/]+ ]] && repository="$MATCH"
  
  if [[ -z "$repository" ]]; then
    PREV_REPOSITORY=""
  elif [[ "$RESTRICTED_SHELL" != "1" && "$repository" != "$PREV_REPOSITORY" ]]; then
    PREV_REPOSITORY="$repository"
    echo "info: Start a restricted shell. (To exit, type 'exit'.)"
    RESTRICTED_SHELL=1 sandbox-exec -f ~/.config/sandbox/restricted-shell.sb -D GHQ_ROOT="$ghq_root" -D REPOSITORY="$repository" "$SHELL"
    fc -R
    cd "$(</tmp/rsh-pwd)"
  fi
}
function restricted-shell-exit() {
  if [[ "$RESTRICTED_SHELL" == "1" ]]; then
    pwd > /tmp/rsh-pwd
  fi
}
autoload -Uz add-zsh-hook
add-zsh-hook precmd start-restricted-shell-if-needed
add-zsh-hook zshexit restricted-shell-exit
alias rsh='PREV_REPOSITORY="" start-restricted-shell-if-needed'

色々書いてありますが、重要なのは add-zsh-hook precmd ... の部分と、start-restricted-shell-if-needed 関数の中身です。zsh の precmd hook を使い、プロンプトが表示される際にリポジトリのディレクトリかどうかチェックしてます。そして、リポジトリのディレクトリなら RESTRICTED_SHELL=1 sandbox-exec -f ~/.config/sandbox/restricted-shell.sb ... $SHELL でサブシェルを起動してます。

コードの他の部分は、 サブシェルの履歴とカレントディレクトリの引き継ぎ、rsh コマンドに関するものです。色々な機能がありますが、これ全部で 25 行で済んでます。

最後に、プロンプトの設定 (starship.toml) です。RESTRICTED_SHELL=1 なら鍵の絵文字を出してるだけです。これの導入は必須ではないですが、あると便利です。

format = """
$time\
$cmd_duration\
$jobs\
$status\
$username\
$hostname\
$localip\
$shlvl\
$directory\
$git_branch\
$git_state\
$git_metrics\
$git_status\
$custom\
$sudo\
$shell\
$character"""

[custom.restricted_shell]
command = "echo 🔒"
when = ''' test "$RESTRICTED_SHELL" = 1 '''

starship を使ってない人は、なんか上手いことやってください。

実際使ってみてどうか

まだ使い始めて 1 週間くらいですが、趣味でも業務でも苦なく使えています。普段使いできる保護レイヤーとしての役目は果たせてそうです。

ちなみに open . で Finder でディレクトリを開くのも、ちゃんと動きます。当初は Finder が Apple Seatbelt の制限を引き継ぐせいで上手く動かないのでは、と思っていたのですが、制限を引き継がずに起動されるようです。open で起動されたアプリケーションは、シェルではなく launchd が親プロセスになり、その結果 Apple Seatbelt による保護が効かないようです。保護を回避できて安全性が下がってしまってますが、普段使いする分にはこちらのほうが都合が良いとも言えます。

ちょっとしたトラブルもありました。VS Code をシェルから起動する code コマンドが上手く動作しないのです。具体的には、code . と打っても、カレントディレクトリを workspace として開いてくれません。

カレントディレクトリが workspace として開かれない様子。

code コマンドの内部では open -n で VS Code を起動するのですが、その -n オプション (既にアプリケーションが起動していても新しいインスタンスを起動するオプション) の挙動が Apple Seatbelt の有無で変わるようです。

どうやら Apple Seatbelt では (allow default) していても、強制的に課される制限があるようです。id:mizdra が確認した限りでは、ps コマンドや top コマンドの実行が制限されていました。

;; ~/.config/sandbox/allow-default.sb
(version 1)
(allow default)
$ sandbox-exec -f ~/.config/sandbox/allow-default.sb ps
sandbox-exec: execvp() of 'ps' failed: Operation not permitted
$ sandbox-exec -f ~/.config/sandbox/allow-default.sb top
sandbox-exec: execvp() of 'top' failed: Operation not permitted

恐らく、他のプロセスの情報の取得が制限されているのだと思います。そのため open -n のようなコマンドが動かないのでしょう。

色々調べたところ、code コマンドを使わずに直接 open コマンドで VS Code を開けば、問題を回避できるようでした。僕は以下のような alias を貼るようにしてます。

if [[ "$RESTRICTED_SHELL" == "1" ]]; then
  alias code='open -a "Visual Studio Code"'
fi

とはいえこれも完璧ではありません。code --wait のようなオプションが動作しなくなってしまいます *2EDITOR="code --wait" を使ってる人は EDITOR="vim" に切り替えたりと、追加の対策を導入する必要があります。

code . が動かないのはちょっと面倒ですが、回避策もあるのでまあ...という感じですね。

面白いところ

ちょっと面白いのは、restricted-shell.sb を編集することで自由に保護レベルをカスタマイズできるところです。.zshrcrestricted-shell.sb の書き換えを禁止したり、opendocker の起動を禁止したり。好みに応じて変えられます。

やろうと思えばうんと厳しくもできます。Apple Seatbelt では (deny default) と書くと、デフォルトで全てが禁止され、allowlist のような形でルールを記述するモードになります。そうすれば、Sandbox のような厳しい制限も課せると思います。まあ普段使いするには厳しいでしょうが... それやるくらいなら、普通に Development Container 使ったら良いと思います。

macOS 以外への移植

Apple Seatbelt 相当のものがあれば、他の OS へも移植できるはずです。

僕は詳しくないですが、Linux だと AppArmor というものが使えるそうです。後日同僚の id:Windymelt さんが Linux 版の記事を書いて下さるようなので、それを待って下さい。

それ以外の OS についても、興味がある人がいたら移植してみてください。

大事なこと

もちろん、restricted shell は完璧ではありません。restricted shell は (少なくとも今回紹介した sb ファイルでは) ファイルアクセスくらいしか制限してません。マルウェアからネットワークリクエストも、プロセスの起動も仕放題です。open コマンドを使えば保護を回避できます。マルウェアが本気を出せば、きっと色々なことができてしまうでしょう。Sandbox と呼ぶにはお粗末なものです。

セキュリティ対策に銀の弾丸はありません。様々な対策を幾重にも導入し、地道にリスクを下げていくべきです。例えば、以下のようなことをやると良いでしょう。

心構えや勉強も大事です。

  • 信頼できる package をインストールするよう心がける
  • 脆弱性や攻撃の情報に目を光らせ、発見されたらすぐに対応する
  • セキュリティに関する書籍を読む

restricted shell はあくまで保護レイヤーの 1 つです。基本的なセキュリティ対策を疎かにしないよう気をつけましょう。

おわりに

この記事では「restricted shell」を紹介しました。本当にそのような緩い保護レイヤーで役に立つのか、疑問に感じる人も居るかもしれません。一方で、普段使いできる追加の保護レイヤーと考えると、面白い仕組みではないかと思ってます。

僕自身まだ1週間しか使ってないので、本当に使えるものなのか正直よく分かってません。しばらく使ってみて、使用感を確かめたいと思ってます。そのような感じですが、もし興味あれば使ったり、参考にしてみてください。

はてなエンジニア Advent Calendar 2025 2日目の担当は id:pokutuna さんです。

合わせて読みたい

www.mizdra.net

*1:もちろんリポジトリ内に GitHub の Token があって、それにすべてのリポジトリの読み取り権限が付いていたりしたら、その限りではないですが...

*2:--wait のハンドリングが code コマンドの中で行われているためです

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.