返回 CodeWhale
managed_policy.rs
根目录 / crates / tui / src / plugins / managed_policy.rs
1 //! Managed (organization/fleet) plugin policy: the Runtime-side enforcement primitive.
2 //!
3 //! An organization governs its members' Runtimes by delivering a policy document
4 //! to each machine; this module only defines the document and enforces it.
5 //! Delivery (network fetch, control-plane client, MDM deployment) is out of
6 //! scope: the policy arrives as a local file.
7 //!
8 //! Semantics, in order of importance:
9 //! - **Absent policy means today's exact behaviour.** Every plugin that enables
10 //! now must still enable when no policy file exists.
11 //! - **Local state can never defeat the policy.** Enforcement lives in the
12 //! registry's `apply_state` choke point, so a plugin enabled before the
13 //! policy arrived, or an `enabled: true` bit hand-edited into `state.json`,
14 //! comes back disabled — there is no window in which a forbidden plugin is
15 //! active. `PluginRegistry::enable` re-reads the document so a policy that
16 //! lands after discovery still refuses.
17 //! - **A malformed policy fails closed.** An unreadable, unparseable, or
18 //! wrong-schema document refuses every enablement with a clear error; it
19 //! never silently degrades to "allow everything".
20 //!
21 //! The document is resolved to a single source: `managed-policy.json` next to
22 //! the plugin `state.json`, overridable by `CODEWHALE_MANAGED_POLICY_PATH` for
23 //! testing. The override is read from the pre-dotenv [`HostEnvironment`]
24 //! snapshot so a workspace dotenv file cannot redirect organization governance.
25 //!
26 //! This is deliberately a different concept from
27 //! [`PluginActivationPolicy`](super::activation::PluginActivationPolicy), which
28 //! describes which capability *kinds* this build will ever activate. The managed
29 //! policy describes which plugin *identities* this machine may enable.
30 //!
31 //! # Known limitations
32 //!
33 //! The "no window in which a forbidden plugin is active" guarantee above is
34 //! scoped to *in-process activation*: every live capability surface (MCP
35 //! servers, Skills, agents, hooks, commands) reaches the plugin through
36 //! [`LoadedPlugin::active`](super::types::LoadedPlugin::active), which reads
37 //! the `enabled` bit that `apply_state` has already gated. This policy is
38 //! **not** consulted by
39 //! [`verify_plugin_state_authority`](super::registry::verify_plugin_state_authority),
40 //! the execution-boundary revocation probe, which re-reads the persisted
41 //! `state.json` `enabled` bit directly and never sees the in-memory registry.
42 //! A [`PluginAuthority`](super::types::PluginAuthority) minted before the
43 //! policy arrived and then *persisted* — today only the offline-queue
44 //! `skill_provenance` receipt, which survives a restart — therefore
45 //! revalidates against `state.json` alone. Closing that path means enforcing
46 //! the policy at the authority boundary too, which this slice does not do.
47
48 use std::collections::BTreeSet;
49 use std::fs;
50 use std::io::ErrorKind;
51 use std::path::{Path, PathBuf};
52
53 use serde::{Deserialize, Serialize};
54
55 use super::context::HostEnvironment;
56 use super::types::PluginId;
57
58 /// Schema version of the managed policy document, versioned exactly like
59 /// `PluginStateFile`: a required `schema_version` field that must match.
60 pub const MANAGED_POLICY_SCHEMA_VERSION: u32 = 1;
61
62 /// File name resolved next to the plugin state file (e.g.
63 /// `~/.codewhale/plugins/managed-policy.json`).
64 pub const MANAGED_POLICY_FILE_NAME: &str = "managed-policy.json";
65
66 /// Process-environment override for the policy path. Read from the pre-dotenv
67 /// host snapshot, never from ambient process state after dotenv loads.
68 pub const MANAGED_POLICY_PATH_ENV: &str = "CODEWHALE_MANAGED_POLICY_PATH";
69
70 /// Organization-supplied allowlist of plugin identities permitted on this machine.
71 ///
72 /// Identities are full discovery [`PluginId`]s (`scope/hash/name`), matched
73 /// exactly: a renamed or relocated bundle has a different id and is not
74 /// covered by an entry written for another id.
75 #[derive(Debug, Clone, Serialize, Deserialize)]
76 #[serde(deny_unknown_fields)]
77 pub struct ManagedPluginPolicy {
78 pub schema_version: u32,
79 #[serde(default)]
80 pub allowed_plugins: BTreeSet<PluginId>,
81 #[serde(default)]
82 pub allow_unlisted: bool,
83 }
84
85 impl ManagedPluginPolicy {
86 /// True when `id` may be enabled under this policy: either it is
87 /// allowlisted, or the policy permits plugins outside the allowlist.
88 #[must_use]
89 pub fn allows(&self, id: &PluginId) -> bool {
90 self.allow_unlisted || self.allowed_plugins.contains(id)
91 }
92 }
93
94 /// Outcome of loading the managed policy document. There is no "ignore and
95 /// allow" outcome: anything other than absent-or-valid fails closed.
96 pub(crate) enum ManagedPolicyOutcome {
97 Absent,
98 Loaded(ManagedPluginPolicy),
99 Invalid(String),
100 }
101
102 /// Resolve the single policy source: the env override when set to a non-empty
103 /// value, otherwise `managed-policy.json` next to the plugin state file.
104 pub(crate) fn resolve_managed_policy_path(
105 state_path: &Path,
106 host_environment: Option<&HostEnvironment>,
107 ) -> PathBuf {
108 let override_path = host_environment
109 .and_then(|environment| environment.var(MANAGED_POLICY_PATH_ENV).ok())
110 .filter(|value| !value.trim().is_empty());
111 if let Some(path) = override_path {
112 return PathBuf::from(path);
113 }
114 state_path
115 .parent()
116 .filter(|parent| !parent.as_os_str().is_empty())
117 .unwrap_or_else(|| Path::new("."))
118 .join(MANAGED_POLICY_FILE_NAME)
119 }
120
121 /// Load the policy document. A missing file is [`ManagedPolicyOutcome::Absent`]
122 /// (today's behaviour); any other failure is [`ManagedPolicyOutcome::Invalid`].
123 pub(crate) fn load_managed_policy(path: &Path) -> ManagedPolicyOutcome {
124 let raw = match fs::read_to_string(path) {
125 Ok(raw) => raw,
126 Err(error) if error.kind() == ErrorKind::NotFound => {
127 return ManagedPolicyOutcome::Absent;
128 }
129 Err(error) => {
130 return ManagedPolicyOutcome::Invalid(format!(
131 "failed to read {}: {error}",
132 path.display()
133 ));
134 }
135 };
136 let policy: ManagedPluginPolicy = match serde_json::from_str(&raw) {
137 Ok(policy) => policy,
138 Err(error) => {
139 return ManagedPolicyOutcome::Invalid(format!(
140 "failed to parse {}: {error}",
141 path.display()
142 ));
143 }
144 };
145 if policy.schema_version != MANAGED_POLICY_SCHEMA_VERSION {
146 return ManagedPolicyOutcome::Invalid(format!(
147 "unsupported managed plugin policy schema {}; expected {MANAGED_POLICY_SCHEMA_VERSION}",
148 policy.schema_version
149 ));
150 }
151 ManagedPolicyOutcome::Loaded(policy)
152 }
153
153 lines RUST