diff --git a/Cargo.lock b/Cargo.lock index a407d4a7..aed8cfd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1444,6 +1444,7 @@ version = "0.1.0" dependencies = [ "serde", "tempfile", + "thiserror 2.0.18", "toml", ] diff --git a/TODO.md b/TODO.md index d0ae9570..dd3c5d1b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,8 @@ - [ ] テスト設計 → [tickets/test-design.md](tickets/test-design.md) - [ ] ツール設計 - [ ] Bash ツール (Permission 層と統合) → [tickets/bash-tool.md](tickets/bash-tool.md) -- [ ] Scope の再設計 (pwd + writable、必須化) → [tickets/scope-redesign.md](tickets/scope-redesign.md) +- [ ] Scope の再設計 (pwd 分離、allow/deny、必須化) → [tickets/scope-redesign.md](tickets/scope-redesign.md) +- [ ] 複数 Pod 間の Scope 排他制御 → [tickets/scope-exclusion.md](tickets/scope-exclusion.md) - [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md) - [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md) - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index 2e17cfb9..0e396240 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] serde = { version = "1.0.228", features = ["derive"] } +thiserror = "2.0.18" toml = "1.1.2" [dev-dependencies] diff --git a/crates/manifest/README.md b/crates/manifest/README.md index a02189a5..1cb56524 100644 --- a/crates/manifest/README.md +++ b/crates/manifest/README.md @@ -5,9 +5,9 @@ Pod の宣言的設定を TOML マニフェストとして定義・パースす ## 公開型 - `PodManifest` — Pod 設定全体(`from_toml()` でパース) -- `PodMeta` — Pod メタデータ(名前) +- `PodMeta` — Pod メタデータ(名前、pwd) - `ProviderConfig` — LLM プロバイダ設定(種別、モデル、APIキー環境変数、ベースURL) - `ProviderKind` — プロバイダ種別(`Anthropic`, `Openai`, `Gemini`, `Ollama`) - `WorkerManifest` — ワーカー設定(システムプロンプト、max_tokens、temperature) -- `ScopeConfig` — スコープ設定(ルートディレクトリ) -- `Scope` — ディレクトリスコープの実行時チェック(`contains()` でパス包含判定) +- `ScopeConfig` / `ScopeRule` / `Permission` — allow / deny の宣言的スコープ設定 +- `Scope` — 実行時スコープ。`from_config(&ScopeConfig, pwd)` で構築し、`is_readable` / `is_writable` / `permission_at` で問い合わせる diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 91404d92..a43712d0 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -1,6 +1,6 @@ mod scope; -pub use scope::Scope; +pub use scope::{Scope, ScopeError}; use std::num::NonZeroU32; use std::path::PathBuf; @@ -10,14 +10,13 @@ use serde::{Deserialize, Serialize}; /// Declarative configuration for a Pod. /// /// Parsed from a TOML manifest file. Describes the provider, model, -/// system prompt, and optional directory scope. +/// system prompt, and directory scope (required). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PodManifest { pub pod: PodMeta, pub provider: ProviderConfig, pub worker: WorkerManifest, - #[serde(default)] - pub scope: Option, + pub scope: ScopeConfig, #[serde(default)] pub compaction: Option, } @@ -26,6 +25,9 @@ pub struct PodManifest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PodMeta { pub name: String, + /// Working directory for the Pod. Relative paths are resolved against + /// the directory containing the manifest file. + pub pwd: PathBuf, } /// LLM provider configuration. @@ -79,10 +81,53 @@ pub struct WorkerManifest { pub temperature: Option, } -/// Directory scope configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Declarative scope configuration. +/// +/// A Pod may only touch paths whose effective permission (computed from +/// allow/deny rules below) is at least `Read` / `Write`. See +/// [`Scope`] for the resolved runtime form. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ScopeConfig { - pub root: PathBuf, + /// Rules granting access. At least one entry is required for the + /// scope to be meaningful; [`Scope::from_config`] enforces this. + #[serde(default)] + pub allow: Vec, + /// Rules capping access below the stated permission level. Empty by + /// default. + #[serde(default)] + pub deny: Vec, +} + +/// A single allow or deny rule inside [`ScopeConfig`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopeRule { + /// Target path. Relative paths are resolved against the Pod's pwd + /// when [`Scope::from_config`] runs. + pub target: PathBuf, + /// Permission level this rule grants (allow) or caps strictly below + /// (deny). + pub permission: Permission, + /// When `false`, the rule only matches the target itself and its + /// direct children. Defaults to `true`. + #[serde(default = "default_recursive")] + pub recursive: bool, +} + +fn default_recursive() -> bool { + true +} + +/// Permission lattice used by [`ScopeRule`]. +/// +/// The derived `Ord` instance follows declaration order, so +/// `Read < Write`. Allow rules grant the stated level (and by extension +/// everything below); deny rules cap the effective level **strictly +/// below** the stated level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Permission { + Read, + Write, } /// Context compaction configuration. @@ -146,24 +191,32 @@ impl PodManifest { mod tests { use super::*; - #[test] - fn parse_minimal_manifest() { - let toml = r#" + const MINIMAL_REQUIRED: &str = r#" [pod] name = "test-agent" +pwd = "./" [provider] kind = "anthropic" model = "claude-sonnet-4-20250514" [worker] + +[[scope.allow]] +target = "./" +permission = "write" "#; - let manifest = PodManifest::from_toml(toml).unwrap(); + + #[test] + fn parse_minimal_manifest() { + let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); assert_eq!(manifest.pod.name, "test-agent"); + assert_eq!(manifest.pod.pwd, PathBuf::from("./")); assert_eq!(manifest.provider.kind, ProviderKind::Anthropic); assert_eq!(manifest.provider.model, "claude-sonnet-4-20250514"); assert!(manifest.provider.api_key_file.is_none()); - assert!(manifest.scope.is_none()); + assert_eq!(manifest.scope.allow.len(), 1); + assert!(manifest.scope.deny.is_empty()); assert!(manifest.worker.system_prompt.is_none()); } @@ -172,6 +225,7 @@ model = "claude-sonnet-4-20250514" let toml = r#" [pod] name = "code-reviewer" +pwd = "./src" [provider] kind = "anthropic" @@ -183,11 +237,22 @@ system_prompt = "You are a code reviewer." max_tokens = 4096 temperature = 0.3 -[scope] -root = "./src" +[[scope.allow]] +target = "./" +permission = "write" + +[[scope.allow]] +target = "../docs" +permission = "read" +recursive = false + +[[scope.deny]] +target = "./secrets.rs" +permission = "write" "#; let manifest = PodManifest::from_toml(toml).unwrap(); assert_eq!(manifest.pod.name, "code-reviewer"); + assert_eq!(manifest.pod.pwd, PathBuf::from("./src")); assert_eq!( manifest.provider.api_key_file.as_deref(), Some(std::path::Path::new("~/.config/insomnia/keys/anthropic")) @@ -198,83 +263,37 @@ root = "./src" ); assert_eq!(manifest.worker.max_tokens, Some(4096)); assert_eq!(manifest.worker.temperature, Some(0.3)); - assert_eq!( - manifest.scope.as_ref().unwrap().root, - PathBuf::from("./src") - ); + let allow = &manifest.scope.allow; + assert_eq!(allow.len(), 2); + assert_eq!(allow[0].permission, Permission::Write); + assert!(allow[0].recursive); + assert_eq!(allow[1].permission, Permission::Read); + assert!(!allow[1].recursive); + assert_eq!(manifest.scope.deny.len(), 1); + assert_eq!(manifest.scope.deny[0].permission, Permission::Write); } #[test] - fn parse_ollama_no_api_key() { + fn reject_missing_scope() { let toml = r#" [pod] -name = "local-agent" - -[provider] -kind = "ollama" -model = "llama3" - -[worker] -"#; - let manifest = PodManifest::from_toml(toml).unwrap(); - assert_eq!(manifest.provider.kind, ProviderKind::Ollama); - assert!(manifest.provider.api_key_file.is_none()); - } - - #[test] - fn parse_max_turns() { - let toml = r#" -[pod] -name = "test" +name = "missing-scope" +pwd = "./" [provider] kind = "anthropic" model = "claude-sonnet-4-20250514" [worker] -max_turns = 50 -"#; - let manifest = PodManifest::from_toml(toml).unwrap(); - assert_eq!(manifest.worker.max_turns.unwrap().get(), 50); - } - - #[test] - fn omitted_max_turns_is_none() { - let toml = r#" -[pod] -name = "test" - -[provider] -kind = "anthropic" -model = "claude-sonnet-4-20250514" - -[worker] -"#; - let manifest = PodManifest::from_toml(toml).unwrap(); - assert!(manifest.worker.max_turns.is_none()); - } - - #[test] - fn reject_max_turns_zero() { - let toml = r#" -[pod] -name = "test" - -[provider] -kind = "anthropic" -model = "claude-sonnet-4-20250514" - -[worker] -max_turns = 0 "#; assert!(PodManifest::from_toml(toml).is_err()); } #[test] - fn parse_compaction_config() { + fn reject_missing_pwd() { let toml = r#" [pod] -name = "test" +name = "missing-pwd" [provider] kind = "anthropic" @@ -282,10 +301,36 @@ model = "claude-sonnet-4-20250514" [worker] -[compaction] -compact_threshold = 80000 +[[scope.allow]] +target = "./" +permission = "write" "#; - let manifest = PodManifest::from_toml(toml).unwrap(); + assert!(PodManifest::from_toml(toml).is_err()); + } + + #[test] + fn parse_max_turns() { + let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n"); + let manifest = PodManifest::from_toml(&toml).unwrap(); + assert_eq!(manifest.worker.max_turns.unwrap().get(), 50); + } + + #[test] + fn omitted_max_turns_is_none() { + let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); + assert!(manifest.worker.max_turns.is_none()); + } + + #[test] + fn reject_max_turns_zero() { + let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 0\n"); + assert!(PodManifest::from_toml(&toml).is_err()); + } + + #[test] + fn parse_compaction_config() { + let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\ncompact_threshold = 80000\n"); + let manifest = PodManifest::from_toml(&toml).unwrap(); let c = manifest.compaction.unwrap(); assert_eq!(c.prune_protected_turns, 3); assert_eq!(c.prune_min_savings, 4096); @@ -295,24 +340,15 @@ compact_threshold = 80000 #[test] fn parse_compaction_with_provider() { - let toml = r#" -[pod] -name = "test" - -[provider] -kind = "anthropic" -model = "claude-sonnet-4-20250514" - -[worker] - -[compaction] -compact_threshold = 80000 - -[compaction.provider] -kind = "gemini" -model = "gemini-2.0-flash" -"#; - let manifest = PodManifest::from_toml(toml).unwrap(); + let toml = format!( + "{MINIMAL_REQUIRED}\n\ + [compaction]\n\ + compact_threshold = 80000\n\n\ + [compaction.provider]\n\ + kind = \"gemini\"\n\ + model = \"gemini-2.0-flash\"\n" + ); + let manifest = PodManifest::from_toml(&toml).unwrap(); let c = manifest.compaction.unwrap(); let p = c.provider.unwrap(); assert_eq!(p.kind, ProviderKind::Gemini); @@ -321,32 +357,25 @@ model = "gemini-2.0-flash" #[test] fn omitted_compaction_is_none() { - let toml = r#" -[pod] -name = "test" - -[provider] -kind = "anthropic" -model = "claude-sonnet-4-20250514" - -[worker] -"#; - let manifest = PodManifest::from_toml(toml).unwrap(); + let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); assert!(manifest.compaction.is_none()); } #[test] fn reject_unknown_provider() { - let toml = r#" -[pod] -name = "test" + let toml = MINIMAL_REQUIRED.replace("kind = \"anthropic\"", "kind = \"unknown_provider\""); + assert!(PodManifest::from_toml(&toml).is_err()); + } -[provider] -kind = "unknown_provider" -model = "x" - -[worker] -"#; - assert!(PodManifest::from_toml(toml).is_err()); + #[test] + fn default_recursive_true() { + let rule: ScopeRule = toml::from_str( + r#" +target = "./" +permission = "read" +"#, + ) + .unwrap(); + assert!(rule.recursive); } } diff --git a/crates/manifest/src/scope.rs b/crates/manifest/src/scope.rs index 0ca9e6c7..01f41932 100644 --- a/crates/manifest/src/scope.rs +++ b/crates/manifest/src/scope.rs @@ -1,115 +1,349 @@ +//! Runtime representation of a Pod's access scope. +//! +//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`] once the +//! Pod's pwd (working directory) has been resolved to an absolute path. +//! All rule `target` paths inside the [`Scope`] are absolute and lexically +//! stable, so access checks are pure path comparisons. + +use std::ffi::OsString; use std::path::{Path, PathBuf}; -/// Directory scope constraining a Pod's write access. +use crate::{Permission, ScopeConfig, ScopeRule}; + +/// Parsed, pwd-resolved set of allow/deny rules for a Pod. /// -/// Read access is unrestricted — only write operations are checked against the scope. +/// Read/write access decisions are pure functions of the path being +/// queried and these rules — see [`Scope::permission_at`]. #[derive(Debug, Clone)] pub struct Scope { - root: PathBuf, + allow: Vec, + deny: Vec, +} + +#[derive(Debug, Clone)] +struct ResolvedRule { + /// Absolute, canonicalized-or-normalized target directory/file. + target: PathBuf, + permission: Permission, + recursive: bool, +} + +/// Errors raised when constructing a [`Scope`] from a [`ScopeConfig`]. +#[derive(Debug, thiserror::Error)] +pub enum ScopeError { + #[error("scope must declare at least one [[scope.allow]] rule")] + EmptyAllow, + #[error("scope base path must be absolute: {}", .0.display())] + BaseNotAbsolute(PathBuf), + #[error("failed to resolve scope target {}: {source}", .path.display())] + ResolveTarget { + path: PathBuf, + #[source] + source: std::io::Error, + }, } impl Scope { - /// Create a new scope rooted at the given directory. - /// - /// The path is canonicalized to resolve symlinks and relative components. - pub fn new(root: impl Into) -> std::io::Result { - let root = root.into().canonicalize()?; - Ok(Self { root }) + /// Build a [`Scope`] from a declarative [`ScopeConfig`], resolving + /// relative `target` paths against `base` (conventionally the Pod's + /// absolute pwd). + pub fn from_config(config: &ScopeConfig, base: &Path) -> Result { + if !base.is_absolute() { + return Err(ScopeError::BaseNotAbsolute(base.to_path_buf())); + } + if config.allow.is_empty() { + return Err(ScopeError::EmptyAllow); + } + let allow = config + .allow + .iter() + .map(|r| resolve_rule(r, base)) + .collect::, _>>()?; + let deny = config + .deny + .iter() + .map(|r| resolve_rule(r, base)) + .collect::, _>>()?; + Ok(Self { allow, deny }) } - /// The root directory of this scope. - pub fn root(&self) -> &Path { - &self.root + /// Convenience constructor for tests and simple setups: a single + /// recursive `allow(Write)` rule rooted at `root`. + pub fn writable(root: impl AsRef) -> std::io::Result { + let root = root.as_ref().canonicalize()?; + Ok(Self { + allow: vec![ResolvedRule { + target: root, + permission: Permission::Write, + recursive: true, + }], + deny: Vec::new(), + }) } - /// Check whether `path` falls within this scope. + /// Effective permission for `path`. /// - /// The path is canonicalized before comparison. If the path does not - /// exist yet (typical for new-file writes), the closest existing - /// ancestor is canonicalized and checked, so deep new directory - /// hierarchies inside the scope are also accepted. - pub fn contains(&self, path: &Path) -> bool { - let mut cur = path; - loop { - if let Ok(canonical) = cur.canonicalize() { - return canonical.starts_with(&self.root); - } - match cur.parent() { - Some(parent) if parent != cur => cur = parent, - _ => return false, + /// Returns `None` when `path` is outside every allow rule, or when + /// deny rules have knocked it below `Read`. + pub fn permission_at(&self, path: &Path) -> Option { + let resolved = resolve_path(path)?; + let mut effective: Option = None; + for rule in &self.allow { + if rule.matches(&resolved) { + effective = match effective { + None => Some(rule.permission), + Some(cur) => Some(cur.max(rule.permission)), + }; } } + let mut effective = effective?; + + // Deny: min(min_deny) dictates the cap. Effective level is capped + // strictly below that value, so deny(read) wipes access entirely. + let mut min_deny: Option = None; + for rule in &self.deny { + if rule.matches(&resolved) { + min_deny = match min_deny { + None => Some(rule.permission), + Some(cur) => Some(cur.min(rule.permission)), + }; + } + } + if let Some(cap) = min_deny { + match cap { + Permission::Read => return None, + Permission::Write => effective = effective.min(Permission::Read), + } + } + Some(effective) + } + + /// Shorthand: `permission_at(path) >= Some(Read)`. + pub fn is_readable(&self, path: &Path) -> bool { + matches!( + self.permission_at(path), + Some(Permission::Read | Permission::Write) + ) + } + + /// Shorthand: `permission_at(path) == Some(Write)`. + pub fn is_writable(&self, path: &Path) -> bool { + matches!(self.permission_at(path), Some(Permission::Write)) + } +} + +impl ResolvedRule { + fn matches(&self, path: &Path) -> bool { + if self.recursive { + path.starts_with(&self.target) + } else { + path == self.target || path.parent() == Some(self.target.as_path()) + } + } +} + +fn resolve_rule(rule: &ScopeRule, base: &Path) -> Result { + let joined = if rule.target.is_absolute() { + rule.target.clone() + } else { + base.join(&rule.target) + }; + let target = resolve_path(&joined).ok_or_else(|| ScopeError::ResolveTarget { + path: rule.target.clone(), + source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"), + })?; + Ok(ResolvedRule { + target, + permission: rule.permission, + recursive: rule.recursive, + }) +} + +/// Convert `path` to an absolute form suitable for prefix comparison. +/// +/// Tries `canonicalize` on the full path first (resolves symlinks). If +/// the path doesn't exist yet, climbs to the closest existing ancestor, +/// canonicalizes it, then rejoins the missing tail. Returns `None` for +/// relative inputs that have no existing ancestor to anchor against. +fn resolve_path(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + if let Ok(canonical) = path.canonicalize() { + return Some(canonical); + } + let mut tail: Vec = Vec::new(); + let mut cur = path.to_path_buf(); + loop { + if let Ok(canonical) = cur.canonicalize() { + let mut out = canonical; + for segment in tail.iter().rev() { + out.push(segment); + } + return Some(out); + } + let name = cur.file_name()?.to_os_string(); + tail.push(name); + let parent = cur.parent()?.to_path_buf(); + if parent == cur { + return None; + } + cur = parent; } } #[cfg(test)] mod tests { use super::*; - use std::fs; use tempfile::TempDir; - #[test] - fn contains_file_inside_scope() { - let dir = TempDir::new().unwrap(); - let scope = Scope::new(dir.path()).unwrap(); - - let file = dir.path().join("test.txt"); - fs::write(&file, "hello").unwrap(); - - assert!(scope.contains(&file)); + fn allow_rule(target: &Path, permission: Permission) -> ScopeRule { + ScopeRule { + target: target.to_path_buf(), + permission, + recursive: true, + } } #[test] - fn rejects_file_outside_scope() { + fn writable_shortcut_permits_root() { + let dir = TempDir::new().unwrap(); + let scope = Scope::writable(dir.path()).unwrap(); + assert!(scope.is_writable(&dir.path().join("a.txt"))); + assert!(scope.is_readable(&dir.path().join("a.txt"))); + } + + #[test] + fn writable_shortcut_rejects_outside() { let dir = TempDir::new().unwrap(); let outside = TempDir::new().unwrap(); - let scope = Scope::new(dir.path()).unwrap(); - - let file = outside.path().join("test.txt"); - fs::write(&file, "hello").unwrap(); - - assert!(!scope.contains(&file)); + let scope = Scope::writable(dir.path()).unwrap(); + assert!(!scope.is_readable(&outside.path().join("x"))); } #[test] - fn contains_new_file_in_existing_parent() { + fn allow_write_grants_read_and_write() { let dir = TempDir::new().unwrap(); - let scope = Scope::new(dir.path()).unwrap(); - - // File doesn't exist yet, but parent dir is inside scope - let new_file = dir.path().join("new.txt"); - assert!(scope.contains(&new_file)); + let cfg = ScopeConfig { + allow: vec![allow_rule(dir.path(), Permission::Write)], + deny: Vec::new(), + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + let f = dir.path().join("a.txt"); + assert_eq!(scope.permission_at(&f), Some(Permission::Write)); } #[test] - fn contains_nested_directory() { + fn allow_read_only() { let dir = TempDir::new().unwrap(); - let nested = dir.path().join("a/b/c"); - fs::create_dir_all(&nested).unwrap(); - let scope = Scope::new(dir.path()).unwrap(); + let cfg = ScopeConfig { + allow: vec![allow_rule(dir.path(), Permission::Read)], + deny: Vec::new(), + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + let f = dir.path().join("a.txt"); + assert_eq!(scope.permission_at(&f), Some(Permission::Read)); + assert!(scope.is_readable(&f)); + assert!(!scope.is_writable(&f)); + } - let file = nested.join("test.txt"); - assert!(scope.contains(&file)); + #[test] + fn deny_write_downgrades_to_read() { + let dir = TempDir::new().unwrap(); + let sub = dir.path().join("sub"); + std::fs::create_dir(&sub).unwrap(); + let cfg = ScopeConfig { + allow: vec![allow_rule(dir.path(), Permission::Write)], + deny: vec![allow_rule(&sub, Permission::Write)], + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + let f = sub.join("a.txt"); + assert_eq!(scope.permission_at(&f), Some(Permission::Read)); + // outside the deny, still writable. + assert_eq!( + scope.permission_at(&dir.path().join("top.txt")), + Some(Permission::Write) + ); + } + + #[test] + fn deny_read_removes_access_entirely() { + let dir = TempDir::new().unwrap(); + let secret = dir.path().join("secret.txt"); + std::fs::write(&secret, b"").unwrap(); + let cfg = ScopeConfig { + allow: vec![allow_rule(dir.path(), Permission::Write)], + deny: vec![allow_rule(&secret, Permission::Read)], + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + assert_eq!(scope.permission_at(&secret), None); + } + + #[test] + fn multiple_allow_rules_take_max() { + let dir = TempDir::new().unwrap(); + let docs = dir.path().join("docs"); + std::fs::create_dir(&docs).unwrap(); + let cfg = ScopeConfig { + allow: vec![ + allow_rule(dir.path(), Permission::Read), + allow_rule(&docs, Permission::Write), + ], + deny: Vec::new(), + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + assert_eq!( + scope.permission_at(&dir.path().join("a.txt")), + Some(Permission::Read) + ); + assert_eq!( + scope.permission_at(&docs.join("a.txt")), + Some(Permission::Write) + ); + } + + #[test] + fn non_recursive_rule_matches_direct_children_only() { + let dir = TempDir::new().unwrap(); + let nested = dir.path().join("a/b"); + std::fs::create_dir_all(&nested).unwrap(); + let cfg = ScopeConfig { + allow: vec![ScopeRule { + target: dir.path().to_path_buf(), + permission: Permission::Write, + recursive: false, + }], + deny: Vec::new(), + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + assert!(scope.is_writable(&dir.path().join("top.txt"))); + assert!(!scope.is_writable(&nested.join("deep.txt"))); + } + + #[test] + fn empty_allow_rejected() { + let dir = TempDir::new().unwrap(); + let cfg = ScopeConfig { + allow: Vec::new(), + deny: Vec::new(), + }; + let err = Scope::from_config(&cfg, dir.path()).unwrap_err(); + assert!(matches!(err, ScopeError::EmptyAllow)); } #[test] fn rejects_traversal_attack() { let dir = TempDir::new().unwrap(); - let scope = Scope::new(dir.path()).unwrap(); - + let scope = Scope::writable(dir.path()).unwrap(); let traversal = dir.path().join("../../../etc/passwd"); - assert!(!scope.contains(&traversal)); + assert!(!scope.is_readable(&traversal)); } #[test] - fn contains_deeply_nested_new_path() { + fn resolves_new_nested_file_inside_scope() { let dir = TempDir::new().unwrap(); - let scope = Scope::new(dir.path()).unwrap(); - - // Neither the file nor any of its ancestors (a, a/b, a/b/c) exist yet - // under the scope; contains should still accept because the closest - // existing ancestor (the scope root) is inside the scope. + let scope = Scope::writable(dir.path()).unwrap(); let deep = dir.path().join("a/b/c/new.txt"); - assert!(scope.contains(&deep)); + assert!(scope.is_writable(&deep)); } } diff --git a/crates/pod/examples/pod_cli.rs b/crates/pod/examples/pod_cli.rs index f811c5ce..83563a3a 100644 --- a/crates/pod/examples/pod_cli.rs +++ b/crates/pod/examples/pod_cli.rs @@ -17,6 +17,7 @@ use session_store::FsStore; const MANIFEST_TOML: &str = r#" [pod] name = "hello-pod" +pwd = "./" [provider] kind = "anthropic" @@ -25,6 +26,10 @@ model = "claude-sonnet-4-20250514" [worker] system_prompt = "You are a concise assistant. Reply in one or two sentences." max_tokens = 256 + +[[scope.allow]] +target = "./" +permission = "write" "#; #[tokio::main] @@ -40,7 +45,7 @@ async fn main() -> Result<(), Box> { let store = FsStore::new(tmp.path()).await?; // 3. Build the Pod from manifest - let mut pod = Pod::from_manifest(manifest, store, None, None).await?; + let mut pod = Pod::from_manifest(manifest, store, None).await?; println!("Session: {}", pod.session_id()); // 4. Run a prompt diff --git a/crates/pod/examples/pod_protocol.rs b/crates/pod/examples/pod_protocol.rs index 30e21453..8df4ecde 100644 --- a/crates/pod/examples/pod_protocol.rs +++ b/crates/pod/examples/pod_protocol.rs @@ -11,6 +11,7 @@ use session_store::FsStore; const MANIFEST_TOML: &str = r#" [pod] name = "protocol-demo" +pwd = "./" [provider] kind = "anthropic" @@ -19,6 +20,10 @@ model = "claude-sonnet-4-20250514" [worker] system_prompt = "You are a concise assistant. Reply in one or two sentences." max_tokens = 256 + +[[scope.allow]] +target = "./" +permission = "write" "#; #[tokio::main] @@ -28,7 +33,7 @@ async fn main() -> Result<(), Box> { let manifest = PodManifest::from_toml(MANIFEST_TOML)?; let tmp = tempfile::tempdir()?; let store = FsStore::new(tmp.path()).await?; - let pod = pod::Pod::from_manifest(manifest, store, None, None).await?; + let pod = pod::Pod::from_manifest(manifest, store, None).await?; let runtime_tmp = tempfile::tempdir()?; let handle = PodController::spawn(pod, runtime_tmp.path()).await?; diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index c57b764b..3e48d388 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -83,9 +83,10 @@ impl PodController { // Keep the server alive by moving it into the controller task // (it will be dropped when the task ends) - // Grab the scope before the mutable borrow of the worker so we can - // build a `ScopedFs` for the builtin tools. `Scope` is cheap to clone. - let scope_for_tools = pod.scope().cloned(); + // Grab the scope/pwd before the mutable borrow of the worker so we + // can build a `ScopedFs` for the builtin tools. + let scope_for_tools = pod.scope().clone(); + let pwd_for_tools = pod.pwd().to_path_buf(); // Register event bridge callbacks on the worker { @@ -161,21 +162,17 @@ impl PodController { }); // Register the builtin file-manipulation tools (Read / Write / - // Edit / Glob / Grep) when the manifest declares a scope. - // - // `ScopedFs` carries the pod-lifetime write boundary (derived - // from the manifest scope). `Tracker` is session-scoped — - // a fresh instance per controller spawn ensures state from a - // previous process lifetime cannot be reused after a resume. - // The tracker is also handed to the Pod itself so Pod-level - // operations (e.g. context compaction) can ask which files - // the agent has been touching. - if let Some(scope) = scope_for_tools { - let fs = tools::ScopedFs::new(scope); - let tracker = tools::Tracker::new(); - worker.register_tools(tools::builtin_tools(fs, tracker.clone())); - pod.attach_tracker(tracker); - } + // Edit / Glob / Grep). `ScopedFs` carries the pod-lifetime + // scope/pwd; `Tracker` is session-scoped — a fresh instance per + // controller spawn ensures state from a previous process + // lifetime cannot be reused after a resume. The tracker is + // also handed to the Pod itself so Pod-level operations (e.g. + // context compaction) can ask which files the agent has been + // touching. + let fs = tools::ScopedFs::new(scope_for_tools, pwd_for_tools); + let tracker = tools::Tracker::new(); + worker.register_tools(tools::builtin_tools(fs, tracker.clone())); + pod.attach_tracker(tracker); } // Clone cancel sender before moving pod diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index 3aa3687f..89eae268 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -70,23 +70,11 @@ async fn main() -> ExitCode { } }; - // Build scope from manifest - let scope = match manifest.scope.as_ref() { - Some(sc) => match manifest::Scope::new(&sc.root) { - Ok(s) => Some(s), - Err(e) => { - eprintln!("error: invalid scope root {:?}: {e}", sc.root); - return ExitCode::FAILURE; - } - }, - None => None, - }; - - // Build the Pod + // Build the Pod (pwd/scope derived from manifest + manifest_dir). let manifest_dir = std::fs::canonicalize(&cli.manifest) .ok() .and_then(|p| p.parent().map(Path::to_path_buf)); - let pod = match Pod::from_manifest(manifest, store, scope, manifest_dir).await { + let pod = match Pod::from_manifest(manifest, store, manifest_dir).await { Ok(p) => p, Err(e) => { eprintln!("error: failed to create pod: {e}"); diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index a96cc8d8..97e42dba 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use llm_worker::Item; @@ -11,7 +11,7 @@ use session_store::{ }; use tracing::{info, warn}; -use manifest::{PodManifest, Scope, WorkerManifest}; +use manifest::{PodManifest, Scope, ScopeError, WorkerManifest}; use crate::compact_interceptor::CompactInterceptor; use crate::compact_state::CompactState; @@ -64,7 +64,10 @@ pub struct Pod { store: St, session_id: SessionId, head_hash: Option, - scope: Option, + /// Absolute working directory of the Pod. + pwd: PathBuf, + /// Resolved scope — always present. + scope: Scope, hook_builder: HookRegistryBuilder, interceptor_installed: bool, /// Directory containing the manifest file (needed for api_key_file resolution). @@ -92,11 +95,16 @@ pub struct Pod { impl Pod { /// Create a new Pod from a pre-built Worker and store. + /// + /// Callers must pre-resolve `pwd` (absolute) and build a [`Scope`] + /// — typically via [`Scope::from_config`] when coming from a + /// manifest, or [`Scope::writable`] in tests. pub async fn new( manifest: PodManifest, worker: Worker, store: St, - scope: Option, + pwd: PathBuf, + scope: Scope, ) -> Result { let state = SessionStartState { system_prompt: worker.get_system_prompt(), @@ -110,6 +118,7 @@ impl Pod { store, session_id, head_hash: Some(head_hash), + pwd, scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, @@ -129,7 +138,8 @@ impl Pod { manifest: PodManifest, client: C, store: St, - scope: Option, + pwd: PathBuf, + scope: Scope, ) -> Result { let state = session_store::restore(&store, session_id).await?; let mut worker = Worker::new(client); @@ -147,6 +157,7 @@ impl Pod { store, session_id, head_hash: state.head_hash, + pwd, scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, @@ -170,9 +181,14 @@ impl Pod { &self.manifest } - /// The Pod's directory scope, if any. - pub fn scope(&self) -> Option<&Scope> { - self.scope.as_ref() + /// The Pod's working directory. + pub fn pwd(&self) -> &Path { + &self.pwd + } + + /// The Pod's directory scope. + pub fn scope(&self) -> &Scope { + &self.scope } /// Direct access to the underlying Worker. @@ -689,12 +705,22 @@ impl Pod { impl Pod, St> { /// Create a Pod entirely from a manifest. + /// + /// Resolves `manifest.pod.pwd` against `manifest_dir` (or the + /// current working directory when absent), builds the [`Scope`] + /// from `manifest.scope`, and validates that the resolved pwd is + /// readable under that scope. pub async fn from_manifest( manifest: PodManifest, store: St, - scope: Option, manifest_dir: Option, ) -> Result { + let pwd = resolve_pwd(&manifest.pod.pwd, manifest_dir.as_deref())?; + let scope = Scope::from_config(&manifest.scope, &pwd).map_err(PodError::Scope)?; + if !scope.is_readable(&pwd) { + return Err(PodError::PwdOutsideScope { pwd }); + } + let client = provider::build_client(&manifest.provider, manifest_dir.as_deref())?; let mut worker = Worker::new(client); apply_worker_manifest(&mut worker, &manifest.worker); @@ -711,6 +737,7 @@ impl Pod, St> { store, session_id, head_hash: Some(head_hash), + pwd, scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, @@ -811,8 +838,18 @@ pub enum PodError { #[error(transparent)] Store(#[from] StoreError), - #[error("scope violation: {path} is outside the allowed directory")] - ScopeViolation { path: String }, + #[error(transparent)] + Scope(ScopeError), + + #[error("pwd is not readable under the configured scope: {}", .pwd.display())] + PwdOutsideScope { pwd: PathBuf }, + + #[error("failed to resolve pwd {}: {source}", .pwd.display())] + InvalidPwd { + pwd: PathBuf, + #[source] + source: std::io::Error, + }, #[error(transparent)] Provider(#[from] provider::ProviderError), @@ -820,3 +857,23 @@ pub enum PodError { #[error("compaction thrash: context still exceeds threshold immediately after compact")] CompactThrash, } + +/// Resolve the pwd declared in a manifest against `manifest_dir` (or the +/// current working directory when absent), canonicalizing symlinks. +fn resolve_pwd(pwd: &Path, manifest_dir: Option<&Path>) -> Result { + let joined = if pwd.is_absolute() { + pwd.to_path_buf() + } else { + let base = manifest_dir + .map(Path::to_path_buf) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + base.join(pwd) + }; + joined + .canonicalize() + .map_err(|source| PodError::InvalidPwd { + pwd: joined, + source, + }) +} diff --git a/crates/pod/tests/controller_test.rs b/crates/pod/tests/controller_test.rs index 6c74a533..31f99c10 100644 --- a/crates/pod/tests/controller_test.rs +++ b/crates/pod/tests/controller_test.rs @@ -74,6 +74,7 @@ fn simple_text_events() -> Vec { const MANIFEST_TOML: &str = r#" [pod] name = "test-pod" +pwd = "./" [provider] kind = "anthropic" @@ -81,16 +82,28 @@ model = "test-model" [worker] max_tokens = 100 + +[[scope.allow]] +target = "./" +permission = "write" "#; async fn make_pod(client: MockClient) -> Pod { let manifest = PodManifest::from_toml(MANIFEST_TOML).unwrap(); - let tmp = tempfile::tempdir().unwrap(); - let store = FsStore::new(tmp.path()).await.unwrap(); - // Leak tempdir to keep it alive - std::mem::forget(tmp); + let store_tmp = tempfile::tempdir().unwrap(); + let store = FsStore::new(store_tmp.path()).await.unwrap(); + std::mem::forget(store_tmp); + + // Separate tempdir to serve as the Pod's pwd/scope — these tests + // exercise the controller via a mock client and never touch the + // filesystem through tools, so a throwaway writable dir is enough. + let pwd_tmp = tempfile::tempdir().unwrap(); + let pwd = pwd_tmp.path().to_path_buf(); + let scope = manifest::Scope::writable(&pwd).unwrap(); + std::mem::forget(pwd_tmp); + let worker = Worker::new(client); - Pod::new(manifest, worker, store, None).await.unwrap() + Pod::new(manifest, worker, store, pwd, scope).await.unwrap() } use pod::PodHandle; diff --git a/crates/tools/src/edit.rs b/crates/tools/src/edit.rs index c1194353..8ad6acba 100644 --- a/crates/tools/src/edit.rs +++ b/crates/tools/src/edit.rs @@ -158,7 +158,10 @@ mod tests { fn setup() -> (TempDir, ScopedFs, Tracker) { let dir = TempDir::new().unwrap(); - let fs = ScopedFs::new(Scope::new(dir.path()).unwrap()); + let fs = ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ); (dir, fs, Tracker::new()) } diff --git a/crates/tools/src/error.rs b/crates/tools/src/error.rs index 05264dd2..fb548f7e 100644 --- a/crates/tools/src/error.rs +++ b/crates/tools/src/error.rs @@ -16,6 +16,9 @@ pub enum ToolsError { #[error("path is outside allowed scope: {}", .0.display())] OutOfScope(PathBuf), + #[error("path is read-only in this scope: {}", .0.display())] + ReadOnly(PathBuf), + #[error("path is a directory: {}", .0.display())] IsDirectory(PathBuf), @@ -70,6 +73,7 @@ impl From for ToolError { match err { RelativePath(_) | OutOfScope(_) + | ReadOnly(_) | IsDirectory(_) | NotRead(_) | ExternallyModified(_) diff --git a/crates/tools/src/glob.rs b/crates/tools/src/glob.rs index a1d30554..edda5f92 100644 --- a/crates/tools/src/glob.rs +++ b/crates/tools/src/glob.rs @@ -6,6 +6,7 @@ use std::time::SystemTime; use async_trait::async_trait; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; +use manifest::Scope; use serde::Deserialize; use crate::error::ToolsError; @@ -47,12 +48,13 @@ impl Tool for GlobTool { let base = params .path .clone() - .unwrap_or_else(|| self.fs.scope().root().to_path_buf()); + .unwrap_or_else(|| self.fs.pwd().to_path_buf()); let pattern = params.pattern.clone(); + let scope = self.fs.scope().clone(); // ignore::Walk is synchronous; run it on a blocking thread so we // don't stall the runtime for large trees. - let results = tokio::task::spawn_blocking(move || run_glob(&base, &pattern)) + let results = tokio::task::spawn_blocking(move || run_glob(&base, &pattern, &scope)) .await .map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??; @@ -92,7 +94,7 @@ impl Tool for GlobTool { } } -fn run_glob(base: &Path, pattern: &str) -> Result, ToolsError> { +fn run_glob(base: &Path, pattern: &str, scope: &Scope) -> Result, ToolsError> { if !base.is_absolute() { return Err(ToolsError::RelativePath(base.to_path_buf())); } @@ -131,6 +133,9 @@ fn run_glob(base: &Path, pattern: &str) -> Result, ToolsError> { if !glob.is_match(rel) { continue; } + if !scope.is_readable(entry.path()) { + continue; + } let mtime = entry .metadata() .ok() @@ -164,7 +169,10 @@ mod tests { fn setup() -> (TempDir, ScopedFs) { let dir = TempDir::new().unwrap(); - let fs = ScopedFs::new(Scope::new(dir.path()).unwrap()); + let fs = ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ); (dir, fs) } @@ -237,6 +245,43 @@ mod tests { assert!(matches!(err, ToolError::InvalidArgument(_))); } + #[tokio::test] + async fn glob_filters_results_by_scope_readability() { + use manifest::{Permission, ScopeConfig, ScopeRule}; + + let dir = TempDir::new().unwrap(); + let secret_dir = dir.path().join("secret"); + std::fs::create_dir(&secret_dir).unwrap(); + touch(&dir.path().join("visible.rs"), ""); + touch(&secret_dir.join("hidden.rs"), ""); + + let cfg = ScopeConfig { + allow: vec![ScopeRule { + target: dir.path().to_path_buf(), + permission: Permission::Write, + recursive: true, + }], + deny: vec![ScopeRule { + target: secret_dir.clone(), + permission: Permission::Read, + recursive: true, + }], + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + let fs = ScopedFs::new(scope, dir.path().to_path_buf()); + + let def = glob_tool(fs); + let (_, tool) = def(); + let inp = serde_json::json!({ "pattern": "**/*.rs" }); + let out = tool.execute(&inp.to_string()).await.unwrap(); + let body = out.content.unwrap_or_default(); + assert!(body.contains("visible.rs")); + assert!( + !body.contains("hidden.rs"), + "scope-denied file leaked into glob output: {body}" + ); + } + #[tokio::test] async fn glob_honors_hidden_files() { let (dir, fs) = setup(); diff --git a/crates/tools/src/grep.rs b/crates/tools/src/grep.rs index 9a3dcea7..9eda3824 100644 --- a/crates/tools/src/grep.rs +++ b/crates/tools/src/grep.rs @@ -11,6 +11,7 @@ use ignore::WalkBuilder; use ignore::overrides::OverrideBuilder; use ignore::types::TypesBuilder; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; +use manifest::Scope; use serde::Deserialize; use crate::error::ToolsError; @@ -91,8 +92,9 @@ impl Tool for GrepTool { "Grep" ); - let default_base = self.fs.scope().root().to_path_buf(); - let report = tokio::task::spawn_blocking(move || run_grep(default_base, params)) + let default_base = self.fs.pwd().to_path_buf(); + let scope = self.fs.scope().clone(); + let report = tokio::task::spawn_blocking(move || run_grep(default_base, params, &scope)) .await .map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??; @@ -228,7 +230,7 @@ impl GrepReport { } } -fn run_grep(default_base: PathBuf, p: GrepParams) -> Result { +fn run_grep(default_base: PathBuf, p: GrepParams, scope: &Scope) -> Result { let matcher = RegexMatcherBuilder::new() .case_insensitive(p.case_insensitive) .multi_line(p.multiline) @@ -309,6 +311,9 @@ fn run_grep(default_base: PathBuf, p: GrepParams) -> Result { @@ -472,7 +477,10 @@ mod tests { fn setup() -> (TempDir, ScopedFs) { let dir = TempDir::new().unwrap(); - let fs = ScopedFs::new(Scope::new(dir.path()).unwrap()); + let fs = ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ); (dir, fs) } @@ -483,6 +491,43 @@ mod tests { fs::write(path, content).unwrap(); } + #[tokio::test] + async fn grep_filters_results_by_scope_readability() { + use manifest::{Permission, ScopeConfig, ScopeRule}; + + let dir = TempDir::new().unwrap(); + let secret_dir = dir.path().join("secret"); + fs::create_dir(&secret_dir).unwrap(); + touch(&dir.path().join("visible.txt"), "needle\n"); + touch(&secret_dir.join("hidden.txt"), "needle\n"); + + let cfg = ScopeConfig { + allow: vec![ScopeRule { + target: dir.path().to_path_buf(), + permission: Permission::Write, + recursive: true, + }], + deny: vec![ScopeRule { + target: secret_dir.clone(), + permission: Permission::Read, + recursive: true, + }], + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + let scoped = ScopedFs::new(scope, dir.path().to_path_buf()); + + let def = grep_tool(scoped); + let (_, tool) = def(); + let inp = serde_json::json!({ "pattern": "needle" }); + let out = tool.execute(&inp.to_string()).await.unwrap(); + let body = out.content.unwrap_or_default(); + assert!(body.contains("visible.txt")); + assert!( + !body.contains("hidden.txt"), + "scope-denied file leaked into grep output: {body}" + ); + } + #[tokio::test] async fn grep_files_with_matches_default() { let (dir, fs) = setup(); diff --git a/crates/tools/src/read.rs b/crates/tools/src/read.rs index 65a01525..2bebbc18 100644 --- a/crates/tools/src/read.rs +++ b/crates/tools/src/read.rs @@ -137,7 +137,10 @@ mod tests { fn setup() -> (TempDir, ScopedFs, Tracker) { let dir = TempDir::new().unwrap(); - let fs = ScopedFs::new(Scope::new(dir.path()).unwrap()); + let fs = ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ); (dir, fs, Tracker::new()) } diff --git a/crates/tools/src/scoped_fs.rs b/crates/tools/src/scoped_fs.rs index 40fc6f92..cce29d9a 100644 --- a/crates/tools/src/scoped_fs.rs +++ b/crates/tools/src/scoped_fs.rs @@ -1,15 +1,16 @@ //! Scope-aware filesystem primitive. //! -//! `ScopedFs` represents **only** the write-block boundary: it knows a -//! [`manifest::Scope`] and refuses writes outside of it. It carries no -//! per-session state and is cheap to clone (pod-lifetime, reusable across -//! sessions). The read-before-edit policy lives separately in -//! [`crate::Tracker`]. +//! `ScopedFs` is the write/read gate layered on top of a [`manifest::Scope`] +//! and a Pod's working directory. The scope decides which paths are +//! readable and writable; the pwd is carried alongside for convenience +//! (Glob/Grep default their search base to it). //! -//! Reads are unrestricted by design (see `tickets/builtin-tools.md`). +//! `ScopedFs` is cheap to clone (`Arc` inside) and carries no per-session +//! state — the read-before-edit policy lives separately in +//! [`crate::Tracker`]. use std::io::Write as _; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use manifest::Scope; @@ -19,6 +20,7 @@ use crate::error::ToolsError; #[derive(Debug)] struct ScopedFsInner { scope: Scope, + pwd: PathBuf, } /// Scope-aware filesystem handle. Clone-cheap (`Arc` inside). @@ -35,10 +37,10 @@ pub struct WriteOutcome { } impl ScopedFs { - /// Create a new [`ScopedFs`] wrapping the given [`Scope`]. - pub fn new(scope: Scope) -> Self { + /// Create a new [`ScopedFs`] wrapping the given [`Scope`] and pwd. + pub fn new(scope: Scope, pwd: PathBuf) -> Self { Self { - inner: Arc::new(ScopedFsInner { scope }), + inner: Arc::new(ScopedFsInner { scope, pwd }), } } @@ -47,18 +49,27 @@ impl ScopedFs { &self.inner.scope } + /// The Pod's working directory. Glob/Grep default their search base + /// to this path when callers omit an explicit `path` parameter. + pub fn pwd(&self) -> &Path { + &self.inner.pwd + } + // ========================================================================= - // Read — unrestricted + // Read — scope-checked against readability // ========================================================================= /// Read the full contents of `path` as raw bytes. /// - /// Follows symlinks. Rejects directories, relative paths, and missing - /// files. No scope check. + /// Follows symlinks. Rejects directories, relative paths, paths not + /// readable by the scope, and missing files. pub fn read_bytes(&self, path: &Path) -> Result, ToolsError> { if !path.is_absolute() { return Err(ToolsError::RelativePath(path.to_path_buf())); } + if !self.inner.scope.is_readable(path) { + return Err(ToolsError::OutOfScope(path.to_path_buf())); + } let meta = std::fs::metadata(path).map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()), _ => ToolsError::io(path, e), @@ -75,9 +86,10 @@ impl ScopedFs { /// Atomically write `content` to `path`, creating or overwriting it. /// - /// - `path` must be absolute and inside the scope (delegates to - /// [`Scope::contains`]). - /// - Missing parent directories inside the scope are created. + /// - `path` must be absolute and writable under the scope. + /// - Paths that are readable but not writable return [`ToolsError::ReadOnly`]. + /// - Paths outside the scope entirely return [`ToolsError::OutOfScope`]. + /// - Missing parent directories are created. /// - The actual write uses a sibling tempfile + `persist`, so the /// target file transitions atomically between states. /// @@ -88,8 +100,12 @@ impl ScopedFs { if !path.is_absolute() { return Err(ToolsError::RelativePath(path.to_path_buf())); } - if !self.inner.scope.contains(path) { - return Err(ToolsError::OutOfScope(path.to_path_buf())); + 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()) + }); } // Reject existing directory targets. @@ -138,11 +154,15 @@ impl ScopedFs { #[cfg(test)] mod tests { use super::*; + use manifest::{Permission, ScopeConfig, ScopeRule}; use std::fs; use tempfile::TempDir; fn make_fs(dir: &TempDir) -> ScopedFs { - ScopedFs::new(Scope::new(dir.path()).unwrap()) + ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ) } // ------------------------------------------------------------------------- @@ -183,15 +203,15 @@ mod tests { } #[test] - fn read_bytes_allows_paths_outside_scope() { - // Reads are unrestricted — scope only gates writes. + fn read_bytes_rejects_paths_outside_scope() { let dir = TempDir::new().unwrap(); let outside = TempDir::new().unwrap(); let outside_file = outside.path().join("x.txt"); fs::write(&outside_file, b"hi").unwrap(); let scoped = make_fs(&dir); - assert_eq!(scoped.read_bytes(&outside_file).unwrap(), b"hi"); + let err = scoped.read_bytes(&outside_file).unwrap_err(); + assert!(matches!(err, ToolsError::OutOfScope(_))); } // ------------------------------------------------------------------------- @@ -229,6 +249,32 @@ mod tests { assert!(matches!(err, ToolsError::OutOfScope(_))); } + #[test] + fn write_rejects_readonly_path() { + let dir = TempDir::new().unwrap(); + let sub = dir.path().join("sub"); + fs::create_dir(&sub).unwrap(); + let cfg = ScopeConfig { + allow: vec![ScopeRule { + target: dir.path().to_path_buf(), + permission: Permission::Write, + recursive: true, + }], + deny: vec![ScopeRule { + target: sub.clone(), + permission: Permission::Write, + recursive: true, + }], + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + let scoped = ScopedFs::new(scope, dir.path().to_path_buf()); + let err = scoped.write(&sub.join("locked.txt"), b"x").unwrap_err(); + assert!( + matches!(err, ToolsError::ReadOnly(_)), + "expected ReadOnly, got {err:?}" + ); + } + #[test] fn write_rejects_relative_path() { let dir = TempDir::new().unwrap(); diff --git a/crates/tools/src/tracker.rs b/crates/tools/src/tracker.rs index 4cdf4e63..6e108474 100644 --- a/crates/tools/src/tracker.rs +++ b/crates/tools/src/tracker.rs @@ -25,10 +25,11 @@ //! the Pod wires them together when registering builtin tools. //! //! ```no_run +//! # use std::path::PathBuf; //! # use manifest::Scope; //! # use tools::{ScopedFs, Tracker, builtin_tools}; -//! let scope = Scope::new("/workspace").unwrap(); -//! let fs = ScopedFs::new(scope); // pod lifetime +//! let scope = Scope::writable("/workspace").unwrap(); +//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime //! let tracker = Tracker::new(); // session lifetime //! let defs = builtin_tools(fs, tracker); //! ``` diff --git a/crates/tools/src/write.rs b/crates/tools/src/write.rs index 9290ae9d..762387d3 100644 --- a/crates/tools/src/write.rs +++ b/crates/tools/src/write.rs @@ -99,7 +99,10 @@ mod tests { fn setup() -> (TempDir, ScopedFs, Tracker) { let dir = TempDir::new().unwrap(); - let fs = ScopedFs::new(Scope::new(dir.path()).unwrap()); + let fs = ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ); (dir, fs, Tracker::new()) } diff --git a/crates/tools/tests/edge_cases.rs b/crates/tools/tests/edge_cases.rs index 7dc6b39b..686392ad 100644 --- a/crates/tools/tests/edge_cases.rs +++ b/crates/tools/tests/edge_cases.rs @@ -29,7 +29,10 @@ impl Registry { fn setup() -> (TempDir, Registry) { let dir = TempDir::new().unwrap(); - let fs = ScopedFs::new(Scope::new(dir.path()).unwrap()); + let fs = ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ); let tracker = Tracker::new(); (dir, Registry::new(builtin_tools(fs, tracker))) } @@ -76,14 +79,19 @@ async fn symlink_to_outside_scope_is_rejected_for_write() { let link = dir.path().join("linked.txt"); symlink(&outside_target, &link).unwrap(); - // Read tool must work against the symlink (read is unrestricted). + // Read through the symlink must be rejected because the resolved + // target sits outside the scope. let read = reg.get("Read"); - read.execute(&json!({ "file_path": link.to_str().unwrap() }).to_string()) + let read_err = read + .execute(&json!({ "file_path": link.to_str().unwrap() }).to_string()) .await - .unwrap(); + .unwrap_err(); + assert!( + format!("{read_err}").contains("outside allowed scope"), + "symlink read escape not rejected: {read_err}" + ); - // Write through the symlink must be rejected because canonicalization - // resolves it to outside the scope. + // Write through the symlink must be rejected for the same reason. let write = reg.get("Write"); let err = write .execute( diff --git a/crates/tools/tests/integration.rs b/crates/tools/tests/integration.rs index 115f33d9..f34db06d 100644 --- a/crates/tools/tests/integration.rs +++ b/crates/tools/tests/integration.rs @@ -38,7 +38,10 @@ impl Registry { fn setup() -> (TempDir, Registry) { let dir = TempDir::new().unwrap(); - let fs = ScopedFs::new(Scope::new(dir.path()).unwrap()); + let fs = ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ); let tracker = Tracker::new(); let reg = Registry::new(builtin_tools(fs, tracker)); (dir, reg) @@ -275,7 +278,10 @@ fn tool_names_match_reference_spec() { async fn tracker_recent_files_tracks_read_write_edit() { // Build a fresh registry that shares a tracker we can query afterwards. let dir = TempDir::new().unwrap(); - let fs = ScopedFs::new(Scope::new(dir.path()).unwrap()); + let fs = ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ); let tracker = Tracker::new(); let reg = Registry::new(builtin_tools(fs, tracker.clone())); diff --git a/tickets/scope-exclusion.md b/tickets/scope-exclusion.md new file mode 100644 index 00000000..f6661ef5 --- /dev/null +++ b/tickets/scope-exclusion.md @@ -0,0 +1,101 @@ +# 複数 Pod 間の Scope 排他制御 + +## 背景 + +[scope-redesign.md](scope-redesign.md) で Scope は「allow / deny の領域リスト + permission レベル」に再設計された。 +これにより複数 Pod が同じファイルツリーに対して異なる権限を宣言する状況が日常化する。 + +現状、複数 Pod が同じ pwd や重なる allow を持っても何のチェックも警告もない。 +両者が同じファイルに `write` を持っていた場合、ツール経由で同時に書き換えて +内容を破壊しうる。Git のコミット粒度より細かい競合は履歴からも復元しづらい。 + +Pod を「並行作業の単位」として扱う以上、scope の重なりは設計時に検知・制御 +できる必要がある。 + +## 要件 + +- **R1: write の重複を検出する** + 同じファイルが 2 つ以上の Pod から `write` 権限で見えている状態を作らせない。 + 片方の Pod が起動中なら、もう片方の起動を拒否する(または昇格を拒否する)。 + +- **R2: read は共有可** + `read` のみの重なりは許す。複数の閲覧者は問題ない。 + +- **R3: deny は重なりに影響しない** + `deny` で write が落とされている path は、その Pod にとって write を持たないと + みなして他 Pod との重複判定を行う。 + +- **R4: 解放の確実性** + Pod の異常終了で排他が永久に残らないこと。lock の所有者プロセスが死んだら + 自動解放されるか、stale lock として検知できる。 + +- **R5: 観測可能性** + ユーザーが「今どの Pod がどこを write 占有しているか」を見られる。 + 競合で起動が拒否されたとき、競合相手をエラーメッセージで示す。 + +## 設計上の論点 + +### 排他の粒度 + +選択肢: +- **A. ファイル単位** — 厳密だが lock 数が膨大になる +- **B. allow rule 単位** — 宣言された target ごとに lock。粒度が荒いが宣言と一致して直感的 +- **C. パス prefix の最小被覆** — write 領域を最小の prefix に縮約して lock。中間 + +おすすめは **B**。Pod の宣言と lock の単位が一致する方が説明しやすく、 +ユーザーが「この Pod は src を握っている」と理解しやすい。 + +### lock の保管場所 + +選択肢: +- **A. lock file** — `~/.insomnia/locks/.lock` 等。各 Pod プロセスが直接握る +- **B. 中央レジストリプロセス** — 常駐デーモンが scope の貸し借りを管理 +- **C. session-store の拡張** — 既存の永続化基盤に lock テーブルを足す + +おすすめは **A**。デーモンを増やしたくないし、Pod は短命〜中時間生存の想定なので +ファイルロック (`flock(2)` / `fcntl`) で十分。stale lock 検知は PID 死活で行う。 + +### 競合発生時の挙動 + +選択肢: +- **A. 起動失敗 (fail-fast)** — エラーで起動拒否、ユーザーが手で解決 +- **B. 待機 (block)** — 競合相手が解放するまで起動を待つ +- **C. 自動降格** — 競合する write を read に降格して起動 + +おすすめは **A**。並行作業の単位として Pod を起動するなら、競合は意図しない +状況であることが多い。明示エラーで気づける方が安全。`--wait` フラグや `--read-only` +フラグで B / C を選べるようにするのは将来拡張で十分。 + +### 取得タイミング + +- Pod 起動時に scope 全体を一括取得し、終了時に解放 +- 起動中に scope を変えることは現状想定しない(manifest 編集 → 再起動) + +### resume との関係 + +`Pod::restore` でも同じ scope が要求される。前回終了時に解放されているはずなので +取得が成功するのが正常。失敗する場合は別 Pod が起動している = ユーザーが意図的に +2 つ動かそうとしている。fail-fast に乗せる。 + +## 影響範囲(想定) + +実装時に詰める。現時点での見立て: + +- 新規モジュール `crates/pod/src/scope_lock.rs`(または独立クレート `scope-lock`) + - lock の取得 / 解放 + - stale lock 検知(PID 確認) + - 競合情報の収集(誰がどの allow を握っているか) +- `Pod::new` / `Pod::restore` の前段で lock 取得 +- `Pod` の Drop / 明示解放で lock 返却 +- `Controller` 層でのエラー伝搬とユーザー向けメッセージ +- CLI に「現在のロック一覧」を見るコマンド(観測性 R5) + +## 非ゴール + +- **同一 Pod 内の並行性制御** — 1 Pod 内のツール並行実行は本 ticket では扱わない +- **ネットワーク越しの排他** — ローカルファイルロックのみ。複数マシンで同じワーキングコピーを共有する想定はしない +- **read-write lock の細かな格上げ/格下げ** — 取得時に確定、起動中の変更はしない + +## 依存 + +- [scope-redesign.md](scope-redesign.md) — allow / deny / permission レベルの構造が前提 diff --git a/tickets/scope-redesign.md b/tickets/scope-redesign.md index bd594d4f..fe783d3d 100644 --- a/tickets/scope-redesign.md +++ b/tickets/scope-redesign.md @@ -2,65 +2,186 @@ ## 背景 -Scope は Pod の作業ディレクトリとアクセス権限を定義するもの。全ての Pod が必ず持つ。 - +Scope は Pod がどのファイルにどの権限でアクセスできるかを宣言するもの。 現状の問題: -1. `Pod.scope` が `Option` — scope なしの Pod が存在しうる(ツールが登録されない) -2. `Scope` が `root: PathBuf` しか持たない — 書き込み許可/禁止の概念がない -3. Scope なしの場合、Glob/Grep のデフォルト検索パスがない -4. マニフェストで `[scope]` 省略時のフォールバックが定義されていない + +1. `Pod.scope` が `Option` — scope なし Pod が成立しうる(ツール未登録) +2. `Scope` は `root: PathBuf` のみ — 書き込み許可/禁止の概念がない +3. 単一ディレクトリしか宣言できない — 「src は書ける、docs は読むだけ」が表現できない +4. pwd(作業ディレクトリ)と「アクセス可能領域」が同じ概念に押し込まれている +5. 将来の複数 Pod 排他制御に必要な「権限つき領域宣言」のデータ構造がない ## 方針 -### Scope の拡張 +### 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 外(アクセス不可)。 +- 順序非依存。 + +### マニフェスト + +```toml +[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 型 ```rust -pub struct Scope { - pwd: PathBuf, // 作業ディレクトリ - writable: bool, // false = 読み取り専用 Pod +// crates/manifest/src/lib.rs +pub struct PodMeta { + pub name: String, + pub pwd: PathBuf, +} + +pub struct ScopeConfig { + pub allow: Vec, + pub deny: Vec, +} + +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, } ``` -- `writable: true`(デフォルト): Read/Write/Edit/Glob/Grep 全て使える -- `writable: false`: Read/Glob/Grep のみ。Write/Edit はエラー -- `pwd` は Glob/Grep のデフォルト検索パスとしても使われる +```rust +// crates/manifest/src/scope.rs +pub struct Scope { + allow: Vec, + deny: Vec, +} + +impl Scope { + /// Effective permission for `path`. `None` means out-of-scope. + pub fn permission_at(&self, path: &Path) -> Option; + + /// 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` 計算: +1. allow 群のうち path にマッチするもの(recursive 考慮)から最大 permission を取る。なければ `None` を返す。 +2. deny 群のうち path にマッチするものから最小 permission を取る(あれば、その値**未満**にキャップ)。 +3. キャップ後のレベルが 0 なら `None`、`Read` なら `Some(Read)`、`Write` なら `Some(Write)`。 ### Pod で必須化 ```rust pub struct Pod { - scope: Scope, // Option ではない + pwd: PathBuf, + scope: Scope, // Option ではない // ... } + +impl Pod { + pub async fn new( + manifest: PodManifest, + worker: Worker, + store: St, + pwd: PathBuf, + scope: Scope, + ) -> Result { /* ... */ } + + pub async fn restore( + session_id: SessionId, + manifest: PodManifest, + client: C, + store: St, + pwd: PathBuf, + scope: Scope, + ) -> Result { /* ... */ } + + pub fn pwd(&self) -> &Path { &self.pwd } + pub fn scope(&self) -> &Scope { &self.scope } +} ``` -### マニフェスト - -```toml -# 明示指定 -[scope] -pwd = "./src" -writable = false # 省略時 true - -# [scope] 省略時 → マニフェストファイルの親ディレクトリが pwd、writable = true -``` - -`Pod::new()`(マニフェストなし構築)では Scope を引数で必須にする。 +`from_manifest` 系は manifest の `[pod].pwd` と `[scope]` から pwd / scope を構築する。整合性チェック(pwd が allow に含まれる、deny で潰されていない)はここで行う。 ### ScopedFs の変更 ```rust -pub fn write(&self, path: &Path, content: &[u8]) -> Result { - if !self.inner.scope.writable() { - return Err(ToolsError::ReadOnly); - } - if !self.inner.scope.contains(path) { +pub fn read(&self, path: &Path) -> Result, 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 { + 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 統合 ```rust @@ -68,19 +189,58 @@ pub fn write(&self, path: &Path, content: &[u8]) -> Result` → `scope: Scope` -- `Pod::new()`, `Pod::restore()`, `Pod::from_manifest()` — シグネチャ変更 -- `ScopedFs` — writable チェック追加 -- `Controller` — scope の Optional 分岐を削除 -- `tools::error::ToolsError` — `ReadOnly` variant 追加 -- `Scope::contains` — `root` → `pwd` のリネーム +- `crates/manifest/src/lib.rs` + - `PodMeta` に `pwd: PathBuf` を追加 + - `ScopeConfig` を `{ allow: Vec, deny: Vec }` に置き換え(旧 `root` フィールド削除) + - `Permission` enum、`ScopeRule` 構造体を追加 + - `PodManifest::scope` を `Option` → `ScopeConfig` に(省略不可) + - manifest ロード時の整合性チェック(scope 必須、allow 1 件以上、pwd と allow/deny の関係) + +- `crates/manifest/src/scope.rs` + - `Scope` 構造体を allow/deny ベースに作り直し + - `permission_at` / `is_readable` / `is_writable` + - 旧 `Scope::contains` は削除(呼び出し元は `is_readable` / `is_writable` に置換) + +- `crates/pod/src/pod.rs` + - `scope: Option` → `scope: Scope` + - `pwd: 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 のデフォルト検索パス用) + - 既存の境界チェックを置換 + +- `crates/tools/src/error.rs` + - `ReadOnly(PathBuf)` variant を追加 + - 既存の `OutOfScope` は維持 + +- `crates/pod/src/controller.rs` + - `scope` の 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 値。