TL;DR
- Anthropic は「有効な sandbox にはファイルシステムとネットワークの両方の隔離が要る」と書いています(Anthropic Engineering)。
- このリポジトリにコミットされた
.claude/settings.json(refc0fb8b73)にはsandboxキーが 0 件でした。通信系の deny はcurl/wgetのコマンド名 2 件で、宛先で書いたルールは 0 件です(社内データ)。user / local / managed の設定は読んでいません。 - 同じ ref では、allow の
Bash(gh api:*)が前方一致で、メソッドとエンドポイントの制限がありませんでした。CI 7 本に通信先を絞る宣言は 0 件で、使っていない 3 つの step に LLM API のキーが 3 本渡っていました(社内データ)。 - この 2 点は公開前に塞ぎました。#649 で
gh apiの allow を読み取りの 7 件に絞り、#651 でキーを外しています。ヘッダーでメソッドを上書きする形は、文字列照合では拾えません。 - 宛先・DNS・credential・監査・例外を 1 枚に書くこの記事の書式と、宣言を数える読み取り専用のスクリプトを載せました。
はじめに:被害半径の表に、ネットワークの行が無かった
この記事で egress(外向き通信)は、sandbox の中のプロセスから外部のホストへ向かう通信を指します。名前解決(DNS)も含めます。
親記事の AIエージェントの権限は影響範囲で設計する では、被害半径を 5 行の表に整理しました。この表にネットワークの行がありません。本記事はその 1 行を足す下位記事で、GitHub Agentic Workflows の CI/CD 設計 にある「egress allowlist を適用」という 1 行の中身にもあたります。
層が抜けた一次記録として、Anthropic が 2026-09-09 に公表したインシデント 4 件の評価があります。設定ミスで評価環境が外部に到達できた記録で、同じページにこうあります。
We take a layered approach to safety on the expectation that any given layer will sometimes fail.
この記事は提言ではなく、このリポジトリで宣言を数えた記録です。基準のコミットは c0fb8b73f3dc5c363d797495746505f4cd032f63 で、以降は c0fb8b73 と書きます。見つけた穴を塞いだ後の再測は 1861597b6a53f1c6b6a3373fd34bbafa111c57ec(以降 1861597b)で取りました。測ったのはリポジトリにコミットされた宣言だけで、通信は観測していません。
1. ファイル隔離だけでは egress(外向き通信)は閉じない
2 つの層は独立していて、互いを補えない
Anthropic Engineering の記事(2025-10-20)は、sandbox の条件をこう書いています。
It is worth noting that effective sandboxing requires both filesystem and network isolation.
Claude Code の sandbox のドキュメントも、sandbox を "two independent layers" と書いています。
外部と通信できる能力は、3 つ目の条件にあたる
Simon Willison はthe lethal trifectaとして、次の 3 つが揃うとエージェントは騙されてデータを持ち出しうると書いています。
- "Access to your private data"
- "Exposure to untrusted content"
- "The ability to externally communicate in a way that could be used to steal your data"
egress を絞ることは 3 つ目の条件を削ることにあたる、というのは筆者の対応づけです。OWASP の LLM01 Prompt Injection には、間接注入の帰結として会話が持ち出されるシナリオの例が載っています。入力側で注入を防ぎきれない前提は LLMガードレール設計 で扱いました。
HTTP の宛先を絞っても、経路は残る
MITRE ATT&CK は、持ち出し(Exfiltration)の技法を別々に分類しています。
| ID | 名称 | 定義の冒頭 | 緩和策の例 |
|---|---|---|---|
| T1048 | Exfiltration Over Alternative Protocol | 既存の C2 経路とは別のプロトコルで持ち出す | M1037 Filter Network Traffic、M1030 Network Segmentation |
| T1567 | Exfiltration Over Web Service | 正規の外部 Web サービスを使って持ち出す | M1021「Web プロキシで未許可の外部サービスの利用を防ぐ」 |
名前解決も通信経路です。ATT&CK は DNS を使う通信を T1071.004(Command and Control の技法)に分類しており、その緩和策 M1037 が DNS 要求のフィルタを挙げています。
Claude Code のドキュメントも、プロキシが既定で TLS の中身を検査しないこと、github.com のような広いドメインの許可が持ち出し経路になりうることを警告しています。
2. 製品ごとの egress の既定値と、その条件(Claude Code・Codex・Agents API ほか。確認日 2026-10-12)
既定値は条件つきで書かれています。条件を揃えずに並べると、向きを取り違えます。以下はすべて公式ページの記述(公式値、確認日 2026-10-12)で、優劣は述べません。
| 実行環境 | 条件 | 外向き通信の既定 | 絞る単位 | 公式ページにある注意 |
|---|---|---|---|---|
| Claude Code(sandboxed Bash) | sandbox を有効にしたとき | 直接の経路なし。ローカルのプロキシがホスト名を照合し、許可リストは空から始まる。新しいドメインは承認プロンプト | ホスト名(sandbox.network.allowedDomains / deniedDomains) | 広いドメインは持ち出し経路になりうる |
| Codex CLI | workspace-write モードのとき | オフ。sandbox_workspace_write.network_access で有効化 | features.network_proxy に domains キーがある | — |
| Codex cloud【Legacy ページの記述・現行仕様ではない】 | agent フェーズ(setup スクリプトは依存の取得のため通信あり) | 遮断 | ドメイン許可リスト(プリセット None / Common dependencies / All)、HTTP メソッド | GET / HEAD / OPTIONS への制限を案内(既定値ではない) |
| OpenAI Agents API の hosted sandbox | テンプレートのポリシーを継承しないとき | enabled | network.access: restricted で完全一致のホスト名 1〜100 件。ワイルドカード不可、サブドメインとリダイレクト先は別エントリ | secrets は vault credentials で sandbox の外に置くと記載 |
| GitHub-hosted runner | — | 一般的な egress 制限の記述は見当たらない | Azure private networking で VNET の NSG の outbound ルールが適用(larger runners のみ) | hosts ファイルで一部のマイニングプールと悪性サイトをブロック |
| Kubernetes NetworkPolicy | ポリシーの例として | "Default deny all egress traffic" の例がある | Pod セレクタ・IP・ポート | deny-all は DNS も止める |
Codex cloud の行は Legacy と表示されたページの記述です。Claude Code の sandbox は既定で無効です("The sandbox is off by default."、確認日 2026-10-14)。
Codex CLI は閉じる側から、Agents API の hosted sandbox は開く側から始まります。Agents APIの責務境界 では egress に踏み込みませんでした。ネットワークを丸ごと持たせない選択肢は WASMで安全な拡張を作る にあります。
3. このリポジトリで測った:コミットされた宣言では、ネットワーク層は空白だった
c0fb8b73 を git archive で展開して数えた値です(すべて 社内データ)。公開前に塞いだ後の値は 1861597b 時点として書き分けます。
.claude/settings.json:sandbox キーは無く、通信系 deny はコマンド名の 2 件
読んだのはリポジトリにコミットされた .claude/settings.json だけです。user(~/.claude/settings.json)、local(.claude/settings.local.json)、managed の設定と CLI の --settings は読んでいません。作業ツリーに settings.local.json は無く、それ以上は観測していません。
jq -c '{keys:keys, allow:(.permissions.allow|length), deny:(.permissions.deny|length), ask:(.permissions.ask|length)}' \
.claude/settings.json
# => {"keys":["hooks","permissions"],"allow":71,"deny":19,"ask":1}
sandbox キーはありません。deny 19 件のうち通信系は Bash(curl:*) と Bash(wget:*) の 2 件で、どちらもコマンド名で始まります。WebFetch で始まるルールは allow・deny・ask のどれにも無く、宛先で書いたルールは 0 件です。
allow 71 件のうち、定義したパターンに当たる候補は 40 件でした(全経路の網羅値ではない)。
| 分類 | 件数 |
|---|---|
直接:MCP(mcp__) | 15 |
直接:Bash(gh | 15 |
直接:git fetch / push / pull | 3 |
間接:Bash(pnpm | 5 |
間接:Bash(npx | 2 |
この中の Bash(gh api:*) は前方一致の allow で、メソッドとエンドポイントの制限がありません。curl を deny しても、GitHub の API には別のコマンドで届きます。親記事の「rm を deny しながら find を allow」と同じ構造です。
その後 #649 で塞ぎました。 Bash(gh api:*) を外し、代わりに repos/* と user で始まる形の 7 件を allow に足しました。-X / --method / -f / -F / --input を含む形は ask に回しました。1861597b 時点は allow 77 / deny 19 / ask 6 で、sandbox キーはありません。ヘッダーでメソッドを上書きする形は、文字列照合では拾えません。
ツール名やパスの allowlist との対比は MCPのガードレール設計 にあります。
Claude Code のドキュメントによれば、strictAllowlist はリポジトリの .claude/settings.json では効きません(user・managed・--settings のみ)。コミットされた設定だけでは sandbox の実効状態は決まらない、という範囲の根拠です。
hooks:ネットワークの検査は 0 件で、それは意図どおり
.claude/hooks/ の 3 本に、ネットワーク関連の検査は 0 件でした(curl|wget|http|network|egress の grep で一致なし)。安全フックの safety.sh は冒頭のコメントで、自分の担当外を宣言しています。
被害半径の封じ込めはファイルシステム・プロセス隔離の担当であって、文字列マッチの担当ではない。
宛先の封じ込めも同じくネットワーク層の担当です。なお hooks は sandbox の外で動きます(Claude Code のドキュメント)。
CI:供給元は固定、通信先を絞る宣言は無い(宣言の監査であって通信の観測ではない)
| 項目 | 値 |
|---|---|
| ワークフロー | 7 本 |
top-level permissions: の宣言 | 5 本。未宣言 2 本(readme-sync-check / spec-sync-check)はリポジトリ既定を継承し、既定は read(取得日 2026-10-13) |
harden-runner / allowed-endpoints / 語単位の egress | 0 件 |
uses: の 40 桁 SHA pin | 26 / 26 |
pnpm install --frozen-lockfile | 2 本 |
executor ワークフローでは、3 つの step が LLM API のキー 3 本を env で受け取っていますが、run の中身は placeholder の echo だけです。同じファイルの品質ゲートの step も placeholder でした。GitHub Docs の Secure use reference は "Any user with write access to your repository has read access to all secrets" と書き、credential を最小権限にするよう求めています。
その後 #651 で塞ぎました。 placeholder の step 3 本から LLM API のキーの env を外しています。1861597b 時点で、workflow 7 本に LLM API のキーを渡す参照は 0 件です(残るのは GitHub 向けトークンの参照 3 行で、secrets. を含む行 2・github.token を含む行 2。うち 1 行は両方を含む)。
gh run list で見た直近 8 件の run(2026-10-11 作成、取得日 2026-10-13)は、すべて conclusion=failure でした。ログは見ていないので原因は断定しません。
scripts:外に出るのは 3 本
| 役割 | 通信先 | 備考 |
|---|---|---|
| 外部の LLM API を呼ぶスクリプト | LLM API | モック用の環境変数で通信を回避できる |
| 画像生成のスクリプト | 画像生成 API | deprecated。package.json からは呼ばれない |
gh を呼ぶ検査スクリプト | GitHub API | テストは偽の gh を置いて動くので、ネットワークは不要 |
層表に network の行を足す
親記事の 5 行の表(ref 5832132 の値)に、network の行を足しました。
| 層 | 書ける単位 | このリポジトリの状態 |
|---|---|---|
| permissions | コマンド名の前方一致 | deny 19 件すべて名前。同効果の別名が残る |
| PreToolUse フック | コマンド文字列 | 修正後は嘘をつかなくなったが、半径は絞れない |
ツール層(tools:) | 能力の集合 | 24/24 が Bash、読み取り専用 0 本 |
| ファイルシステム | 到達可能な範囲 | worktree はブランチのみ隔離。FS は共有 |
| CI トークン | 権限スコープ | 6 本中 4 本が宣言済み |
| network | 宛先ホスト・名前解決・credential の注入先 | コミットされた宣言では sandbox キー無し、宛先ルール 0、CI の egress 宣言 0(user / local / managed は未観測)。gh api とキーの 2 点は #649・#651 で塞いだ |
親記事との件数の差(allow 72→71、CI 6→7 本など)は、測った ref の違いによるものです。
4. egress の sandbox contract:5 項目を 1 枚に書く(この記事の書式)
ここで示すのはこの記事が置いた書式です。一次資料が 5 項目の統合を勧めているわけではありません。5 項目で足りるとも、全部が必須だとも主張しません。各欄の根拠は、対応する一次資料の記述を個別に示します。
1 枚に並べたのは、1 項目だけ埋めても空欄が連鎖して残るからです(筆者の整理)。allowlist だけを書くと DNS の経路が残ります。広いドメインを許可すると、許可済みの宛先を経由する経路が残ります。credential の配り先が書かれていないと、監査で何を拾うかが決まりません。例外に期限の欄が無いと、allowlist は増える一方になります。AIコーディングセキュリティ設計 の 4 層モデルでいえば、権限の層と流出検知の層の間に出口の層を置く位置です。
flowchart LR
subgraph SB["sandbox(shell コマンド)"]
A["エージェントのプロセス"]
end
subgraph OUT["sandbox の外で動くもの(書式の outside_sandbox)"]
M["MCP サーバ / hooks / file tools / WebFetch"]
end
A -- "直接の外向き経路なし" --x NET(("インターネット"))
A --> P["プロキシ宛先 allowlist 照合ローカルアドレスにしか解決しない名前は拒否"]
P --> D["DNS リゾルバ(どこで解決するかを書式に書く)"]
P --> C["credential 注入(許可した宛先にだけ)"]
C --> H["許可済みホストapi.github.com など"]
P -. "拒否・許可・名前解決" .-> L["監査ログ想定外の宛先で停止"]
E["例外の記録欄理由・期限・承認の記録"] --> P
M --> NET
sandbox・プロキシ・sandbox の外のボックスは Claude Code のドキュメントの記述に対応し、それ以外は書式の欄で製品の機能ではありません。
5 項目と、空欄のとき記録に残らないもの
| 項目 | 書式に置く欄 | この欄が空欄のとき、記録に残らないもの | 欄の根拠(各欄の個別の根拠) | このリポジトリ(c0fb8b73) |
|---|---|---|---|---|
| 宛先 allowlist | 既定(deny)、宛先ホスト(完全一致)、用途、メソッド、フェーズ、credential、記録者 | どの宛先を何のために開けたか | NIST SP 800-41 Rev.1 は §4.5 の Recommendations に、outbound も既定で拒否すると書いている | 宛先ルール 0 |
| DNS | 名前解決の場所(sandbox 内か、プロキシか)、リゾルバ、deny-all 時に DNS を別に許可するか | 名前解決の経路が開いているか | ATT&CK T1071.004 と M1037。Kubernetes の「deny-all は DNS も止める」 | 記述 0 |
| credential | secret ごとの宛先・step・sandbox 内から見えるか・スコープ | どの宛先に、どの鍵が届きうるか | GitHub Docs の「write 権限者は全 secrets を読める」。Agents API の vault credentials | 使っていない 3 step にキー 3 本(#651 で外した) |
| 監査 | 記録するもの(許可・拒否、名前解決、credential の注入)、記録先、想定外の宛先で止めるか、確認の記録 | 想定外の宛先に出たか | Anthropic が事後の検証で挙げた、sandbox の探索や想定外のインターネット到達を捉えるリアルタイム監視 | 記録の仕組み 0 |
| 例外(記録欄の例。誰が申請・承認するかは述べない) | 宛先、理由、期限、申請の記録、承認の記録、記録先、期限が切れたあと何が起きたかを残す欄、sandbox の外で動くもの | 例外がいつ、なぜ足されたか | Claude Code の「Yes」はセッション限り。excludedCommands はファイル制限もプロキシも外れる | 記録先・期限の欄なし |
env 名 GH_TOKEN に限れば正の側の例で、リスクゲートの 1 ジョブだけに pull-requests: read 付きで渡されています。一方、別の workflow では write 権限のトークン(executor の GITHUB_TOKEN、planner の PR 用トークン)が PR 作成の step に渡っています。出力に紛れた secret の検知は GitHub MCP と secret scanning で扱いました。
例外の行も記録欄の例で、誰が申請・承認するかは述べません。
ひな形(YAML)
製品の設定ファイルではなく、設計を書き留める文書の書式です。このリポジトリの実在の宛先で埋め、埋められない欄は空欄(=現状)のまま残しました。
# sandbox-contract.yaml(この記事が置いた書式。製品の設定ファイルではない)
contract: repo-agent-egress
version: 1
scope:
agent: repo-agents
runtime: [developer-machine, github-actions]
phase: [setup, agent]
default: deny
destinations:
- host: api.github.com
purpose: PR と issue の読み書き(gh / MCP)
methods: [] # 空欄=現状。宛先の層ではメソッドを宣言していない(gh api の読み取り限定は permissions 側。#649)
phase: agent
credential: GH_TOKEN
recorded_by: ""
- host: registry.npmjs.org
purpose: 依存の取得(CI の pnpm install --frozen-lockfile)
methods: []
phase: setup
credential: ""
recorded_by: ""
dns:
resolver: "" # 空欄=現状
resolve_inside_sandbox: null
allow_cluster_dns: null
credentials:
- name: EXAMPLE_API_KEY # 架空の名前
inject_to_hosts: []
steps: []
visible_inside_sandbox: null
scope: ""
audit:
log: [allowed, denied, dns_query, credential_injection]
store: ""
halt_on: []
review_record: ""
# exceptions は記録欄の例。誰が申請・承認するかは規定しない
exceptions:
- host: ""
reason: ""
expires: ""
request_record: ""
approval_record: ""
recorded_in: ""
after_expiry: "" # 期限が切れたあと何が起きたかを残す欄
outside_sandbox: [mcp_servers, hooks, file_tools, web_fetch]
製品の設定キーとの対応
確認した範囲(2026-10-12)のキー名の並記です。
| 書式の欄 | Claude Code | Codex CLI | Agents API hosted sandbox | GitHub Actions |
|---|---|---|---|---|
default | 許可リストは空から始まる。strictAllowlist | sandbox_workspace_write.network_access | network.access | 該当キーなし |
destinations | sandbox.network.allowedDomains / deniedDomains | features.network_proxy の domains | allowed_domains(restricted のとき) | 該当キーなし |
dns | 該当キーなし | 該当キーなし | 該当キーなし | 該当キーなし |
credentials | sandbox.credentials | 該当キーなし | vault credentials | secrets コンテキスト、env |
audit | 該当キーなし | 該当キーなし | 該当キーなし | 該当キーなし |
exceptions | excludedCommands、ローカル設定に保存される WebFetch(domain:...) | 該当キーなし | 該当キーなし | 該当キーなし |
5. 宣言を数える監査スクリプト
このスクリプトは通信を観測せず、制御が効いているかも判定しません。宣言に指定した文字列やキーがあるかを数えるだけです。対象リポジトリには書き込まず、ネットワークも使いません。そのため、API が要るリポジトリ既定の workflow 権限は対象外です。
各検査は「なし」「指摘」「情報」「未検査」のどれかで終わります。終了コードは、未検査があれば 2、無ければ指摘があれば 1、どちらも無ければ 0 です。検査 1 は前提(settings.json を読めるか)の確認で、結果は検査 2〜4 に伝わります。
親記事のスクリプトは allow と deny が 0 件のときを未検査に倒しましたが、このスクリプトは許可する宛先の 0 件を指摘として数えます。sandbox だけを書いた正当な設定と「読めなかった」を区別するためです。
必要なものは bash 3.2 以上、grep、jq です。
#!/usr/bin/env bash
# egress-declaration-audit.sh <repo>
#
# 宣言に指定した文字列・キーがあるかを数える。通信は観測しないし、
# 制御が効いているかも判定しない。対象リポジトリには書き込まない。
# ネットワークは使わない(結果を実行者のネットワークや認証の状態に左右させないため)。
#
# 結果: なし / 指摘 / 情報 / 未検査(理由: 対象なし・読めない・型が想定外・jq が無い)
# 終了コード: 未検査が 1 つでもあれば 2、無ければ指摘が 1 つでもあれば 1、どちらも無ければ 0
# 必要なもの: bash 3.2 以上、grep、jq(無ければ検査 2〜4 が未検査)
set -u
export LC_ALL=C
REPO="${1:-.}"
SETTINGS="$REPO/.claude/settings.json"
WF_DIR="$REPO/.github/workflows"
CMD_DENY_RE='^Bash\((curl|wget|nc|ncat|ssh|scp)([ :)]|$)'
BARE_ALLOW_RE='^Bash\((gh|gh api|curl|wget)(:\*| \*)\)$'
SECRET_RE='secrets\.[A-Za-z_][A-Za-z0-9_]*|github\.token'
EGRESS_RE='harden-runner|allowed-endpoints|(^|[^A-Za-z0-9_-])egress([^A-Za-z0-9_]|$)'
none=0; found=0; info=0; unchecked=0
result() { # result <なし|指摘|情報|未検査> <検査番号> <要約>
printf '[%s] %s %s\n' "$1" "$2" "$3"
case "$1" in なし) none=$((none + 1)) ;; 指摘) found=$((found + 1)) ;;
情報) info=$((info + 1)) ;; *) unchecked=$((unchecked + 1)) ;; esac
}
jq_q() { jq -r "$1" "$SETTINGS" 2>/dev/null; }
count_matching() { printf '%s\n' "$1" | grep -cE "$2"; }
list() { jq_q "($1 // [])[]"; }
settings_state() {
command -v jq >/dev/null 2>&1 || { echo "jq が無い"; return; }
[ -f "$SETTINGS" ] || { echo "対象なし"; return; }
[ -s "$SETTINGS" ] || { echo "読めない: 空のファイル"; return; }
jq -e 'type' "$SETTINGS" >/dev/null 2>&1 || { echo "読めない"; return; }
[ "$(jq_q 'type')" = object ] || { echo "型が想定外"; return; }
echo ok
}
object_or_null() { case "$(jq_q "$1 | type")" in object | null) echo ok ;; *) echo "型が想定外" ;; esac; }
# null か「文字列だけの配列」なら真。親が想定外の型なら jq が失敗して偽になる
string_array_or_null() {
[ "$(jq_q "($1) as \$a | if \$a == null then \"ok\" elif (\$a | type) == \"array\"
and all(\$a[]; type == \"string\") then \"ok\" else \"bad\" end")" = ok ]
}
echo "読んだ設定: $SETTINGS のみ。user / local / managed の設定と --settings は読んでいない"
state="$(settings_state)"; sandbox="$state"; perms="$state"
[ "$state" = ok ] && sandbox="$(object_or_null .sandbox)" && perms="$(object_or_null .permissions)"
# 検査 2: トップレベルに sandbox があるか
if [ "$sandbox" != ok ]; then result 未検査 2 "sandbox: $sandbox"
elif [ "$(jq_q '.sandbox != null')" = true ]; then result なし 2 "sandbox: キーがある"
else result 指摘 2 "sandbox: キーが無い"; fi
# 検査 3: 許可する宛先の宣言(allowedDomains と allow / ask の WebFetch(domain:...))
# 判定は許可側だけで行う。拒否側(deniedDomains と deny の WebFetch)は件数を併記するだけ
if [ "$sandbox" != ok ] || [ "$perms" != ok ]; then
[ "$sandbox" != ok ] && why="$sandbox" || why="$perms"
result 未検査 3 "許可する宛先の宣言: $why"
elif ! string_array_or_null .permissions.allow || ! string_array_or_null .permissions.deny ||
! string_array_or_null .permissions.ask || ! string_array_or_null .sandbox.network.allowedDomains ||
! string_array_or_null .sandbox.network.deniedDomains; then
result 未検査 3 "許可する宛先の宣言: 型が想定外"
else
allow_dest=$(($(count_matching "$(list .permissions.allow; list .permissions.ask)" '^WebFetch\(domain:') +
$(count_matching "$(list .sandbox.network.allowedDomains)" '.')))
deny_dest=$(($(count_matching "$(list .permissions.deny)" '^WebFetch\(domain:') +
$(count_matching "$(list .sandbox.network.deniedDomains)" '.')))
cmd_deny="$(count_matching "$(list .permissions.deny)" "$CMD_DENY_RE")"
detail="(拒否側の宛先 $deny_dest 件、コマンド名で始まる deny $cmd_deny 件)"
if [ "$allow_dest" -eq 0 ]; then result 指摘 3 "許可する宛先の宣言: 0 件$detail"
else result なし 3 "許可する宛先の宣言: $allow_dest 件$detail"; fi
fi
# 検査 4: コマンド名だけで終わる前方一致の allow(引数で絞ったものは数えるだけ)
if [ "$perms" != ok ]; then result 未検査 4 "前方一致の allow: $perms"
elif ! string_array_or_null .permissions.allow; then result 未検査 4 "前方一致の allow: 型が想定外"
else
allow="$(list .permissions.allow)"
bare="$(printf '%s\n' "$allow" | grep -E "$BARE_ALLOW_RE")"
narrowed=$(($(count_matching "$allow" '^Bash\((gh api|curl|wget) ') -
$(count_matching "$bare" '^Bash\((gh api|curl|wget) ')))
if [ -n "$bare" ]; then
result 指摘 4 "メソッドとエンドポイントの制限が無い前方一致の allow(引数で絞った allow $narrowed 件)"
printf '%s\n' "$bare" | while IFS= read -r rule; do printf ' %s\n' "$rule"; done
else
result なし 4 "前方一致の allow: 0 件(引数で絞った allow $narrowed 件)"
fi
fi
# 検査 5〜7 は settings.json に依存しない。workflow が 0 本なら未検査
WORKFLOWS="$(for f in "$WF_DIR"/*.yml "$WF_DIR"/*.yaml; do [ -f "$f" ] && echo "$f"; done)"
# 検査 5: top-level の permissions: 宣言
if [ -z "$WORKFLOWS" ]; then result 未検査 5 "top-level permissions: 対象なし(workflow 0 本)"
else
missing=""; unreadable=0
while IFS= read -r f; do
grep -q '^permissions:' "$f"; rc=$?
if [ "$rc" -eq 1 ]; then missing="$missing ${f##*/}"; elif [ "$rc" -ge 2 ]; then unreadable=1; fi
done <<<"$WORKFLOWS"
if [ "$unreadable" -eq 1 ]; then result 未検査 5 "top-level permissions: 読めない"
elif [ -n "$missing" ]; then
result 指摘 5 "top-level permissions: が無い workflow:$missing(リポジトリ既定を継承。既定値は見ていない)"
else result なし 5 "top-level permissions: 全 workflow にある"; fi
fi
# 検査 6: secrets 参照(情報)。ファイル名と行数だけを出し、secret 名と行番号は出さない
if [ -z "$WORKFLOWS" ]; then result 未検査 6 "secrets 参照: 対象なし(workflow 0 本)"
else
report=""; total=0; unreadable=0
while IFS= read -r f; do
n="$(grep -cE "$SECRET_RE" "$f")"; rc=$?
[ "$rc" -ge 2 ] && unreadable=1 && continue
[ "$n" -gt 0 ] && report="${report}[情報] 6 secrets 参照: ${f##*/} $n 行"$'\n' && total=$((total + n))
done <<<"$WORKFLOWS"
if [ "$unreadable" -eq 1 ]; then result 未検査 6 "secrets 参照: 読めない"
else
[ -n "$report" ] || report="[情報] 6 secrets 参照: 0 行"$'\n'
printf '%s' "$report"; info=$((info + 1))
echo " 合計 $total 行。使われているかは静的には決められない。toJSON(secrets) の形は数えない"
fi
fi
# 検査 7: 指定した文字列(情報)。行頭がコメントの行は除く。「egress 制限あり/なし」とは表示しない
if [ -z "$WORKFLOWS" ]; then result 未検査 7 "指定した文字列: 対象なし(workflow 0 本)"
else
hits=0; unreadable=0
while IFS= read -r f; do
body="$(grep -v '^[[:space:]]*#' "$f")"; rc=$?
[ "$rc" -ge 2 ] && unreadable=1 && continue
hits=$((hits + $(printf '%s\n' "$body" | grep -ciE "$EGRESS_RE")))
done <<<"$WORKFLOWS"
label="指定した文字列(harden-runner / allowed-endpoints / 語単位の egress)"
if [ "$unreadable" -eq 1 ]; then result 未検査 7 "$label: 読めない"
elif [ "$hits" -gt 0 ]; then result 情報 7 "$label: 見つかった $hits 件"
else result 情報 7 "$label: 見つからなかった"; fi
fi
if [ "$unchecked" -gt 0 ]; then code=2; elif [ "$found" -gt 0 ]; then code=1; else code=0; fi
echo "なし $none / 指摘 $found / 情報 $info / 未検査 $unchecked"
echo "終了コード: $code"
echo "検査対象外: user / local / managed の設定、--settings、リポジトリ既定の workflow 権限"
echo "制約: YAML は grep で行単位に読む(複数行のフロー記法と anchor は読めない。行末コメントは除外しない)"
exit "$code"
穴を塞いだ後の 1861597b の作業ツリーのルートで bash egress-declaration-audit.sh . と実行した出力を、加工せずに貼ります。c0fb8b73 では検査 4 が Bash(gh api:*) を挙げて指摘になり、指摘は 4 でした。
読んだ設定: ./.claude/settings.json のみ。user / local / managed の設定と --settings は読んでいない
[指摘] 2 sandbox: キーが無い
[指摘] 3 許可する宛先の宣言: 0 件(拒否側の宛先 0 件、コマンド名で始まる deny 2 件)
[なし] 4 前方一致の allow: 0 件(引数で絞った allow 7 件)
[指摘] 5 top-level permissions: が無い workflow: readme-sync-check.yml spec-sync-check.yml(リポジトリ既定を継承。既定値は見ていない)
[情報] 6 secrets 参照: article-risk-gate-check.yml 1 行
[情報] 6 secrets 参照: blog-factory-executor.yml 1 行
[情報] 6 secrets 参照: blog-factory-planner.yml 1 行
合計 3 行。使われているかは静的には決められない。toJSON(secrets) の形は数えない
[情報] 7 指定した文字列(harden-runner / allowed-endpoints / 語単位の egress): 見つからなかった
なし 1 / 指摘 3 / 情報 2 / 未検査 0
終了コード: 1
検査対象外: user / local / managed の設定、--settings、リポジトリ既定の workflow 権限
制約: YAML は grep で行単位に読む(複数行のフロー記法と anchor は読めない。行末コメントは除外しない)
検査 6 は secret 名と行番号を出さず、出すオプションもありません。名前は自分の grep で見られるからです。
検査 3 の拒否側(deniedDomains など)は件数の併記だけで、判定に使いません。
14 通りの入力で終了コードが仕様どおりになり、bash 3.2 と 5 で出力が一致しました。
- このリポジトリ(
c0fb8b73):検査 2〜5 が指摘、6・7 が情報 →exit 1 - 空のディレクトリ:2〜7 が未検査(対象なし)→
exit 2 settings.json無し・workflow あり:2〜4 が未検査(対象なし)→exit 2- 壊れた JSON:2〜4 が未検査(読めない)→
exit 2 - 空の
settings.json:2〜4 が未検査(読めない: 空のファイル)→exit 2 permissions.allowが文字列:3・4 が未検査(型が想定外)→exit 2sandboxがtrue:2・3 が未検査(型が想定外)、4 は実行 →exit 2allowedDomains1 件だけの設定と、permissions:を宣言した workflow:なしと情報だけ →exit 0- 8 の設定で workflow が無い:5〜7 が未検査(対象なし)→
exit 2 - deny の
Bash(curl:*)だけ:2・3 が指摘 →exit 1 - allow の
Bash(gh api:*)だけ:2・3・4 が指摘 →exit 1 - 誤一致の否定(
Bash(git branch:*)、Bash(ssh-keygen:*)、引数で絞ったgh api、regression、インデントしたコメント行):一致なし →exit 0 jqを PATH から外す:2〜4 が未検査(jq が無い)、5〜7 は実行 →exit 2deniedDomainsと deny のWebFetch(domain:...)だけ:3 が指摘(拒否側の宛先 2 件)→exit 1
6. この記録で言えないこと
- 読んだのはリポジトリにコミットされた
.claude/settings.jsonと、ワークフロー・hooks・scripts だけです。user / local / managed の設定、CLI の--settings、実行時の環境変数は読んでいません。「このリポジトリで動くエージェントに sandbox が効いていない」とは言えません。 - 1 リポジトリの宣言の監査で、通信の観測ではありません。CI は直近の run がすべて失敗しています。
- このリポジトリは非公開で、読者は数値を検証できません。ref と取得日で再現の条件だけを示しました。
- 5 項目の書式は筆者が置いたもので、一次資料が統合を勧めているわけではありません。項目の十分性も必須性も主張しません。
- 塞いだのは
gh apiの allow とキーの受け渡しの 2 点だけで、sandbox・宛先・CI の egress の宣言は1861597bでも空白です。#649 の照合は、ヘッダーでメソッドを上書きする形を拾えません。 - 書式は、このリポジトリにまだ適用していません(未実施)。
- 製品仕様は 2026-10-12 時点のものです。Codex cloud の行は Legacy ページの記述で、現行の仕様ではありません。Claude Code の sandbox が既定で無効なことは 2026-10-14 に確認しました。
まとめ
このリポジトリで測った結果です(ref c0fb8b73、社内データ)。
- コミットされた
.claude/settings.jsonにsandboxキーは無い - 通信系の deny は
curl/wgetのコマンド名 2 件で、宛先で書いたルールは 0 件 Bash(gh api:*)は、メソッドとエンドポイントの制限が無い前方一致の allow だった- CI 7 本に通信先を絞る宣言は 0 件。
uses:は 26/26 が SHA pin - 使っていない 3 step に、LLM API のキー 3 本が渡っていた
- 親記事の層表には network の行が無かった
このうち gh api とキーの 2 点は、公開前に #649・#651 で塞ぎました(1861597b)。sandbox と宛先の宣言は空白のままです。
次に測るのは、この書式を実際に埋めたときに残る空欄です。手元で試すなら、第 5 節のスクリプトをリポジトリのルートで . を付けて実行してください(読み取りのみ)。残り 5 層の測り方は 親記事 にあります。
FAQ
ファイルシステムを隔離していれば、ネットワークは開けたままでも問題ありませんか
Anthropic は、有効な sandbox にはファイルシステムとネットワークの両方の隔離が要ると書いています。このリポジトリでコミットされた設定を数えたところ、ネットワーク側の宣言は 0 件でした(user / local / managed の設定は読んでいません)。
許可リストにドメインを入れれば、データの持ち出しは防げますか
防げるとは限りません。Claude Code のドキュメントは、プロキシが既定で TLS の中身を検査しないこと、github.com のような広いドメインの許可が持ち出し経路になりうることを書いています。
DNS も制限の対象に入りますか
名前解決も通信経路で、ATT&CK は DNS を使う通信を T1071.004(Command and Control の技法)に分類し、緩和策 M1037 が DNS 要求のフィルタを挙げています。Kubernetes のドキュメントも、egress を全拒否するポリシーは DNS も止めると注意しています。
permissions で curl を deny するのと egress allowlist は何が違いますか
前者はコマンド名を、後者は宛先を単位にします。このリポジトリでは前方一致の Bash(gh api:*)(c0fb8b73)を #649 で読み取りの 7 件に絞りましたが、ヘッダーでメソッドを上書きする形は文字列照合では拾えません。
GitHub Actions の GitHub-hosted runner は外向き通信が制限されていますか
GitHub Docs には hosts ファイルで一部のマイニングプールと悪性サイトをブロックする記述がありますが、一般的な egress 制限の記述は確認した範囲(2026-10-12)では見当たりませんでした。outbound を組織側で制御する Azure private networking は、larger runners だけが対象です。
References
確認日はすべて 2026-10-12 です。
- Anthropic — An alignment assessment of recent cybersecurity incidents(2026-09-09)
- Anthropic Engineering — Beyond permission prompts: making Claude Code more secure and autonomous(2025-10-20)
- Claude Code — Configure the sandboxed Bash tool
- Codex — Agent approvals & security
- Codex cloud(Legacy)— internet access(Legacy と表示されたページ。現行の仕様ではない)
- OpenAI — OpenAI-hosted sandboxes(Agents API)
- GitHub Docs — Secure use reference
- GitHub Docs — Using secrets in GitHub Actions
- GitHub Docs — GitHub-hosted runners
- GitHub Docs — About Azure private networking for GitHub-hosted runners in your organization
- MITRE ATT&CK — T1048 Exfiltration Over Alternative Protocol / T1567 Exfiltration Over Web Service(Exfiltration)/ T1071.004 Application Layer Protocol: DNS(Command and Control)
- OWASP — LLM06:2025 Excessive Agency / LLM01:2025 Prompt Injection
- Simon Willison — The lethal trifecta for AI agents(2025-06-16)
- Kubernetes — Network Policies
- NIST SP 800-41 Rev.1 Guidelines on Firewalls and Firewall Policy
