9.2 KiB
9.2 KiB
Scope の再設計
背景
Scope は Pod がどのファイルにどの権限でアクセスできるかを宣言するもの。 現状の問題:
Pod.scopeがOption<Scope>— scope なし Pod が成立しうる(ツール未登録)Scopeはroot: PathBufのみ — 書き込み許可/禁止の概念がない- 単一ディレクトリしか宣言できない — 「src は書ける、docs は読むだけ」が表現できない
- pwd(作業ディレクトリ)と「アクセス可能領域」が同じ概念に押し込まれている
- 将来の複数 Pod 排他制御に必要な「権限つき領域宣言」のデータ構造がない
方針
pwd と scope を分離
- pwd は Pod の作業ディレクトリ。
[pod]テーブルで指定。Glob/Grep のデフォルト検索パスにもなる。 - scope は「許可された領域」と「禁止された領域」のリスト。
[scope]テーブルで指定。 - pwd は scope の allow に read 以上で含まれていなければならない。違反は manifest ロード時エラー。
permission のレベル格子モデル
permission は単一文字列で能力レベルを表す:
| 値 | レベル | 意味 |
|---|---|---|
"read" |
1 | 読める |
"write" |
2 | 読み書きできる |
- allow: 「このレベル以上を許可」。
permission = "write"は読み書き両方を grant。 - deny: 「このレベル未満まで引き下げる」。
permission = "write"は write だけ落として read は残す。permission = "read"は read を落とすので write も連鎖して落ちる(write は read を要求するため)= 完全アクセス不可。 - 有効権限 =
min( max(allow), min(deny) )。allow にマッチしない path は scope 外(アクセス不可)。 - 順序非依存。
マニフェスト
[pod]
name = "agent-foo"
pwd = "./src"
[scope]
[[scope.allow]]
target = "./src"
permission = "write"
recursive = true # 省略可、デフォルト true
[[scope.allow]]
target = "./docs"
permission = "read"
[[scope.deny]]
target = "./src/secrets.rs"
permission = "write" # → ./src/secrets.rs は read-only に格下げ
[[scope.deny]]
target = "./src/.env"
permission = "read" # → アクセス不可
ルール:
[scope]は 省略不可。なければ manifest エラー。[[scope.allow]]は 最低 1 件必須。空 scope は無意味。[[scope.deny]]は省略可。permissionは allow / deny のどちらでも必須。recursive省略時はtrue。falseのとき、target ディレクトリ直下のエントリのみが対象(サブディレクトリは降りない)。- pwd は allow のいずれかで read 以上にマッチしなければならない。
- pwd が deny で read を落とされていたらエラー。
Rust 型
// crates/manifest/src/lib.rs
pub struct PodMeta {
pub name: String,
pub pwd: PathBuf,
}
pub struct ScopeConfig {
pub allow: Vec<ScopeRule>,
pub deny: Vec<ScopeRule>,
}
pub struct ScopeRule {
pub target: PathBuf,
pub permission: Permission,
#[serde(default = "default_recursive")]
pub recursive: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum Permission {
Read = 1,
Write = 2,
}
// crates/manifest/src/scope.rs
pub struct Scope {
allow: Vec<ScopeRule>,
deny: Vec<ScopeRule>,
}
impl Scope {
/// Effective permission for `path`. `None` means out-of-scope.
pub fn permission_at(&self, path: &Path) -> Option<Permission>;
/// Convenience: writable iff effective permission ≥ Write.
pub fn is_writable(&self, path: &Path) -> bool;
/// Convenience: readable iff effective permission ≥ Read.
pub fn is_readable(&self, path: &Path) -> bool;
}
permission_at 計算:
- allow 群のうち path にマッチするもの(recursive 考慮)から最大 permission を取る。なければ
Noneを返す。 - deny 群のうち path にマッチするものから最小 permission を取る(あれば、その値未満にキャップ)。
- キャップ後のレベルが 0 なら
None、ReadならSome(Read)、WriteならSome(Write)。
Pod で必須化
pub struct Pod<C, St> {
pwd: PathBuf,
scope: Scope, // Option ではない
// ...
}
impl<C: LlmClient, St: Store> Pod<C, St> {
pub async fn new(
manifest: PodManifest,
worker: Worker<C>,
store: St,
pwd: PathBuf,
scope: Scope,
) -> Result<Self, PodError> { /* ... */ }
pub async fn restore(
session_id: SessionId,
manifest: PodManifest,
client: C,
store: St,
pwd: PathBuf,
scope: Scope,
) -> Result<Self, PodError> { /* ... */ }
pub fn pwd(&self) -> &Path { &self.pwd }
pub fn scope(&self) -> &Scope { &self.scope }
}
from_manifest 系は manifest の [pod].pwd と [scope] から pwd / scope を構築する。整合性チェック(pwd が allow に含まれる、deny で潰されていない)はここで行う。
ScopedFs の変更
pub fn read(&self, path: &Path) -> Result<Vec<u8>, ToolsError> {
if !self.inner.scope.is_readable(path) {
return Err(ToolsError::OutOfScope(path.to_path_buf()));
}
// ...
}
pub fn write(&self, path: &Path, content: &[u8]) -> Result<WriteOutcome, ToolsError> {
if !self.inner.scope.is_writable(path) {
// 読めるが書けない場合と、そもそも見えない場合を区別
return Err(if self.inner.scope.is_readable(path) {
ToolsError::ReadOnly(path.to_path_buf())
} else {
ToolsError::OutOfScope(path.to_path_buf())
});
}
// ...
}
Glob/Grep のデフォルト検索パスは Pod の pwd。targets 全走査はしない。
絶対パスでの検索結果は is_readable でフィルタする。
Controller 統合
// 今: scope が Some のときだけツール登録
if let Some(scope) = scope_for_tools { ... }
// 後: 常にツール登録(scope は必須)
let fs = tools::ScopedFs::new(pod.scope().clone(), pod.pwd().to_path_buf());
let tracker = tools::Tracker::new();
worker.register_tools(tools::builtin_tools(fs, tracker));
影響範囲
-
crates/manifest/src/lib.rsPodMetaにpwd: PathBufを追加ScopeConfigを{ allow: Vec<ScopeRule>, deny: Vec<ScopeRule> }に置き換え(旧rootフィールド削除)Permissionenum、ScopeRule構造体を追加PodManifest::scopeをOption<ScopeConfig>→ScopeConfigに(省略不可)- manifest ロード時の整合性チェック(scope 必須、allow 1 件以上、pwd と allow/deny の関係)
-
crates/manifest/src/scope.rsScope構造体を allow/deny ベースに作り直しpermission_at/is_readable/is_writable- 旧
Scope::containsは削除(呼び出し元はis_readable/is_writableに置換)
-
crates/pod/src/pod.rsscope: Option<Scope>→scope: Scopepwd: PathBufフィールド追加Pod::new/Pod::restoreシグネチャ変更Pod::scope()の戻り値をOption<&Scope>→&Scopeにfrom_manifest系は manifest から pwd / scope を取り出して新シグネチャに渡す
-
crates/tools/src/scoped_fs.rs- 内部の scope 参照を新 API(
is_readable/is_writable)に置換 pwdを別途保持(Glob/Grep のデフォルト検索パス用)- 既存の境界チェックを置換
- 内部の scope 参照を新 API(
-
crates/tools/src/error.rsReadOnly(PathBuf)variant を追加- 既存の
OutOfScopeは維持
-
crates/pod/src/controller.rsscopeの Optional 分岐を削除し、常時ツール登録
-
crates/pod/src/prune.rs等の scope 参照箇所Option<&Scope>前提の処理を&Scope前提に書き換え
-
既存テスト
manifestのパースケース更新(新 TOML 構文)tools::scoped_fsのテスト更新(権限レベル別、deny 部分化のケース追加)
確認済み論点
- target の解決基準:
[scope]内のtargetは manifest の[pod].pwdからの相対パスとして解決する。manifest ロード時に pwd と合わせて絶対パスへ正規化。 - deny 合成: マッチする deny が複数あれば min(permission) を取り、その値未満にキャップ。順序非依存・冪等。
- Glob/Grep の検索起点: 既存の
pathパラメータを保持し、省略時のデフォルトを旧scope.root()から pwd に切り替えるだけ。pwd 外に絶対パスで検索された場合、結果はis_readableでフィルタする。越境検索禁止の特別ロジックは入れない。
非ゴール
- 複数 Pod 間の scope 排他制御 は本 ticket では扱わない。別 ticket [scope-exclusion.md] に切り出す。本 ticket では「将来 read 共有 / write 排他の単位として
Scopeを使えるようにデータ構造を整える」までで止める。 - bash ツールへの execute 権限 は本 ticket では扱わない。bash-tool ticket / permission-extension-point ticket で扱う。
Permissionは当面Read/Writeの 2 値。