Skip to main content

kovra_core/
scope.rs

1//! `AgentScope` — the capability that bounds an MCP session (spec §3.2, I13).
2//!
3//! Scope is enforced **first**: a coordinate outside the session's scope is
4//! *unaddressable* — it does not exist for that channel — rather than being
5//! resolved and then denied. This is defense in depth: even a hijacked agent
6//! cannot reach what the scope excludes, because the relevant secrets are never
7//! surfaced to it (I13).
8//!
9//! The scope is defined on **operation axes** and a **project/environment
10//! filter**, never on environment alone (a blunt "no prod for Claude" would
11//! break legitimate diagnose/deploy flows — §3.2).
12
13use std::collections::BTreeSet;
14
15use serde::{Deserialize, Serialize};
16
17use crate::coordinate::{Coordinate, EnvSegment};
18
19/// What an operation does with a secret's value.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub enum Operation {
22    /// List / status / fingerprint — no value touched.
23    Metadata,
24    /// Deliver a value *through* an operation; the value never returns to the
25    /// caller's context (injection into a child process).
26    Inject,
27    /// Return plaintext *into* the caller's context.
28    Reveal,
29    /// Mutate the vault — create/update/delete a record or edit its metadata. No
30    /// value flows *to* the caller, but the store changes, so a genuinely
31    /// read-only session must be able to withhold it (I13). A scope without
32    /// `Write` is read-only: every mutating tool is refused as unaddressable.
33    Write,
34}
35
36/// Which face is asking — selects the §3.1 delivery column.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Surface {
39    /// The `kovra` CLI (host, attended).
40    Cli,
41    /// The local Web UI (loopback).
42    WebUi,
43    /// The MCP server (the model's channel).
44    Mcp,
45}
46
47/// Who initiated the request — weighs differently for `prod` reveals (I14, §8.3).
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum Origin {
51    /// Agent-initiated (MCP, or a wrapper invoked by the agent).
52    Agent,
53    /// Human-initiated (a deliberate act at the CLI / UI).
54    Human,
55}
56
57impl Origin {
58    /// Stable lowercase label, for audit records and prompts.
59    pub fn as_str(&self) -> &'static str {
60        match self {
61            Origin::Agent => "agent",
62            Origin::Human => "human",
63        }
64    }
65}
66
67/// A set-membership filter over a string axis (projects or environments).
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Filter {
70    /// Every value is in scope.
71    Any,
72    /// Only the listed values are in scope.
73    Only(BTreeSet<String>),
74}
75
76impl Filter {
77    /// Build an `Only` filter from an iterator of names.
78    pub fn only<I, S>(values: I) -> Self
79    where
80        I: IntoIterator<Item = S>,
81        S: Into<String>,
82    {
83        Filter::Only(values.into_iter().map(Into::into).collect())
84    }
85
86    /// Whether `value` passes the filter.
87    pub fn allows(&self, value: &str) -> bool {
88        match self {
89            Filter::Any => true,
90            Filter::Only(set) => set.contains(value),
91        }
92    }
93}
94
95/// The bounded capability a session operates under (§3.2).
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct AgentScope {
98    /// Operation axes this session may perform at all.
99    pub operations: BTreeSet<Operation>,
100    /// Which projects are addressable (`None` project = the global vault).
101    pub projects: Filter,
102    /// Which environments are addressable.
103    pub environments: Filter,
104}
105
106impl AgentScope {
107    /// The unrestricted scope — every operation, every project/environment.
108    /// The local CLI/UI operate under this; MCP sessions get something narrower.
109    pub fn full() -> Self {
110        Self {
111            operations: [
112                Operation::Metadata,
113                Operation::Inject,
114                Operation::Reveal,
115                Operation::Write,
116            ]
117            .into_iter()
118            .collect(),
119            projects: Filter::Any,
120            environments: Filter::Any,
121        }
122    }
123
124    /// A metadata-only scope (diagnose without any value flow) — §3.2.
125    pub fn metadata_only() -> Self {
126        Self {
127            operations: [Operation::Metadata].into_iter().collect(),
128            projects: Filter::Any,
129            environments: Filter::Any,
130        }
131    }
132
133    /// Whether `operation` is permitted at all in this session.
134    pub fn permits(&self, operation: Operation) -> bool {
135        self.operations.contains(&operation)
136    }
137
138    /// Whether the coordinate (under an optional project) is **addressable** in
139    /// this scope — the first gate (I13). A `${ENV}` placeholder is treated as
140    /// not-yet-resolved and therefore not addressable until substituted (L4).
141    pub fn addresses(&self, coord: &Coordinate, project: Option<&str>) -> bool {
142        let env_ok = match &coord.environment {
143            EnvSegment::Literal(env) => self.environments.allows(env),
144            EnvSegment::Placeholder => false,
145        };
146        // `None` project means the global vault; it is addressable unless the
147        // project filter is an explicit allowlist that the global is not in.
148        let project_ok = match project {
149            Some(name) => self.projects.allows(name),
150            None => matches!(self.projects, Filter::Any),
151        };
152        env_ok && project_ok
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::str::FromStr;
160
161    fn coord(s: &str) -> Coordinate {
162        Coordinate::from_str(s).unwrap()
163    }
164
165    #[test]
166    fn full_scope_addresses_everything() {
167        let s = AgentScope::full();
168        assert!(s.addresses(&coord("secret:prod/db/password"), Some("api")));
169        assert!(s.addresses(&coord("secret:dev/app/key"), None));
170        assert!(s.permits(Operation::Reveal));
171    }
172
173    #[test]
174    fn env_filter_excludes_out_of_scope_env() {
175        let s = AgentScope {
176            operations: [Operation::Metadata].into_iter().collect(),
177            projects: Filter::Any,
178            environments: Filter::only(["dev", "test"]),
179        };
180        assert!(s.addresses(&coord("secret:dev/app/key"), None));
181        assert!(!s.addresses(&coord("secret:prod/db/password"), None));
182    }
183
184    #[test]
185    fn project_allowlist_excludes_global_and_other_projects() {
186        let s = AgentScope {
187            operations: [Operation::Metadata].into_iter().collect(),
188            projects: Filter::only(["api"]),
189            environments: Filter::Any,
190        };
191        assert!(s.addresses(&coord("secret:dev/app/key"), Some("api")));
192        assert!(!s.addresses(&coord("secret:dev/app/key"), Some("billing")));
193        // global (None) is not in an explicit project allowlist
194        assert!(!s.addresses(&coord("secret:dev/app/key"), None));
195    }
196
197    #[test]
198    fn placeholder_env_is_not_addressable() {
199        let s = AgentScope::full();
200        assert!(!s.addresses(&coord("secret:${ENV}/db/password"), Some("api")));
201    }
202
203    #[test]
204    fn metadata_only_scope_forbids_reveal_and_inject() {
205        let s = AgentScope::metadata_only();
206        assert!(s.permits(Operation::Metadata));
207        assert!(!s.permits(Operation::Reveal));
208        assert!(!s.permits(Operation::Inject));
209    }
210}