リモート設定に新しいキーを足すとき、決まって手が止まる場所があります。
「この値でアプリの見た目が変わるのは良い。では、この値で画面そのものが差し替わるのはどうなのか」。運用している壁紙アプリは、カタログもテキストもサーバー側から配っています。配信で変えられる範囲が広いほど運用は楽になりますが、広げすぎた先に審査があります。
2026年3月、Apple が Replit と Vibecode の App Store 上での更新を止めた件が報じられました。根拠として挙げられたのが、審査ガイドライン 2.5.2 です。生成したコードをアプリの中で実行できる設計が問題になった、という整理がされています(Apple Blocks Updates for AI Vibe Coding Apps in App Store )。
Rork で作ったアプリを出す側から見ると、これは他人事ではありません。AI にコードを書かせること自体は何も禁じられていませんが、書かれたコードをアプリの中で走らせる導線が残っていると、対象になり得ます 。しかも、その導線は自分で意図して作った覚えがなくても入り込みます。
以下では、その導線をコードから機械的に洗い出す手順をまとめます。実際に動かしたスクリプトと、走らせて分かった誤検知の出方まで含めています。
2.5.2 が見ているのは配信経路ではなく、機能が変わるかどうか
まず条文を正確に押さえておきます。2.5.2 は、アプリが「そのアプリ(および他のアプリ)の機能を導入または変更するコードをダウンロード・インストール・実行すること」を禁じています(App Review Guidelines )。
ここで読み違えやすいのが主語です。禁じられているのは「コードをダウンロードすること」そのものではありません。禁じられているのは、それによって機能が導入・変更されること です。
この読み方が正しいことは、例外規定の形から逆算できます。WebKit の中で動く JavaScript は、この条項の下で認められています。教育目的でコードを実行させるアプリも、条件付きで認められています。もし「リモートから届いたコードを実行するのが一律に駄目」だったなら、これらの例外は成立しません。
私自身、最初はここを取り違えていました。「サーバーから何かを取ってくる処理は全部危ない」と思って設計していた時期があります。実際の線はもっと具体的なところに引かれています。
リモートから届くもの アプリの側で起きること 2.5.2 の観点
壁紙カタログの JSON 一覧の中身が変わる データの差し替え。機能は変わらない
Remote Config のしきい値 すでに実装済みの分岐の入り方が変わる レビュー済みの機能の範囲内
EAS Update の JS バンドル 実装が新しいものに入れ替わる 解釈実行層の更新。認められた運用
式を表す文字列を eval に渡す その場で新しい処理が生まれる 機能の導入にあたる
リモート URL からの動的 import 審査時に存在しなかった実装が動く 機能の導入にあたる
3行目と4行目を並べたときの違和感が、この条項の要点です。EAS Update は JS バンドルを丸ごと入れ替えます。差し替わるコードの量でいえば、eval に文字列を1行渡すのとは比べものになりません。それでも前者は成立していて、後者が問題になります。
境界は「コードがどこから来たか」ではなく、「レビューされたときの機能の範囲を超えるか」に引かれています。 OTA 更新は、レビュー済みのアプリと同じ目的・同じ機能を保ったまま実装を差し替える運用として扱われます。一方 eval は、審査時点で誰も見ていない処理を実行時に生み出す口を開けたままにします。バイト数の問題ではなく、範囲が閉じているかどうかの問題です。
この見方を持っておくと、判断が速くなります。新しい実装を入れるとき、「これは配信か、それとも生成か」と自問すれば、たいていその場で答えが出ます。
境界の外に出やすい6つの実装
洗い出しの対象を先に決めます。個人開発の規模で実際に混入し得るのは、次の6種類でした。上の3つは提出前に消すべきもの、下の3つは用途を説明できるなら残せるものとして分けています。
eval(...) — 文字列をそのままコードとして実行します
new Function(...) — eval と同じ扱いです。式評価ライブラリを自作すると入りがちです
import("https://...") — 実行時にリモートのモジュールを読み込みます
injectedJavaScript — WebView に注入する JS。定数なら問題ありませんが、サーバーから受け取った文字列だと話が変わります
source={{ html: ... }} — 任意の HTML を描画する経路です
リモート値から画面実装を選ぶマップ — components[remote.screen] のような形です
4〜6を「消すべき」に入れていないのは、これらが正当な実装として広く使われているからです。ヘルプ画面を WebView で出すのも、リモート値でタブの並びを変えるのも普通の設計です。問題になるのは、そこに流し込まれる値の出どころが外部で、かつ内容が事前に決まっていない場合 だけです。
だからこの6つは「見つけたら消す」ではなく「見つけたら出どころを辿る」ためのリストとして扱います。
提出前に洗い出すスクリプト
以下は実際に動かしたものです。Node.js 22 で確認しました。依存はありません。
#!/usr/bin/env node
// scan-2-5-2.mjs — アプリの機能を後から変える経路を洗い出す
// 使い方: node scan-2-5-2.mjs src app
import { readdirSync, readFileSync, statSync } from "node:fs" ;
import { join, extname } from "node:path" ;
const TARGET_EXT = new Set ([ ".js" , ".jsx" , ".ts" , ".tsx" ]);
const SKIP_DIR = new Set ([ "node_modules" , ".git" , "ios" , "android" , "dist" , "build" ]);
// severity: block = 提出前に必ず消す / review = 用途を説明できるなら残せる
const RULES = [
{ id: "eval-call" , severity: "block" , re: /( ^| [ ^ .\w] )eval \s * \( / ,
hint: "文字列をコードとして実行しています" },
{ id: "function-ctor" , severity: "block" , re: /new \s + Function \s * \( / ,
hint: "Function コンストラクタは eval と同じ扱いです" },
{ id: "remote-import" , severity: "block" , re: /import \s * \( \s * [`'"] https ? : \/\/ / ,
hint: "リモートのモジュールを実行時に読み込んでいます" },
{ id: "webview-inject" , severity: "review" , re: /injectedJavaScript(BeforeContentLoaded) ? \s * [=:] / ,
hint: "WebView に注入する JS の出どころを確認してください" },
{ id: "webview-html" , severity: "review" , re: /source \s * = \s * \{\{ \s * html \s * :/ ,
hint: "任意の HTML を描画していないか確認してください" },
{ id: "remote-screen-map" , severity: "review" , re: /components ? \s * \[ \s * (remote | payload | res(ponse) ?| json) \w * \. / i ,
hint: "リモート値から画面実装を選んでいます" },
];
function walk ( dir , out = []) {
for ( const name of readdirSync (dir)) {
if ( SKIP_DIR . has (name)) continue ;
const p = join (dir, name);
if ( statSync (p). isDirectory ()) walk (p, out);
else if ( TARGET_EXT . has ( extname (p))) out. push (p);
}
return out;
}
// 行頭が // または * の行だけ除外する。過検出は残す方針(見落としより安全)
const isComment = ( line ) => / ^ \s * ( \/\/ | \* | \/\* )/ . test (line);
const roots = process.argv. slice ( 2 );
if (roots. length === 0 ) {
console. error ( "usage: node scan-2-5-2.mjs <dir> [dir...]" );
process. exit ( 2 );
}
const findings = [];
let scanned = 0 ;
for ( const root of roots) {
for ( const file of walk (root)) {
scanned ++ ;
const lines = readFileSync (file, "utf8" ). split ( " \n " );
lines. forEach (( line , i ) => {
if ( isComment (line)) return ;
for ( const rule of RULES ) {
if (rule.re. test (line)) {
findings. push ({ file, line: i + 1 , ... rule, text: line. trim (). slice ( 0 , 100 ) });
}
}
});
}
}
const blocks = findings. filter (( f ) => f.severity === "block" );
const reviews = findings. filter (( f ) => f.severity === "review" );
for ( const f of findings) {
const mark = f.severity === "block" ? "BLOCK " : "REVIEW" ;
console. log ( `${ mark } ${ f . file }:${ f . line } [${ f . id }] ${ f . hint }` );
console. log ( ` ${ f . text }` );
}
console. log ( ` \n scanned ${ scanned } files / block ${ blocks . length } / review ${ reviews . length }` );
process. exit (blocks. length > 0 ? 1 : 0 );
6種類の実装を1ファイルずつ含む検証用ディレクトリ(安全なコードのファイルも混ぜて計7ファイル)に対して走らせた出力が次のものです。
BLOCK src/lib/formula.ts:3 [function-ctor] Function コンストラクタは eval と同じ扱いです
const fn = new Function(...keys, `return (${expr});`);
BLOCK src/lib/formula.ts:7 [eval-call] 文字列をコードとして実行しています
return eval(expr);
BLOCK src/lib/plugins.ts:2 [remote-import] リモートのモジュールを実行時に読み込んでいます
const mod = await import(`https://cdn.example.com/plugins/${name}.mjs`);
REVIEW src/screens/Help.tsx:3 [webview-inject] WebView に注入する JS の出どころを確認してください
return <WebView source={{ html: html }} injectedJavaScript={boot} />;
REVIEW src/screens/Help.tsx:3 [webview-html] 任意の HTML を描画していないか確認してください
return <WebView source={{ html: html }} injectedJavaScript={boot} />;
REVIEW src/screens/Router.tsx:5 [remote-screen-map] リモート値から画面実装を選んでいます
return components[remote.screen];
scanned 7 files / block 3 / review 3
リモート設定で色だけを差し替えるファイルと、カタログ JSON を一覧に流すファイルは、どちらも何も出ませんでした。「サーバーから値を受け取っている」ことでは引っかからず、「受け取った値がコードとして扱われている」ときだけ引っかかる 、という設計が意図どおりに効いています。
BLOCK が1件でもあれば終了コード 1 を返すので、CI に置けばそのままゲートになります。私は EAS Build を走らせる前段に入れています。
# .github/workflows/preflight.yml の一部
- name : Scan for 2.5.2 risks
run : node scripts/scan-2-5-2.mjs src app
ここで1つ注意点があります。走査対象に Expo Router の生成物やビルド成果物が混ざっていると、自分が書いていないコードで BLOCK が出ます。本番運用では SKIP_DIR にプロジェクト固有の生成先を足して回避してください。これを放置すると、最初の1週間で誰もログを読まなくなります。
誤検知はコメントではなく、文字列から出ました
このスクリプトを書いたとき、真っ先に警戒したのはコメント行でした。「eval は使うな」と注意書きを残しているファイルが警告を出したら邪魔になる、と考えて isComment を先に実装しています。
ところが実際に紛らわしいコードへ当ててみると、外したのは別の場所でした。
const retrieval = ( q : string ) => q; // 検出されない(意図どおり)
const msg = "eval( is not allowed in this app" ; // ← BLOCK として検出された
// eval(dangerous) // 検出されない(意図どおり)
export const upsert = ( db : any ) => db. eval ( "noop" ); // 検出されない(意図どおり)
このファイル(4パターン + import 行)を走らせた結果は scanned 1 files / block 1 / review 0 で、唯一の検出は文字列定数 でした。コメントは想定どおり落ちています。
つまり、誤検知の源はコメントではなく文字列リテラルです。しかも「eval を禁止する」という趣旨の注意文をコードに書いているプロジェクトほど出やすい。皮肉な話ですが、気をつけている人ほど引っかかります。
同時に、除外が効きすぎている箇所も見えました。db.eval("noop") は検出されません。正規表現の [^.\w] が、ドットに続く eval を落としているためです。
このガードを外すと何が起きるかは、実際に試すと分かります。.eval( を持つメソッドは無関係なライブラリにも存在するため、BLOCK が量産されます。そして BLOCK が量産されたチェックは、必ず無視されるようになります。 私は精度より継続性を取って、ガードを残しました。ドット付きのメソッド呼び出しは、そもそも自前のコード生成経路ではないことが多いという判断です。
検出漏れを許す設計にしたので、スクリプトは「これで安全」を保証するものではありません。提出前に一度は自分のコードを目で追うための、当たりをつける道具 として使っています。
WebView に出すか、外部ブラウザに逃がすか
REVIEW に落ちた WebView まわりは、機械では判定しきれません。ここは設計の判断になります。
判断の軸を1つに絞るなら、**「その画面の中身を、次に変えるのは誰か」**です。
出したいもの 置き場所 理由
プライバシーポリシー・利用規約 アプリ内 WebView(固定 URL) 更新するのは自分で、内容は文書。機能は増えない
ヘルプ・お知らせ アプリ内 WebView(固定 URL) 同上。導線を切らずに戻れる利点が大きい
外部サービスのページ Linking.openURL で外部ブラウザ中身を自分が管理していない。埋め込むと責任範囲が曖昧になる
ユーザーや AI が生成した成果物 Linking.openURL で外部ブラウザ審査時に存在しない内容が、アプリの機能として動いてしまう
4行目が、冒頭で触れた事例の核心にあたる部分です。生成した成果物をアプリの中でプレビューさせると、アプリの機能が実行時に増え続ける構造になります。外部ブラウザで開けば、実行しているのはブラウザであってアプリではありません。
体験としては後者のほうが劣ります。アプリから離れる摩擦が生まれます。それでも、更新を止められる可能性を抱えたまま運用するよりは軽い代償だと考えています。
WebView を残す場合は、注入する JS をリポジトリ内の定数に固定することを推奨します。ブリッジの設計そのものについては、WebViewのpostMessageは投げたら終わり — 相関IDで要求と応答を結ぶブリッジを設計する に別途まとめてあります。
6本のアプリで引いている線
運用している壁紙アプリでは、リモートで変えられるものを最初から3つに限定しています。カタログの中身、表示のしきい値、そして文言です。
この3つに絞ったのは審査対策が理由ではなく、事故を減らすためでした。配信で変えられる範囲が広いほど、深夜に手元から本番の挙動を変えられてしまいます。段階公開を 5% から始めて Crash-free の数字を見ながら上げていく運用とも、相性が良くありません。リモートで何でも変えられるなら、段階公開の意味が薄れます。
結果的に、この制約は 2.5.2 に対する備えとしても働いていました。運用の安全のために引いた線が、そのまま審査の安全側に寄っていた というのが、正直なところです。
新しい機能を足すときは、いまでも同じ順番で考えます。まず「これは配信で変える必要があるのか」。次に「配信で変えたとして、変わるのはデータか、それとも処理の中身か」。後者なら、リモート設定ではなく EAS Update 側の仕事として設計し直します。同じ「後から変える」でも、通る道が違えば扱いが変わるからです。
フラグの数が増えてきたときの整理については、フィーチャーフラグが散らかる前に — 命名・段階公開・キルスイッチを6本横断で統治する設計 が近い話をしています。
審査が長引いている時期だからこそ
2026年上半期だけで、App Store には約56万本の新規アプリが追加されたと集計されています。承認まで数週間待たされたという報告も出ています。
この状況で 2.5.2 のリジェクトを受けると、痛いのは指摘の内容そのものではありません。修正して再提出したあと、もう一度その列の最後尾に並ぶこと です。実装を1行消すだけの修正でも、待ち時間はやり直しになります。
提出前のスキャンに数秒かければ、その数週間を賭ける必要がなくなります。費用対効果としては、これほど分かりやすいものもそう多くありません。
次にやること
自分のプロジェクトのルートで、まず1回だけ走らせてみてください。
node scan-2-5-2.mjs src
BLOCK が0件なら、今日のところは考えなくて構いません。1件でも出たなら、その行が「データを受け取っている」のか「処理を作り出している」のかを確かめるところから始まります。前者ならリファクタリングで済み、後者なら設計を戻す判断が要ります。
個人開発では、審査に落ちてから考えても間に合う場面が多くあります。ただ 2.5.2 だけは、落ちてからでは実装ごと作り直しになりやすい。手前で見ておく価値がある数少ない項目だと感じています。
私自身、境界の引き方はまだ手探りの部分が残っています。同じところで迷った方の参考になれば嬉しいです。お読みいただきありがとうございました。