scopeの再設計
This commit is contained in:
parent
f8eabd3ac8
commit
db02afb74f
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1444,6 +1444,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
]
|
||||
|
||||
|
|
|
|||
3
TODO.md
3
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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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` で問い合わせる
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
//! ```
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
101
tickets/scope-exclusion.md
Normal 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 レベルの構造が前提
|
||||
|
|
@ -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 値。
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user