Scope-Lockの実装

This commit is contained in:
Keisuke Hirata 2026-04-18 19:25:03 +09:00
parent 4ba58723dc
commit a7b9b6fa4b
9 changed files with 1196 additions and 4 deletions

27
Cargo.lock generated
View File

@ -735,6 +735,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
dependencies = [
"rustix 1.1.4",
"windows-sys 0.59.0",
]
[[package]]
name = "futures"
version = "0.3.32"
@ -1388,9 +1398,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.184"
version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "libredox"
@ -1893,8 +1903,10 @@ dependencies = [
"chrono",
"clap",
"dotenv",
"fs4",
"futures",
"include_dir",
"libc",
"llm-worker",
"manifest",
"minijinja",
@ -2244,7 +2256,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -3583,6 +3595,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"

View File

@ -138,6 +138,22 @@ impl Scope {
self.allow.iter().map(|r| r.target.as_path())
}
/// Allow rules with their targets resolved to absolute paths.
///
/// Used by the scope-lock registry, where every Pod's allocation
/// must be expressed in absolute terms so prefix comparisons are
/// meaningful across processes.
pub fn allow_rules(&self) -> Vec<ScopeRule> {
self.allow
.iter()
.map(|r| ScopeRule {
target: r.target.clone(),
permission: r.permission,
recursive: r.recursive,
})
.collect()
}
/// Iterate over absolute paths granted `Write` by an allow rule.
/// Subset of [`readable_paths`](Self::readable_paths).
pub fn writable_paths(&self) -> impl Iterator<Item = &Path> {

View File

@ -22,6 +22,8 @@ tools = { version = "0.1.0", path = "../tools" }
minijinja = "2.19.0"
chrono = "0.4.44"
include_dir = "0.7.4"
fs4 = { version = "0.13.1", features = ["sync"] }
libc = "0.2.185"
[dev-dependencies]
async-trait = "0.1.89"

View File

@ -2,6 +2,7 @@ pub mod controller;
pub mod hook;
pub mod notifier;
pub mod runtime_dir;
pub mod scope_lock;
pub mod shared_state;
pub mod socket_server;

View File

@ -23,6 +23,8 @@ use crate::notification_buffer::NotificationBuffer;
use crate::notifier::Notifier;
use crate::pod_interceptor::PodInterceptor;
use crate::prompt_loader::PromptLoader;
use crate::runtime_dir;
use crate::scope_lock::{self, ScopeAllocationGuard, ScopeLockError};
use crate::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::usage_tracker::UsageTracker;
use protocol::{NotificationLevel, NotificationSource};
@ -105,6 +107,13 @@ pub struct Pod<C: LlmClient, St: Store> {
/// injection into the next LLM request. Shared with the
/// PodInterceptor installed in `ensure_interceptor_installed`.
pending_notifications: NotificationBuffer,
/// Scope allocation in the machine-wide lock file. `Some` for
/// Pods built via `from_manifest` (production path); `None` for
/// lower-level constructors (`Pod::new`, `Pod::restore`) that
/// bypass the registry. Kept purely for its `Drop` impl, which
/// releases the allocation when the Pod is dropped.
#[allow(dead_code)]
scope_allocation: Option<ScopeAllocationGuard>,
}
impl<C: LlmClient, St: Store> Pod<C, St> {
@ -146,6 +155,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
system_prompt_template: None,
notifier: None,
pending_notifications: NotificationBuffer::new(),
scope_allocation: None,
};
pod.apply_prune_from_manifest();
Ok(pod)
@ -195,6 +205,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
system_prompt_template: None,
notifier: None,
pending_notifications: NotificationBuffer::new(),
scope_allocation: None,
};
pod.apply_prune_from_manifest();
Ok(pod)
@ -875,6 +886,20 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
return Err(PodError::PwdOutsideScope { pwd });
}
// Register this Pod in the machine-wide scope-lock registry
// before building anything else, so a spawn that conflicts on
// scope fails fast (and without having paid for client setup).
let socket_path = runtime_dir::default_base()
.map_err(ScopeLockError::from)?
.join(&manifest.pod.name)
.join("sock");
let scope_allocation = scope_lock::install_top_level(
manifest.pod.name.clone(),
std::process::id(),
socket_path,
scope.allow_rules(),
)?;
let client = provider::build_client(&manifest.provider)?;
let mut worker = Worker::new(client);
apply_worker_manifest(&mut worker, &manifest.worker);
@ -910,6 +935,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
system_prompt_template,
notifier: None,
pending_notifications: NotificationBuffer::new(),
scope_allocation: Some(scope_allocation),
};
pod.apply_prune_from_manifest();
Ok(pod)
@ -1058,6 +1084,9 @@ pub enum PodError {
#[source]
source: SystemPromptError,
},
#[error(transparent)]
ScopeLock(#[from] ScopeLockError),
}
/// Canonicalize an absolute pwd (resolves symlinks and any `.`/`..`

View File

@ -86,7 +86,11 @@ async fn atomic_write(target: &Path, content: &[u8]) -> Result<(), io::Error> {
}
/// Resolve the default base directory for runtime data.
fn default_base() -> Result<PathBuf, io::Error> {
///
/// Public so the scope-lock registry (which lives outside the
/// `RuntimeDir` instance lifecycle) can predict a Pod's socket path
/// without constructing a `RuntimeDir` first.
pub fn default_base() -> Result<PathBuf, io::Error> {
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
Ok(PathBuf::from(runtime_dir).join("insomnia"))
} else if let Ok(home) = std::env::var("HOME") {

1014
crates/pod/src/scope_lock.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,10 @@
# Scope lock file: write 排他とスコープ分譲の記録基盤
## レビュー状態
初回レビュー実施済み。[scope-lock.review.md](scope-lock.review.md) を参照。
指摘1件ファイルパーミッション 0600 の明示設定)の修正を条件に受け入れ可。
## 背景
Pod オーケストレーションでは scope の分譲spawner が自身の scope を spawned Pod に譲渡)が発生する。また、人間が独立に複数の Pod を起動した場合にも同一パスへの write 衝突を検出する必要がある。

View File

@ -0,0 +1,100 @@
# レビュー: Scope lock file
対象差分: `crates/pod/src/scope_lock.rs` (新規 992行), `crates/manifest/src/scope.rs`, `crates/pod/src/{pod,lib,runtime_dir}.rs`, `crates/pod/Cargo.toml`(未コミット)
## 要件達成状況
| 要件 | 状態 |
|---|---|
| lock file に Pod の scope allocation を記録 | ✅ `register_pod` / `delegate_scope` で JSON に書き込み |
| flock による排他アクセス | ✅ `LockFileGuard::open``lock_exclusive`、Drop で `unlock` |
| write 衝突の検出 | ✅ `find_conflict_owner` が delegation tree を下降して真の所有者を特定 |
| stale エントリの自動回収 (PID 死活) | ✅ `reclaim_stale``kill(pid, 0)` で判定、dead を削除・子を reparent |
| scope 分譲の記録 (`delegated_from`) | ✅ `delegate_scope` が spawner の effective scope 内か検証してから登録 |
| 分譲チェーンの reparent (A→B→D、B 死亡時 D を A に付け替え) | ✅ `release_pod` / `reclaim_stale` ともに `delegated_from` を親に付け替え |
| Pod 正常終了時の allocation 解放 | ✅ `ScopeAllocationGuard` の Drop で `release_pod` を呼ぶ |
| Pod 起動時 (`from_manifest`) に自動登録 | ✅ `scope_lock::install_top_level``from_manifest` 内で呼出 |
| テストで衝突検出・stale 回収・分譲/返却を検証 | ✅ 16 テストケース |
| パーミッション制御 (0600) | — チケットに記載あるが、ファイル作成時の umask 制御は未実装。`OpenOptions` に mode 設定なし |
## アーキテクチャ
### 良い点
**`LockFileGuard` の RAII 設計**: `open` で flock 取得、`save` で書き込み、Drop で unlock。mutate-but-don't-save のパスでは変更が破棄される(エラー時に安全)。
**`ScopeAllocationGuard` で Pod ライフサイクルに紐付け**: Pod 構造体が `Option<ScopeAllocationGuard>` を保持し、Drop で lock file から自動削除。`Pod::new` / `Pod::restore`(テスト用)は `None` でバイパス。
**`find_conflict_owner` が delegation tree を下降**: 衝突エラーに「真の所有者」(最深のノード)を表示。`conflict_detection_descends_to_real_owner` テストで lock-in。
**`reclaim_stale_with` のテストシーム**: PID 生存判定を引数で差し替え可能。テストで任意の PID を「dead」にできる。
**`is_within_effective_write`**: spawner の allow set から子に委譲済みの部分を差し引いた「実効 scope」を計算。`delegate_scope` のバリデーションに使用。
**`rules_overlap` の 4 パターン分岐** (recursive×recursive, recursive×non, non×recursive, non×non): 非再帰ルールの覆域target 自身 + 直下の子)を正しく考慮。
### 指摘事項
#### 1. 🟡 ファイルパーミッションの明示設定
チケットの「セキュリティとアクセス」セクション:
> ファイルパーミッション `0600`owner only、ディレクトリは `0700`
`LockFileGuard::open``OpenOptions::new().create(true)` で作成しているが、パーミッションの明示設定がない。umask がデフォルト (0022) なら `0644` になり、他ユーザーから読めてしまう。
```rust
// 現状
let file = OpenOptions::new()
.read(true).write(true).create(true).truncate(false)
.open(path)?;
// 修正案: Unix 拡張で mode を設定
use std::os::unix::fs::OpenOptionsExt;
let file = OpenOptions::new()
.read(true).write(true).create(true).truncate(false)
.mode(0o600)
.open(path)?;
```
ディレクトリも `create_dir_all` の後に `std::fs::set_permissions``0700` に制限する。
**判断**: セキュリティ要件。修正すべき。
#### 2. 🟢 `socket` フィールドの予測パス
`pod.rs` で socket path を `runtime_dir::default_base()?.join(&manifest.pod.name).join("sock")` と予測しているが、実際の `RuntimeDir` が作る socket path と一致するか保証がない(`RuntimeDir` のパス構築ロジックが変わったら乖離する)。
現時点では一致しているが、将来 `RuntimeDir` のパス規則が変わったとき scope_lock の socket 情報が嘘になる。`RuntimeDir` 側に `fn socket_path_for(pod_name: &str) -> PathBuf` のような static メソッドを置いて、両者が同じ関数を呼ぶようにするとより堅牢。
**判断**: 現時点では問題なし。リファクタ余地として認識。
#### 3. 🟢 `covers_fully` の permission 比較
```rust
fn covers_fully(cover: &ScopeRule, inner: &ScopeRule) -> bool {
if cover.permission < inner.permission {
return false;
}
...
}
```
`Permission``Ord``Read < Write`。`cover.permission < inner.permission` = cover Read inner Write なら不十分」。正しい
#### 4. 🟢 テストの網羅性
16 ケース:
- ファイル操作: open creates / save-reopen roundtrip
- overlap 判定: prefix relation / non-recursive
- 登録: write conflict / duplicate name / read doesn't conflict
- 分譲: must be subset / succeeds within parent / rejects sibling overlap
- 解放: reparents children / reopens scope / returns to parent
- stale: reparents and removes dead entries
- guard: releases on drop
- conflict detection: descends to real owner
delegation tree の主要シナリオ (A→B→D) がカバーされている。
## 結論
**指摘1 (ファイルパーミッション 0600) の修正を条件に受け入れ可**。他は問題なし。実装の核心delegation tree walk, effective scope 計算, stale reclaim + reparentが正確で、テストが充実している。