cratesの整理
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "manifest"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
toml = "1.1.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
@@ -0,0 +1,168 @@
|
||||
mod scope;
|
||||
|
||||
pub use scope::Scope;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodManifest {
|
||||
pub pod: PodMeta,
|
||||
pub provider: ProviderConfig,
|
||||
pub worker: WorkerManifest,
|
||||
#[serde(default)]
|
||||
pub scope: Option<ScopeConfig>,
|
||||
}
|
||||
|
||||
/// Pod metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodMeta {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub kind: ProviderKind,
|
||||
pub model: String,
|
||||
/// Environment variable name holding the API key.
|
||||
#[serde(default)]
|
||||
pub api_key_env: Option<String>,
|
||||
/// Custom base URL for the provider API.
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Supported LLM providers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProviderKind {
|
||||
Anthropic,
|
||||
Openai,
|
||||
Gemini,
|
||||
Ollama,
|
||||
}
|
||||
|
||||
/// Worker-level configuration embedded in the manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerManifest {
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub max_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// Directory scope configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScopeConfig {
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl PodManifest {
|
||||
/// Parse a manifest from a TOML string.
|
||||
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
||||
toml::from_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_manifest() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "test-agent"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert_eq!(manifest.pod.name, "test-agent");
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||
assert_eq!(manifest.provider.model, "claude-sonnet-4-20250514");
|
||||
assert!(manifest.provider.api_key_env.is_none());
|
||||
assert!(manifest.scope.is_none());
|
||||
assert!(manifest.worker.system_prompt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_manifest() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "code-reviewer"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
api_key_env = "ANTHROPIC_API_KEY"
|
||||
|
||||
[worker]
|
||||
system_prompt = "You are a code reviewer."
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
|
||||
[scope]
|
||||
root = "./src"
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert_eq!(manifest.pod.name, "code-reviewer");
|
||||
assert_eq!(
|
||||
manifest.provider.api_key_env.as_deref(),
|
||||
Some("ANTHROPIC_API_KEY")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.worker.system_prompt.as_deref(),
|
||||
Some("You are a code reviewer.")
|
||||
);
|
||||
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")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_no_api_key() {
|
||||
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_env.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_unknown_provider() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "test"
|
||||
|
||||
[provider]
|
||||
kind = "unknown_provider"
|
||||
model = "x"
|
||||
|
||||
[worker]
|
||||
"#;
|
||||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Directory scope constraining a Pod's write access.
|
||||
///
|
||||
/// Read access is unrestricted — only write operations are checked against the scope.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Scope {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
/// The root directory of this scope.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Check whether `path` falls within this scope.
|
||||
///
|
||||
/// The path is canonicalized before comparison.
|
||||
pub fn contains(&self, path: &Path) -> bool {
|
||||
match path.canonicalize() {
|
||||
Ok(canonical) => canonical.starts_with(&self.root),
|
||||
Err(_) => {
|
||||
// Path doesn't exist yet — check the parent directory instead.
|
||||
// This handles write_file to a new file inside the scope.
|
||||
match path.parent().and_then(|p| p.canonicalize().ok()) {
|
||||
Some(parent) => parent.starts_with(&self.root),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_file_outside_scope() {
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_new_file_in_existing_parent() {
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_nested_directory() {
|
||||
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 file = nested.join("test.txt");
|
||||
assert!(scope.contains(&file));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_attack() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = Scope::new(dir.path()).unwrap();
|
||||
|
||||
let traversal = dir.path().join("../../../etc/passwd");
|
||||
assert!(!scope.contains(&traversal));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user