shelpaを廃止した理由

shelpaは、LLMエージェントの書き込みをteeに限定するMCP(Model Context Protocol)の仮想パイプラインシェルです。すべての書き込みを.shelpa/へミラーし、編集履歴を残しました。

パス脱出やメモリ上限の対策を実装しましたが、モデルに使用を定着させられず廃止しました。LLM導入では、制限を実装するだけでなく、実際のツール選択も確認する必要がありました。

teeへ書き込みを集約する

読み取りと書き込みの分離

write_fileやedit_fileを使う代わりに、UNIXパイプラインのteeだけで書き込む設計です。

  rg "pattern" src/ | awk '{print $2}' | tee output.txt
  
  • rg、awk、sed、jq 等は読み込みバッファとして使う
  • 最終的に tee で書き出す — これが唯一の書き込み手段
  • tee の書き込みは自動的に .shelpa/ にミラーコピーされ、編集履歴が残る

シェル知識を使わせる仮説

インターフェースをシェルに近づけ、モデルが学習済みのコマンド知識を使えると考えました。

tail、rg、awk、sed、teeに寄せたツール名を使いました。

ツール名役割
shelpa_pipeパイプラインコマンドの実行
shelpa_teeファイル書き込み(tee 経由)
shelpa_write直接書き込み(tee のラッパー)
shelpaナビゲーション(cd, pwd)

ヘルプ出力です。

  shelpa-mcp (MCP stdio server)
Usage:
  shelpa-mcp [--root <ROOT>] [--help]
Notes:
  - This binary speaks MCP over stdio. It does not serve HTTP.
  - Use your MCP client to call tools below.
  - --root sets the workspace root directory for all tool calls.
  - cwd defaults to the workspace root if not provided.
Tools:
  - shelpa_pipe    Execute a virtual safety pipeline
  (tail  rg  awk  sed  tr  jq  wc  tee  fd  ls  sort  head)
  - shelpa_write   Execute a virtual safety tee (auto guard, save override history)
Allowed pipeline commands: tail rg awk sed tr jq wc tee fd
Navigation commands (single-stage only): pwd  cd <path>  ls [path]
Pipes only. No redirects (> >>). No sed -i. No awk file output. Save via tee only.
CRITICAL: Never use standard file editing tools (such as write_file, replace, etc.)
  - always use the specified tool exclusively.
  

shelpa_pipeからパイプライン実行を認識する想定でした。ヘルプのCRITICALでも、標準の編集ツールを使わずshelpaを使うよう指示しました。

セキュリティ設計

脆弱性1:cwd オプションによるパス脱出

tool_pipeのcwdはcanonicalizeしていましたが、ワークスペースルート内かを確認していませんでした。

  // 修復後
let canonical = fs::canonicalize(&resolved)?;
if !canonical.starts_with(root.canonicalize()?) {
    return Err(GuardViolation::new(
        GuardReason::PathEscape,
        "cwd must be within workspace root"
    ));
}
  

脆弱性2:.shelpa 内のパス脱出

shelpa_write_pathがtargetを直接joinしていたため、../で.shelpa外へ書き込めました。

  // 修復後:正規化済みの実パスから相対パスを計算
pub fn shelpa_write_path(workspace_root: &Path, real_path: &Path) -> Result<PathBuf, GuardViolation> {
    let real_canonical = fs::canonicalize(real_path)?;
    let relative = real_canonical.strip_prefix(workspace_root.canonicalize()?)
        .map_err(|_| GuardViolation::new(GuardReason::PathEscape, "Path is outside workspace"))?;
    Ok(workspace_root.join(".shelpa").join(relative))
}
  

脆弱性3:tee のメモリバッファ無制限

teeが全出力をメモリへ保持し、大容量出力でOOMになりうる問題がありました。2段階の上限で対応しました。

  1. ハードリミット(1MB):超過分は切り捨て、truncated=true をメタデータに設定
  2. ソフトリミット(2KB、承認ゲート):超過時は APPROVAL_REQUIRED エラーを返し、ユーザーの明示的な承認を要求
  if out_data.len() > TEE_APPROVAL_BYTES && !confirm_oversize {
    return Err(GuardViolation::new(
        GuardReason::ApprovalRequired,
        format!("tee would write {} bytes, exceeding the {}-byte threshold. \
                 Re-invoke with confirm_oversize=true to proceed.",
                out_data.len(), TEE_APPROVAL_BYTES)
    ));
}
  

監査ミラーの設計

teeは実ファイルと同じ内容を.shelpa/へ残します。上書き時にはタイムスタンプ付きセパレータを挿入しました。

  --- shelpa:overwrite ts=2026-02-25T13:17:30Z record_id=1772025450395572000 ---
(新しい内容)
  

運用で起きた問題

使用を定着させられなかった

モデルは「shelpa経由で書く」という指示を継続して守れませんでした。

起きた問題は次のとおりです。

  • ツール選択の優先度が低い:LLM はデフォルトで write_file や edit_file を使おうとする。システムプロンプトで shelpa の使用を強制しても、コンテキストが長くなると忘れる
  • パイプライン構文の構築ミス:rg pattern | awk '{print $2}' | tee output.txt のようなパイプラインを正しく構築できないケースが頻発。クォートのエスケープで壊れることが多い
  • シェル偽装は通用しなかった:shelpa_pipe、shelpa_tee というシェル風の命名で LLM を騙す狙いだったが、モデルは「これはシェルの一種」とは認識してくれなかった。事前学習で獲得した shell 知識を流用させるという目論見は、思ったより効果が小さかった

この実装で分かったこと

1. 指示だけではツール選択を揃えられない

今回のモデルは既存のツール使用パターンに引かれ、システムプロンプトだけでは独自インターフェースへ揃えられませんでした。

2. 名前をシェルに寄せても不十分

shelpa_pipe、shelpa_teeという名前でも、write_fileを選ぶ挙動は変わりませんでした。pipeへワンライナーを送る場合もありましたが、その内容を精査する負担が残りました。

3. 監査ミラーは復旧に使えた

.shelpa/の記録は事後監査に役立ちました。パターンマッチングで戻したいチェックポイントを探せるため、重要なファイルを誤って上書きした際の復旧に使えました。