scopeの再設計

This commit is contained in:
Keisuke Hirata 2026-04-14 12:09:18 +09:00
parent f8eabd3ac8
commit db02afb74f
24 changed files with 1074 additions and 318 deletions

1
Cargo.lock generated
View File

@ -1444,6 +1444,7 @@ version = "0.1.0"
dependencies = [
"serde",
"tempfile",
"thiserror 2.0.18",
"toml",
]

View File

@ -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)

View File

@ -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]

View File

@ -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` で問い合わせる

View File

@ -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<ScopeConfig>,
pub scope: ScopeConfig,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
}
@ -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<f32>,
}
/// 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<ScopeRule>,
/// Rules capping access below the stated permission level. Empty by
/// default.
#[serde(default)]
pub deny: Vec<ScopeRule>,
}
/// 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);
}
}

View File

@ -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<ResolvedRule>,
deny: Vec<ResolvedRule>,
}
#[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<PathBuf>) -> std::io::Result<Self> {
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<Self, ScopeError> {
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::<Result<Vec<_>, _>>()?;
let deny = config
.deny
.iter()
.map(|r| resolve_rule(r, base))
.collect::<Result<Vec<_>, _>>()?;
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<Path>) -> std::io::Result<Self> {
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<Permission> {
let resolved = resolve_path(path)?;
let mut effective: Option<Permission> = 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<Permission> = 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<ResolvedRule, ScopeError> {
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<PathBuf> {
if !path.is_absolute() {
return None;
}
if let Ok(canonical) = path.canonicalize() {
return Some(canonical);
}
let mut tail: Vec<OsString> = 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));
}
}

View File

@ -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<dyn std::error::Error>> {
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

View File

@ -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<dyn std::error::Error>> {
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?;

View File

@ -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

View File

@ -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}");

View File

@ -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<C: LlmClient, St: Store> {
store: St,
session_id: SessionId,
head_hash: Option<EntryHash>,
scope: Option<Scope>,
/// 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<C: LlmClient, St: Store> {
impl<C: LlmClient, St: Store> Pod<C, St> {
/// 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<C>,
store: St,
scope: Option<Scope>,
pwd: PathBuf,
scope: Scope,
) -> Result<Self, PodError> {
let state = SessionStartState {
system_prompt: worker.get_system_prompt(),
@ -110,6 +118,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
store,
session_id,
head_hash: Some(head_hash),
pwd,
scope,
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
@ -129,7 +138,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
manifest: PodManifest,
client: C,
store: St,
scope: Option<Scope>,
pwd: PathBuf,
scope: Scope,
) -> Result<Self, PodError> {
let state = session_store::restore(&store, session_id).await?;
let mut worker = Worker::new(client);
@ -147,6 +157,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
store,
session_id,
head_hash: state.head_hash,
pwd,
scope,
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
@ -170,9 +181,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
&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<C: LlmClient, St: Store> Pod<C, St> {
impl<St: Store> Pod<Box<dyn LlmClient>, 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<Scope>,
manifest_dir: Option<PathBuf>,
) -> Result<Self, PodError> {
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<St: Store> Pod<Box<dyn LlmClient>, 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<PathBuf, PodError> {
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,
})
}

View File

@ -74,6 +74,7 @@ fn simple_text_events() -> Vec<LlmEvent> {
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<MockClient, FsStore> {
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;

View File

@ -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())
}

View File

@ -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<ToolsError> for ToolError {
match err {
RelativePath(_)
| OutOfScope(_)
| ReadOnly(_)
| IsDirectory(_)
| NotRead(_)
| ExternallyModified(_)

View File

@ -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<Vec<PathBuf>, ToolsError> {
fn run_glob(base: &Path, pattern: &str, scope: &Scope) -> Result<Vec<PathBuf>, 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<Vec<PathBuf>, 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();

View File

@ -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<GrepReport, ToolsError> {
fn run_grep(default_base: PathBuf, p: GrepParams, scope: &Scope) -> Result<GrepReport, ToolsError> {
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<GrepReport, ToolsErr
continue;
}
let path = entry.path();
if !scope.is_readable(path) {
continue;
}
match mode {
GrepOutputMode::FilesWithMatches => {
@ -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();

View File

@ -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())
}

View File

@ -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<Vec<u8>, 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();

View File

@ -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);
//! ```

View File

@ -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())
}

View File

@ -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(

View File

@ -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()));

101
tickets/scope-exclusion.md Normal file
View File

@ -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/<hash>.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 レベルの構造が前提

View File

@ -2,65 +2,186 @@
## 背景
Scope は Pod の作業ディレクトリとアクセス権限を定義するもの。全ての Pod が必ず持つ。
Scope は Pod がどのファイルにどの権限でアクセスできるかを宣言するもの。
現状の問題:
1. `Pod.scope``Option<Scope>` — scope なしの Pod が存在しうる(ツールが登録されない)
2. `Scope``root: PathBuf` しか持たない — 書き込み許可/禁止の概念がない
3. Scope なしの場合、Glob/Grep のデフォルト検索パスがない
4. マニフェストで `[scope]` 省略時のフォールバックが定義されていない
1. `Pod.scope``Option<Scope>` — 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<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,
}
```
- `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<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` 計算:
1. allow 群のうち path にマッチするものrecursive 考慮)から最大 permission を取る。なければ `None` を返す。
2. deny 群のうち path にマッチするものから最小 permission を取る(あれば、その値**未満**にキャップ)。
3. キャップ後のレベルが 0 なら `None`、`Read` なら `Some(Read)`、`Write` なら `Some(Write)`
### Pod で必須化
```rust
pub struct Pod<C, St> {
scope: Scope, // Option ではない
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 }
}
```
### マニフェスト
```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<WriteOutcome, ToolsError> {
if !self.inner.scope.writable() {
return Err(ToolsError::ReadOnly);
}
if !self.inner.scope.contains(path) {
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 統合
```rust
@ -68,19 +189,58 @@ pub fn write(&self, path: &Path, content: &[u8]) -> Result<WriteOutcome, ToolsEr
if let Some(scope) = scope_for_tools { ... }
// 後: 常にツール登録scope は必須)
let fs = tools::ScopedFs::new(pod.scope().clone());
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));
```
## 影響範囲
- `manifest::Scope``root``pwd`、`writable` フィールド追加
- `manifest::ScopeConfig``root``pwd`、`writable` の serde 対応
- `manifest::PodManifest` — [scope] 省略時のフォールバック解決
- `Pod``scope: Option<Scope>``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<ScopeRule>, deny: Vec<ScopeRule> }` に置き換え(旧 `root` フィールド削除)
- `Permission` enum、`ScopeRule` 構造体を追加
- `PodManifest::scope``Option<ScopeConfig>``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: 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 値。