From 9b503e47b6d99c434400088952f9b65c0723bc71 Mon Sep 17 00:00:00 2001 From: Alex Oliveira <4482374+aroff@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:38:01 +0000 Subject: [PATCH] feat(managed): gate installs by digest and report managed statuses Every operation that adds or replaces skill content now checks the managed state before changing anything: skill add and update, bundle add, update, override and reset, project install and restoring personal overrides. Blocked digests are refused, and under allowed = listed so is anything unlisted; editable local skills skip the list but never the block. skill list gains managed-blocked and managed-not-allowed, which take precedence over the other statuses and fail --check. Skill commands follow the state's command policy: refused without a required state, a warning when the state expired or is missing. --- CONTEXT.md | 11 +- crates/fastskill-cli/src/commands/list.rs | 14 +- .../fastskill-cli/src/commands/list/global.rs | 66 ++- .../src/commands/list/reconciliation_tests.rs | 56 +++ .../fastskill-cli/src/commands/list/status.rs | 29 +- crates/fastskill-cli/src/registration.rs | 82 ++++ crates/fastskill-core/src/core/bundle.rs | 12 + .../src/core/bundle_override.rs | 4 + .../src/core/bundle_persistence.rs | 18 + .../src/core/bundle_persistence_tests.rs | 29 ++ .../fastskill-core/src/core/bundle_tests.rs | 69 ++++ crates/fastskill-core/src/core/install.rs | 3 + .../src/core/install/prepared_apply.rs | 33 +- .../fastskill-core/src/core/install/tests.rs | 55 +++ .../src/core/managed/enrollment.rs | 33 +- .../fastskill-core/src/core/managed/gate.rs | 359 +++++++++++++++++ .../src/core/managed/gate_tests.rs | 376 ++++++++++++++++++ crates/fastskill-core/src/core/managed/mod.rs | 5 + crates/fastskill-core/src/core/service.rs | 15 + webdocs/cli-reference/managed-command.mdx | 21 + webdocs/skill-management/reconciliation.mdx | 7 + 21 files changed, 1277 insertions(+), 20 deletions(-) create mode 100644 crates/fastskill-core/src/core/managed/gate.rs create mode 100644 crates/fastskill-core/src/core/managed/gate_tests.rs diff --git a/CONTEXT.md b/CONTEXT.md index b05f2949..56289f8d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -60,15 +60,18 @@ The comparison of the three states — Manifest (desired), Lock (pinned), skills (actual) — producing exactly one **reconciliation status** per skill. Owned by `skill list`. **Reconciliation status**: -A closed vocabulary of eleven values. Two are **settled** — the skill needs no action: +A closed vocabulary of thirteen values. Two are **settled** — the skill needs no action: - `ok` — the three states agree. - `excluded` — the skill is required by something, but is outside the current selection. -The other nine each name a specific disagreement, and `skill list --check` fails on any of +The other eleven each name a specific disagreement, and `skill list --check` fails on any of them. Because `--check` is how callers gate a build, this vocabulary is a compatibility surface rather than an internal detail: +- `managed-blocked` — the **managed state** blocks the installed content's digest. +- `managed-not-allowed` — the managed state allows only listed content, and the installed + content isn't listed. - `missing-lock` — in the Manifest, absent from the Lock. - `missing-content` — selected, but absent from the skills directory. - `intent-mismatch` — the Manifest and the Lock disagree about where the skill comes from. @@ -79,6 +82,10 @@ surface rather than an internal detail: - `ownership-conflict` — two **dependency roots** require the same skill at different content. - `extraneous` — installed, but no dependency root requires it. +The two `managed-` values appear only on a machine that follows a verified managed state, and +take precedence over every other value; `managed-blocked` comes first. `--check` fails on +them even when the skill is outside the current selection. + _Avoid_: `missing` and `mismatch` — neither is a status; name which one. **Installation scope**: diff --git a/crates/fastskill-cli/src/commands/list.rs b/crates/fastskill-cli/src/commands/list.rs index d6274a30..5092606a 100644 --- a/crates/fastskill-cli/src/commands/list.rs +++ b/crates/fastskill-cli/src/commands/list.rs @@ -342,6 +342,7 @@ pub async fn execute_list( .cloned() .collect(); + let situation = service.managed_gate().situation(); let mut check_failures = Vec::new(); let mut rows: Vec = all_ids .into_iter() @@ -418,7 +419,13 @@ pub async fn execute_list( let desired_entry = desired_entries.get(&id); let mutable = locked_entry .is_some_and(|entry| matches!(entry.origin, Origin::Local { editable: true, .. })); - let reconciliation = if !selected && !owners.is_empty() { + let storage = service.config().skill_storage_path.join(&id); + let managed = installed + .then(|| ReconciliationStatus::managed(&situation, &storage, mutable)) + .flatten(); + let reconciliation = if let Some(status) = managed { + status + } else if !selected && !owners.is_empty() { ReconciliationStatus::Excluded } else if selected && desired_entry.is_some() && locked_entry.is_none() { ReconciliationStatus::MissingLock @@ -467,7 +474,10 @@ pub async fn execute_list( } else { ReconciliationStatus::Ok }; - if args.check && selected && !reconciliation.is_settled() { + if args.check + && (selected || reconciliation.is_managed()) + && !reconciliation.is_settled() + { check_failures.push(format!("{id}: {reconciliation}")); } let desired_constraint = desired_entry.map(|entry| match &entry.origin { diff --git a/crates/fastskill-cli/src/commands/list/global.rs b/crates/fastskill-cli/src/commands/list/global.rs index 46b99ee8..a5897a9b 100644 --- a/crates/fastskill-cli/src/commands/list/global.rs +++ b/crates/fastskill-cli/src/commands/list/global.rs @@ -66,6 +66,7 @@ pub(super) async fn execute_global_list( .cloned() .collect::>(); let selected_ids = closure(&lock, &selected_roots); + let situation = service.managed_gate().situation(); let mut failures = Vec::new(); let mut rows = Vec::new(); for id in ids { @@ -74,7 +75,12 @@ pub(super) async fn execute_global_list( let selected = selected_ids.contains(&id); let mutable = locked .is_some_and(|entry| matches!(entry.origin, Origin::Local { editable: true, .. })); - let reconciliation = if locked.is_some() && !selected { + let storage = service.config().skill_storage_path.join(&id); + let managed = + actual.and_then(|_| ReconciliationStatus::managed(&situation, &storage, mutable)); + let reconciliation = if let Some(status) = managed { + status + } else if locked.is_some() && !selected { ReconciliationStatus::Excluded } else { reconcile(service, &id, locked, actual, mutable) @@ -414,4 +420,62 @@ mod tests { Err(CliError::Config(message)) if message.contains("missing-content") )); } + + #[tokio::test] + async fn global_list_reports_blocked_content_first() { + use fastskill_core::core::managed::{ManagedGate, ManagedSource, ManagedState, Situation}; + let _lock = fastskill_core::test_utils::DIR_MUTEX + .lock() + .unwrap_or_else(|error| error.into_inner()); + let temp = TempDir::new().unwrap(); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &temp.path().join("config")); + let storage = temp.path().join("skills"); + let installed = storage.join("demo"); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write( + installed.join("SKILL.md"), + "---\nname: demo\nversion: 1.0.0\ndescription: demo\n---\n# demo\n", + ) + .unwrap(); + let digest = managed_tree_digest(&installed).unwrap(); + let path = global_lock_path().unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut lock = GlobalSkillsLock::new_empty(); + lock.covered_roots.push("demo".to_string()); + lock.skills + .push(entry("demo", "1.0.0", Some(digest.clone()))); + lock.save_to_file(&path).unwrap(); + let source = ManagedSource::File(temp.path().join("state.dsse")); + let state = serde_json::json!({ + "format_version": 1, + "issued_at": "2026-10-09T11:00:00Z", + "expires_at": "2026-10-16T11:00:00Z", + "source": source.as_str(), + "subject": "team-a", + "skills": [], + "allowed": "any", + "blocked": [{ "digest": digest }], + }); + let situation = Situation::State { + state: Box::new(ManagedState::parse(&serde_json::to_vec(&state).unwrap()).unwrap()), + source, + expired: false, + }; + let service = service(&storage) + .await + .with_managed_gate(ManagedGate::fixed(situation)); + let args = ListArgs { + format: None, + json: false, + details: false, + check: true, + only: None, + without: None, + skills_dir: None, + }; + assert!(matches!( + execute_global_list(&service, args, OutputFormat::Json).await, + Err(CliError::Config(message)) if message.contains("demo: managed-blocked") + )); + } } diff --git a/crates/fastskill-cli/src/commands/list/reconciliation_tests.rs b/crates/fastskill-cli/src/commands/list/reconciliation_tests.rs index e1bfc8eb..82bf6418 100644 --- a/crates/fastskill-cli/src/commands/list/reconciliation_tests.rs +++ b/crates/fastskill-cli/src/commands/list/reconciliation_tests.rs @@ -271,3 +271,59 @@ async fn selected_roots_check_transitive_content_and_exclude_the_same_closure_by let rows: Vec = serde_json::from_str(&output).unwrap(); assert_eq!(row(&rows, "edited")["reconciliation"], "excluded"); } + +fn managed_situation(listed: &str, blocked: &str) -> fastskill_core::core::managed::Situation { + use fastskill_core::core::managed::{ManagedSource, ManagedState, Situation}; + let source = ManagedSource::File(env::temp_dir().join("state.dsse")); + let state = serde_json::json!({ + "format_version": 1, + "issued_at": "2026-10-09T11:00:00Z", + "expires_at": "2026-10-16T11:00:00Z", + "source": source.as_str(), + "subject": "team-a", + "skills": [{ "id": "listed", "digest": listed, "artifact": "listed.zip" }], + "allowed": "listed", + "blocked": [{ "digest": blocked }], + }); + Situation::State { + state: Box::new(ManagedState::parse(&serde_json::to_vec(&state).unwrap()).unwrap()), + source, + expired: false, + } +} + +// DIR_MUTEX is deliberately held across awaits so cwd-changing tests never run concurrently. +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn managed_statuses_take_precedence_and_fail_the_check() { + use fastskill_core::core::content_digest::content_digest; + let _lock = fastskill_core::test_utils::DIR_MUTEX + .lock() + .unwrap_or_else(|error| error.into_inner()); + let fixture = ProjectFixture::new(""); + let listed = fixture.skill("listed", "1.0.0"); + fixture.skill("blocked", "1.0.0"); + fixture.skill("stray", "1.0.0"); + let mut lock = ProjectSkillsLock::new_empty(); + lock.bundles = vec![bundle( + "team", + &[("listed", &listed), ("blocked", "recorded-elsewhere")], + )]; + let listed_digest = content_digest(&fixture.storage.join("listed")).unwrap(); + let blocked_digest = content_digest(&fixture.storage.join("blocked")).unwrap(); + let service = fixture.service(&lock).await.with_managed_gate( + fastskill_core::core::managed::ManagedGate::fixed(managed_situation( + &listed_digest, + &blocked_digest, + )), + ); + + let (result, output) = crate::output::capture(execute_list(&service, json_args(), false)).await; + let error = result.unwrap_err().to_string(); + let rows: Vec = serde_json::from_str(&output).unwrap(); + assert_eq!(row(&rows, "listed")["reconciliation"], "ok"); + assert_eq!(row(&rows, "blocked")["reconciliation"], "managed-blocked"); + assert_eq!(row(&rows, "stray")["reconciliation"], "managed-not-allowed"); + assert!(error.contains("blocked: managed-blocked"), "{error}"); + assert!(error.contains("stray: managed-not-allowed"), "{error}"); +} diff --git a/crates/fastskill-cli/src/commands/list/status.rs b/crates/fastskill-cli/src/commands/list/status.rs index 13b3d20d..fc400e40 100644 --- a/crates/fastskill-cli/src/commands/list/status.rs +++ b/crates/fastskill-cli/src/commands/list/status.rs @@ -7,10 +7,17 @@ //! the renderers and the `--check` gate to account for it instead of silently //! treating it as a failure. +use fastskill_core::core::managed::{ManagedSkillStatus, Situation}; +use std::path::Path; + /// A single skill's status from comparing manifest (desired), lock (pinned) and /// skills directory (actual). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ReconciliationStatus { + /// The managed state blocks the installed content's digest. + ManagedBlocked, + /// The managed state allows only listed content, and the installed content isn't listed. + ManagedNotAllowed, /// Desired, pinned and actual agree. Ok, /// Owned by a bundle or override that the current selection excludes. @@ -38,7 +45,9 @@ pub(super) enum ReconciliationStatus { impl ReconciliationStatus { /// Every status, so tests can assert over the whole vocabulary. #[cfg(test)] - pub(super) const ALL: [Self; 11] = [ + pub(super) const ALL: [Self; 13] = [ + Self::ManagedBlocked, + Self::ManagedNotAllowed, Self::Ok, Self::Excluded, Self::MissingLock, @@ -56,6 +65,8 @@ impl ReconciliationStatus { /// table, JSON and XML renderings cannot drift apart. pub(super) fn as_str(self) -> &'static str { match self { + Self::ManagedBlocked => "managed-blocked", + Self::ManagedNotAllowed => "managed-not-allowed", Self::Ok => "ok", Self::Excluded => "excluded", Self::MissingLock => "missing-lock", @@ -70,6 +81,22 @@ impl ReconciliationStatus { } } + /// The managed status of installed content, which takes precedence over every other status. + /// Content that can't be digested is left to the content checks. + pub(super) fn managed(situation: &Situation, installed: &Path, mutable: bool) -> Option { + match situation.status_of(installed, mutable) { + Ok(Some(ManagedSkillStatus::Blocked)) => Some(Self::ManagedBlocked), + Ok(Some(ManagedSkillStatus::NotAllowed)) => Some(Self::ManagedNotAllowed), + Ok(None) | Err(_) => None, + } + } + + /// Whether this status comes from the managed state. `skill list --check` fails on these + /// whether or not the current selection includes the skill. + pub(super) fn is_managed(self) -> bool { + matches!(self, Self::ManagedBlocked | Self::ManagedNotAllowed) + } + /// Whether `skill list --check` accepts this status. `excluded` counts as /// settled because the skill is deliberately outside the current selection. pub(super) fn is_settled(self) -> bool { diff --git a/crates/fastskill-cli/src/registration.rs b/crates/fastskill-cli/src/registration.rs index 610cdc62..65bae1e0 100644 --- a/crates/fastskill-cli/src/registration.rs +++ b/crates/fastskill-cli/src/registration.rs @@ -21,6 +21,8 @@ use std::future::Future; use std::sync::Arc; use crate::output::{self, Mode}; +use chrono::Utc; +use fastskill_core::core::managed::{CommandPolicy, Situation}; pub mod surface; @@ -60,6 +62,7 @@ where let spec = Arc::new(T::command_spec()); let id: Arc = Arc::from(path.leaf().unwrap_or("")); let handler = Arc::new(handler); + let managed = follows_managed_policy(path); Command { id, @@ -76,6 +79,9 @@ where // drain below. let fut = handler(ctx, typed); Box::pin(async move { + if managed { + managed_policy(Situation::for_current_user(Utc::now()).command_policy())?; + } match output::mode() { Mode::Direct => fut.await, Mode::Capture if output::has_active_sink() => fut.await, @@ -94,6 +100,41 @@ where } } +/// Command groups that read or change skills. They follow the managed state's command policy; +/// `managed`, `cache`, `repo` and `cli` stay available so a machine can always recover. +const SKILL_GROUPS: &[&str] = &[ + "skill", + "bundle", + "project", + "analysis", + "index", + "eval", + "optimization", + "marketplace", +]; + +/// Whether the command at `path` follows the managed command policy. +fn follows_managed_policy(path: &CommandPath) -> bool { + match path.0.as_slice() { + [group, ..] if SKILL_GROUPS.contains(&group.as_str()) => true, + [group, leaf] => leaf == "serve" && (group == "server" || group == "mcp"), + _ => false, + } +} + +/// Apply a command policy: refuse with a configuration error, or print the warning to stderr +/// and run. +fn managed_policy(policy: CommandPolicy) -> anyhow::Result<()> { + match policy { + CommandPolicy::Run => Ok(()), + CommandPolicy::Warn(message) => { + eprintln!("warning: {message}"); + Ok(()) + } + CommandPolicy::Refuse(message) => Err(crate::error::CliError::Config(message).into()), + } +} + impl AppBuilderExt for AppBuilder { fn register_out(self, path: CommandPath, handler: F) -> anyhow::Result where @@ -115,3 +156,44 @@ impl AppBuilderExt for AppBuilder { self.register_command_at(&path, command) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn skill_commands_follow_the_managed_policy_and_recovery_commands_do_not() { + let path = |segments: &[&str]| CommandPath::new(segments).unwrap(); + for segments in [ + &["skill", "add"][..], + &["bundle", "override"], + &["project", "install"], + &["eval", "run"], + &["server", "serve"], + &["mcp", "serve"], + ] { + assert!(follows_managed_policy(&path(segments)), "{segments:?}"); + } + for segments in [ + &["managed", "apply"][..], + &["cache", "clean"], + &["repo", "list"], + &["cli", "doctor"], + &["mcp", "install"], + &["server"], + ] { + assert!(!follows_managed_policy(&path(segments)), "{segments:?}"); + } + } + + #[test] + fn a_refusal_is_a_configuration_error_and_a_warning_runs() { + assert!(managed_policy(CommandPolicy::Run).is_ok()); + assert!(managed_policy(CommandPolicy::Warn("expired".to_string())).is_ok()); + let error = managed_policy(CommandPolicy::Refuse("required".to_string())).unwrap_err(); + let error = error.downcast_ref::().unwrap(); + assert_eq!(error.exit_code(), 2); + assert!(error.to_string().contains("required")); + } +} diff --git a/crates/fastskill-core/src/core/bundle.rs b/crates/fastskill-core/src/core/bundle.rs index f0529e47..0df70a8a 100644 --- a/crates/fastskill-core/src/core/bundle.rs +++ b/crates/fastskill-core/src/core/bundle.rs @@ -15,6 +15,7 @@ use crate::core::bundle_persistence::{ use crate::core::contained_path::ContainedPath; use crate::core::content_digest::{content_digest, content_digest_matches, DigestForms}; use crate::core::lock::{ProjectLockedBundleEntry, ProjectLockedBundleMember, ProjectSkillsLock}; +use crate::core::managed::{Candidate, ManagedGate}; use crate::core::manifest::SkillProjectToml; use crate::core::ownership::ProjectOwnership; use crate::core::service::ServiceError; @@ -83,6 +84,8 @@ pub struct DeclaredBundleMember { pub struct BundleService { pub(crate) project_root: PathBuf, pub(crate) skills_directory: PathBuf, + /// The managed-state install gate (ADR-0016 decision 13): this user's situation by default. + pub(crate) managed_gate: ManagedGate, } impl BundleService { @@ -90,9 +93,16 @@ impl BundleService { Self { project_root: project_root.into(), skills_directory: skills_directory.into(), + managed_gate: ManagedGate::default(), } } + /// Replace the managed-state install gate, which otherwise reads this user's situation. + pub fn with_managed_gate(mut self, gate: ManagedGate) -> Self { + self.managed_gate = gate; + self + } + /// Build `-.zip` from every dependency in /// the current project Manifest and its installed dependency closure. pub fn build(&self, output_directory: &Path) -> Result { @@ -683,6 +693,7 @@ impl BundleService { lock: &ProjectSkillsLock, ) -> Result { let mut changes = BundleChanges::default(); + let situation = self.managed_gate.situation(); for member in prepared.members.values() { if self.has_personal_override(lock, &member.id) { continue; @@ -694,6 +705,7 @@ impl BundleService { .transpose()? .is_some_and(|digest| digest == member.digest.current); if !identical { + situation.check(Candidate::digest(&member.id, &member.digest.current))?; changes.replacements.push(BundleReplacement { id: member.id.clone(), source: member.source.clone(), diff --git a/crates/fastskill-core/src/core/bundle_override.rs b/crates/fastskill-core/src/core/bundle_override.rs index 4b8a5508..537ae60a 100644 --- a/crates/fastskill-core/src/core/bundle_override.rs +++ b/crates/fastskill-core/src/core/bundle_override.rs @@ -11,6 +11,7 @@ use crate::core::content_digest::{ content_digest_matches, recorded_digests_conflict, upgraded_digest, }; use crate::core::lock::{ProjectLockedSkillEntry, ProjectSkillsLock}; +use crate::core::managed::Candidate; use crate::core::manifest::{DependenciesSection, DependencySpec, SkillProjectToml}; use crate::core::origin::{Origin, Resolved}; use crate::core::ownership::normalize_lock_ownership; @@ -407,6 +408,9 @@ fn prepare_reset( artifact.display() ))); } + service + .managed_gate + .check(Candidate::digest(id, &packaged.digest.current))?; Ok(Some(ResetPreparation { manifest, lock, diff --git a/crates/fastskill-core/src/core/bundle_persistence.rs b/crates/fastskill-core/src/core/bundle_persistence.rs index 4ec170e6..33831d13 100644 --- a/crates/fastskill-core/src/core/bundle_persistence.rs +++ b/crates/fastskill-core/src/core/bundle_persistence.rs @@ -8,6 +8,7 @@ use crate::core::content_digest::{ DigestForms, }; use crate::core::lock::{ProjectLockedPersonalOverride, ProjectSkillsLock}; +use crate::core::managed::Candidate; use crate::core::manifest::DependencySpec; use crate::core::origin::Origin; use crate::core::service::{ServiceError, SkillId}; @@ -434,6 +435,9 @@ pub(crate) fn preview_personal_override( } let source = source.canonicalize().map_err(ServiceError::Io)?; let target = DigestForms::of_directory(&source)?; + service + .managed_gate + .check(Candidate::digest(id, &target.current))?; let manifest_path = service.project_root.join("skill-project.toml"); let content = fs::read_to_string(&manifest_path).map_err(ServiceError::Io)?; let tables: BundleManifestTables = toml::from_str(&content).map_err(|error| { @@ -530,6 +534,20 @@ fn restore_personal_overrides_impl( let mut lock = ProjectSkillsLock::load_from_file(&lock_path) .map_err(|error| ServiceError::Config(format!("Failed to load skills.lock: {error}")))?; let ids = tables.overrides.keys().cloned().collect::>(); + let sources = tables + .overrides + .iter() + .map(|(id, declaration)| (id, PathBuf::from(&declaration.origin))) + .filter(|(_, source)| source.join("SKILL.md").is_file()) + .collect::>(); + service + .managed_gate + .check_all(sources.iter().map(|(id, source)| Candidate { + id, + digest: None, + path: Some(source), + editable: false, + }))?; let state_guard = StateMutationLease::acquire_or_borrow( &service.project_root, Some(&service.skills_directory), diff --git a/crates/fastskill-core/src/core/bundle_persistence_tests.rs b/crates/fastskill-core/src/core/bundle_persistence_tests.rs index 38d7f2fe..8bdda0c0 100644 --- a/crates/fastskill-core/src/core/bundle_persistence_tests.rs +++ b/crates/fastskill-core/src/core/bundle_persistence_tests.rs @@ -757,3 +757,32 @@ fn a_manifest_override_key_that_is_not_a_skill_id_is_refused() { assert!(error.contains("../victim"), "{error}"); } + +#[test] +fn the_managed_gate_refuses_blocked_overrides_and_restores() { + let (root, service, source) = override_fixture(); + let personal = content_digest(&source).unwrap(); + let blocking = |digest: &str| { + crate::core::managed::ManagedGate::fixed(crate::core::managed::gate::test_situation( + digest, None, + )) + }; + + let gated = BundleService::new(root.path(), &service.skills_directory) + .with_managed_gate(blocking(&personal)); + let error = preview_personal_override(&gated, "demo", &source) + .unwrap_err() + .to_string(); + assert!( + error.contains("demo") && error.contains("is blocked"), + "{error}" + ); + assert!(apply_personal_override(&gated, "demo", &source).is_err()); + + apply_personal_override(&service, "demo", &source).unwrap(); + fs::remove_dir_all(service.skills_directory.join("demo")).unwrap(); + let error = restore_personal_overrides(&gated).unwrap_err().to_string(); + assert!(error.contains("is blocked"), "{error}"); + assert!(!service.skills_directory.join("demo").exists()); + restore_personal_overrides(&service).unwrap(); +} diff --git a/crates/fastskill-core/src/core/bundle_tests.rs b/crates/fastskill-core/src/core/bundle_tests.rs index 334780d2..b7e9c985 100644 --- a/crates/fastskill-core/src/core/bundle_tests.rs +++ b/crates/fastskill-core/src/core/bundle_tests.rs @@ -395,3 +395,72 @@ fn a_legacy_locked_release_that_names_other_contents_is_refused() { "{error}" ); } + +#[test] +fn the_managed_gate_refuses_a_bundle_with_a_blocked_member() { + let (_author, artifact) = author_single_member_bundle(); + let member = PreparedBundle::load(&artifact).unwrap().members["demo"] + .digest + .current + .clone(); + let (recipient, service) = fixture(); + let service = service.with_managed_gate(crate::core::managed::ManagedGate::fixed( + crate::core::managed::gate::test_situation(&member, None), + )); + + let error = service.install(&artifact).unwrap_err().to_string(); + assert!( + error.contains("demo") && error.contains("is blocked"), + "{error}" + ); + assert!(!recipient.path().join("skills/demo").exists()); + assert!( + ProjectSkillsLock::load_from_file(&recipient.path().join("skills.lock")) + .unwrap() + .bundles + .is_empty() + ); +} + +#[test] +fn the_managed_gate_refuses_resetting_to_a_blocked_member() { + let author = TempDir::new().unwrap(); + built_artifact(author.path()); + let manifest = author.path().join("skill-project.toml"); + let overridable = fs::read_to_string(&manifest).unwrap().replace( + "[bundle.members.demo]\n", + "[bundle.members.demo]\noverridable = true\n", + ); + fs::write(&manifest, overridable).unwrap(); + let artifact = BundleService::new(author.path(), author.path().join("skills")) + .build(&author.path().join("dist2")) + .unwrap() + .artifact; + let packaged = PreparedBundle::load(&artifact).unwrap().members["demo"] + .digest + .current + .clone(); + let (recipient, service) = fixture(); + service.install(&artifact).unwrap(); + let personal = recipient.path().join("personal/demo"); + fs::create_dir_all(&personal).unwrap(); + fs::write( + personal.join("SKILL.md"), + "---\nname: demo\nversion: 2.0.0\ndescription: personal\n---\npersonal\n", + ) + .unwrap(); + service.override_member("demo", &personal).unwrap(); + + let gated = BundleService::new(recipient.path(), recipient.path().join("skills")) + .with_managed_gate(crate::core::managed::ManagedGate::fixed( + crate::core::managed::gate::test_situation(&packaged, None), + )); + let error = gated.reset_override("demo").unwrap_err().to_string(); + assert!(error.contains("is blocked"), "{error}"); + assert!( + fs::read_to_string(recipient.path().join("skills/demo/SKILL.md")) + .unwrap() + .contains("personal") + ); + assert!(service.reset_override("demo").unwrap()); +} diff --git a/crates/fastskill-core/src/core/install.rs b/crates/fastskill-core/src/core/install.rs index 49fe1a81..ace80733 100644 --- a/crates/fastskill-core/src/core/install.rs +++ b/crates/fastskill-core/src/core/install.rs @@ -262,6 +262,8 @@ impl FastSkillService { } } + self.check_fetched(id.as_str(), &fetched, &recorded_origin)?; + if let Some(expected) = expected { verify_resolved_facts( expected_id.unwrap_or(id.as_str()), @@ -703,6 +705,7 @@ impl FastSkillService { if mode == AddMode::Fresh && existing.is_some() { return Err(ServiceError::AlreadyIndexed(id.into_string())); } + self.check_managed(id.as_str(), &resolved, &skill_path, &origin)?; let storage_dir = self.config().skill_storage_path.join(id.as_str()); let editable = matches!(&origin, Origin::Local { editable: true, .. }); diff --git a/crates/fastskill-core/src/core/install/prepared_apply.rs b/crates/fastskill-core/src/core/install/prepared_apply.rs index f1b8c1a1..ecb2720d 100644 --- a/crates/fastskill-core/src/core/install/prepared_apply.rs +++ b/crates/fastskill-core/src/core/install/prepared_apply.rs @@ -1,5 +1,8 @@ -use super::{AddMode, AddOutcome, PreparedSkill}; +use super::{AddMode, AddOutcome, Fetched, PreparedSkill}; +use crate::core::managed::Candidate; +use crate::core::origin::{Origin, Resolved}; use crate::core::service::{FastSkillService, ServiceError}; +use std::path::Path; impl PreparedSkill { pub fn id(&self) -> &str { @@ -29,6 +32,34 @@ impl PreparedSkill { } impl FastSkillService { + /// [`Self::check_managed`] for content fetched for preparation, so a whole project is + /// checked before anything is applied. + pub(super) fn check_fetched( + &self, + id: &str, + fetched: &Fetched, + origin: &Origin, + ) -> Result<(), ServiceError> { + self.check_managed(id, &fetched.resolved, &fetched.skill_path, origin) + } + + /// Ask the managed-state install gate whether fetched content may be installed, before any + /// state changes (ADR-0016 decision 13). + pub(super) fn check_managed( + &self, + id: &str, + resolved: &Resolved, + skill_path: &Path, + origin: &Origin, + ) -> Result<(), ServiceError> { + self.managed_gate().check(Candidate { + id, + digest: resolved.checksum.as_deref(), + path: Some(skill_path), + editable: matches!(origin, Origin::Local { editable: true, .. }), + }) + } + /// Apply a previously prepared candidate without changing desired state or /// the Lock. The caller persists one deterministic Lock after the complete /// operation succeeds. diff --git a/crates/fastskill-core/src/core/install/tests.rs b/crates/fastskill-core/src/core/install/tests.rs index 37b96adf..09176c75 100644 --- a/crates/fastskill-core/src/core/install/tests.rs +++ b/crates/fastskill-core/src/core/install/tests.rs @@ -1765,3 +1765,58 @@ fn content_digest_distinguishes_file_boundaries() { "a one-file tree and a two-file tree produced the same content digest" ); } + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn the_managed_gate_refuses_blocked_content_before_installing() { + let _lock = crate::test_utils::DIR_MUTEX + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (tmp, _guard, skills_dir) = setup_project(); + let src = write_valid_skill(tmp.path(), "src-skill"); + let digest = crate::core::content_digest::content_digest(&src).unwrap(); + let service = make_service(&skills_dir).await.with_managed_gate( + crate::core::managed::ManagedGate::fixed(crate::core::managed::gate::test_situation( + &digest, None, + )), + ); + + let origin = Origin::Local { + path: src.clone(), + editable: false, + }; + let error = service + .add_from_origin(origin, AddMode::Fresh, vec![]) + .await + .unwrap_err() + .to_string(); + assert!( + error.contains("is blocked by the managed state: withdrawn"), + "{error}" + ); + assert!(!skills_dir.join("test-skill").exists()); + assert!(!tmp.path().join("skills.lock").exists()); + + let listed_elsewhere = make_service(&skills_dir).await.with_managed_gate( + crate::core::managed::ManagedGate::fixed(crate::core::managed::gate::test_situation( + &format!("sha256-tree-v2:{}", "0".repeat(64)), + Some(&format!("sha256-tree-v2:{}", "1".repeat(64))), + )), + ); + let error = listed_elsewhere + .add_from_origin( + Origin::Local { + path: src, + editable: false, + }, + AddMode::Fresh, + vec![], + ) + .await + .unwrap_err() + .to_string(); + assert!( + error.contains("isn't allowed by the managed state"), + "{error}" + ); +} diff --git a/crates/fastskill-core/src/core/managed/enrollment.rs b/crates/fastskill-core/src/core/managed/enrollment.rs index 6711441d..ef9941d6 100644 --- a/crates/fastskill-core/src/core/managed/enrollment.rs +++ b/crates/fastskill-core/src/core/managed/enrollment.rs @@ -158,22 +158,29 @@ pub fn status( ..ManagedStatus::default() }; if enrollment.is_enrolled() { - match std::fs::read(layout.cached_state()) { - Ok(bytes) => { - // The cached state is the newest accepted, so its own issued_at is allowed. - match super::open(&bytes, settings, &enrollment.recorded, now) { - Ok(opened) => { - status.subject = Some(opened.state.subject.clone()); - status.issued_at = Some(opened.state.issued_at); - status.expires_at = Some(opened.state.expires_at); - status.expired = opened.state.is_expired(now); - } - Err(error) => status.state_problem = Some(error.to_string()), - } + match open_cached(layout, settings, &enrollment, now) { + Ok(opened) => { + status.subject = Some(opened.state.subject.clone()); + status.issued_at = Some(opened.state.issued_at); + status.expires_at = Some(opened.state.expires_at); + status.expired = opened.state.is_expired(now); } - Err(_) => status.state_problem = Some("no state has been accepted yet".to_string()), + Err(problem) => status.state_problem = Some(problem), } status.enrollment = Some(enrollment); } Ok(status) } + +/// Verify the cached state again, or say why it can't be used. +pub(crate) fn open_cached( + layout: &ManagedLayout, + settings: &ManagedSettings, + enrollment: &Enrollment, + now: DateTime, +) -> Result { + let bytes = std::fs::read(layout.cached_state()) + .map_err(|_| "no state has been accepted yet".to_string())?; + // The cached state is the newest accepted, so its own issued_at is allowed. + super::open(&bytes, settings, &enrollment.recorded, now).map_err(|error| error.to_string()) +} diff --git a/crates/fastskill-core/src/core/managed/gate.rs b/crates/fastskill-core/src/core/managed/gate.rs new file mode 100644 index 00000000..61be410c --- /dev/null +++ b/crates/fastskill-core/src/core/managed/gate.rs @@ -0,0 +1,359 @@ +//! The install gate and the managed statuses (ADR-0016 decisions 4, 13, 14, 21 to 23). +//! +//! Every operation that adds or replaces skill content asks [`ManagedGate`] first, before it +//! changes any state: the CLI, `server serve` and `mcp serve` share the core install seam, so +//! they all inherit it. `skill list` asks [`Situation::status_of`] for the two managed statuses, +//! and the CLI asks [`Situation::command_policy`] before a command that reads or changes skills. +//! +//! With no managed source configured every answer is "as today". + +use super::config::{ManagedSettings, ManagedSource}; +use super::enrollment::open_cached; +use super::layout::{read_record, ManagedLayout}; +use super::records::Enrollment; +use super::state::{Allowed, Editable, ManagedState}; +use super::ManagedError; +use crate::core::content_digest::content_digest; +use crate::core::service::ServiceError; +use chrono::{DateTime, Utc}; +use std::path::Path; +use std::sync::Arc; + +/// Where this user stands with managed state, read once and used for every check in one +/// operation. +#[derive(Debug, Clone, PartialEq)] +pub enum Situation { + /// No managed source is configured: nothing changes. + NotConfigured, + /// The managed settings can't be used, for instance an untrusted system file. + Unusable(String), + /// A source is configured but no valid state is cached. + NoState { required: bool, problem: String }, + /// A verified state, which may have expired. + State { + state: Box, + source: ManagedSource, + expired: bool, + }, +} + +/// One of the two reconciliation statuses that only a managed state emits (decision 14). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManagedSkillStatus { + /// The content's digest is blocked. + Blocked, + /// The state allows only listed content, and this isn't listed. + NotAllowed, +} + +/// What a command that reads or changes skills may do (decision 4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandPolicy { + Run, + /// Run, after printing this warning. + Warn(String), + /// Refuse with this message. + Refuse(String), +} + +/// One piece of content an operation is about to install. +#[derive(Debug, Clone, Copy)] +pub struct Candidate<'a> { + pub id: &'a str, + /// The content's digest, when the caller already has it. + pub digest: Option<&'a str>, + /// Where the content is, to compute the digest when the caller doesn't have it. + pub path: Option<&'a Path>, + /// An editable local skill (ADR-0005), whose digest changes with every edit. + pub editable: bool, +} + +impl<'a> Candidate<'a> { + /// Content whose digest is known. + pub fn digest(id: &'a str, digest: &'a str) -> Self { + Self { + id, + digest: Some(digest), + path: None, + editable: false, + } + } +} + +const APPLY_HINT: &str = "run `fastskill managed apply`"; + +impl Situation { + /// Read the situation of the current user from the trusted settings and the managed store. + pub fn for_current_user(now: DateTime) -> Self { + match ManagedLayout::for_current_user() { + Ok(layout) => Self::load(&layout, ManagedSettings::load(), now), + Err(error) => match ManagedSettings::load() { + Ok(settings) if !settings.is_configured() => Self::NotConfigured, + _ => Self::Unusable(error.to_string()), + }, + } + } + + /// Read the situation from `settings` and the records in `layout`. + pub fn load( + layout: &ManagedLayout, + settings: Result, + now: DateTime, + ) -> Self { + let settings = match settings { + Ok(settings) => settings, + Err(error) => return Self::Unusable(error.to_string()), + }; + let Some(source) = settings.source.clone() else { + return Self::NotConfigured; + }; + let no_state = |problem: String| Self::NoState { + required: settings.required, + problem, + }; + let enrollment: Enrollment = match read_record(&layout.enrollment_file()) { + Ok(enrollment) => enrollment, + Err(error) => return no_state(error.to_string()), + }; + if !enrollment.is_enrolled() { + let hint = if settings.source_from_system { + APPLY_HINT + } else { + "run `fastskill managed enroll`" + }; + return no_state(format!("this user isn't enrolled; {hint}")); + } + match open_cached(layout, &settings, &enrollment, now) { + Ok(opened) => Self::State { + expired: opened.state.is_expired(now), + state: Box::new(opened.state), + source, + }, + Err(problem) => no_state(format!("{problem}; {APPLY_HINT}")), + } + } + + /// Whether `candidate` may be installed, refusing with the reason when it may not + /// (decisions 4, 13, 22 and 23). + pub fn check(&self, candidate: Candidate<'_>) -> Result<(), ServiceError> { + match self { + Self::NotConfigured + | Self::NoState { + required: false, .. + } => Ok(()), + Self::Unusable(problem) => Err(refused(format!( + "the managed settings can't be used: {problem}" + ))), + Self::NoState { + required: true, + problem, + } => Err(refused(format!( + "a valid managed state is required to change skills: {problem}" + ))), + Self::State { + expired: true, + state, + .. + } => Err(refused(format!( + "the managed state expired at {}, so skills can't be added or changed; \ + {APPLY_HINT}", + state.expires_at + ))), + Self::State { state, source, .. } => check_state(state, source, candidate), + } + } + + /// Check several candidates, stopping at the first refusal. + pub fn check_all<'a>( + &self, + candidates: impl IntoIterator>, + ) -> Result<(), ServiceError> { + candidates + .into_iter() + .try_for_each(|candidate| self.check(candidate)) + } + + /// The managed status of installed content, or `None` when it has none. The digest is only + /// computed when a verified state is present. + pub fn status_of( + &self, + path: &Path, + editable: bool, + ) -> Result, ServiceError> { + let Self::State { state, source, .. } = self else { + return Ok(None); + }; + let digest = digest_of(path)?; + Ok(if state.blocked(&digest).is_some() { + Some(ManagedSkillStatus::Blocked) + } else if not_allowed(state, source, &digest, editable) { + Some(ManagedSkillStatus::NotAllowed) + } else { + None + }) + } + + /// What a command that reads or changes skills may do (decision 4). Refusals for content + /// are left to [`Situation::check`]. + pub fn command_policy(&self) -> CommandPolicy { + match self { + Self::NotConfigured => CommandPolicy::Run, + Self::State { expired: false, .. } => CommandPolicy::Run, + Self::Unusable(problem) => { + CommandPolicy::Refuse(format!("the managed settings can't be used: {problem}")) + } + Self::NoState { + required: true, + problem, + } => CommandPolicy::Refuse(format!( + "a valid managed state is required to read or change skills: {problem}" + )), + Self::NoState { + required: false, + problem, + } => CommandPolicy::Warn(format!("no valid managed state: {problem}")), + Self::State { state, .. } => CommandPolicy::Warn(format!( + "the managed state expired at {}; skills can't be added or changed until \ + `fastskill managed apply` gets a newer one", + state.expires_at + )), + } + } +} + +fn check_state( + state: &ManagedState, + source: &ManagedSource, + candidate: Candidate<'_>, +) -> Result<(), ServiceError> { + if candidate.editable + && state.allowed == Allowed::Listed + && state.editable(source) == Editable::Refused + { + return Err(refused(format!( + "{} is an editable local skill, and the managed state refuses those", + candidate.id + ))); + } + let digest = match (candidate.digest, candidate.path) { + (Some(digest), _) => digest.to_string(), + (None, Some(path)) => digest_of(path)?, + (None, None) => { + return Err(refused(format!( + "{} has no content digest to check against the managed state", + candidate.id + ))) + } + }; + if let Some(blocked) = state.blocked(&digest) { + let message = blocked + .message + .as_deref() + .map(|message| format!(": {message}")) + .unwrap_or_default(); + return Err(refused(format!( + "{} ({digest}) is blocked by the managed state{message}", + candidate.id + ))); + } + if not_allowed(state, source, &digest, candidate.editable) { + let link = state + .request_link(source, &digest) + .map(|link| format!("; request it at {link}")) + .unwrap_or_default(); + return Err(refused(format!( + "{} ({digest}) isn't allowed by the managed state{link}", + candidate.id + ))); + } + Ok(()) +} + +/// Not allowed under `allowed = listed`. Editable local skills skip the list unless the state +/// refuses them (decision 22). +fn not_allowed(state: &ManagedState, source: &ManagedSource, digest: &str, editable: bool) -> bool { + if state.allowed != Allowed::Listed || state.allows(digest) { + return false; + } + !editable || state.editable(source) == Editable::Refused +} + +/// The current-form digest of a skill folder, following a link at the top (an editable skill +/// is a link to its source). +fn digest_of(path: &Path) -> Result { + let folder = std::fs::canonicalize(path)?; + content_digest(&folder) +} + +fn refused(message: String) -> ServiceError { + ServiceError::Validation(message) +} + +/// The gate a service consults: the current user's situation, read again at each operation so a +/// long-running server follows new states, or a fixed one for tests and embedding callers. +#[derive(Debug, Clone, Default)] +pub struct ManagedGate { + fixed: Option>, +} + +impl ManagedGate { + /// Always use `situation`. + pub fn fixed(situation: Situation) -> Self { + Self { + fixed: Some(Arc::new(situation)), + } + } + + /// The situation now. + pub fn situation(&self) -> Arc { + match &self.fixed { + Some(situation) => situation.clone(), + None => Arc::new(Situation::for_current_user(Utc::now())), + } + } + + /// Check one candidate against the situation now. + pub fn check(&self, candidate: Candidate<'_>) -> Result<(), ServiceError> { + self.situation().check(candidate) + } + + /// Check several candidates against one reading of the situation. + pub fn check_all<'a>( + &self, + candidates: impl IntoIterator>, + ) -> Result<(), ServiceError> { + self.situation().check_all(candidates) + } +} + +/// A verified, unexpired state from a local file that blocks `blocked` and, when `listed` is +/// given, allows only that digest; for tests elsewhere in the crate. +#[cfg(test)] +#[allow(clippy::expect_used)] +pub(crate) fn test_situation(blocked: &str, listed: Option<&str>) -> Situation { + let source = ManagedSource::File(std::env::temp_dir().join("state.dsse")); + let value = serde_json::json!({ + "format_version": 1, + "issued_at": "2026-10-09T11:00:00Z", + "expires_at": "2026-10-16T11:00:00Z", + "source": source.as_str(), + "subject": "team-a", + "skills": listed + .map(|digest| vec![serde_json::json!({ "id": "listed", "digest": digest, "artifact": "a.zip" })]) + .unwrap_or_default(), + "allowed": if listed.is_some() { "listed" } else { "any" }, + "blocked": [{ "digest": blocked, "message": "withdrawn" }], + }); + Situation::State { + state: Box::new( + ManagedState::parse(&serde_json::to_vec(&value).expect("state json")) + .expect("test state"), + ), + source, + expired: false, + } +} + +#[cfg(test)] +#[path = "gate_tests.rs"] +mod tests; diff --git a/crates/fastskill-core/src/core/managed/gate_tests.rs b/crates/fastskill-core/src/core/managed/gate_tests.rs new file mode 100644 index 00000000..5d6064f4 --- /dev/null +++ b/crates/fastskill-core/src/core/managed/gate_tests.rs @@ -0,0 +1,376 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use crate::core::managed::records::Enrollment; +use crate::core::managed::tests::{pinned, sign}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +fn now() -> DateTime { + "2026-10-09T12:00:00Z".parse().unwrap() +} + +/// A skill folder with a body, and its digest. +fn skill(dir: &Path, id: &str, body: &str) -> (PathBuf, String) { + let folder = dir.join(id); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write( + folder.join("SKILL.md"), + format!("---\nname: {id}\ndescription: test\n---\n{body}\n"), + ) + .unwrap(); + let digest = content_digest(&folder).unwrap(); + (folder, digest) +} + +fn other(fill: char) -> String { + format!("sha256-tree-v2:{}", fill.to_string().repeat(64)) +} + +fn state_json(source: &str, listed: &str, blocked: &str) -> Value { + json!({ + "format_version": 1, + "issued_at": "2026-10-09T11:00:00Z", + "expires_at": "2026-10-16T11:00:00Z", + "source": source, + "subject": "team-a", + "skills": [{ "id": "pdf", "digest": listed, "artifact": "pdf.zip" }], + "allowed": "listed", + "blocked": [ + { "digest": blocked, "message": "withdrawn" }, + { "digest": other('e') } + ], + }) +} + +fn situation(value: Value, source: ManagedSource, expired: bool) -> Situation { + Situation::State { + state: Box::new(ManagedState::parse(&serde_json::to_vec(&value).unwrap()).unwrap()), + source, + expired, + } +} + +fn file_source() -> ManagedSource { + ManagedSource::File(std::env::temp_dir().join("state.dsse")) +} + +fn https() -> ManagedSource { + ManagedSource::parse("https://skills.example.com/state").unwrap() +} + +fn error(result: Result<(), ServiceError>) -> String { + result.unwrap_err().to_string() +} + +#[test] +fn without_a_source_everything_runs() { + let dir = tempfile::tempdir().unwrap(); + let (folder, _) = skill(dir.path(), "pdf", "one"); + let situation = Situation::NotConfigured; + assert!(situation.check(Candidate::digest("pdf", "x")).is_ok()); + assert_eq!(situation.status_of(&folder, false).unwrap(), None); + assert_eq!(situation.command_policy(), CommandPolicy::Run); +} + +#[test] +fn listed_blocked_and_unlisted_content() { + let dir = tempfile::tempdir().unwrap(); + let (listed, listed_digest) = skill(dir.path(), "pdf", "one"); + let (blocked, blocked_digest) = skill(dir.path(), "bad", "two"); + let (unlisted, unlisted_digest) = skill(dir.path(), "docx", "three"); + let source = file_source(); + let value = state_json(source.as_str(), &listed_digest, &blocked_digest); + let situation = situation(value, source, false); + + assert!(situation + .check(Candidate::digest("pdf", &listed_digest)) + .is_ok()); + let message = error(situation.check(Candidate::digest("bad", &blocked_digest))); + assert!( + message.contains("is blocked by the managed state: withdrawn"), + "{message}" + ); + let message = error(situation.check(Candidate::digest("e", &other('e')))); + assert!( + message.ends_with("is blocked by the managed state"), + "{message}" + ); + let message = error(situation.check(Candidate::digest("docx", &unlisted_digest))); + assert!( + message.contains("isn't allowed by the managed state"), + "{message}" + ); + assert!(!message.contains("request"), "{message}"); + + assert_eq!(situation.status_of(&listed, false).unwrap(), None); + assert_eq!( + situation.status_of(&blocked, false).unwrap(), + Some(ManagedSkillStatus::Blocked) + ); + assert_eq!( + situation.status_of(&unlisted, false).unwrap(), + Some(ManagedSkillStatus::NotAllowed) + ); + assert_eq!(situation.command_policy(), CommandPolicy::Run); +} + +#[test] +fn a_candidate_digest_is_computed_from_its_folder() { + let dir = tempfile::tempdir().unwrap(); + let (blocked, blocked_digest) = skill(dir.path(), "bad", "two"); + let source = file_source(); + let value = state_json(source.as_str(), &other('a'), &blocked_digest); + let situation = situation(value, source, false); + let candidate = Candidate { + id: "bad", + digest: None, + path: Some(&blocked), + editable: false, + }; + assert!(error(situation.check(candidate)).contains("blocked")); + let nothing = Candidate { + path: None, + ..candidate + }; + assert!(error(situation.check(nothing)).contains("no content digest")); + let gone = Candidate { + path: Some(&dir.path().join("gone")), + ..candidate + }; + assert!(situation.check(gone).is_err()); +} + +#[test] +fn editable_skills_skip_the_list_but_never_the_block() { + let dir = tempfile::tempdir().unwrap(); + let (editable, editable_digest) = skill(dir.path(), "mine", "one"); + let (blocked, blocked_digest) = skill(dir.path(), "bad", "two"); + let source = file_source(); + let mut value = state_json(source.as_str(), &other('a'), &blocked_digest); + // A file source ignores `refused`. + value["editable"] = json!("refused"); + let situation = situation(value, source, false); + let candidate = |id, path| Candidate { + id, + digest: None, + path: Some(path), + editable: true, + }; + assert!(situation.check(candidate("mine", &editable)).is_ok()); + assert!(error(situation.check(candidate("bad", &blocked))).contains("blocked")); + assert_eq!(situation.status_of(&editable, true).unwrap(), None); + assert_eq!( + situation.status_of(&blocked, true).unwrap(), + Some(ManagedSkillStatus::Blocked) + ); + assert!(!editable_digest.is_empty()); +} + +#[test] +fn an_https_source_may_refuse_editable_skills_and_link_requests() { + let dir = tempfile::tempdir().unwrap(); + let (editable, _) = skill(dir.path(), "mine", "one"); + let (_, unlisted_digest) = skill(dir.path(), "docx", "three"); + let source = https(); + let mut value = state_json(source.as_str(), &other('a'), &other('c')); + value["skills"][0]["artifact"] = json!("https://skills.example.com/pdf.zip"); + value["editable"] = json!("refused"); + value["request_url"] = json!("https://skills.example.com/request?d={digest}"); + let situation = situation(value, source, false); + + let candidate = Candidate { + id: "mine", + digest: None, + path: Some(&editable), + editable: true, + }; + assert!(error(situation.check(candidate)).contains("refuses those")); + assert_eq!( + situation.status_of(&editable, true).unwrap(), + Some(ManagedSkillStatus::NotAllowed) + ); + let message = error(situation.check(Candidate::digest("docx", &unlisted_digest))); + assert!( + message.contains("request it at https://skills.example.com/request?d=sha256-tree-v2"), + "{message}" + ); +} + +#[test] +fn allowing_any_content_refuses_only_the_blocked() { + let dir = tempfile::tempdir().unwrap(); + let (unlisted, unlisted_digest) = skill(dir.path(), "docx", "three"); + let source = file_source(); + let mut value = state_json(source.as_str(), &other('a'), &other('c')); + value["allowed"] = json!("any"); + let situation = situation(value, source, false); + assert!(situation + .check(Candidate::digest("docx", &unlisted_digest)) + .is_ok()); + assert_eq!(situation.status_of(&unlisted, false).unwrap(), None); +} + +#[test] +fn an_expired_state_refuses_changes_and_warns_readers() { + let source = file_source(); + let value = state_json(source.as_str(), &other('a'), &other('c')); + let situation = situation(value, source, true); + let message = error(situation.check(Candidate::digest("pdf", &other('a')))); + assert!(message.contains("expired at"), "{message}"); + let CommandPolicy::Warn(warning) = situation.command_policy() else { + panic!("expected a warning"); + }; + assert!(warning.contains("expired"), "{warning}"); +} + +#[test] +fn without_a_valid_state_required_refuses_and_otherwise_warns() { + let required = Situation::NoState { + required: true, + problem: "nothing cached".to_string(), + }; + assert!(error(required.check(Candidate::digest("pdf", "x"))).contains("is required")); + assert!( + matches!(required.command_policy(), CommandPolicy::Refuse(text) if text.contains("nothing cached")) + ); + + let optional = Situation::NoState { + required: false, + problem: "nothing cached".to_string(), + }; + assert!(optional.check(Candidate::digest("pdf", "x")).is_ok()); + assert!( + matches!(optional.command_policy(), CommandPolicy::Warn(text) if text.contains("nothing cached")) + ); + + let unusable = Situation::Unusable("owned by someone else".to_string()); + assert!(error(unusable.check(Candidate::digest("pdf", "x"))).contains("can't be used")); + assert!(matches!( + unusable.command_policy(), + CommandPolicy::Refuse(_) + )); +} + +struct Store { + dir: tempfile::TempDir, + layout: ManagedLayout, + state_file: PathBuf, +} + +impl Store { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let layout = ManagedLayout::new(dir.path().join("data")); + let state_file = dir.path().join("state.dsse"); + Self { + dir, + layout, + state_file, + } + } + + fn settings(&self, from_system: bool) -> ManagedSettings { + ManagedSettings { + source: Some(ManagedSource::File(self.state_file.clone())), + keys: vec![pinned("k1", 1)], + source_from_system: from_system, + ..ManagedSettings::default() + } + } + + fn enroll(&self) { + std::fs::create_dir_all(self.layout.managed()).unwrap(); + let enrollment = Enrollment::new(&self.state_file.display().to_string(), now()); + std::fs::write( + self.layout.enrollment_file(), + serde_json::to_vec(&enrollment).unwrap(), + ) + .unwrap(); + } + + fn cache(&self, expires_at: &str) { + let mut value = state_json( + &self.state_file.display().to_string(), + &other('a'), + &other('c'), + ); + value["expires_at"] = json!(expires_at); + let envelope = sign(&serde_json::to_vec(&value).unwrap(), &[("k1", 1)]); + std::fs::write(self.layout.cached_state(), envelope).unwrap(); + } + + fn load(&self, settings: ManagedSettings) -> Situation { + Situation::load(&self.layout, Ok(settings), now()) + } +} + +#[test] +fn loading_reads_the_enrollment_and_the_cached_state() { + let store = Store::new(); + assert_eq!( + Situation::load(&store.layout, Ok(ManagedSettings::default()), now()), + Situation::NotConfigured + ); + let unusable = Situation::load( + &store.layout, + Err(ManagedError::Config("bad".to_string())), + now(), + ); + assert!(matches!(unusable, Situation::Unusable(text) if text.contains("bad"))); + + let Situation::NoState { problem, .. } = store.load(store.settings(false)) else { + panic!("expected no state"); + }; + assert!(problem.contains("managed enroll"), "{problem}"); + let Situation::NoState { problem, .. } = store.load(store.settings(true)) else { + panic!("expected no state"); + }; + assert!(problem.contains("managed apply"), "{problem}"); + + store.enroll(); + let mut required = store.settings(true); + required.required = true; + let Situation::NoState { required, problem } = store.load(required) else { + panic!("expected no state"); + }; + assert!(required); + assert!( + problem.contains("no state has been accepted yet"), + "{problem}" + ); + + store.cache("2026-10-16T11:00:00Z"); + assert!(matches!( + store.load(store.settings(true)), + Situation::State { expired: false, .. } + )); + store.cache("2026-10-09T11:30:00Z"); + assert!(matches!( + store.load(store.settings(true)), + Situation::State { expired: true, .. } + )); + + std::fs::write(store.layout.enrollment_file(), "not json").unwrap(); + assert!(matches!( + store.load(store.settings(true)), + Situation::NoState { .. } + )); + drop(store.dir); +} + +#[test] +fn a_fixed_gate_checks_against_its_situation() { + let gate = ManagedGate::fixed(Situation::Unusable("broken".to_string())); + assert!(gate.check(Candidate::digest("pdf", "x")).is_err()); + assert!(gate.check_all([Candidate::digest("pdf", "x")]).is_err()); + assert!(ManagedGate::fixed(Situation::NotConfigured) + .check_all([Candidate::digest("pdf", "x")]) + .is_ok()); + // The default gate reads this user's situation; a test machine has no managed source. + let current = ManagedGate::default().situation(); + assert!(matches!( + *current, + Situation::NotConfigured | Situation::NoState { .. } | Situation::State { .. } + )); +} diff --git a/crates/fastskill-core/src/core/managed/mod.rs b/crates/fastskill-core/src/core/managed/mod.rs index 23d8a68f..4a3ad096 100644 --- a/crates/fastskill-core/src/core/managed/mod.rs +++ b/crates/fastskill-core/src/core/managed/mod.rs @@ -16,12 +16,16 @@ //! [`quarantine`], the per-user [`layout`] and lock, and the [`records`] it keeps //! (decisions 8 to 12 and 17). //! +//! - [`gate`]: the install gate, the two managed reconciliation statuses and what a command may +//! do without a valid state (decisions 4, 13 and 14). +//! //! [`open`] is the one entry point that turns envelope bytes into a state FastSkill may use. pub mod apply; pub mod config; pub mod enrollment; pub mod envelope; +pub mod gate; pub mod layout; pub mod quarantine; pub mod records; @@ -37,6 +41,7 @@ pub use apply::{apply, ApplyContext, ApplyOutcome, ApplyResult, EntryChange}; pub use config::{ManagedSettings, ManagedSource, PinnedKey, SYSTEM_FILE_NAME, USER_FILE_NAME}; pub use enrollment::{enroll, status, unenroll, ManagedStatus, UnenrollOutcome}; pub use envelope::{verify_envelope, Verified, PAYLOAD_TYPE}; +pub use gate::{Candidate, CommandPolicy, ManagedGate, ManagedSkillStatus, Situation}; pub use layout::{ApplyLock, ManagedLayout}; pub use quarantine::{QuarantineReason, QuarantineRecord}; pub use records::{Enrollment, EntryMode, OwnedEntry, Ownership}; diff --git a/crates/fastskill-core/src/core/service.rs b/crates/fastskill-core/src/core/service.rs index 5c735a52..729cd549 100644 --- a/crates/fastskill-core/src/core/service.rs +++ b/crates/fastskill-core/src/core/service.rs @@ -328,6 +328,9 @@ pub struct FastSkillService { /// `config.skill_cache_root` (env-resolved when unset). skill_cache: crate::core::cache::SkillCache, + /// The managed-state install gate (ADR-0016 decision 13): this user's situation by default. + managed_gate: crate::core::managed::ManagedGate, + /// Service state initialized: bool, } @@ -399,6 +402,7 @@ impl FastSkillService { storage, hot_reload_manager, skill_cache, + managed_gate: crate::core::managed::ManagedGate::default(), initialized: false, }) } @@ -454,6 +458,17 @@ impl FastSkillService { self.project_root.as_ref() } + /// Replace the managed-state install gate, which otherwise reads this user's situation. + pub fn with_managed_gate(mut self, gate: crate::core::managed::ManagedGate) -> Self { + self.managed_gate = gate; + self + } + + /// The managed-state install gate every content-adding operation consults. + pub fn managed_gate(&self) -> &crate::core::managed::ManagedGate { + &self.managed_gate + } + /// Initialize the service pub async fn initialize(&mut self) -> Result<(), ServiceError> { if self.initialized { diff --git a/webdocs/cli-reference/managed-command.mdx b/webdocs/cli-reference/managed-command.mdx index eea72392..25cbbefb 100644 --- a/webdocs/cli-reference/managed-command.mdx +++ b/webdocs/cli-reference/managed-command.mdx @@ -102,6 +102,27 @@ fastskill managed unenroll **Options** (every subcommand): - `--json`: Print the result as JSON +## Installs and other commands + +While this user follows a state, every command that adds or replaces skill content checks it +first, before anything changes: `skill add`, `skill update`, `bundle add`, `bundle update`, +`bundle override` (and resetting one), `project install` and restoring personal overrides. The +same check applies to `server serve` and `mcp serve`. Content whose digest is blocked is +refused, and when the state allows only the listed skills, so is anything it doesn't list. An +editable local skill changes with every edit, so it skips the list, but never the block. + +Other commands that read or change skills follow the state too: + +| Situation | `managed`, `cache`, `repo`, `cli` | Commands that read skills | Commands that change content | +| --- | --- | --- | --- | +| Expired state | run | run, with a warning | refused | +| No valid state, `required = true` | run | refused | refused | +| No valid state, not required | run | run, with a warning | run, with a warning | + +`skill list` reports installed content the state blocks as `managed-blocked`, and content it +doesn't allow as `managed-not-allowed`; `skill list --check` fails on both. See +[reconciliation](/skill-management/reconciliation). + ## The quarantine Content is moved, not deleted, when its digest is blocked, when the state allows only the diff --git a/webdocs/skill-management/reconciliation.mdx b/webdocs/skill-management/reconciliation.mdx index 3152777a..cd0b150c 100644 --- a/webdocs/skill-management/reconciliation.mdx +++ b/webdocs/skill-management/reconciliation.mdx @@ -39,6 +39,8 @@ The `reconciliation` field is the canonical machine-readable status. Values can | Value | Meaning | | --- | --- | +| `managed-blocked` | The managed state blocks the installed content's digest. | +| `managed-not-allowed` | The managed state allows only listed content, and the installed content isn't listed. | | `ok` | Selected state agrees and managed content passes integrity checks. | | `excluded` | A group filter excluded the owning root. | | `missing-lock` | Declared intent has no resolved lock entry. | @@ -51,6 +53,11 @@ The `reconciliation` field is the canonical machine-readable status. Values can | `ownership-conflict` | Bundle owners disagree about the expected content. | | `extraneous` | Installed content has no manifest, lock, bundle, or override owner. | +The two `managed-` values appear only when this machine follows a verified managed state (see +[`fastskill managed`](/cli-reference/managed-command)). They take precedence over every other +value, `managed-blocked` first, and `skill list --check` fails on them even for a skill outside +the current selection. + ## JSON schema ```console