From ad4d69c0cadd788a4c470ee111720c1345eb48bc Mon Sep 17 00:00:00 2001 From: Lauritz-Timm Date: Sun, 9 Aug 2026 20:25:12 +0200 Subject: [PATCH 1/3] feat(provider): add lossless configuration primitives --- Cargo.lock | 3 +- Cargo.toml | 3 +- crates/icm-cli/Cargo.toml | 1 + crates/icm-cli/src/main.rs | 2 + crates/icm-cli/src/provider_document.rs | 1409 +++++++++++++++++++++++ crates/icm-cli/src/provider_journal.rs | 1157 +++++++++++++++++++ 6 files changed, 2573 insertions(+), 2 deletions(-) create mode 100644 crates/icm-cli/src/provider_document.rs create mode 100644 crates/icm-cli/src/provider_journal.rs diff --git a/Cargo.lock b/Cargo.lock index 4a0037bf..d267cb5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1460,7 +1460,7 @@ dependencies = [ [[package]] name = "icm-cli" -version = "0.10.54" +version = "0.10.61" dependencies = [ "anyhow", "axum", @@ -1487,6 +1487,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "toml_edit", "tower-http", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 58876618..b4cc3405 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,8 +29,9 @@ fastembed = { version = "4", default-features = false } # Serialization serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } -serde_json_lenient = { version = "0.2", features = ["preserve_order"] } +serde_json_lenient = { version = "0.2", features = ["preserve_order", "raw_value"] } toml = "0.8" +toml_edit = "0.22" # Error handling thiserror = "2" diff --git a/crates/icm-cli/Cargo.toml b/crates/icm-cli/Cargo.toml index a3b6f99f..4a5c2ae8 100644 --- a/crates/icm-cli/Cargo.toml +++ b/crates/icm-cli/Cargo.toml @@ -67,6 +67,7 @@ directories = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } # Optional, only enabled by the `vendored-openssl` feature (see diff --git a/crates/icm-cli/src/main.rs b/crates/icm-cli/src/main.rs index 53ba7ad4..d11adcb3 100644 --- a/crates/icm-cli/src/main.rs +++ b/crates/icm-cli/src/main.rs @@ -24,6 +24,8 @@ mod learn_tests; // time; the static build links onnxruntime in. #[cfg(feature = "embeddings-dynamic")] mod ort_runtime; +mod provider_document; +mod provider_journal; mod recall_format; mod summarizer; #[cfg(feature = "tui")] diff --git a/crates/icm-cli/src/provider_document.rs b/crates/icm-cli/src/provider_document.rs new file mode 100644 index 00000000..a93ef794 --- /dev/null +++ b/crates/icm-cli/src/provider_document.rs @@ -0,0 +1,1409 @@ +//! Lossless, fail-closed edits for provider-owned JSON, JSONC, and TOML. +//! +//! User documents are never serialized back from a value tree. We parse to +//! validate and locate a semantic parent, then splice only text authored by +//! ICM. The exact splice is the inverse recorded by the provider journal. + +use std::borrow::Cow; +use std::collections::HashSet; +use std::fmt; +use std::ops::Range; + +use anyhow::{bail, Context, Result}; +use serde::de::{Deserialize, Deserializer, MapAccess, Visitor}; +use serde::{Deserialize as DeriveDeserialize, Serialize}; +use serde_json::Value as JsonValue; +use serde_json_lenient::value::RawValue; +use toml_edit::{ImDocument, Item, Key, Table}; + +const UTF8_BOM: &[u8] = b"\xef\xbb\xbf"; +const MAX_JSON_DEPTH: usize = 128; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, DeriveDeserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SourceFormat { + Json, + Jsonc, + Toml, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, DeriveDeserialize)] +#[serde(transparent)] +pub(crate) struct SourcePath(pub(crate) Vec); + +impl SourcePath { + pub(crate) fn root() -> Self { + Self::default() + } + + pub(crate) fn new(segments: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self(segments.into_iter().map(Into::into).collect()) + } + + fn child(&self, segment: impl Into) -> Self { + let mut path = self.0.clone(); + path.push(segment.into()); + Self(path) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Insert { + JsonMember { + parent: SourcePath, + key: String, + value: JsonValue, + }, + JsonArrayElement { + array: SourcePath, + value: JsonValue, + }, + TomlTable { + path: SourcePath, + values: Vec<(String, toml::Value)>, + }, + TomlScalar { + table: SourcePath, + key: String, + value: toml::Value, + }, +} + +/// The direct semantic node an authored splice created. Removal must prove this +/// node still exists at this exact location before touching source bytes. +#[derive(Clone, Debug, PartialEq, Serialize, DeriveDeserialize)] +#[serde( + tag = "kind", + rename_all = "kebab-case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub(crate) enum OwnedTarget { + JsonRootObject, + JsonMember { + parent: SourcePath, + key: String, + value: JsonValue, + }, + JsonArrayElement { + array: SourcePath, + value: JsonValue, + }, + TomlTable { + path: SourcePath, + values: Vec<(String, toml::Value)>, + }, + TomlScalar { + table: SourcePath, + key: String, + value: toml::Value, + }, +} + +/// An offset-independent inverse. `inserted` is exactly the UTF-8 fragment +/// authored by ICM, including any separator that ICM introduced. +#[derive(Clone, Debug, PartialEq, Serialize, DeriveDeserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct OwnedSplice { + pub(crate) target: OwnedTarget, + pub(crate) inserted: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct AppliedInsert { + pub(crate) bytes: Vec, + pub(crate) owned: Option, + pub(crate) created: Vec, +} + +impl AppliedInsert { + pub(crate) fn changed(&self) -> bool { + self.owned.is_some() || !self.created.is_empty() + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ParsedSource { + Json(JsonValue), + Toml(toml::Value), +} + +/// Parse a provider source without normalizing it. JSON duplicate rejection is +/// scoped to provider-owned roots so unrelated legacy content remains usable. +pub(crate) fn validate_and_parse( + format: SourceFormat, + bytes: &[u8], + relevant_roots: &[&str], +) -> Result { + let (_, body) = source_text(bytes)?; + match format { + SourceFormat::Json | SourceFormat::Jsonc => { + let normalized = normalized_json(body, format)?; + if normalized.trim().is_empty() { + return Ok(ParsedSource::Json(JsonValue::Object(Default::default()))); + } + + let root = parse_raw::<&RawValue>(&normalized, SourceFormat::Json) + .context("invalid provider JSON")?; + let object = parse_raw_object(root, SourceFormat::Json) + .context("provider JSON root must be an object")?; + reject_relevant_duplicates(&object, relevant_roots, SourceFormat::Json)?; + + let value: JsonValue = + parse_raw(&normalized, SourceFormat::Json).context("invalid provider JSON")?; + if !value.is_object() { + bail!("provider JSON root must be an object"); + } + Ok(ParsedSource::Json(value)) + } + SourceFormat::Toml => { + if body.trim().is_empty() { + return Ok(ParsedSource::Toml(toml::Value::Table(Default::default()))); + } + ImDocument::parse(body).context("invalid provider TOML")?; + let value = toml::from_str(body).context("invalid provider TOML")?; + Ok(ParsedSource::Toml(value)) + } + } +} + +pub(crate) fn insert( + bytes: &[u8], + format: SourceFormat, + relevant_roots: &[&str], + operation: &Insert, +) -> Result { + validate_and_parse(format, bytes, relevant_roots)?; + match operation { + Insert::JsonMember { parent, key, value } => { + ensure_json_format(format)?; + let (mut next, created) = + ensure_json_container(bytes, format, relevant_roots, parent, JsonKind::Object)?; + let owned = append_json_member(&mut next, format, relevant_roots, parent, key, value)?; + Ok(AppliedInsert { + bytes: next, + owned, + created, + }) + } + Insert::JsonArrayElement { array, value } => { + ensure_json_format(format)?; + let (mut next, created) = + ensure_json_container(bytes, format, relevant_roots, array, JsonKind::Array)?; + let owned = append_json_array_element(&mut next, format, relevant_roots, array, value)?; + Ok(AppliedInsert { + bytes: next, + owned, + created, + }) + } + Insert::TomlTable { path, values } => { + ensure_toml_format(format)?; + insert_toml_table(bytes, relevant_roots, path, values) + } + Insert::TomlScalar { table, key, value } => { + ensure_toml_format(format)?; + insert_toml_scalar(bytes, relevant_roots, table, key, value) + } + } +} + +/// Remove one exact owned fragment from its direct semantic target. Missing or +/// changed targets fail rather than falling back to a byte match elsewhere. +pub(crate) fn remove_owned( + bytes: &[u8], + format: SourceFormat, + relevant_roots: &[&str], + inverse: &OwnedSplice, +) -> Result> { + try_remove_owned(bytes, format, relevant_roots, inverse)?.with_context(|| { + format!( + "owned provider fragment is absent or changed at {}", + display_target(&inverse.target) + ) + }) +} + +/// Best-effort exact removal for owned ancestor containers. A valid document +/// whose direct target is absent or changed returns `None`; malformed or +/// ambiguous source remains an error. +pub(crate) fn try_remove_owned( + bytes: &[u8], + format: SourceFormat, + relevant_roots: &[&str], + inverse: &OwnedSplice, +) -> Result>> { + if inverse.inserted.is_empty() { + bail!("refusing an empty provider inverse"); + } + validate_and_parse(format, bytes, relevant_roots)?; + let (bom_len, body) = source_text(bytes)?; + let Some((scope, direct)) = locate_owned_target(body, format, &inverse.target)? else { + return Ok(None); + }; + let Some(relative) = exact_matches(&body[scope.clone()], &inverse.inserted) + .into_iter() + .find(|offset| { + let candidate = (scope.start + offset)..(scope.start + offset + inverse.inserted.len()); + candidate.start <= direct.start && candidate.end >= direct.end + }) + else { + return Ok(None); + }; + + let start = bom_len + scope.start + relative; + let end = start + inverse.inserted.len(); + let mut next = bytes.to_vec(); + next.drain(start..end); + if validate_and_parse(format, &next, relevant_roots).is_ok() { + return Ok(Some(next)); + } + + // A disjoint edit can append a sibling after an item that was originally + // the sole child, making that sibling's comma adjacent to our old splice. + // Remove only that structural comma; never remove another value. + if matches!(format, SourceFormat::Json | SourceFormat::Jsonc) { + let comma_at = start; + if next.get(comma_at) == Some(&b',') { + let mut repaired = next; + repaired.remove(comma_at); + if validate_and_parse(format, &repaired, relevant_roots).is_ok() { + return Ok(Some(repaired)); + } + } + } + Ok(None) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum JsonKind { + Object, + Array, +} + +fn locate_owned_target( + body: &str, + format: SourceFormat, + target: &OwnedTarget, +) -> Result, Range)>> { + match target { + OwnedTarget::JsonRootObject => { + ensure_json_format(format)?; + let normalized = normalized_json(body, format)?; + let root = parse_raw::<&RawValue>(&normalized, SourceFormat::Json)?; + let value: JsonValue = parse_raw(root.get(), SourceFormat::Json)?; + if value.as_object().is_none_or(|object| !object.is_empty()) { + return Ok(None); + } + Ok(Some((0..body.len(), raw_range(&normalized, root)?))) + } + OwnedTarget::JsonMember { parent, key, value } => { + ensure_json_format(format)?; + let normalized = normalized_json(body, format)?; + let Some(located) = locate_json_optional(&normalized, SourceFormat::Json, parent)? + else { + return Ok(None); + }; + if first_json_token(located.raw.get(), SourceFormat::Json)? != Some(b'{') { + return Ok(None); + } + let object = parse_raw_object(located.raw, SourceFormat::Json)?; + let matches: Vec<_> = object.0.iter().filter(|(name, _)| name == key).collect(); + let [(_, raw)] = matches.as_slice() else { + if matches.len() > 1 { + bail!("duplicate provider JSON key {key:?}"); + } + return Ok(None); + }; + let current: JsonValue = parse_raw(raw.get(), SourceFormat::Json)?; + if current != *value { + return Ok(None); + } + Ok(Some((located.range, raw_range(&normalized, raw)?))) + } + OwnedTarget::JsonArrayElement { array, value } => { + ensure_json_format(format)?; + let normalized = normalized_json(body, format)?; + let Some(located) = locate_json_optional(&normalized, SourceFormat::Json, array)? + else { + return Ok(None); + }; + if first_json_token(located.raw.get(), SourceFormat::Json)? != Some(b'[') { + return Ok(None); + } + let mut matches = parse_raw_array(located.raw, SourceFormat::Json)? + .into_iter() + .filter_map(|raw| { + parse_raw::(raw.get(), SourceFormat::Json) + .map(|current| (current == *value).then_some(raw)) + .transpose() + }) + .collect::>>()?; + let [raw] = matches.as_mut_slice() else { + if matches.len() > 1 { + bail!("provider JSON array contains multiple matching owned values"); + } + return Ok(None); + }; + Ok(Some((located.range, raw_range(&normalized, raw)?))) + } + OwnedTarget::TomlTable { path, values } => { + ensure_toml_format(format)?; + reject_duplicate_names(values.iter().map(|(key, _)| key.as_str()))?; + if path.0.is_empty() { + bail!("an owned TOML table needs a non-root path"); + } + let document = ImDocument::parse(body).context("invalid provider TOML")?; + let semantic: toml::Value = toml::from_str(body).context("invalid provider TOML")?; + let Some(actual) = toml_value_at(&semantic, &path.0) else { + return Ok(None); + }; + let expected = toml::Value::Table(values.iter().cloned().collect()); + if actual != &expected { + return Ok(None); + } + let Some(table) = toml_table_at_optional(&document, &path.0) else { + return Ok(None); + }; + let Some(direct) = table.span() else { + return Ok(None); + }; + Ok(Some((0..body.len(), direct))) + } + OwnedTarget::TomlScalar { table, key, value } => { + ensure_toml_format(format)?; + if table.0.is_empty() { + bail!("an owned TOML scalar needs a non-root table"); + } + let document = ImDocument::parse(body).context("invalid provider TOML")?; + let semantic: toml::Value = toml::from_str(body).context("invalid provider TOML")?; + let Some(actual) = toml_value_at(&semantic, &table.0) + .and_then(toml::Value::as_table) + .and_then(|values| values.get(key)) + else { + return Ok(None); + }; + if actual != value { + return Ok(None); + } + let Some(source_table) = toml_table_at_optional(&document, &table.0) else { + return Ok(None); + }; + let Some(direct) = source_table.get(key).and_then(Item::span) else { + return Ok(None); + }; + let Some(scope) = source_table + .span() + .map(|span| span.start..after_physical_line(body, span.end)) + else { + return Ok(None); + }; + Ok(Some((scope, direct))) + } + } +} + +struct RawObject<'a>(Vec<(String, &'a RawValue)>); + +impl<'de> Deserialize<'de> for RawObject<'de> { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + struct RawObjectVisitor; + + impl<'de> Visitor<'de> for RawObjectVisitor { + type Value = RawObject<'de>; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON object") + } + + fn visit_map(self, mut map: A) -> std::result::Result + where + A: MapAccess<'de>, + { + let mut entries = Vec::new(); + while let Some(key) = map.next_key::()? { + let value = map.next_value::<&'de RawValue>()?; + entries.push((key, value)); + } + Ok(RawObject(entries)) + } + } + + deserializer.deserialize_map(RawObjectVisitor) + } +} + +fn parse_raw<'a, T>(source: &'a str, format: SourceFormat) -> Result +where + T: Deserialize<'a>, +{ + let mut parser = serde_json_lenient::Deserializer::from_str(source); + if format == SourceFormat::Json { + parser.set_allow_comments(false); + parser.set_ignore_trailing_commas(false); + } + let value = T::deserialize(&mut parser)?; + parser.end()?; + Ok(value) +} + +fn parse_raw_object<'a>(raw: &'a RawValue, format: SourceFormat) -> Result> { + parse_raw(raw.get(), format) +} + +fn parse_raw_array(raw: &RawValue, format: SourceFormat) -> Result> { + parse_raw(raw.get(), format) +} + +fn reject_relevant_duplicates( + root: &RawObject<'_>, + relevant_roots: &[&str], + format: SourceFormat, +) -> Result<()> { + if relevant_roots.is_empty() { + return reject_object_duplicates(root, format, 0); + } + + for relevant in relevant_roots { + let matches: Vec<_> = root.0.iter().filter(|(key, _)| key == relevant).collect(); + if matches.len() > 1 { + bail!("duplicate provider JSON key {relevant:?}"); + } + if let Some((_, value)) = matches.first() { + reject_value_duplicates(value, format, 1)?; + } + } + Ok(()) +} + +fn reject_object_duplicates( + object: &RawObject<'_>, + format: SourceFormat, + depth: usize, +) -> Result<()> { + if depth > MAX_JSON_DEPTH { + bail!("provider JSON exceeds the supported nesting depth"); + } + let mut keys = HashSet::new(); + for (key, value) in &object.0 { + if !keys.insert(key) { + bail!("duplicate provider JSON key {key:?}"); + } + reject_value_duplicates(value, format, depth + 1)?; + } + Ok(()) +} + +fn reject_value_duplicates(raw: &RawValue, format: SourceFormat, depth: usize) -> Result<()> { + if depth > MAX_JSON_DEPTH { + bail!("provider JSON exceeds the supported nesting depth"); + } + match first_json_token(raw.get(), format)? { + Some(b'{') => reject_object_duplicates(&parse_raw_object(raw, format)?, format, depth), + Some(b'[') => { + for value in parse_raw_array(raw, format)? { + reject_value_duplicates(value, format, depth + 1)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn ensure_json_container( + bytes: &[u8], + format: SourceFormat, + relevant_roots: &[&str], + path: &SourcePath, + target_kind: JsonKind, +) -> Result<(Vec, Vec)> { + let mut next = bytes.to_vec(); + let mut created = Vec::new(); + let (_, body) = source_text(&next)?; + if json_is_empty(body, format)? { + let prefix = if body.is_empty() || ends_with_line_break(body) { + "" + } else { + newline_for(body) + }; + let inserted = format!("{prefix}{{}}"); + append_source(&mut next, &inserted); + created.push(OwnedSplice { + target: OwnedTarget::JsonRootObject, + inserted, + }); + validate_and_parse(format, &next, relevant_roots)?; + } + + if path.0.is_empty() { + if target_kind != JsonKind::Object { + bail!("provider JSON root arrays are not supported"); + } + return Ok((next, created)); + } + + let mut parent = SourcePath::root(); + for (index, segment) in path.0.iter().enumerate() { + let kind = if index + 1 == path.0.len() { + target_kind + } else { + JsonKind::Object + }; + let (_, current_body) = source_text(&next)?; + let normalized = normalized_json(current_body, format)?; + let current = locate_json(&normalized, SourceFormat::Json, &parent)?; + ensure_raw_kind(current.raw, SourceFormat::Json, JsonKind::Object, &parent)?; + let object = parse_raw_object(current.raw, SourceFormat::Json)?; + let matches: Vec<_> = object.0.iter().filter(|(key, _)| key == segment).collect(); + if matches.len() > 1 { + bail!("duplicate provider JSON key {segment:?}"); + } + if let Some((_, value)) = matches.first() { + ensure_raw_kind(value, SourceFormat::Json, kind, &parent.child(segment))?; + } else { + let value = match kind { + JsonKind::Object => JsonValue::Object(Default::default()), + JsonKind::Array => JsonValue::Array(Vec::new()), + }; + let owned = + append_json_member(&mut next, format, relevant_roots, &parent, segment, &value)? + .expect("missing member must be inserted"); + created.push(owned); + } + parent = parent.child(segment); + } + Ok((next, created)) +} + +fn append_json_member( + bytes: &mut Vec, + format: SourceFormat, + relevant_roots: &[&str], + parent: &SourcePath, + key: &str, + value: &JsonValue, +) -> Result> { + let (bom_len, body) = source_text(bytes)?; + let normalized = normalized_json(body, format)?; + let located = locate_json(&normalized, SourceFormat::Json, parent)?; + ensure_raw_kind(located.raw, SourceFormat::Json, JsonKind::Object, parent)?; + let object = parse_raw_object(located.raw, SourceFormat::Json)?; + let matches: Vec<_> = object.0.iter().filter(|(name, _)| name == key).collect(); + if matches.len() > 1 { + bail!("duplicate provider JSON key {key:?}"); + } + if let Some((_, raw)) = matches.first() { + let current: JsonValue = parse_raw(raw.get(), SourceFormat::Json)?; + if current == *value { + return Ok(None); + } + bail!("provider JSON key {key:?} already has a different value"); + } + + let encoded_key = serde_json::to_string(key)?; + let encoded_value = serde_json::to_string(value)?; + let encoded = format!("{encoded_key}:{encoded_value}"); + let (relative, inserted) = json_append_fragment( + body, + &normalized, + located.range.clone(), + object.0.iter().map(|(_, value)| *value).collect(), + &encoded, + format, + )?; + splice_text(bytes, bom_len + relative, &inserted); + validate_and_parse(format, bytes, relevant_roots)?; + Ok(Some(OwnedSplice { + target: OwnedTarget::JsonMember { + parent: parent.clone(), + key: key.to_owned(), + value: value.clone(), + }, + inserted, + })) +} + +fn append_json_array_element( + bytes: &mut Vec, + format: SourceFormat, + relevant_roots: &[&str], + array: &SourcePath, + value: &JsonValue, +) -> Result> { + let (bom_len, body) = source_text(bytes)?; + let normalized = normalized_json(body, format)?; + let located = locate_json(&normalized, SourceFormat::Json, array)?; + ensure_raw_kind(located.raw, SourceFormat::Json, JsonKind::Array, array)?; + let values = parse_raw_array(located.raw, SourceFormat::Json)?; + let mut equal = 0; + for raw in &values { + let current: JsonValue = parse_raw(raw.get(), SourceFormat::Json)?; + equal += usize::from(current == *value); + } + match equal { + 0 => {} + 1 => return Ok(None), + count => bail!("provider JSON array contains {count} matching values"), + } + + let encoded = serde_json::to_string(value)?; + let (relative, inserted) = json_append_fragment( + body, + &normalized, + located.range.clone(), + values, + &encoded, + format, + )?; + splice_text(bytes, bom_len + relative, &inserted); + validate_and_parse(format, bytes, relevant_roots)?; + Ok(Some(OwnedSplice { + target: OwnedTarget::JsonArrayElement { + array: array.clone(), + value: value.clone(), + }, + inserted, + })) +} + +fn json_append_fragment( + body: &str, + parsed_body: &str, + container: Range, + values: Vec<&RawValue>, + encoded: &str, + format: SourceFormat, +) -> Result<(usize, String)> { + let close = container + .end + .checked_sub(1) + .context("empty JSON container")?; + if values.is_empty() { + return Ok((close, encoded.to_owned())); + } + + let last = raw_range(parsed_body, values[values.len() - 1])?; + let suffix = &body[last.end..close]; + let after_trivia = skip_json_trivia(suffix, 0, format)?; + let trailing_comma = suffix.as_bytes().get(after_trivia) == Some(&b','); + let style = json_separator_style(body, &container, last.start); + if trailing_comma { + Ok((last.end + after_trivia + 1, format!("{style}{encoded},"))) + } else { + Ok((last.end, format!(",{style}{encoded}"))) + } +} + +struct LocatedRaw<'a> { + raw: &'a RawValue, + range: Range, +} + +fn locate_json<'a>( + body: &'a str, + format: SourceFormat, + path: &SourcePath, +) -> Result> { + let mut raw = parse_raw::<&RawValue>(body, format)?; + for segment in &path.0 { + let object = parse_raw_object(raw, format) + .with_context(|| format!("provider JSON {} is not an object", display_path(path)))?; + let matches: Vec<_> = object.0.iter().filter(|(key, _)| key == segment).collect(); + match matches.as_slice() { + [(_, value)] => raw = value, + [] => bail!("provider JSON path {} is missing", display_path(path)), + _ => bail!("duplicate provider JSON key {segment:?}"), + } + } + Ok(LocatedRaw { + range: raw_range(body, raw)?, + raw, + }) +} + +fn locate_json_optional<'a>( + body: &'a str, + format: SourceFormat, + path: &SourcePath, +) -> Result>> { + let mut raw = parse_raw::<&RawValue>(body, format)?; + for segment in &path.0 { + if first_json_token(raw.get(), format)? != Some(b'{') { + return Ok(None); + } + let object = parse_raw_object(raw, format)?; + let matches: Vec<_> = object.0.iter().filter(|(key, _)| key == segment).collect(); + match matches.as_slice() { + [(_, value)] => raw = value, + [] => return Ok(None), + _ => bail!("duplicate provider JSON key {segment:?}"), + } + } + Ok(Some(LocatedRaw { + range: raw_range(body, raw)?, + raw, + })) +} + +fn ensure_raw_kind( + raw: &RawValue, + format: SourceFormat, + expected: JsonKind, + path: &SourcePath, +) -> Result<()> { + let actual = match first_json_token(raw.get(), format)? { + Some(b'{') => JsonKind::Object, + Some(b'[') => JsonKind::Array, + _ => bail!("provider JSON {} is not a container", display_path(path)), + }; + if actual != expected { + bail!( + "provider JSON {} is a {:?}, expected {:?}", + display_path(path), + actual, + expected + ); + } + Ok(()) +} + +fn raw_range(body: &str, raw: &RawValue) -> Result> { + let base = body.as_ptr() as usize; + let start = (raw.get().as_ptr() as usize) + .checked_sub(base) + .context("JSON value is outside its source document")?; + let end = start + raw.get().len(); + if end > body.len() { + bail!("JSON value is outside its source document"); + } + Ok(start..end) +} + +fn json_separator_style(body: &str, container: &Range, last_start: usize) -> String { + if !body[container.clone()].contains('\n') { + return " ".to_owned(); + } + let line_start = body[..last_start].rfind('\n').map_or(0, |index| index + 1); + let indent: String = body[line_start..last_start] + .chars() + .take_while(|character| matches!(character, ' ' | '\t' | '\r')) + .filter(|character| *character != '\r') + .collect(); + format!("{}{}", newline_for(&body[container.clone()]), indent) +} + +fn first_json_token(source: &str, format: SourceFormat) -> Result> { + let index = skip_json_trivia(source, 0, format)?; + Ok(source.as_bytes().get(index).copied()) +} + +fn json_is_empty(source: &str, format: SourceFormat) -> Result { + Ok(normalized_json(source, format)?.trim().is_empty()) +} + +/// Build a parser-only JSON view with exactly the same byte offsets as the +/// JSONC source. Only comments and commas immediately before `]` or `}` are +/// blanked; strings and line endings are untouched. +fn normalized_json(source: &str, format: SourceFormat) -> Result> { + if format == SourceFormat::Json { + return Ok(Cow::Borrowed(source)); + } + + let original = source.as_bytes(); + let mut normalized = original.to_vec(); + let mut index = 0; + let mut in_string = false; + while index < original.len() { + if in_string { + match original[index] { + b'\\' => index = (index + 2).min(original.len()), + b'"' => { + in_string = false; + index += 1; + } + _ => index += 1, + } + continue; + } + + match (original[index], original.get(index + 1)) { + (b'"', _) => { + in_string = true; + index += 1; + } + (b'/', Some(b'/')) => { + normalized[index] = b' '; + normalized[index + 1] = b' '; + index += 2; + while index < original.len() && !matches!(original[index], b'\r' | b'\n') { + normalized[index] = b' '; + index += 1; + } + } + (b'/', Some(b'*')) => { + normalized[index] = b' '; + normalized[index + 1] = b' '; + index += 2; + let mut closed = false; + while index < original.len() { + if original[index] == b'*' && original.get(index + 1) == Some(&b'/') { + normalized[index] = b' '; + normalized[index + 1] = b' '; + index += 2; + closed = true; + break; + } + if !matches!(original[index], b'\r' | b'\n') { + normalized[index] = b' '; + } + index += 1; + } + if !closed { + bail!("unterminated JSONC block comment"); + } + } + _ => index += 1, + } + } + + index = 0; + in_string = false; + while index < normalized.len() { + if in_string { + match normalized[index] { + b'\\' => index = (index + 2).min(normalized.len()), + b'"' => { + in_string = false; + index += 1; + } + _ => index += 1, + } + continue; + } + match normalized[index] { + b'"' => { + in_string = true; + index += 1; + } + b',' => { + let mut next = index + 1; + while matches!(normalized.get(next), Some(b' ' | b'\t' | b'\r' | b'\n')) { + next += 1; + } + if matches!(normalized.get(next), Some(b']' | b'}')) { + normalized[index] = b' '; + } + index += 1; + } + _ => index += 1, + } + } + + Ok(Cow::Owned(String::from_utf8(normalized).expect( + "replacing source bytes with spaces preserves UTF-8", + ))) +} + +fn skip_json_trivia(source: &str, mut index: usize, format: SourceFormat) -> Result { + let bytes = source.as_bytes(); + loop { + while matches!(bytes.get(index), Some(b' ' | b'\t' | b'\r' | b'\n')) { + index += 1; + } + if format != SourceFormat::Jsonc || bytes.get(index) != Some(&b'/') { + return Ok(index); + } + match bytes.get(index + 1) { + Some(b'/') => { + index += 2; + while !matches!(bytes.get(index), None | Some(b'\r' | b'\n')) { + index += 1; + } + } + Some(b'*') => { + index += 2; + let Some(end) = source[index..].find("*/") else { + bail!("unterminated JSONC block comment"); + }; + index += end + 2; + } + _ => return Ok(index), + } + } +} + +fn insert_toml_table( + bytes: &[u8], + relevant_roots: &[&str], + path: &SourcePath, + values: &[(String, toml::Value)], +) -> Result { + if path.0.is_empty() { + bail!("a TOML provider table needs a non-root path"); + } + reject_duplicate_names(values.iter().map(|(key, _)| key.as_str()))?; + let (_, body) = source_text(bytes)?; + let parsed = validate_and_parse(SourceFormat::Toml, bytes, relevant_roots)?; + let ParsedSource::Toml(value) = parsed else { + unreachable!() + }; + if let Some(existing) = toml_value_at(&value, &path.0) { + let Some(table) = existing.as_table() else { + bail!("provider TOML {} is not a table", display_path(path)); + }; + if values + .iter() + .all(|(key, expected)| table.get(key) == Some(expected)) + { + return Ok(AppliedInsert { + bytes: bytes.to_vec(), + owned: None, + created: Vec::new(), + }); + } + bail!("provider TOML table {} already differs", display_path(path)); + } + reject_toml_ancestor_conflict(&value, &path.0)?; + + let newline = newline_for(body); + let mut block = format!("[{}]{newline}", toml_header(&path.0)); + for (key, value) in values { + block.push_str(&toml_scalar_line(key, value, newline)?); + } + let prefix = if body.is_empty() || ends_with_line_break(body) { + "" + } else { + newline + }; + let inserted = format!("{prefix}{block}"); + let mut next = bytes.to_vec(); + append_source(&mut next, &inserted); + validate_and_parse(SourceFormat::Toml, &next, relevant_roots)?; + Ok(AppliedInsert { + bytes: next, + owned: Some(OwnedSplice { + target: OwnedTarget::TomlTable { + path: path.clone(), + values: values.to_vec(), + }, + inserted, + }), + created: Vec::new(), + }) +} + +fn insert_toml_scalar( + bytes: &[u8], + relevant_roots: &[&str], + table_path: &SourcePath, + key: &str, + value: &toml::Value, +) -> Result { + if value.is_table() { + bail!("TOML table values require a table insertion"); + } + if table_path.0.is_empty() { + bail!("a provider TOML scalar needs a non-root table"); + } + let mut next = bytes.to_vec(); + let mut created = Vec::new(); + let parsed = validate_and_parse(SourceFormat::Toml, &next, relevant_roots)?; + let ParsedSource::Toml(root) = parsed else { + unreachable!() + }; + match toml_value_at(&root, &table_path.0) { + Some(existing) if !existing.is_table() => { + bail!("provider TOML {} is not a table", display_path(table_path)); + } + Some(existing) => { + let table = existing.as_table().expect("checked table"); + if let Some(current) = table.get(key) { + if current == value { + return Ok(AppliedInsert { + bytes: next, + owned: None, + created, + }); + } + bail!("provider TOML key {key:?} already has a different value"); + } + } + None if table_path.0.is_empty() => {} + None => { + reject_toml_ancestor_conflict(&root, &table_path.0)?; + let (_, body) = source_text(&next)?; + let newline = newline_for(body); + let prefix = if body.is_empty() || ends_with_line_break(body) { + "" + } else { + newline + }; + let inserted = format!("{prefix}[{}]{newline}", toml_header(&table_path.0)); + append_source(&mut next, &inserted); + created.push(OwnedSplice { + target: OwnedTarget::TomlTable { + path: table_path.clone(), + values: Vec::new(), + }, + inserted, + }); + validate_and_parse(SourceFormat::Toml, &next, relevant_roots)?; + } + } + + let (bom_len, body) = source_text(&next)?; + let document = ImDocument::parse(body).context("invalid provider TOML")?; + let table = toml_table_at(&document, &table_path.0)?; + let mut position = if table_path.0.is_empty() { + table.span().map_or(0, |span| span.end) + } else { + table + .span() + .context("cannot safely splice an implicit TOML table")? + .end + }; + position = after_physical_line(body, position); + let newline = newline_for(body); + let prefix = if position == 0 || ends_with_line_break(&body[..position]) { + "" + } else { + newline + }; + let inserted = format!("{prefix}{}", toml_scalar_line(key, value, newline)?); + splice_text(&mut next, bom_len + position, &inserted); + validate_and_parse(SourceFormat::Toml, &next, relevant_roots)?; + Ok(AppliedInsert { + bytes: next, + owned: Some(OwnedSplice { + target: OwnedTarget::TomlScalar { + table: table_path.clone(), + key: key.to_owned(), + value: value.clone(), + }, + inserted, + }), + created, + }) +} + +fn toml_table_at<'a>(document: &'a ImDocument<&str>, path: &[String]) -> Result<&'a Table> { + let mut table = document.as_table(); + for segment in path { + let item = table + .get(segment) + .with_context(|| format!("provider TOML path {:?} is missing", path))?; + table = match item { + Item::Table(table) => table, + Item::Value(value) if value.is_inline_table() => { + bail!("inline provider TOML tables are not safely mutable") + } + Item::ArrayOfTables(_) => { + bail!("provider TOML arrays of tables are not safely mutable") + } + Item::None | Item::Value(_) => { + bail!("provider TOML path {:?} is not a table", path) + } + }; + } + Ok(table) +} + +fn toml_table_at_optional<'a>( + document: &'a ImDocument<&str>, + path: &[String], +) -> Option<&'a Table> { + let mut table = document.as_table(); + for segment in path { + table = table.get(segment)?.as_table()?; + } + Some(table) +} + +fn toml_value_at<'a>(root: &'a toml::Value, path: &[String]) -> Option<&'a toml::Value> { + let mut value = root; + for segment in path { + value = value.as_table()?.get(segment)?; + } + Some(value) +} + +fn reject_toml_ancestor_conflict(root: &toml::Value, path: &[String]) -> Result<()> { + let mut value = root; + for segment in path { + let Some(next) = value.as_table().and_then(|table| table.get(segment)) else { + return Ok(()); + }; + if !next.is_table() { + bail!("provider TOML path segment {segment:?} is not a table"); + } + value = next; + } + Ok(()) +} + +fn toml_header(path: &[String]) -> String { + path.iter() + .map(|segment| Key::new(segment).to_string()) + .collect::>() + .join(".") +} + +fn toml_scalar_line(key: &str, value: &toml::Value, newline: &str) -> Result { + if value.is_table() { + bail!("TOML table values require a table insertion"); + } + let mut table = toml::map::Map::new(); + table.insert(key.to_owned(), value.clone()); + let rendered = toml::to_string(&toml::Value::Table(table))?; + Ok(with_newline(&rendered, newline)) +} + +fn reject_duplicate_names<'a>(names: impl IntoIterator) -> Result<()> { + let mut seen = HashSet::new(); + for name in names { + if !seen.insert(name) { + bail!("duplicate provider key {name:?}"); + } + } + Ok(()) +} + +fn after_physical_line(body: &str, start: usize) -> usize { + let Some(offset) = body[start..].find('\n') else { + return body.len(); + }; + start + offset + 1 +} + +fn source_text(bytes: &[u8]) -> Result<(usize, &str)> { + let bom_len = usize::from(bytes.starts_with(UTF8_BOM)) * UTF8_BOM.len(); + let body = std::str::from_utf8(&bytes[bom_len..]).context("provider document is not UTF-8")?; + Ok((bom_len, body)) +} + +fn append_source(bytes: &mut Vec, inserted: &str) { + bytes.extend_from_slice(inserted.as_bytes()); +} + +fn splice_text(bytes: &mut Vec, position: usize, inserted: &str) { + bytes.splice(position..position, inserted.bytes()); +} + +fn exact_matches(source: &str, needle: &str) -> Vec { + source + .match_indices(needle) + .map(|(index, _)| index) + .collect() +} + +fn newline_for(source: &str) -> &'static str { + if source.contains("\r\n") { + "\r\n" + } else { + "\n" + } +} + +fn with_newline(source: &str, newline: &str) -> String { + if newline == "\n" { + source.to_owned() + } else { + source.replace('\n', newline) + } +} + +fn ends_with_line_break(source: &str) -> bool { + source.ends_with('\n') || source.ends_with('\r') +} + +fn ensure_json_format(format: SourceFormat) -> Result<()> { + if matches!(format, SourceFormat::Json | SourceFormat::Jsonc) { + Ok(()) + } else { + bail!("JSON insertion requested for TOML source") + } +} + +fn ensure_toml_format(format: SourceFormat) -> Result<()> { + if format == SourceFormat::Toml { + Ok(()) + } else { + bail!("TOML insertion requested for JSON source") + } +} + +fn display_path(path: &SourcePath) -> String { + if path.0.is_empty() { + "".to_owned() + } else { + path.0.join(".") + } +} + +fn display_target(target: &OwnedTarget) -> String { + match target { + OwnedTarget::JsonRootObject => "".to_owned(), + OwnedTarget::JsonMember { parent, key, .. } => { + format!("{}.{}", display_path(parent), key) + } + OwnedTarget::JsonArrayElement { array, .. } => display_path(array), + OwnedTarget::TomlTable { path, .. } => display_path(path), + OwnedTarget::TomlScalar { table, key, .. } => { + format!("{}.{}", display_path(table), key) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lossless_apply_strip_uninstall_round_trip() { + let cases = [ + ( + SourceFormat::Json, + br#"{"other" : 1, "mcpServers": {}}"#.to_vec(), + Insert::JsonMember { + parent: SourcePath::new(["mcpServers"]), + key: "icm".into(), + value: serde_json::json!({"command": "icm", "args": ["mcp"]}), + }, + vec!["mcpServers"], + ), + ( + SourceFormat::Jsonc, + b"\xef\xbb\xbf{\r\n // keep me\r\n \"permissions\": {\r\n \"allow\": [\r\n ],\r\n },\r\n}\r\n" + .to_vec(), + Insert::JsonArrayElement { + array: SourcePath::new(["permissions", "allow"]), + value: JsonValue::String("mcp__icm__recall".into()), + }, + vec!["permissions"], + ), + ( + SourceFormat::Toml, + b"\xef\xbb\xbf# keep me\r\ntheme = \"dark\"\r\n".to_vec(), + Insert::TomlTable { + path: SourcePath::new(["mcp_servers", "icm"]), + values: vec![("command".into(), toml::Value::String("icm".into()))], + }, + vec![], + ), + ]; + + for (format, original, operation, roots) in cases { + let applied = insert(&original, format, &roots, &operation).unwrap(); + assert!(applied.changed()); + let mut stripped = applied.bytes; + if let Some(owned) = applied.owned { + stripped = remove_owned(&stripped, format, &roots, &owned).unwrap(); + } + for created in applied.created.iter().rev() { + stripped = remove_owned(&stripped, format, &roots, created).unwrap(); + } + assert_eq!(stripped, original); + } + } + + #[test] + fn relevant_duplicates_are_read_only() { + let duplicate_json = br#"{"permissions":{"allow":[],"allow":[]},"legacy":1}"#; + let before = duplicate_json.to_vec(); + assert!(insert( + duplicate_json, + SourceFormat::Json, + &["permissions"], + &Insert::JsonArrayElement { + array: SourcePath::new(["permissions", "allow"]), + value: JsonValue::String("mcp__icm__recall".into()), + }, + ) + .is_err()); + assert_eq!(&duplicate_json[..], before.as_slice()); + + let duplicate_toml = b"[mcp_servers.icm]\ncommand = \"icm\"\ncommand = \"other\"\n"; + assert!(validate_and_parse(SourceFormat::Toml, duplicate_toml, &[]).is_err()); + } + + #[test] + fn disjoint_external_edit_survives_owned_inverse() { + let original = br#"{"permissions":{"allow":[]},"theme":"dark"}"#; + let applied = insert( + original, + SourceFormat::Json, + &["permissions"], + &Insert::JsonArrayElement { + array: SourcePath::new(["permissions", "allow"]), + value: JsonValue::String("mcp__icm__recall".into()), + }, + ) + .unwrap(); + let owned = applied.owned.unwrap(); + let externally_edited = String::from_utf8(applied.bytes) + .unwrap() + .replace("\"theme\":\"dark\"", "\"theme\" : \"light\""); + let stripped = remove_owned( + externally_edited.as_bytes(), + SourceFormat::Json, + &["permissions"], + &owned, + ) + .unwrap(); + assert_eq!( + stripped, + br#"{"permissions":{"allow":[]},"theme" : "light"}"# + ); + } + + #[test] + fn inverse_never_removes_an_identical_nested_json_fragment() { + let applied = insert( + br#"{"mcpServers":{}}"#, + SourceFormat::Json, + &["mcpServers"], + &Insert::JsonMember { + parent: SourcePath::new(["mcpServers"]), + key: "icm".to_owned(), + value: serde_json::json!({}), + }, + ) + .unwrap(); + let owned = applied.owned.unwrap(); + let external = br#"{"mcpServers":{"external":{"icm":{}}}}"#; + assert!( + try_remove_owned(external, SourceFormat::Json, &["mcpServers"], &owned,) + .unwrap() + .is_none() + ); + assert!(remove_owned(external, SourceFormat::Json, &["mcpServers"], &owned,).is_err()); + } + + #[test] + fn inverse_never_removes_a_toml_fragment_from_a_multiline_string() { + let applied = insert( + b"[mcp_servers.icm]\n", + SourceFormat::Toml, + &[], + &Insert::TomlScalar { + table: SourcePath::new(["mcp_servers", "icm"]), + key: "approval_mode".to_owned(), + value: toml::Value::String("approve".to_owned()), + }, + ) + .unwrap(); + let owned = applied.owned.unwrap(); + let external = b"[mcp_servers.icm]\nnotes = \"\"\"\napproval_mode = \"approve\"\n\"\"\"\n"; + assert!(try_remove_owned(external, SourceFormat::Toml, &[], &owned) + .unwrap() + .is_none()); + assert!(remove_owned(external, SourceFormat::Toml, &[], &owned).is_err()); + } +} diff --git a/crates/icm-cli/src/provider_journal.rs b/crates/icm-cli/src/provider_journal.rs new file mode 100644 index 00000000..024a3eec --- /dev/null +++ b/crates/icm-cli/src/provider_journal.rs @@ -0,0 +1,1157 @@ +//! Closed schema-v2 provider ownership journal. +//! +//! The public `providerOwnership` value stores hashes and semantic ownership. +//! Exact ICM-authored source splices live in the private sidecar below; neither +//! representation stores a provider document snapshot. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{bail, ensure, Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::provider_document::{OwnedSplice, OwnedTarget, SourceFormat}; + +pub(crate) const SCHEMA_VERSION: u32 = 2; +pub(crate) const MAX_OPERATIONS: usize = 128; +pub(crate) const MAX_OWNED_FRAGMENTS: usize = 256; +pub(crate) const MAX_TARGETS_PER_OPERATION: usize = 64; +pub(crate) const MAX_CREATED_CONTAINERS: usize = 32; +const PRODUCER_VERSION: &str = "icm-provider-engine-v2"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProviderOwnership { + pub(crate) schema_version: u32, + pub(crate) min_reader_version: u32, + pub(crate) producer_version: String, + pub(crate) generation: u64, + pub(crate) installation_id: String, + pub(crate) operations: Vec, + pub(crate) owned_fragments: Vec, +} + +impl ProviderOwnership { + pub(crate) fn empty(installation_id: impl Into) -> Result { + let journal = Self { + schema_version: SCHEMA_VERSION, + min_reader_version: SCHEMA_VERSION, + producer_version: PRODUCER_VERSION.to_owned(), + generation: 0, + installation_id: installation_id.into(), + operations: Vec::new(), + owned_fragments: Vec::new(), + }; + journal.validate()?; + Ok(journal) + } + + /// Schema-1 snapshots carry no fragment-level deletion authority. + pub(crate) fn from_legacy_unproven(installation_id: impl Into) -> Result { + Self::empty(installation_id) + } + + pub(crate) fn push_operation(&mut self, operation: ProviderOperation) -> Result<()> { + ensure!( + self.operations.len() < MAX_OPERATIONS, + "provider journal operation retention bound exceeded" + ); + operation.validate()?; + ensure!( + !self + .operations + .iter() + .any(|existing| existing.id == operation.id), + "provider journal duplicates operation id" + ); + self.operations.push(operation); + Ok(()) + } + + pub(crate) fn push_fragment(&mut self, fragment: OwnedFragment) -> Result<()> { + ensure!( + self.owned_fragments.len() < MAX_OWNED_FRAGMENTS, + "provider journal owned-fragment retention bound exceeded" + ); + self.owned_fragments.push(fragment); + if let Err(error) = self.validate() { + self.owned_fragments.pop(); + return Err(error); + } + Ok(()) + } + + pub(crate) fn validate(&self) -> Result<()> { + ensure!( + self.schema_version == SCHEMA_VERSION, + "unsupported provider journal schema version {}", + self.schema_version + ); + ensure!( + self.min_reader_version == SCHEMA_VERSION, + "unsupported provider journal minimum reader version {}", + self.min_reader_version + ); + ensure!( + valid_producer_version(&self.producer_version), + "invalid provider journal producer version" + ); + ensure!( + valid_installation_id(&self.installation_id), + "invalid provider journal installation id" + ); + ensure!( + self.operations.len() <= MAX_OPERATIONS, + "provider journal operation retention bound exceeded" + ); + ensure!( + self.owned_fragments.len() <= MAX_OWNED_FRAGMENTS, + "provider journal owned-fragment retention bound exceeded" + ); + + let mut operation_ids = BTreeSet::new(); + for operation in &self.operations { + operation.validate()?; + ensure!( + operation_ids.insert(operation.id.as_str()), + "provider journal duplicates operation id" + ); + } + + let mut fragment_ids = BTreeSet::new(); + let mut semantic_fragments = BTreeSet::new(); + for fragment in &self.owned_fragments { + fragment.validate(self.generation)?; + ensure!( + fragment_ids.insert(fragment.id.as_str()), + "provider journal duplicates fragment id" + ); + ensure!( + semantic_fragments.insert(( + fragment.provider, + fragment.scope, + fragment.canonical_path.as_str(), + fragment.semantic_selector.as_str(), + )), + "provider journal duplicates semantic fragment" + ); + + let operation = self + .operations + .iter() + .find(|operation| operation.id == fragment.introducing_operation_id) + .context("provider fragment references an unknown operation")?; + ensure!( + operation.requested.provider == fragment.provider + && operation.requested.surface == fragment.surface + && operation.requested.scope == fragment.scope + && operation.requested.dialect == fragment.dialect, + "provider fragment identity differs from its introducing operation" + ); + let target = operation + .targets + .iter() + .find(|target| { + target.canonical_path == fragment.canonical_path + && target.patch.selector == fragment.semantic_selector + }) + .context("provider fragment has no exact introducing target")?; + ensure!( + target.format == fragment.format + && target.dialect == fragment.dialect + && target.patch.value_fingerprint == fragment.value_fingerprint, + "provider fragment differs from its introducing target" + ); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProviderOperation { + pub(crate) id: String, + pub(crate) requested: RequestedOperation, + pub(crate) phase: OperationPhase, + pub(crate) targets: Vec, +} + +impl ProviderOperation { + fn validate(&self) -> Result<()> { + ensure!(!self.id.is_empty(), "provider operation id is empty"); + ensure!(!self.targets.is_empty(), "provider operation has no target"); + ensure!( + self.targets.len() <= MAX_TARGETS_PER_OPERATION, + "provider operation target bound exceeded" + ); + let mut targets = BTreeSet::new(); + for target in &self.targets { + target.validate(&self.requested, self.phase)?; + ensure!( + targets.insert(( + target.canonical_path.as_str(), + target.patch.selector.as_str() + )), + "provider operation duplicates a path/selector target" + ); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RequestedOperation { + pub(crate) provider: ProviderId, + pub(crate) surface: ProviderSurface, + pub(crate) scope: ProviderScope, + pub(crate) dialect: ProviderDialect, + pub(crate) action: OperationAction, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct TargetIntent { + pub(crate) canonical_path: String, + pub(crate) display_path: String, + pub(crate) format: SourceFormat, + pub(crate) dialect: ProviderDialect, + pub(crate) before_hash: String, + pub(crate) expected_after_hash: String, + pub(crate) observed_after_hash: Option, + pub(crate) patch: ExactTransform, + pub(crate) inverse: ExactTransform, + pub(crate) ownership_delta: OwnershipDelta, + pub(crate) phase: OperationPhase, +} + +impl TargetIntent { + fn validate( + &self, + requested: &RequestedOperation, + operation_phase: OperationPhase, + ) -> Result<()> { + ensure!( + !self.canonical_path.is_empty() && self.display_path == self.canonical_path, + "provider target path is invalid" + ); + ensure!( + self.dialect == requested.dialect && self.phase == operation_phase, + "provider target dialect or phase differs from its operation" + ); + ensure!( + is_sha256(&self.before_hash) && is_sha256(&self.expected_after_hash), + "provider target before/after hash is not SHA-256" + ); + match (&self.observed_after_hash, self.phase) { + (None, OperationPhase::Prepared) => {} + (Some(hash), phase) if phase != OperationPhase::Prepared && is_sha256(hash) => {} + _ => bail!("provider target observed hash does not match its phase"), + } + self.patch.validate()?; + self.inverse.validate()?; + ensure!( + self.patch.selector == self.inverse.selector + && self.patch.value_fingerprint == self.inverse.value_fingerprint, + "provider patch and inverse identity differ" + ); + self.ownership_delta.validate(&self.patch, &self.inverse) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ExactTransform { + pub(crate) kind: TransformKind, + pub(crate) selector: String, + pub(crate) value_fingerprint: String, +} + +impl ExactTransform { + fn validate(&self) -> Result<()> { + ensure!( + !self.selector.is_empty(), + "provider transform selector is empty" + ); + ensure!( + is_sha256(&self.value_fingerprint), + "provider transform fingerprint is not SHA-256" + ); + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct OwnershipDelta { + pub(crate) add: Vec, + pub(crate) remove: Vec, +} + +impl OwnershipDelta { + fn validate(&self, patch: &ExactTransform, inverse: &ExactTransform) -> Result<()> { + let add = exact_selector_set(&self.add)?; + let remove = exact_selector_set(&self.remove)?; + ensure!( + add.is_disjoint(&remove), + "provider ownership delta adds and removes the same selector" + ); + let valid = match (patch.kind, inverse.kind) { + (TransformKind::Insert, TransformKind::Remove) => { + add == BTreeSet::from([patch.selector.as_str()]) && remove.is_empty() + } + (TransformKind::Remove, TransformKind::Insert) => { + remove == BTreeSet::from([patch.selector.as_str()]) && add.is_empty() + } + (TransformKind::Adopt, TransformKind::PreserveExternal) => { + add.is_empty() && remove.is_empty() + } + _ => false, + }; + ensure!( + valid, + "provider ownership delta contradicts exact transforms" + ); + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct OwnedFragment { + pub(crate) id: String, + pub(crate) provider: ProviderId, + pub(crate) surface: ProviderSurface, + pub(crate) scope: ProviderScope, + pub(crate) dialect: ProviderDialect, + pub(crate) canonical_path: String, + pub(crate) display_path: String, + pub(crate) format: SourceFormat, + pub(crate) semantic_selector: String, + pub(crate) value_fingerprint: String, + pub(crate) created_containers: Vec, + pub(crate) ownership_kind: OwnershipKind, + pub(crate) introducing_operation_id: String, + pub(crate) generation: u64, +} + +impl OwnedFragment { + fn validate(&self, journal_generation: u64) -> Result<()> { + ensure!( + !self.id.is_empty() + && !self.semantic_selector.is_empty() + && !self.introducing_operation_id.is_empty(), + "provider fragment identity is empty" + ); + ensure!( + !self.canonical_path.is_empty() && self.display_path == self.canonical_path, + "provider fragment path is invalid" + ); + ensure!( + is_sha256(&self.value_fingerprint), + "provider fragment fingerprint is not SHA-256" + ); + ensure!( + self.generation > 0 && self.generation <= journal_generation, + "provider fragment generation is invalid" + ); + ensure!( + self.created_containers.len() <= MAX_CREATED_CONTAINERS, + "provider fragment created-container bound exceeded" + ); + let containers: BTreeSet<_> = self.created_containers.iter().map(String::as_str).collect(); + ensure!( + containers.len() == self.created_containers.len() + && containers + .iter() + .all(|container| !container.is_empty() && container.starts_with('/')), + "provider fragment created container is invalid" + ); + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ProviderId { + Codex, + ClaudeCode, + Cursor, + #[serde(rename = "opencode")] + OpenCode, + Zed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ProviderSurface { + CombinedRegistrationAndPermission, + SplitRegistrationAndPermission, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ProviderScope { + ProjectLocal, + User, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub(crate) enum ProviderDialect { + #[serde(rename = "codex-toml-v1")] + CodexTomlV1, + #[serde(rename = "claude-json-v1")] + ClaudeJsonV1, + #[serde(rename = "cursor-json-v1")] + CursorJsonV1, + #[serde(rename = "opencode-json-v2")] + OpenCodeJsonV2, + #[serde(rename = "zed-json-v1")] + ZedJsonV1, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum OperationAction { + Trust, + Strip, + Uninstall, + Recover, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum OperationPhase { + Prepared, + Applied, + Removed, + Conflict, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum TransformKind { + #[serde(rename = "insert-exact-fragment")] + Insert, + #[serde(rename = "remove-exact-fragment")] + Remove, + #[serde(rename = "adopt-exact-fragment")] + Adopt, + #[serde(rename = "preserve-external-fragment")] + PreserveExternal, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum OwnershipKind { + Owned, + Adopted, + Removed, +} + +/// Private sidecar. It mirrors the public journal so stale manifest writers +/// cannot erase provider provenance, and adds only exact ICM-authored splices. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SpliceJournal { + pub(crate) version: u32, + pub(crate) provider_ownership: ProviderOwnership, + pub(crate) records: Vec, +} + +impl SpliceJournal { + pub(crate) fn from_json(bytes: &[u8]) -> Result { + let journal: Self = serde_json::from_slice(bytes).context("invalid provider sidecar")?; + journal.validate()?; + Ok(journal) + } + + pub(crate) fn to_json(&self) -> Result> { + self.validate()?; + serde_json::to_vec_pretty(self).context("cannot encode provider sidecar") + } + + pub(crate) fn validate(&self) -> Result<()> { + ensure!( + self.version == SCHEMA_VERSION, + "unsupported provider sidecar version {}", + self.version + ); + self.provider_ownership.validate()?; + ensure!( + self.records.len() <= MAX_OWNED_FRAGMENTS, + "provider sidecar record bound exceeded" + ); + let mut keys = BTreeSet::new(); + let mut origins = BTreeMap::new(); + for record in &self.records { + record.validate()?; + ensure!( + keys.insert(( + record.provider, + record.scope, + record.dialect, + record.canonical_path.as_str(), + record.semantic_selector.as_str(), + record.introducing_operation_id.as_str(), + )), + "provider sidecar duplicates an exact splice record" + ); + let origin_key = ( + record.provider, + record.scope, + record.canonical_path.as_str(), + ); + if let Some(origin) = origins.insert(origin_key, record.document_origin) { + ensure!( + origin == record.document_origin, + "provider sidecar disagrees on document origin" + ); + } + + let operation = self + .provider_ownership + .operations + .iter() + .find(|operation| operation.id == record.introducing_operation_id) + .context("provider sidecar splice references an unknown operation")?; + ensure!( + operation.requested.provider == record.provider + && operation.requested.surface == record.surface + && operation.requested.scope == record.scope + && operation.requested.dialect == record.dialect, + "provider sidecar splice identity differs from its operation" + ); + let target = operation + .targets + .iter() + .find(|target| { + target.canonical_path == record.canonical_path + && target.patch.selector == record.semantic_selector + }) + .context("provider sidecar splice has no exact operation target")?; + ensure!( + target.format == record.source_patch.format && target.dialect == record.dialect, + "provider sidecar splice format differs from its operation target" + ); + + let fragment = self + .provider_ownership + .owned_fragments + .iter() + .find(|fragment| { + fragment.provider == record.provider + && fragment.surface == record.surface + && fragment.scope == record.scope + && fragment.dialect == record.dialect + && fragment.canonical_path == record.canonical_path + && fragment.semantic_selector == record.semantic_selector + && fragment.introducing_operation_id == record.introducing_operation_id + }); + match operation.phase { + OperationPhase::Prepared => ensure!( + fragment.is_none(), + "prepared provider splice prematurely claims fragment ownership" + ), + _ => ensure!( + fragment.is_some_and(|fragment| { + fragment.ownership_kind == OwnershipKind::Owned + && fragment.format == record.source_patch.format + }), + "terminal provider splice is not backed by active ownership" + ), + } + } + + for fragment in &self.provider_ownership.owned_fragments { + let records = self + .records + .iter() + .filter(|record| { + fragment.provider == record.provider + && fragment.surface == record.surface + && fragment.scope == record.scope + && fragment.dialect == record.dialect + && fragment.canonical_path == record.canonical_path + && fragment.semantic_selector == record.semantic_selector + && fragment.introducing_operation_id == record.introducing_operation_id + }) + .count(); + ensure!( + records == usize::from(fragment.ownership_kind == OwnershipKind::Owned), + "provider sidecar and fragment ownership are not an exact active bijection" + ); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SpliceRecord { + pub(crate) provider: ProviderId, + pub(crate) surface: ProviderSurface, + pub(crate) scope: ProviderScope, + pub(crate) dialect: ProviderDialect, + pub(crate) canonical_path: String, + pub(crate) semantic_selector: String, + pub(crate) introducing_operation_id: String, + pub(crate) document_origin: DocumentOrigin, + pub(crate) source_patch: SourcePatch, +} + +impl SpliceRecord { + fn validate(&self) -> Result<()> { + ensure!( + !self.canonical_path.is_empty() + && !self.semantic_selector.is_empty() + && !self.introducing_operation_id.is_empty(), + "provider sidecar splice identity is empty" + ); + self.source_patch + .validate_for(self.dialect, &self.semantic_selector) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SourcePatch { + pub(crate) format: SourceFormat, + pub(crate) owned: Option, + pub(crate) created: Vec, +} + +impl SourcePatch { + fn validate_for(&self, dialect: ProviderDialect, selector: &str) -> Result<()> { + ensure!( + match dialect { + ProviderDialect::CodexTomlV1 => self.format == SourceFormat::Toml, + _ => matches!(self.format, SourceFormat::Json | SourceFormat::Jsonc), + }, + "provider sidecar format differs from its dialect" + ); + ensure!( + self.created.len() <= MAX_CREATED_CONTAINERS, + "provider sidecar created-splice bound exceeded" + ); + let owned = self + .owned + .as_ref() + .context("provider sidecar has no primary inverse splice")?; + validate_splice(owned)?; + ensure!( + target_matches_selector(dialect, selector, &owned.target), + "provider sidecar primary splice differs from its semantic selector" + ); + for (index, splice) in self.created.iter().enumerate() { + validate_splice(splice)?; + ensure!( + !self.created[..index].contains(splice), + "provider sidecar duplicates a created splice" + ); + ensure!( + created_target_is_authorized(&splice.target, &owned.target, dialect, selector), + "provider sidecar created splice is outside its primary target" + ); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum DocumentOrigin { + Missing, + Empty, + Existing, +} + +fn validate_splice(splice: &OwnedSplice) -> Result<()> { + ensure!( + !splice.inserted.is_empty(), + "provider sidecar splice is empty" + ); + let valid = match &splice.target { + OwnedTarget::JsonRootObject => true, + OwnedTarget::JsonMember { parent, key, .. } => { + valid_source_path(parent, true) && !key.is_empty() + } + OwnedTarget::JsonArrayElement { array, .. } => valid_source_path(array, false), + OwnedTarget::TomlTable { path, values } => { + valid_source_path(path, false) + && values.iter().all(|(key, _)| !key.is_empty()) + && values + .iter() + .map(|(key, _)| key) + .collect::>() + .len() + == values.len() + } + OwnedTarget::TomlScalar { table, key, value } => { + valid_source_path(table, false) && !key.is_empty() && !value.is_table() + } + }; + ensure!(valid, "provider sidecar splice target is invalid"); + Ok(()) +} + +fn valid_source_path(path: &super::provider_document::SourcePath, allow_root: bool) -> bool { + (allow_root || !path.0.is_empty()) && path.0.iter().all(|segment| !segment.is_empty()) +} + +fn target_matches_selector(dialect: ProviderDialect, selector: &str, target: &OwnedTarget) -> bool { + match (dialect, target) { + (ProviderDialect::CodexTomlV1, OwnedTarget::TomlTable { path, values }) + if path.0.len() == 2 && path.0[0] == "mcp_servers" => + { + selector == format!("mcp_servers.{}", path.0[1]) + && values + .iter() + .map(|(key, _)| key.as_str()) + .collect::>() + == BTreeSet::from(["args", "command"]) + } + (ProviderDialect::CodexTomlV1, OwnedTarget::TomlScalar { table, key, value }) + if table.0.len() == 4 && table.0[0] == "mcp_servers" && table.0[2] == "tools" => + { + key == "approval_mode" + && value.as_str() == Some("approve") + && selector + == format!( + "mcp_servers.{}.tools.{}.approval_mode=approve", + table.0[1], table.0[3] + ) + } + ( + ProviderDialect::ClaudeJsonV1 | ProviderDialect::CursorJsonV1, + OwnedTarget::JsonMember { parent, key, .. }, + ) if source_path_is(parent, &["mcpServers"]) => selector == format!("mcpServers.{key}"), + ( + ProviderDialect::ClaudeJsonV1 | ProviderDialect::CursorJsonV1, + OwnedTarget::JsonArrayElement { array, value }, + ) if source_path_is(array, &["permissions", "allow"]) => value.as_str() == Some(selector), + (ProviderDialect::OpenCodeJsonV2, OwnedTarget::JsonMember { parent, key, .. }) + if source_path_is(parent, &["mcp", "servers"]) => + { + selector == format!("mcp.servers.{key}") + } + (ProviderDialect::OpenCodeJsonV2, OwnedTarget::JsonArrayElement { array, value }) + if source_path_is(array, &["permissions"]) => + { + value.as_object().is_some_and(|rule| { + let action = rule.get("action").and_then(serde_json::Value::as_str); + let resource = rule.get("resource").and_then(serde_json::Value::as_str); + let effect = rule.get("effect").and_then(serde_json::Value::as_str); + rule.len() == 3 + && action.is_some_and(|action| selector == format!("{action}|*|allow")) + && resource == Some("*") + && effect == Some("allow") + }) + } + (ProviderDialect::ZedJsonV1, OwnedTarget::JsonMember { parent, key, value }) + if source_path_is(parent, &["context_servers"]) => + { + selector == format!("context_servers.{key}") && value.is_object() + } + (ProviderDialect::ZedJsonV1, OwnedTarget::JsonMember { parent, key, value }) + if source_path_is(parent, &["agent", "tool_permissions", "tools"]) => + { + selector == format!("agent.tool_permissions.tools.{key}.default=allow") + && value == &serde_json::json!({"default": "allow"}) + } + _ => false, + } +} + +fn created_target_is_authorized( + created: &OwnedTarget, + primary: &OwnedTarget, + dialect: ProviderDialect, + selector: &str, +) -> bool { + match created { + OwnedTarget::JsonRootObject => { + matches!( + primary, + OwnedTarget::JsonMember { .. } | OwnedTarget::JsonArrayElement { .. } + ) + } + OwnedTarget::JsonMember { parent, key, value } + if value.as_object().is_some_and(serde_json::Map::is_empty) + || value.as_array().is_some_and(Vec::is_empty) => + { + let mut created_path = parent.0.clone(); + created_path.push(key.clone()); + json_target_path(primary) + .is_some_and(|primary_path| is_path_prefix(&created_path, &primary_path)) + } + OwnedTarget::TomlTable { path, values } if values.is_empty() => toml_target_path(primary) + .is_some_and(|primary_path| is_path_prefix(&path.0, primary_path)), + OwnedTarget::TomlScalar { table, key, value } + if dialect == ProviderDialect::CodexTomlV1 + && key == "enabled_tools" + && table.0.len() == 2 + && table.0[0] == "mcp_servers" => + { + let expected = toml::Value::Array(vec![ + toml::Value::String("icm_memory_recall".to_owned()), + toml::Value::String("icm_memory_store".to_owned()), + ]); + let same_server = matches!( + primary, + OwnedTarget::TomlScalar { table: primary, .. } + if primary.0.get(1) == table.0.get(1) + ); + same_server && value == &expected && selector.starts_with("mcp_servers.") + } + _ => false, + } +} + +fn source_path_is(path: &super::provider_document::SourcePath, expected: &[&str]) -> bool { + path.0 + .iter() + .map(String::as_str) + .eq(expected.iter().copied()) +} + +fn json_target_path(target: &OwnedTarget) -> Option> { + match target { + OwnedTarget::JsonMember { parent, key, .. } => { + let mut path = parent.0.clone(); + path.push(key.clone()); + Some(path) + } + OwnedTarget::JsonArrayElement { array, .. } => Some(array.0.clone()), + _ => None, + } +} + +fn toml_target_path(target: &OwnedTarget) -> Option<&[String]> { + match target { + OwnedTarget::TomlTable { path, .. } => Some(&path.0), + OwnedTarget::TomlScalar { table, .. } => Some(&table.0), + _ => None, + } +} + +fn is_path_prefix(prefix: &[String], path: &[String]) -> bool { + prefix.len() <= path.len() && prefix.iter().zip(path).all(|(left, right)| left == right) +} + +fn exact_selector_set(values: &[String]) -> Result> { + let mut selectors = BTreeSet::new(); + for value in values { + ensure!(!value.is_empty(), "provider ownership selector is empty"); + ensure!( + selectors.insert(value.as_str()), + "provider ownership delta duplicates a selector" + ); + } + Ok(selectors) +} + +fn valid_producer_version(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'+')) +} + +fn valid_installation_id(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::provider_document::SourcePath; + + fn hash(byte: char) -> String { + std::iter::repeat_n(byte, 64).collect() + } + + fn sample() -> ProviderOwnership { + let selector = "mcp_servers.i123.tools.icm_memory_recall.approval_mode=approve"; + let fingerprint = hash('b'); + ProviderOwnership { + schema_version: SCHEMA_VERSION, + min_reader_version: SCHEMA_VERSION, + producer_version: PRODUCER_VERSION.to_owned(), + generation: 1, + installation_id: "i123".to_owned(), + operations: vec![ProviderOperation { + id: "op1".to_owned(), + requested: RequestedOperation { + provider: ProviderId::Codex, + surface: ProviderSurface::CombinedRegistrationAndPermission, + scope: ProviderScope::ProjectLocal, + dialect: ProviderDialect::CodexTomlV1, + action: OperationAction::Trust, + }, + phase: OperationPhase::Applied, + targets: vec![TargetIntent { + canonical_path: "config.toml".to_owned(), + display_path: "config.toml".to_owned(), + format: SourceFormat::Toml, + dialect: ProviderDialect::CodexTomlV1, + before_hash: hash('a'), + expected_after_hash: hash('c'), + observed_after_hash: Some(hash('c')), + patch: ExactTransform { + kind: TransformKind::Insert, + selector: selector.to_owned(), + value_fingerprint: fingerprint.clone(), + }, + inverse: ExactTransform { + kind: TransformKind::Remove, + selector: selector.to_owned(), + value_fingerprint: fingerprint.clone(), + }, + ownership_delta: OwnershipDelta { + add: vec![selector.to_owned()], + remove: Vec::new(), + }, + phase: OperationPhase::Applied, + }], + }], + owned_fragments: vec![OwnedFragment { + id: "fragment1".to_owned(), + provider: ProviderId::Codex, + surface: ProviderSurface::CombinedRegistrationAndPermission, + scope: ProviderScope::ProjectLocal, + dialect: ProviderDialect::CodexTomlV1, + canonical_path: "config.toml".to_owned(), + display_path: "config.toml".to_owned(), + format: SourceFormat::Toml, + semantic_selector: selector.to_owned(), + value_fingerprint: fingerprint, + created_containers: Vec::new(), + ownership_kind: OwnershipKind::Owned, + introducing_operation_id: "op1".to_owned(), + generation: 1, + }], + } + } + + fn sample_record() -> SpliceRecord { + SpliceRecord { + provider: ProviderId::Codex, + surface: ProviderSurface::CombinedRegistrationAndPermission, + scope: ProviderScope::ProjectLocal, + dialect: ProviderDialect::CodexTomlV1, + canonical_path: "config.toml".to_owned(), + semantic_selector: "mcp_servers.i123.tools.icm_memory_recall.approval_mode=approve" + .to_owned(), + introducing_operation_id: "op1".to_owned(), + document_origin: DocumentOrigin::Existing, + source_patch: SourcePatch { + format: SourceFormat::Toml, + owned: Some(OwnedSplice { + target: OwnedTarget::TomlScalar { + table: SourcePath(vec![ + "mcp_servers".to_owned(), + "i123".to_owned(), + "tools".to_owned(), + "icm_memory_recall".to_owned(), + ]), + key: "approval_mode".to_owned(), + value: toml::Value::String("approve".to_owned()), + }, + inserted: "\napproval_mode = \"approve\"".to_owned(), + }), + created: Vec::new(), + }, + } + } + + #[test] + fn closed_schema_rejects_unknown_fields() { + let mut value = serde_json::to_value(sample()).unwrap(); + value["future"] = json!(true); + assert!(serde_json::from_value::(value).is_err()); + + let mut value = serde_json::to_value(sample()).unwrap(); + value["operations"][0]["targets"][0]["patch"]["future"] = json!(true); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn version_and_downgrade_are_rejected() { + for (schema, reader) in [(1, 2), (3, 2), (2, 1), (2, 3)] { + let mut journal = sample(); + journal.schema_version = schema; + journal.min_reader_version = reader; + assert!(journal.validate().is_err()); + } + + let mut value = serde_json::to_value(sample()).unwrap(); + value["operations"][0]["phase"] = json!("future-phase"); + assert!(serde_json::from_value::(value).is_err()); + + assert_eq!( + serde_json::to_value(ProviderId::OpenCode).unwrap(), + "opencode" + ); + } + + #[test] + fn retention_bounds_fail_closed() { + let mut journal = sample(); + journal.operations = (0..=MAX_OPERATIONS) + .map(|index| { + let mut operation = journal.operations[0].clone(); + operation.id = format!("op{index}"); + operation.targets[0].canonical_path = format!("config-{index}.toml"); + operation.targets[0].display_path = format!("config-{index}.toml"); + operation + }) + .collect(); + journal.owned_fragments.clear(); + assert!(journal.validate().is_err()); + + let mut journal = sample(); + let target = journal.operations[0].targets[0].clone(); + journal.operations[0].targets = (0..=MAX_TARGETS_PER_OPERATION) + .map(|index| { + let mut target = target.clone(); + target.canonical_path = format!("config-{index}.toml"); + target.display_path = format!("config-{index}.toml"); + target + }) + .collect(); + journal.owned_fragments.clear(); + assert!(journal.validate().is_err()); + + let mut journal = sample(); + journal.owned_fragments[0].created_containers = (0..=MAX_CREATED_CONTAINERS) + .map(|index| format!("/container/{index}")) + .collect(); + assert!(journal.validate().is_err()); + + let mut journal = sample(); + let fragment = journal.owned_fragments[0].clone(); + journal.owned_fragments = (0..=MAX_OWNED_FRAGMENTS) + .map(|index| { + let mut fragment = fragment.clone(); + fragment.id = format!("fragment{index}"); + fragment.semantic_selector = format!("selector{index}"); + fragment + }) + .collect(); + assert!(journal.validate().is_err()); + } + + #[test] + fn operation_and_fragment_cross_references_are_exact() { + let journal = sample(); + journal.validate().unwrap(); + + let mut broken = journal.clone(); + broken.operations[0].targets[0].inverse.selector = "other".to_owned(); + assert!(broken.validate().is_err()); + + let mut broken = journal.clone(); + broken.operations[0].targets[0].ownership_delta.add.clear(); + assert!(broken.validate().is_err()); + + let mut broken = journal.clone(); + broken.owned_fragments[0].introducing_operation_id = "missing".to_owned(); + assert!(broken.validate().is_err()); + + let mut tombstone = journal; + tombstone.owned_fragments[0].ownership_kind = OwnershipKind::Removed; + tombstone.validate().unwrap(); + } + + #[test] + fn legacy_migration_claims_no_snapshot_ownership() { + let journal = ProviderOwnership::from_legacy_unproven("i123").unwrap(); + let value = serde_json::to_value(journal).unwrap(); + assert_eq!(value["operations"], json!([])); + assert_eq!(value["owned_fragments"], json!([])); + assert!(value.to_string().find("bytes_before").is_none()); + } + + #[test] + fn sidecar_carries_only_exact_authored_splices() { + let ownership = sample(); + let mut sidecar = SpliceJournal { + version: SCHEMA_VERSION, + provider_ownership: ownership, + records: vec![sample_record()], + }; + sidecar.validate().unwrap(); + let encoded = sidecar.to_json().unwrap(); + assert!(SpliceJournal::from_json(&encoded).is_ok()); + + sidecar.records[0] + .source_patch + .owned + .as_mut() + .unwrap() + .inserted + .clear(); + assert!(sidecar.validate().is_err()); + } + + #[test] + fn splice_records_follow_prepared_owned_removed_transitions() { + let mut prepared = sample(); + prepared.operations[0].phase = OperationPhase::Prepared; + prepared.operations[0].targets[0].phase = OperationPhase::Prepared; + prepared.operations[0].targets[0].observed_after_hash = None; + prepared.owned_fragments.clear(); + let record = sample_record(); + SpliceJournal { + version: SCHEMA_VERSION, + provider_ownership: prepared, + records: vec![record.clone()], + } + .validate() + .unwrap(); + + let applied = sample(); + assert!(SpliceJournal { + version: SCHEMA_VERSION, + provider_ownership: applied.clone(), + records: vec![record.clone()], + } + .validate() + .is_ok()); + assert!(SpliceJournal { + version: SCHEMA_VERSION, + provider_ownership: applied.clone(), + records: Vec::new(), + } + .validate() + .is_err()); + + let mut removed = applied; + removed.owned_fragments[0].ownership_kind = OwnershipKind::Removed; + assert!(SpliceJournal { + version: SCHEMA_VERSION, + provider_ownership: removed.clone(), + records: Vec::new(), + } + .validate() + .is_ok()); + assert!(SpliceJournal { + version: SCHEMA_VERSION, + provider_ownership: removed, + records: vec![record], + } + .validate() + .is_err()); + } +} From 4d88faa2ac37192afada935c935edefd49f4af77 Mon Sep 17 00:00:00 2001 From: Lauritz-Timm Date: Sun, 9 Aug 2026 20:27:43 +0200 Subject: [PATCH 2/3] feat(provider): add crash-safe trusted provider lifecycle --- crates/icm-cli/Cargo.toml | 4 +- crates/icm-cli/src/install_manifest.rs | 565 ++- crates/icm-cli/src/main.rs | 8 + crates/icm-cli/src/provider.rs | 4362 +++++++++++++++++ crates/icm-cli/src/uninstall/mod.rs | 62 +- crates/icm-cli/src/uninstall/mutate.rs | 27 +- crates/icm-cli/src/uninstall/report.rs | 40 +- crates/icm-cli/tests/uninstall_integration.rs | 62 + 8 files changed, 5086 insertions(+), 44 deletions(-) create mode 100644 crates/icm-cli/src/provider.rs diff --git a/crates/icm-cli/Cargo.toml b/crates/icm-cli/Cargo.toml index 4a5c2ae8..5cfc2ea5 100644 --- a/crates/icm-cli/Cargo.toml +++ b/crates/icm-cli/Cargo.toml @@ -40,7 +40,7 @@ opensearch = ["icm-store/opensearch", "icm-mcp/opensearch"] http-api = ["dep:axum", "dep:tokio"] # Full web dashboard (SvelteKit SPA + auth). Pulls everything `http-api` # does plus the embedded-assets stack. -web = ["http-api", "dep:tower-http", "dep:rust-embed", "dep:mime_guess", "dep:getrandom"] +web = ["http-api", "dep:tower-http", "dep:rust-embed", "dep:mime_guess"] # Benchmark suite (`icm bench*`): ~30 KB of synthetic fixtures + an agent # harness that shells out to `claude`. Dev/CI tooling, not shipped in the # production binary (audit finding). @@ -88,7 +88,7 @@ tokio = { workspace = true, optional = true } tower-http = { workspace = true, optional = true } rust-embed = { workspace = true, optional = true } mime_guess = { workspace = true, optional = true } -getrandom = { workspace = true, optional = true } +getrandom = { workspace = true } serde_json_lenient = { workspace = true } walkdir = { workspace = true } diff --git a/crates/icm-cli/src/install_manifest.rs b/crates/icm-cli/src/install_manifest.rs index 835df243..d6e19951 100644 --- a/crates/icm-cli/src/install_manifest.rs +++ b/crates/icm-cli/src/install_manifest.rs @@ -2,8 +2,8 @@ //! //! Every time `icm init` configures an AI tool, it records the touched //! path here. The manifest persists across invocations: subsequent -//! `icm init` runs update entries in place, and `icm uninstall` (a -//! future PR) consumes it to know exactly what to clean up — without +//! `icm init` runs update entries in place, and `icm uninstall` consumes +//! it to know exactly what to clean up — without //! having to derive the surface from a hard-coded list. //! //! Path: `/install-manifest.json` @@ -11,24 +11,29 @@ //! - macOS: `~/Library/Application Support/icm/install-manifest.json` //! - Windows: `%APPDATA%\icm\icm\data\install-manifest.json` //! -//! Schema is versioned (`schema_version` field) so future migrations -//! stay backwards-compatible. - -#![allow(dead_code)] // consumed by cmd_init in the next commit +//! Schema is versioned (`schema_version`; legacy `version` is accepted) +//! so future migrations stay backwards-compatible. +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -const CURRENT_SCHEMA: u32 = 1; +use crate::provider_journal::{ProviderOwnership, SpliceJournal, SpliceRecord}; + +const CURRENT_SCHEMA: u32 = 2; +const LEGACY_SCHEMA: u32 = 1; /// Top-level install manifest persisted at `/install-manifest.json`. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct InstallManifest { /// Bumped on incompatible field changes. Always read on load; reject /// unknown versions with a clear error so older binaries don't /// silently truncate a newer manifest. + #[serde(alias = "version")] pub schema_version: u32, /// Version of the `icm` binary that wrote / last updated this file. pub icm_version: String, @@ -36,6 +41,25 @@ pub(crate) struct InstallManifest { pub updated_at: String, /// One entry per configuration target. pub entries: Vec, + /// Public, bounded schema-v2 provider operation and ownership journal. + #[serde( + default, + rename = "providerOwnership", + skip_serializing_if = "Option::is_none" + )] + pub provider_ownership: Option, + /// Exact source inverses are private implementation state. They are never + /// emitted into the public install manifest. + #[serde(skip)] + pub provider_splices: Vec, +} + +/// OS-backed lock shared by every current manifest writer. The lock file is +/// intentionally retained: unlinking a locked file permits two processes to +/// lock different inodes under the same name. +pub(crate) struct ManifestLock { + _file: File, + manifest_path: PathBuf, } /// One configuration mutation recorded by init. @@ -79,47 +103,140 @@ impl InstallManifest { /// Empty manifest scaffold. pub fn empty() -> Self { Self { - schema_version: CURRENT_SCHEMA, + schema_version: LEGACY_SCHEMA, icm_version: env!("CARGO_PKG_VERSION").to_string(), updated_at: iso_timestamp(), entries: Vec::new(), + provider_ownership: None, + provider_splices: Vec::new(), } } + /// Migrate to the schema-v2 provider journal and assign a durable 128-bit + /// installation/server identity. Legacy snapshots confer no deletion + /// authority. Returns true when the caller must save before target writes. + pub(crate) fn ensure_provider_ownership(&mut self) -> Result { + if let Some(ownership) = &self.provider_ownership { + ownership.validate()?; + return Ok(false); + } + self.schema_version = CURRENT_SCHEMA; + let installation_id = random_identifier("icm")?; + self.provider_ownership = Some(ProviderOwnership::from_legacy_unproven(installation_id)?); + Ok(true) + } + + pub(crate) fn installation_id(&self) -> Result<&str> { + let id = self + .provider_ownership + .as_ref() + .map(|ownership| ownership.installation_id.as_str()) + .context("install manifest has no installation identity")?; + validate_installation_id(id)?; + Ok(id) + } + /// Read the manifest at `path`, or return an empty one if the file /// does not exist yet. Rejects unknown `schema_version`s loudly. pub fn load(path: &Path) -> Result { - if !path.exists() { - return Ok(Self::empty()); - } - let raw = std::fs::read_to_string(path) - .with_context(|| format!("cannot read manifest at {}", path.display()))?; - let m: InstallManifest = serde_json::from_str(&raw) - .with_context(|| format!("invalid JSON in manifest {}", path.display()))?; - if m.schema_version > CURRENT_SCHEMA { + reject_symlink_components(path)?; + let mut m = match secure_read(path)? { + Some(raw) => serde_json::from_slice(&raw) + .with_context(|| format!("invalid JSON in manifest {}", path.display()))?, + None => Self::empty(), + }; + if !(LEGACY_SCHEMA..=CURRENT_SCHEMA).contains(&m.schema_version) { anyhow::bail!( "install manifest {} was written by a newer icm \ - (schema {} > {}). Upgrade icm or back up the manifest \ + (unsupported schema {}; maximum {}). Upgrade icm or back up the manifest \ before re-running init.", path.display(), m.schema_version, CURRENT_SCHEMA, ); } + if let Some(sidecar) = load_provider_ledger(path)? { + m.schema_version = CURRENT_SCHEMA; + m.provider_ownership = Some(sidecar.provider_ownership); + m.provider_splices = sidecar.records; + } + match (m.schema_version, &m.provider_ownership) { + (LEGACY_SCHEMA, None) => {} + (CURRENT_SCHEMA, Some(ownership)) => ownership.validate()?, + (LEGACY_SCHEMA, Some(_)) => { + anyhow::bail!("schema-1 install manifest cannot contain providerOwnership") + } + (CURRENT_SCHEMA, None) => { + anyhow::bail!("schema-2 install manifest must contain providerOwnership") + } + _ => unreachable!("schema range was validated above"), + } Ok(m) } + /// Acquire the cross-process lock used for a provider config plus its + /// provenance. Callers that hold this across config writes must use + /// `save_locked` to avoid recursively locking the same file. + pub(crate) fn lock(path: &Path) -> Result { + let lock_path = sibling_path(path, ".lock"); + prepare_parent(&lock_path)?; + reject_symlink_components(&lock_path)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = options + .open(&lock_path) + .with_context(|| format!("cannot open manifest lock {}", lock_path.display()))?; + file.lock() + .with_context(|| format!("cannot lock manifest {}", path.display()))?; + reject_symlink_components(&lock_path)?; + Ok(ManifestLock { + _file: file, + manifest_path: path.to_path_buf(), + }) + } + /// Write the manifest, creating the parent directory if needed. /// Bumps `updated_at` and `icm_version` on every save. pub fn save(&mut self, path: &Path) -> Result<()> { + let lock = Self::lock(path)?; + // Only provider.rs mutates provider state. The sidecar wins over a + // stale snapshot held by another long-lived manifest writer. + if let Some(sidecar) = load_provider_ledger(path)? { + self.schema_version = CURRENT_SCHEMA; + self.provider_ownership = Some(sidecar.provider_ownership); + self.provider_splices = sidecar.records; + } + self.save_locked(path, &lock) + } + + /// Save while the caller holds `InstallManifest::lock(path)`. + pub(crate) fn save_locked(&mut self, path: &Path, lock: &ManifestLock) -> Result<()> { + if lock.manifest_path != path { + anyhow::bail!("manifest lock does not cover {}", path.display()); + } + if self.provider_ownership.is_some() { + self.schema_version = CURRENT_SCHEMA; + } self.updated_at = iso_timestamp(); self.icm_version = env!("CARGO_PKG_VERSION").to_string(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("cannot create {}", parent.display()))?; + prepare_parent(path)?; + if let Some(ownership) = &self.provider_ownership { + let sidecar = SpliceJournal { + version: CURRENT_SCHEMA, + provider_ownership: ownership.clone(), + records: self.provider_splices.clone(), + }; + let ledger_json = sidecar.to_json()?; + secure_atomic_write(&sibling_path(path, ".provider-ownership"), &ledger_json) + .with_context(|| format!("cannot write provider ledger for {}", path.display()))?; } let json = serde_json::to_string_pretty(self)?; - crate::uninstall::atomic_write(path, json.as_bytes()) + secure_atomic_write(path, json.as_bytes()) .with_context(|| format!("cannot write manifest {}", path.display()))?; Ok(()) } @@ -168,15 +285,246 @@ impl InstallManifest { pub fn is_empty(&self) -> bool { self.entries.is_empty() + && self + .provider_ownership + .as_ref() + .is_none_or(|ownership| ownership.owned_fragments.is_empty()) + } +} + +fn load_provider_ledger(path: &Path) -> Result> { + let ledger_path = sibling_path(path, ".provider-ownership"); + reject_symlink_components(&ledger_path)?; + let raw = match secure_read(&ledger_path)? { + Some(raw) => raw, + None => return Ok(None), + }; + SpliceJournal::from_json(&raw) + .with_context(|| format!("invalid provider ledger {}", ledger_path.display())) + .map(Some) +} + +fn sibling_path(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(suffix); + path.with_file_name(name) +} + +fn prepare_parent(path: &Path) -> Result<()> { + reject_symlink_components(path)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + reject_symlink_components(parent)?; } + Ok(()) +} + +/// Atomically replace a file without following symlinked targets or using a +/// predictable temporary pathname. `create_new` makes pre-created temp links +/// harmless; the final rename replaces a link rather than following it. +pub(crate) fn secure_atomic_write(path: &Path, content: &[u8]) -> Result<()> { + secure_atomic_write_inner(path, content, None) +} + +/// Same as `secure_atomic_write`, but rechecks the exact expected target bytes +/// after the temporary file is durable and immediately before replacement. +pub(crate) fn secure_atomic_write_if_unchanged( + path: &Path, + expected: Option<&[u8]>, + content: &[u8], +) -> Result<()> { + secure_atomic_write_inner(path, content, Some(expected)) +} + +fn secure_atomic_write_inner( + path: &Path, + content: &[u8], + expected: Option>, +) -> Result<()> { + prepare_parent(path)?; + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let stem = path.file_name().unwrap_or_default().to_string_lossy(); + let existing_permissions = std::fs::symlink_metadata(path) + .ok() + .filter(|metadata| metadata.is_file()) + .map(|metadata| metadata.permissions()); + let mut last_error = None; + for _ in 0..128 { + let nonce = random_128_bit_hex()?; + let temp = parent.join(format!(".{stem}.icm-{nonce}.tmp")); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = match options.open(&temp) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + last_error = Some(error); + continue; + } + Err(error) => { + return Err(error).with_context(|| { + format!("cannot create temporary file for {}", path.display()) + }) + } + }; + let result = (|| -> Result<()> { + file.write_all(content) + .with_context(|| format!("cannot write temporary file for {}", path.display()))?; + if let Some(permissions) = &existing_permissions { + file.set_permissions(permissions.clone()).with_context(|| { + format!("cannot preserve permissions for {}", path.display()) + })?; + } + file.sync_all() + .with_context(|| format!("cannot sync temporary file for {}", path.display()))?; + reject_symlink_components(path)?; + if let Some(expected) = expected { + let current = secure_read(path)?; + if current.as_deref() != expected { + anyhow::bail!("{} changed before atomic replacement", path.display()); + } + } + std::fs::rename(&temp, path) + .with_context(|| format!("cannot replace {}", path.display()))?; + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .with_context(|| format!("cannot sync directory {}", parent.display()))?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + return result; + } + Err(last_error.unwrap_or_else(|| std::io::Error::other("temporary name exhaustion"))) + .with_context(|| format!("cannot create temporary file for {}", path.display())) +} + +fn validate_installation_id(id: &str) -> Result<()> { + if id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + { + anyhow::bail!("install manifest contains an invalid installation identity"); + } + Ok(()) +} + +pub(crate) fn random_identifier(prefix: &str) -> Result { + if prefix.is_empty() || !prefix.bytes().all(|byte| byte.is_ascii_lowercase()) { + anyhow::bail!("random identifier prefix must be lowercase ASCII"); + } + Ok(format!("{prefix}{}", random_128_bit_hex()?)) +} + +fn random_128_bit_hex() -> Result { + let mut bytes = [0_u8; 16]; + getrandom::getrandom(&mut bytes).map_err(|error| { + anyhow::anyhow!("cannot read the operating-system random source ({error})") + })?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +/// Read a regular file without following a final symlink and verify that its +/// pathname still identifies the opened file after the read. +pub(crate) fn secure_read(path: &Path) -> Result>> { + reject_symlink_components(path)?; + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = match options.open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).with_context(|| format!("cannot open {}", path.display())), + }; + let opened = file + .metadata() + .with_context(|| format!("cannot stat {}", path.display()))?; + if !opened.is_file() { + anyhow::bail!("refusing non-regular file {}", path.display()); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .with_context(|| format!("cannot read {}", path.display()))?; + reject_symlink_components(path)?; + let current = + std::fs::metadata(path).with_context(|| format!("cannot restat {}", path.display()))?; + if !same_file(&opened, ¤t) { + anyhow::bail!("{} changed while it was being read", path.display()); + } + Ok(Some(bytes)) +} + +#[cfg(unix)] +fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(not(unix))] +fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { + left.file_type() == right.file_type() + && left.len() == right.len() + && left.modified().ok() == right.modified().ok() +} + +/// Reject symlinks at the target and in every existing parent component. +/// Missing suffix components are allowed so callers can create new config +/// directories after the already-existing prefix has been validated. +pub(crate) fn reject_symlink_components(path: &Path) -> Result<()> { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .context("cannot resolve current directory")? + .join(path) + }; + let mut current = PathBuf::new(); + for component in absolute.components() { + current.push(component); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + anyhow::bail!("refusing symlinked path component {}", current.display()) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("cannot inspect path component {}", current.display()) + }) + } + } + } + Ok(()) } /// Resolve the manifest path from `ProjectDirs`. Falls back to /// `/install-manifest.json` only when ProjectDirs is unavailable /// (stripped sandboxes). pub(crate) fn default_manifest_path() -> PathBuf { + #[cfg(target_os = "macos")] + { + return directories::BaseDirs::new() + .map(|dirs| { + dirs.home_dir() + .join("Library/Application Support/icm/install-manifest.json") + }) + .unwrap_or_else(|| PathBuf::from("install-manifest.json")); + } + #[cfg(not(target_os = "macos"))] directories::ProjectDirs::from("dev", "icm", "icm") - .map(|d| d.data_dir().join("install-manifest.json")) + .map(|dirs| dirs.data_dir().join("install-manifest.json")) .unwrap_or_else(|| PathBuf::from("install-manifest.json")) } @@ -190,7 +538,7 @@ fn sha256_of(path: &Path) -> Result { /// `YYYY-MM-DDTHH:MM:SSZ` UTC. Manifest is JSON so colons are fine /// here, unlike the backup directory name. -fn iso_timestamp() -> String { +pub(crate) fn iso_timestamp() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let secs = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -225,11 +573,36 @@ mod tests { use super::*; #[test] - fn empty_manifest_has_current_schema_and_no_entries() { + fn empty_manifest_has_legacy_schema_and_no_provider_state() { let m = InstallManifest::empty(); - assert_eq!(m.schema_version, CURRENT_SCHEMA); + assert_eq!(m.schema_version, LEGACY_SCHEMA); assert!(m.entries.is_empty()); assert!(!m.icm_version.is_empty()); + assert!(m.provider_ownership.is_none()); + } + + #[test] + fn installation_identity_is_random_128_bit_hex_and_stable() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("install-manifest.json"); + let mut manifest = InstallManifest::empty(); + assert!(manifest.ensure_provider_ownership().unwrap()); + let first = manifest.installation_id().unwrap().to_owned(); + assert_eq!(first.len(), 35); + assert!(first.starts_with("icm")); + assert!(first + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())); + assert!(!manifest.ensure_provider_ownership().unwrap()); + assert_eq!(manifest.installation_id().unwrap(), first); + manifest.save(&path).unwrap(); + assert_eq!( + InstallManifest::load(&path) + .unwrap() + .installation_id() + .unwrap(), + first + ); } #[test] @@ -325,9 +698,151 @@ mod tests { assert!(format!("{err:#}").contains("newer icm")); } + #[test] + fn load_rejects_schema_provider_ownership_mismatch() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("m.json"); + std::fs::write( + &path, + r#"{"schema_version":2,"icm_version":"old","updated_at":"x","entries":[]}"#, + ) + .unwrap(); + assert!(InstallManifest::load(&path) + .unwrap_err() + .to_string() + .contains("schema-2")); + + let mut manifest = InstallManifest::empty(); + manifest.ensure_provider_ownership().unwrap(); + manifest.schema_version = LEGACY_SCHEMA; + std::fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + assert!(InstallManifest::load(&path) + .unwrap_err() + .to_string() + .contains("schema-1")); + } + + #[test] + fn v1_manifest_stays_v1_until_provider_migration() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("m.json"); + std::fs::write( + &path, + r#"{"schema_version":1,"icm_version":"old","updated_at":"x","entries":[]}"#, + ) + .unwrap(); + let mut manifest = InstallManifest::load(&path).unwrap(); + assert!(manifest.provider_ownership.is_none()); + manifest.save(&path).unwrap(); + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(value["schema_version"], LEGACY_SCHEMA); + assert!(value.get("providerOwnership").is_none()); + } + #[test] fn iso_timestamp_known_reference_point() { let (y, mo, d, h, mi, s) = epoch_to_ymdhms(1_700_000_000); assert_eq!((y, mo, d, h, mi, s), (2023, 11, 14, 22, 13, 20)); } + + #[test] + fn provider_ledger_survives_an_already_running_v1_writer() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("install-manifest.json"); + let mut manifest = InstallManifest::empty(); + manifest.ensure_provider_ownership().unwrap(); + manifest.save(&path).unwrap(); + + // A v1 process that loaded before the migration can still replace the + // main file. It cannot know about or truncate the provider ledger. + std::fs::write( + &path, + r#"{"schema_version":1,"icm_version":"old","updated_at":"x","entries":[]}"#, + ) + .unwrap(); + let recovered = InstallManifest::load(&path).unwrap(); + assert_eq!(recovered.schema_version, CURRENT_SCHEMA); + assert!(recovered.provider_ownership.is_some()); + assert_eq!( + recovered.installation_id().unwrap(), + manifest.installation_id().unwrap() + ); + } + + #[test] + fn stale_current_writer_preserves_provider_ledger() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("install-manifest.json"); + let mut stale = InstallManifest::empty(); + let mut provider = InstallManifest::empty(); + provider.ensure_provider_ownership().unwrap(); + provider.save(&path).unwrap(); + + stale.record(ManifestEntry { + path: tmp.path().join("other.json"), + tool: "other".into(), + kind: EntryKind::OwnedFile, + sha256_before: None, + bytes_before: 0, + }); + stale.save(&path).unwrap(); + let loaded = InstallManifest::load(&path).unwrap(); + assert_eq!(loaded.entries.len(), 1); + assert_eq!( + loaded.installation_id().unwrap(), + provider.installation_id().unwrap() + ); + } + + #[test] + fn manifest_lock_excludes_a_second_file_descriptor() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("install-manifest.json"); + let first = InstallManifest::lock(&path).unwrap(); + let second = OpenOptions::new() + .read(true) + .write(true) + .open(sibling_path(&path, ".lock")) + .unwrap(); + assert!(second.try_lock().is_err()); + drop(first); + second.try_lock().unwrap(); + second.unlock().unwrap(); + } + + #[cfg(unix)] + #[test] + fn secure_write_ignores_predictable_temp_symlink_and_rejects_parent_symlink() { + use std::os::unix::fs::symlink; + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("settings.json"); + let victim = tmp.path().join("victim"); + let predictable = tmp.path().join("settings.json.icm-tmp"); + std::fs::write(&victim, "untouched").unwrap(); + symlink(&victim, &predictable).unwrap(); + secure_atomic_write(&target, b"new").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "untouched"); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + secure_atomic_write(&target, b"newer").unwrap(); + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o640 + ); + + let real_parent = tmp.path().join("real-parent"); + let linked_parent = tmp.path().join("linked-parent"); + std::fs::create_dir(&real_parent).unwrap(); + symlink(&real_parent, &linked_parent).unwrap(); + assert!( + secure_atomic_write(&linked_parent.join("config.json"), b"{}") + .unwrap_err() + .to_string() + .contains("symlink") + ); + assert!(!real_parent.join("config.json").exists()); + } } diff --git a/crates/icm-cli/src/main.rs b/crates/icm-cli/src/main.rs index d11adcb3..0a14adb3 100644 --- a/crates/icm-cli/src/main.rs +++ b/crates/icm-cli/src/main.rs @@ -24,6 +24,7 @@ mod learn_tests; // time; the static build links onnxruntime in. #[cfg(feature = "embeddings-dynamic")] mod ort_runtime; +mod provider; mod provider_document; mod provider_journal; mod recall_format; @@ -456,6 +457,9 @@ enum Commands { /// signal (0 = clean). See issue #229. Uninstall(uninstall::UninstallOpts), + /// Resolve provider-specific MCP registration and trust plans. + Provider(provider::ProviderArgs), + /// List files the agent has worked in during recent sessions. /// /// Rows are populated automatically by the PostToolUse hook @@ -1602,6 +1606,9 @@ fn main() -> Result<()> { .init(); let cli = Cli::parse(); + if let Commands::Provider(args) = &cli.command { + return provider::run(args); + } let cfg = config::load_config()?; let embeddings_enabled = cfg.embeddings.enabled && !cli.no_embeddings && std::env::var("ICM_NO_EMBEDDINGS").is_err(); @@ -2042,6 +2049,7 @@ fn main() -> Result<()> { Commands::Doctor => cmd_doctor(&db_path), Commands::Repair { dry_run } => cmd_repair(&db_path, dry_run), Commands::Uninstall(_) => unreachable!("dispatched before open_store"), + Commands::Provider(_) => unreachable!("dispatched before configuration loading"), // `icm embeddings` is dispatched before `open_store` above; this arm // exists only for match exhaustiveness and is unreachable. Commands::Embeddings { .. } => unreachable!("dispatched before open_store"), diff --git a/crates/icm-cli/src/provider.rs b/crates/icm-cli/src/provider.rs new file mode 100644 index 00000000..7aad5a9d --- /dev/null +++ b/crates/icm-cli/src/provider.rs @@ -0,0 +1,4362 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::File; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use clap::{Args, Subcommand, ValueEnum}; +use serde::Serialize; +use serde_json::Value as JsonValue; +use sha2::{Digest, Sha256}; + +use crate::install_manifest::{ + default_manifest_path, random_identifier, secure_atomic_write_if_unchanged, secure_read, + InstallManifest, ManifestLock, +}; +use crate::provider_document::{ + insert as insert_source, remove_owned as remove_source_owned, + try_remove_owned as try_remove_source_owned, validate_and_parse, Insert, ParsedSource, + SourceFormat, SourcePath, +}; +use crate::provider_journal::{ + DocumentOrigin, ExactTransform, OperationAction, OperationPhase, OwnedFragment, OwnershipDelta, + OwnershipKind, ProviderDialect, ProviderId, ProviderOperation, ProviderScope, ProviderSurface, + RequestedOperation, SourcePatch, SpliceRecord, TargetIntent, TransformKind, + MAX_OWNED_FRAGMENTS, +}; + +const TOOLS: [&str; 2] = ["icm_memory_recall", "icm_memory_store"]; + +#[derive(Clone, Copy)] +struct ProviderDefinition { + provider: Provider, + id: ProviderId, + surface: ProviderSurface, + dialect: ProviderDialect, + permission: PermissionShape, + project: &'static [DocumentTemplate], + user: &'static [DocumentTemplate], +} + +#[derive(Clone, Copy)] +enum PermissionShape { + Codex, + Claude, + Cursor, + OpenCode, + Zed, +} + +#[derive(Clone, Copy)] +struct DocumentTemplate { + location: Location, + kind: DocumentKind, +} + +#[derive(Clone, Copy)] +enum Location { + Project(&'static str), + Home(&'static str), + CodexUser, + ClaudeSettingsUser, + OpenCodeUser, + ZedUser, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum DocumentKind { + Codex, + JsonRegistration, + JsonPermissions, + OpenCode, + Zed, +} + +impl DocumentKind { + fn owns_rule(self, rule: &str) -> bool { + match self { + Self::Codex => rule.starts_with("mcp_servers."), + Self::JsonRegistration => rule.starts_with("mcpServers."), + Self::JsonPermissions => !rule.starts_with("mcpServers."), + Self::OpenCode => rule.starts_with("mcp.servers.") || rule.contains("|*|"), + Self::Zed => { + rule.starts_with("context_servers.") + || rule.starts_with("agent.tool_permissions.tools.") + } + } + } + + fn source_format(self, path: &Path) -> SourceFormat { + if self == Self::Codex { + SourceFormat::Toml + } else if path + .extension() + .is_some_and(|extension| extension == "jsonc") + { + SourceFormat::Jsonc + } else { + SourceFormat::Json + } + } + + fn relevant_roots(self) -> &'static [&'static str] { + match self { + Self::Codex => &["mcp_servers"], + Self::JsonRegistration => &["mcpServers"], + Self::JsonPermissions => &["permissions"], + Self::OpenCode => &["mcp", "permissions", "agents"], + Self::Zed => &["context_servers", "agent"], + } + } +} + +const CODEX_PROJECT: &[DocumentTemplate] = &[DocumentTemplate { + location: Location::Project(".codex/config.toml"), + kind: DocumentKind::Codex, +}]; +const CODEX_USER: &[DocumentTemplate] = &[DocumentTemplate { + location: Location::CodexUser, + kind: DocumentKind::Codex, +}]; +const CLAUDE_PROJECT: &[DocumentTemplate] = &[ + DocumentTemplate { + location: Location::Project(".mcp.json"), + kind: DocumentKind::JsonRegistration, + }, + DocumentTemplate { + location: Location::Project(".claude/settings.local.json"), + kind: DocumentKind::JsonPermissions, + }, +]; +const CLAUDE_USER: &[DocumentTemplate] = &[ + DocumentTemplate { + location: Location::Home(".claude.json"), + kind: DocumentKind::JsonRegistration, + }, + DocumentTemplate { + location: Location::ClaudeSettingsUser, + kind: DocumentKind::JsonPermissions, + }, +]; +const CURSOR_PROJECT: &[DocumentTemplate] = &[ + DocumentTemplate { + location: Location::Project(".cursor/mcp.json"), + kind: DocumentKind::JsonRegistration, + }, + DocumentTemplate { + location: Location::Project(".cursor/cli.json"), + kind: DocumentKind::JsonPermissions, + }, +]; +const CURSOR_USER: &[DocumentTemplate] = &[ + DocumentTemplate { + location: Location::Home(".cursor/mcp.json"), + kind: DocumentKind::JsonRegistration, + }, + DocumentTemplate { + location: Location::Home(".cursor/cli-config.json"), + kind: DocumentKind::JsonPermissions, + }, +]; +const OPENCODE_PROJECT: &[DocumentTemplate] = &[DocumentTemplate { + location: Location::Project("opencode.json"), + kind: DocumentKind::OpenCode, +}]; +const OPENCODE_USER: &[DocumentTemplate] = &[DocumentTemplate { + location: Location::OpenCodeUser, + kind: DocumentKind::OpenCode, +}]; +const ZED_PROJECT: &[DocumentTemplate] = &[DocumentTemplate { + location: Location::Project(".zed/settings.json"), + kind: DocumentKind::Zed, +}]; +const ZED_USER: &[DocumentTemplate] = &[DocumentTemplate { + location: Location::ZedUser, + kind: DocumentKind::Zed, +}]; + +/// The source-controlled authorization allowlist. Paths, formats, rule +/// shapes, plan metadata, and lifecycle tests all derive from this table. +const PROVIDER_REGISTRY: &[ProviderDefinition] = &[ + ProviderDefinition { + provider: Provider::Codex, + id: ProviderId::Codex, + surface: ProviderSurface::CombinedRegistrationAndPermission, + dialect: ProviderDialect::CodexTomlV1, + permission: PermissionShape::Codex, + project: CODEX_PROJECT, + user: CODEX_USER, + }, + ProviderDefinition { + provider: Provider::ClaudeCode, + id: ProviderId::ClaudeCode, + surface: ProviderSurface::SplitRegistrationAndPermission, + dialect: ProviderDialect::ClaudeJsonV1, + permission: PermissionShape::Claude, + project: CLAUDE_PROJECT, + user: CLAUDE_USER, + }, + ProviderDefinition { + provider: Provider::Cursor, + id: ProviderId::Cursor, + surface: ProviderSurface::SplitRegistrationAndPermission, + dialect: ProviderDialect::CursorJsonV1, + permission: PermissionShape::Cursor, + project: CURSOR_PROJECT, + user: CURSOR_USER, + }, + ProviderDefinition { + provider: Provider::OpenCode, + id: ProviderId::OpenCode, + surface: ProviderSurface::CombinedRegistrationAndPermission, + dialect: ProviderDialect::OpenCodeJsonV2, + permission: PermissionShape::OpenCode, + project: OPENCODE_PROJECT, + user: OPENCODE_USER, + }, + ProviderDefinition { + provider: Provider::Zed, + id: ProviderId::Zed, + surface: ProviderSurface::CombinedRegistrationAndPermission, + dialect: ProviderDialect::ZedJsonV1, + permission: PermissionShape::Zed, + project: ZED_PROJECT, + user: ZED_USER, + }, +]; + +#[derive(Args, Debug)] +pub(crate) struct ProviderArgs { + #[command(subcommand)] + action: ProviderAction, +} + +#[derive(Subcommand, Debug)] +enum ProviderAction { + /// Register the MCP server and trust exactly the two memory tools. + Trust(MutationArgs), + /// Remove only provider values previously written by ICM. + Strip(MutationArgs), + /// Resolve and validate a provider plan without changing files. + Doctor(TargetArgs), + /// Reconcile one interrupted provider mutation from its durable intent. + Recover(MutationArgs), +} + +#[derive(Args, Debug)] +struct MutationArgs { + #[command(flatten)] + target: TargetArgs, + /// Confirm the provider configuration mutation. + #[arg(long)] + yes: bool, +} + +#[derive(Args, Debug)] +struct TargetArgs { + #[arg(long, value_enum)] + provider: Provider, + #[arg(long, value_enum)] + scope: Scope, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum Provider { + Codex, + ClaudeCode, + Cursor, + #[value(name = "opencode")] + OpenCode, + Zed, +} + +impl Provider { + #[cfg(test)] + fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::ClaudeCode => "claude-code", + Self::Cursor => "cursor", + Self::OpenCode => "opencode", + Self::Zed => "zed", + } + } + + fn definition(self) -> &'static ProviderDefinition { + PROVIDER_REGISTRY + .iter() + .find(|definition| definition.provider == self) + .expect("every typed provider is registered") + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum Scope { + ProjectLocal, + User, +} + +impl Scope { + #[cfg(test)] + const ALL: [Self; 2] = [Self::ProjectLocal, Self::User]; + + fn as_str(self) -> &'static str { + match self { + Self::ProjectLocal => "project-local", + Self::User => "user", + } + } + + fn journal_scope(self) -> ProviderScope { + match self { + Self::ProjectLocal => ProviderScope::ProjectLocal, + Self::User => ProviderScope::User, + } + } +} + +fn provider_from_journal(provider: ProviderId) -> Provider { + match provider { + ProviderId::Codex => Provider::Codex, + ProviderId::ClaudeCode => Provider::ClaudeCode, + ProviderId::Cursor => Provider::Cursor, + ProviderId::OpenCode => Provider::OpenCode, + ProviderId::Zed => Provider::Zed, + } +} + +fn scope_from_journal(scope: ProviderScope) -> Scope { + match scope { + ProviderScope::ProjectLocal => Scope::ProjectLocal, + ProviderScope::User => Scope::User, + } +} + +fn opposite_scope(scope: Scope) -> Scope { + match scope { + Scope::ProjectLocal => Scope::User, + Scope::User => Scope::ProjectLocal, + } +} + +struct ProviderSpec { + definition: &'static ProviderDefinition, + provider: Provider, + scope: Scope, + /// The one or more documents this lifecycle operation may mutate. + documents: Vec, + /// Documents OpenCode merges to produce the effective configuration. + inspection_documents: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DocumentSpec { + path: PathBuf, + kind: DocumentKind, +} + +struct Observation { + path: PathBuf, + rule: String, + exists: bool, + blocked: bool, + expected: JsonValue, +} + +#[derive(Clone)] +enum DocumentValue { + Json(JsonValue), + Toml(toml::Value), +} + +struct PreparedSourceDocument { + document: DocumentSpec, + document_origin: DocumentOrigin, + before: Vec, + after: Vec, + observations: Vec, + patches: BTreeMap, +} + +struct PreparedRemovalDocument { + document: DocumentSpec, + before_state: DocumentOrigin, + before: Vec, + after: Option>, + fragments: Vec, +} + +struct RebasedRecoveryDocument { + document: DocumentSpec, + before_state: DocumentOrigin, + before: Vec, + after: Option>, +} + +struct RecoveryPlan { + documents: Vec, + replacement_paths: BTreeSet, + replacement_splices: Vec, + terminal_phase: OperationPhase, +} + +type RecoveryStates = BTreeMap, String)>; + +#[derive(Clone, Copy)] +enum LifecycleAction { + Apply, + Strip, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum RemovalMode { + TrustOnly, + Uninstall, +} + +#[derive(Default)] +pub(crate) struct StripSummary { + pub(crate) outcomes: Vec, +} + +pub(crate) struct StripPathOutcome { + pub(crate) path: PathBuf, + pub(crate) entries_removed: usize, + pub(crate) deleted: bool, +} + +pub(crate) struct OwnedProviderPath { + pub(crate) path: PathBuf, + pub(crate) entries_owned: usize, +} + +impl StripSummary { + fn add(&mut self, other: Self) { + self.outcomes.extend(other.outcomes); + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ResolvedPlan { + paths: Vec, + surface: ProviderSurface, + scope: &'static str, + dialect: ProviderDialect, + server_id: String, + tool_rules: Vec, + preserved_restrictions: Vec, + causal_blockers: Vec, + ownership_disposition: Vec, +} + +#[derive(Serialize)] +struct OwnershipDisposition { + path: String, + rule: String, + disposition: &'static str, +} + +impl ResolvedPlan { + fn has_blocker(&self) -> bool { + self.ownership_disposition + .iter() + .any(|item| item.disposition == "blocked-existing") + } + + fn has_only_permission_blockers(&self) -> bool { + self.has_blocker() + && self.ownership_disposition.iter().all(|item| { + item.disposition != "blocked-existing" || self.tool_rules.contains(&item.rule) + }) + } +} + +pub(crate) fn run(args: &ProviderArgs) -> Result<()> { + let (plan, fail_after_plan) = match &args.action { + ProviderAction::Doctor(target) => { + let manifest = InstallManifest::load(&default_manifest_path())?; + ( + resolve_plan(target.provider, target.scope, true, &manifest)?, + false, + ) + } + ProviderAction::Trust(args) => { + require_yes(args)?; + let plan = mutate_target(&args.target, LifecycleAction::Apply)?; + let blocked = plan.has_only_permission_blockers(); + (plan, blocked) + } + ProviderAction::Strip(args) => { + require_yes(args)?; + (mutate_target(&args.target, LifecycleAction::Strip)?, false) + } + ProviderAction::Recover(args) => { + require_yes(args)?; + (recover_target(&args.target)?, false) + } + }; + println!("{}", serde_json::to_string(&plan)?); + if fail_after_plan { + bail!("provider configuration contains a conflicting value"); + } + Ok(()) +} + +fn recover_target(target: &TargetArgs) -> Result { + let manifest_path = default_manifest_path(); + let lock = InstallManifest::lock(&manifest_path)?; + let mut manifest = InstallManifest::load(&manifest_path)?; + let server_id = installation_server_id(&manifest)?; + recover_provenance(&mut manifest, target, &server_id, &manifest_path, &lock)?; + resolve_plan(target.provider, target.scope, false, &manifest) +} + +fn mutate_target(target: &TargetArgs, action: LifecycleAction) -> Result { + let manifest_path = default_manifest_path(); + let lock = InstallManifest::lock(&manifest_path)?; + let mut manifest = InstallManifest::load(&manifest_path)?; + if matches!(action, LifecycleAction::Apply) { + manifest.ensure_provider_ownership()?; + } + let server_id = installation_server_id(&manifest)?; + reject_prepared_operations(&manifest)?; + let enforce_resolution = matches!(action, LifecycleAction::Apply); + let plan = resolve_plan(target.provider, target.scope, enforce_resolution, &manifest)?; + let spec = provider_spec(target.provider, target.scope)?; + match action { + LifecycleAction::Apply => { + if plan.has_blocker() { + if plan.has_only_permission_blockers() { + return Ok(plan); + } + bail!("provider configuration contains a conflicting value"); + } + apply_documents(&spec, &server_id, &mut manifest, &manifest_path, &lock)?; + } + LifecycleAction::Strip => { + strip_documents( + target.provider, + target.scope, + &spec.documents, + RemovalMode::TrustOnly, + &mut manifest, + &manifest_path, + &lock, + )?; + } + } + Ok(plan) +} + +fn require_yes(args: &MutationArgs) -> Result<()> { + if !args.yes { + bail!("provider mutations require --yes"); + } + Ok(()) +} + +fn reject_prepared_operations(manifest: &InstallManifest) -> Result<()> { + if manifest + .provider_ownership + .as_ref() + .is_some_and(|ownership| { + ownership + .operations + .iter() + .any(|operation| operation.phase == OperationPhase::Prepared) + }) + { + bail!("provider mutation recovery is required; run `icm provider recover ... --yes`"); + } + Ok(()) +} + +pub(crate) fn owned_paths() -> Result> { + let manifest = InstallManifest::load(&default_manifest_path())?; + Ok(group_owned_paths(&manifest)) +} + +fn group_owned_paths(manifest: &InstallManifest) -> Vec { + let mut paths = BTreeMap::::new(); + for fragment in manifest + .provider_ownership + .as_ref() + .into_iter() + .flat_map(|ownership| &ownership.owned_fragments) + .filter(|fragment| fragment.ownership_kind == OwnershipKind::Owned) + { + *paths + .entry(PathBuf::from(&fragment.canonical_path)) + .or_default() += 1; + } + paths + .into_iter() + .map(|(path, entries_owned)| OwnedProviderPath { + path, + entries_owned, + }) + .collect() +} + +/// Provider-uninstall path used by `icm uninstall`: remove owned trust first, +/// then the exact owned registration. The provider `strip` command passes +/// `TrustOnly` instead and deliberately retains registration. +pub(crate) fn strip_all_owned() -> Result { + let manifest_path = default_manifest_path(); + let lock = InstallManifest::lock(&manifest_path)?; + let mut manifest = InstallManifest::load(&manifest_path)?; + if manifest.provider_ownership.is_none() { + return Ok(StripSummary::default()); + } + reject_prepared_operations(&manifest)?; + let mut targets = BTreeMap::<(Provider, Scope), BTreeSet<(PathBuf, DocumentKind)>>::new(); + for fragment in manifest + .provider_ownership + .as_ref() + .expect("provider ownership was checked") + .owned_fragments + .iter() + .filter(|fragment| fragment.ownership_kind == OwnershipKind::Owned) + { + let provider = provider_from_journal(fragment.provider); + let scope = scope_from_journal(fragment.scope); + let kind = owned_document_kind(provider, &fragment.semantic_selector)?; + targets + .entry((provider, scope)) + .or_default() + .insert((PathBuf::from(&fragment.canonical_path), kind)); + } + let mut summary = StripSummary::default(); + for ((provider, scope), documents) in targets { + let documents = documents + .into_iter() + .map(|(path, kind)| DocumentSpec { path, kind }) + .collect::>(); + let stripped = strip_documents( + provider, + scope, + &documents, + RemovalMode::Uninstall, + &mut manifest, + &manifest_path, + &lock, + )?; + summary.add(stripped); + } + Ok(summary) +} + +fn resolve_plan( + provider: Provider, + scope: Scope, + enforce_resolution: bool, + manifest: &InstallManifest, +) -> Result { + if enforce_resolution && provider == Provider::OpenCode { + reject_opencode_external_overrides(scope)?; + } + let spec = provider_spec(provider, scope)?; + if enforce_resolution { + validate_resolved_paths(&spec)?; + } + let server_id = installation_server_id(manifest)?; + let observations = inspect(&spec, &server_id)?; + let (preserved_restrictions, causal_blockers) = observed_restrictions(&spec, &server_id)?; + + if enforce_resolution { + let opposite = provider_spec(provider, opposite_scope(scope))?; + validate_resolved_paths(&opposite)?; + reject_opposite_scope_binding(&inspect_local(&opposite, &server_id)?)?; + } + + let ownership_disposition = observations + .iter() + .filter(|observation| { + spec.documents + .iter() + .find(|document| document.path == observation.path) + .is_none_or(|document| public_observation(document.kind, observation)) + }) + .map(|observation| -> Result { + let fragment = manifest.provider_ownership.as_ref().and_then(|ownership| { + ownership.owned_fragments.iter().find(|fragment| { + fragment.provider == provider.definition().id + && fragment.scope == scope.journal_scope() + && Path::new(&fragment.canonical_path) == observation.path + && fragment.semantic_selector == observation.rule + }) + }); + Ok(OwnershipDisposition { + path: utf8_path(&observation.path)?.to_owned(), + rule: observation.rule.clone(), + disposition: if observation.blocked { + "blocked-existing" + } else if fragment.is_some_and(|fragment| { + fragment.ownership_kind == OwnershipKind::Removed && !observation.exists + }) { + "already-removed" + } else if fragment.is_some_and(|fragment| { + fragment.ownership_kind == OwnershipKind::Owned && observation.exists + }) { + "owned-existing" + } else if observation.exists { + "preexisting-adopted" + } else { + "new-owned" + }, + }) + }) + .collect::>>()?; + + Ok(ResolvedPlan { + paths: spec + .documents + .iter() + .map(|document| utf8_path(&document.path).map(str::to_owned)) + .collect::>>()?, + surface: spec.definition.surface, + scope: spec.scope.as_str(), + dialect: spec.definition.dialect, + server_id: server_id.clone(), + tool_rules: tool_rules(provider, &server_id), + preserved_restrictions: preserved_restrictions.into_iter().collect(), + causal_blockers: causal_blockers.into_iter().collect(), + ownership_disposition, + }) +} + +fn reject_opposite_scope_binding(observations: &[Observation]) -> Result<()> { + if observations + .first() + .is_some_and(|registration| registration.exists || registration.blocked) + || observations.iter().skip(1).any(|item| item.blocked) + { + bail!("the same provider identity is active in the opposite scope"); + } + Ok(()) +} + +fn provider_spec(provider: Provider, scope: Scope) -> Result { + let definition = provider.definition(); + let home = directories::BaseDirs::new() + .context("cannot resolve the user home directory")? + .home_dir() + .to_path_buf(); + let cwd = std::env::current_dir().context("cannot resolve current project directory")?; + let xdg_config = env_path("XDG_CONFIG_HOME").unwrap_or_else(|| home.join(".config")); + let templates = match scope { + Scope::ProjectLocal => definition.project, + Scope::User => definition.user, + }; + let mut documents = templates + .iter() + .map(|template| -> Result { + let path = match template.location { + Location::Project(relative) => project_path(provider, relative, &cwd)?, + Location::Home(relative) => home.join(relative), + Location::CodexUser => std::env::var_os("CODEX_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")) + .join("config.toml"), + Location::ClaudeSettingsUser => std::env::var_os("CLAUDE_CONFIG_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".claude")) + .join("settings.json"), + Location::OpenCodeUser => xdg_config.join("opencode/opencode.json"), + Location::ZedUser => platform_config_dir(&home, &xdg_config, "zed", "Zed", "Zed") + .join("settings.json"), + }; + let path = if provider == Provider::OpenCode { + path + } else { + provider_config_at(provider, &path)? + }; + Ok(DocumentSpec { + path, + kind: template.kind, + }) + }) + .collect::>>()?; + let mut inspection_documents = documents.clone(); + if provider == Provider::OpenCode { + let global = xdg_config.join("opencode/opencode.json"); + (documents, inspection_documents) = opencode_documents(scope, &cwd, &global)?; + } + if documents + .iter() + .any(|document| !document.path.is_absolute()) + { + bail!("provider configuration roots must be absolute"); + } + Ok(ProviderSpec { + definition, + provider, + scope, + documents, + inspection_documents, + }) +} + +fn opencode_documents( + scope: Scope, + cwd: &Path, + global_default: &Path, +) -> Result<(Vec, Vec)> { + if scope == Scope::User { + let global = opencode_config_at(global_default)?; + let target = global.unwrap_or_else(|| global_default.to_path_buf()); + let document = DocumentSpec { + path: target, + kind: DocumentKind::OpenCode, + }; + return Ok((vec![document.clone()], vec![document])); + } + + let directories = opencode_project_directories(cwd)?; + let mut project = Vec::new(); + for directory in &directories { + if let Some(path) = opencode_config_at(&directory.join("opencode.json"))? { + project.push(path); + } + } + for directory in &directories { + if let Some(path) = opencode_config_at(&directory.join(".opencode/opencode.json"))? { + project.push(path); + } + } + + let target = project + .last() + .cloned() + .unwrap_or_else(|| cwd.join("opencode.json")); + let mutation = DocumentSpec { + path: target.clone(), + kind: DocumentKind::OpenCode, + }; + let global = + opencode_config_at(global_default)?.unwrap_or_else(|| global_default.to_path_buf()); + let mut inspection = std::iter::once(global) + .chain(project) + .map(|path| DocumentSpec { + path, + kind: DocumentKind::OpenCode, + }) + .collect::>(); + if !inspection.iter().any(|document| document.path == target) { + inspection.push(mutation.clone()); + } + Ok((vec![mutation], inspection)) +} + +fn opencode_project_directories(cwd: &Path) -> Result> { + let root = project_root(cwd)?; + let mut directories = cwd + .ancestors() + .take_while(|directory| *directory != root) + .map(Path::to_path_buf) + .collect::>(); + directories.push(root); + directories.reverse(); + Ok(directories) +} + +fn project_path(provider: Provider, relative: &str, cwd: &Path) -> Result { + let root = project_root(cwd)?; + let root = if provider == Provider::ClaudeCode && relative == ".claude/settings.local.json" { + main_checkout_root(&root)? + } else { + root + }; + Ok(root.join(relative)) +} + +fn project_root(cwd: &Path) -> Result { + for directory in cwd.ancestors() { + if path_present(&directory.join(".git"))? { + return Ok(directory.to_path_buf()); + } + } + Ok(cwd.to_path_buf()) +} + +fn main_checkout_root(worktree_root: &Path) -> Result { + let marker = worktree_root.join(".git"); + if !path_present(&marker)? { + return Ok(worktree_root.to_path_buf()); + } + let metadata = std::fs::symlink_metadata(&marker) + .with_context(|| format!("cannot inspect {}", marker.display()))?; + if metadata.file_type().is_dir() { + return Ok(worktree_root.to_path_buf()); + } + let Some(marker) = secure_read(&marker)? else { + return Ok(worktree_root.to_path_buf()); + }; + let marker = std::str::from_utf8(&marker).context("worktree .git file is not UTF-8")?; + let Some(git_dir) = marker.trim().strip_prefix("gitdir:") else { + return Ok(worktree_root.to_path_buf()); + }; + let git_dir = PathBuf::from(git_dir.trim()); + let git_dir = if git_dir.is_absolute() { + git_dir + } else { + worktree_root.join(git_dir) + }; + let Some(common_dir) = secure_read(&git_dir.join("commondir"))? else { + return Ok(worktree_root.to_path_buf()); + }; + let common_dir = std::str::from_utf8(&common_dir).context("Git commondir is not UTF-8")?; + let common_dir = git_dir + .join(common_dir.trim()) + .canonicalize() + .context("cannot resolve the main checkout Git directory")?; + if common_dir.file_name().is_some_and(|name| name == ".git") { + return common_dir + .parent() + .map(Path::to_path_buf) + .context("main checkout Git directory has no parent"); + } + bail!("linked worktree commondir is not a main checkout .git directory") +} + +fn opencode_config_at(json: &Path) -> Result> { + let jsonc = json.with_extension("jsonc"); + sole_config_candidate(json, &jsonc) +} + +fn provider_config_at(provider: Provider, primary: &Path) -> Result { + let alternate = if provider == Provider::Codex { + primary.with_file_name("config.local.toml") + } else { + primary.with_extension("jsonc") + }; + Ok(sole_config_candidate(primary, &alternate)?.unwrap_or_else(|| primary.to_path_buf())) +} + +fn sole_config_candidate(primary: &Path, alternate: &Path) -> Result> { + match (path_present(primary)?, path_present(alternate)?) { + (true, true) => bail!( + "ambiguous provider configuration: both {} and {} exist", + primary.display(), + alternate.display() + ), + (true, false) => Ok(Some(primary.to_path_buf())), + (false, true) => Ok(Some(alternate.to_path_buf())), + (false, false) => Ok(None), + } +} + +fn path_present(path: &Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| format!("cannot inspect {}", path.display())), + } +} + +fn reject_opencode_external_overrides(scope: Scope) -> Result<()> { + for key in [ + "OPENCODE_CONFIG", + "OPENCODE_CONFIG_DIR", + "OPENCODE_CONFIG_CONTENT", + "OPENCODE_PERMISSION", + ] { + if std::env::var_os(key).is_some_and(|value| !value.is_empty()) { + bail!("cannot verify OpenCode trust while {key} supplies an external override"); + } + } + if scope == Scope::ProjectLocal + && std::env::var_os("OPENCODE_DISABLE_PROJECT_CONFIG").is_some_and(|value| { + matches!( + value.to_string_lossy().to_ascii_lowercase().as_str(), + "1" | "true" + ) + }) + { + bail!("OpenCode project configuration is disabled by the environment"); + } + for path in managed_opencode_paths() { + if path_present(&path)? { + bail!( + "cannot verify OpenCode trust while managed configuration exists at {}", + path.display() + ); + } + } + Ok(()) +} + +fn managed_opencode_paths() -> Vec { + #[cfg(target_os = "linux")] + return vec![ + PathBuf::from("/etc/opencode/opencode.json"), + PathBuf::from("/etc/opencode/opencode.jsonc"), + ]; + #[cfg(target_os = "macos")] + return vec![ + PathBuf::from("/Library/Application Support/opencode/opencode.json"), + PathBuf::from("/Library/Application Support/opencode/opencode.jsonc"), + PathBuf::from("/Library/Managed Preferences/ai.opencode.managed.plist"), + ]; + #[cfg(target_os = "windows")] + return env_path("ProgramData") + .into_iter() + .flat_map(|root| { + ["opencode.json", "opencode.jsonc"].map(|file| root.join("opencode").join(file)) + }) + .collect(); + #[allow(unreachable_code)] + Vec::new() +} + +#[cfg(test)] +fn document(path: PathBuf, kind: DocumentKind) -> DocumentSpec { + DocumentSpec { path, kind } +} + +fn env_path(key: &str) -> Option { + std::env::var_os(key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn utf8_path(path: &Path) -> Result<&str> { + path.to_str() + .context("provider paths and executable names must be valid UTF-8") +} + +fn platform_config_dir( + home: &Path, + xdg_config: &Path, + linux: &str, + macos: &str, + windows: &str, +) -> PathBuf { + #[cfg(target_os = "macos")] + return home.join("Library/Application Support").join(macos); + #[cfg(target_os = "windows")] + return env_path("APPDATA") + .unwrap_or_else(|| home.join("AppData/Roaming")) + .join(windows); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = (home, macos, windows); + xdg_config.join(linux) + } +} + +fn installation_server_id(manifest: &InstallManifest) -> Result { + Ok(manifest.installation_id()?.to_owned()) +} + +fn validate_resolved_paths(spec: &ProviderSpec) -> Result<()> { + for document in &spec.documents { + if spec.provider == Provider::OpenCode + && path_present(&document.path)? + && std::fs::metadata(&document.path) + .with_context(|| format!("cannot inspect {}", document.path.display()))? + .permissions() + .readonly() + { + bail!( + "highest-precedence OpenCode configuration is read-only: {}", + document.path.display() + ); + } + } + Ok(()) +} + +fn inspect(spec: &ProviderSpec, server_id: &str) -> Result> { + let mut observations = inspect_local(spec, server_id)?; + if spec.provider == Provider::OpenCode { + let effective = inspect_effective(spec, server_id)?; + for observation in &mut observations { + if let Some(effective) = effective + .iter() + .find(|effective| effective.rule == observation.rule) + { + observation.blocked |= effective.blocked; + } + } + } + Ok(observations) +} + +fn inspect_local(spec: &ProviderSpec, server_id: &str) -> Result> { + let mut observations = Vec::new(); + for document in &spec.documents { + let (_, value) = read_document(document)?; + observations.extend(inspect_document( + spec.provider, + document, + &value, + server_id, + )?); + } + Ok(observations) +} + +fn inspect_effective(spec: &ProviderSpec, server_id: &str) -> Result> { + if spec.provider != Provider::OpenCode { + return inspect_local(spec, server_id); + } + let target = spec + .documents + .first() + .context("OpenCode registry has no mutation document")?; + let effective = effective_opencode_value(spec)?; + inspect_document( + spec.provider, + target, + &DocumentValue::Json(effective), + server_id, + ) +} + +fn observed_restrictions( + spec: &ProviderSpec, + server_id: &str, +) -> Result<(BTreeSet, BTreeSet)> { + let mut preserved = BTreeSet::new(); + let mut causal = BTreeSet::new(); + let documents = if spec.provider == Provider::OpenCode { + vec![DocumentValue::Json(effective_opencode_value(spec)?)] + } else { + spec.documents + .iter() + .map(|document| read_document(document).map(|(_, value)| value)) + .collect::>>()? + }; + for value in &documents { + match (spec.provider, value) { + (Provider::Codex, DocumentValue::Toml(value)) => { + let root = value.as_table().context("Codex root is not a table")?; + for (id, server) in optional_table(root.get("mcp_servers"), "mcp_servers")? { + let Some(server) = server.as_table() else { + continue; + }; + for tool in string_set(server.get("disabled_tools"))? { + let rendered = format!("mcp_servers.{id}.disabled_tools:{tool}"); + if id == server_id && TOOLS.contains(&tool.as_str()) { + causal.insert(rendered); + } else { + preserved.insert(rendered); + } + } + let Some(tools) = server.get("tools").and_then(toml::Value::as_table) else { + continue; + }; + for (tool, rule) in tools { + let Some(mode) = rule + .as_table() + .and_then(|rule| rule.get("approval_mode")) + .and_then(toml::Value::as_str) + else { + continue; + }; + if mode == "approve" { + continue; + } + let rendered = + format!("mcp_servers.{id}.tools.{tool}.approval_mode={mode}"); + if id == server_id && TOOLS.contains(&tool.as_str()) { + causal.insert(rendered); + } else { + preserved.insert(rendered); + } + } + } + } + (Provider::ClaudeCode | Provider::Cursor, DocumentValue::Json(value)) => { + let root = value.as_object().expect("validated JSON root"); + let permissions = root.get("permissions").and_then(JsonValue::as_object); + for field in ["deny", "ask"] { + for pattern in json_string_set( + permissions.and_then(|permissions| permissions.get(field)), + &format!("permissions.{field}"), + )? { + let rendered = format!("permissions.{field}:{pattern}"); + if TOOLS.iter().any(|tool| { + permission_rule(spec.provider, server_id, tool).is_ok_and(|rule| { + permission_matches(spec.provider, &pattern, &rule) + }) + }) { + causal.insert(rendered); + } else { + preserved.insert(rendered); + } + } + } + if spec.provider == Provider::Cursor { + preserved.insert("ideTrust=prompt-only".to_owned()); + } + } + (Provider::OpenCode, DocumentValue::Json(value)) => { + let root = value.as_object().expect("validated JSON root"); + if let Some(servers) = root + .get("mcp") + .and_then(JsonValue::as_object) + .and_then(|mcp| mcp.get("servers")) + .and_then(JsonValue::as_object) + { + for (id, server) in servers { + if server.as_object().and_then(|server| server.get("disabled")) + == Some(&JsonValue::Bool(true)) + { + let rendered = format!("mcp.servers.{id}.disabled=true"); + if id == server_id { + causal.insert(rendered); + } else { + preserved.insert(rendered); + } + } + } + } + let rules = + opencode_permission_rules(root.get("permissions"), "OpenCode permissions")?; + let managed_actions = TOOLS.map(|tool| format!("{server_id}_{tool}")); + let effective = managed_actions + .iter() + .filter_map(|managed| { + rules.iter().rposition(|rule| { + glob_matches( + rule["action"].as_str().expect("validated action"), + managed, + ) && glob_matches( + rule["resource"].as_str().expect("validated resource"), + "*", + ) + }) + }) + .collect::>(); + for (index, rule) in rules.into_iter().enumerate() { + let action = rule["action"].as_str().expect("validated action"); + let resource = rule["resource"].as_str().expect("validated resource"); + let effect = rule["effect"].as_str().expect("validated effect"); + let rendered = format!("permissions:{action}|{resource}|{effect}"); + let matches_managed = managed_actions.iter().any(|managed| { + glob_matches(action, managed) && glob_matches(resource, "*") + }); + if matches_managed && effective.contains(&index) && effect != "allow" { + causal.insert(rendered); + } else if !matches_managed && effect != "allow" { + preserved.insert(rendered); + } + } + preserved.insert("last-matching-rule-wins".to_owned()); + } + (Provider::Zed, DocumentValue::Json(value)) => { + let tools = value + .pointer("/agent/tool_permissions/tools") + .and_then(JsonValue::as_object); + if let Some(default) = value + .pointer("/agent/tool_permissions/default") + .and_then(JsonValue::as_str) + { + preserved.insert(format!("agent.tool_permissions.default={default}")); + } + for (key, rule) in tools.into_iter().flatten() { + let Some(rule) = rule.as_object() else { + continue; + }; + for field in ["always_deny", "always_confirm"] { + if let Some(values) = + rule.get(field).filter(|value| nonempty_json_array(value)) + { + let rendered = + format!("agent.tool_permissions.tools.{key}.{field}={values}"); + if TOOLS + .iter() + .any(|tool| key == &format!("mcp:{server_id}:{tool}")) + { + causal.insert(rendered); + } else { + preserved.insert(rendered); + } + } + } + let Some(default) = rule.get("default").and_then(JsonValue::as_str) else { + continue; + }; + if default == "allow" { + continue; + } + let rendered = format!("agent.tool_permissions.tools.{key}.default={default}"); + if TOOLS + .iter() + .any(|tool| key == &format!("mcp:{server_id}:{tool}")) + { + causal.insert(rendered); + } else { + preserved.insert(rendered); + } + } + } + _ => {} + } + } + Ok((preserved, causal)) +} + +fn effective_opencode_value(spec: &ProviderSpec) -> Result { + let mut effective = serde_json::json!({}); + for document in &spec.inspection_documents { + let (_, value) = read_document(document)?; + let DocumentValue::Json(value) = value else { + bail!("OpenCode registry paired a document with the wrong format"); + }; + merge_json(&mut effective, value); + } + Ok(effective) +} + +fn merge_json(target: &mut JsonValue, source: JsonValue) { + match (target, source) { + (JsonValue::Object(target), JsonValue::Object(source)) => { + for (key, value) in source { + match target.get_mut(&key) { + Some(target) => merge_json(target, value), + None => { + target.insert(key, value); + } + } + } + } + (target, source) => *target = source, + } +} + +fn inspect_document( + provider: Provider, + document: &DocumentSpec, + value: &DocumentValue, + server_id: &str, +) -> Result> { + match (document.kind, value) { + (DocumentKind::Codex, DocumentValue::Toml(value)) => { + inspect_codex_value(&document.path, value, server_id) + } + (DocumentKind::JsonRegistration, DocumentValue::Json(value)) => { + inspect_json_registration(provider, &document.path, value, server_id) + } + (DocumentKind::JsonPermissions, DocumentValue::Json(value)) => { + inspect_json_permissions(provider, &document.path, value, server_id) + } + (DocumentKind::OpenCode, DocumentValue::Json(value)) => { + inspect_opencode(&document.path, value, server_id) + } + (DocumentKind::Zed, DocumentValue::Json(value)) => { + inspect_zed(&document.path, value, server_id) + } + _ => bail!("provider registry paired a document with the wrong format"), + } +} + +fn observation( + path: &Path, + rule: String, + actual: Option, + expected: JsonValue, + blocked: bool, +) -> Observation { + let exists = actual.as_ref() == Some(&expected); + let blocked = blocked || actual.as_ref().is_some_and(|actual| actual != &expected); + Observation { + path: path.to_path_buf(), + rule, + exists, + blocked, + expected, + } +} + +fn inspect_json_registration( + provider: Provider, + path: &Path, + value: &JsonValue, + server_id: &str, +) -> Result> { + let registration_root = value.as_object().expect("validated JSON root"); + reject_json_keys( + registration_root, + &["mcp", "context_servers", "permission", "agent"], + )?; + let servers = match registration_root.get("mcpServers") { + Some(value) => Some(value.as_object().context("mcpServers is not an object")?), + None => None, + }; + reject_normalization_collisions( + servers + .into_iter() + .flat_map(|servers| servers.keys().map(String::as_str)), + )?; + let actual = servers.and_then(|servers| servers.get(server_id)).cloned(); + let existing_registration = actual.as_ref(); + if let Some(value) = existing_registration { + value + .as_object() + .context("existing provider registration is not an object")?; + } + Ok(vec![observation( + path, + format!("mcpServers.{server_id}"), + actual, + json_registration(provider)?, + false, + )]) +} + +fn inspect_json_permissions( + provider: Provider, + path: &Path, + value: &JsonValue, + server_id: &str, +) -> Result> { + let permission_root = value.as_object().expect("validated JSON root"); + reject_json_keys( + permission_root, + &[ + "permission", + "mcp", + "mcpServers", + "context_servers", + "agent", + ], + )?; + let permissions = match permission_root.get("permissions") { + Some(value) => Some(value.as_object().context("permissions is not an object")?), + None => None, + }; + let allow = json_string_set( + permissions.and_then(|permissions| permissions.get("allow")), + "permissions.allow", + )?; + let deny = json_string_set( + permissions.and_then(|permissions| permissions.get("deny")), + "permissions.deny", + )?; + let ask = json_string_set( + permissions.and_then(|permissions| permissions.get("ask")), + "permissions.ask", + )?; + let mut observations = Vec::new(); + for tool in TOOLS { + let rule = permission_rule(provider, server_id, tool)?; + let blocked = deny + .iter() + .chain(ask.iter()) + .any(|pattern| permission_matches(provider, pattern, &rule)); + let actual = allow + .contains(&rule) + .then(|| JsonValue::String(rule.clone())); + observations.push(observation( + path, + rule.clone(), + actual, + JsonValue::String(rule), + blocked, + )); + } + Ok(observations) +} + +fn permission_rule(provider: Provider, server_id: &str, tool: &str) -> Result { + match provider.definition().permission { + PermissionShape::Claude => Ok(format!("mcp__{server_id}__{tool}")), + PermissionShape::Cursor => Ok(format!("Mcp({server_id}:{tool})")), + _ => bail!("provider does not use split JSON permissions"), + } +} + +fn permission_matches(provider: Provider, pattern: &str, rule: &str) -> bool { + match provider.definition().permission { + PermissionShape::Claude => { + pattern == rule + || rule + .strip_prefix(pattern) + .is_some_and(|suffix| suffix.starts_with("__")) + || glob_matches(pattern, rule) + } + PermissionShape::Cursor => glob_matches(pattern, rule), + _ => false, + } +} + +fn inspect_opencode(path: &Path, value: &JsonValue, server_id: &str) -> Result> { + let root = value.as_object().expect("validated JSON root"); + reject_json_keys( + root, + &["permission", "mcpServers", "context_servers", "agent"], + )?; + let mcp = match root.get("mcp") { + Some(value) => Some(value.as_object().context("OpenCode mcp is not an object")?), + None => None, + }; + let servers = match mcp.and_then(|mcp| mcp.get("servers")) { + Some(value) => Some( + value + .as_object() + .context("OpenCode mcp.servers is not an object")?, + ), + None => None, + }; + reject_normalization_collisions( + servers + .into_iter() + .flat_map(|servers| servers.keys().map(String::as_str)), + )?; + let actual_registration = servers.and_then(|servers| servers.get(server_id)).cloned(); + let existing_registration = actual_registration.as_ref(); + if let Some(value) = existing_registration { + value + .as_object() + .context("existing OpenCode provider registration is not an object")?; + } + let expected_registration = opencode_registration()?; + let permissions = opencode_permission_rules(root.get("permissions"), "OpenCode permissions")?; + let agent_permissions = match root.get("agents") { + Some(value) => value + .as_object() + .context("OpenCode agents is not an object")? + .iter() + .map(|(id, agent)| { + let agent = agent + .as_object() + .with_context(|| format!("OpenCode agent {id:?} is not an object"))?; + opencode_permission_rules( + agent.get("permissions"), + &format!("OpenCode agent {id:?} permissions"), + ) + }) + .collect::>>()?, + None => Vec::new(), + }; + let mut observations = vec![observation( + path, + format!("mcp.servers.{server_id}"), + actual_registration, + expected_registration, + false, + )]; + for tool in TOOLS { + let action = format!("{server_id}_{tool}"); + let effect = opencode_permission_effect(&permissions, &action); + let agent_blocked = agent_permissions.iter().any(|rules| { + opencode_permission_effect(rules, &action).is_some_and(|effect| effect != "allow") + }); + let exact = permissions.iter().find(|rule| { + rule["action"].as_str() == Some(action.as_str()) + && rule["resource"].as_str() == Some("*") + && rule["effect"].as_str() == Some("allow") + }); + let expected = serde_json::json!({ + "action": action.clone(), + "resource": "*", + "effect": "allow" + }); + observations.push(observation( + path, + format!("{action}|*|allow"), + exact.map(|rule| JsonValue::Object((*rule).clone())), + expected, + effect.is_some_and(|effect| effect != "allow") || agent_blocked, + )); + } + Ok(observations) +} + +fn opencode_permission_rules<'a>( + value: Option<&'a JsonValue>, + field: &str, +) -> Result>> { + value + .map(|value| { + value + .as_array() + .with_context(|| format!("{field} must be a v2 rule array"))? + .iter() + .map(|rule| { + let rule = rule + .as_object() + .with_context(|| format!("{field} rule is not an object"))?; + for key in ["action", "resource", "effect"] { + rule.get(key) + .and_then(JsonValue::as_str) + .with_context(|| format!("{field} rule lacks string {key}"))?; + } + Ok(rule) + }) + .collect() + }) + .unwrap_or_else(|| Ok(Vec::new())) +} + +fn opencode_permission_effect<'a>( + rules: &'a [&'a serde_json::Map], + action: &str, +) -> Option<&'a str> { + rules + .iter() + .rev() + .find(|rule| { + glob_matches(rule["action"].as_str().expect("validated action"), action) + && glob_matches(rule["resource"].as_str().expect("validated resource"), "*") + }) + .and_then(|rule| rule["effect"].as_str()) +} + +fn inspect_zed(path: &Path, value: &JsonValue, server_id: &str) -> Result> { + let root = value.as_object().expect("validated JSON root"); + reject_json_keys(root, &["permissions", "permission", "mcpServers", "mcp"])?; + let servers = match root.get("context_servers") { + Some(value) => Some( + value + .as_object() + .context("Zed context_servers is not an object")?, + ), + None => None, + }; + reject_normalization_collisions( + servers + .into_iter() + .flat_map(|servers| servers.keys().map(String::as_str)), + )?; + let actual_registration = servers.and_then(|servers| servers.get(server_id)).cloned(); + let existing_registration = actual_registration.as_ref(); + if let Some(value) = existing_registration { + value + .as_object() + .context("existing Zed provider registration is not an object")?; + } + let expected_registration = json_registration(Provider::Zed)?; + let agent = match root.get("agent") { + Some(value) => Some(value.as_object().context("Zed agent is not an object")?), + None => None, + }; + let permissions = match agent.and_then(|agent| agent.get("tool_permissions")) { + Some(value) => Some( + value + .as_object() + .context("Zed agent.tool_permissions is not an object")?, + ), + None => None, + }; + let tools = match permissions.and_then(|permissions| permissions.get("tools")) { + Some(value) => Some( + value + .as_object() + .context("Zed agent.tool_permissions.tools is not an object")?, + ), + None => None, + }; + let mut observations = vec![observation( + path, + format!("context_servers.{server_id}"), + actual_registration, + expected_registration, + false, + )]; + for tool in TOOLS { + let key = format!("mcp:{server_id}:{tool}"); + let rule = tools + .and_then(|tools| tools.get(&key)) + .map(|rule| { + rule.as_object() + .context("Zed tool permission is not an object") + }) + .transpose()?; + let default = rule.and_then(|rule| rule.get("default")).cloned(); + let higher_precedence = rule.is_some_and(|rule| { + ["always_deny", "always_confirm"] + .iter() + .any(|field| rule.get(*field).is_some_and(nonempty_json_array)) + }); + observations.push(observation( + path, + format!("agent.tool_permissions.tools.{key}.default=allow"), + default, + JsonValue::String("allow".to_owned()), + higher_precedence, + )); + } + Ok(observations) +} + +fn nonempty_json_array(value: &JsonValue) -> bool { + value.as_array().is_none_or(|values| !values.is_empty()) +} + +fn glob_matches(pattern: &str, value: &str) -> bool { + let pattern = pattern.chars().collect::>(); + let value = value.chars().collect::>(); + let (mut pattern_at, mut value_at, mut star, mut retry_at) = (0, 0, None, 0); + while value_at < value.len() { + if pattern + .get(pattern_at) + .is_some_and(|token| *token == '?' || glob_chars_equal(*token, value[value_at])) + { + pattern_at += 1; + value_at += 1; + } else if pattern.get(pattern_at) == Some(&'*') { + star = Some(pattern_at); + pattern_at += 1; + retry_at = value_at; + } else if let Some(star_at) = star { + pattern_at = star_at + 1; + retry_at += 1; + value_at = retry_at; + } else { + return false; + } + } + pattern[pattern_at..].iter().all(|token| *token == '*') +} + +fn glob_chars_equal(left: char, right: char) -> bool { + if cfg!(windows) { + left.eq_ignore_ascii_case(&right) + } else { + left == right + } +} + +fn reject_json_keys(root: &serde_json::Map, keys: &[&str]) -> Result<()> { + if keys.iter().any(|key| root.contains_key(*key)) { + bail!("provider configuration uses an unknown or mixed dialect"); + } + Ok(()) +} + +fn json_string_set(value: Option<&JsonValue>, field: &str) -> Result> { + match value { + None => Ok(BTreeSet::new()), + Some(value) => value + .as_array() + .with_context(|| format!("{field} is not an array"))? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .with_context(|| format!("{field} contains a non-string value")) + }) + .collect(), + } +} + +fn read_document(document: &DocumentSpec) -> Result<(Vec, DocumentValue)> { + let bytes = read_provider_document(&document.path)?; + let value = parse_document_source(document, &bytes)?; + Ok((bytes, value)) +} + +fn read_document_snapshot( + document: &DocumentSpec, +) -> Result<(DocumentOrigin, Vec, DocumentValue)> { + let (state, bytes) = read_provider_document_snapshot(&document.path)?; + let value = parse_document_source(document, &bytes)?; + Ok((state, bytes, value)) +} + +fn parse_document_source(document: &DocumentSpec, bytes: &[u8]) -> Result { + match validate_and_parse( + document.kind.source_format(&document.path), + bytes, + document.kind.relevant_roots(), + ) + .with_context(|| format!("invalid provider config {}", document.path.display()))? + { + ParsedSource::Json(value) => Ok(DocumentValue::Json(value)), + ParsedSource::Toml(value) => Ok(DocumentValue::Toml(value)), + } +} + +fn inspect_codex_value( + path: &Path, + value: &toml::Value, + server_id: &str, +) -> Result> { + let root = value + .as_table() + .with_context(|| format!("provider TOML root is not a table in {}", path.display()))?; + if [ + "mcpServers", + "mcp", + "context_servers", + "permissions", + "permission", + ] + .iter() + .any(|key| root.contains_key(*key)) + { + bail!( + "{} uses an unknown or mixed provider dialect", + path.display() + ); + } + let servers = optional_table(root.get("mcp_servers"), "mcp_servers")?; + reject_normalization_collisions(servers.keys().map(String::as_str))?; + let server = servers + .get(server_id) + .map(|value| { + value + .as_table() + .context("existing Codex provider registration is not a table") + }) + .transpose()?; + let expected_registration = codex_registration()?; + let expected_registration_table = expected_registration + .as_table() + .expect("canonical Codex registration is a table"); + let registration_blocked = server.is_some_and(|server| { + server.get("command") != expected_registration_table.get("command") + || server.get("args") != expected_registration_table.get("args") + || server.keys().any(|key| { + !matches!( + key.as_str(), + "command" | "args" | "enabled_tools" | "disabled_tools" | "tools" + ) + }) + }); + let mut observations = vec![Observation { + path: path.to_path_buf(), + rule: format!("mcp_servers.{server_id}"), + exists: server.is_some(), + blocked: registration_blocked, + expected: serde_json::to_value(expected_registration)?, + }]; + + let enabled = server.and_then(|table| table.get("enabled_tools")); + let (enabled_exists, enabled_blocked) = exact_tool_set(enabled)?; + let disabled = string_set(server.and_then(|table| table.get("disabled_tools")))?; + observations.push(Observation { + path: path.to_path_buf(), + rule: format!("mcp_servers.{server_id}.enabled_tools"), + exists: enabled_exists, + blocked: enabled_blocked || TOOLS.iter().any(|tool| disabled.contains(*tool)), + expected: serde_json::json!(TOOLS), + }); + + for tool in TOOLS { + let approval = server + .and_then(|table| table.get("tools")) + .map(|value| value.as_table().context("Codex tools is not a table")) + .transpose()? + .and_then(|tools| tools.get(tool)) + .map(|value| value.as_table().context("Codex tool rule is not a table")) + .transpose()? + .and_then(|rule| rule.get("approval_mode")); + let exists = approval.and_then(toml::Value::as_str) == Some("approve"); + observations.push(Observation { + path: path.to_path_buf(), + rule: format!("mcp_servers.{server_id}.tools.{tool}.approval_mode=approve"), + exists, + blocked: approval.is_some() && !exists, + expected: JsonValue::String("approve".to_owned()), + }); + } + Ok(observations) +} + +fn reject_blocked_observations(observations: &[Observation]) -> Result<()> { + if observations.iter().any(|observation| observation.blocked) { + bail!("provider configuration contains a conflicting value"); + } + Ok(()) +} + +fn reject_opencode_permission_shadowing(spec: &ProviderSpec) -> Result<()> { + if spec.provider != Provider::OpenCode || spec.inspection_documents.len() < 2 { + return Ok(()); + } + let target = spec + .documents + .first() + .context("OpenCode target is missing")?; + let (_, target_value) = read_document(target)?; + let DocumentValue::Json(target_value) = target_value else { + bail!("OpenCode registry paired a document with the wrong format"); + }; + if target_value.get("permissions").is_some() { + return Ok(()); + } + for document in &spec.inspection_documents { + if document.path == target.path { + break; + } + let (_, value) = read_document(document)?; + if matches!(value, DocumentValue::Json(value) if value.get("permissions").is_some()) { + bail!( + "writing {} would shadow lower-precedence OpenCode permissions", + target.path.display() + ); + } + } + Ok(()) +} + +fn prepare_source_document( + spec: &ProviderSpec, + document: &DocumentSpec, + server_id: &str, +) -> Result { + let (state, before, value) = read_document_snapshot(document)?; + let observations = inspect_document(spec.provider, document, &value, server_id)?; + reject_blocked_observations(&observations)?; + let mut after = before.clone(); + let mut patches = BTreeMap::new(); + + let observation = |rule: &str| -> Result<&Observation> { + observations + .iter() + .find(|observation| observation.rule == rule) + .with_context(|| format!("provider plan lacks rule {rule:?}")) + }; + match document.kind { + DocumentKind::Codex => { + let registration_rule = format!("mcp_servers.{server_id}"); + if !observation(®istration_rule)?.exists { + let registration = codex_registration()?; + let registration = registration + .as_table() + .context("canonical Codex registration is not a table")?; + let values = ["command", "args"] + .into_iter() + .map(|key| { + registration + .get(key) + .cloned() + .map(|value| (key.to_owned(), value)) + .with_context(|| format!("Codex registration lacks {key}")) + }) + .collect::>>()?; + let patch = apply_source_insert( + document, + &mut after, + Insert::TomlTable { + path: SourcePath::new(["mcp_servers", server_id]), + values, + }, + )?; + patches.insert(registration_rule, patch); + } + + let enabled_rule = format!("mcp_servers.{server_id}.enabled_tools"); + let internal_patch = if observation(&enabled_rule)?.exists { + None + } else { + Some(apply_source_insert( + document, + &mut after, + Insert::TomlScalar { + table: SourcePath::new(["mcp_servers", server_id]), + key: "enabled_tools".to_owned(), + value: toml::Value::Array( + TOOLS + .iter() + .map(|tool| toml::Value::String((*tool).to_owned())) + .collect(), + ), + }, + )?) + }; + + let mut first_owned_tool = None; + for tool in TOOLS { + let rule = format!("mcp_servers.{server_id}.tools.{tool}.approval_mode=approve"); + if observation(&rule)?.exists { + continue; + } + let patch = apply_source_insert( + document, + &mut after, + Insert::TomlScalar { + table: SourcePath::new(["mcp_servers", server_id, "tools", tool]), + key: "approval_mode".to_owned(), + value: toml::Value::String("approve".to_owned()), + }, + )?; + first_owned_tool.get_or_insert_with(|| rule.clone()); + patches.insert(rule, patch); + } + if let Some(internal) = internal_patch { + let owner = first_owned_tool + .context("Codex enabled_tools changed without an owned tool-rule insertion")?; + merge_internal_patch( + patches + .get_mut(&owner) + .expect("owned Codex tool patch was recorded"), + internal, + ); + } + } + DocumentKind::JsonRegistration => { + let rule = format!("mcpServers.{server_id}"); + if !observation(&rule)?.exists { + let patch = apply_source_insert( + document, + &mut after, + Insert::JsonMember { + parent: SourcePath::new(["mcpServers"]), + key: server_id.to_owned(), + value: json_registration(spec.provider)?, + }, + )?; + patches.insert(rule, patch); + } + } + DocumentKind::JsonPermissions => { + for rule in tool_rules(spec.provider, server_id) { + if observation(&rule)?.exists { + continue; + } + let patch = apply_source_insert( + document, + &mut after, + Insert::JsonArrayElement { + array: SourcePath::new(["permissions", "allow"]), + value: JsonValue::String(rule.clone()), + }, + )?; + patches.insert(rule, patch); + } + } + DocumentKind::OpenCode => { + let registration_rule = format!("mcp.servers.{server_id}"); + if !observation(®istration_rule)?.exists { + let patch = apply_source_insert( + document, + &mut after, + Insert::JsonMember { + parent: SourcePath::new(["mcp", "servers"]), + key: server_id.to_owned(), + value: opencode_registration()?, + }, + )?; + patches.insert(registration_rule, patch); + } + for tool in TOOLS { + let rule = format!("{server_id}_{tool}|*|allow"); + if observation(&rule)?.exists { + continue; + } + let patch = apply_source_insert( + document, + &mut after, + Insert::JsonArrayElement { + array: SourcePath::new(["permissions"]), + value: observation(&rule)?.expected.clone(), + }, + )?; + patches.insert(rule, patch); + } + } + DocumentKind::Zed => { + let registration_rule = format!("context_servers.{server_id}"); + if !observation(®istration_rule)?.exists { + let patch = apply_source_insert( + document, + &mut after, + Insert::JsonMember { + parent: SourcePath::new(["context_servers"]), + key: server_id.to_owned(), + value: json_registration(Provider::Zed)?, + }, + )?; + patches.insert(registration_rule, patch); + } + for tool in TOOLS { + let key = format!("mcp:{server_id}:{tool}"); + let rule = format!("agent.tool_permissions.tools.{key}.default=allow"); + if observation(&rule)?.exists { + continue; + } + let patch = apply_source_insert( + document, + &mut after, + Insert::JsonMember { + parent: SourcePath::new(["agent", "tool_permissions", "tools"]), + key, + value: serde_json::json!({"default": "allow"}), + }, + )?; + patches.insert(rule, patch); + } + } + } + + let final_value = parse_document_source(document, &after)?; + let final_observations = inspect_document(spec.provider, document, &final_value, server_id)?; + if final_observations + .iter() + .any(|observation| !observation.exists || observation.blocked) + { + bail!("lossless provider patch did not produce the exact requested semantics"); + } + Ok(PreparedSourceDocument { + document: document.clone(), + document_origin: state, + before, + after, + observations, + patches, + }) +} + +fn apply_source_insert( + document: &DocumentSpec, + bytes: &mut Vec, + operation: Insert, +) -> Result { + let format = document.kind.source_format(&document.path); + let applied = insert_source(bytes, format, document.kind.relevant_roots(), &operation)?; + if !applied.changed() { + bail!("provider source insertion unexpectedly made no change"); + } + *bytes = applied.bytes; + Ok(SourcePatch { + format, + owned: applied.owned, + created: applied.created, + }) +} + +fn merge_internal_patch(primary: &mut SourcePatch, internal: SourcePatch) { + let mut created = internal.created; + created.extend(internal.owned); + created.append(&mut primary.created); + primary.created = created; +} + +fn prepare_trust_operation( + manifest: &mut InstallManifest, + spec: &ProviderSpec, + documents: &[PreparedSourceDocument], +) -> Result> { + let ownership = manifest + .provider_ownership + .as_ref() + .context("provider ownership journal is not initialized")?; + let mut planned = Vec::new(); + for document in documents { + let path = utf8_path(&document.document.path)?.to_owned(); + for observation in document + .observations + .iter() + .filter(|observation| public_observation(document.document.kind, observation)) + { + let prior = ownership.owned_fragments.iter().find(|fragment| { + fragment.provider == spec.definition.id + && fragment.scope == spec.scope.journal_scope() + && fragment.canonical_path == path + && fragment.semantic_selector == observation.rule + }); + if prior.is_some_and(|fragment| { + observation.exists + && matches!( + fragment.ownership_kind, + OwnershipKind::Owned | OwnershipKind::Adopted + ) + }) { + continue; + } + if prior.is_some() { + bail!("provider ownership history requires explicit recovery before re-trust"); + } + let patch = (!observation.exists) + .then(|| { + document + .patches + .get(&observation.rule) + .cloned() + .with_context(|| { + format!("lossless patch is absent for {:?}", observation.rule) + }) + }) + .transpose()?; + planned.push((document, observation, patch)); + } + } + if planned.is_empty() { + return Ok(None); + } + if ownership.owned_fragments.len() + planned.len() > MAX_OWNED_FRAGMENTS { + bail!("provider journal owned-fragment retention bound exceeded"); + } + + let operation_id = random_identifier("op")?; + let requested = RequestedOperation { + provider: spec.definition.id, + surface: spec.definition.surface, + scope: spec.scope.journal_scope(), + dialect: spec.definition.dialect, + action: OperationAction::Trust, + }; + let mut targets = Vec::with_capacity(planned.len()); + let mut records = Vec::new(); + for (document, observation, source_patch) in planned { + let canonical_path = utf8_path(&document.document.path)?.to_owned(); + let fingerprint = fragment_fingerprint( + document.document.kind, + &observation.rule, + &observation.expected, + )?; + let adopted = observation.exists; + targets.push(TargetIntent { + canonical_path: canonical_path.clone(), + display_path: canonical_path.clone(), + format: document + .document + .kind + .source_format(&document.document.path), + dialect: requested.dialect, + before_hash: sha256(&document.before), + expected_after_hash: sha256(&document.after), + observed_after_hash: None, + patch: ExactTransform { + kind: if adopted { + crate::provider_journal::TransformKind::Adopt + } else { + crate::provider_journal::TransformKind::Insert + }, + selector: observation.rule.clone(), + value_fingerprint: fingerprint.clone(), + }, + inverse: ExactTransform { + kind: if adopted { + crate::provider_journal::TransformKind::PreserveExternal + } else { + crate::provider_journal::TransformKind::Remove + }, + selector: observation.rule.clone(), + value_fingerprint: fingerprint, + }, + ownership_delta: OwnershipDelta { + add: (!adopted) + .then(|| observation.rule.clone()) + .into_iter() + .collect(), + remove: Vec::new(), + }, + phase: OperationPhase::Prepared, + }); + if let Some(source_patch) = source_patch { + records.push(SpliceRecord { + provider: requested.provider, + surface: requested.surface, + scope: requested.scope, + dialect: requested.dialect, + canonical_path, + semantic_selector: observation.rule.clone(), + introducing_operation_id: operation_id.clone(), + document_origin: document.document_origin, + source_patch, + }); + } + } + let ownership = manifest + .provider_ownership + .as_mut() + .context("provider ownership journal is not initialized")?; + ownership.generation = ownership + .generation + .checked_add(1) + .context("provider ownership generation overflow")?; + ownership.push_operation(ProviderOperation { + id: operation_id.clone(), + requested, + phase: OperationPhase::Prepared, + targets, + })?; + manifest.provider_splices.extend(records); + Ok(Some(operation_id)) +} + +fn public_observation(kind: DocumentKind, observation: &Observation) -> bool { + kind != DocumentKind::Codex || !observation.rule.ends_with(".enabled_tools") +} + +fn fragment_fingerprint( + kind: DocumentKind, + selector: &str, + expected: &JsonValue, +) -> Result { + if is_registration_rule(kind, selector) { + Ok(sha256(&serde_json::to_vec(expected)?)) + } else { + Ok(sha256(selector.as_bytes())) + } +} + +fn owned_fragment( + operation: &ProviderOperation, + index: usize, + target: &TargetIntent, + generation: u64, +) -> OwnedFragment { + OwnedFragment { + id: format!("fragment{}{index}", operation.id), + provider: operation.requested.provider, + surface: operation.requested.surface, + scope: operation.requested.scope, + dialect: operation.requested.dialect, + canonical_path: target.canonical_path.clone(), + display_path: target.display_path.clone(), + format: target.format, + semantic_selector: target.patch.selector.clone(), + value_fingerprint: target.patch.value_fingerprint.clone(), + created_containers: Vec::new(), + ownership_kind: if target.patch.kind == TransformKind::Adopt { + OwnershipKind::Adopted + } else { + OwnershipKind::Owned + }, + introducing_operation_id: operation.id.clone(), + generation, + } +} + +fn finalize_operation( + manifest: &mut InstallManifest, + operation_id: &str, + phase: OperationPhase, +) -> Result<()> { + let operation = { + let ownership = manifest + .provider_ownership + .as_mut() + .context("provider ownership journal is not initialized")?; + let operation = ownership + .operations + .iter_mut() + .find(|operation| operation.id == operation_id) + .context("provider operation disappeared before finalization")?; + if operation.phase != OperationPhase::Prepared { + bail!("provider operation is not prepared"); + } + operation.phase = phase; + for target in &mut operation.targets { + target.phase = phase; + target.observed_after_hash = Some(target.expected_after_hash.clone()); + } + operation.clone() + }; + let ownership = manifest + .provider_ownership + .as_mut() + .expect("provider ownership was checked"); + let generation = ownership.generation; + let removed_keys = match operation.requested.action { + OperationAction::Trust => { + for (index, target) in operation.targets.iter().enumerate() { + if ownership.owned_fragments.iter().any(|fragment| { + fragment.provider == operation.requested.provider + && fragment.scope == operation.requested.scope + && fragment.canonical_path == target.canonical_path + && fragment.semantic_selector == target.patch.selector + }) { + bail!("provider operation would duplicate semantic ownership"); + } + ownership.push_fragment(owned_fragment(&operation, index, target, generation))?; + } + Vec::new() + } + OperationAction::Strip | OperationAction::Uninstall => { + let mut removed = Vec::new(); + for target in &operation.targets { + let fragment = ownership + .owned_fragments + .iter_mut() + .find(|fragment| { + fragment.provider == operation.requested.provider + && fragment.scope == operation.requested.scope + && fragment.canonical_path == target.canonical_path + && fragment.semantic_selector == target.patch.selector + }) + .context("provider removal target has no owned fragment")?; + fragment.ownership_kind = OwnershipKind::Removed; + removed.push((target.canonical_path.clone(), target.patch.selector.clone())); + } + removed + } + OperationAction::Recover => { + let mut removed = Vec::new(); + for (index, target) in operation.targets.iter().enumerate() { + match target.patch.kind { + crate::provider_journal::TransformKind::Insert + | crate::provider_journal::TransformKind::Adopt => { + ownership + .push_fragment(owned_fragment(&operation, index, target, generation))?; + } + crate::provider_journal::TransformKind::Remove => { + let fragment = ownership + .owned_fragments + .iter_mut() + .find(|fragment| { + fragment.provider == operation.requested.provider + && fragment.scope == operation.requested.scope + && fragment.canonical_path == target.canonical_path + && fragment.semantic_selector == target.patch.selector + }) + .context("recovered removal target has no owned fragment")?; + fragment.ownership_kind = OwnershipKind::Removed; + removed + .push((target.canonical_path.clone(), target.patch.selector.clone())); + } + crate::provider_journal::TransformKind::PreserveExternal => { + bail!("provider recovery target has an invalid forward transform") + } + } + } + removed + } + }; + ownership.validate()?; + if !removed_keys.is_empty() { + manifest.provider_splices.retain(|record| { + record.provider != operation.requested.provider + || record.scope != operation.requested.scope + || removed_keys.iter().all(|(path, selector)| { + record.canonical_path != *path || record.semantic_selector != *selector + }) + }); + } + Ok(()) +} + +fn apply_documents( + spec: &ProviderSpec, + server_id: &str, + manifest: &mut InstallManifest, + manifest_path: &Path, + lock: &ManifestLock, +) -> Result<()> { + reject_blocked_observations(&inspect_effective(spec, server_id)?)?; + reject_opencode_permission_shadowing(spec)?; + let inspection_before = spec + .inspection_documents + .iter() + .map(|document| { + let (state, bytes) = read_provider_document_snapshot(&document.path)?; + Ok((document.path.clone(), state, bytes)) + }) + .collect::>>()?; + let prepared = spec + .documents + .iter() + .map(|document| prepare_source_document(spec, document, server_id)) + .collect::>>()?; + + // Validate the complete split-provider plan before the first write. + for (path, state, before) in &inspection_before { + reject_provider_document_changed(path, *state, before)?; + } + for document in &prepared { + reject_provider_document_changed( + &document.document.path, + document.document_origin, + &document.before, + )?; + } + let operation_id = prepare_trust_operation(manifest, spec, &prepared)?; + let Some(operation_id) = operation_id else { + return Ok(()); + }; + manifest.save_locked(manifest_path, lock)?; + for document in &prepared { + if document.after == document.before { + continue; + } + write_provider_document( + &document.document.path, + document.document_origin, + &document.before, + Some(&document.after), + )?; + verify_provider_document(&document.document, Some(&document.after))?; + } + finalize_operation(manifest, &operation_id, OperationPhase::Applied)?; + manifest.save_locked(manifest_path, lock)?; + Ok(()) +} + +fn json_registration(provider: Provider) -> Result { + let executable = std::env::current_exe().context("cannot resolve the icm executable")?; + let executable = utf8_path(&executable)?; + Ok( + if matches!(provider, Provider::ClaudeCode | Provider::Cursor) { + serde_json::json!({ + "type": "stdio", + "command": executable, + "args": ["serve"], + "env": {} + }) + } else { + serde_json::json!({ + "command": executable, + "args": ["serve"], + "env": {} + }) + }, + ) +} + +fn opencode_registration() -> Result { + let executable = std::env::current_exe().context("cannot resolve the icm executable")?; + let executable = utf8_path(&executable)?; + Ok(serde_json::json!({ + "type": "local", + "command": [executable, "serve"] + })) +} + +fn write_provider_document( + path: &Path, + before_state: DocumentOrigin, + before: &[u8], + after: Option<&[u8]>, +) -> Result<()> { + reject_provider_document_changed(path, before_state, before)?; + match after { + Some(after) => secure_atomic_write_if_unchanged( + path, + (before_state != DocumentOrigin::Missing).then_some(before), + after, + ) + .with_context(|| format!("cannot write provider config {}", path.display())), + None => { + std::fs::remove_file(path) + .with_context(|| format!("cannot remove provider config {}", path.display()))?; + #[cfg(unix)] + File::open(path.parent().unwrap_or_else(|| Path::new("."))) + .and_then(|directory| directory.sync_all()) + .with_context(|| { + format!( + "cannot sync provider config directory for {}", + path.display() + ) + })?; + Ok(()) + } + } +} + +fn reject_provider_document_changed( + path: &Path, + before_state: DocumentOrigin, + before: &[u8], +) -> Result<()> { + let (current_state, current) = read_provider_document_snapshot(path)?; + if current_state != before_state || current != before { + bail!( + "provider configuration changed while the mutation was being prepared: {}", + path.display() + ); + } + Ok(()) +} + +fn verify_provider_document(document: &DocumentSpec, expected: Option<&[u8]>) -> Result<()> { + match expected { + Some(expected) => { + let (state, bytes, _) = read_document_snapshot(document)?; + if state == DocumentOrigin::Missing || sha256(&bytes) != sha256(expected) { + bail!( + "provider configuration failed post-write verification: {}", + document.path.display() + ); + } + } + None if secure_read(&document.path)?.is_none() => {} + None => bail!( + "provider configuration failed post-remove verification: {}", + document.path.display() + ), + } + Ok(()) +} + +fn read_provider_document(path: &Path) -> Result> { + Ok(read_provider_document_snapshot(path)?.1) +} + +fn read_provider_document_snapshot(path: &Path) -> Result<(DocumentOrigin, Vec)> { + Ok(match secure_read(path)? { + None => (DocumentOrigin::Missing, Vec::new()), + Some(bytes) if bytes.is_empty() => (DocumentOrigin::Empty, bytes), + Some(bytes) => (DocumentOrigin::Existing, bytes), + }) +} + +fn codex_registration() -> Result { + let executable = std::env::current_exe().context("cannot resolve the icm executable")?; + let executable = utf8_path(&executable)?; + let mut registration = toml::map::Map::new(); + registration.insert( + "command".to_owned(), + toml::Value::String(executable.to_owned()), + ); + registration.insert( + "args".to_owned(), + toml::Value::Array(vec![toml::Value::String("serve".to_owned())]), + ); + registration.insert( + "enabled_tools".to_owned(), + toml::Value::Array( + TOOLS + .iter() + .map(|tool| toml::Value::String((*tool).to_owned())) + .collect(), + ), + ); + let tools = TOOLS + .iter() + .map(|tool| { + let mut rule = toml::map::Map::new(); + rule.insert( + "approval_mode".to_owned(), + toml::Value::String("approve".to_owned()), + ); + ((*tool).to_owned(), toml::Value::Table(rule)) + }) + .collect(); + registration.insert("tools".to_owned(), toml::Value::Table(tools)); + Ok(toml::Value::Table(registration)) +} + +fn strip_documents( + provider: Provider, + scope: Scope, + documents: &[DocumentSpec], + mode: RemovalMode, + manifest: &mut InstallManifest, + manifest_path: &Path, + lock: &ManifestLock, +) -> Result { + let mut prepared = Vec::new(); + for document in documents { + if let Some(document) = prepare_removal_document(manifest, provider, scope, document, mode)? + { + prepared.push(document); + } + } + if prepared.is_empty() { + return Ok(StripSummary::default()); + } + for document in &prepared { + reject_provider_document_changed( + &document.document.path, + document.before_state, + &document.before, + )?; + } + let operation_id = prepare_removal_operation(manifest, provider, scope, mode, &prepared)?; + manifest.save_locked(manifest_path, lock)?; + for document in &prepared { + if document.after.as_deref() == Some(document.before.as_slice()) { + continue; + } + write_provider_document( + &document.document.path, + document.before_state, + &document.before, + document.after.as_deref(), + )?; + verify_provider_document(&document.document, document.after.as_deref())?; + } + finalize_operation(manifest, &operation_id, OperationPhase::Removed)?; + manifest.save_locked(manifest_path, lock)?; + Ok(StripSummary { + outcomes: prepared + .into_iter() + .map(|document| StripPathOutcome { + path: document.document.path, + entries_removed: document.fragments.len(), + deleted: document.after.is_none(), + }) + .collect(), + }) +} + +fn prepare_removal_document( + manifest: &InstallManifest, + provider: Provider, + scope: Scope, + document: &DocumentSpec, + mode: RemovalMode, +) -> Result> { + let path = utf8_path(&document.path)?.to_owned(); + let fragments = manifest + .provider_ownership + .as_ref() + .into_iter() + .flat_map(|ownership| ownership.owned_fragments.iter()) + .filter(|fragment| { + fragment.provider == provider.definition().id + && fragment.scope == scope.journal_scope() + && fragment.canonical_path == path + && fragment.ownership_kind == OwnershipKind::Owned + && (mode == RemovalMode::Uninstall + || !is_registration_rule(document.kind, &fragment.semantic_selector)) + }) + .cloned() + .collect::>(); + if fragments.is_empty() { + return Ok(None); + } + let mut records = fragments + .iter() + .map(|fragment| { + manifest + .provider_splices + .iter() + .find(|record| { + record.provider == fragment.provider + && record.scope == fragment.scope + && record.canonical_path == fragment.canonical_path + && record.semantic_selector == fragment.semantic_selector + && record.introducing_operation_id == fragment.introducing_operation_id + }) + .cloned() + .with_context(|| { + format!( + "owned provider fragment {:?} has no exact source inverse", + fragment.semantic_selector + ) + }) + }) + .collect::>>()?; + records.sort_by_key(|record| { + manifest + .provider_splices + .iter() + .position(|candidate| candidate == record) + .unwrap_or(usize::MAX) + }); + let (before_state, before) = read_provider_document_snapshot(&document.path)?; + parse_document_source(document, &before)?; + let after = remove_source_records(document, &before, &records)?; + let document_origin = records[0].document_origin; + if records + .iter() + .any(|record| record.document_origin != document_origin) + { + bail!("provider source inverses disagree about document origin"); + } + let after = if document_origin == DocumentOrigin::Missing && after.is_empty() { + None + } else { + Some(after) + }; + Ok(Some(PreparedRemovalDocument { + document: document.clone(), + before_state, + before, + after, + fragments, + })) +} + +fn remove_source_records( + document: &DocumentSpec, + before: &[u8], + records: &[SpliceRecord], +) -> Result> { + let mut after = before.to_vec(); + for record in records.iter().rev() { + if let Some(owned) = &record.source_patch.owned { + after = remove_source_owned( + &after, + record.source_patch.format, + document.kind.relevant_roots(), + owned, + )?; + } + for created in record.source_patch.created.iter().rev() { + if let Some(next) = try_remove_source_owned( + &after, + record.source_patch.format, + document.kind.relevant_roots(), + created, + )? { + after = next; + } + } + } + parse_document_source(document, &after)?; + Ok(after) +} + +fn prepare_removal_operation( + manifest: &mut InstallManifest, + provider: Provider, + scope: Scope, + mode: RemovalMode, + documents: &[PreparedRemovalDocument], +) -> Result { + let operation_id = random_identifier("op")?; + let requested = RequestedOperation { + provider: provider.definition().id, + surface: provider.definition().surface, + scope: scope.journal_scope(), + dialect: provider.definition().dialect, + action: if mode == RemovalMode::Uninstall { + OperationAction::Uninstall + } else { + OperationAction::Strip + }, + }; + let mut targets = Vec::new(); + for document in documents { + let after = document.after.as_deref().unwrap_or_default(); + for fragment in &document.fragments { + targets.push(TargetIntent { + canonical_path: fragment.canonical_path.clone(), + display_path: fragment.display_path.clone(), + format: fragment.format, + dialect: fragment.dialect, + before_hash: sha256(&document.before), + expected_after_hash: sha256(after), + observed_after_hash: None, + patch: ExactTransform { + kind: crate::provider_journal::TransformKind::Remove, + selector: fragment.semantic_selector.clone(), + value_fingerprint: fragment.value_fingerprint.clone(), + }, + inverse: ExactTransform { + kind: crate::provider_journal::TransformKind::Insert, + selector: fragment.semantic_selector.clone(), + value_fingerprint: fragment.value_fingerprint.clone(), + }, + ownership_delta: OwnershipDelta { + add: Vec::new(), + remove: vec![fragment.semantic_selector.clone()], + }, + phase: OperationPhase::Prepared, + }); + } + } + let ownership = manifest + .provider_ownership + .as_mut() + .context("provider ownership journal is not initialized")?; + ownership.generation = ownership + .generation + .checked_add(1) + .context("provider ownership generation overflow")?; + ownership.push_operation(ProviderOperation { + id: operation_id.clone(), + requested, + phase: OperationPhase::Prepared, + targets, + })?; + Ok(operation_id) +} + +fn owned_document_kind(provider: Provider, rule: &str) -> Result { + provider + .definition() + .project + .iter() + .chain(provider.definition().user.iter()) + .map(|document| document.kind) + .find(|kind| kind.owns_rule(rule)) + .with_context(|| format!("unknown owned provider rule {rule:?}")) +} + +fn recover_provenance( + manifest: &mut InstallManifest, + target: &TargetArgs, + server_id: &str, + manifest_path: &Path, + lock: &ManifestLock, +) -> Result<()> { + let operations = manifest + .provider_ownership + .as_ref() + .into_iter() + .flat_map(|ownership| ownership.operations.iter()) + .filter(|operation| { + operation.phase == OperationPhase::Prepared + && operation.requested.provider == target.provider.definition().id + && operation.requested.scope == target.scope.journal_scope() + }) + .cloned() + .collect::>(); + for operation in operations { + recover_operation(manifest, target, server_id, manifest_path, lock, &operation)?; + } + Ok(()) +} + +fn recover_operation( + manifest: &mut InstallManifest, + target: &TargetArgs, + server_id: &str, + manifest_path: &Path, + lock: &ManifestLock, + operation: &ProviderOperation, +) -> Result<()> { + let mut documents = BTreeMap::::new(); + for intent in &operation.targets { + let kind = document_kind_for_target(operation.requested.provider, intent); + documents + .entry(intent.canonical_path.clone()) + .or_insert_with(|| DocumentSpec { + path: PathBuf::from(&intent.canonical_path), + kind, + }); + } + let mut states = RecoveryStates::new(); + for (path, document) in &documents { + let (state, bytes) = read_provider_document_snapshot(&document.path)?; + let current = sha256(&bytes); + states.insert(path.clone(), (state, bytes, current)); + } + for (path, document) in &documents { + let (_, bytes, _) = states + .get(path) + .expect("recovery document state was captured"); + if let Err(error) = parse_document_source(document, bytes) { + mark_operation_conflict(manifest, &operation.id, &states)?; + manifest.save_locked(manifest_path, lock)?; + return Err(error.context("prepared provider target is no longer parseable")); + } + } + + let plan = + match prepare_recovery_plan(manifest, target, server_id, operation, &documents, &states) { + Ok(plan) => plan, + Err(error) => { + mark_operation_conflict(manifest, &operation.id, &states)?; + manifest.save_locked(manifest_path, lock)?; + return Err( + error.context("prepared provider operation overlaps current configuration") + ); + } + }; + rebase_prepared_operation(manifest, &operation.id, &plan)?; + // A disjoint edit changes both the target hashes and the exact source + // inverse. Persist that rebased intent before touching a target. + manifest.save_locked(manifest_path, lock)?; + for document in &plan.documents { + if document.after.as_deref() == Some(document.before.as_slice()) { + continue; + } + write_provider_document( + &document.document.path, + document.before_state, + &document.before, + document.after.as_deref(), + )?; + verify_provider_document(&document.document, document.after.as_deref())?; + } + promote_recovery_operation(manifest, &operation.id)?; + finalize_operation(manifest, &operation.id, plan.terminal_phase)?; + manifest.save_locked(manifest_path, lock) +} + +fn prepare_recovery_plan( + manifest: &InstallManifest, + target: &TargetArgs, + server_id: &str, + operation: &ProviderOperation, + documents: &BTreeMap, + states: &RecoveryStates, +) -> Result { + if operation.targets.iter().all(|target| { + matches!( + target.patch.kind, + TransformKind::Insert | TransformKind::Adopt + ) + }) { + prepare_insertion_recovery(manifest, target, server_id, operation, documents, states) + } else if operation + .targets + .iter() + .all(|target| target.patch.kind == TransformKind::Remove) + { + prepare_removal_recovery(manifest, target, server_id, operation, documents, states) + } else { + bail!("prepared provider operation mixes incompatible transform kinds") + } +} + +fn prepare_insertion_recovery( + manifest: &InstallManifest, + target: &TargetArgs, + server_id: &str, + operation: &ProviderOperation, + documents: &BTreeMap, + states: &RecoveryStates, +) -> Result { + let recovery_documents = documents.values().cloned().collect::>(); + let spec = ProviderSpec { + definition: target.provider.definition(), + provider: target.provider, + scope: target.scope, + documents: recovery_documents.clone(), + inspection_documents: recovery_documents, + }; + let mut plan = RecoveryPlan { + documents: Vec::new(), + replacement_paths: BTreeSet::new(), + replacement_splices: Vec::new(), + terminal_phase: OperationPhase::Applied, + }; + for (path, document) in documents { + let (before_state, before, _) = states + .get(path) + .context("provider recovery target state is absent")?; + let prepared = prepare_source_document(&spec, document, server_id)?; + let intents = operation + .targets + .iter() + .filter(|intent| intent.canonical_path == *path) + .collect::>(); + let selectors = intents + .iter() + .map(|intent| intent.patch.selector.as_str()) + .collect::>(); + if prepared + .patches + .keys() + .any(|selector| !selectors.contains(selector.as_str())) + { + bail!("recovery would repair a provider fragment outside its durable intent"); + } + + let mut inserted = Vec::new(); + for intent in &intents { + let observation = prepared + .observations + .iter() + .find(|observation| observation.rule == intent.patch.selector) + .context("provider recovery intent has no semantic observation")?; + if observation.blocked + || fragment_fingerprint(document.kind, &observation.rule, &observation.expected)? + != intent.patch.value_fingerprint + { + bail!("provider recovery intent no longer has its exact semantic identity"); + } + match intent.patch.kind { + TransformKind::Insert => inserted.push(observation.exists), + TransformKind::Adopt if observation.exists => {} + TransformKind::Adopt => { + bail!("an adopted provider fragment changed during recovery") + } + _ => bail!("insertion recovery contains a non-insertion transform"), + } + } + let all_inserted = inserted.iter().all(|exists| *exists); + let none_inserted = inserted.iter().all(|exists| !*exists); + if !all_inserted && !none_inserted { + bail!("provider target contains only part of its prepared mutation"); + } + + let after = if all_inserted { + validate_recovered_source_splices(manifest, operation, document, before)?; + before.clone() + } else { + plan.replacement_paths.insert(path.clone()); + for intent in intents + .iter() + .filter(|intent| intent.patch.kind == TransformKind::Insert) + { + let source_patch = prepared + .patches + .get(&intent.patch.selector) + .cloned() + .context("rebased provider insertion lacks an exact source patch")?; + plan.replacement_splices.push(SpliceRecord { + provider: operation.requested.provider, + surface: operation.requested.surface, + scope: operation.requested.scope, + dialect: operation.requested.dialect, + canonical_path: path.clone(), + semantic_selector: intent.patch.selector.clone(), + introducing_operation_id: operation.id.clone(), + document_origin: prepared.document_origin, + source_patch, + }); + } + prepared.after + }; + plan.documents.push(RebasedRecoveryDocument { + document: document.clone(), + before_state: *before_state, + before: before.clone(), + after: Some(after), + }); + } + Ok(plan) +} + +fn prepare_removal_recovery( + manifest: &InstallManifest, + target: &TargetArgs, + server_id: &str, + operation: &ProviderOperation, + documents: &BTreeMap, + states: &RecoveryStates, +) -> Result { + let mode = if operation.targets.iter().any(|target| { + is_registration_rule( + document_kind_for_target(operation.requested.provider, target), + &target.patch.selector, + ) + }) { + RemovalMode::Uninstall + } else { + RemovalMode::TrustOnly + }; + let mut plan = RecoveryPlan { + documents: Vec::new(), + replacement_paths: BTreeSet::new(), + replacement_splices: Vec::new(), + terminal_phase: OperationPhase::Removed, + }; + for (path, document) in documents { + let (before_state, before, _) = states + .get(path) + .context("provider recovery target state is absent")?; + let value = parse_document_source(document, before)?; + let observations = inspect_document(target.provider, document, &value, server_id)?; + let intents = operation + .targets + .iter() + .filter(|intent| intent.canonical_path == *path) + .collect::>(); + let mut present = Vec::new(); + for intent in &intents { + let observation = observations + .iter() + .find(|observation| observation.rule == intent.patch.selector) + .context("provider recovery intent has no semantic observation")?; + if observation.blocked + || fragment_fingerprint(document.kind, &observation.rule, &observation.expected)? + != intent.patch.value_fingerprint + { + bail!("provider removal target changed at its exact semantic location"); + } + present.push(observation.exists); + } + let all_present = present.iter().all(|exists| *exists); + let none_present = present.iter().all(|exists| !*exists); + if !all_present && !none_present { + bail!("provider target contains only part of its prepared removal"); + } + let after = if all_present { + let prepared = + prepare_removal_document(manifest, target.provider, target.scope, document, mode)? + .context("prepared provider removal has no owned fragments")?; + let planned = intents + .iter() + .map(|intent| intent.patch.selector.as_str()) + .collect::>(); + let recovered = prepared + .fragments + .iter() + .map(|fragment| fragment.semantic_selector.as_str()) + .collect::>(); + if recovered != planned { + bail!("rebased removal differs from its durable fragment set"); + } + prepared.after + } else { + Some(before.clone()) + }; + plan.documents.push(RebasedRecoveryDocument { + document: document.clone(), + before_state: *before_state, + before: before.clone(), + after, + }); + } + Ok(plan) +} + +fn validate_recovered_source_splices( + manifest: &InstallManifest, + operation: &ProviderOperation, + document: &DocumentSpec, + bytes: &[u8], +) -> Result<()> { + let records = manifest + .provider_splices + .iter() + .filter(|record| { + record.introducing_operation_id == operation.id + && Path::new(&record.canonical_path) == document.path + }) + .cloned() + .collect::>(); + let expected = operation + .targets + .iter() + .filter(|target| { + Path::new(&target.canonical_path) == document.path + && target.patch.kind == TransformKind::Insert + }) + .map(|target| target.patch.selector.as_str()) + .collect::>(); + let actual = records + .iter() + .map(|record| record.semantic_selector.as_str()) + .collect::>(); + if records.len() != expected.len() || actual != expected { + bail!("prepared provider insertion lacks exact source provenance"); + } + remove_source_records(document, bytes, &records)?; + Ok(()) +} + +fn rebase_prepared_operation( + manifest: &mut InstallManifest, + operation_id: &str, + plan: &RecoveryPlan, +) -> Result<()> { + let ownership = manifest + .provider_ownership + .as_mut() + .context("provider ownership journal is not initialized")?; + let operation = ownership + .operations + .iter_mut() + .find(|operation| operation.id == operation_id) + .context("provider recovery operation disappeared")?; + if operation.phase != OperationPhase::Prepared { + bail!("only a prepared provider operation can be rebased"); + } + for target in &mut operation.targets { + let document = plan + .documents + .iter() + .find(|document| Path::new(&target.canonical_path) == document.document.path) + .context("provider recovery plan omitted a target document")?; + target.before_hash = sha256(&document.before); + target.expected_after_hash = sha256(document.after.as_deref().unwrap_or_default()); + target.observed_after_hash = None; + } + manifest.provider_splices.retain(|record| { + record.introducing_operation_id != operation_id + || !plan.replacement_paths.contains(&record.canonical_path) + }); + manifest + .provider_splices + .extend(plan.replacement_splices.iter().cloned()); + Ok(()) +} + +fn mark_operation_conflict( + manifest: &mut InstallManifest, + operation_id: &str, + states: &BTreeMap, String)>, +) -> Result<()> { + let discard_prepared_splices = manifest + .provider_ownership + .as_ref() + .and_then(|ownership| { + ownership + .operations + .iter() + .find(|operation| operation.id == operation_id) + }) + .is_some_and(|operation| { + operation + .targets + .iter() + .any(|target| target.patch.kind == crate::provider_journal::TransformKind::Insert) + }); + promote_recovery_operation(manifest, operation_id)?; + let ownership = manifest + .provider_ownership + .as_mut() + .context("provider ownership journal is not initialized")?; + let operation = ownership + .operations + .iter_mut() + .find(|operation| operation.id == operation_id) + .context("provider recovery operation disappeared")?; + operation.phase = OperationPhase::Conflict; + for target in &mut operation.targets { + let current = states + .get(&target.canonical_path) + .context("provider recovery target state is absent")? + .2 + .clone(); + target.phase = OperationPhase::Conflict; + target.before_hash = current.clone(); + target.observed_after_hash = Some(current); + } + if discard_prepared_splices { + manifest + .provider_splices + .retain(|record| record.introducing_operation_id != operation_id); + } + Ok(()) +} + +fn promote_recovery_operation(manifest: &mut InstallManifest, operation_id: &str) -> Result<()> { + let ownership = manifest + .provider_ownership + .as_mut() + .context("provider ownership journal is not initialized")?; + let operation = ownership + .operations + .iter_mut() + .find(|operation| operation.id == operation_id) + .context("provider recovery operation disappeared")?; + if operation.phase != OperationPhase::Prepared { + bail!("only a prepared provider operation can be recovered"); + } + ownership.generation = ownership + .generation + .checked_add(1) + .context("provider ownership generation overflow")?; + operation.requested.action = OperationAction::Recover; + Ok(()) +} + +fn document_kind_for_target(provider: ProviderId, target: &TargetIntent) -> DocumentKind { + match provider { + ProviderId::Codex => DocumentKind::Codex, + ProviderId::ClaudeCode | ProviderId::Cursor => { + if target.patch.selector.starts_with("mcpServers.") { + DocumentKind::JsonRegistration + } else { + DocumentKind::JsonPermissions + } + } + ProviderId::OpenCode => DocumentKind::OpenCode, + ProviderId::Zed => DocumentKind::Zed, + } +} + +fn is_registration_rule(kind: DocumentKind, rule: &str) -> bool { + match kind { + DocumentKind::Codex => { + rule.starts_with("mcp_servers.") + && !rule.contains(".enabled_tools") + && !rule.contains(".tools.") + } + DocumentKind::JsonRegistration => true, + DocumentKind::JsonPermissions => false, + DocumentKind::OpenCode => rule.starts_with("mcp.servers."), + DocumentKind::Zed => rule.starts_with("context_servers."), + } +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn tool_rules(provider: Provider, server_id: &str) -> Vec { + TOOLS + .iter() + .map(|tool| match provider.definition().permission { + PermissionShape::Codex => { + format!("mcp_servers.{server_id}.tools.{tool}.approval_mode=approve") + } + PermissionShape::Claude => format!("mcp__{server_id}__{tool}"), + PermissionShape::Cursor => format!("Mcp({server_id}:{tool})"), + PermissionShape::OpenCode => format!("{server_id}_{tool}|*|allow"), + PermissionShape::Zed => { + format!("agent.tool_permissions.tools.mcp:{server_id}:{tool}.default=allow") + } + }) + .collect() +} + +fn optional_table<'a>( + value: Option<&'a toml::Value>, + field: &str, +) -> Result<&'a toml::map::Map> { + static EMPTY: std::sync::OnceLock> = + std::sync::OnceLock::new(); + match value { + Some(value) => value + .as_table() + .with_context(|| format!("{field} is not a table")), + None => Ok(EMPTY.get_or_init(toml::map::Map::new)), + } +} + +fn string_set(value: Option<&toml::Value>) -> Result> { + match value { + None => Ok(BTreeSet::new()), + Some(value) => value + .as_array() + .context("Codex tool list is not an array")? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .context("Codex tool list contains a non-string value") + }) + .collect(), + } +} + +fn exact_tool_set(value: Option<&toml::Value>) -> Result<(bool, bool)> { + if value.is_none() { + return Ok((false, false)); + } + let actual = string_set(value)?; + let expected: BTreeSet<_> = TOOLS.iter().copied().map(str::to_owned).collect(); + Ok((actual == expected, actual != expected)) +} + +fn reject_normalization_collisions<'a>(ids: impl Iterator) -> Result<()> { + let mut normalized = BTreeMap::new(); + for id in ids { + let key = id.replace('-', "_"); + if normalized + .insert(key.clone(), id) + .is_some_and(|prior| prior != id) + { + bail!("provider server IDs collide after normalization: {key}"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalization_collisions_fail_closed() { + assert!(reject_normalization_collisions(["icm-a", "icm_a"].into_iter()).is_err()); + assert!(reject_normalization_collisions(["icma", "icmb"].into_iter()).is_ok()); + } + + #[test] + fn opposite_scope_permission_blockers_are_rejected() { + let observations = vec![ + Observation { + path: PathBuf::from("project.json"), + rule: "registration".into(), + exists: false, + blocked: false, + expected: JsonValue::Null, + }, + Observation { + path: PathBuf::from("project.json"), + rule: "tool".into(), + exists: false, + blocked: true, + expected: JsonValue::Null, + }, + ]; + assert!(reject_opposite_scope_binding(&observations).is_err()); + } + + #[test] + fn opposite_scope_registration_is_always_rejected() { + assert_eq!(opposite_scope(Scope::ProjectLocal), Scope::User); + assert_eq!(opposite_scope(Scope::User), Scope::ProjectLocal); + let observations = vec![Observation { + path: PathBuf::from("opposite.json"), + rule: "registration".into(), + exists: true, + blocked: false, + expected: JsonValue::Null, + }]; + assert!(reject_opposite_scope_binding(&observations).is_err()); + } + + #[cfg(unix)] + #[test] + fn non_utf8_provider_paths_fail_closed() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let path = PathBuf::from(OsString::from_vec(vec![0xff])); + assert!(utf8_path(&path).is_err()); + } + + #[cfg(unix)] + #[test] + fn symlinked_provider_configs_are_rejected() { + use std::os::unix::fs::symlink; + + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target.json"); + let link = tmp.path().join("settings.json"); + std::fs::write(&target, "{}").unwrap(); + symlink(&target, &link).unwrap(); + + assert!(read_provider_document(&link) + .unwrap_err() + .to_string() + .contains("symlink")); + assert_eq!(std::fs::read_to_string(target).unwrap(), "{}"); + } + + #[test] + fn stale_provider_config_writes_are_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + std::fs::write(&path, b"before").unwrap(); + let before = read_provider_document(&path).unwrap(); + std::fs::write(&path, b"concurrent edit").unwrap(); + + assert!(write_provider_document( + &path, + DocumentOrigin::Existing, + &before, + Some(b"replacement") + ) + .is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"concurrent edit"); + } + + #[test] + fn malformed_provider_documents_fail_closed() { + let tmp = tempfile::tempdir().unwrap(); + let json = document(tmp.path().join("bad.json"), DocumentKind::Zed); + let toml = document(tmp.path().join("bad.toml"), DocumentKind::Codex); + std::fs::write(&json.path, "{/* broken */").unwrap(); + std::fs::write(&toml.path, "[broken").unwrap(); + assert!(read_document(&json).is_err()); + assert!(read_document(&toml).is_err()); + } + + #[test] + fn zed_settings_json_accepts_jsonc() { + let tmp = tempfile::tempdir().unwrap(); + let settings = document(tmp.path().join("settings.jsonc"), DocumentKind::Zed); + std::fs::write(&settings.path, "{ // comment\n \"sentinel\": true,\n}\n").unwrap(); + let (_, DocumentValue::Json(value)) = read_document(&settings).unwrap() else { + panic!("Zed settings must be JSON"); + }; + assert_eq!(value["sentinel"], true); + } + + #[test] + fn opencode_uses_v2_wildcards_and_last_matching_permission() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("opencode.json"); + std::fs::write( + &path, + r#"{"mcp":{"servers":{"icm123":{"type":"local","command":["external","serve"]}}},"permissions":[{"action":"icm?23_*_memory_*","resource":"*","effect":"allow"},{"action":"icm*memory?recall","resource":"?","effect":"deny"}],"agents":{"reviewer":{"permissions":[{"action":"icm123_icm_memory_store","resource":"*","effect":"ask"}]}}}"#, + ) + .unwrap(); + let document = document(path, DocumentKind::OpenCode); + let spec = ProviderSpec { + definition: Provider::OpenCode.definition(), + provider: Provider::OpenCode, + scope: Scope::ProjectLocal, + documents: vec![document.clone()], + inspection_documents: vec![document], + }; + + let observations = inspect(&spec, "icm123").unwrap(); + assert!(observations[0].blocked); + assert!(observations[1].blocked); + assert!(observations[2].blocked); + #[cfg(windows)] + assert!(glob_matches("ICM?23_*", "icm123_store")); + } + + #[test] + fn opencode_reports_only_effective_managed_blockers() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("opencode.json"); + let mut registration = opencode_registration().unwrap(); + registration["disabled"] = JsonValue::Bool(true); + std::fs::write( + &path, + serde_json::to_vec(&serde_json::json!({ + "mcp": {"servers": { + "existing": {"type":"local", "command":["external"], "disabled":true}, + "icm123": registration + }}, + "permissions": [ + {"action":"existing_*", "resource":"*", "effect":"ask"}, + {"action":"dangerous_*", "resource":"*", "effect":"deny"}, + {"action":"icm123_icm_memory_recall", "resource":"*", "effect":"deny"}, + {"action":"icm123_icm_memory_recall", "resource":"*", "effect":"allow"}, + {"action":"icm123_icm_memory_store", "resource":"*", "effect":"ask"} + ] + })) + .unwrap(), + ) + .unwrap(); + let document = document(path, DocumentKind::OpenCode); + let spec = ProviderSpec { + definition: Provider::OpenCode.definition(), + provider: Provider::OpenCode, + scope: Scope::ProjectLocal, + documents: vec![document.clone()], + inspection_documents: vec![document], + }; + + let (preserved, causal) = observed_restrictions(&spec, "icm123").unwrap(); + assert_eq!( + preserved, + BTreeSet::from([ + "last-matching-rule-wins".to_owned(), + "mcp.servers.existing.disabled=true".to_owned(), + "permissions:dangerous_*|*|deny".to_owned(), + "permissions:existing_*|*|ask".to_owned(), + ]) + ); + assert_eq!( + causal, + BTreeSet::from([ + "mcp.servers.icm123.disabled=true".to_owned(), + "permissions:icm123_icm_memory_store|*|ask".to_owned(), + ]) + ); + } + + #[test] + fn project_templates_resolve_from_subdirectories_and_claude_uses_main_checkout_settings() { + let tmp = tempfile::tempdir().unwrap(); + let main = tmp.path().join("main"); + let git_dir = main.join(".git/worktrees/topic"); + let worktree = tmp.path().join("topic"); + let cwd = worktree.join("nested/deeper"); + std::fs::create_dir_all(&git_dir).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write(git_dir.join("commondir"), "../..\n").unwrap(); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", git_dir.display()), + ) + .unwrap(); + + for definition in PROVIDER_REGISTRY + .iter() + .filter(|definition| definition.provider != Provider::OpenCode) + { + for template in definition.project { + let Location::Project(relative) = template.location else { + continue; + }; + let root = if definition.provider == Provider::ClaudeCode + && relative == ".claude/settings.local.json" + { + &main + } else { + &worktree + }; + assert_eq!( + project_path(definition.provider, relative, &cwd).unwrap(), + root.join(relative) + ); + } + } + } + + #[test] + fn opencode_resolver_orders_layers_and_mutates_only_highest_jsonc() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("repo"); + let cwd = root.join("packages/web"); + let global = tmp.path().join("config/opencode.json"); + let root_direct = root.join("opencode.json"); + let cwd_direct = cwd.join("opencode.json"); + let root_dot = root.join(".opencode/opencode.json"); + let cwd_dot = cwd.join(".opencode/opencode.jsonc"); + for path in [&global, &root_direct, &cwd_direct, &root_dot, &cwd_dot] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + } + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::write(&global, r#"{"winner":"global"}"#).unwrap(); + std::fs::write(&root_direct, r#"{"winner":"root-direct"}"#).unwrap(); + std::fs::write(&cwd_direct, r#"{"winner":"cwd-direct"}"#).unwrap(); + std::fs::write(&root_dot, r#"{"winner":"root-dot"}"#).unwrap(); + std::fs::write( + &cwd_dot, + "{\n // JSONC remains a supported mutation target\n \"winner\": \"cwd-dot\",\n \"permissions\": [{\"action\": \"dangerous_*\", \"resource\": \"*\", \"effect\": \"deny\"}],\n}\n", + ) + .unwrap(); + + let (documents, inspection_documents) = + opencode_documents(Scope::ProjectLocal, &cwd, &global).unwrap(); + assert_eq!(documents[0].path, cwd_dot); + assert_eq!( + inspection_documents + .iter() + .map(|document| document.path.clone()) + .collect::>(), + vec![ + global.clone(), + root_direct.clone(), + cwd_direct.clone(), + root_dot.clone(), + cwd_dot.clone(), + ] + ); + let spec = ProviderSpec { + definition: Provider::OpenCode.definition(), + provider: Provider::OpenCode, + scope: Scope::ProjectLocal, + documents, + inspection_documents, + }; + assert_eq!( + effective_opencode_value(&spec).unwrap()["winner"], + "cwd-dot" + ); + + let untouched = [&global, &root_direct, &cwd_direct, &root_dot] + .map(|path| std::fs::read(path).unwrap()); + let manifest_path = tmp.path().join("manifest.json"); + let lock = InstallManifest::lock(&manifest_path).unwrap(); + let mut manifest = InstallManifest::empty(); + manifest.provider_ownership = + Some(crate::provider_journal::ProviderOwnership::empty("icm123").unwrap()); + apply_documents(&spec, "icm123", &mut manifest, &manifest_path, &lock).unwrap(); + assert_eq!( + untouched, + [&global, &root_direct, &cwd_direct, &root_dot] + .map(|path| std::fs::read(path).unwrap()) + ); + assert!(inspect_effective(&spec, "icm123") + .unwrap() + .iter() + .all(|observation| observation.exists && !observation.blocked)); + let (_, DocumentValue::Json(value)) = + read_document(&document(cwd_dot, DocumentKind::OpenCode)).unwrap() + else { + panic!("OpenCode settings must be JSON"); + }; + assert_eq!(value["permissions"][0]["effect"], "deny"); + } + + #[test] + fn opencode_resolver_rejects_json_and_jsonc_at_one_layer() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir(tmp.path().join(".git")).unwrap(); + std::fs::write(tmp.path().join("opencode.json"), "{}").unwrap(); + std::fs::write(tmp.path().join("opencode.jsonc"), "{}").unwrap(); + assert!(opencode_documents( + Scope::ProjectLocal, + tmp.path(), + &tmp.path().join("global/opencode.json") + ) + .unwrap_err() + .to_string() + .contains("ambiguous provider")); + } + + #[test] + fn non_opencode_resolver_selects_one_candidate_and_rejects_two() { + let tmp = tempfile::tempdir().unwrap(); + let primary = tmp.path().join("settings.json"); + let alternate = tmp.path().join("settings.jsonc"); + assert_eq!( + provider_config_at(Provider::Zed, &primary).unwrap(), + primary + ); + std::fs::write(&alternate, "{}").unwrap(); + assert_eq!( + provider_config_at(Provider::Zed, &primary).unwrap(), + alternate + ); + std::fs::write(&primary, "{}").unwrap(); + assert!(provider_config_at(Provider::Zed, &primary).is_err()); + + let codex = tmp.path().join("config.toml"); + let codex_local = tmp.path().join("config.local.toml"); + std::fs::write(&codex_local, "").unwrap(); + assert_eq!( + provider_config_at(Provider::Codex, &codex).unwrap(), + codex_local + ); + } + + fn registry_test_spec( + definition: &'static ProviderDefinition, + scope: Scope, + root: &Path, + ) -> ProviderSpec { + let templates = match scope { + Scope::ProjectLocal => definition.project, + Scope::User => definition.user, + }; + let documents = templates + .iter() + .enumerate() + .map(|(index, template)| { + let extension = if template.kind == DocumentKind::Codex { + "toml" + } else { + "json" + }; + document( + root.join(format!( + "{}-{index}.{extension}", + definition.provider.as_str() + )), + template.kind, + ) + }) + .collect::>(); + ProviderSpec { + definition, + provider: definition.provider, + scope, + inspection_documents: documents.clone(), + documents, + } + } + + fn seed_registry_documents(spec: &ProviderSpec) { + for document in &spec.documents { + std::fs::create_dir_all(document.path.parent().unwrap()).unwrap(); + let content = match document.kind { + DocumentKind::Codex => b"[sentinel]\nkeep = 'unchanged'\n\ + [mcp_servers.external.tools.external_tool]\napproval_mode = 'deny'\n" + .to_vec(), + DocumentKind::JsonPermissions => serde_json::to_vec(&serde_json::json!({ + "sentinel":"unchanged", + "permissions":{"deny":["Bash(rm:*)"],"ask":["WebFetch(*)"]} + })) + .unwrap(), + DocumentKind::OpenCode => serde_json::to_vec(&serde_json::json!({ + "sentinel":"unchanged", + "permissions":[{"action":"dangerous_*","resource":"*","effect":"deny"}] + })) + .unwrap(), + DocumentKind::Zed => serde_json::to_vec(&serde_json::json!({ + "sentinel":"unchanged", + "agent":{"tool_permissions":{"default":"confirm","tools":{ + "dangerous.write":{"default":"deny"} + }}} + })) + .unwrap(), + DocumentKind::JsonRegistration => br#"{"sentinel":"unchanged"}"#.to_vec(), + }; + std::fs::write(&document.path, &content).unwrap(); + } + } + + fn prepared_zed_recovery( + root: &Path, + ) -> ( + ProviderSpec, + PathBuf, + ManifestLock, + InstallManifest, + String, + Vec, + ) { + let document = document(root.join("settings.jsonc"), DocumentKind::Zed); + std::fs::write( + &document.path, + b"{\n // unrelated user bytes\n \"sentinel\": \"before\",\n}\n", + ) + .unwrap(); + let spec = ProviderSpec { + definition: Provider::Zed.definition(), + provider: Provider::Zed, + scope: Scope::ProjectLocal, + documents: vec![document.clone()], + inspection_documents: vec![document], + }; + let manifest_path = root.join("install-manifest.json"); + let lock = InstallManifest::lock(&manifest_path).unwrap(); + let mut manifest = InstallManifest::empty(); + manifest.provider_ownership = + Some(crate::provider_journal::ProviderOwnership::empty("icm123").unwrap()); + let prepared = prepare_source_document(&spec, &spec.documents[0], "icm123").unwrap(); + let prepared_after = prepared.after.clone(); + let operation_id = prepare_trust_operation(&mut manifest, &spec, &[prepared]) + .unwrap() + .unwrap(); + manifest.save_locked(&manifest_path, &lock).unwrap(); + ( + spec, + manifest_path, + lock, + manifest, + operation_id, + prepared_after, + ) + } + + #[test] + fn recovery_rebases_disjoint_edits_before_writing() { + let tmp = tempfile::tempdir().unwrap(); + let (spec, manifest_path, lock, mut manifest, operation_id, _) = + prepared_zed_recovery(tmp.path()); + let path = &spec.documents[0].path; + let external = std::fs::read_to_string(path) + .unwrap() + .replace("\"before\"", "\"external-disjoint\"") + .into_bytes(); + std::fs::write(path, &external).unwrap(); + let external_hash = sha256(&external); + + recover_provenance( + &mut manifest, + &TargetArgs { + provider: Provider::Zed, + scope: Scope::ProjectLocal, + }, + "icm123", + &manifest_path, + &lock, + ) + .unwrap(); + + let final_bytes = std::fs::read(path).unwrap(); + assert!(String::from_utf8_lossy(&final_bytes).contains("external-disjoint")); + assert!(String::from_utf8_lossy(&final_bytes).contains("unrelated user bytes")); + assert!(inspect(&spec, "icm123") + .unwrap() + .iter() + .all(|observation| observation.exists && !observation.blocked)); + let ownership = manifest.provider_ownership.as_ref().unwrap(); + let operation = ownership + .operations + .iter() + .find(|operation| operation.id == operation_id) + .unwrap(); + assert_eq!(ownership.generation, 2); + assert_eq!(operation.requested.action, OperationAction::Recover); + assert_eq!(operation.phase, OperationPhase::Applied); + assert!(operation.targets.iter().all(|target| { + target.before_hash == external_hash + && target.expected_after_hash == sha256(&final_bytes) + && target.observed_after_hash.as_ref() == Some(&target.expected_after_hash) + })); + assert!(ownership + .owned_fragments + .iter() + .all(|fragment| fragment.generation == 2)); + + let manifest_bytes = std::fs::read(&manifest_path).unwrap(); + recover_provenance( + &mut manifest, + &TargetArgs { + provider: Provider::Zed, + scope: Scope::ProjectLocal, + }, + "icm123", + &manifest_path, + &lock, + ) + .unwrap(); + assert_eq!(std::fs::read(path).unwrap(), final_bytes); + assert_eq!(std::fs::read(manifest_path).unwrap(), manifest_bytes); + } + + #[test] + fn recovery_finalizes_an_already_written_after_state() { + let tmp = tempfile::tempdir().unwrap(); + let (spec, manifest_path, lock, mut manifest, operation_id, prepared_after) = + prepared_zed_recovery(tmp.path()); + let path = &spec.documents[0].path; + std::fs::write(path, &prepared_after).unwrap(); + let after_hash = sha256(&prepared_after); + + recover_provenance( + &mut manifest, + &TargetArgs { + provider: Provider::Zed, + scope: Scope::ProjectLocal, + }, + "icm123", + &manifest_path, + &lock, + ) + .unwrap(); + + assert_eq!(std::fs::read(path).unwrap(), prepared_after); + let ownership = manifest.provider_ownership.as_ref().unwrap(); + let operation = ownership + .operations + .iter() + .find(|operation| operation.id == operation_id) + .unwrap(); + assert_eq!(ownership.generation, 2); + assert_eq!(operation.requested.action, OperationAction::Recover); + assert_eq!(operation.phase, OperationPhase::Applied); + assert!(operation.targets.iter().all(|target| { + target.before_hash == after_hash + && target.expected_after_hash == after_hash + && target.observed_after_hash.as_ref() == Some(&after_hash) + })); + assert_eq!(ownership.owned_fragments.len(), 3); + assert!(ownership + .owned_fragments + .iter() + .all(|fragment| fragment.generation == 2)); + } + + #[test] + fn recovery_marks_semantic_overlap_without_writing_target() { + let tmp = tempfile::tempdir().unwrap(); + let (spec, manifest_path, lock, mut manifest, operation_id, _) = + prepared_zed_recovery(tmp.path()); + let path = &spec.documents[0].path; + let desired_hash = manifest.provider_ownership.as_ref().unwrap().operations[0].targets[0] + .expected_after_hash + .clone(); + let conflict = br#"{"context_servers":{"icm123":{"command":"external"}}}"#; + std::fs::write(path, conflict).unwrap(); + let conflict_hash = sha256(conflict); + + assert!(recover_provenance( + &mut manifest, + &TargetArgs { + provider: Provider::Zed, + scope: Scope::ProjectLocal, + }, + "icm123", + &manifest_path, + &lock, + ) + .is_err()); + + assert_eq!(std::fs::read(path).unwrap(), conflict); + let ownership = manifest.provider_ownership.as_ref().unwrap(); + let operation = ownership + .operations + .iter() + .find(|operation| operation.id == operation_id) + .unwrap(); + assert_eq!(ownership.generation, 2); + assert_eq!(operation.requested.action, OperationAction::Recover); + assert_eq!(operation.phase, OperationPhase::Conflict); + assert!(operation.targets.iter().all(|target| { + target.before_hash == conflict_hash + && target.expected_after_hash == desired_hash + && target.observed_after_hash.as_ref() == Some(&conflict_hash) + })); + assert!(manifest.provider_splices.is_empty()); + assert!(ownership.owned_fragments.is_empty()); + } + + #[test] + fn removal_recovery_rebases_disjoint_edits() { + let tmp = tempfile::tempdir().unwrap(); + let document = document(tmp.path().join("settings.jsonc"), DocumentKind::Zed); + std::fs::write( + &document.path, + b"{\n // unrelated user bytes\n \"sentinel\": \"before\",\n}\n", + ) + .unwrap(); + let spec = ProviderSpec { + definition: Provider::Zed.definition(), + provider: Provider::Zed, + scope: Scope::ProjectLocal, + documents: vec![document.clone()], + inspection_documents: vec![document], + }; + let manifest_path = tmp.path().join("install-manifest.json"); + let lock = InstallManifest::lock(&manifest_path).unwrap(); + let mut manifest = InstallManifest::empty(); + manifest.provider_ownership = + Some(crate::provider_journal::ProviderOwnership::empty("icm123").unwrap()); + apply_documents(&spec, "icm123", &mut manifest, &manifest_path, &lock).unwrap(); + + let prepared = prepare_removal_document( + &manifest, + Provider::Zed, + Scope::ProjectLocal, + &spec.documents[0], + RemovalMode::TrustOnly, + ) + .unwrap() + .unwrap(); + let operation_id = prepare_removal_operation( + &mut manifest, + Provider::Zed, + Scope::ProjectLocal, + RemovalMode::TrustOnly, + &[prepared], + ) + .unwrap(); + manifest.save_locked(&manifest_path, &lock).unwrap(); + + let external = std::fs::read_to_string(&spec.documents[0].path) + .unwrap() + .replace("\"before\"", "\"external-disjoint\"") + .into_bytes(); + std::fs::write(&spec.documents[0].path, &external).unwrap(); + let external_hash = sha256(&external); + recover_provenance( + &mut manifest, + &TargetArgs { + provider: Provider::Zed, + scope: Scope::ProjectLocal, + }, + "icm123", + &manifest_path, + &lock, + ) + .unwrap(); + + let final_bytes = std::fs::read(&spec.documents[0].path).unwrap(); + let observations = inspect(&spec, "icm123").unwrap(); + assert!(observations[0].exists); + assert!(observations + .iter() + .skip(1) + .all(|observation| !observation.exists)); + assert!(String::from_utf8_lossy(&final_bytes).contains("external-disjoint")); + assert!(String::from_utf8_lossy(&final_bytes).contains("unrelated user bytes")); + let ownership = manifest.provider_ownership.as_ref().unwrap(); + let operation = ownership + .operations + .iter() + .find(|operation| operation.id == operation_id) + .unwrap(); + assert_eq!(ownership.generation, 3); + assert_eq!(operation.requested.action, OperationAction::Recover); + assert_eq!(operation.phase, OperationPhase::Removed); + assert!(operation.targets.iter().all(|target| { + target.before_hash == external_hash + && target.expected_after_hash == sha256(&final_bytes) + && target.observed_after_hash.as_ref() == Some(&target.expected_after_hash) + })); + } + + #[test] + fn registry_drives_every_provider_and_scope_through_one_lifecycle() { + let mut scenarios = 0; + for definition in PROVIDER_REGISTRY { + for scope in Scope::ALL { + scenarios += 1; + let tmp = tempfile::tempdir().unwrap(); + let spec = registry_test_spec(definition, scope, tmp.path()); + seed_registry_documents(&spec); + let manifest_path = tmp.path().join("install-manifest.json"); + let lock = InstallManifest::lock(&manifest_path).unwrap(); + let mut manifest = InstallManifest::empty(); + let server_id = "icm123"; + manifest.provider_ownership = + Some(crate::provider_journal::ProviderOwnership::empty(server_id).unwrap()); + + apply_documents(&spec, server_id, &mut manifest, &manifest_path, &lock).unwrap(); + let first_bytes = spec + .documents + .iter() + .map(|document| std::fs::read(&document.path).unwrap()) + .collect::>(); + let observations = inspect(&spec, server_id).unwrap(); + assert!( + observations.iter().all(|item| item.exists && !item.blocked), + "{} {} did not become trusted", + definition.provider.as_str(), + scope.as_str() + ); + let ownership = manifest.provider_ownership.as_ref().unwrap(); + assert_eq!(ownership.generation, 1); + assert_eq!(ownership.operations.len(), 1); + assert_eq!(ownership.operations[0].targets.len(), 3); + let owned = ownership + .owned_fragments + .iter() + .filter(|fragment| fragment.ownership_kind == OwnershipKind::Owned) + .collect::>(); + assert_eq!(owned.len(), 3); + let owned_paths = group_owned_paths(&manifest); + assert_eq!( + owned_paths + .iter() + .map(|path| path.entries_owned) + .sum::(), + 3 + ); + assert!(owned_paths.iter().all(|owned| spec + .documents + .iter() + .any(|document| document.path == owned.path))); + let exact_tool_rules = tool_rules(definition.provider, server_id); + assert_eq!(exact_tool_rules.len(), TOOLS.len()); + assert!(exact_tool_rules.iter().all(|rule| { + owned + .iter() + .any(|fragment| fragment.semantic_selector == *rule) + && TOOLS.iter().any(|tool| rule.contains(tool)) + })); + assert!(!owned.iter().any(|fragment| { + fragment.semantic_selector == format!("mcp__{server_id}") + || fragment.semantic_selector == format!("mcp__{server_id}__*") + || fragment.semantic_selector == format!("Mcp({server_id}:*)") + })); + let owned_count = owned.len(); + let first_manifest = std::fs::read(&manifest_path).unwrap(); + + apply_documents(&spec, server_id, &mut manifest, &manifest_path, &lock).unwrap(); + assert_eq!( + first_bytes, + spec.documents + .iter() + .map(|document| std::fs::read(&document.path).unwrap()) + .collect::>() + ); + assert_eq!(first_manifest, std::fs::read(&manifest_path).unwrap()); + assert_eq!( + owned_count, + manifest + .provider_ownership + .as_ref() + .unwrap() + .owned_fragments + .iter() + .filter(|fragment| fragment.ownership_kind == OwnershipKind::Owned) + .count() + ); + + let summary = strip_documents( + definition.provider, + scope, + &spec.documents, + RemovalMode::TrustOnly, + &mut manifest, + &manifest_path, + &lock, + ) + .unwrap(); + assert_eq!( + summary + .outcomes + .iter() + .map(|outcome| outcome.entries_removed) + .sum::(), + owned_count - 1 + ); + assert!(summary.outcomes.iter().all(|outcome| { + !outcome.deleted + && spec + .documents + .iter() + .any(|document| document.path == outcome.path) + })); + let stripped = inspect(&spec, server_id).unwrap(); + assert!(stripped[0].exists, "strip must retain registration"); + assert!(stripped.iter().skip(1).all(|item| !item.exists)); + assert_eq!( + manifest + .provider_ownership + .as_ref() + .unwrap() + .owned_fragments + .iter() + .filter(|fragment| fragment.ownership_kind == OwnershipKind::Owned) + .count(), + 1 + ); + + let uninstall = strip_documents( + definition.provider, + scope, + &spec.documents, + RemovalMode::Uninstall, + &mut manifest, + &manifest_path, + &lock, + ) + .unwrap(); + assert_eq!( + uninstall + .outcomes + .iter() + .map(|outcome| outcome.entries_removed) + .sum::(), + 1 + ); + assert!(inspect(&spec, server_id) + .unwrap() + .iter() + .all(|item| !item.exists)); + assert!(!manifest + .provider_ownership + .as_ref() + .unwrap() + .owned_fragments + .iter() + .any(|fragment| fragment.ownership_kind == OwnershipKind::Owned)); + for document in &spec.documents { + match read_document(document).unwrap().1 { + DocumentValue::Json(value) => { + assert_eq!(value["sentinel"], "unchanged"); + match document.kind { + DocumentKind::JsonPermissions => { + assert_eq!(value["permissions"]["deny"][0], "Bash(rm:*)"); + assert_eq!(value["permissions"]["ask"][0], "WebFetch(*)"); + } + DocumentKind::OpenCode => { + assert_eq!(value["permissions"][0]["effect"], "deny") + } + DocumentKind::Zed => assert_eq!( + value["agent"]["tool_permissions"]["tools"]["dangerous.write"] + ["default"], + "deny" + ), + _ => {} + } + } + DocumentValue::Toml(value) => { + assert_eq!(value["sentinel"]["keep"].as_str(), Some("unchanged")); + assert_eq!( + value["mcp_servers"]["external"]["tools"]["external_tool"] + ["approval_mode"] + .as_str(), + Some("deny") + ); + } + } + } + } + } + assert_eq!(scenarios, PROVIDER_REGISTRY.len() * Scope::ALL.len()); + } + #[test] + fn wildcard_and_higher_precedence_blockers_are_effective() { + let claude = serde_json::json!({ + "permissions": { + "allow": ["mcp__icm123__icm_memory_recall"], + "deny": ["mcp__icm123__*"] + } + }); + assert!(inspect_json_permissions( + Provider::ClaudeCode, + Path::new("claude.json"), + &claude, + "icm123" + ) + .unwrap() + .iter() + .all(|item| item.blocked)); + + let cursor = serde_json::json!({ + "permissions": { + "allow": ["Mcp(icm123:icm_memory_recall)"], + "deny": ["Mcp(icm123:*)"] + } + }); + assert!(inspect_json_permissions( + Provider::Cursor, + Path::new("cursor.json"), + &cursor, + "icm123" + ) + .unwrap() + .iter() + .all(|item| item.blocked)); + + let zed = serde_json::json!({ + "agent":{"tool_permissions":{"tools":{ + "mcp:icm123:icm_memory_recall":{ + "default":"allow", + "always_confirm":[{"pattern":".*"}] + } + }}} + }); + assert!(inspect_zed(Path::new("zed.json"), &zed, "icm123").unwrap()[1].blocked); + } +} diff --git a/crates/icm-cli/src/uninstall/mod.rs b/crates/icm-cli/src/uninstall/mod.rs index e604b13d..1d157b1b 100644 --- a/crates/icm-cli/src/uninstall/mod.rs +++ b/crates/icm-cli/src/uninstall/mod.rs @@ -102,6 +102,11 @@ pub fn run(opts: UninstallOpts) -> Result { let dirs = locations::DirContext::from_env()?; let specs = locations::build_locations(&dirs); let mut plan = discover::scan(&specs, opts.purge_data)?; + let provider_paths = crate::provider::owned_paths()?; + let provider_entries = provider_paths + .iter() + .map(|owned| owned.entries_owned) + .sum::(); if let Some(dir) = opts.scan_dir.as_deref() { plan.scan_dir_hits = scan_dir::scan_dir(dir)?; } @@ -117,23 +122,38 @@ pub fn run(opts: UninstallOpts) -> Result { // --- Read-only modes --- if opts.check { - return Ok(report::print_check(&plan)); + return Ok(report::print_check(&plan, provider_entries)); } if opts.audit { - report::print_audit(&plan, "ICM uninstall audit", opts.purge_data); + report::print_audit( + &plan, + "ICM uninstall audit", + opts.purge_data, + &provider_paths, + ); return Ok(exit_codes::CLEAN); } if opts.dry_run { - report::print_audit(&plan, "ICM uninstall (dry run)", opts.purge_data); + report::print_audit( + &plan, + "ICM uninstall (dry run)", + opts.purge_data, + &provider_paths, + ); return Ok(exit_codes::CLEAN); } // --- Mutating run --- - if plan.is_empty() { + if plan.is_empty() && provider_paths.is_empty() { println!("Nothing to uninstall — already clean."); return Ok(exit_codes::CLEAN); } - report::print_audit(&plan, "ICM uninstall plan", opts.purge_data); + report::print_audit( + &plan, + "ICM uninstall plan", + opts.purge_data, + &provider_paths, + ); if !opts.yes && !mutate::confirm("Proceed with removal?") { println!("Aborted (no changes made)."); @@ -158,8 +178,31 @@ pub fn run(opts: UninstallOpts) -> Result { )?) }; + if let Some(backup) = backup_session.as_mut() { + for owned in &provider_paths { + backup.stage(&owned.path)?; + } + } + + let provider_removed = crate::provider::strip_all_owned()?; + let mut outcomes = provider_removed + .outcomes + .into_iter() + .map(|outcome| mutate::ApplyOutcome { + path: outcome.path, + label: "Provider MCP", + entries_removed: outcome.entries_removed, + result: Ok(if outcome.deleted { + formats::StripResult::DeleteFile + } else { + formats::StripResult::Removed { + removed: outcome.entries_removed, + } + }), + }) + .collect::>(); + outcomes.extend(mutate::apply(&plan, &specs, &mut backup_session)); let mut summary = mutate::ApplySummary::default(); - let outcomes = mutate::apply(&plan, &specs, &mut backup_session); for o in &outcomes { summary.record(o); } @@ -225,11 +268,16 @@ pub fn run(opts: UninstallOpts) -> Result { // Verify pass: rescan to detect any residue (ambiguous YAML, parse // errors that skipped a file, etc.). let after = discover::scan(&specs, opts.purge_data)?; + let provider_after = crate::provider::owned_paths()?; + let provider_residue = provider_after + .iter() + .map(|owned| owned.entries_owned) + .sum::(); let exit = report::print_apply_summary( &outcomes, &summary, backup_session.as_ref().map(|b| b.root()), - after.total_hits(), + after.total_hits() + provider_residue, ); Ok(exit) } diff --git a/crates/icm-cli/src/uninstall/mutate.rs b/crates/icm-cli/src/uninstall/mutate.rs index 7a9ae309..28a2a66a 100644 --- a/crates/icm-cli/src/uninstall/mutate.rs +++ b/crates/icm-cli/src/uninstall/mutate.rs @@ -19,6 +19,7 @@ use super::locations::{HookCommandField, LocationKind, LocationSpec}; pub(crate) struct ApplyOutcome { pub path: std::path::PathBuf, pub label: &'static str, + pub entries_removed: usize, pub result: Result, } @@ -91,9 +92,14 @@ pub(crate) fn apply( LocationKind::DataDir => Ok(StripResult::NoOp), } })(); + let entries_removed = match &result { + Ok(StripResult::Removed { removed }) => *removed, + _ => 0, + }; outcomes.push(ApplyOutcome { path: hit.path.clone(), label: hit.spec_label, + entries_removed, result, }); } @@ -170,6 +176,7 @@ pub(crate) fn purge_data( outcomes.push(ApplyOutcome { path: hit.path.clone(), label: hit.spec_label, + entries_removed: 0, result: res, }); } @@ -184,18 +191,30 @@ pub(crate) struct ApplySummary { pub entries_removed: usize, pub errors: Vec<(std::path::PathBuf, String)>, pub ambiguous: Vec<(std::path::PathBuf, String)>, + changed_paths: std::collections::BTreeSet, + deleted_paths: std::collections::BTreeSet, } impl ApplySummary { pub fn record(&mut self, outcome: &ApplyOutcome) { match &outcome.result { Ok(StripResult::NoOp) => {} - Ok(StripResult::Removed { removed }) => { - self.files_changed += 1; - self.entries_removed += removed; + Ok(StripResult::Removed { .. }) => { + self.entries_removed += outcome.entries_removed; + if !self.deleted_paths.contains(&outcome.path) + && self.changed_paths.insert(outcome.path.clone()) + { + self.files_changed += 1; + } } Ok(StripResult::DeleteFile) => { - self.files_deleted += 1; + self.entries_removed += outcome.entries_removed; + if self.changed_paths.remove(&outcome.path) { + self.files_changed -= 1; + } + if self.deleted_paths.insert(outcome.path.clone()) { + self.files_deleted += 1; + } } Ok(StripResult::Ambiguous { reason }) => { self.ambiguous.push((outcome.path.clone(), reason.clone())); diff --git a/crates/icm-cli/src/uninstall/report.rs b/crates/icm-cli/src/uninstall/report.rs index be5ae1f3..d36b2b8e 100644 --- a/crates/icm-cli/src/uninstall/report.rs +++ b/crates/icm-cli/src/uninstall/report.rs @@ -2,16 +2,22 @@ //! `--audit`) and the post-mutation summary. use super::discover::{HitDetail, RemovalPlan}; +use crate::provider::OwnedProviderPath; /// Print the audit / dry-run preview. Groups hits by file so users see /// each path once with all the things uninstall would touch under it. /// `purge_data` toggles the wording for [`HitDetail::DataDir`] so the /// preview matches what the run is actually about to do. -pub(crate) fn print_audit(plan: &RemovalPlan, header: &str, purge_data: bool) { +pub(crate) fn print_audit( + plan: &RemovalPlan, + header: &str, + purge_data: bool, + provider_paths: &[OwnedProviderPath], +) { println!("{header}"); println!("{}", "=".repeat(header.len())); - if plan.hits.is_empty() && plan.scan_dir_hits.is_empty() { + if plan.hits.is_empty() && plan.scan_dir_hits.is_empty() && provider_paths.is_empty() { println!("No known ICM residue found."); return; } @@ -24,6 +30,18 @@ pub(crate) fn print_audit(plan: &RemovalPlan, header: &str, purge_data: bool) { purge_data, ); } + if !provider_paths.is_empty() { + println!(); + println!("Provider-owned MCP values"); + println!("-------------------------"); + for owned in provider_paths { + println!( + " {} ({} value(s))", + owned.path.display(), + owned.entries_owned + ); + } + } if !plan.processes.is_empty() { println!(); @@ -34,7 +52,14 @@ pub(crate) fn print_audit(plan: &RemovalPlan, header: &str, purge_data: bool) { } println!(); - println!("Total: {} item(s).", plan.total_hits()); + println!( + "Total: {} item(s).", + plan.total_hits() + + provider_paths + .iter() + .map(|owned| owned.entries_owned) + .sum::() + ); } fn print_section(title: &str, hits: &[super::discover::LocationHit], purge_data: bool) { @@ -100,12 +125,15 @@ fn print_section(title: &str, hits: &[super::discover::LocationHit], purge_data: } /// Brief output for `--check`. Returns the exit code. -pub(crate) fn print_check(plan: &RemovalPlan) -> i32 { - if plan.is_empty() { +pub(crate) fn print_check(plan: &RemovalPlan, provider_count: usize) -> i32 { + if plan.is_empty() && provider_count == 0 { println!("OK: no known ICM residue found"); super::exit_codes::CLEAN } else { - println!("FOUND: {} known ICM residue item(s)", plan.total_hits()); + println!( + "FOUND: {} known ICM residue item(s)", + plan.total_hits() + provider_count + ); super::exit_codes::CHECK_RESIDUE } } diff --git a/crates/icm-cli/tests/uninstall_integration.rs b/crates/icm-cli/tests/uninstall_integration.rs index 63a0c90a..21aaf98d 100644 --- a/crates/icm-cli/tests/uninstall_integration.rs +++ b/crates/icm-cli/tests/uninstall_integration.rs @@ -157,6 +157,68 @@ fn uninstall_is_idempotent_when_already_clean() { ); } +#[test] +fn uninstall_removes_manifest_owned_provider_values() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + let cwd = home.join("proj"); + std::fs::create_dir_all(cwd.join(".git")).unwrap(); + + let trust = icm_in( + home, + &cwd, + &[ + "provider", + "trust", + "--provider", + "zed", + "--scope", + "project-local", + "--yes", + ], + ); + assert!( + trust.status.success(), + "provider trust failed: {}", + String::from_utf8_lossy(&trust.stderr) + ); + let settings = cwd.join(".zed/settings.json"); + assert!(std::fs::read_to_string(&settings) + .unwrap() + .contains("icm_memory_recall")); + + let other_cwd = home.join("other-project"); + std::fs::create_dir_all(&other_cwd).unwrap(); + let check = icm_in(home, &other_cwd, &["uninstall", "--check"]); + assert_eq!(check.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&check.stdout).contains("FOUND: 3 known ICM residue item(s)")); + + let uninstall = icm_in(home, &other_cwd, &["uninstall", "--yes", "--no-backup"]); + assert!( + uninstall.status.success(), + "provider uninstall failed: {}", + String::from_utf8_lossy(&uninstall.stderr) + ); + assert!(!settings.exists()); + let stdout = String::from_utf8_lossy(&uninstall.stdout); + assert!( + stdout.contains("[Provider MCP"), + "missing provider result: {stdout}" + ); + assert!( + stdout.contains("Files modified : 0"), + "wrong modified count: {stdout}" + ); + assert!( + stdout.contains("Files deleted : 1"), + "wrong deleted count: {stdout}" + ); + assert!( + stdout.contains("Entries removed: 3"), + "wrong entry count: {stdout}" + ); +} + // `directories::ProjectDirs` returns OS-specific data/cache paths that // don't match the Linux XDG layout this test seeds. Gate it to Linux // where seed and code agree — the macOS/Windows path resolution is From d93fb9853a40d5f3efd00cae31d17f91518b0550 Mon Sep 17 00:00:00 2001 From: Lauritz-Timm Date: Mon, 10 Aug 2026 00:13:00 +0200 Subject: [PATCH 3/3] fix(provider): preserve recovery invariants --- crates/icm-cli/src/provider.rs | 350 ++++++++++++++---- .../tests/fixtures/providers/codex.toml | 5 + .../tests/fixtures/providers/opencode.json | 6 + .../tests/fixtures/providers/permissions.json | 7 + .../fixtures/providers/registration.json | 3 + .../icm-cli/tests/fixtures/providers/zed.json | 11 + crates/icm-cli/tests/uninstall_integration.rs | 101 +++++ 7 files changed, 414 insertions(+), 69 deletions(-) create mode 100644 crates/icm-cli/tests/fixtures/providers/codex.toml create mode 100644 crates/icm-cli/tests/fixtures/providers/opencode.json create mode 100644 crates/icm-cli/tests/fixtures/providers/permissions.json create mode 100644 crates/icm-cli/tests/fixtures/providers/registration.json create mode 100644 crates/icm-cli/tests/fixtures/providers/zed.json diff --git a/crates/icm-cli/src/provider.rs b/crates/icm-cli/src/provider.rs index 7aad5a9d..0f03b967 100644 --- a/crates/icm-cli/src/provider.rs +++ b/crates/icm-cli/src/provider.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use std::cell::Cell; use std::collections::{BTreeMap, BTreeSet}; use std::fs::File; use std::path::{Path, PathBuf}; @@ -57,6 +59,7 @@ enum Location { Project(&'static str), Home(&'static str), CodexUser, + ClaudeLegacyUser, ClaudeSettingsUser, OpenCodeUser, ZedUser, @@ -129,7 +132,7 @@ const CLAUDE_PROJECT: &[DocumentTemplate] = &[ ]; const CLAUDE_USER: &[DocumentTemplate] = &[ DocumentTemplate { - location: Location::Home(".claude.json"), + location: Location::ClaudeLegacyUser, kind: DocumentKind::JsonRegistration, }, DocumentTemplate { @@ -174,6 +177,19 @@ const ZED_USER: &[DocumentTemplate] = &[DocumentTemplate { kind: DocumentKind::Zed, }]; +#[cfg(test)] +thread_local! { + static FAIL_AFTER_FIRST_DOCUMENT_WRITE: Cell = const { Cell::new(false) }; +} + +#[cfg(test)] +fn fail_after_first_document_write(operation: impl FnOnce() -> T) -> T { + FAIL_AFTER_FIRST_DOCUMENT_WRITE.with(|fail| fail.set(true)); + let result = operation(); + FAIL_AFTER_FIRST_DOCUMENT_WRITE.with(|fail| fail.set(false)); + result +} + /// The source-controlled authorization allowlist. Paths, formats, rule /// shapes, plan metadata, and lifecycle tests all derive from this table. const PROVIDER_REGISTRY: &[ProviderDefinition] = &[ @@ -518,7 +534,10 @@ fn mutate_target(target: &TargetArgs, action: LifecycleAction) -> Result installation_server_id(&manifest)?, + LifecycleAction::Strip => planning_server_id(&manifest), + }; reject_prepared_operations(&manifest)?; let enforce_resolution = matches!(action, LifecycleAction::Apply); let plan = resolve_plan(target.provider, target.scope, enforce_resolution, &manifest)?; @@ -659,7 +678,7 @@ fn resolve_plan( if enforce_resolution { validate_resolved_paths(&spec)?; } - let server_id = installation_server_id(manifest)?; + let server_id = planning_server_id(manifest); let observations = inspect(&spec, &server_id)?; let (preserved_restrictions, causal_blockers) = observed_restrictions(&spec, &server_id)?; @@ -759,6 +778,11 @@ fn provider_spec(provider: Provider, scope: Scope) -> Result { .map(PathBuf::from) .unwrap_or_else(|| home.join(".codex")) .join("config.toml"), + Location::ClaudeLegacyUser => std::env::var_os("CLAUDE_CONFIG_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.clone()) + .join(".claude.json"), Location::ClaudeSettingsUser => std::env::var_os("CLAUDE_CONFIG_DIR") .filter(|value| !value.is_empty()) .map(PathBuf::from) @@ -1051,6 +1075,14 @@ fn installation_server_id(manifest: &InstallManifest) -> Result { Ok(manifest.installation_id()?.to_owned()) } +fn planning_server_id(manifest: &InstallManifest) -> String { + manifest + .provider_ownership + .as_ref() + .map(|ownership| ownership.installation_id.clone()) + .unwrap_or_else(|| "icm".to_owned()) +} + fn validate_resolved_paths(spec: &ProviderSpec) -> Result<()> { for document in &spec.documents { if spec.provider == Provider::OpenCode @@ -2136,53 +2168,80 @@ fn prepare_trust_operation( spec: &ProviderSpec, documents: &[PreparedSourceDocument], ) -> Result> { - let ownership = manifest - .provider_ownership - .as_ref() - .context("provider ownership journal is not initialized")?; let mut planned = Vec::new(); - for document in documents { - let path = utf8_path(&document.document.path)?.to_owned(); - for observation in document - .observations - .iter() - .filter(|observation| public_observation(document.document.kind, observation)) - { - let prior = ownership.owned_fragments.iter().find(|fragment| { - fragment.provider == spec.definition.id - && fragment.scope == spec.scope.journal_scope() - && fragment.canonical_path == path - && fragment.semantic_selector == observation.rule - }); - if prior.is_some_and(|fragment| { - observation.exists - && matches!( - fragment.ownership_kind, - OwnershipKind::Owned | OwnershipKind::Adopted - ) - }) { - continue; - } - if prior.is_some() { - bail!("provider ownership history requires explicit recovery before re-trust"); + let mut retired = BTreeSet::new(); + { + let ownership = manifest + .provider_ownership + .as_ref() + .context("provider ownership journal is not initialized")?; + for document in documents { + let path = utf8_path(&document.document.path)?.to_owned(); + for observation in document + .observations + .iter() + .filter(|observation| public_observation(document.document.kind, observation)) + { + let prior = ownership.owned_fragments.iter().find(|fragment| { + fragment.provider == spec.definition.id + && fragment.scope == spec.scope.journal_scope() + && fragment.canonical_path == path + && fragment.semantic_selector == observation.rule + }); + if prior.is_some_and(|fragment| { + observation.exists + && matches!( + fragment.ownership_kind, + OwnershipKind::Owned | OwnershipKind::Adopted + ) + }) { + continue; + } + if let Some(fragment) = prior { + if fragment.ownership_kind != OwnershipKind::Removed { + bail!( + "provider ownership history requires explicit recovery before re-trust" + ); + } + retired.insert((path.clone(), observation.rule.clone())); + } + let patch = (!observation.exists) + .then(|| { + document + .patches + .get(&observation.rule) + .cloned() + .with_context(|| { + format!("lossless patch is absent for {:?}", observation.rule) + }) + }) + .transpose()?; + planned.push((document, observation, patch)); } - let patch = (!observation.exists) - .then(|| { - document - .patches - .get(&observation.rule) - .cloned() - .with_context(|| { - format!("lossless patch is absent for {:?}", observation.rule) - }) - }) - .transpose()?; - planned.push((document, observation, patch)); } } + if !retired.is_empty() { + let ownership = manifest + .provider_ownership + .as_mut() + .context("provider ownership journal is not initialized")?; + ownership.owned_fragments.retain(|fragment| { + !(fragment.provider == spec.definition.id + && fragment.scope == spec.scope.journal_scope() + && fragment.ownership_kind == OwnershipKind::Removed + && retired.contains(&( + fragment.canonical_path.clone(), + fragment.semantic_selector.clone(), + ))) + }); + } if planned.is_empty() { return Ok(None); } + let ownership = manifest + .provider_ownership + .as_ref() + .context("provider ownership journal is not initialized")?; if ownership.owned_fragments.len() + planned.len() > MAX_OWNED_FRAGMENTS { bail!("provider journal owned-fragment retention bound exceeded"); } @@ -2199,6 +2258,17 @@ fn prepare_trust_operation( let mut records = Vec::new(); for (document, observation, source_patch) in planned { let canonical_path = utf8_path(&document.document.path)?.to_owned(); + // Preserve the path's original origin when re-trusting a retained fragment. + let document_origin = manifest + .provider_splices + .iter() + .find(|record| { + record.provider == requested.provider + && record.scope == requested.scope + && record.canonical_path == canonical_path + }) + .map(|record| record.document_origin) + .unwrap_or(document.document_origin); let fingerprint = fragment_fingerprint( document.document.kind, &observation.rule, @@ -2252,7 +2322,7 @@ fn prepare_trust_operation( canonical_path, semantic_selector: observation.rule.clone(), introducing_operation_id: operation_id.clone(), - document_origin: document.document_origin, + document_origin, source_patch, }); } @@ -2477,6 +2547,10 @@ fn apply_documents( Some(&document.after), )?; verify_provider_document(&document.document, Some(&document.after))?; + #[cfg(test)] + if FAIL_AFTER_FIRST_DOCUMENT_WRITE.with(|fail| fail.replace(false)) { + bail!("injected provider interruption after first document write"); + } } finalize_operation(manifest, &operation_id, OperationPhase::Applied)?; manifest.save_locked(manifest_path, lock)?; @@ -3810,33 +3884,24 @@ mod tests { } } + fn registry_fixture(kind: DocumentKind) -> &'static [u8] { + match kind { + DocumentKind::Codex => include_bytes!("../tests/fixtures/providers/codex.toml"), + DocumentKind::JsonRegistration => { + include_bytes!("../tests/fixtures/providers/registration.json") + } + DocumentKind::JsonPermissions => { + include_bytes!("../tests/fixtures/providers/permissions.json") + } + DocumentKind::OpenCode => include_bytes!("../tests/fixtures/providers/opencode.json"), + DocumentKind::Zed => include_bytes!("../tests/fixtures/providers/zed.json"), + } + } + fn seed_registry_documents(spec: &ProviderSpec) { for document in &spec.documents { std::fs::create_dir_all(document.path.parent().unwrap()).unwrap(); - let content = match document.kind { - DocumentKind::Codex => b"[sentinel]\nkeep = 'unchanged'\n\ - [mcp_servers.external.tools.external_tool]\napproval_mode = 'deny'\n" - .to_vec(), - DocumentKind::JsonPermissions => serde_json::to_vec(&serde_json::json!({ - "sentinel":"unchanged", - "permissions":{"deny":["Bash(rm:*)"],"ask":["WebFetch(*)"]} - })) - .unwrap(), - DocumentKind::OpenCode => serde_json::to_vec(&serde_json::json!({ - "sentinel":"unchanged", - "permissions":[{"action":"dangerous_*","resource":"*","effect":"deny"}] - })) - .unwrap(), - DocumentKind::Zed => serde_json::to_vec(&serde_json::json!({ - "sentinel":"unchanged", - "agent":{"tool_permissions":{"default":"confirm","tools":{ - "dangerous.write":{"default":"deny"} - }}} - })) - .unwrap(), - DocumentKind::JsonRegistration => br#"{"sentinel":"unchanged"}"#.to_vec(), - }; - std::fs::write(&document.path, &content).unwrap(); + std::fs::write(&document.path, registry_fixture(document.kind)).unwrap(); } } @@ -4123,6 +4188,120 @@ mod tests { })); } + #[test] + fn interrupted_apply_recovers_from_durable_preparation() { + let tmp = tempfile::tempdir().unwrap(); + let spec = registry_test_spec(Provider::ClaudeCode.definition(), Scope::User, tmp.path()); + let manifest_path = tmp.path().join("install-manifest.json"); + let lock = InstallManifest::lock(&manifest_path).unwrap(); + let mut manifest = InstallManifest::empty(); + manifest.provider_ownership = + Some(crate::provider_journal::ProviderOwnership::empty("icm123").unwrap()); + + let error = fail_after_first_document_write(|| { + apply_documents(&spec, "icm123", &mut manifest, &manifest_path, &lock) + }) + .unwrap_err(); + assert!(error + .to_string() + .contains("injected provider interruption after first document write")); + assert_eq!( + read_provider_document_snapshot(&spec.documents[0].path) + .unwrap() + .0, + DocumentOrigin::Existing + ); + assert_eq!( + read_provider_document_snapshot(&spec.documents[1].path) + .unwrap() + .0, + DocumentOrigin::Missing + ); + + let mut recovered = InstallManifest::load(&manifest_path).unwrap(); + assert!(recovered + .provider_ownership + .as_ref() + .unwrap() + .operations + .iter() + .any(|operation| operation.phase == OperationPhase::Prepared)); + recover_provenance( + &mut recovered, + &TargetArgs { + provider: Provider::ClaudeCode, + scope: Scope::User, + }, + "icm123", + &manifest_path, + &lock, + ) + .unwrap(); + assert!(inspect(&spec, "icm123") + .unwrap() + .iter() + .all(|item| item.exists && !item.blocked)); + let operation = recovered + .provider_ownership + .as_ref() + .unwrap() + .operations + .last() + .unwrap(); + assert_eq!(operation.requested.action, OperationAction::Recover); + assert_eq!(operation.phase, OperationPhase::Applied); + assert!(operation.targets.iter().all(|target| { + target.observed_after_hash.as_ref() == Some(&target.expected_after_hash) + })); + } + + #[test] + fn missing_zed_config_retrust_after_strip_preserves_document_origin() { + let tmp = tempfile::tempdir().unwrap(); + let spec = registry_test_spec(Provider::Zed.definition(), Scope::ProjectLocal, tmp.path()); + let manifest_path = tmp.path().join("install-manifest.json"); + let lock = InstallManifest::lock(&manifest_path).unwrap(); + let mut manifest = InstallManifest::empty(); + manifest.provider_ownership = + Some(crate::provider_journal::ProviderOwnership::empty("icm123").unwrap()); + + apply_documents(&spec, "icm123", &mut manifest, &manifest_path, &lock).unwrap(); + let first_bytes = std::fs::read(&spec.documents[0].path).unwrap(); + assert!(manifest + .provider_splices + .iter() + .all(|record| record.document_origin == DocumentOrigin::Missing)); + + strip_documents( + Provider::Zed, + Scope::ProjectLocal, + &spec.documents, + RemovalMode::TrustOnly, + &mut manifest, + &manifest_path, + &lock, + ) + .unwrap(); + assert!(spec.documents[0].path.exists()); + assert_eq!(manifest.provider_splices.len(), 1); + assert_eq!( + manifest.provider_splices[0].document_origin, + DocumentOrigin::Missing + ); + + apply_documents(&spec, "icm123", &mut manifest, &manifest_path, &lock).unwrap(); + assert_eq!(std::fs::read(&spec.documents[0].path).unwrap(), first_bytes); + assert!(inspect(&spec, "icm123") + .unwrap() + .iter() + .all(|observation| observation.exists && !observation.blocked)); + assert!(manifest + .provider_splices + .iter() + .all(|record| record.document_origin == DocumentOrigin::Missing)); + InstallManifest::load(&manifest_path).unwrap(); + } + #[test] fn registry_drives_every_provider_and_scope_through_one_lifecycle() { let mut scenarios = 0; @@ -4251,6 +4430,23 @@ mod tests { 1 ); + apply_documents(&spec, server_id, &mut manifest, &manifest_path, &lock).unwrap(); + assert!(inspect(&spec, server_id) + .unwrap() + .iter() + .all(|item| item.exists && !item.blocked)); + assert_eq!( + manifest + .provider_ownership + .as_ref() + .unwrap() + .owned_fragments + .iter() + .filter(|fragment| fragment.ownership_kind == OwnershipKind::Owned) + .count(), + owned_count + ); + let uninstall = strip_documents( definition.provider, scope, @@ -4267,7 +4463,7 @@ mod tests { .iter() .map(|outcome| outcome.entries_removed) .sum::(), - 1 + owned_count ); assert!(inspect(&spec, server_id) .unwrap() @@ -4311,6 +4507,22 @@ mod tests { } } } + + apply_documents(&spec, server_id, &mut manifest, &manifest_path, &lock).unwrap(); + assert!(inspect(&spec, server_id) + .unwrap() + .iter() + .all(|item| item.exists && !item.blocked)); + let retrusted = manifest.provider_ownership.as_ref().unwrap(); + assert_eq!(retrusted.generation, 5); + assert_eq!( + retrusted + .owned_fragments + .iter() + .filter(|fragment| fragment.ownership_kind == OwnershipKind::Owned) + .count(), + owned_count + ); } } assert_eq!(scenarios, PROVIDER_REGISTRY.len() * Scope::ALL.len()); diff --git a/crates/icm-cli/tests/fixtures/providers/codex.toml b/crates/icm-cli/tests/fixtures/providers/codex.toml new file mode 100644 index 00000000..79dc766b --- /dev/null +++ b/crates/icm-cli/tests/fixtures/providers/codex.toml @@ -0,0 +1,5 @@ +[sentinel] +keep = "unchanged" + +[mcp_servers.external.tools.external_tool] +approval_mode = "deny" diff --git a/crates/icm-cli/tests/fixtures/providers/opencode.json b/crates/icm-cli/tests/fixtures/providers/opencode.json new file mode 100644 index 00000000..8ac66207 --- /dev/null +++ b/crates/icm-cli/tests/fixtures/providers/opencode.json @@ -0,0 +1,6 @@ +{ + "sentinel": "unchanged", + "permissions": [ + {"action": "dangerous_*", "resource": "*", "effect": "deny"} + ] +} diff --git a/crates/icm-cli/tests/fixtures/providers/permissions.json b/crates/icm-cli/tests/fixtures/providers/permissions.json new file mode 100644 index 00000000..c2b98449 --- /dev/null +++ b/crates/icm-cli/tests/fixtures/providers/permissions.json @@ -0,0 +1,7 @@ +{ + "sentinel": "unchanged", + "permissions": { + "deny": ["Bash(rm:*)"], + "ask": ["WebFetch(*)"] + } +} diff --git a/crates/icm-cli/tests/fixtures/providers/registration.json b/crates/icm-cli/tests/fixtures/providers/registration.json new file mode 100644 index 00000000..f11ecfa4 --- /dev/null +++ b/crates/icm-cli/tests/fixtures/providers/registration.json @@ -0,0 +1,3 @@ +{ + "sentinel": "unchanged" +} diff --git a/crates/icm-cli/tests/fixtures/providers/zed.json b/crates/icm-cli/tests/fixtures/providers/zed.json new file mode 100644 index 00000000..3b36505c --- /dev/null +++ b/crates/icm-cli/tests/fixtures/providers/zed.json @@ -0,0 +1,11 @@ +{ + "sentinel": "unchanged", + "agent": { + "tool_permissions": { + "default": "confirm", + "tools": { + "dangerous.write": {"default": "deny"} + } + } + } +} diff --git a/crates/icm-cli/tests/uninstall_integration.rs b/crates/icm-cli/tests/uninstall_integration.rs index 21aaf98d..6e8e86bd 100644 --- a/crates/icm-cli/tests/uninstall_integration.rs +++ b/crates/icm-cli/tests/uninstall_integration.rs @@ -45,6 +45,27 @@ fn icm_in(home: &Path, cwd: &Path, args: &[&str]) -> std::process::Output { .expect("spawn icm") } +fn icm_in_with_claude_dir( + home: &Path, + cwd: &Path, + claude_dir: &Path, + args: &[&str], +) -> std::process::Output { + Command::new(ICM) + .env("HOME", home) + .env("CLAUDE_CONFIG_DIR", claude_dir) + .env_remove("GEMINI_CONFIG_DIR") + .env_remove("CODEX_HOME") + .env_remove("COPILOT_HOME") + .env_remove("XDG_DATA_HOME") + .env_remove("XDG_CACHE_HOME") + .env_remove("XDG_CONFIG_HOME") + .current_dir(cwd) + .args(args) + .output() + .expect("spawn icm") +} + fn seed_minimal_residue(home: &Path) { // One JSON mcpServers entry. write( @@ -219,6 +240,86 @@ fn uninstall_removes_manifest_owned_provider_values() { ); } +#[test] +fn provider_doctor_and_strip_are_noops_before_first_trust() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + let cwd = home.join("proj"); + std::fs::create_dir_all(&cwd).unwrap(); + + let doctor = icm_in( + home, + &cwd, + &[ + "provider", + "doctor", + "--provider", + "zed", + "--scope", + "project-local", + ], + ); + assert!( + doctor.status.success(), + "provider doctor failed: {}", + String::from_utf8_lossy(&doctor.stderr) + ); + assert!(String::from_utf8_lossy(&doctor.stdout).contains("\"serverId\":\"icm\"")); + + let strip = icm_in( + home, + &cwd, + &[ + "provider", + "strip", + "--provider", + "zed", + "--scope", + "project-local", + "--yes", + ], + ); + assert!( + strip.status.success(), + "provider strip failed: {}", + String::from_utf8_lossy(&strip.stderr) + ); + assert!(!home.join(".local/share/icm/install-manifest.json").exists()); +} + +#[test] +fn claude_user_provider_trust_honors_config_dir() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + let cwd = home.join("proj"); + let config_dir = home.join("relocated-claude"); + std::fs::create_dir_all(&cwd).unwrap(); + + let trust = icm_in_with_claude_dir( + home, + &cwd, + &config_dir, + &[ + "provider", + "trust", + "--provider", + "claude-code", + "--scope", + "user", + "--yes", + ], + ); + assert!( + trust.status.success(), + "provider trust failed: {}", + String::from_utf8_lossy(&trust.stderr) + ); + assert!(config_dir.join(".claude.json").exists()); + assert!(config_dir.join("settings.json").exists()); + assert!(!home.join(".claude.json").exists()); + assert!(!home.join(".claude/settings.json").exists()); +} + // `directories::ProjectDirs` returns OS-specific data/cache paths that // don't match the Linux XDG layout this test seeds. Gate it to Linux // where seed and code agree — the macOS/Windows path resolution is