コンテンツにスキップ

cloudflare_os_basics — Cloudflare OS の承認ゲートを写経する

一言で言うと「承認待ちでエージェントを立ち往生させず、送信だけを保留する」。

human-in-the-loop の常識は「危ない操作の前でエージェントが止まり、人間が OK を出すまで進まない」だった。これは席を外した瞬間に破綻する——コーヒーを取りに行って戻ると 1 手目で待っている。だから人は --dangerously-skip-permissions を押す。安全機構が使われなくなる形をしている、というのがそもそもの問題だった。

Cloudflare OS(2026-08 に OSS 公開。Cloudflare 社内で実運用中)の Gatekeeper は、ここを分解して作り直している。エージェントを止めているのは「承認」ではなく「送信」だと気づけば、止めるべきは送信だけでいい。エージェントは進み、外の世界だけが凍る。人間は数時間後にまとめて裁く。

このレクチャーは、その機構を Cloudflare アカウントも LLM も使わずに TypeScript で最小再現したもの。ex01〜ex08 を順に動かすと、承認ゲートが「1 つの機能」ではなく互いに支え合う 8 つの決めごとでできていることが見える。

  • 学習ロードマップ
  • [x] STEP 1: 能力ベースのアクセス制御(既定の持ち物はゼロ・introduce() で手渡す)— ex01
  • [x] STEP 2: 信頼境界(read と action の仕分けが 1 か所しかない)— ex02
  • [x] STEP 3: 非同期の承認キュー(submitAction は即返る)— ex03
  • [x] STEP 4: 保留の見せ方(シミュレート / awaitDecision / 放置)— ex04
  • [x] STEP 5: 自動承認の 2 鍵と掃き出しの順序 — ex05
  • [x] STEP 6: at most once(claim を送信前に書く)— ex06
  • [x] STEP 7: 承認プロンプト自体への注入対策 — ex07
  • [x] STEP 8: 観測側のゲート(機密を読んだらロックダウン)— ex08

全体像

登場人物の対応(実物 → このレクチャー)

Cloudflare OS は自分を「OS」と呼んでいて、それは比喩以上の意味がある。

普通の OS Cloudflare OS このレクチャー
カーネル packages/workshop-backend _kernel.tsOverseer
デバイスドライバ packages/gatekeeper-* _gatekeeper.tsGatekeeper / Session
シェル packages/workshop-frontend _io.ts(ターミナル)
プロセス Gadget(個人用の小アプリ) エージェントの台本(ex04 の agentTask
周辺機器 外部サービス(GitHub 等) _service.tsIssueTracker

ドライバの比喩が効いている。Gatekeeper は「外の世界に触る唯一の道」であり、その道の途中に検問がある。アプリ側にどんなコードが書かれていようと、検問を通らずに外へ出る経路が存在しない——これが「確率の防御」(プロンプトでお願いする)ではなく「構造の防御」である理由。cf. guardrails_basics

データの流れ

flowchart TD
    Agent[エージェント / Gadget]
    Session[Session<br>唯一の出口]
    Classify{"readOnlyHint === true か"}
    Store[(ActionStore<br>pending / applying / applied)]
    Overseer[Overseer<br>承認キュー・台帳]
    Human[人間<br>後からまとめて裁く]
    Service[外部サービス]

    Agent -->|callTool| Session
    Session --> Classify
    Classify -->|read| Service
    Classify -->|action| Store
    Service -.->|結果| Session
    Session -->|authorizeObservation| Overseer
    Store -->|submitAction| Overseer
    Overseer --> Human
    Human -->|承認| Store
    Store ==>|送信は承認後だけ| Service
    Agent -.->|getActionResult でポーリング| Session

肝は read と action で線の太さが違うこと。read は関所を素通りして即実行され、通ったという記録(観測)だけが残る。action は一旦ストアに積まれ、人間の決定が出るまで一度も送信されない。だから「拒否」は取り消しではなく、単に未送信のまま終わる

1 アクションの時系列

sequenceDiagram
    participant Ag as エージェント
    participant Ses as Session
    participant St as ActionStore
    participant Ovs as Overseer
    participant Hum as 人間
    participant Svc as 外部サービス

    Ag->>Ses: callTool('createIssue', args)
    Ses->>Ses: classifyTool → action
    Ses->>St: stage() → id=1, state=pending
    Ses->>Ovs: submitAction(1, description)
    Ovs-->>Ses: 即座に返る
    Ses-->>Ag: status=pending, actionId=1
    Note over Ag: ターンは 1.1 ms で終了。<br>人間はまだ席にいない
    Hum->>Ovs: (数時間後)承認
    Ovs->>St: apply(1)
    St->>St: state=applying を先に書く(claim)
    St->>Svc: ここで初めて送信
    Svc-->>St: 結果
    St->>St: state=applied + result
    Ag->>Ses: getActionResult(1)
    Ses->>Ovs: authorizeObservation(結果を渡す瞬間)
    Ses-->>Ag: status=ok, text

原理: なぜこの 8 つが要るのか

承認ゲートは 1 個の機能ではない。「送信だけ保留する」と決めた瞬間に、芋づるで解かなければならない問題が出てくる。

決めごと それが無いと何が起きるか ex
能力は紹介制(既定はゼロ) 設定に書いた接続が全チャットに漂い、無関係な仕事でも全権限を持つ 01
read / action の仕分けが 1 か所 「ここでは注釈を信じる/ここでは信じない」が散らばり、緩い経路が必ず残る 02
submitAction は即返る 承認待ちで止まり、人は結局 auto-approve を押す 03
保留をどう見せるか決める エージェントが自分の書き込みを観測できず、失敗と誤解して二度やる 04
自動承認は鍵 2 本 作者かユーザーの片方の判断だけで全自動になる 05
適用は多くとも 1 回 通信断のたびに二重書き込み。取り消し操作が無い相手には致命的 06
プロンプト自体を無害化 承認画面の文字を攻撃者が書けるので、承認ボタンが意味を失う 07
観測にもゲート アクションだけ守っても、読めたデータは別経路から出ていく 08

一番おもしろい対比(ex04)

README の売り文句は「Gatekeeper は結果をシミュレートして、エージェントを進ませる」。ところが実物の MCP Gatekeeper はシミュレートしていないsession.ts にこう書いてある:

Nothing about a queued call is simulated, so later reads would show a world in which it never happened. Wait for the decision instead.

つまり awaitDecision: true(=待たせる)を選んでいる。理由は ex04 を動かすと体で分かる: シミュレートするには「暫定 id が承認後の本番 id と一致する」ことを Gatekeeper が保証しなければならない。自分が中身を知っている Google Docs や Home Assistant なら書ける。任意の MCP ツールに対しては書けない

だから設計は「シミュレートできる Gatekeeper は止まらない/できない Gatekeeper は正直に待つ」の二段構えになっている。「止まらない」は無料ではない——ここが README だけ読んでいると見落とすところ。


ファイル別の役割

ファイル 役割 写経元
_service.ts 外部サービスのスタブ(課題管理)とツールカタログ。OS の外側 GitHub / Notion / MCP サーバ
_kernel.ts ミニ Overseer。承認キューと台帳だけを持ち、外部には触らない workshop-backend/src/overseer.ts, auto-approval.ts
_gatekeeper.ts 分類 → 保留 → 適用。外へ出る唯一の道 mcp-shared/src/{tools,action-store,session}.ts
_prompt.ts 承認プロンプトの組み立てと無害化 mcp-shared/src/tools.tsdescribeCall
_io.ts 承認 UI の代わり(TTY なら手入力) workshop-frontend
ex01ex08 各 1 テーマの観察スクリプト

中心ロジックの読み解き

① 信頼境界: classifyTool_gatekeeper.ts:37

export function classifyTool(tool: Tool, trust: ServerTrust): ClassifiedTool {
  const annotations = tool.annotations ?? {}
  const isReadOnly = annotations.readOnlyHint === true          // ①

  const autoApprovable =
    !isReadOnly && trust === 'vetted'                            // ②
    && annotations.destructiveHint === false                     // ③
    && annotations.idempotentHint === true                       // ③

  return {
    tool,
    mode: isReadOnly ? 'read' : 'action',
    autoApprovable,
    classifiedBy: isReadOnly ? 'server-annotation' : 'default',   // ④
  }
}
やってること なぜそうする
=== true で厳密比較 真偽値としての truthy 判定にすると、注釈が無いツールの扱いが実装依存になる。厳密比較なら未注釈は必ず false 側に落ち、MCP 仕様の既定値(readOnlyHint: false)と一致する
自動承認は vetted 限定 readOnlyHint は byo でも信じるのに、書き込みの自動適用は信じない。「読み取りの誤判定」と「書き込みの自動実行」でリスクが桁違いだから、同じ材料でも扱いを変える
「壊さない」かつ「二度やっても同じ」の両方 冪等でない操作は、自動適用の再送が二重書き込みになる。ex06 の at-most-once とここが噛み合っている
誰の言葉を信じたか記録 後から「サーバの自己申告で通した呼び出し」だけを監査で洗い出せる。再計算ではなく記録なのは、消費側が別の答えを出せないようにするため

この関数以外は annotations を読まない、というのが mcp-shared の宣言。信頼境界は「1 か所しかない」ことが価値で、2 か所あれば必ず片方が緩む。

ex02 の出力(実測):

│ 'listIssues'   │ '{"readOnlyHint":true}' │ 'read'   │ 'server-annotation' │ false │ false │
│ 'searchIssues' │ '{}'                    │ 'action' │ 'default'           │ false │ false │
│ 'addLabel'     │ '{... destructive:false, idempotent:true}' │ 'action' │ 'default' │ false │ true  │

searchIssues は意味的には読み取りなのに action に落ちる。言っていないことを好意的に解釈しないという既定が、そのまま表に出ている。

② 承認ゲート本体: Session.callTool の action 経路(_gatekeeper.ts:243

const staged = this.#store.stage(name, args)                       // ①
const simulated = this.#options.simulator?.simulate(staged.id, name, args)
const description: ActionDescription = {
  ...described,
  implementsRevert: false,
  awaitDecision: simulated === undefined,                          // ②
  autoApprovable: entry.autoApprovable,
  actionKind: { tag: `${this.#options.name}:${name}`, label: name },  // ③
}

try {
  await this.#queue.submitAction(staged.id, description)
} catch (error: unknown) {
  this.#store.discard(staged.id)                                   // ④
  throw error
}

return { status: 'pending', actionId: staged.id, message: ... }    // ⑤
やってること なぜそうする
先にストアへ積む 「積んだ」と「キューに載せた」が別トランザクション。id を先に確定させないと、承認時に何を適用するか指せない
シミュレートできない時だけ待てと言う 助言(hint)であって強制ではない。守るのはハーネスの仕事——ex04 の A はわざと無視するハーネスを書いて、何が起きるか見ている
tag に束の名前を含める 「GitHub の createIssue を自動承認」の設定が、別サーバの同名ツールに流用されないようにする名前空間
載らなかったら積んだ分を捨てる ロックダウン(ex08)でキューが拒否したとき、承認待ちの幽霊が残らないようにする
即座に pending を返す ここが承認ゲートの心臓。ex03 の実測でターン 1.1 ms・サービス呼び出し 1 回(読み取りの分だけ)。3 つの書き込みは 1 つも送信されていない

③ at most once: ActionStore.apply_gatekeeper.ts:126

const claimed: StoredAction = { ...stored, state: 'applying', result: undefined, error: undefined }
this.#save(claimed)                                                // ① 送信の「前」に claim

let text: string
try {
  text = await call(claimed.toolName, claimed.args)                // ② ここで初めて外に出る
} catch (error: unknown) {
  const mayHaveLanded = callMayHaveTakenEffect(error)              // ③
  this.#save({ ...claimed, state: 'failed', retryable: !mayHaveLanded, error: message })
  throw new Error(message)
}

this.#save({ ...claimed, state: 'applied', retryable: undefined, result: text })  // ④
やってること なぜそうする
claim を先に永続化 二重承認の 2 本目がここで弾かれる。止めているのは UI のボタン制御ではなくストアの状態——UI は信用できない
送信は claim の後 逆順(送ってから claim)だと、送信直後に落ちたときに claim が残らず、復旧時に再送してしまう
失敗を 2 種類に割る 401/403 だけが「届く前に断られた」証拠。タイムアウト・切断・壊れた応答は結果不明なので「起きたかもしれない」に倒す(fail safe)
「効いた」を先に確定 実物はここが 2 回の書き込みに分かれていて、状態を確定してから結果を付ける。結果の整形・保存で失敗しても「書き込みは起きた」事実を失わない

さらにコンストラクタ(_gatekeeper.ts:77)で、前回の活性化が applying のまま残した行を問答無用で failed / retryable: false にする。新しいストア=新しいプロセスなので、applying の行は「送信したが結果を見ていない」ものしかありえない。

ex06 の実測が効く。タイムアウト後にサービス側を覗くと:

実際の世界: ... / #4 タイムアウトした起票     ← 書き込みは着弾している
ストア: state=failed retryable=false
もう一度承認しようとすると: 送信後に失敗したため、効いたかどうか分からない。…

結果は失ったが世界は変わっている。だから再送を封じ、人間に「サーバを見て、必要なら積み直せ」と返す。保証が "exactly once" ではなく "at most once" であることを、実装が正直に認めている箇所。

④ 自動承認の 2 鍵と掃き出し(_kernel.ts:177 / :222

const willAutoApprove =
  description.autoApprovable === true                                    // 鍵①: 作者
  && tag !== undefined
  && this.#autoApproveTags.has(`${gatekeeperId}:${tag}`)                 // 鍵②: 人間
for (const record of pending) {           // id の昇順
  const hasRule = tag !== undefined && this.#autoApproveTags.has(`${gatekeeperId}:${tag}`)
  if (record.description.autoApprovable !== true || !hasRule) return     // ← break ではなく打ち切り
  await this.#applyRecord(record, true)
}

人手ゲートに当たったら、その先へ進まない。「後ろに自動承認できるものがあるから先にやっておく」は禁止で、理由は順序。#3 が「請求書を削除」で #4 が「ラベル付け」のとき、#3 を保留したまま #4 だけ通ると、人間が想定していない中間状態が外の世界に生まれる。

ex05 の実測:

#3 社内課題管理: createIssue → pending      ← 人手ゲート
#4 社内課題管理: addLabel   → pending      ← 条件は満たすのに止まっている

なお実物は掃き出しを ctx.waitUntil() でバックグラウンドに逃がす。適用は Gatekeeper 側へ呼び返すが、その Gatekeeper は今まさに submitAction を待っているので、同期でやると再入する。このレクチャーでは #deferred チェーン+settle() で同じ形を作った。


実行記録(実測)

ex 見どころ 実出力
01 紹介前後の持ち物 env のキー: [][ 'ISSUES' ]。範囲外呼び出しは「この束が許しているのは次のツールだけである: listIssues」
02 嘘つきサーバ readOnlyHint: true と偽った deleteIssue承認 0 件で実行され、課題 [1,2][2]
03 ターンの速さ 1.1 ms / サービス呼び出し 1 回。承認後に 3 回へ。拒否した #2 は 1 度も届かない
04 保留の見せ方 A は #3#4同じ課題が重複。B・C は重複なし
05 掃き出し 人手ゲート #3 の後ろで #4 が pending のまま。byo 側はルールを入れても通らない
06 at most once 二重承認 → 2 本目 rejected・送信 1 回。timeout → retryable=false。403 → 再送成功
07 注入対策 行頭「エンドポイント:」が素朴 2 本 → 無害化 1 本。見出し 1 → 0
08 ロックダウン 給与テーブルを読んだ後、無関係な課題管理でも書き込み不可

検証: npm run typecheck 通過、ex01〜ex08 を通し実行して全て exit 0(Node v26.0.0 / npm 11.12.1)。外部 API を一切呼ばないので何度実行しても同じ出力になる。


学んだこと(要点)

  • 止めるべきは「エージェント」ではなく「送信」だった。 この 1 個の再定義で、同期 HITL の待ち時間問題がほぼ消える。安全機構は「使い続けられる形」でなければ意味が無い(auto-approve を押させたら負け)
  • 保留は「取り消し」ではなく「未送信」。 だから拒否のコストがゼロで、人間は気軽に拒否できる。逆に一度送ったものは取り消せない(implementsRevert: false)ので、送信の前に全部を寄せている
  • 信頼境界は「1 か所しかない」ことが価値。 annotations を読む場所が 2 つあれば、片方は必ず緩む。だから tools.ts 以外は読まないと宣言してある
  • readOnlyHint を信じるのは意識的な敗北。 ドキュメントが「これはトレードオフだ」と明記している。読み取りのたびに承認を求めるとコネクタが使い物にならないので、使われるために危険を引き受け、その代わり書き込みの自動化は同じ条件では認めない。セキュリティ設計の判断がそのまま文章で残っているのが良い教材
  • エージェントの「暴走」の多くは、世界の見せ方の失敗。 ex04 の A でエージェントは嘘をつかれたのでもバグったのでもなく、「自分の書き込みが存在しない世界」を正直に観測して合理的に動いた。環境が一貫していないと、賢いモデルほど混乱する
  • 承認 UI に出す文字列は、体裁ではなくセキュリティ境界。 ツール名・説明・引数の全部が攻撃者側の文字列で、Markdown に流し込めば承認画面を偽造できる。承認ボタンの意味は「何が書いてあるか」で決まる
  • "exactly once" を諦める勇気。 冪等キーも取り消しも無い相手に厳密一回は無理。だから「結果を失う」ほうを選び、その代償を人が意図的に積み直す 1 回分の手間に固定した。曖昧な保証を装うより正直

用語集(混同しやすい語を並べる)

登場人物

何か 混同しやすい相手との違い
Gatekeeper 外部サービス 1 つに対応する関所(=ドライバ) MCP サーバは「向こう側」。Gatekeeper はこちら側に立つ代理人で、認可・記録・保留を担う
Overseer 承認キューと台帳を持つカーネル Gatekeeper が「外との窓口」、Overseer は「内の役所」。外部サービスには一切触らない
Gadget ユーザーごとの私物アプリ(サンドボックス内) SaaS と違い利用者ごとに別インスタンス。だから改造してよい
Blueprint Gadget を作るための雛形(コード一式) テンプレ=中身のコピーではなく、アプリまるごとの配布
Session Gadget が実際に握る能力(capability) 「接続設定」ではなく手渡された権限そのもの。持っていなければ道が無い

呼び出しの種類

何か 判定
observation(観測) 外から中へデータが入ること 即時に実行され、記録が残る。止めるのは例外時(ex08)
action(アクション) 中から外へ影響が出ること 保留され、承認まで送信されない
read / action の分岐 readOnlyHint === true かどうかだけ 「意味的に読み取りっぽい」は無関係。未注釈は action

承認まわり

何か 混同しやすい相手との違い
autoApprovable 鍵①。Gatekeeper 作者の「この操作は自動でよい」判定(操作ごと 単独では絶対に通らない
自動承認ルール(actionKind.tag 鍵②。人間の「この種類は任せる」オプトイン(種類ごと・束ごと 単独では絶対に通らない
awaitDecision 「決定が出るまで進むな」という助言 強制ではない。従うのはハーネスの責任で、承認の意味論は変わらない
シミュレート 保留中の効果を「起きたこと」として見せる awaitDecision排他。シミュレートできるなら待たせる必要が無い
vetted / byo 注釈をどこまで信じるかの階層 byo = ユーザーが URL を貼った。vetted = 配備管理者が保証。アカウントの状態ではなく配備の設定なので、取り消せばすぐ効く
implementsRevert 取り消しを実装しているか 「取り消せる=安全」ではない。時間が経てば取り消しは失敗しうる

状態

意味 注意
pending 積まれたが未送信 「失敗」ではない。まだ何も起きていない
applying 承認が下りて送信中(claim 済み) ここに居る行は再送してはいけない。二重承認と再起動の両方をここで止める
applied / rejected 送信して成功 / 未送信で終了 rejected は取り消しではなく未送信
failed + retryable: true 届く前に断られた(401/403) 再送してよい
failed + retryable: false 届いたかもしれない 人間が世界を見てから積み直す。自動再送は禁止
at most once / exactly once 「多くとも 1 回」/「ちょうど 1 回」 後者は諦めている。結果を失うほうが、二重書き込みより安い

このレクチャーが簡略化しているところ(実物との差)

写経なので、意図的に落としたものがある。再現できていない=実物にも無い、ではない点に注意。

項目 実物 ここ
永続化 Durable Object の SQLite(STRICT + CHECK 制約) メモリ上の Map。再起動はコンストラクタに行を渡して疑似再現
並行制御 DO の input gate + 単一飛行ガード(AutoApprovalDrainer 単一スレッド前提。claim だけ写経
waitUntil Workers ランタイムのバックグラウンド実行 #deferred チェーン + settle()
RPC Cap'n Web(プロミスパイプライン・スタブ破棄) 直接のメソッド呼び出し
サンドボックス Dynamic Worker + iframe CSP 無し(隔離は sandbox_basics の担当)
OAuth・SSRF 対策 oauth.ts / fetch.ts(リダイレクト毎に再検査) 無し
上限値 保持 100 件・結果 128 KB 等 決定待ち 50 件だけ写経
監査ログ 誰が承認したか(resolvedBy)まで記録 自動 / 手動の別のみ

拡張アイデア

  1. implementsRevert: true の Gatekeeper を書く。 addLabel に対する removeLabel を revert として実装し、承認済みアクションを後から取り消せるようにする。「取り消せるなら承認は軽くてよいか?」を考えると、autoApprovable の判断基準が変わるかを検証できる
  2. ex04 の C を壊す。 シミュレータが暫定 id を配った後、別の経路で本物の課題が増える状況(他人が同時に起票)を作ると、暫定 id と本番 id がずれる。実物が汎用シミュレートを諦めた理由を、失敗として再現できる
  3. 上限を実装して詰まらせる。 MAX_PENDING_ACTIONS = 50 に当たるまでループでアクションを積み、「承認待ちが溜まりすぎたエージェント」が何を返すか観察する。上限が UX にどう出るか
  4. LLM を挿す。 ex04 の agentTask を台本ではなく実際の LLM に置き換え、A(保留を隠す)で本当に重複起票するかを測る。台本では自明だが、実モデルが同じ罠に落ちるかは別問題(guardrails_basics の「確率の防御」と地続き)
  5. 監査の観点で台帳を読む。 classifiedBy: 'server-annotation' の呼び出しだけを抽出するクエリを書き、「サーバの自己申告で承認なしに通った操作」の一覧を作る。インシデント後に最初に欲しくなる情報
  6. 本物を起動して比べる。 cd ~/cloudflare-os && pnpm run-local で承認 UI を見て、このレクチャーの printActionPrompt が何を捨てているかを確認する

参照

  • Cloudflare OS: https://github.com/cloudflare/cloudflare-os(ローカル clone: ~/cloudflare-os。2026-08 の early access リリース)
  • 読んだ一次資料: packages/mcp-shared/{README.md, src/tools.ts, src/action-store.ts, src/session.ts} / packages/workshop-shared/src/gatekeeper.ts / packages/workshop-backend/src/{overseer.ts, auto-approval.ts} / ルート README.mdAGENTS.md
  • 関連レクチャー: guardrails_basics(確率の防御 vs 構造の防御・権限ゲート)/ sandbox_basics(能力そのものの隔離)/ mastra_basics ex07(同期 HITL の suspend / resume と対比)
  • 手順・実行方法: README.md

作成: 2026-08-14 / 最終更新: 2026-08-14