Permission ソースコード解説
モジュール概要
Permission モジュールは OpenCode のセキュリティゲートキーパーです。Agent がツールを呼び出そうとするたび — ファイルの読み取り、書き込み、Bash コマンドの実行 — Permission モジュールが介入し、3 段階の判断を行います:自動許可、ブロック・拒否、または一時停止してユーザーに確認。「完全な自律性」と「安全で制御可能」のバランスを見つけることが、Permission モジュールの中核的な設計目標です。
コア設計の選択:
- Effect Deferred 非同期モデル:ask/reply は
Effect.Deferredを使用して一時停止/再開を行い、キャンセルとタイムアウトをネイティブにサポートします - last-match-wins ルール評価:設定ファイル内で後のルールが前のルールを上書きします。「一般的なルールを先に宣言し、例外を後に宣言する」という直感に適合します
- Wildcard パターンマッチング:
*は.*に、?は.にマッピングされ、マッチング前にパス区切り文字が正規化されます - Bus イベントブロードキャスト:
Event.Asked/Event.Repliedは Bus 経由でブロードキャストされ、CLI と TUI の両方が購読できます - プロジェクトレベルの永続化:ユーザーの
always決定は SQLite に書き込まれ、project_idにバインドされるため、プロジェクト間の権限漏洩を防止します - カスケード拒否とバッチ承認:1 つのリクエストを拒否すると、同じセッション内の保留中のすべてのリクエストがカスケード拒否されます。
always承認は他の満たされた保留リクエストを自動的にチェックして承認します
Permission は Effect.ts の ServiceMap.Service アーキテクチャに基づいて実装され、Bus イベントシステムを通じて非同期の「質問・回答」フローを完了し、SQLite を通じてユーザーの認可決定を永続化します。
主要ファイル
| ファイルパス | 行数 | 責任範囲 |
|---|---|---|
src/permission/index.ts | ~326 | モジュールのメインエントリ:Service 定義、ask/reply フロー、fromConfig 解析、expand パス展開、カスケードロジック |
src/permission/evaluate.ts | ~15 | 純粋関数:last-match-wins ルール評価、findLast セマンティクス |
src/permission/schema.ts | ~18 | PermissionID 型定義 |
src/permission/arity.ts | ~163 | BashArity 辞書:Bash コマンドの引数数マッピング |
src/session/session.sql.ts | — | PermissionTable 定義、Ruleset の SQLite ストレージ |
src/util/wildcard.ts | — | Wildcard.match / Wildcard.all、ワイルドカードパターンマッチングエンジン |
型システム
Action — 権限アクション三状態
Permission モジュールは Zod 列挙型を使用して 3 つのアクションレベルを定義します:
const Action = z.enum(["allow", "deny", "ask"]).meta({ ref: "PermissionAction" });
- allow:自動許可、Agent は待機せず、ツールが直接実行されます
- ask:実行を一時停止し、Deferred を介して明示的なユーザー承認を非同期に待機します
- deny:直接拒否、
DeniedErrorをスローし、Agent はエラー信号を受信します
注意:
confirmではなくaskです。askは「質問」のセマンティクスを強調し、以下のReply型に対応します。
Reply — ユーザー応答三状態
ユーザーが問い合わせに応答する際、Reply 列挙型が使用されます:
const Reply = z.enum(["once", "always", "reject"]);
- once:今回のみ許可、同様の操作は次回も質問が必要です
- always:決定を記憶、
approved: Rulesetに書き込み、SQLite に永続化し、以降自動許可します - reject:今回拒否、Agent は
RejectedErrorを受信します
Rule — 単一権限ルール
権限ルールは 3 つの次元で構成されます:
const Rule = z.object({
permission: z.string(), // ツール/操作名、例:"read"、"bash"、"mcp.github.create_issue"
pattern: z.string(), // ファイルパスまたはコマンドパターン、例:"src/**"、".env"、"rm *"
action: Action, // allow / deny / ask
});
- permission:ツール名。組み込みツールには
read、edit、bash、glob、grepなどがあります。MCP ツールはserver.tool形式(例:github.create_issue)を使用します - pattern:マッチング範囲。ファイルパス(
"src/**")、コマンドパターン("rm *")、またはワイルドカード("*"はすべてにマッチ)を使用できます - action:ルールがマッチした際のアクション
Ruleset — ルールコレクション
Ruleset は Rule[] の型エイリアスです。権限評価時、すべてのルールが順番にマッチングされ、最後にマッチしたルールが有効になります(last-match-wins)。
type Ruleset = Rule[]
Ruleset には 2 つのソースがあります:
- configRules:Config ファイルから読み込まれた初期ルール
- approvedRules:ランタイムでユーザーの「always」決定によって蓄積されたルール
評価時、両者は 1 つの配列にマージされます:evaluate(permission, pattern, configRules, approvedRules)。
PendingEntry — 非同期保留リクエスト
type PendingEntry = {
deferred: Effect.Deferred<...> // 非同期一時停止ポイント
request: PermissionRequest // 元のリクエスト情報
}
evaluate() が ask を返すと、Permission は PendingEntry を作成し pending Map に保存します。deferred は Effect の非同期プリミティブです — Promise に似ていますが、外部からの resolve/fail をサポートします。
EDIT_TOOLS — 編集ツールコレクション
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
これは読み取り操作と書き込み操作を区別するための組み込み定数です。特定のロジック(デフォルト権限ポリシーなど)は、ツールが EDIT_TOOLS に属するかどうかに基づいて初期アクションを決定します。
コアフロー
ルール評価:last-match-wins(evaluate.ts)
evaluate() は Permission モジュール全体の意思決定コアであり、純粋で副作用のない関数です:
import { Wildcard } from "@/util/wildcard"
type Rule = {
permission: string
pattern: string
action: "allow" | "deny" | "ask"
}
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
const rules = rulesets.flat()
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
主要な設計ポイント:
- 複数 Ruleset のフラット化:可変長引数
...rulesetsを受け取り、通常[configRules, approvedRules]を単一の配列にフラット化します - 二重マッチング:各ルールは
permissionとpatternの両方の次元でマッチする必要があります - findLast セマンティクス:最後にマッチしたルールが勝ちます。これにより「先にすべて拒否し、特定パスを許可する」宣言パターンが可能になります
- デフォルトは ask:ルールがマッチしない場合、
{ action: "ask" }を返します — 安全第一、もう一度確認する方が良いという方針です
Wildcard マッチングエンジン(wildcard.ts)
Wildcard.match() は glob パターンを正規表現に変換してマッチングを行います:
import { sortBy, pipe } from "remeda"
export namespace Wildcard {
export function match(str: string, pattern: string) {
// パス区切り文字の正規化:Windows のバックスラッシュ → スラッシュ
if (str) str = str.replaceAll("\\", "/")
if (pattern) pattern = pattern.replaceAll("\\", "/")
// Glob → 正規表現:
// 1. 正規表現の特殊文字をエスケープ(. + ^ $ { } ( ) | [ ] \)
let escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*") // * → .*(任意の文字列にマッチ)
.replace(/\?/g, ".") // ? → . (任意の 1 文字にマッチ)
// 末尾の " .*" パターンをオプションとして処理
// 例:"src" は "src" と "src/xxx" の両方にマッチ
if (escaped.endsWith(" .*")) {
escaped = escaped.slice(0, -3) + "( .*)?"
}
// プラットフォーム適応:Windows では大文字小文字を区別しない
const flags = process.platform === "win32" ? "si" : "s"
return new RegExp("^" + escaped + "$", flags).test(str)
}
}
マッチングルールの詳細:
| Glob パターン | 正規表現 | マッチ例 |
|---|---|---|
* | ^.*$ | すべての文字列にマッチ |
src/** | ^src/.*$ | src/ 配下のすべてのパスにマッチ |
.env | ^\.env$ | .env に完全一致 |
*.ts | ^.*\.ts$ | すべての .ts ファイルにマッチ |
rm * | ^rm .*$ | rm で始まるすべてのコマンドにマッチ |
パス正規化:マッチング前に Windows の \ を / に置換し、クロスプラットフォームでの一貫性を確保します。s フラグにより . は改行にもマッチします。
fromConfig — 設定解析
fromConfig() は Config の権限設定を Ruleset に解析します。2 つの形式をサポートします:
// 形式 1:短縮形式(アクションのみ宣言)
const config1 = {
permission: {
bash: "allow", // すべての bash 操作を許可
edit: "deny", // すべての edit 操作を拒否
}
}
// 解析結果:
// [
// { permission: "bash", pattern: "*", action: "allow" },
// { permission: "edit", pattern: "*", action: "deny" },
// ]
// 形式 2:オブジェクト形式(きめ細かな制御)
const config2 = {
permission: [
{ permission: "edit", pattern: "src/**", action: "allow" },
{ permission: "edit", pattern: ".env", action: "deny" },
{ permission: "bash", pattern: "git *", action: "allow" },
]
}
短縮形式は内部的に pattern: "*" のルールに変換されます。オブジェクト形式はパスレベルのきめ細かな制御をサポートします。
expand — パス展開
expand() 関数はルールマッチング前にパスを展開します:
function expand(path: string): string {
// ~ → ユーザーのホームディレクトリ(homedir)
// $HOME → 実際のパス
// ${VAR} → 環境変数の値
return path
.replace(/^~/, homedir())
.replace(/\$HOME/g, homedir())
.replace(/\$\{(\w+)\}/g, (_, varName) => process.env[varName] ?? "")
}
これにより、ユーザーは設定で ~/.ssh/* や $HOME/projects/** などのパスを使用でき、Permission がマッチング時に自動的に実際のパスに展開します。
ask/reply 非同期フロー
evaluate() が ask を返すと、Permission は非同期インタラクションに入ります:
Agent ツール呼び出し(例:edit("src/app.ts"))
│
▼
Permission.ask({ permission: "edit", patterns: ["src/app.ts"] })
│
├─ disabled("edit") チェック
│ └─ ツールがグローバルに deny の場合 → 直接 DeniedError をスロー
│
├─ evaluate(configRules + approvedRules, "edit", "src/app.ts")
│ ├─ { action: "allow" } を返す → 直接許可、return
│ ├─ { action: "deny" } を返す → DeniedError をスロー
│ └─ { action: "ask" } を返す → 以下の非同期フローに進む
│
├─ PendingEntry を構築
│ └─ { deferred: Effect.Deferred.make(), request }
│
├─ pending.set(id, entry) // 保留キューに保存
│
├─ Bus.publish(Event.Asked, request) // 質問イベントをブロードキャスト
│ └─ CLI / TUI がこのイベントを購読し、確認 UI をレンダリング
│
└─ Effect.await(deferred) // 一時停止、ユーザーの応答を待機
│ // Agent のツール呼び出しはここで一時停止
│
▼
ユーザーが CLI で "once" / "always" / "reject" を選択
│
▼
Permission.reply(id, reply)
│
├─ pending Map から PendingEntry を取得
│
├─ reply = "once" の場合:
│ ├─ Deferred.succeed() // 一時停止を解除、ツールの実行を継続
│ └─ pending.delete(id) // 保留キューをクリーンアップ
│
├─ reply = "always" の場合:
│ ├─ approved.add(matchedRule) // 承認済みルールセットに追加
│ ├─ persistToSQLite(approved) // PermissionTable に永続化
│ ├─ checkPendingRequests() // 他の保留リクエストをバッチチェック
│ │ └─ 同じセッション内の満たされた保留リクエストを自動承認
│ ├─ Deferred.succeed() // 一時停止を解除
│ └─ pending.delete(id)
│
├─ reply = "reject" の場合:
│ ├─ cascadeReject(sessionID) // セッション内の全保留リクエストをカスケード拒否
│ ├─ Deferred.fail(RejectedError) // 現在のリクエストが失敗
│ └─ pending.delete(id)
│
└─ Bus.publish(Event.Replied, { id, reply }) // 応答イベントをブロードキャスト
カスケード拒否(cascadeReject)
ユーザーが権限リクエストを拒否すると、Permission は同じセッション内のすべての保留リクエストをカスケード拒否します:
function cascadeReject(sessionID: string) {
// pending Map を反復
for (const [id, entry] of pending) {
if (entry.request.sessionID === sessionID) {
// 同じセッションの保留リクエストをすべて拒否
Deferred.fail(entry.deferred, new RejectedError(entry.request))
pending.delete(id)
Bus.publish(Event.Replied, { id, reply: "reject" })
}
}
}
設計意図:ユーザーがリクエストを拒否した場合、通常は Agent の現在の操作シーケンスをもう信頼していないことを意味します。カスケード拒否により、無意味な確認プロンプトの連続を回避し、ユーザーが迅速に制御を取り戻せるようにします。
always バッチ承認
ユーザーが always を選択すると、Permission は現在のリクエストを承認するだけでなく、同じセッション内の他の保留リクエストが新しいルールによって自動的に満たされるかをチェックします:
function checkPendingRequests(sessionID: string, newRule: Rule) {
for (const [id, entry] of pending) {
if (entry.request.sessionID === sessionID) {
// 新しいルールで保留リクエストを再評価
const result = evaluate(
entry.request.permission,
entry.request.pattern,
[newRule], // 新しく承認されたルール
)
if (result.action === "allow") {
// 新しいルールによりこの保留リクエストも満たされた
Deferred.succeed(entry.deferred)
pending.delete(id)
Bus.publish(Event.Replied, { id, reply: "always" })
}
}
}
}
シナリオ例:Agent が連続して 3 つの edit("src/xxx") リクエストを発行し、最初のものが ask をトリガーします。ユーザーが always を選択すると、後の 2 つのリクエストは新しいルールによって自動的に満たされ、さらなる確認が不要になります。
disabled — グローバルツール無効チェック
disabled() 関数は ask() の初期段階で呼び出され、ツールがグローバルに deny されているかをチェックします:
function disabled(tool: string): boolean {
// すべての ruleset で { permission: tool, pattern: "*", action: "deny" } を検索
// これは高速拒否パスであり、完全な evaluate フローをスキップします
const rules = [...configRules, ...approvedRules]
const result = evaluate(tool, "*", rules)
return result.action === "deny"
}
disabled() がツールに対して true を返した場合、ask() は非同期待機フローに入らずに DeniedError をスローします。これはパフォーマンスの最適化です — 必然的に拒否されるリクエストに対して Deferred と Bus イベントを作成するのを回避します。
BashArity — Bash コマンド引数数
arity.ts は Bash コマンドの引数数辞書を管理します:
const BashArity: Record<string, number> = {
"rm": 1, // rm は最低 1 つの引数が必要(ファイル名)
"git": 1, // git は最低 1 つの引数が必要(サブコマンド)
"npm": 1, // npm は最低 1 つの引数が必要(サブコマンド)
"node": 1, // node は最低 1 つの引数が必要(スクリプトパス)
"cat": 1, // cat は最低 1 つの引数が必要(ファイル名)
"echo": 0, // echo は引数なしで実行可能
"ls": 0, // ls は引数なしで実行可能
// ... その他のコマンド
}
この辞書は権限パターンマッチング時のコマンド分割に使用されます — Bash コマンドの「ツール名」と「引数」の境界を決定します。例えば、rm -rf /tmp/test では、rm がツール名、-rf /tmp/test が引数です。Arity 情報は Permission がコマンドを正しく permission と pattern の部分に分割するのに役立ちます。
ユーザー決定の永続化
ユーザーが always を選択した際のルールは SQLite に永続化されます:
// session.sql.ts
const PermissionTable = sqliteTable("permission", {
project_id: text("project_id").primaryKey(),
// ...Timestamps
data: text("mode", { mode: "json" }).$type<Permission.Ruleset>(),
});
- 各プロジェクト(
project_id)が独自のRulesetを独立して保存します fromConfig()は設定から初期ルールを読み込み、approvedルールはランタイムで追加されますlist()は現在のプロジェクトのすべての永続化ルール(config + approved のマージ結果)を返します- 永続化は Session と SQLite データベースを共有し、
project_id経由で関連付けられます
エラー型
Permission モジュールは異なるシナリオに応じて 3 つのエラー型を定義します:
// ユーザーが今回の操作を拒否
class RejectedError {
request: PermissionRequest // 拒否された元のリクエスト
}
// ユーザーが修正内容を提供
class CorrectedError {
feedback: string // ユーザーの修正メモ
request: PermissionRequest // 元のリクエスト
}
// 操作がルールにより直接拒否された
class DeniedError {
ruleset: Ruleset // deny をトリガーしたルールセット(デバッグ用)
request: PermissionRequest // 拒否されたリクエスト
}
- RejectedError:ユーザーが能動的に拒否。Agent はこれをキャッチして戦略を調整または操作を中止できます
- CorrectedError:ユーザーが拒否しただけでなく修正内容も提供。
feedbackフィールドにユーザーの具体的な指示が含まれ、Agent はそれに基づいて再計画できます。ユーザーが Agent の方向に同意しない場合に特に有用です - DeniedError:設定ルールにより直接拒否。
rulesetフィールドは開発者が「なぜ操作が拒否されたか」をデバッグするのに役立ちます
呼び出しチェーンの例
チェーン 1:Agent がファイルを編集(ask → always)
Agent がツール呼び出し edit(path="src/app.ts") を発行
│
▼
Permission.ask({
permission: "edit",
patterns: ["src/app.ts"],
sessionID: "sess-abc",
})
│
├─ disabled("edit") → false(グローバルに無効化されていない)
│
├─ evaluate("edit", "src/app.ts", configRules, approvedRules)
│ ├─ すべてのルールを反復(findLast セマンティクス):
│ │ rule: { permission: "read", pattern: "*", action: "allow" } → permission が "edit" にマッチしない
│ │ rule: { permission: "edit", pattern: ".env", action: "deny" } → pattern が "src/app.ts" にマッチしない
│ │ マッチするルールはもうない
│ └─ デフォルトを返す:{ action: "ask", permission: "edit", pattern: "*" }
│
├─ action = "ask" → 非同期フローに入る
│ ├─ Deferred.make() → 非同期一時停止ポイントを作成
│ ├─ pending.set("req-1", { deferred, request })
│ ├─ Bus.publish(Event.Asked, { id: "req-1", permission: "edit", pattern: "src/app.ts" })
│ └─ await(deferred) → Agent のツール呼び出しが一時停止
│
▼
CLI が確認プロンプトをレンダリング:
"Agent が src/app.ts の編集を要求 [一度だけ許可] [常に許可] [拒否]"
│
▼
ユーザーが "常に許可" を選択
│
▼
Permission.reply("req-1", "always")
│
├─ approved.add({ permission: "edit", pattern: "src/app.ts", action: "allow" })
├─ PermissionTable.update(projectID, approved) → SQLite 書き込み
│
├─ checkPendingRequests("sess-abc", newRule)
│ └─ pending Map を反復、同じセッションの他のリクエストが新しいルールで満たされるかチェック
│ └─ 他の保留リクエストはない
│
├─ Deferred.succeed(deferred) → Agent のツール呼び出しが再開
├─ pending.delete("req-1")
└─ Bus.publish(Event.Replied, { id: "req-1", reply: "always" })
チェーン 2:Bash コマンドが deny ルールによりブロック
Agent がツール呼び出し bash(command="rm -rf /") を発行
│
▼
Permission.ask({
permission: "bash",
patterns: ["rm -rf /"],
sessionID: "sess-abc",
})
│
├─ disabled("bash") → false
│
├─ evaluate("bash", "rm -rf /", configRules, approvedRules)
│ ├─ configRules 内:
│ │ { permission: "bash", pattern: "rm -rf *", action: "deny" }
│ │ → Wildcard.match("rm -rf /", "rm -rf *") → true ✓
│ │ → Wildcard.match("bash", "bash") → true ✓
│ │ → マッチ!action = "deny"
│ └─ 返す:{ permission: "bash", pattern: "rm -rf *", action: "deny" }
│
├─ action = "deny" → 直接 DeniedError をスロー
│ └─ トリガーとなったルールを含む { permission: "bash", pattern: "rm -rf *", action: "deny" }
│
▼
Agent が DeniedError をキャッチ → 戦略を調整、コマンドを実行しない
チェーン 3:MCP ツール呼び出し(ask → once)
Agent が MCP ツール github.create_issue を呼び出し
│
▼
Permission.ask({
permission: "github.create_issue",
patterns: ["create issue with title..."],
sessionID: "sess-abc",
})
│
├─ disabled("github.create_issue") → false
│
├─ evaluate("github.create_issue", "...", configRules, approvedRules)
│ └─ マッチするルールなし → デフォルト { action: "ask", ... }
│
├─ 非同期フロー → Bus.publish(Event.Asked)
│
▼
CLI が確認プロンプトをレンダリング:
"Agent が github.create_issue の呼び出しを要求 [一度だけ許可] [常に許可] [拒否]"
│
▼
ユーザーが "一度だけ許可" を選択
│
▼
Permission.reply("req-2", "once")
│
├─ Deferred.succeed() → Agent のツール呼び出しが再開
├─ pending.delete("req-2")
└─ Bus.publish(Event.Replied, { id: "req-2", reply: "once" })
│
▼
次回 Agent が github.create_issue を呼び出す → まだ質問が必要("once" のみ承認されたため)
チェーン 4:カスケード拒否シナリオ
Agent が 3 つの並列ツール呼び出しを発行(同じセッション):
req-1: edit("src/a.ts") → ask(保留中)
req-2: edit("src/b.ts") → ask(保留中)
req-3: bash("npm test") → ask(保留中)
pending Map:
req-1 → { deferred: d1, request: { sessionID: "sess-abc", ... } }
req-2 → { deferred: d2, request: { sessionID: "sess-abc", ... } }
req-3 → { deferred: d3, request: { sessionID: "sess-abc", ... } }
ユーザーが req-1 で "拒否" を選択
│
▼
Permission.reply("req-1", "reject")
│
├─ Deferred.fail(d1, RejectedError) → req-1 が失敗
│
├─ cascadeReject("sess-abc")
│ ├─ req-2 は同じセッションに属する → Deferred.fail(d2, RejectedError)
│ │ └─ Bus.publish(Event.Replied, { id: "req-2", reply: "reject" })
│ └─ req-3 は同じセッションに属する → Deferred.fail(d3, RejectedError)
│ └─ Bus.publish(Event.Replied, { id: "req-3", reply: "reject" })
│
└─ 3 つのリクエストすべてが拒否され、Agent は 3 つの RejectedError を受信 → 現在の操作シーケンスを停止
チェーン 5:always バッチ承認シナリオ
Agent が連続して 3 つの edit リクエストを発行(同じセッション):
req-1: edit("src/a.ts") → ask(保留中)
req-2: edit("src/b.ts") → ask(保留中)
req-3: edit("src/c.ts") → ask(保留中)
ユーザーが req-1 で "常に許可" を選択
│
▼
Permission.reply("req-1", "always")
│
├─ approved.add({ permission: "edit", pattern: "src/a.ts", action: "allow" })
│
├─ checkPendingRequests("sess-abc", newRule)
│ ├─ req-2: evaluate("edit", "src/b.ts", [newRule])
│ │ → pattern "src/a.ts" は "src/b.ts" にマッチしない → 満たされない
│ └─ req-3: evaluate("edit", "src/c.ts", [newRule])
│ → pattern "src/a.ts" は "src/c.ts" にマッチしない → 満たされない
│
└─ req-1 のみが承認、req-2/req-3 は引き続きユーザーの確認が必要
--- 比較シナリオ:ユーザーが "src/**" パターンで許可した場合 ---
ルールが { permission: "edit", pattern: "src/**", action: "allow" } の場合
checkPendingRequests("sess-abc", newRule)
├─ req-2: evaluate("edit", "src/b.ts", [newRule])
│ → Wildcard.match("src/b.ts", "src/**") → true ✓
│ → 自動承認!Deferred.succeed(d2)
└─ req-3: evaluate("edit", "src/c.ts", [newRule])
→ Wildcard.match("src/c.ts", "src/**") → true ✓
→ 自動承認!Deferred.succeed(d3)
チェーン 6:Doom Loop 検出での Permission 呼び出し
SessionProcessor が Agent による同じツールの連続 3 回呼び出し(同じ引数)を検出
│
▼
Permission.ask({
permission: "doom_loop",
patterns: [toolName], // 例:["edit"]
sessionID: "sess-abc",
})
│
├─ evaluate("doom_loop", "edit", configRules, approvedRules)
│ └─ 通常は専用ルールなし → デフォルト ask
│
├─ CLI プロンプト:
│ "Agent が edit を連続 3 回呼び出しました(同じ引数)、続行しますか?"
│
▼
ユーザーが "常に許可" を選択
│ → approved.add({ permission: "doom_loop", pattern: "edit", action: "allow" })
│ → 同じツールの以降の doom loop は再度質問されない
設計上のトレードオフ
| 決定 | 理由 |
|---|---|
| last-match-wins vs first-match-wins | last-match-wins を選択したのは、設定ファイル内で後のルールが通常より具体的だからです(例:先に * を deny し、その後 src/** を allow)。これは「一般的なルールを先に宣言し、例外を後に宣言する」という直感に適合します。Apache や Nginx などの成熟したシステムのルール戦略とも一致します |
| Effect Deferred 非同期モデル | コールバックや Promise に比べ、Effect Deferred はキャンセル(AbortController 経由)とタイムアウトをネイティブにサポートし、長時間実行される Agent シナリオに適しています。Deferred は外部から resolve/fail でき、Permission の ask/reply パターンに完全に一致します |
| プロジェクトレベルの永続化 | 権限決定はグローバルではなく project_id にバインドされ、プロジェクト間の権限漏洩を防止します。プロジェクト A で always により承認された権限は、プロジェクト B に自動的に適用されません |
| カスケード拒否 | 1 つのリクエストを拒否すると、同じセッション内のすべての保留リクエストがカスケード拒否され、Agent が拒否後に無意味な操作シーケンスを継続するのを防止します |
| always バッチチェック | always 後、同じセッション内の他の保留リクエストが新しいルールで満たされるかを自動的にチェックし、ユーザーの繰り返し確認の回数を削減します |
| Wildcard 正規表現変換 | glob パターンを正規表現に変換する方が、カスタム glob マッチャーを書くよりもコードが簡潔で正確性が保証されます。Permission のコンテキストではパフォーマンスのボトルネックになりません(ルール数は通常 100 未満) |
| パス正規化 | マッチング前に \ を / に置換し、Windows と Unix システムの両方で一貫したルール動作を確保します |
| 純粋関数 evaluate | evaluate() は副作用のない純粋関数であり、テストと理解が容易です。すべての副作用(永続化、イベントブロードキャスト)は ask()/reply() で処理されます |
| catchall のデフォルトは ask | Config の Permission Schema の catchall(PermissionAction.default("ask")) は、新しいツールタイプが誤って許可されないことを保証します |
| evaluate.ts の分離 | 評価ロジックを 15 行の純粋関数ファイルに分離し、index.ts の Service ロジックと疎結合にし、ユニットテストを容易にします |
他のモジュールとの関係
- Agent:Agent は各ツール呼び出しの実行前に
Permission.ask()を呼び出し、承認または拒否を待機します。Agent はRejectedError/DeniedErrorをキャッチして戦略を調整できます。Doom Loop 検出もPermission.ask()を通じてユーザーの確認を要求します - Config:権限ルールは Config の
permissionフィールドからPermission.fromConfig()を通じて読み込まれます。Config のPermissionSchema は 14 以上の操作に対して独立したルールを定義し、catchallにより新しいツールが安全に保たれます - CLI / TUI:
Event.Askedを購読して確認 UI をレンダリングし、ユーザーの操作がPermission.reply()をトリガーします。TUI はPermission.list()を通じて現在の権限ルールを表示することもできます - MCP:MCP ツール呼び出しも Permission 管理下にあり、
permissionフィールドはserver.tool形式(例:github.create_issue)を使用します - Session:
PermissionTableはsession.sql.tsで定義され、Session と SQLite データベースを共有します。ask()のsessionIDパラメータはカスケード拒否とバッチ承認のグループ化基準として使用されます - Storage:永続化された
approvedRuleset は Storage 層を通じて SQLite のPermissionTableに書き込まれます - Bus:
Event.AskedとEvent.Repliedは Bus 経由でブロードキャストされ、すべての UI 層(CLI、TUI、API)がこれらのイベントを購読できます - Wildcard(util):
evaluate()はワイルドカードパターンマッチングのためにWildcard.match()に依存し、Permission 決定の基盤となる機能です