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}
30
31/// Which face is asking — selects the §3.1 delivery column.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Surface {
34    /// The `kovra` CLI (host, attended).
35    Cli,
36    /// The local Web UI (loopback).
37    WebUi,
38    /// The MCP server (the model's channel).
39    Mcp,
40}
41
42/// Who initiated the request — weighs differently for `prod` reveals (I14, §8.3).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum Origin {
46    /// Agent-initiated (MCP, or a wrapper invoked by the agent).
47    Agent,
48    /// Human-initiated (a deliberate act at the CLI / UI).
49    Human,
50}
51
52impl Origin {
53    /// Stable lowercase label, for audit records and prompts.
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            Origin::Agent => "agent",
57            Origin::Human => "human",
58        }
59    }
60}
61
62/// A set-membership filter over a string axis (projects or environments).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Filter {
65    /// Every value is in scope.
66    Any,
67    /// Only the listed values are in scope.
68    Only(BTreeSet<String>),
69}
70
71impl Filter {
72    /// Build an `Only` filter from an iterator of names.
73    pub fn only<I, S>(values: I) -> Self
74    where
75        I: IntoIterator<Item = S>,
76        S: Into<String>,
77    {
78        Filter::Only(values.into_iter().map(Into::into).collect())
79    }
80
81    /// Whether `value` passes the filter.
82    pub fn allows(&self, value: &str) -> bool {
83        match self {
84            Filter::Any => true,
85            Filter::Only(set) => set.contains(value),
86        }
87    }
88}
89
90/// The bounded capability a session operates under (§3.2).
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct AgentScope {
93    /// Operation axes this session may perform at all.
94    pub operations: BTreeSet<Operation>,
95    /// Which projects are addressable (`None` project = the global vault).
96    pub projects: Filter,
97    /// Which environments are addressable.
98    pub environments: Filter,
99}
100
101impl AgentScope {
102    /// The unrestricted scope — every operation, every project/environment.
103    /// The local CLI/UI operate under this; MCP sessions get something narrower.
104    pub fn full() -> Self {
105        Self {
106            operations: [Operation::Metadata, Operation::Inject, Operation::Reveal]
107                .into_iter()
108                .collect(),
109            projects: Filter::Any,
110            environments: Filter::Any,
111        }
112    }
113
114    /// A metadata-only scope (diagnose without any value flow) — §3.2.
115    pub fn metadata_only() -> Self {
116        Self {
117            operations: [Operation::Metadata].into_iter().collect(),
118            projects: Filter::Any,
119            environments: Filter::Any,
120        }
121    }
122
123    /// Whether `operation` is permitted at all in this session.
124    pub fn permits(&self, operation: Operation) -> bool {
125        self.operations.contains(&operation)
126    }
127
128    /// Whether the coordinate (under an optional project) is **addressable** in
129    /// this scope — the first gate (I13). A `${ENV}` placeholder is treated as
130    /// not-yet-resolved and therefore not addressable until substituted (L4).
131    pub fn addresses(&self, coord: &Coordinate, project: Option<&str>) -> bool {
132        let env_ok = match &coord.environment {
133            EnvSegment::Literal(env) => self.environments.allows(env),
134            EnvSegment::Placeholder => false,
135        };
136        // `None` project means the global vault; it is addressable unless the
137        // project filter is an explicit allowlist that the global is not in.
138        let project_ok = match project {
139            Some(name) => self.projects.allows(name),
140            None => matches!(self.projects, Filter::Any),
141        };
142        env_ok && project_ok
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::str::FromStr;
150
151    fn coord(s: &str) -> Coordinate {
152        Coordinate::from_str(s).unwrap()
153    }
154
155    #[test]
156    fn full_scope_addresses_everything() {
157        let s = AgentScope::full();
158        assert!(s.addresses(&coord("secret:prod/db/password"), Some("api")));
159        assert!(s.addresses(&coord("secret:dev/app/key"), None));
160        assert!(s.permits(Operation::Reveal));
161    }
162
163    #[test]
164    fn env_filter_excludes_out_of_scope_env() {
165        let s = AgentScope {
166            operations: [Operation::Metadata].into_iter().collect(),
167            projects: Filter::Any,
168            environments: Filter::only(["dev", "test"]),
169        };
170        assert!(s.addresses(&coord("secret:dev/app/key"), None));
171        assert!(!s.addresses(&coord("secret:prod/db/password"), None));
172    }
173
174    #[test]
175    fn project_allowlist_excludes_global_and_other_projects() {
176        let s = AgentScope {
177            operations: [Operation::Metadata].into_iter().collect(),
178            projects: Filter::only(["api"]),
179            environments: Filter::Any,
180        };
181        assert!(s.addresses(&coord("secret:dev/app/key"), Some("api")));
182        assert!(!s.addresses(&coord("secret:dev/app/key"), Some("billing")));
183        // global (None) is not in an explicit project allowlist
184        assert!(!s.addresses(&coord("secret:dev/app/key"), None));
185    }
186
187    #[test]
188    fn placeholder_env_is_not_addressable() {
189        let s = AgentScope::full();
190        assert!(!s.addresses(&coord("secret:${ENV}/db/password"), Some("api")));
191    }
192
193    #[test]
194    fn metadata_only_scope_forbids_reveal_and_inject() {
195        let s = AgentScope::metadata_only();
196        assert!(s.permits(Operation::Metadata));
197        assert!(!s.permits(Operation::Reveal));
198        assert!(!s.permits(Operation::Inject));
199    }
200}