Skip to main content
Glama
Asaad-Suliman

safe-mcp-suite

safe-mcp-suite

2つのMCPサーバー — ターミナルとファイルオーガナイザー — どちらも迂回できない単一の安全コアを共有しています。

Python 3.12+ License: MIT CI MCP


これは何か

GitHubでシェルコマンドを実行するMCPサーバーを検索すると、同じファイルが何度も見つかります。@mcp.tool()で装飾されたツール、subprocess.run(command, shell=True)の呼び出し、そして結果をそのままモデルに返す。ファイルシステム系も同じ形です — os.renameをループで回し、場合によってはtry/exceptで囲む。

それらは動作します。それが問題です。モデルが誰も予期しなかった何かを生成するまで動作し、その頃には削除はすでに起こっており、何が実行されたのか、なぜ許可されたのかの記録はありません。

このリポジトリは、安全性を後付けのラッパーではなく設計そのものとした、これら2つのサーバーの再構築です。単一のsafety/パッケージが、あなたに害を及ぼす可能性のあるすべての決定を所有します — 何が許可されるか、境界はどこか、何が記録されるか、何が隠されるか。その下にある2つのサーバーは配線です。どちらもコアを越えて到達できません。どちらも独自のルールを実装していないからです。

その背後にある賭け: 決定論的ポリシーはモデルの判断よりも信頼できる。 プロンプトはモデルを注意深くなくさせることができます。しかし、パス包含チェックにTrueを返させることはできません。


Related MCP server: Safe Terminal MCP Server

クイックスタート

Python 3.12+とuvが必要です。

git clone https://github.com/Asaad-Suliman/safe-mcp-suite.git safe-mcp-suite
cd safe-mcp-suite
uv sync
./scripts/make_demo_sandbox.sh

その最後のスクリプトはsandbox/terminal、sandbox/files、state/をシードし、両方のサーバーが合法的に動作する場所を確保します。その後、好きな方を起動します:

uv run safe-mcp terminal --config policy.example.toml
uv run safe-mcp files --config policy.example.toml

--configフラグについて

それはオプションではなく、フォールバックもありません。暗黙の./policy.tomlの検索も、.envの読み込みも、あなたのホームディレクトリを静かに指すデフォルトルートもありません。--configもSAFE_MCP_POLICY_FILEも設定されていない場合、サーバーは理由を表示して終了します。

これは意図的であり、セットアップの中で最も意見が強い点です。静かに便利な場所をデフォルトにするサンドボックスは、いつか高価な場所をデフォルトにするでしょう。起動を拒否することは、可能な限り最も安価な失敗です。

リポジトリには2つのポリシーファイルが同梱されており、それらは交換できません:

ファイル

内容

起動しますか?

policy.example.toml

動作する例、./sandboxをルートとする

はい — 今すぐ実行

policy.toml

ルートがコメントアウトされた注釈付きテンプレート

いいえ、意図的に

policy.tomlは、jail_rootとworkspace_rootを自分で記入するまで起動を拒否します。その拒否は機能であり、それを維持する回帰テストがあります。コピーして編集し、準備ができたら実際のディレクトリを指定してください。

ルートは、必要に応じて環境から取得することもできます:

SAFE_MCP_JAIL_ROOT=/path/to/jail
SAFE_MCP_WORKSPACE_ROOT=/path/to/workspace

MCPクライアントへの登録

{
  "mcpServers": {
    "safe-mcp terminal": {
      "command": "uv",
      "args": ["run", "safe-mcp", "terminal"],
      "env": {
        "SAFE_MCP_POLICY_FILE": "/srv/safe-mcp/policy.example.toml",
        "SAFE_MCP_JAIL_ROOT": "/srv/safe-mcp/sandbox"
      }
    },
    "safe-mcp files": {
      "command": "uv",
      "args": ["run", "safe-mcp", "files"],
      "env": {
        "SAFE_MCP_POLICY_FILE": "/srv/safe-mcp/policy.example.toml",
        "SAFE_MCP_WORKSPACE_ROOT": "/srv/safe-mcp/inbox"
      }
    }
  }
}

デモ

以下はすべて実際にキャプチャされた出力です。ここにあるものは手書きでも、トリミングでも、後から装飾されたものでもありません — これらは、policy.example.tomlを使用して./scripts/make_demo_sandbox.shからシードされたサンドボックスに対して実行された、両方のサーバーと通信するライブMCPクライアントが返した実際のOperationResultエンベロープです。

それぞれのcodeフィールドを読んでください。その分類法が要点です: 成功か拒否か、すべての結果が同じ形で返されます。

ターミナルサーバー

許可されたコマンド:

>>> tool: run_command  args: {"command": "cat notes.txt"}
{
  "action": "run_command",
  "code": "OK",
  "detail": {
    "exit_code": 0,
    "stderr": "",
    "stdout": "demo file\n"
  },
  "duration_ms": 1,
  "ok": true,
  "reason": "'cat' is allowed"
}

拒否されたコマンド:

>>> tool: run_command  args: {"command": "rm -rf /"}
{
  "action": "run_command",
  "code": "POLICY_DENIED",
  "detail": {},
  "duration_ms": 0,
  "ok": false,
  "reason": "'rm' is on the denylist"
}

拒否がそうでないものに注意してください: トレースバック、発生した例外、モデルが推測しなければならない文字列ではありません。理由が添付された型付きコードです。

ファイルサーバー

このシーケンスは、通常のファイル、インストーラー、ドットファイル、シンボリックリンクを含むシードされたワークスペースに対して実行されます — ポリシーが異なる扱いをする各要素が1つずつあります。

plan_organizeが移動を提案し、スキップを列挙:

>>> tool: plan_organize  args: {}
{
  "action": "plan_organize",
  "code": "OK",
  "detail": {
    "created": "2026-08-07T16:49:00.048899+00:00",
    "move_count": 2,
    "moves": [
      {
        "category": "Images",
        "dest": "Images/photo.png",
        "size": 41,
        "src": "photo.png"
      },
      {
        "category": "Documents",
        "dest": "Documents/report.pdf",
        "size": 16,
        "src": "report.pdf"
      }
    ],
    "plan_id": "7dcf2cff-399d-4af5-bb9a-a4131e1d5288",
    "skip_count": 3,
    "skips": [
      {
        "code": "NEEDS_EXPLICIT_REQUEST",
        "name": ".bashrc",
        "reason": "dotfiles are configuration, not clutter to be filed",
        "rule": "organize"
      },
      {
        "code": "POLICY_DENIED",
        "name": "link.pdf",
        "reason": "'organize' is not permitted by any rule (deny by default)",
        "rule": null
      },
      {
        "code": "NEEDS_EXPLICIT_REQUEST",
        "name": "setup.exe",
        "reason": "installers, executables and application folders are left where the user put them",
        "rule": "organize"
      }
    ],
    "truncated": false
  },
  "duration_ms": 0,
  "ok": true,
  "reason": "proposed 2 move(s), skipped 3"
}

apply_planが同じプランを実行:

>>> tool: apply_plan  args: {"plan_id": "7dcf2cff-399d-4af5-bb9a-a4131e1d5288"}
{
  "action": "apply_plan",
  "code": "OK",
  "detail": {
    "moved": 2,
    "moves": [
      {
        "dest": "Images/photo.png",
        "src": "photo.png"
      },
      {
        "dest": "Documents/report.pdf",
        "src": "report.pdf"
      }
    ],
    "plan_id": "7dcf2cff-399d-4af5-bb9a-a4131e1d5288",
    "planned": 2
  },
  "duration_ms": 2,
  "ok": true,
  "reason": "moved 2 file(s)"
}

次は興味深いペアです。同じツール、2つの名前付きターゲット、2つの異なる答え。

move_fileがPROTECTIONによって拒否 — 上でplan_organizeがスキップした同じシンボリックリンクを明示的に指定:

>>> tool: move_file  args: {"src": "link.pdf", "dest": "Documents/link.pdf"}
{
  "action": "move_file",
  "code": "POLICY_DENIED",
  "detail": {},
  "duration_ms": 0,
  "ok": false,
  "reason": "'organize' is not permitted by any rule (deny by default)"
}

move_fileがNEEDS_EXPLICIT_REQUESTで延期されたインストーラーに対して成功、今度は明示的に指定:

>>> tool: move_file  args: {"src": "setup.exe", "dest": "Documents/setup.exe"}
{
  "action": "move_file",
  "code": "OK",
  "detail": {
    "dest": "Documents/setup.exe",
    "moved": 1,
    "src": "setup.exe"
  },
  "duration_ms": 0,
  "ok": true,
  "reason": "moved setup.exe"
}

プランナーは両方を拒否しました。直接尋ねられたムーバーは一方を拒否し、もう一方を実行しました。その違いは矛盾ではありません — それは2層モデルであり、以下で独自のセクションを設けます。


システム設計

サーバーは配線であり、ポリシーではない

どちらのサーバーファイルにも安全ルールは含まれていません。それらはすべてsafety/にあり、両方のサーバーは同じ関数に到達して同じ答えを得ます。

flowchart TD
    A["MCP client"] --> B["terminal server"]
    A --> C["files server"]
    B --> D["safety/policy.py<br/>allow or deny"]
    C --> D
    D --> E["safety/paths.py<br/>PathJail containment"]
    E --> F["execute or move"]
    F --> G["safety/redact.py<br/>secrets out, then truncate"]
    G --> H["safety/audit.py<br/>append-only JSONL"]
    H --> I["OperationResult"]
    I --> A

これは自己目的のための整理整頓ではありません。セキュリティ修正が正確に1か所に配置されることを意味し、このリポジトリを監査するレビュアーがsafety/を読めば完了することを意味します。サーバーモジュールに隠れて、最初の実装と静かに同期がずれる2番目の実装はありません。

リクエストが実際に流れる方法

  1. 解析。 コマンド文字列はargvリストに分割されます。シェルのメタ文字は、何かを解釈する前にここで拒否されます — 引用符内も含みます。最後の部分は意図的に保守的であり、文書化された上限であり、見落としではありません。

  2. 評価。 ポリシーエンジンは許可または拒否を返します。拒否は常に勝ちます。明示的に許可されていないものは拒否されるため、空のポリシーはオープンなサーバーではなく役に立たないサーバーになります。

  3. 包含。 すべてのパスは解決され、jailルートに対してチェックされます。シンボリックリンクはリンクとして検査され、決して通過しません。

  4. 実行。 subprocess.runをshell=False、正確にPATH、HOME、LANGのスクラブされた環境、タイムアウト、出力キャップ付きで実行します。または、ファイル側では、ジャーナルに記録された単一のガード付き移動。

  5. 編集、そして切り詰め。 常にその順序で。

  6. 記録。 監査ログに追記します。その書き込みが失敗した場合、操作も失敗します。

盗む価値のある6つの決定

エラーはデータであり、例外ではありません。 すべての結果は、型付きのResultCodeを持つOperationResultです。トレースバックがクライアントに到達することはありません。スタックトレースを受け取ったモデルはそれを回避しようとします。POLICY_DENIEDを受け取ったモデルは、有用で曖昧さのない何かを伝えられています。

拒否は常に勝ちます。 許可ルールと拒否ルールは重み付けも順序付けもされず、タイブレークはありません。拒否が一致した場合、答えはノーです。引数ルールはトークンを順序に依存せずに一致させるため、フラグを並べ替えてもバイパスにはなりません。

明示的なルートなしには何も起動しません。 クイックスタートで説明しましたが、設計リストにも含めるべきです。なぜなら「賢明なデフォルト」がほとんどのサンドボックスが最初の脱出を獲得する方法だからです。

監査ログはあなたを止めることができます。 デフォルトではフェイルクローズ: ログを書き込めない場合、操作は発生しません。SAFE_MCP_AUDIT_FAIL_MODEでフェイルオープンに切り替えることができますが、それは誰かが見える設定ファイルで意図的に行う決定です。

切り詰めの前に編集。 この2つを逆にすると、64KBの出力キャップが秘密を半分にスライスし、最初の断片を発行し、パターンに一致しません。これは、リークのカテゴリ全体を閉じる2行の順序の選択です。

エントロピーベースの秘密検出はデフォルトでオフです。 ハッシュ、UUID、base64ペイロードで常に発火します。狼が来たと叫ぶ編集者は、自分のユーザーによって無効にされます。それは、事前に限界を認めるものよりも悪いです。

PROTECTION vs RESTRAINT

スキップルールは2つの層に分かれており、すべてのルールはどちらに属するかを宣言します。

PROTECTIONは、例外なくすべてのツールによって強制されます。その一部だけが宣言されたルールです — unsafe-nameはファイル名の制御文字をカバーします。残りは構造的です: パスエスケープはsafety/paths.pyの包含チェックに失敗し、占有された宛先はservers/files/apply.pyのlstatチェックに失敗し、シンボリックリンクは許可ルールにまったく一致せず、デフォルト拒否にフォールスルーします — これはevaluate_layeredが意図的にPROTECTIONとして分類します。下の注を参照してください。いずれにせよ、フラグもオーバーライドも「自分が何をしているかわかっている」という議論もありません。これらは不変条件です。

RESTRAINTはplan_organizeによってのみ強制されます。インストーラー、アプリケーションフォルダー、システムファイル、隠しファイル、ディレクトリ。これらは危険ではありません — 自動分類器があなたの代わりに推測すべきではないものです。プランナーはそれらをNEEDS_EXPLICIT_REQUESTとして報告し、次に進みます。

その結果が上記のデモペアです。move_fileはあなたが自分で指定したインストーラーを移動します。それを拒否することは安全性ではなくパターナリズムだからです。どれだけ明示的に要求してもシンボリックリンクは移動しません。それは包含だからです。

明確にすべきニュアンスが1つあります: シンボリックリンクの拒否理由は、 "'organize' is not permitted by any rule (deny by default)"と読み取られ、 「シンボリックリンク」に言及するものではありません。シンボリックリンクはどちらの層の許可ルールにも一致しないため、デフォルト拒否にフォールスルーします — そしてsafety.policy.evaluate_layeredは、ポリシーが語彙を持たない何かの最も厳格な解釈として、一致しないデフォルト拒否フォールスルーを意図的にPROTECTIONとして分類します。一般的な表現は弱い保証ではありません。上記のmove_fileがシンボリックリンクを直接指定していることが証明です。

どこでも繰り返す実装の詳細: PROTECTIONセットは、完全なルールセットをフィルタリングして導出され、独自のリストとして組み立てられることはありません。手動で維持された2つのリストはドリフトし、ここでのドリフトの障害モードは、保護ルールが静かに失われることです。フィルターは忘れることができません。

テストについて

661のテスト、すべて密閉されています。実際の状態を書き込むテストはありません: 実行されるものはすべてtmp_pathに対して行われ、同梱のpolicy.tomlとpolicy.example.tomlを読み取る2つのスイートは、まずそれらをtmp_pathにコピーします。実際の監査ログやワークスペースに触れることはありません。CIは、すべてのプッシュとプルリクエストでスイートとgitleaksスキャンを実行します。

その数は、テストを追加した瞬間に古くなります。重要な特性は分離であり、数ではありません。


保証

各行は、それを強制するモジュールと関数を指定します。ここでの主張が、開くことができるコードによって裏付けられていない場合、テーブルにあるべきではありません。

保証

強制方法

両サーバーでデフォルト拒否

safety/policy.py evaluate() — 一致するルールがなければ拒否

一致する拒否は常に一致する許可に勝る

safety/policy.py evaluate() はすべての一致を走査し、拒否は短絡する

ターミナルサーバーにシェルはない

servers/terminal/execute.py — subprocess.run(argv, shell=False)

シェルのメタ文字はポリシーチェックの前に拒否される

servers/terminal/parse.py + safety/patterns.py (; | & < > `` $()

コマンドは1つのディレクトリに閉じ込められる

safety/paths.py PathJail.resolve、cwd でチェック

ファイル移動は1つのワークスペースに閉じ込められ、最終コンポーネントではシンボリックリンク安全

safety/paths.py PathJail.resolve_for_write — 親のみを解決し、書き込む名前のリンクは決して追わない

2層のファイルポリシー: 安全性の不変条件 vs 推測回避

safety/policy.py PROTECTION / RESTRAINT、evaluate_layered()

宛先が黙って上書きされることは決してない

servers/files/apply.py move_one() — すべての rename の前に lstat でチェック

削除ツールは存在しない。ガード付きであっても

servers/files/server.py — 6つのツール、どれも削除ではない。どの引数でも削除ツールは生成されない

すべての移動は元に戻せる。適用済みプラン全体も含む

servers/files/journal.py + undo_last_action / redo_last_action

古くなったプランは何も移動しない

servers/files/plan.py verify() — 部分的なサブセットではなく、プラン全体が拒否される

ハングしたコマンドは期限で強制終了される

servers/terminal/execute.py — subprocess.run(timeout=...)

出力は秘匿化の後、決して前ではなく、上限が適用される

safety/redact.py redact_and_truncate()

子プロセスは浄化された環境を受け取る

servers/terminal/execute.py — ENV_ALLOWLIST = (PATH, HOME, LANG)

シークレットは出力と監査フィールドの両方から秘匿化される

safety/redact.py BUILTIN_PATTERNS、1つの共有レダクターで適用される

監査証跡はデフォルトでフェイルクローズド

safety/audit.py AuditLogger.record() — 書き込み不可のログは例外を発生させ、操作は決して実行されない

すべての拒否は理由付きで記録される

servers/*/server.py _serve() — ツールごとの単一の結果書き込みポイント


脅威モデル — 明示的にスコープ外

  • 許可リストに登録した危険なコマンド。 インタープリタやシェル的な ツール(bash、python、sh、find -exec、awk、env など)を許可した場合、 モデルはそのツールができることは何でもできる。ポリシーの強度は完全に オペレーターの許可リスト次第である。

  • カーネル / サンドボックスエスケープ。 ジャイルはパス包含チェックであり、 カーネルサンドボックスではない — 名前空間、cgroups、seccomp はない。「正直な注意点」を参照。

  • 状態ファイルへのホストアクセス。 監査ログとアンドゥジャーナルは サーバーに対して改ざん検知可能であるが、ホストのファイルシステムアクセス権を 持つ誰に対しても改ざん防止はできない。

  • 秘匿化の完全性。 パターンベースでベストエフォート。パターンが 認識しないシークレットの形状は通過する。

  • マルチテナントのアイデンティティやレート制限。 どちらのサーバー内にも 呼び出し元ごとの認証はない — 信頼境界は「このプロセスを起動できる者」であり、 MCP クライアントが起動することで強制するのであって、このコードが強制するのではない。

  • パスの検証と実行の間の競合(TOCTOU)。 以下の「正直な注意点」を参照。


素朴な MCP サーバーとの比較

多くの手早い MCP サーバーは、ターミナルアクセスに subprocess.run(cmd, shell=True) を、 ファイル移動に os.rename をラップしている。どちらも便利で安全ではない。 この表は事実に基づくものであり、完全なセキュリティを主張するものではない。

懸念事項

素朴な MCP サーバー

safe-mcp-suite

コマンド実行

subprocess.run(cmd, shell=True) — シェルが解析できるものは何でも

argv のみ、shell=False、デフォルト拒否の許可リスト、拒否リストが優先

シェルのメタ文字

解釈される(;、|、$()、リダイレクション)

ポリシーチェックの前に拒否される

ファイル移動

プロセスが到達できる場所ならどこでも os.rename

1つのワークスペースに閉じ込められる。どちらかの端のシンボリックリンクは拒否され、決して追わない

ファイルの上書き

通常は黙って行われる — POSIX の rename は宛先を置き換える

常に拒否される。番号付きの変種(report(1).pdf)が発明されることは決してない

アンドゥ

なし

すべての移動はジャーナル化される。undo_last_action / redo_last_action

ファイルの削除

しばしば存在し、しばしば無防備

このサーバーには削除ツールは存在しない。完全に。

子プロセスに与えられる環境

親の完全な環境、シークレット含む

PATH / HOME / LANG に浄化される

出力やログのシークレット

そのまま通過

応答と監査証跡の両方で、切り詰めの前に秘匿化される

監査可能性

デフォルトではなし

追記専用 JSONL、デフォルトでフェイルクローズド

自動アクション vs 明示的に要求されたアクション

1つのコードパスが両方を同じように扱う

plan_organize(プロンプトなし)は両方のルール層に拘束される。move_file(明示的な要求)は安全性の不変条件のみに拘束される

両方のツールは OperationResult を返す:

OperationResult {
  ok: bool
  code: ResultCode
  action: str
  reason: str
  detail: dict            # stdout, stderr, exit_code — empty when nothing ran
  duration_ms: int
}
  • run_command(command: str, cwd: str | None = None) — command をポリシーに照らして評価し、許可されればサンドボックス内で実行する。cwd はオプションで、ジャイルルート内に解決される必要がある。トラバーサル、シンボリックリンク、または脱出する絶対パスは、何も実行せずに PATH_ESCAPE を返す。すべての呼び出しは監査される。フェイルクローズド監査では、書き込み不可のログは、未記録で実行するのではなく AUDIT_UNAVAILABLE を返す。非ゼロの終了コードでも ok: true である — コマンドは実行された。成功したかどうかはコマンド自身の問題である。

  • explain_command(command: str) — ドライラン。run_command と同じ解析と評価に到達し、実行器の前に返るため、detail は stdout、stderr、終了コードを決して運ばない — 何も実行されていない。

このサーバーが返せる結果コード: OK、POLICY_DENIED、INVALID_REQUEST、PATH_ESCAPE、TIMEOUT、OUTPUT_TRUNCATED、OPERATION_FAILED、AUDIT_UNAVAILABLE、INTERNAL_ERROR。

6つのツールがあり、そのリストが設計そのものである — 7つ目はなく、どれも削除しない。

  • list_files(subdir: str | None = None) — 読み取り専用。各エントリの名前、サイズ、カテゴリ、オーガナイザーが移動するかどうか、移動しない場合はその理由を報告する。

  • plan_organize() — 移動とスキップを提案する。宛先フォルダを含め、何も変更しない。apply_plan に渡す plan_id を返す。

  • apply_plan(plan_id: str) — プランを実行する。すべてのファイルが最初に再チェックされる。計画時から変更、移動、または消滅したファイルが1つでもあれば、プラン全体が拒否される。使い捨て — id は再生できない。

  • move_file(src: str, dest: str) — 名前付きの1つのファイルを移動する。dest は完全な宛先パスであり、フォルダではない。既に存在する宛先は拒否され、上書きも、名前を変えて回避することもない。PROTECTION ルールのみに従う — 上記の「2層モデル」を参照。

  • undo_last_action() — 最新の移動または適用済みプランを1つのアクションとして元に戻す。復元されたファイルのために何かを上書きすることはない。

  • redo_last_action() — 最新のアンドゥされたアクションを再適用する。新しい作業が記録されるたびに、リドゥスタックはクリアされる。

このサーバーが追加で返せる結果コード: NEEDS_EXPLICIT_REQUEST(すべての安全性の不変条件をクリアしているが、プロンプトなしで行動することが推測になるため拒否された — ターゲットを直接指定して要求してください)。

1つのファイル policy.toml を両方のサーバーが読む:

audit_log = "audit.jsonl"          # shared
audit_fail_mode = "closed"         # shared: "closed" or "open"

[redaction]                        # shared
enabled = true
entropy_fallback = false
extra_patterns = []                # [{ name = "...", regex = "..." }]

[terminal]
# jail_root = "/srv/safe-mcp/sandbox"   # REQUIRED — here or via env

[terminal.limits]
timeout_seconds = 30
max_output_bytes = 65536

[terminal.allowlist]
commands = ["ls", "cat", "echo", "pwd", "git"]

[terminal.denylist]
commands = ["rm", "shutdown", "reboot", "curl", "wget", "chmod", "sudo"]

[[terminal.rules]]
command = "git"
deny_args = ["push --force", "push -f"]
reason = "force-push rewrites shared history"

[files]
# workspace_root = "/srv/safe-mcp/inbox"   # REQUIRED — here or via env
journal = "organizer-journal.json"
max_plan_moves = 500

[files.categories]
Documents = [".pdf", ".doc", ".docx", "..."]
# ...

[[files.skip]]
layer = "protection"   # or "restraint" — required, no default
when = ["unsafe-name"]
reason = "..."

ポリシーファイル自体にはデフォルトの場所がない。--config で指定する:

safe-mcp terminal --config /path/to/policy.toml
safe-mcp files --config /path/to/policy.toml

環境変数は引き続きサポートされ、それぞれが対応する policy.toml のキーを上書きする。SAFE_MCP_POLICY_FILE は特筆に値する唯一の例外である: これは --config の代替であり、上書きではない — 両方が指定された場合は --config が優先され、どちらも指定されない場合は起動が拒否される。

変数

意味

デフォルト

SAFE_MCP_POLICY_FILE

policy.toml へのパス(必須。ここまたは --config)

なし — 起動を拒否

SAFE_MCP_JAIL_ROOT

ターミナルジャイルディレクトリ(必須。ここまたは jail_root)

なし — 起動を拒否

SAFE_MCP_WORKSPACE_ROOT

ファイルワークスペースディレクトリ(必須。ここまたは files.workspace_root)

なし — 起動を拒否

SAFE_MCP_FILES_JOURNAL

アンドゥ/リドゥジャーナルのパス(ワークスペースの外に置く必要があります)

organizer-journal.json

SAFE_MCP_AUDIT_LOG

共有監査証跡のパス(両方のジャイルの外に置く必要があります)

audit.jsonl

SAFE_MCP_AUDIT_FAIL_MODE

closed または open

closed

起動は明示的に失敗します — fatal: メッセージの出力と非ゼロの終了コード — 以下の場合: ポリシーファイルのパスがまったく指定されていない(--config も SAFE_MCP_POLICY_FILE もない)、policy.toml が存在しないか無効、ジャイル/ワークスペースのルートが未設定またはディレクトリではない、オペレーターのリダクション正規表現が無効、[[files.skip]] エントリにラベルがない、または監査ログ/ジャーナルが、移動や改ざんが可能なジャイルの内部に配置されている。


正直な注意事項

これはハードニングレイヤーであり、金庫ではありません。どちらかのサーバーをデプロイする前にこれらをお読みください。

  • ジャイルはパス包含チェックであり、カーネルサンドボックスではありません。 名前空間、cgroups、seccomp はありません。カーネルのエクスプロイトや、許可リストに登録されたバイナリから到達可能な脱出経路は封じ込められません。

  • 監査ログは改ざん検知可能であって、改ざん防止ではなく、ローテーションもありません。 追記専用でレコードごとのフラッシュ + fsync により、クラッシュしてもレコードを失うことはありませんが、ホストのファイルシステムから audit.jsonl にアクセスできる人は誰でも読み取り、変更、削除が可能です。また、ファイルは無制限に成長し、組み込みのローテーションや保持ポリシーはありません。

  • リダクションはパターンベースでベストエフォートです。 一般的なシークレットの形状は捕捉しますが、新しい形式や珍しい形式はリダクションされずに通過します。オプションのエントロピーフォールバックはデフォルトでオフですが、これは弱いからではなく、git SHA、UUID、base64 データでノイズが多いためです。

  • ターミナルのメタ文字は引用符の中でも拒否されます — 既知の限界です。 echo "a;b" は、引用符内では ; が不活性であるにもかかわらず拒否されます。これは、スキャンが引用符を認識しない生の部分文字列チェックであるためです。これは安全側に誤る方向です — そもそも引用符を無視するスキャンをオペレーターが引用符のトリックで回避することはできません — しかし、正当な入力の一部が拒否されることを意味します。

  • TOCTOU: 検証後に操作されるパスは、その間に変更される可能性があります。 safety/paths.py と servers/files/apply.py の両方が包含または占有をチェックしてから、別の syscall で操作を行います。その隙間にシンボリックリンクがすり替えられたり、ファイルが作成されたりした場合はカバーされません。これは意図的で名前付きの限界としてコード内に文書化されており(両ファイルの # NOTE: コメント)、必要になった場合のアップグレードパス(O_NOFOLLOW と dir-fd 相対操作)も記載されています。

  • 孫プロセスは回収されません。 強制終了またはタイムアウトしたコマンドの子プロセスは別のプロセスグループに属していません。実行器は直接の子プロセスのみを強制終了するため、そのコマンドが生成したものはすべて存続し得ます。


関連プロジェクト

  • hardened-terminal-mcp — このスイートのターミナルサーバーの前身となるスタンドアロン版。

  • MCP-file-organizer — このスイートのファイルサーバーの前身となるスタンドアロン版。


ライセンス

MIT — LICENSE を参照。

Related MCP Connectors

Related MCP Servers