ある朝、半年ほど静かに動いていた Sheets 連携の自動化を少し直してデプロイしたところ、再認可の画面が出ました。そこに並んでいたのは「Gmail のすべてのメールの読み取り、作成、送信、完全な削除」。直したのは Sheets に一行書き足す処理だけで、Gmail には一切触れていません。
それでも、その権限を求められました。
理由は単純で、私が以前テストで GmailApp を一度呼び、その行をコメントアウトしただけで消し忘れていたからです。Apps Script はコードを静的に走査して「使われていそうな API」からスコープを自動推定します。コメントの中の一行が残っていれば、それを根拠に最大級のスコープを要求してくることがあります。
個人開発で、私はいくつもの自動化を Workspace 上に抱えています。そうなると、この「自動推定されたスコープ」が時間とともに静かに膨らみます。本稼働しているスクリプトが、本来必要のない読み書き権限をいつの間にか握っている。これは事故が起きたときの被害範囲(blast radius)を無自覚に広げる、地味ですが重い負債です。
その負債を断つために、Gmail・Sheets・Gemini API をまたぐ典型的な自動化を題材として、appsscript.json で必要最小限のスコープを宣言し、肥大を CI で検知し、スコープ変更時の再同意事故を避けるところまでを扱います。
なぜ自動推定スコープが危険なのか
Apps Script のスコープには、二つの決め方があります。
一つは、何も宣言しないとコードから自動推定される方法。もう一つは、appsscript.json の oauthScopes フィールドに自分で明示する方法です。後者を書いた瞬間、自動推定は止まり、宣言したスコープ「だけ」が要求されます。
自動推定が危険なのは、次の三点が重なるからです。
| 問題 | 具体的に何が起きるか |
| 過大なスコープを引き当てる | GmailApp.search() を1回呼ぶだけで「メールの完全な読み書き・削除」スコープが付く。読み取りだけのつもりでも削除権限まで握る |
| 死んだコードが権限を生む | コメントアウトや到達不能な分岐に残ったAPI呼び出しからもスコープが推定される。実際には使っていない権限を要求する |
| 変化が見えない | 誰がいつどの権限を増やしたかが履歴に残らない。レビュー対象にならず、肥大に気づけない |
最小権限(least privilege)の原則は「そのコードが今この瞬間に必要とする権限だけを持つ」ことです。自動推定はこの原則と本質的に相性が悪いのです。
題材にする自動化
具体例で考えます。次のような、私自身が実際に運用しているのに近い構成を想定します。
- Gmail の特定ラベルの未読メールを読む(送信も削除もしない)
- 本文を Gemini API に渡して要約・分類する
- 結果を1枚のスプレッドシートに追記する
この自動化に「本当に必要な権限」は、突き詰めると次の三つだけです。
- Gmail を読むだけ(
gmail.readonly)
- 自分が作った特定のスプレッドシートへの読み書き(
spreadsheets.currentonly)
- 外部 HTTP リクエスト(Gemini API を叩くため。
script.external_request)
メールの送信権限も、Drive 全体の権限も要りません。自動推定に任せると、ここに送信・削除権限まで紛れ込みます。
appsscript.json に明示スコープを宣言する
Apps Script エディタの「プロジェクトの設定」で「appsscript.json マニフェスト ファイルをエディタで表示する」を有効にすると、マニフェストを直接編集できます。oauthScopes を次のように書きます。
{
"timeZone": "Asia/Tokyo",
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/spreadsheets.currentonly",
"https://www.googleapis.com/auth/script.external_request"
]
}
ポイントは三つあります。
gmail.readonly を選ぶことで、削除も送信もできない読み取り専用に絞れます。GmailApp のうち書き込み系メソッドを呼ぶとこのスコープでは実行時に失敗するので、「読むだけ」が強制されます。これは制約ではなく安全装置です。
spreadsheets.currentonly は、スクリプトに「紐づいたスプレッドシート1枚」だけにアクセスを限定します。spreadsheets(全スプレッドシート)ではなく currentonly を選べる場面では、必ずこちらにします。コンテナバインドのスクリプト(スプレッドシートに紐づいたスクリプト)でのみ使えます。
script.external_request は UrlFetchApp を使うために必要です。Gemini API は Apps Script に専用クライアントがないため、REST を UrlFetchApp で叩きます。
Gemini を最小スコープで呼ぶ
外部リクエストのスコープだけで Gemini を呼ぶコードは次の通りです。API キーはスクリプトプロパティに保存し、コードに直書きしません。
function summarizeWithGemini(text) {
const apiKey = PropertiesService
.getScriptProperties()
.getProperty('GEMINI_API_KEY');
if (!apiKey) throw new Error('GEMINI_API_KEY is not set');
const model = 'gemini-2.5-flash';
const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
+ model + ':generateContent';
const payload = {
contents: [{
role: 'user',
parts: [{ text: '次のメールを2文で要約してください:\n\n' + text }]
}],
generationConfig: { temperature: 0.2, maxOutputTokens: 256 }
};
const res = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
headers: { 'x-goog-api-key': apiKey },
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const code = res.getResponseCode();
if (code !== 200) {
throw new Error('Gemini API error ' + code + ': ' + res.getContentText());
}
const data = JSON.parse(res.getContentText());
return data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
}
API キーを Authorization ヘッダではなく x-goog-api-key で渡しているのは、Gemini の Generative Language API がこの形式を受け付けるためです。スクリプトプロパティに置くことで、コードを共有・コピーしても鍵が漏れません。
メールを読む側も、書き込み系メソッドを一切呼ばないように書きます。
function processUnread() {
const threads = GmailApp.search('label:to-summarize is:unread', 0, 20);
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
threads.forEach(function (thread) {
const msg = thread.getMessages()[0];
const summary = summarizeWithGemini(msg.getPlainBody());
sheet.appendRow([new Date(), msg.getSubject(), summary]);
// 既読化は markRead を使わず、ラベル運用で代替する。
// markRead は gmail.modify スコープを要求し、最小権限を崩すため。
});
}
ここで意図的に thread.markRead() を使っていません。markRead は gmail.modify を要求し、せっかく gmail.readonly に絞った意味が消えるからです。「既読管理は別ラベルの付け外しで」と割り切るより、ここでは Sheets 側に処理済みの記録を持たせ、Gmail 側は読むだけに徹する設計にしています。最小権限を守るために、機能の実現方法を一段ずらす。この判断こそが設計の本体だと考えています。
スコープ肥大を CI で検知する
明示スコープを宣言しても、後から誰か(未来の自分を含む)が広いスコープを足してしまえば元の木阿弥です。そこで、宣言済みスコープを「許可リスト」と突き合わせ、想定外のスコープが混入したら失敗する検査を入れます。
clasp でローカルに引いた appsscript.json を、次の Node スクリプトで検査します。許可リストにないスコープが1つでもあれば exit 1 で止まります。
// scope-audit.mjs — appsscript.json のスコープを許可リストと突き合わせる
import { readFileSync } from 'node:fs';
const ALLOWED = new Set([
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/spreadsheets.currentonly',
'https://www.googleapis.com/auth/script.external_request',
]);
// 特に危険なスコープ(混入したら即失敗させたい)
const FORBIDDEN = new Set([
'https://mail.google.com/', // Gmail 全権
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/drive', // Drive 全権
'https://www.googleapis.com/auth/spreadsheets', // 全スプレッドシート
]);
const manifest = JSON.parse(readFileSync('./appsscript.json', 'utf8'));
const scopes = manifest.oauthScopes ?? [];
if (scopes.length === 0) {
console.error('FAIL: oauthScopes が未宣言です(自動推定に任せると肥大します)');
process.exit(1);
}
const forbidden = scopes.filter((s) => FORBIDDEN.has(s));
const unexpected = scopes.filter((s) => !ALLOWED.has(s));
if (forbidden.length) {
console.error('FAIL: 禁止スコープが混入:\n ' + forbidden.join('\n '));
}
if (unexpected.length) {
console.error('FAIL: 許可リスト外のスコープ:\n ' + unexpected.join('\n '));
}
if (forbidden.length || unexpected.length) process.exit(1);
console.log('OK: ' + scopes.length + ' scopes, すべて許可リスト内');
このスクリプトを GitHub Actions の push 時に走らせれば、「Gmail の読み取りだけのはずが、いつのまにか送信権限が増えていた」という事故をマージ前に止められます。スコープは機能ではなくセキュリティ境界なので、コードレビューと同じ重みでレビュー対象に載せるべきだと考えています。
許可リストを増やすときは、その差分が必ず pull request に現れます。「なぜこのスコープが必要になったか」を一行コメントで残す運用にすると、半年後の自分が助かります。
スコープ変更が招く「全員再同意」の罠
最小権限を追い求めるうえで、見落としやすい運用上の落とし穴があります。oauthScopes を変更すると、すでに認可済みのユーザーにも再同意を求めるという挙動です。
自分一人で使うスクリプトなら一度承認し直すだけです。しかし、組織で配布していたり、アドオンとして複数人に使ってもらっている場合、スコープを1つ足すだけで全ユーザーに再認可のフローが走ります。これを軽く考えると、ある朝ユーザー全員が「権限が変わりました」という画面に直面し、問い合わせが殺到します。
避け方は、スコープ変更を「リリース」として扱うことです。
- 変更が本当に必要か、機能側で回避できないかをまず疑う(先ほどの
markRead の例のように)
- 足すスコープと理由を、変更ログとリリースノートに明記する
- 配布物なら、再同意が必要になる旨を事前に告知してから出す
- 可能なら、複数のスコープ変更を1回のリリースにまとめ、再同意の回数を最小化する
スコープを「コードの一部」ではなく「ユーザーとの契約」として扱うと、この運用は自然と身につきます。
既存スクリプトを最小権限へ移す手順
すでに動いている、スコープが膨らんだスクリプトを締め直すときは、いきなり本番を変えないことです。次の順で進めると安全でした。
まず現状のスコープを棚卸しします。Apps Script の「プロジェクトの設定」や、Google アカウントのセキュリティ設定にある「サードパーティのアクセス」から、そのスクリプトが実際に握っている権限を確認します。
次に appsscript.json に、棚卸しした中で本当に使っているものだけを oauthScopes として明示します。この時点で自動推定は止まります。
そのうえで、テスト用のコピープロジェクトで一通り実行し、足りないスコープがあれば実行時エラーで判明します。エラーが出たスコープだけを、理由を添えて許可リストに足す。この「エラーで足りない分を見つける」進め方は、推測で広めに付けるより確実で、結果として最小に収束します。
最後に CI へ scope-audit.mjs を組み込み、二度と肥大しないようにします。
運用して見えた勘所
私自身、しばらくこのやり方で複数の自動化を回してみて、いくつか公式ドキュメントには書かれていない感触がありました。
gmail.readonly と gmail.modify の境界は、思っているより頻繁に踏みます。「未読を既読にする」「ラベルを付け替える」といった一見軽い操作が modify を引き込みます。読むだけで完結する設計に寄せると、スコープも自然に軽くなります。
spreadsheets.currentonly は強力ですが、コンテナバインドのスクリプトでしか使えません。スタンドアロンのスクリプトから複数シートを触る構成にしてしまうと、spreadsheets(全権)に逆戻りします。シート連携を作るなら、最初からコンテナバインドにしておくと最小権限を保ちやすいです。
そして何より、スコープは一度広げると縮めにくい、という非対称性があります。広げる変更は再同意で済みますが、縮めても古い同意は残ることがあり、ユーザー体験上きれいに反映されないことがあります。だからこそ、最初に狭く始めることが何よりの近道でした。
権限を絞る作業は派手さがなく、機能を増やすわけでもありません。それでも、自動化が静かに動き続ける前提を支えているのは、この地味な境界線だと感じています。同じように Workspace 上で自動化を育てている方の、点検のきっかけになれば幸いです。
関連して、Apps Script で Workspace を横断する自動化そのものの組み方はGemini × Apps Script の業務自動化マスタークラスで、組織配布時のポリシー設計は管理者向けの組織展開ガイドで扱っています。