Skip to main content

lean_ctx/core/intelligence_runtime/
code_security.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Optional licensed hints over a caller-admitted source snapshot. Detection
3//! remains private; the public host validates bounded findings and owns wording.
4
5use super::{InstallError, Result};
6
7pub(crate) const MAX_SOURCE_BYTES: usize = 65_536;
8const MAX_LINES: usize = 2048;
9const MAX_FINDINGS: usize = 8;
10
11#[derive(Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize)]
12#[serde(rename_all = "snake_case")]
13enum Rule {
14    ShellInterpretationEnabled,
15    TlsVerificationDisabled,
16}
17
18#[derive(serde::Deserialize)]
19#[serde(deny_unknown_fields)]
20struct Finding {
21    rule_id: Rule,
22    line: usize,
23}
24
25#[derive(serde::Deserialize)]
26#[serde(deny_unknown_fields)]
27struct Hints {
28    schema_version: u32,
29    strategy: String,
30    source_sha256: String,
31    supported: bool,
32    truncated: bool,
33    findings: Vec<Finding>,
34}
35
36fn language(path: &str) -> Option<&'static str> {
37    match std::path::Path::new(path).extension()?.to_str()? {
38        "py" | "pyi" => Some("python"),
39        "js" | "jsx" | "mjs" | "cjs" => Some("javascript"),
40        "ts" | "tsx" => Some("typescript"),
41        "go" => Some("go"),
42        "rs" => Some("rust"),
43        "cs" => Some("csharp"),
44        "java" => Some("java"),
45        _ => None,
46    }
47}
48
49pub fn enabled_for(path: &str) -> bool {
50    let config = crate::core::config::Config::load_global().intelligence_runtime;
51    language(path).is_some()
52        && config.enabled
53        && config.accept_proprietary
54        && super::bootstrap::permits_config(&config)
55}
56
57/// Never opens a path. The caller supplies the same safe source snapshot used
58/// by the read, before compression, and rechecks source/output authority later.
59/// The note is not a security verdict and must not be stored in source caches.
60pub fn note(path: &str, source: &str) -> Option<String> {
61    let language = language(path)?;
62    let config = crate::core::config::Config::load_global().intelligence_runtime;
63    if !config.enabled || !config.accept_proprietary || !super::bootstrap::permits_config(&config) {
64        return None;
65    }
66    if source.len() > MAX_SOURCE_BYTES
67        || source.lines().count() > MAX_LINES
68        || source.contains('\0')
69    {
70        return Some(
71            "LeanCTX | Hive code hints: not analyzed (source exceeds the supported text bounds)."
72                .into(),
73        );
74    }
75    Some(match invoke(&config, language, source) {
76        Ok(hints) => render(&hints),
77        Err(_) => {
78            "LeanCTX | Hive code hints unavailable; existing read protections are unchanged.".into()
79        }
80    })
81}
82
83fn render(hints: &Hints) -> String {
84    if !hints.supported {
85        return "LeanCTX | Hive code hints: no checks available for this language.".into();
86    }
87    if hints.findings.is_empty() {
88        return "LeanCTX | Hive code hints: no matching patterns in inspected source (limited static checks)."
89            .into();
90    }
91    let mut text =
92        String::from("LeanCTX | Hive code hints (limited static checks; review required):");
93    for finding in &hints.findings {
94        let message = match finding.rule_id {
95            Rule::TlsVerificationDisabled => {
96                "TLS certificate verification is explicitly disabled; review connection trust."
97            }
98            Rule::ShellInterpretationEnabled => {
99                "Shell interpretation is enabled; review command construction and untrusted input."
100            }
101        };
102        // Lines refer to the policy-filtered snapshot, never a guessed original
103        // location after a rule has removed or replaced multiple source lines.
104        text.push_str(&format!(
105            "\n- Inspected source line {}: {message}",
106            finding.line
107        ));
108    }
109    if hints.truncated {
110        text.push_str("\nAdditional matching patterns were omitted at the finding limit.");
111    }
112    text
113}
114
115#[cfg(any(unix, windows, test))]
116fn validate(raw: &str, source: &str) -> Result<Hints> {
117    if raw.len() > 4096 {
118        return Err(InstallError::Exchange);
119    }
120    let hints: Hints = serde_json::from_str(raw).map_err(|_| InstallError::Exchange)?;
121    let lines = source.lines().count();
122    if hints.schema_version != 1
123        || hints.strategy != "bounded_static_v1"
124        || hints.source_sha256 != super::sha256(source.as_bytes())
125        || hints.findings.len() > MAX_FINDINGS
126        || (!hints.supported && (!hints.findings.is_empty() || hints.truncated))
127        || (hints.truncated && hints.findings.len() != MAX_FINDINGS)
128        || hints.findings.iter().any(|f| f.line == 0 || f.line > lines)
129        || hints
130            .findings
131            .windows(2)
132            .any(|pair| (pair[0].line, &pair[0].rule_id) >= (pair[1].line, &pair[1].rule_id))
133    {
134        return Err(InstallError::Exchange);
135    }
136    Ok(hints)
137}
138
139#[cfg(any(unix, windows))]
140fn invoke(
141    config: &crate::core::config::IntelligenceRuntimeConfig,
142    language: &str,
143    source: &str,
144) -> Result<Hints> {
145    use lean_ctx_protocol::runtime_exchange::{RuntimeRequestV1, RuntimeResponseV1};
146    use serde_json::json;
147    use std::{
148        path::Path,
149        time::{SystemTime, UNIX_EPOCH},
150    };
151    let root = Path::new(&config.root);
152    let trust = super::trust_key(&config.trust_key_hex)?;
153    let input =
154        json!({"schema_version":1,"language":language,"source":source,"max_findings":MAX_FINDINGS})
155            .to_string();
156    let binding = super::sha256(input.as_bytes());
157    let input_ref = format!("projection:sha256:{binding}");
158    let (request, executed) = super::session::invoke_built(
159        root,
160        &config.manifest_sha256,
161        &trust,
162        |package| {
163            if !super::health::supports_code_security(package) {
164                return Err(InstallError::Manifest);
165            }
166            let deadline = SystemTime::now()
167                .duration_since(UNIX_EPOCH)
168                .ok()
169                .and_then(|now| u64::try_from(now.as_millis()).ok())
170                .and_then(|now| now.checked_add(2000))
171                .ok_or(InstallError::Exchange)?;
172            let request: RuntimeRequestV1 = serde_json::from_value(json!({
173        "protocol_version":lean_ctx_protocol::runtime_exchange::RUNTIME_EXCHANGE_VERSION,
174        "session_id":format!("code-{binding}"),"request_id":format!("code-{binding}"),
175        "sequence":1,"deadline_unix_ms":deadline,"input":input,
176        "invocation":{"schema_version":1,"invocation_id":format!("code-{binding}"),
177            "engine":{"engine_id":"leanctx-intelligence","engine_version":package.receipt.version},
178            "operation":{"capability_id":"pro.runtime.code_security","capability_version":"1.0.0"},
179            "input_ref":input_ref,"input_digest":format!("sha256:{binding}"),"source_refs":[input_ref],
180            "policy_admission":{"decision":"admitted","policy_ref":format!("policy:code-{binding}")}}
181        })).map_err(|_| InstallError::Exchange)?;
182            Ok(request)
183        },
184    )?;
185    let response: RuntimeResponseV1 =
186        serde_json::from_value(executed["response"].clone()).map_err(|_| InstallError::Exchange)?;
187    response
188        .validate_for(&request)
189        .map_err(|_| InstallError::Exchange)?;
190    if response.observation.status != lean_ctx_protocol::EngineObservationStatusV1::Succeeded
191        || crate::core::config::Config::load_global().intelligence_runtime != *config
192        || super::install::active_manifest(root)? != config.manifest_sha256
193    {
194        return Err(InstallError::Exchange);
195    }
196    validate(
197        response.output.as_deref().ok_or(InstallError::Exchange)?,
198        source,
199    )
200}
201
202#[cfg(not(any(unix, windows)))]
203fn invoke(_: &crate::core::config::IntelligenceRuntimeConfig, _: &str, _: &str) -> Result<Hints> {
204    Err(InstallError::Exchange)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    #[test]
211    fn private_findings_cannot_change_source_or_inject_text() {
212        let source = "first\nsecond\n";
213        let valid = serde_json::json!({"schema_version":1,"strategy":"bounded_static_v1",
214            "source_sha256":super::super::sha256(source.as_bytes()),"supported":true,"truncated":false,
215            "findings":[{"rule_id":"tls_verification_disabled","line":2}]});
216        let rendered = render(&validate(&valid.to_string(), source).unwrap());
217        assert!(rendered.contains("Inspected source line 2"));
218        for (field, replacement) in [
219            ("source_sha256", serde_json::json!("other")),
220            ("supported", serde_json::json!(false)),
221            ("truncated", serde_json::json!(true)),
222            (
223                "findings",
224                serde_json::json!([{"rule_id":"tls_verification_disabled","line":0}]),
225            ),
226            (
227                "findings",
228                serde_json::json!([{"rule_id":"tls_verification_disabled","line":3}]),
229            ),
230            (
231                "findings",
232                serde_json::json!([{"rule_id":"secret text","line":1}]),
233            ),
234            (
235                "findings",
236                serde_json::json!([{"rule_id":"tls_verification_disabled","line":1,"source":"secret"}]),
237            ),
238            (
239                "findings",
240                serde_json::json!([{"rule_id":"tls_verification_disabled","line":2},{"rule_id":"tls_verification_disabled","line":2}]),
241            ),
242        ] {
243            let mut invalid = valid.clone();
244            invalid[field] = replacement;
245            assert!(validate(&invalid.to_string(), source).is_err(), "{field}");
246        }
247    }
248}