マニフェスト解決の相対パス化
This commit is contained in:
@@ -39,8 +39,6 @@ pub struct PodManifestConfig {
|
||||
pub struct PodMetaConfig {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -137,6 +135,40 @@ impl PodManifestConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve every relative path inside this partial config against
|
||||
/// `base` (assumed absolute). Paths that are already absolute are
|
||||
/// left untouched. This is the only place per-layer path resolution
|
||||
/// happens — cascade merge runs against fully absolute paths so
|
||||
/// rules from different layers do not accidentally inherit another
|
||||
/// layer's base.
|
||||
///
|
||||
/// Affected fields: `provider.api_key_file`,
|
||||
/// `scope.allow[].target`, `scope.deny[].target`,
|
||||
/// `compaction.provider.api_key_file`.
|
||||
pub fn resolve_paths(mut self, base: &Path) -> Self {
|
||||
debug_assert!(
|
||||
base.is_absolute(),
|
||||
"resolve_paths base must be absolute: {}",
|
||||
base.display()
|
||||
);
|
||||
if let Some(ref mut p) = self.provider.api_key_file {
|
||||
*p = join_if_relative(base, p);
|
||||
}
|
||||
for rule in &mut self.scope.allow {
|
||||
rule.target = join_if_relative(base, &rule.target);
|
||||
}
|
||||
for rule in &mut self.scope.deny {
|
||||
rule.target = join_if_relative(base, &rule.target);
|
||||
}
|
||||
if let Some(ref mut compaction) = self.compaction
|
||||
&& let Some(ref mut cp) = compaction.provider
|
||||
&& let Some(ref mut p) = cp.api_key_file
|
||||
{
|
||||
*p = join_if_relative(base, p);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Merge `upper` into `self`. Fields present in `upper` override
|
||||
/// fields from `self`. Map entries merge key-wise with `upper`
|
||||
/// winning on conflict. Scope rules from both layers accumulate
|
||||
@@ -160,7 +192,6 @@ impl PodMetaConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
name: upper.name.or(self.name),
|
||||
pwd: upper.pwd.or(self.pwd),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +257,18 @@ fn merge_option<T>(lower: Option<T>, upper: Option<T>, merge: fn(T, T) -> T) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn join_if_relative(base: &Path, p: &Path) -> PathBuf {
|
||||
if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
base.join(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Invariant check: every path in a fully-resolved [`PodManifestConfig`]
|
||||
/// must be absolute. Relative paths are resolved per-layer via
|
||||
/// [`PodManifestConfig::resolve_paths`]; if one reaches `TryFrom` it
|
||||
/// indicates a caller skipped the per-layer resolve step.
|
||||
fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> {
|
||||
if path.is_absolute() {
|
||||
Ok(())
|
||||
@@ -264,8 +307,6 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
.pod
|
||||
.name
|
||||
.ok_or(ResolveError::MissingField("pod.name"))?;
|
||||
let pwd = cfg.pod.pwd.ok_or(ResolveError::MissingField("pod.pwd"))?;
|
||||
ensure_absolute("pod.pwd", &pwd)?;
|
||||
|
||||
let provider = resolve_provider(
|
||||
cfg.provider,
|
||||
@@ -333,7 +374,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
.transpose()?;
|
||||
|
||||
Ok(PodManifest {
|
||||
pod: PodMeta { name, pwd },
|
||||
pod: PodMeta { name },
|
||||
provider,
|
||||
worker,
|
||||
scope: cfg.scope,
|
||||
@@ -355,7 +396,6 @@ mod tests {
|
||||
PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("test".into()),
|
||||
pwd: Some(abs("/pod")),
|
||||
},
|
||||
provider: ProviderConfigPartial {
|
||||
kind: Some(ProviderKind::Anthropic),
|
||||
@@ -379,25 +419,54 @@ mod tests {
|
||||
fn resolve_minimal_succeeds() {
|
||||
let manifest: PodManifest = minimal_valid().try_into().unwrap();
|
||||
assert_eq!(manifest.pod.name, "test");
|
||||
assert_eq!(manifest.pod.pwd, abs("/pod"));
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_relative_pwd() {
|
||||
fn resolve_paths_joins_relative_api_key_file() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.pod.pwd = Some(PathBuf::from("./rel"));
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::RelativePath { field: "pod.pwd", .. }
|
||||
));
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("keys/anthropic"));
|
||||
let resolved = cfg.resolve_paths(Path::new("/home/user/.config/insomnia"));
|
||||
assert_eq!(
|
||||
resolved.provider.api_key_file.as_deref(),
|
||||
Some(Path::new("/home/user/.config/insomnia/keys/anthropic"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_relative_api_key_file() {
|
||||
fn resolve_paths_leaves_absolute_paths_untouched() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("~/.config/key"));
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("/etc/already/abs"));
|
||||
let resolved = cfg.resolve_paths(Path::new("/home/user"));
|
||||
assert_eq!(
|
||||
resolved.provider.api_key_file.as_deref(),
|
||||
Some(Path::new("/etc/already/abs"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_paths_joins_relative_scope_targets() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.scope.allow[0].target = PathBuf::from(".");
|
||||
cfg.scope.deny.push(ScopeRule {
|
||||
target: PathBuf::from("secrets"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
});
|
||||
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
|
||||
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
|
||||
assert_eq!(
|
||||
resolved.scope.deny[0].target,
|
||||
Path::new("/workspace/proj/secrets")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_from_invariant_rejects_lingering_relative_api_key_file() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("keys/relative"));
|
||||
// Skipping resolve_paths on purpose: TryFrom must catch the
|
||||
// invariant violation.
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
@@ -409,9 +478,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_relative_scope_target() {
|
||||
fn try_from_invariant_rejects_lingering_relative_scope_target() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.scope.allow[0].target = PathBuf::from("./docs");
|
||||
cfg.scope.allow[0].target = PathBuf::from("docs");
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
@@ -443,21 +512,23 @@ mod tests {
|
||||
let lower = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("lower".into()),
|
||||
pwd: Some(abs("/lower")),
|
||||
},
|
||||
provider: ProviderConfigPartial {
|
||||
model: Some("lower-model".into()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("upper".into()),
|
||||
pwd: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let merged = lower.merge(upper);
|
||||
assert_eq!(merged.pod.name.as_deref(), Some("upper"));
|
||||
// pwd not present in upper — retain lower
|
||||
assert_eq!(merged.pod.pwd, Some(abs("/lower")));
|
||||
// model not present in upper — retain lower
|
||||
assert_eq!(merged.provider.model.as_deref(), Some("lower-model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -556,7 +627,6 @@ mod tests {
|
||||
let bad = r#"
|
||||
[pod]
|
||||
name = "x"
|
||||
pwd = "/abs"
|
||||
|
||||
[worker]
|
||||
max_tokens = "not-a-number"
|
||||
@@ -567,10 +637,13 @@ max_tokens = "not-a-number"
|
||||
#[test]
|
||||
fn from_toml_accepts_unknown_field() {
|
||||
// Unknown keys are warn-and-ignored, not hard errors.
|
||||
// `pod.pwd` specifically is silently dropped after the
|
||||
// path-resolution ticket — keep it in the fixture to exercise
|
||||
// that code path.
|
||||
let ok = r#"
|
||||
[pod]
|
||||
name = "x"
|
||||
pwd = "/abs"
|
||||
pwd = "/obsolete"
|
||||
|
||||
[worker]
|
||||
max_tokens = 1000
|
||||
@@ -610,7 +683,6 @@ permission = "write"
|
||||
let overlay = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("x".into()),
|
||||
pwd: Some(abs("/pod")),
|
||||
},
|
||||
provider: ProviderConfigPartial {
|
||||
kind: Some(ProviderKind::Anthropic),
|
||||
@@ -658,7 +730,6 @@ permission = "write"
|
||||
r#"
|
||||
[pod]
|
||||
name = "dbg"
|
||||
pwd = "/abs/project"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -666,7 +737,6 @@ pwd = "/abs/project"
|
||||
let merged = builtin.merge(user).merge(project).merge(overlay);
|
||||
let manifest: PodManifest = merged.try_into().unwrap();
|
||||
assert_eq!(manifest.pod.name, "dbg");
|
||||
assert_eq!(manifest.pod.pwd, PathBuf::from("/abs/project"));
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||
assert_eq!(manifest.scope.allow.len(), 1);
|
||||
}
|
||||
|
||||
+12
-36
@@ -17,7 +17,9 @@ use serde::{Deserialize, Serialize};
|
||||
/// Declarative configuration for a Pod.
|
||||
///
|
||||
/// Parsed from a TOML manifest file. Describes the provider, model,
|
||||
/// system prompt, and directory scope (required).
|
||||
/// system prompt, and directory scope (required). The Pod's working
|
||||
/// directory is **not** part of the manifest — it is the process's
|
||||
/// `std::env::current_dir()` at construction time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodManifest {
|
||||
pub pod: PodMeta,
|
||||
@@ -32,9 +34,6 @@ pub struct PodManifest {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodMeta {
|
||||
pub name: String,
|
||||
/// Working directory for the Pod. Relative paths are resolved against
|
||||
/// the directory containing the manifest file.
|
||||
pub pwd: PathBuf,
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
@@ -163,8 +162,9 @@ pub struct ScopeConfig {
|
||||
/// 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.
|
||||
/// Target path. Must be absolute by the time [`Scope::from_config`]
|
||||
/// runs — relative paths are resolved per-layer against the manifest
|
||||
/// file's directory (cwd for overlay layers) before cascade merge.
|
||||
pub target: PathBuf,
|
||||
/// Permission level this rule grants (allow) or caps strictly below
|
||||
/// (deny).
|
||||
@@ -256,7 +256,6 @@ mod tests {
|
||||
const MINIMAL_REQUIRED: &str = r#"
|
||||
[pod]
|
||||
name = "test-agent"
|
||||
pwd = "./"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -265,7 +264,7 @@ model = "claude-sonnet-4-20250514"
|
||||
[worker]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
target = "/abs/scope"
|
||||
permission = "write"
|
||||
"#;
|
||||
|
||||
@@ -273,7 +272,6 @@ permission = "write"
|
||||
fn parse_minimal_manifest() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert_eq!(manifest.pod.name, "test-agent");
|
||||
assert_eq!(manifest.pod.pwd, PathBuf::from("./"));
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||
assert_eq!(manifest.provider.model, "claude-sonnet-4-20250514");
|
||||
assert!(manifest.provider.api_key_file.is_none());
|
||||
@@ -287,12 +285,11 @@ permission = "write"
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "code-reviewer"
|
||||
pwd = "./src"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
api_key_file = "~/.config/insomnia/keys/anthropic"
|
||||
api_key_file = "/abs/keys/anthropic"
|
||||
|
||||
[worker]
|
||||
instruction = "$user/reviewer"
|
||||
@@ -300,24 +297,23 @@ max_tokens = 4096
|
||||
temperature = 0.3
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
target = "/abs/project"
|
||||
permission = "write"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "../docs"
|
||||
target = "/abs/docs"
|
||||
permission = "read"
|
||||
recursive = false
|
||||
|
||||
[[scope.deny]]
|
||||
target = "./secrets.rs"
|
||||
target = "/abs/project/secrets.rs"
|
||||
permission = "write"
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert_eq!(manifest.pod.name, "code-reviewer");
|
||||
assert_eq!(manifest.pod.pwd, PathBuf::from("./src"));
|
||||
assert_eq!(
|
||||
manifest.provider.api_key_file.as_deref(),
|
||||
Some(std::path::Path::new("~/.config/insomnia/keys/anthropic"))
|
||||
Some(std::path::Path::new("/abs/keys/anthropic"))
|
||||
);
|
||||
assert_eq!(manifest.worker.instruction, "$user/reviewer");
|
||||
assert_eq!(manifest.worker.max_tokens, Some(4096));
|
||||
@@ -337,7 +333,6 @@ permission = "write"
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "missing-scope"
|
||||
pwd = "./"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -348,25 +343,6 @@ model = "claude-sonnet-4-20250514"
|
||||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_missing_pwd() {
|
||||
let toml = r#"
|
||||
[pod]
|
||||
name = "missing-pwd"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_max_turns() {
|
||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! 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.
|
||||
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
||||
//! rule `target` must already be an absolute path — per-layer path
|
||||
//! resolution runs earlier, inside [`crate::PodManifestConfig::resolve_paths`].
|
||||
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
|
||||
//! possible) so access checks are pure path comparisons.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -33,8 +34,8 @@ struct ResolvedRule {
|
||||
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("scope target must be absolute: {}", .0.display())]
|
||||
RelativeTarget(PathBuf),
|
||||
#[error("failed to resolve scope target {}: {source}", .path.display())]
|
||||
ResolveTarget {
|
||||
path: PathBuf,
|
||||
@@ -44,25 +45,26 @@ pub enum ScopeError {
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// Build a [`Scope`] from a declarative [`ScopeConfig`], resolving
|
||||
/// relative `target` paths against `base` (conventionally the Pod's
|
||||
/// absolute pwd).
|
||||
pub fn from_config(config: &ScopeConfig, base: &Path) -> Result<Self, ScopeError> {
|
||||
if !base.is_absolute() {
|
||||
return Err(ScopeError::BaseNotAbsolute(base.to_path_buf()));
|
||||
}
|
||||
/// Build a [`Scope`] from a declarative [`ScopeConfig`].
|
||||
///
|
||||
/// Every `target` in `config` must already be absolute — per-layer
|
||||
/// resolution happens upstream in
|
||||
/// [`crate::PodManifestConfig::resolve_paths`] so that cascade merge
|
||||
/// operates on fully-qualified paths. A lingering relative target
|
||||
/// here signals an upstream bug and is rejected.
|
||||
pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
|
||||
if config.allow.is_empty() {
|
||||
return Err(ScopeError::EmptyAllow);
|
||||
}
|
||||
let allow = config
|
||||
.allow
|
||||
.iter()
|
||||
.map(|r| resolve_rule(r, base))
|
||||
.map(resolve_rule)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let deny = config
|
||||
.deny
|
||||
.iter()
|
||||
.map(|r| resolve_rule(r, base))
|
||||
.map(resolve_rule)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Self { allow, deny })
|
||||
}
|
||||
@@ -221,13 +223,11 @@ impl ResolvedRule {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
|
||||
if !rule.target.is_absolute() {
|
||||
return Err(ScopeError::RelativeTarget(rule.target.clone()));
|
||||
}
|
||||
let target = resolve_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
|
||||
path: rule.target.clone(),
|
||||
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
|
||||
})?;
|
||||
@@ -307,7 +307,7 @@ mod tests {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let f = dir.path().join("a.txt");
|
||||
assert_eq!(scope.permission_at(&f), Some(Permission::Write));
|
||||
}
|
||||
@@ -319,7 +319,7 @@ mod tests {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Read)],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let f = dir.path().join("a.txt");
|
||||
assert_eq!(scope.permission_at(&f), Some(Permission::Read));
|
||||
assert!(scope.is_readable(&f));
|
||||
@@ -335,7 +335,7 @@ mod tests {
|
||||
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 scope = Scope::from_config(&cfg).unwrap();
|
||||
let f = sub.join("a.txt");
|
||||
assert_eq!(scope.permission_at(&f), Some(Permission::Read));
|
||||
// outside the deny, still writable.
|
||||
@@ -354,7 +354,7 @@ mod tests {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||
deny: vec![allow_rule(&secret, Permission::Read)],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
assert_eq!(scope.permission_at(&secret), None);
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ mod tests {
|
||||
],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
assert_eq!(
|
||||
scope.permission_at(&dir.path().join("a.txt")),
|
||||
Some(Permission::Read)
|
||||
@@ -394,22 +394,35 @@ mod tests {
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).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();
|
||||
let err = Scope::from_config(&cfg).unwrap_err();
|
||||
assert!(matches!(err, ScopeError::EmptyAllow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_target_rejected_as_invariant_violation() {
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: PathBuf::from("relative/path"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let err = Scope::from_config(&cfg).unwrap_err();
|
||||
assert!(matches!(err, ScopeError::RelativeTarget(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_attack() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -430,7 +443,7 @@ mod tests {
|
||||
],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let summary = scope.summary();
|
||||
assert!(summary.contains("Readable:"));
|
||||
assert!(summary.contains("Writable:"));
|
||||
@@ -448,7 +461,7 @@ mod tests {
|
||||
allow: vec![allow_rule(dir.path(), Permission::Write)],
|
||||
deny: vec![allow_rule(&secret, Permission::Read)],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let summary = scope.summary();
|
||||
assert!(!summary.contains("secret"));
|
||||
}
|
||||
@@ -473,7 +486,7 @@ mod tests {
|
||||
],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let summary = scope.summary();
|
||||
let docs_canon = docs.canonicalize().unwrap().display().to_string();
|
||||
let dir_canon = dir.path().canonicalize().unwrap().display().to_string();
|
||||
@@ -500,7 +513,7 @@ mod tests {
|
||||
],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let readable: Vec<_> = scope.readable_paths().collect();
|
||||
let writable: Vec<_> = scope.writable_paths().collect();
|
||||
assert_eq!(readable.len(), 2);
|
||||
|
||||
+164
-40
@@ -11,6 +11,18 @@
|
||||
//! 4. **Programmatic overlay** — inline TOML string or typed
|
||||
//! [`PodManifestConfig`] supplied by the caller (CLI flags, GUI,
|
||||
//! spawning Pod, etc.). Highest priority.
|
||||
//!
|
||||
//! Path resolution happens **before** merge. Each layer is resolved
|
||||
//! against its own base directory so that a relative `target = "."`
|
||||
//! in the project manifest means "project directory" regardless of
|
||||
//! how the user or overlay layers lay out their own paths:
|
||||
//!
|
||||
//! - user manifest: base = the directory holding the manifest file
|
||||
//! - project manifest: base = the directory holding the manifest file
|
||||
//! (i.e. `<project>/.insomnia/`)
|
||||
//! - overlay: base = the process's `current_dir()` at the time the
|
||||
//! overlay is installed, since an inline TOML string has no file
|
||||
//! location of its own
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -49,8 +61,15 @@ pub enum FactoryError {
|
||||
/// `with_*` method twice overwrites the previous value for that slot.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PodFactory {
|
||||
user: Option<PodManifestConfig>,
|
||||
project: Option<PodManifestConfig>,
|
||||
/// User layer paired with the directory the manifest lives in
|
||||
/// (base for resolving its relative paths).
|
||||
user: Option<(PodManifestConfig, PathBuf)>,
|
||||
/// Project layer paired with the directory the manifest lives in.
|
||||
project: Option<(PodManifestConfig, PathBuf)>,
|
||||
/// Programmatic overlays are resolved against the process's
|
||||
/// `current_dir()` at the time each call arrives, then merged into
|
||||
/// this slot. Storing a pre-resolved (absolute-paths) config means
|
||||
/// later overlay calls from a different cwd still work correctly.
|
||||
overlay: Option<PodManifestConfig>,
|
||||
/// Directory holding the user prompts library — co-located with
|
||||
/// the user manifest when loaded. `<user_manifest_dir>/prompts/`.
|
||||
@@ -74,8 +93,9 @@ impl PodFactory {
|
||||
pub fn with_user_manifest_auto(mut self) -> Result<Self, FactoryError> {
|
||||
let path = user_manifest_path()?;
|
||||
if path.exists() {
|
||||
self.user = Some(read_config_file(&path)?);
|
||||
self.user_prompts_dir = path.parent().map(|p| p.join("prompts"));
|
||||
let base = manifest_base(&path)?;
|
||||
self.user = Some((read_config_file(&path)?, base.clone()));
|
||||
self.user_prompts_dir = Some(base.join("prompts"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
@@ -84,8 +104,9 @@ impl PodFactory {
|
||||
/// exist; missing files are an error (unlike the `_auto` variant).
|
||||
pub fn with_user_manifest(mut self, path: impl AsRef<Path>) -> Result<Self, FactoryError> {
|
||||
let path = path.as_ref();
|
||||
self.user = Some(read_config_file(path)?);
|
||||
self.user_prompts_dir = path.parent().map(|p| p.join("prompts"));
|
||||
let base = manifest_base(path)?;
|
||||
self.user = Some((read_config_file(path)?, base.clone()));
|
||||
self.user_prompts_dir = Some(base.join("prompts"));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
@@ -98,8 +119,9 @@ impl PodFactory {
|
||||
source,
|
||||
})?;
|
||||
if let Some(path) = find_project_manifest(&cwd) {
|
||||
self.project = Some(read_config_file(&path)?);
|
||||
self.project_prompts_dir = path.parent().map(|p| p.join("prompts"));
|
||||
let base = manifest_base(&path)?;
|
||||
self.project = Some((read_config_file(&path)?, base.clone()));
|
||||
self.project_prompts_dir = Some(base.join("prompts"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
@@ -111,31 +133,28 @@ impl PodFactory {
|
||||
start: impl AsRef<Path>,
|
||||
) -> Result<Self, FactoryError> {
|
||||
if let Some(path) = find_project_manifest(start.as_ref()) {
|
||||
self.project = Some(read_config_file(&path)?);
|
||||
self.project_prompts_dir = path.parent().map(|p| p.join("prompts"));
|
||||
let base = manifest_base(&path)?;
|
||||
self.project = Some((read_config_file(&path)?, base.clone()));
|
||||
self.project_prompts_dir = Some(base.join("prompts"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Install a programmatic overlay parsed from a TOML string. This
|
||||
/// is the highest-priority layer — use it to inject per-spawn
|
||||
/// values like `pod.name` or `pod.pwd` from CLI flags.
|
||||
/// Install a programmatic overlay parsed from a TOML string. Any
|
||||
/// relative paths in the overlay are resolved against the process's
|
||||
/// current working directory at the time of this call — an inline
|
||||
/// TOML string has no file location of its own.
|
||||
pub fn with_overlay_toml(mut self, toml: &str) -> Result<Self, FactoryError> {
|
||||
let config = PodManifestConfig::from_toml(toml).map_err(FactoryError::OverlayParse)?;
|
||||
self.overlay = Some(match self.overlay {
|
||||
Some(existing) => existing.merge(config),
|
||||
None => config,
|
||||
});
|
||||
self.overlay = Some(resolve_and_merge_overlay(self.overlay, config)?);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Install a programmatic overlay from an already-parsed config.
|
||||
pub fn with_overlay_config(mut self, config: PodManifestConfig) -> Self {
|
||||
self.overlay = Some(match self.overlay {
|
||||
Some(existing) => existing.merge(config),
|
||||
None => config,
|
||||
});
|
||||
self
|
||||
/// Behaves like [`Self::with_overlay_toml`] regarding relative paths.
|
||||
pub fn with_overlay_config(mut self, config: PodManifestConfig) -> Result<Self, FactoryError> {
|
||||
self.overlay = Some(resolve_and_merge_overlay(self.overlay, config)?);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Build a [`PromptLoader`] that reflects the user / project
|
||||
@@ -162,6 +181,11 @@ impl PodFactory {
|
||||
/// feeds `{% include "name" %}` references in the Pod's system
|
||||
/// prompt template.
|
||||
///
|
||||
/// Each layer is resolved to absolute paths against its own base
|
||||
/// (see module docs) **before** merge, so scope rules and
|
||||
/// `api_key_file` paths from different layers do not accidentally
|
||||
/// inherit another layer's base.
|
||||
///
|
||||
/// The base layer is [`PodManifestConfig::builtin_defaults`] so
|
||||
/// every per-field default flows through a single source of truth
|
||||
/// (see [`manifest::defaults`]).
|
||||
@@ -169,11 +193,11 @@ impl PodFactory {
|
||||
let loader = self.build_prompt_loader();
|
||||
let merged = PodManifestConfig::builtin_defaults();
|
||||
let merged = match self.user {
|
||||
Some(user) => merged.merge(user),
|
||||
Some((user, base)) => merged.merge(user.resolve_paths(&base)),
|
||||
None => merged,
|
||||
};
|
||||
let merged = match self.project {
|
||||
Some(project) => merged.merge(project),
|
||||
Some((project, base)) => merged.merge(project.resolve_paths(&base)),
|
||||
None => merged,
|
||||
};
|
||||
let merged = match self.overlay {
|
||||
@@ -185,6 +209,43 @@ impl PodFactory {
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_base(path: &Path) -> Result<PathBuf, FactoryError> {
|
||||
let parent = path.parent().ok_or_else(|| FactoryError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"manifest path has no parent directory",
|
||||
),
|
||||
})?;
|
||||
// Absolutise against cwd so later path joins produce absolute
|
||||
// results regardless of whether the caller passed a relative
|
||||
// manifest path.
|
||||
if parent.is_absolute() {
|
||||
Ok(parent.to_path_buf())
|
||||
} else {
|
||||
let cwd = std::env::current_dir().map_err(|source| FactoryError::Io {
|
||||
path: PathBuf::from("."),
|
||||
source,
|
||||
})?;
|
||||
Ok(cwd.join(parent))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_and_merge_overlay(
|
||||
existing: Option<PodManifestConfig>,
|
||||
incoming: PodManifestConfig,
|
||||
) -> Result<PodManifestConfig, FactoryError> {
|
||||
let cwd = std::env::current_dir().map_err(|source| FactoryError::Io {
|
||||
path: PathBuf::from("."),
|
||||
source,
|
||||
})?;
|
||||
let resolved = incoming.resolve_paths(&cwd);
|
||||
Ok(match existing {
|
||||
Some(prev) => prev.merge(resolved),
|
||||
None => resolved,
|
||||
})
|
||||
}
|
||||
|
||||
fn user_manifest_path() -> Result<PathBuf, FactoryError> {
|
||||
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
|
||||
if !dir.is_empty() {
|
||||
@@ -242,7 +303,6 @@ mod tests {
|
||||
r#"
|
||||
[pod]
|
||||
name = "solo"
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -261,7 +321,6 @@ permission = "write"
|
||||
.unwrap();
|
||||
let manifest = manifest.0;
|
||||
assert_eq!(manifest.pod.name, "solo");
|
||||
assert_eq!(manifest.pod.pwd, pwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -293,20 +352,21 @@ permission = "write"
|
||||
pwd = pwd.display()
|
||||
))
|
||||
.unwrap();
|
||||
let overlay_cfg = PodManifestConfig::from_toml(&format!(
|
||||
let overlay_cfg = PodManifestConfig::from_toml(
|
||||
r#"
|
||||
[pod]
|
||||
name = "overlay-name"
|
||||
pwd = "{pwd}"
|
||||
"#,
|
||||
pwd = pwd.display()
|
||||
))
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (manifest, _loader) = PodFactory::new()
|
||||
.with_overlay_config(user_cfg)
|
||||
.unwrap()
|
||||
.with_overlay_config(project_cfg)
|
||||
.unwrap()
|
||||
.with_overlay_config(overlay_cfg)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
@@ -332,7 +392,6 @@ pwd = "{pwd}"
|
||||
r#"
|
||||
[pod]
|
||||
name = "from-user"
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -381,7 +440,6 @@ model = "project-model"
|
||||
r#"
|
||||
[pod]
|
||||
name = "walked-up"
|
||||
pwd = "{root}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -415,7 +473,6 @@ permission = "write"
|
||||
r#"
|
||||
[pod]
|
||||
name = "standalone"
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -441,6 +498,77 @@ permission = "write"
|
||||
assert_eq!(manifest.pod.name, "standalone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manifest_relative_paths_resolve_against_its_directory() {
|
||||
// user manifest at <tmp>/cfg/manifest.toml with a relative
|
||||
// scope target `./workspace` must resolve to <tmp>/cfg/workspace.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let cfg_dir = root.join("cfg");
|
||||
std::fs::create_dir_all(&cfg_dir).unwrap();
|
||||
let workspace = cfg_dir.join("workspace");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
|
||||
let user = cfg_dir.join("manifest.toml");
|
||||
write(
|
||||
&user,
|
||||
r#"
|
||||
[pod]
|
||||
name = "rel-user"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "m"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./workspace"
|
||||
permission = "write"
|
||||
"#,
|
||||
);
|
||||
|
||||
let (manifest, _loader) = PodFactory::new()
|
||||
.with_user_manifest(&user)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
assert_eq!(manifest.scope.allow[0].target, workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_manifest_relative_paths_resolve_against_insomnia_dir() {
|
||||
// per ticket: base is the directory holding the manifest —
|
||||
// `.insomnia/` for a project manifest. `target = "."` inside
|
||||
// a project manifest therefore points at `.insomnia/`, not at
|
||||
// the project root.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let insomnia_dir = root.join(".insomnia");
|
||||
std::fs::create_dir_all(&insomnia_dir).unwrap();
|
||||
let project_manifest = insomnia_dir.join("manifest.toml");
|
||||
write(
|
||||
&project_manifest,
|
||||
r#"
|
||||
[pod]
|
||||
name = "rel-project"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "m"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "."
|
||||
permission = "read"
|
||||
"#,
|
||||
);
|
||||
|
||||
let (manifest, _loader) = PodFactory::new()
|
||||
.with_project_manifest_from(&root)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
assert_eq!(manifest.scope.allow[0].target, insomnia_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_produces_loader_with_workspace_prompts_dir() {
|
||||
use crate::system_prompt::{SystemPromptContext, SystemPromptTemplate};
|
||||
@@ -456,7 +584,6 @@ permission = "write"
|
||||
r#"
|
||||
[pod]
|
||||
name = "factory-pod"
|
||||
pwd = "{root}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
@@ -493,7 +620,7 @@ permission = "write"
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&scope_cfg, &root).unwrap();
|
||||
let scope = Scope::from_config(&scope_cfg).unwrap();
|
||||
let ctx = SystemPromptContext {
|
||||
now: chrono::Utc::now(),
|
||||
cwd: &root,
|
||||
@@ -512,12 +639,9 @@ permission = "write"
|
||||
fn resolve_fails_on_missing_required_field() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let pwd = tmp.path().canonicalize().unwrap();
|
||||
// pwd set but pod.name missing
|
||||
// pod.name missing — resolver must reject.
|
||||
let overlay = format!(
|
||||
r#"
|
||||
[pod]
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "m"
|
||||
|
||||
+1
-27
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use pod::{Pod, PodController, PodFactory, PodManifestConfig, PodMetaConfig};
|
||||
use pod::{Pod, PodController, PodFactory};
|
||||
use session_store::FsStore;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -27,11 +27,6 @@ struct Cli {
|
||||
#[arg(long, value_name = "TOML")]
|
||||
overlay: Option<String>,
|
||||
|
||||
/// Shorthand that injects `pod.pwd = <path>` into the overlay
|
||||
/// layer. `--pwd .` uses the current working directory.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pwd: Option<PathBuf>,
|
||||
|
||||
/// Directory for session persistence. Defaults to
|
||||
/// `~/.insomnia/sessions/`.
|
||||
#[arg(short, long)]
|
||||
@@ -68,20 +63,6 @@ fn default_runtime_dir() -> Result<PathBuf, std::io::Error> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a programmatic overlay [`PodManifestConfig`] that carries
|
||||
/// `pod.pwd` derived from the `--pwd` shorthand. Relative CLI paths
|
||||
/// are canonicalized here so the cascade always sees an absolute path.
|
||||
fn pwd_overlay(pwd: &PathBuf) -> PodManifestConfig {
|
||||
let absolute = std::fs::canonicalize(pwd).unwrap_or_else(|_| pwd.clone());
|
||||
PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
pwd: Some(absolute),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_factory(cli: &Cli) -> Result<PodFactory, String> {
|
||||
let mut factory = PodFactory::new();
|
||||
|
||||
@@ -103,13 +84,6 @@ async fn build_factory(cli: &Cli) -> Result<PodFactory, String> {
|
||||
.map_err(|e| format!("failed to auto-load project manifest: {e}"))?,
|
||||
};
|
||||
|
||||
// `--pwd` goes in as a typed config so path strings never have to
|
||||
// pass through TOML escaping. `--overlay` keeps its inline-TOML
|
||||
// interface (that is its entire reason for existing). Both feed
|
||||
// the same overlay slot and merge in call order.
|
||||
if let Some(pwd) = cli.pwd.as_ref() {
|
||||
factory = factory.with_overlay_config(pwd_overlay(pwd));
|
||||
}
|
||||
if let Some(overlay) = cli.overlay.as_deref() {
|
||||
factory = factory
|
||||
.with_overlay_toml(overlay)
|
||||
|
||||
+21
-22
@@ -874,11 +874,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
/// Create a Pod entirely from a validated manifest.
|
||||
///
|
||||
/// `manifest.pod.pwd` must already be an absolute path (the cascade
|
||||
/// layer — `PodManifestConfig` → `PodManifest` — is the sole place
|
||||
/// where path normalisation happens). The Pod builds its [`Scope`]
|
||||
/// from `manifest.scope`, canonicalizes the pwd, and validates that
|
||||
/// the resolved pwd is readable under that scope.
|
||||
/// The Pod's working directory is captured once here from the
|
||||
/// process's `std::env::current_dir()` — callers that want a
|
||||
/// different cwd must `cd` before constructing the Pod (e.g. the
|
||||
/// `SpawnPod` tool sets `Command::current_dir` on the child). The
|
||||
/// captured pwd is canonicalised and validated against
|
||||
/// `manifest.scope`.
|
||||
///
|
||||
/// `loader` is installed into the system-prompt template
|
||||
/// environment so that `{% include "name" %}` /
|
||||
@@ -889,8 +890,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
) -> Result<Self, PodError> {
|
||||
let pwd = resolve_pwd(&manifest.pod.pwd)?;
|
||||
let scope = Scope::from_config(&manifest.scope, &pwd).map_err(PodError::Scope)?;
|
||||
let pwd = current_pwd()?;
|
||||
let scope = Scope::from_config(&manifest.scope).map_err(PodError::Scope)?;
|
||||
if !scope.is_readable(&pwd) {
|
||||
return Err(PodError::PwdOutsideScope { pwd });
|
||||
}
|
||||
@@ -967,8 +968,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
loader: PromptLoader,
|
||||
callback_socket: PathBuf,
|
||||
) -> Result<Self, PodError> {
|
||||
let pwd = resolve_pwd(&manifest.pod.pwd)?;
|
||||
let scope = Scope::from_config(&manifest.scope, &pwd).map_err(PodError::Scope)?;
|
||||
let pwd = current_pwd()?;
|
||||
let scope = Scope::from_config(&manifest.scope).map_err(PodError::Scope)?;
|
||||
if !scope.is_readable(&pwd) {
|
||||
return Err(PodError::PwdOutsideScope { pwd });
|
||||
}
|
||||
@@ -1127,9 +1128,6 @@ pub enum PodError {
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("pwd must be absolute: {}", .0.display())]
|
||||
PwdNotAbsolute(PathBuf),
|
||||
|
||||
#[error("failed to parse manifest TOML: {0}")]
|
||||
ManifestParse(#[source] toml::de::Error),
|
||||
|
||||
@@ -1158,16 +1156,17 @@ pub enum PodError {
|
||||
ScopeLock(#[from] ScopeLockError),
|
||||
}
|
||||
|
||||
/// Canonicalize an absolute pwd (resolves symlinks and any `.`/`..`
|
||||
/// components). Relative inputs are rejected — the cascade layer is
|
||||
/// the sole source of path normalisation and must hand off an absolute
|
||||
/// path.
|
||||
fn resolve_pwd(pwd: &Path) -> Result<PathBuf, PodError> {
|
||||
if !pwd.is_absolute() {
|
||||
return Err(PodError::PwdNotAbsolute(pwd.to_path_buf()));
|
||||
}
|
||||
pwd.canonicalize().map_err(|source| PodError::InvalidPwd {
|
||||
pwd: pwd.to_path_buf(),
|
||||
/// Snapshot the process's current working directory as the Pod's pwd,
|
||||
/// canonicalising symlinks and any `.`/`..` components. The Pod keeps
|
||||
/// this value for its lifetime; changes to the process-wide cwd after
|
||||
/// construction do not affect scope checks or the system prompt.
|
||||
fn current_pwd() -> Result<PathBuf, PodError> {
|
||||
let cwd = std::env::current_dir().map_err(|source| PodError::InvalidPwd {
|
||||
pwd: PathBuf::from("."),
|
||||
source,
|
||||
})?;
|
||||
cwd.canonicalize().map_err(|source| PodError::InvalidPwd {
|
||||
pwd: cwd,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
+14
-12
@@ -174,16 +174,15 @@ impl Tool for SpawnPodTool {
|
||||
// it back — even if later steps (Method::Run delivery, record
|
||||
// write) fail, the child is running and will release its own
|
||||
// entry on exit.
|
||||
let overlay_toml =
|
||||
match build_overlay_toml(&input.name, &self.spawner_pwd, &instruction, &scope_allow) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
self.release_reservation(&lock_path, &input.name);
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"overlay serialisation: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let overlay_toml = match build_overlay_toml(&input.name, &instruction, &scope_allow) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
self.release_reservation(&lock_path, &input.name);
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"overlay serialisation: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let start_outcome = self.exec_child(&overlay_toml, &predicted_socket).await;
|
||||
if let Err(e) = start_outcome {
|
||||
@@ -231,6 +230,7 @@ impl SpawnPodTool {
|
||||
.arg(&self.callback_socket)
|
||||
.arg("--overlay")
|
||||
.arg(overlay_toml)
|
||||
.current_dir(&self.spawner_pwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
@@ -281,16 +281,18 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
|
||||
/// Serialise the overlay TOML that gets handed to the child `pod`
|
||||
/// binary via `--overlay`. `PodManifestConfig`'s `Serialize` impl is
|
||||
/// the single source of truth for the on-disk manifest format.
|
||||
///
|
||||
/// The child's working directory is set separately via
|
||||
/// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is
|
||||
/// not part of the manifest.
|
||||
fn build_overlay_toml(
|
||||
name: &str,
|
||||
pwd: &Path,
|
||||
instruction: &str,
|
||||
scope_allow: &[ScopeRule],
|
||||
) -> Result<String, toml::ser::Error> {
|
||||
let overlay = PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some(name.to_string()),
|
||||
pwd: Some(pwd.to_path_buf()),
|
||||
},
|
||||
worker: WorkerManifestConfig {
|
||||
instruction: Some(instruction.to_string()),
|
||||
|
||||
@@ -229,7 +229,7 @@ mod tests {
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
Scope::from_config(&cfg, dir).unwrap()
|
||||
Scope::from_config(&cfg).unwrap()
|
||||
}
|
||||
|
||||
fn ctx<'a>(
|
||||
|
||||
@@ -267,7 +267,7 @@ mod tests {
|
||||
recursive: true,
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
|
||||
let def = glob_tool(fs);
|
||||
|
||||
@@ -513,7 +513,7 @@ mod tests {
|
||||
recursive: true,
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let scoped = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
|
||||
let def = grep_tool(scoped);
|
||||
|
||||
@@ -266,7 +266,7 @@ mod tests {
|
||||
recursive: true,
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let scoped = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let err = scoped.write(&sub.join("locked.txt"), b"x").unwrap_err();
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user