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 = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
3
TODO.md
3
TODO.md
|
|
@ -1,7 +1,8 @@
|
||||||
- [ ] テスト設計 → [tickets/test-design.md](tickets/test-design.md)
|
- [ ] テスト設計 → [tickets/test-design.md](tickets/test-design.md)
|
||||||
- [ ] ツール設計
|
- [ ] ツール設計
|
||||||
- [ ] Bash ツール (Permission 層と統合) → [tickets/bash-tool.md](tickets/bash-tool.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)
|
- [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md)
|
||||||
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
|
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
|
||||||
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
thiserror = "2.0.18"
|
||||||
toml = "1.1.2"
|
toml = "1.1.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ Pod の宣言的設定を TOML マニフェストとして定義・パースす
|
||||||
## 公開型
|
## 公開型
|
||||||
|
|
||||||
- `PodManifest` — Pod 設定全体(`from_toml()` でパース)
|
- `PodManifest` — Pod 設定全体(`from_toml()` でパース)
|
||||||
- `PodMeta` — Pod メタデータ(名前)
|
- `PodMeta` — Pod メタデータ(名前、pwd)
|
||||||
- `ProviderConfig` — LLM プロバイダ設定(種別、モデル、APIキー環境変数、ベースURL)
|
- `ProviderConfig` — LLM プロバイダ設定(種別、モデル、APIキー環境変数、ベースURL)
|
||||||
- `ProviderKind` — プロバイダ種別(`Anthropic`, `Openai`, `Gemini`, `Ollama`)
|
- `ProviderKind` — プロバイダ種別(`Anthropic`, `Openai`, `Gemini`, `Ollama`)
|
||||||
- `WorkerManifest` — ワーカー設定(システムプロンプト、max_tokens、temperature)
|
- `WorkerManifest` — ワーカー設定(システムプロンプト、max_tokens、temperature)
|
||||||
- `ScopeConfig` — スコープ設定(ルートディレクトリ)
|
- `ScopeConfig` / `ScopeRule` / `Permission` — allow / deny の宣言的スコープ設定
|
||||||
- `Scope` — ディレクトリスコープの実行時チェック(`contains()` でパス包含判定)
|
- `Scope` — 実行時スコープ。`from_config(&ScopeConfig, pwd)` で構築し、`is_readable` / `is_writable` / `permission_at` で問い合わせる
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
mod scope;
|
mod scope;
|
||||||
|
|
||||||
pub use scope::Scope;
|
pub use scope::{Scope, ScopeError};
|
||||||
|
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -10,14 +10,13 @@ use serde::{Deserialize, Serialize};
|
||||||
/// Declarative configuration for a Pod.
|
/// Declarative configuration for a Pod.
|
||||||
///
|
///
|
||||||
/// Parsed from a TOML manifest file. Describes the provider, model,
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PodManifest {
|
pub struct PodManifest {
|
||||||
pub pod: PodMeta,
|
pub pod: PodMeta,
|
||||||
pub provider: ProviderConfig,
|
pub provider: ProviderConfig,
|
||||||
pub worker: WorkerManifest,
|
pub worker: WorkerManifest,
|
||||||
#[serde(default)]
|
pub scope: ScopeConfig,
|
||||||
pub scope: Option<ScopeConfig>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compaction: Option<CompactionConfig>,
|
pub compaction: Option<CompactionConfig>,
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +25,9 @@ pub struct PodManifest {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PodMeta {
|
pub struct PodMeta {
|
||||||
pub name: String,
|
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.
|
/// LLM provider configuration.
|
||||||
|
|
@ -79,10 +81,53 @@ pub struct WorkerManifest {
|
||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Directory scope configuration.
|
/// Declarative scope configuration.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
///
|
||||||
|
/// 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 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.
|
/// Context compaction configuration.
|
||||||
|
|
@ -146,24 +191,32 @@ impl PodManifest {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
const MINIMAL_REQUIRED: &str = r#"
|
||||||
fn parse_minimal_manifest() {
|
|
||||||
let toml = r#"
|
|
||||||
[pod]
|
[pod]
|
||||||
name = "test-agent"
|
name = "test-agent"
|
||||||
|
pwd = "./"
|
||||||
|
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
model = "claude-sonnet-4-20250514"
|
model = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
[worker]
|
[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.name, "test-agent");
|
||||||
|
assert_eq!(manifest.pod.pwd, PathBuf::from("./"));
|
||||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||||
assert_eq!(manifest.provider.model, "claude-sonnet-4-20250514");
|
assert_eq!(manifest.provider.model, "claude-sonnet-4-20250514");
|
||||||
assert!(manifest.provider.api_key_file.is_none());
|
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());
|
assert!(manifest.worker.system_prompt.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,6 +225,7 @@ model = "claude-sonnet-4-20250514"
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
[pod]
|
[pod]
|
||||||
name = "code-reviewer"
|
name = "code-reviewer"
|
||||||
|
pwd = "./src"
|
||||||
|
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
|
|
@ -183,11 +237,22 @@ system_prompt = "You are a code reviewer."
|
||||||
max_tokens = 4096
|
max_tokens = 4096
|
||||||
temperature = 0.3
|
temperature = 0.3
|
||||||
|
|
||||||
[scope]
|
[[scope.allow]]
|
||||||
root = "./src"
|
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();
|
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||||
assert_eq!(manifest.pod.name, "code-reviewer");
|
assert_eq!(manifest.pod.name, "code-reviewer");
|
||||||
|
assert_eq!(manifest.pod.pwd, PathBuf::from("./src"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.provider.api_key_file.as_deref(),
|
manifest.provider.api_key_file.as_deref(),
|
||||||
Some(std::path::Path::new("~/.config/insomnia/keys/anthropic"))
|
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.max_tokens, Some(4096));
|
||||||
assert_eq!(manifest.worker.temperature, Some(0.3));
|
assert_eq!(manifest.worker.temperature, Some(0.3));
|
||||||
assert_eq!(
|
let allow = &manifest.scope.allow;
|
||||||
manifest.scope.as_ref().unwrap().root,
|
assert_eq!(allow.len(), 2);
|
||||||
PathBuf::from("./src")
|
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]
|
#[test]
|
||||||
fn parse_ollama_no_api_key() {
|
fn reject_missing_scope() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
[pod]
|
[pod]
|
||||||
name = "local-agent"
|
name = "missing-scope"
|
||||||
|
pwd = "./"
|
||||||
[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"
|
|
||||||
|
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
model = "claude-sonnet-4-20250514"
|
model = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
[worker]
|
[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());
|
assert!(PodManifest::from_toml(toml).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_compaction_config() {
|
fn reject_missing_pwd() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
[pod]
|
[pod]
|
||||||
name = "test"
|
name = "missing-pwd"
|
||||||
|
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
|
|
@ -282,10 +301,36 @@ model = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
[worker]
|
[worker]
|
||||||
|
|
||||||
[compaction]
|
[[scope.allow]]
|
||||||
compact_threshold = 80000
|
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();
|
let c = manifest.compaction.unwrap();
|
||||||
assert_eq!(c.prune_protected_turns, 3);
|
assert_eq!(c.prune_protected_turns, 3);
|
||||||
assert_eq!(c.prune_min_savings, 4096);
|
assert_eq!(c.prune_min_savings, 4096);
|
||||||
|
|
@ -295,24 +340,15 @@ compact_threshold = 80000
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_compaction_with_provider() {
|
fn parse_compaction_with_provider() {
|
||||||
let toml = r#"
|
let toml = format!(
|
||||||
[pod]
|
"{MINIMAL_REQUIRED}\n\
|
||||||
name = "test"
|
[compaction]\n\
|
||||||
|
compact_threshold = 80000\n\n\
|
||||||
[provider]
|
[compaction.provider]\n\
|
||||||
kind = "anthropic"
|
kind = \"gemini\"\n\
|
||||||
model = "claude-sonnet-4-20250514"
|
model = \"gemini-2.0-flash\"\n"
|
||||||
|
);
|
||||||
[worker]
|
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||||
|
|
||||||
[compaction]
|
|
||||||
compact_threshold = 80000
|
|
||||||
|
|
||||||
[compaction.provider]
|
|
||||||
kind = "gemini"
|
|
||||||
model = "gemini-2.0-flash"
|
|
||||||
"#;
|
|
||||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
let p = c.provider.unwrap();
|
let p = c.provider.unwrap();
|
||||||
assert_eq!(p.kind, ProviderKind::Gemini);
|
assert_eq!(p.kind, ProviderKind::Gemini);
|
||||||
|
|
@ -321,32 +357,25 @@ model = "gemini-2.0-flash"
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn omitted_compaction_is_none() {
|
fn omitted_compaction_is_none() {
|
||||||
let toml = r#"
|
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
[pod]
|
|
||||||
name = "test"
|
|
||||||
|
|
||||||
[provider]
|
|
||||||
kind = "anthropic"
|
|
||||||
model = "claude-sonnet-4-20250514"
|
|
||||||
|
|
||||||
[worker]
|
|
||||||
"#;
|
|
||||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
|
||||||
assert!(manifest.compaction.is_none());
|
assert!(manifest.compaction.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reject_unknown_provider() {
|
fn reject_unknown_provider() {
|
||||||
let toml = r#"
|
let toml = MINIMAL_REQUIRED.replace("kind = \"anthropic\"", "kind = \"unknown_provider\"");
|
||||||
[pod]
|
assert!(PodManifest::from_toml(&toml).is_err());
|
||||||
name = "test"
|
}
|
||||||
|
|
||||||
[provider]
|
#[test]
|
||||||
kind = "unknown_provider"
|
fn default_recursive_true() {
|
||||||
model = "x"
|
let rule: ScopeRule = toml::from_str(
|
||||||
|
r#"
|
||||||
[worker]
|
target = "./"
|
||||||
"#;
|
permission = "read"
|
||||||
assert!(PodManifest::from_toml(toml).is_err());
|
"#,
|
||||||
|
)
|
||||||
|
.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};
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Scope {
|
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 {
|
impl Scope {
|
||||||
/// Create a new scope rooted at the given directory.
|
/// Build a [`Scope`] from a declarative [`ScopeConfig`], resolving
|
||||||
///
|
/// relative `target` paths against `base` (conventionally the Pod's
|
||||||
/// The path is canonicalized to resolve symlinks and relative components.
|
/// absolute pwd).
|
||||||
pub fn new(root: impl Into<PathBuf>) -> std::io::Result<Self> {
|
pub fn from_config(config: &ScopeConfig, base: &Path) -> Result<Self, ScopeError> {
|
||||||
let root = root.into().canonicalize()?;
|
if !base.is_absolute() {
|
||||||
Ok(Self { root })
|
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.
|
/// Convenience constructor for tests and simple setups: a single
|
||||||
pub fn root(&self) -> &Path {
|
/// recursive `allow(Write)` rule rooted at `root`.
|
||||||
&self.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
|
/// Returns `None` when `path` is outside every allow rule, or when
|
||||||
/// exist yet (typical for new-file writes), the closest existing
|
/// deny rules have knocked it below `Read`.
|
||||||
/// ancestor is canonicalized and checked, so deep new directory
|
pub fn permission_at(&self, path: &Path) -> Option<Permission> {
|
||||||
/// hierarchies inside the scope are also accepted.
|
let resolved = resolve_path(path)?;
|
||||||
pub fn contains(&self, path: &Path) -> bool {
|
let mut effective: Option<Permission> = None;
|
||||||
let mut cur = path;
|
for rule in &self.allow {
|
||||||
loop {
|
if rule.matches(&resolved) {
|
||||||
if let Ok(canonical) = cur.canonicalize() {
|
effective = match effective {
|
||||||
return canonical.starts_with(&self.root);
|
None => Some(rule.permission),
|
||||||
}
|
Some(cur) => Some(cur.max(rule.permission)),
|
||||||
match cur.parent() {
|
};
|
||||||
Some(parent) if parent != cur => cur = parent,
|
|
||||||
_ => return false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::fs;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
fn allow_rule(target: &Path, permission: Permission) -> ScopeRule {
|
||||||
fn contains_file_inside_scope() {
|
ScopeRule {
|
||||||
let dir = TempDir::new().unwrap();
|
target: target.to_path_buf(),
|
||||||
let scope = Scope::new(dir.path()).unwrap();
|
permission,
|
||||||
|
recursive: true,
|
||||||
let file = dir.path().join("test.txt");
|
}
|
||||||
fs::write(&file, "hello").unwrap();
|
|
||||||
|
|
||||||
assert!(scope.contains(&file));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 dir = TempDir::new().unwrap();
|
||||||
let outside = TempDir::new().unwrap();
|
let outside = TempDir::new().unwrap();
|
||||||
let scope = Scope::new(dir.path()).unwrap();
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
|
assert!(!scope.is_readable(&outside.path().join("x")));
|
||||||
let file = outside.path().join("test.txt");
|
|
||||||
fs::write(&file, "hello").unwrap();
|
|
||||||
|
|
||||||
assert!(!scope.contains(&file));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn contains_new_file_in_existing_parent() {
|
fn allow_write_grants_read_and_write() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let scope = Scope::new(dir.path()).unwrap();
|
let cfg = ScopeConfig {
|
||||||
|
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||||
// File doesn't exist yet, but parent dir is inside scope
|
deny: Vec::new(),
|
||||||
let new_file = dir.path().join("new.txt");
|
};
|
||||||
assert!(scope.contains(&new_file));
|
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]
|
#[test]
|
||||||
fn contains_nested_directory() {
|
fn allow_read_only() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let nested = dir.path().join("a/b/c");
|
let cfg = ScopeConfig {
|
||||||
fs::create_dir_all(&nested).unwrap();
|
allow: vec![allow_rule(dir.path(), Permission::Read)],
|
||||||
let scope = Scope::new(dir.path()).unwrap();
|
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");
|
#[test]
|
||||||
assert!(scope.contains(&file));
|
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]
|
#[test]
|
||||||
fn rejects_traversal_attack() {
|
fn rejects_traversal_attack() {
|
||||||
let dir = TempDir::new().unwrap();
|
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");
|
let traversal = dir.path().join("../../../etc/passwd");
|
||||||
assert!(!scope.contains(&traversal));
|
assert!(!scope.is_readable(&traversal));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn contains_deeply_nested_new_path() {
|
fn resolves_new_nested_file_inside_scope() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let scope = Scope::new(dir.path()).unwrap();
|
let scope = Scope::writable(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 deep = dir.path().join("a/b/c/new.txt");
|
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#"
|
const MANIFEST_TOML: &str = r#"
|
||||||
[pod]
|
[pod]
|
||||||
name = "hello-pod"
|
name = "hello-pod"
|
||||||
|
pwd = "./"
|
||||||
|
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
|
|
@ -25,6 +26,10 @@ model = "claude-sonnet-4-20250514"
|
||||||
[worker]
|
[worker]
|
||||||
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
||||||
max_tokens = 256
|
max_tokens = 256
|
||||||
|
|
||||||
|
[[scope.allow]]
|
||||||
|
target = "./"
|
||||||
|
permission = "write"
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -40,7 +45,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let store = FsStore::new(tmp.path()).await?;
|
let store = FsStore::new(tmp.path()).await?;
|
||||||
|
|
||||||
// 3. Build the Pod from manifest
|
// 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());
|
println!("Session: {}", pod.session_id());
|
||||||
|
|
||||||
// 4. Run a prompt
|
// 4. Run a prompt
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use session_store::FsStore;
|
||||||
const MANIFEST_TOML: &str = r#"
|
const MANIFEST_TOML: &str = r#"
|
||||||
[pod]
|
[pod]
|
||||||
name = "protocol-demo"
|
name = "protocol-demo"
|
||||||
|
pwd = "./"
|
||||||
|
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
|
|
@ -19,6 +20,10 @@ model = "claude-sonnet-4-20250514"
|
||||||
[worker]
|
[worker]
|
||||||
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
||||||
max_tokens = 256
|
max_tokens = 256
|
||||||
|
|
||||||
|
[[scope.allow]]
|
||||||
|
target = "./"
|
||||||
|
permission = "write"
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -28,7 +33,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let manifest = PodManifest::from_toml(MANIFEST_TOML)?;
|
let manifest = PodManifest::from_toml(MANIFEST_TOML)?;
|
||||||
let tmp = tempfile::tempdir()?;
|
let tmp = tempfile::tempdir()?;
|
||||||
let store = FsStore::new(tmp.path()).await?;
|
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 runtime_tmp = tempfile::tempdir()?;
|
||||||
let handle = PodController::spawn(pod, runtime_tmp.path()).await?;
|
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
|
// Keep the server alive by moving it into the controller task
|
||||||
// (it will be dropped when the task ends)
|
// (it will be dropped when the task ends)
|
||||||
|
|
||||||
// Grab the scope before the mutable borrow of the worker so we can
|
// Grab the scope/pwd before the mutable borrow of the worker so we
|
||||||
// build a `ScopedFs` for the builtin tools. `Scope` is cheap to clone.
|
// can build a `ScopedFs` for the builtin tools.
|
||||||
let scope_for_tools = pod.scope().cloned();
|
let scope_for_tools = pod.scope().clone();
|
||||||
|
let pwd_for_tools = pod.pwd().to_path_buf();
|
||||||
|
|
||||||
// Register event bridge callbacks on the worker
|
// Register event bridge callbacks on the worker
|
||||||
{
|
{
|
||||||
|
|
@ -161,21 +162,17 @@ impl PodController {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the builtin file-manipulation tools (Read / Write /
|
// Register the builtin file-manipulation tools (Read / Write /
|
||||||
// Edit / Glob / Grep) when the manifest declares a scope.
|
// Edit / Glob / Grep). `ScopedFs` carries the pod-lifetime
|
||||||
//
|
// scope/pwd; `Tracker` is session-scoped — a fresh instance per
|
||||||
// `ScopedFs` carries the pod-lifetime write boundary (derived
|
// controller spawn ensures state from a previous process
|
||||||
// from the manifest scope). `Tracker` is session-scoped —
|
// lifetime cannot be reused after a resume. The tracker is
|
||||||
// a fresh instance per controller spawn ensures state from a
|
// also handed to the Pod itself so Pod-level operations (e.g.
|
||||||
// previous process lifetime cannot be reused after a resume.
|
// context compaction) can ask which files the agent has been
|
||||||
// The tracker is also handed to the Pod itself so Pod-level
|
// touching.
|
||||||
// operations (e.g. context compaction) can ask which files
|
let fs = tools::ScopedFs::new(scope_for_tools, pwd_for_tools);
|
||||||
// the agent has been touching.
|
let tracker = tools::Tracker::new();
|
||||||
if let Some(scope) = scope_for_tools {
|
worker.register_tools(tools::builtin_tools(fs, tracker.clone()));
|
||||||
let fs = tools::ScopedFs::new(scope);
|
pod.attach_tracker(tracker);
|
||||||
let tracker = tools::Tracker::new();
|
|
||||||
worker.register_tools(tools::builtin_tools(fs, tracker.clone()));
|
|
||||||
pod.attach_tracker(tracker);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone cancel sender before moving pod
|
// Clone cancel sender before moving pod
|
||||||
|
|
|
||||||
|
|
@ -70,23 +70,11 @@ async fn main() -> ExitCode {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build scope from manifest
|
// Build the Pod (pwd/scope derived from manifest + manifest_dir).
|
||||||
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
|
|
||||||
let manifest_dir = std::fs::canonicalize(&cli.manifest)
|
let manifest_dir = std::fs::canonicalize(&cli.manifest)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.parent().map(Path::to_path_buf));
|
.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,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("error: failed to create pod: {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 std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use llm_worker::Item;
|
use llm_worker::Item;
|
||||||
|
|
@ -11,7 +11,7 @@ use session_store::{
|
||||||
};
|
};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use manifest::{PodManifest, Scope, WorkerManifest};
|
use manifest::{PodManifest, Scope, ScopeError, WorkerManifest};
|
||||||
|
|
||||||
use crate::compact_interceptor::CompactInterceptor;
|
use crate::compact_interceptor::CompactInterceptor;
|
||||||
use crate::compact_state::CompactState;
|
use crate::compact_state::CompactState;
|
||||||
|
|
@ -64,7 +64,10 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||||
store: St,
|
store: St,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
head_hash: Option<EntryHash>,
|
head_hash: Option<EntryHash>,
|
||||||
scope: Option<Scope>,
|
/// Absolute working directory of the Pod.
|
||||||
|
pwd: PathBuf,
|
||||||
|
/// Resolved scope — always present.
|
||||||
|
scope: Scope,
|
||||||
hook_builder: HookRegistryBuilder,
|
hook_builder: HookRegistryBuilder,
|
||||||
interceptor_installed: bool,
|
interceptor_installed: bool,
|
||||||
/// Directory containing the manifest file (needed for api_key_file resolution).
|
/// 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> {
|
impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
/// Create a new Pod from a pre-built Worker and store.
|
/// 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(
|
pub async fn new(
|
||||||
manifest: PodManifest,
|
manifest: PodManifest,
|
||||||
worker: Worker<C>,
|
worker: Worker<C>,
|
||||||
store: St,
|
store: St,
|
||||||
scope: Option<Scope>,
|
pwd: PathBuf,
|
||||||
|
scope: Scope,
|
||||||
) -> Result<Self, PodError> {
|
) -> Result<Self, PodError> {
|
||||||
let state = SessionStartState {
|
let state = SessionStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
|
|
@ -110,6 +118,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
head_hash: Some(head_hash),
|
head_hash: Some(head_hash),
|
||||||
|
pwd,
|
||||||
scope,
|
scope,
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
|
|
@ -129,7 +138,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
manifest: PodManifest,
|
manifest: PodManifest,
|
||||||
client: C,
|
client: C,
|
||||||
store: St,
|
store: St,
|
||||||
scope: Option<Scope>,
|
pwd: PathBuf,
|
||||||
|
scope: Scope,
|
||||||
) -> Result<Self, PodError> {
|
) -> Result<Self, PodError> {
|
||||||
let state = session_store::restore(&store, session_id).await?;
|
let state = session_store::restore(&store, session_id).await?;
|
||||||
let mut worker = Worker::new(client);
|
let mut worker = Worker::new(client);
|
||||||
|
|
@ -147,6 +157,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
head_hash: state.head_hash,
|
head_hash: state.head_hash,
|
||||||
|
pwd,
|
||||||
scope,
|
scope,
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
|
|
@ -170,9 +181,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
&self.manifest
|
&self.manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Pod's directory scope, if any.
|
/// The Pod's working directory.
|
||||||
pub fn scope(&self) -> Option<&Scope> {
|
pub fn pwd(&self) -> &Path {
|
||||||
self.scope.as_ref()
|
&self.pwd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Pod's directory scope.
|
||||||
|
pub fn scope(&self) -> &Scope {
|
||||||
|
&self.scope
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct access to the underlying Worker.
|
/// 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> {
|
impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
/// Create a Pod entirely from a manifest.
|
/// 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(
|
pub async fn from_manifest(
|
||||||
manifest: PodManifest,
|
manifest: PodManifest,
|
||||||
store: St,
|
store: St,
|
||||||
scope: Option<Scope>,
|
|
||||||
manifest_dir: Option<PathBuf>,
|
manifest_dir: Option<PathBuf>,
|
||||||
) -> Result<Self, PodError> {
|
) -> 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 client = provider::build_client(&manifest.provider, manifest_dir.as_deref())?;
|
||||||
let mut worker = Worker::new(client);
|
let mut worker = Worker::new(client);
|
||||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||||
|
|
@ -711,6 +737,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
head_hash: Some(head_hash),
|
head_hash: Some(head_hash),
|
||||||
|
pwd,
|
||||||
scope,
|
scope,
|
||||||
hook_builder: HookRegistryBuilder::new(),
|
hook_builder: HookRegistryBuilder::new(),
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
|
|
@ -811,8 +838,18 @@ pub enum PodError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Store(#[from] StoreError),
|
Store(#[from] StoreError),
|
||||||
|
|
||||||
#[error("scope violation: {path} is outside the allowed directory")]
|
#[error(transparent)]
|
||||||
ScopeViolation { path: String },
|
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)]
|
#[error(transparent)]
|
||||||
Provider(#[from] provider::ProviderError),
|
Provider(#[from] provider::ProviderError),
|
||||||
|
|
@ -820,3 +857,23 @@ pub enum PodError {
|
||||||
#[error("compaction thrash: context still exceeds threshold immediately after compact")]
|
#[error("compaction thrash: context still exceeds threshold immediately after compact")]
|
||||||
CompactThrash,
|
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#"
|
const MANIFEST_TOML: &str = r#"
|
||||||
[pod]
|
[pod]
|
||||||
name = "test-pod"
|
name = "test-pod"
|
||||||
|
pwd = "./"
|
||||||
|
|
||||||
[provider]
|
[provider]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
|
|
@ -81,16 +82,28 @@ model = "test-model"
|
||||||
|
|
||||||
[worker]
|
[worker]
|
||||||
max_tokens = 100
|
max_tokens = 100
|
||||||
|
|
||||||
|
[[scope.allow]]
|
||||||
|
target = "./"
|
||||||
|
permission = "write"
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
async fn make_pod(client: MockClient) -> Pod<MockClient, FsStore> {
|
async fn make_pod(client: MockClient) -> Pod<MockClient, FsStore> {
|
||||||
let manifest = PodManifest::from_toml(MANIFEST_TOML).unwrap();
|
let manifest = PodManifest::from_toml(MANIFEST_TOML).unwrap();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let store_tmp = tempfile::tempdir().unwrap();
|
||||||
let store = FsStore::new(tmp.path()).await.unwrap();
|
let store = FsStore::new(store_tmp.path()).await.unwrap();
|
||||||
// Leak tempdir to keep it alive
|
std::mem::forget(store_tmp);
|
||||||
std::mem::forget(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);
|
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;
|
use pod::PodHandle;
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,10 @@ mod tests {
|
||||||
|
|
||||||
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
||||||
let dir = TempDir::new().unwrap();
|
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())
|
(dir, fs, Tracker::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ pub enum ToolsError {
|
||||||
#[error("path is outside allowed scope: {}", .0.display())]
|
#[error("path is outside allowed scope: {}", .0.display())]
|
||||||
OutOfScope(PathBuf),
|
OutOfScope(PathBuf),
|
||||||
|
|
||||||
|
#[error("path is read-only in this scope: {}", .0.display())]
|
||||||
|
ReadOnly(PathBuf),
|
||||||
|
|
||||||
#[error("path is a directory: {}", .0.display())]
|
#[error("path is a directory: {}", .0.display())]
|
||||||
IsDirectory(PathBuf),
|
IsDirectory(PathBuf),
|
||||||
|
|
||||||
|
|
@ -70,6 +73,7 @@ impl From<ToolsError> for ToolError {
|
||||||
match err {
|
match err {
|
||||||
RelativePath(_)
|
RelativePath(_)
|
||||||
| OutOfScope(_)
|
| OutOfScope(_)
|
||||||
|
| ReadOnly(_)
|
||||||
| IsDirectory(_)
|
| IsDirectory(_)
|
||||||
| NotRead(_)
|
| NotRead(_)
|
||||||
| ExternallyModified(_)
|
| ExternallyModified(_)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use std::time::SystemTime;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
|
use manifest::Scope;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::error::ToolsError;
|
use crate::error::ToolsError;
|
||||||
|
|
@ -47,12 +48,13 @@ impl Tool for GlobTool {
|
||||||
let base = params
|
let base = params
|
||||||
.path
|
.path
|
||||||
.clone()
|
.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 pattern = params.pattern.clone();
|
||||||
|
let scope = self.fs.scope().clone();
|
||||||
|
|
||||||
// ignore::Walk is synchronous; run it on a blocking thread so we
|
// ignore::Walk is synchronous; run it on a blocking thread so we
|
||||||
// don't stall the runtime for large trees.
|
// 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
|
.await
|
||||||
.map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??;
|
.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() {
|
if !base.is_absolute() {
|
||||||
return Err(ToolsError::RelativePath(base.to_path_buf()));
|
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) {
|
if !glob.is_match(rel) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !scope.is_readable(entry.path()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mtime = entry
|
let mtime = entry
|
||||||
.metadata()
|
.metadata()
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -164,7 +169,10 @@ mod tests {
|
||||||
|
|
||||||
fn setup() -> (TempDir, ScopedFs) {
|
fn setup() -> (TempDir, ScopedFs) {
|
||||||
let dir = TempDir::new().unwrap();
|
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)
|
(dir, fs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,6 +245,43 @@ mod tests {
|
||||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
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]
|
#[tokio::test]
|
||||||
async fn glob_honors_hidden_files() {
|
async fn glob_honors_hidden_files() {
|
||||||
let (dir, fs) = setup();
|
let (dir, fs) = setup();
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use ignore::WalkBuilder;
|
||||||
use ignore::overrides::OverrideBuilder;
|
use ignore::overrides::OverrideBuilder;
|
||||||
use ignore::types::TypesBuilder;
|
use ignore::types::TypesBuilder;
|
||||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
|
use manifest::Scope;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::error::ToolsError;
|
use crate::error::ToolsError;
|
||||||
|
|
@ -91,8 +92,9 @@ impl Tool for GrepTool {
|
||||||
"Grep"
|
"Grep"
|
||||||
);
|
);
|
||||||
|
|
||||||
let default_base = self.fs.scope().root().to_path_buf();
|
let default_base = self.fs.pwd().to_path_buf();
|
||||||
let report = tokio::task::spawn_blocking(move || run_grep(default_base, params))
|
let scope = self.fs.scope().clone();
|
||||||
|
let report = tokio::task::spawn_blocking(move || run_grep(default_base, params, &scope))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??;
|
.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()
|
let matcher = RegexMatcherBuilder::new()
|
||||||
.case_insensitive(p.case_insensitive)
|
.case_insensitive(p.case_insensitive)
|
||||||
.multi_line(p.multiline)
|
.multi_line(p.multiline)
|
||||||
|
|
@ -309,6 +311,9 @@ fn run_grep(default_base: PathBuf, p: GrepParams) -> Result<GrepReport, ToolsErr
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
if !scope.is_readable(path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
GrepOutputMode::FilesWithMatches => {
|
GrepOutputMode::FilesWithMatches => {
|
||||||
|
|
@ -472,7 +477,10 @@ mod tests {
|
||||||
|
|
||||||
fn setup() -> (TempDir, ScopedFs) {
|
fn setup() -> (TempDir, ScopedFs) {
|
||||||
let dir = TempDir::new().unwrap();
|
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)
|
(dir, fs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,6 +491,43 @@ mod tests {
|
||||||
fs::write(path, content).unwrap();
|
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]
|
#[tokio::test]
|
||||||
async fn grep_files_with_matches_default() {
|
async fn grep_files_with_matches_default() {
|
||||||
let (dir, fs) = setup();
|
let (dir, fs) = setup();
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,10 @@ mod tests {
|
||||||
|
|
||||||
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
||||||
let dir = TempDir::new().unwrap();
|
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())
|
(dir, fs, Tracker::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
//! Scope-aware filesystem primitive.
|
//! Scope-aware filesystem primitive.
|
||||||
//!
|
//!
|
||||||
//! `ScopedFs` represents **only** the write-block boundary: it knows a
|
//! `ScopedFs` is the write/read gate layered on top of a [`manifest::Scope`]
|
||||||
//! [`manifest::Scope`] and refuses writes outside of it. It carries no
|
//! and a Pod's working directory. The scope decides which paths are
|
||||||
//! per-session state and is cheap to clone (pod-lifetime, reusable across
|
//! readable and writable; the pwd is carried alongside for convenience
|
||||||
//! sessions). The read-before-edit policy lives separately in
|
//! (Glob/Grep default their search base to it).
|
||||||
//! [`crate::Tracker`].
|
|
||||||
//!
|
//!
|
||||||
//! 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::io::Write as _;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use manifest::Scope;
|
use manifest::Scope;
|
||||||
|
|
@ -19,6 +20,7 @@ use crate::error::ToolsError;
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ScopedFsInner {
|
struct ScopedFsInner {
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
|
pwd: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
||||||
|
|
@ -35,10 +37,10 @@ pub struct WriteOutcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScopedFs {
|
impl ScopedFs {
|
||||||
/// Create a new [`ScopedFs`] wrapping the given [`Scope`].
|
/// Create a new [`ScopedFs`] wrapping the given [`Scope`] and pwd.
|
||||||
pub fn new(scope: Scope) -> Self {
|
pub fn new(scope: Scope, pwd: PathBuf) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(ScopedFsInner { scope }),
|
inner: Arc::new(ScopedFsInner { scope, pwd }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,18 +49,27 @@ impl ScopedFs {
|
||||||
&self.inner.scope
|
&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.
|
/// Read the full contents of `path` as raw bytes.
|
||||||
///
|
///
|
||||||
/// Follows symlinks. Rejects directories, relative paths, and missing
|
/// Follows symlinks. Rejects directories, relative paths, paths not
|
||||||
/// files. No scope check.
|
/// readable by the scope, and missing files.
|
||||||
pub fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, ToolsError> {
|
pub fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, ToolsError> {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
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() {
|
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
|
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
|
||||||
_ => ToolsError::io(path, e),
|
_ => ToolsError::io(path, e),
|
||||||
|
|
@ -75,9 +86,10 @@ impl ScopedFs {
|
||||||
|
|
||||||
/// Atomically write `content` to `path`, creating or overwriting it.
|
/// Atomically write `content` to `path`, creating or overwriting it.
|
||||||
///
|
///
|
||||||
/// - `path` must be absolute and inside the scope (delegates to
|
/// - `path` must be absolute and writable under the scope.
|
||||||
/// [`Scope::contains`]).
|
/// - Paths that are readable but not writable return [`ToolsError::ReadOnly`].
|
||||||
/// - Missing parent directories inside the scope are created.
|
/// - Paths outside the scope entirely return [`ToolsError::OutOfScope`].
|
||||||
|
/// - Missing parent directories are created.
|
||||||
/// - The actual write uses a sibling tempfile + `persist`, so the
|
/// - The actual write uses a sibling tempfile + `persist`, so the
|
||||||
/// target file transitions atomically between states.
|
/// target file transitions atomically between states.
|
||||||
///
|
///
|
||||||
|
|
@ -88,8 +100,12 @@ impl ScopedFs {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
if !self.inner.scope.contains(path) {
|
if !self.inner.scope.is_writable(path) {
|
||||||
return Err(ToolsError::OutOfScope(path.to_path_buf()));
|
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.
|
// Reject existing directory targets.
|
||||||
|
|
@ -138,11 +154,15 @@ impl ScopedFs {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use manifest::{Permission, ScopeConfig, ScopeRule};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn make_fs(dir: &TempDir) -> ScopedFs {
|
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]
|
#[test]
|
||||||
fn read_bytes_allows_paths_outside_scope() {
|
fn read_bytes_rejects_paths_outside_scope() {
|
||||||
// Reads are unrestricted — scope only gates writes.
|
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let outside = TempDir::new().unwrap();
|
let outside = TempDir::new().unwrap();
|
||||||
let outside_file = outside.path().join("x.txt");
|
let outside_file = outside.path().join("x.txt");
|
||||||
fs::write(&outside_file, b"hi").unwrap();
|
fs::write(&outside_file, b"hi").unwrap();
|
||||||
|
|
||||||
let scoped = make_fs(&dir);
|
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(_)));
|
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]
|
#[test]
|
||||||
fn write_rejects_relative_path() {
|
fn write_rejects_relative_path() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,11 @@
|
||||||
//! the Pod wires them together when registering builtin tools.
|
//! the Pod wires them together when registering builtin tools.
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
|
//! # use std::path::PathBuf;
|
||||||
//! # use manifest::Scope;
|
//! # use manifest::Scope;
|
||||||
//! # use tools::{ScopedFs, Tracker, builtin_tools};
|
//! # use tools::{ScopedFs, Tracker, builtin_tools};
|
||||||
//! let scope = Scope::new("/workspace").unwrap();
|
//! let scope = Scope::writable("/workspace").unwrap();
|
||||||
//! let fs = ScopedFs::new(scope); // pod lifetime
|
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime
|
||||||
//! let tracker = Tracker::new(); // session lifetime
|
//! let tracker = Tracker::new(); // session lifetime
|
||||||
//! let defs = builtin_tools(fs, tracker);
|
//! let defs = builtin_tools(fs, tracker);
|
||||||
//! ```
|
//! ```
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,10 @@ mod tests {
|
||||||
|
|
||||||
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
||||||
let dir = TempDir::new().unwrap();
|
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())
|
(dir, fs, Tracker::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ impl Registry {
|
||||||
|
|
||||||
fn setup() -> (TempDir, Registry) {
|
fn setup() -> (TempDir, Registry) {
|
||||||
let dir = TempDir::new().unwrap();
|
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 tracker = Tracker::new();
|
||||||
(dir, Registry::new(builtin_tools(fs, tracker)))
|
(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");
|
let link = dir.path().join("linked.txt");
|
||||||
symlink(&outside_target, &link).unwrap();
|
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");
|
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
|
.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
|
// Write through the symlink must be rejected for the same reason.
|
||||||
// resolves it to outside the scope.
|
|
||||||
let write = reg.get("Write");
|
let write = reg.get("Write");
|
||||||
let err = write
|
let err = write
|
||||||
.execute(
|
.execute(
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ impl Registry {
|
||||||
|
|
||||||
fn setup() -> (TempDir, Registry) {
|
fn setup() -> (TempDir, Registry) {
|
||||||
let dir = TempDir::new().unwrap();
|
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 tracker = Tracker::new();
|
||||||
let reg = Registry::new(builtin_tools(fs, tracker));
|
let reg = Registry::new(builtin_tools(fs, tracker));
|
||||||
(dir, reg)
|
(dir, reg)
|
||||||
|
|
@ -275,7 +278,10 @@ fn tool_names_match_reference_spec() {
|
||||||
async fn tracker_recent_files_tracks_read_write_edit() {
|
async fn tracker_recent_files_tracks_read_write_edit() {
|
||||||
// Build a fresh registry that shares a tracker we can query afterwards.
|
// Build a fresh registry that shares a tracker we can query afterwards.
|
||||||
let dir = TempDir::new().unwrap();
|
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 tracker = Tracker::new();
|
||||||
let reg = Registry::new(builtin_tools(fs, tracker.clone()));
|
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` しか持たない — 書き込み許可/禁止の概念がない
|
1. `Pod.scope` が `Option<Scope>` — scope なし Pod が成立しうる(ツール未登録)
|
||||||
3. Scope なしの場合、Glob/Grep のデフォルト検索パスがない
|
2. `Scope` は `root: PathBuf` のみ — 書き込み許可/禁止の概念がない
|
||||||
4. マニフェストで `[scope]` 省略時のフォールバックが定義されていない
|
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
|
```rust
|
||||||
pub struct Scope {
|
// crates/manifest/src/lib.rs
|
||||||
pwd: PathBuf, // 作業ディレクトリ
|
pub struct PodMeta {
|
||||||
writable: bool, // false = 読み取り専用 Pod
|
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 全て使える
|
```rust
|
||||||
- `writable: false`: Read/Glob/Grep のみ。Write/Edit はエラー
|
// crates/manifest/src/scope.rs
|
||||||
- `pwd` は Glob/Grep のデフォルト検索パスとしても使われる
|
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 で必須化
|
### Pod で必須化
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct Pod<C, St> {
|
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 }
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### マニフェスト
|
`from_manifest` 系は manifest の `[pod].pwd` と `[scope]` から pwd / scope を構築する。整合性チェック(pwd が allow に含まれる、deny で潰されていない)はここで行う。
|
||||||
|
|
||||||
```toml
|
|
||||||
# 明示指定
|
|
||||||
[scope]
|
|
||||||
pwd = "./src"
|
|
||||||
writable = false # 省略時 true
|
|
||||||
|
|
||||||
# [scope] 省略時 → マニフェストファイルの親ディレクトリが pwd、writable = true
|
|
||||||
```
|
|
||||||
|
|
||||||
`Pod::new()`(マニフェストなし構築)では Scope を引数で必須にする。
|
|
||||||
|
|
||||||
### ScopedFs の変更
|
### ScopedFs の変更
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub fn write(&self, path: &Path, content: &[u8]) -> Result<WriteOutcome, ToolsError> {
|
pub fn read(&self, path: &Path) -> Result<Vec<u8>, ToolsError> {
|
||||||
if !self.inner.scope.writable() {
|
if !self.inner.scope.is_readable(path) {
|
||||||
return Err(ToolsError::ReadOnly);
|
|
||||||
}
|
|
||||||
if !self.inner.scope.contains(path) {
|
|
||||||
return Err(ToolsError::OutOfScope(path.to_path_buf()));
|
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 統合
|
### Controller 統合
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
|
@ -68,19 +189,58 @@ pub fn write(&self, path: &Path, content: &[u8]) -> Result<WriteOutcome, ToolsEr
|
||||||
if let Some(scope) = scope_for_tools { ... }
|
if let Some(scope) = scope_for_tools { ... }
|
||||||
|
|
||||||
// 後: 常にツール登録(scope は必須)
|
// 後: 常にツール登録(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();
|
let tracker = tools::Tracker::new();
|
||||||
worker.register_tools(tools::builtin_tools(fs, tracker));
|
worker.register_tools(tools::builtin_tools(fs, tracker));
|
||||||
```
|
```
|
||||||
|
|
||||||
## 影響範囲
|
## 影響範囲
|
||||||
|
|
||||||
- `manifest::Scope` — `root` → `pwd`、`writable` フィールド追加
|
- `crates/manifest/src/lib.rs`
|
||||||
- `manifest::ScopeConfig` — `root` → `pwd`、`writable` の serde 対応
|
- `PodMeta` に `pwd: PathBuf` を追加
|
||||||
- `manifest::PodManifest` — [scope] 省略時のフォールバック解決
|
- `ScopeConfig` を `{ allow: Vec<ScopeRule>, deny: Vec<ScopeRule> }` に置き換え(旧 `root` フィールド削除)
|
||||||
- `Pod` — `scope: Option<Scope>` → `scope: Scope`
|
- `Permission` enum、`ScopeRule` 構造体を追加
|
||||||
- `Pod::new()`, `Pod::restore()`, `Pod::from_manifest()` — シグネチャ変更
|
- `PodManifest::scope` を `Option<ScopeConfig>` → `ScopeConfig` に(省略不可)
|
||||||
- `ScopedFs` — writable チェック追加
|
- manifest ロード時の整合性チェック(scope 必須、allow 1 件以上、pwd と allow/deny の関係)
|
||||||
- `Controller` — scope の Optional 分岐を削除
|
|
||||||
- `tools::error::ToolsError` — `ReadOnly` variant 追加
|
- `crates/manifest/src/scope.rs`
|
||||||
- `Scope::contains` — `root` → `pwd` のリネーム
|
- `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