Skip to main content

kaynine_runtime/
policy.rs

1//! Default decision chain (SPEC §8.3 决策优先级): 硬拒绝 → grants → session →
2//! product → default. v0.1 delivers the combinator plus the grant layer; the
3//! session layer is an empty placeholder (documented plan simplification) and
4//! the product/default layers are closures the host supplies as ordinary
5//! `Policy` implementations stacked into the chain.
6
7use kaynine_core::error::PolicyError;
8use kaynine_core::policy::{Decision, Policy, PolicyRequest};
9use kaynine_core::store::SessionStore;
10use std::sync::Arc;
11
12/// Ordered policy combinator: layers are evaluated in priority order
13/// (hard-deny first). Any `Deny` short-circuits immediately; otherwise the
14/// first `Ask` is remembered while later layers keep running (a later Deny
15/// still wins — deny beats ask); if no layer denies, the first Ask wins; all
16/// Allow → Allow.
17pub struct PolicyChain {
18    pub layers: Vec<Arc<dyn Policy>>,
19}
20
21impl PolicyChain {
22    pub fn new(layers: Vec<Arc<dyn Policy>>) -> Self {
23        Self { layers }
24    }
25}
26
27#[async_trait::async_trait]
28impl Policy for PolicyChain {
29    async fn evaluate(&self, request: PolicyRequest) -> Result<Decision, PolicyError> {
30        let mut first_ask: Option<Decision> = None;
31        for layer in &self.layers {
32            match layer.evaluate(request.clone()).await? {
33                Decision::Deny { reason } => return Ok(Decision::Deny { reason }),
34                Decision::Ask => {
35                    first_ask.get_or_insert(Decision::Ask);
36                }
37                Decision::Allow => {}
38            }
39        }
40        Ok(first_ask.unwrap_or(Decision::Allow))
41    }
42}
43
44/// Grant layer: reads the session store's persisted `policy_grants` rows for
45/// the namespace and matches them against the call's declared capabilities by
46/// serialized equality (Capability 全等; path/origin wildcards are deferred to
47/// product closures). A match grants Allow; no match defers to later layers
48/// with Ask.
49///
50/// Grant payload contract:
51/// `{"grant_id": "...", "namespace": "...", "capability": <Capability serde>}`
52pub struct GrantPolicy {
53    pub store: Arc<dyn SessionStore>,
54    pub namespace: String,
55}
56
57#[async_trait::async_trait]
58impl Policy for GrantPolicy {
59    async fn evaluate(&self, request: PolicyRequest) -> Result<Decision, PolicyError> {
60        let grants = self
61            .store
62            .list_grants(&self.namespace)
63            .await
64            .map_err(|e| PolicyError::Evaluate(e.to_string()))?;
65        let call_caps: Vec<serde_json::Value> = request
66            .call
67            .capabilities
68            .iter()
69            .map(serde_json::to_value)
70            .collect::<Result<_, _>>()
71            .map_err(|e| PolicyError::Evaluate(e.to_string()))?;
72        for grant in &grants {
73            let Some(granted) = grant.get("capability") else {
74                continue;
75            };
76            if call_caps.iter().any(|cap| cap == granted) {
77                return Ok(Decision::Allow);
78            }
79        }
80        // No persisted grant: defer to the next layer (typically Ask).
81        Ok(Decision::Ask)
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use kaynine_core::ids::{RunId, SessionId, ToolCallId};
89    use kaynine_core::policy::DenyAllPolicy;
90    use kaynine_core::store::SessionStore;
91    use kaynine_core::testing::ScriptedPolicy;
92    use kaynine_core::tool::{Capability, PreparedToolCall};
93    use kaynine_store::sqlite::SqliteStore;
94    use std::collections::VecDeque;
95    use std::sync::Mutex;
96
97    struct FixedPolicy(Decision);
98
99    #[async_trait::async_trait]
100    impl Policy for FixedPolicy {
101        async fn evaluate(&self, _request: PolicyRequest) -> Result<Decision, PolicyError> {
102            Ok(self.0.clone())
103        }
104    }
105
106    fn request_with_caps(caps: Vec<Capability>) -> PolicyRequest {
107        PolicyRequest {
108            call: PreparedToolCall {
109                call_id: ToolCallId::from("call-1"),
110                name: "bash".into(),
111                arguments: serde_json::json!({}),
112                capabilities: caps,
113            },
114            session_id: SessionId::from("s"),
115            run_id: RunId::from("r"),
116            turn: 1,
117        }
118    }
119
120    #[tokio::test]
121    async fn policy_chain_priority() {
122        // Hard-deny beats ask.
123        let chain = PolicyChain::new(vec![
124            Arc::new(FixedPolicy(Decision::Ask)),
125            Arc::new(DenyAllPolicy {
126                reason: "机密".into(),
127            }),
128        ]);
129        assert_eq!(
130            chain.evaluate(request_with_caps(vec![])).await.unwrap(),
131            Decision::Deny {
132                reason: "机密".into()
133            }
134        );
135
136        // Ask beats allow (first ask wins, later allows do not override).
137        let chain = PolicyChain::new(vec![
138            Arc::new(FixedPolicy(Decision::Allow)),
139            Arc::new(FixedPolicy(Decision::Ask)),
140            Arc::new(FixedPolicy(Decision::Allow)),
141        ]);
142        assert_eq!(
143            chain.evaluate(request_with_caps(vec![])).await.unwrap(),
144            Decision::Ask
145        );
146
147        // All allow → Allow.
148        let chain = PolicyChain::new(vec![
149            Arc::new(FixedPolicy(Decision::Allow)),
150            Arc::new(FixedPolicy(Decision::Allow)),
151        ]);
152        assert_eq!(
153            chain.evaluate(request_with_caps(vec![])).await.unwrap(),
154            Decision::Allow
155        );
156    }
157
158    #[tokio::test]
159    async fn policy_chain_defers_to_scripted_order() {
160        // ScriptedPolicy pops per evaluate; chain must evaluate layers in order.
161        let script = ScriptedPolicy {
162            decisions: Mutex::new(VecDeque::from(vec![Decision::Ask])),
163        };
164        let chain = PolicyChain::new(vec![
165            Arc::new(script),
166            Arc::new(FixedPolicy(Decision::Allow)),
167        ]);
168        assert_eq!(
169            chain.evaluate(request_with_caps(vec![])).await.unwrap(),
170            Decision::Ask
171        );
172    }
173
174    async fn store_with_grant(capability: &Capability) -> Arc<SqliteStore> {
175        let path =
176            std::env::temp_dir().join(format!("kaynine-grant-test-{}.db", uuid::Uuid::new_v4()));
177        let store = SqliteStore::open(&path).expect("open store");
178        store.migrate().await.expect("migrate");
179        store
180            .insert_grant(
181                "grant-1",
182                "prod",
183                serde_json::json!({
184                    "grant_id": "grant-1",
185                    "namespace": "prod",
186                    "capability": capability,
187                }),
188            )
189            .await
190            .expect("insert grant");
191        // Leak the path: the temp file is cleaned up by the OS; tests are
192        // short-lived.
193        Arc::new(store)
194    }
195
196    #[tokio::test]
197    async fn grant_policy_allows_matched_capability() {
198        let cap = Capability::FileRead {
199            path: "/tmp/data".into(),
200        };
201        let store = store_with_grant(&cap).await;
202        let policy = GrantPolicy {
203            store: store as Arc<dyn SessionStore>,
204            namespace: "prod".into(),
205        };
206
207        // Matched → Allow.
208        assert_eq!(
209            policy
210                .evaluate(request_with_caps(vec![
211                    Capability::NetworkAccess {
212                        origin: "example.com".into()
213                    },
214                    cap.clone()
215                ]))
216                .await
217                .unwrap(),
218            Decision::Allow
219        );
220
221        // Unmatched → Ask (defers to later layers).
222        assert_eq!(
223            policy
224                .evaluate(request_with_caps(vec![Capability::FileWrite {
225                    path: "/tmp/data".into()
226                }]))
227                .await
228                .unwrap(),
229            Decision::Ask
230        );
231    }
232}