Skip to main content

lean_ctx_server/
agent_tools_cmd.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::HashMap;
3use std::fs;
4use std::io::{self, BufRead, BufWriter, Write};
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use tokio::sync::RwLock;
11
12use crate::server::registry::{ToolRegistry, build_registry};
13use crate::server::tool_trait::{ShellOutcome, ToolContext, ToolOutput};
14use lean_ctx::core::cache::SessionCache;
15use lean_ctx::core::session::SessionState;
16
17mod release;
18mod source;
19
20use release::protect_output;
21
22const SCHEMA_VERSION: u32 = 1;
23const TRANSPORT_VERSION: u32 = 1;
24const INTERFACE_VERSION: &str = "1.0.0";
25const MAX_POLICY_BYTES: u64 = 64 * 1024;
26const MAX_REQUEST_BYTES: usize = 1024 * 1024;
27const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
28const MAX_ID_BYTES: usize = 128;
29
30const READ_TOOLS: &[&str] = &[
31    "ctx_compose",
32    "ctx_glob",
33    "ctx_read",
34    "ctx_search",
35    "ctx_symbol",
36    "ctx_tree",
37];
38const WRITE_TOOLS: &[&str] = &["ctx_edit", "ctx_fill", "ctx_patch"];
39const EXEC_TOOLS: &[&str] = &["ctx_shell"];
40
41#[derive(Debug, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct PolicyV1 {
44    schema_version: u32,
45    allow_write: bool,
46    allow_exec: bool,
47    allowed_executables: Vec<String>,
48    allowed_env: Vec<String>,
49    max_timeout_ms: u64,
50    #[serde(default)]
51    selected_gitlab: Option<source::GitLabSource>,
52}
53
54#[derive(Debug, Deserialize)]
55#[serde(deny_unknown_fields)]
56struct RequestV1 {
57    id: String,
58    op: String,
59    #[serde(default)]
60    schema_version: Option<u32>,
61    #[serde(default)]
62    transport_version: Option<u32>,
63    #[serde(default)]
64    agent_tools_interface_version: Option<String>,
65    #[serde(default)]
66    sdk_version: Option<String>,
67    #[serde(default)]
68    tool: Option<String>,
69    #[serde(default)]
70    arguments: Option<Map<String, Value>>,
71}
72
73#[derive(Debug, Serialize)]
74struct ErrorV1 {
75    code: &'static str,
76    message: String,
77}
78
79#[derive(Debug, Serialize)]
80struct ResponseV1<T: Serialize> {
81    id: String,
82    ok: bool,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    result: Option<T>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    error: Option<ErrorV1>,
87}
88
89#[derive(Debug, Serialize)]
90struct HelloResultV1 {
91    schema_version: u32,
92    transport_version: u32,
93    agent_tools_interface_version: &'static str,
94    engine_version: &'static str,
95    capabilities: Vec<&'static str>,
96    allow_write: bool,
97    allow_exec: bool,
98}
99
100#[derive(Debug, Serialize)]
101struct ToolResultV1 {
102    text: String,
103    content_blocks: Value,
104    original_tokens: usize,
105    output_tokens: usize,
106    saved_tokens: usize,
107    mode: Option<String>,
108    changed: bool,
109    shell: Option<Value>,
110}
111
112struct Session {
113    root: String,
114    policy: PolicyV1,
115    registry: ToolRegistry,
116    cache: Arc<RwLock<SessionCache>>,
117    state: Arc<RwLock<SessionState>>,
118    bm25_cache: lean_ctx::core::bm25_cache::SharedBm25Cache,
119    autonomy: lean_ctx::core::autonomy::AutonomyState,
120    accepted_calls: std::sync::atomic::AtomicU32,
121    source_authority: Option<String>,
122    /// The `call` whose response frame is not written yet: its admission
123    /// capture travels from the call to the frame writer (`serve`), which
124    /// seals the receipt of the frame's bytes before it writes them.
125    frame_call: Mutex<Option<release::FrameCall>>,
126}
127
128enum InputFrame {
129    Data(Vec<u8>),
130    TooLarge,
131}
132
133fn policy_denied() -> ErrorV1 {
134    ErrorV1 {
135        code: "permission_denied",
136        message: "Tool request withheld under the current policy".into(),
137    }
138}
139
140const OUTPUT_WITHHELD: &str =
141    "Tool may have completed; output withheld by policy. Do not retry automatically.";
142
143fn output_withheld() -> ErrorV1 {
144    ErrorV1 {
145        code: "tool_error",
146        message: OUTPUT_WITHHELD.into(),
147    }
148}
149
150/// The error frame of a `call` whose mandatory receipt could not be sealed
151/// (G08): the frame with the tool result is not written; this one is.
152fn receipt_missing_response(id: &str) -> ResponseV1<Value> {
153    ResponseV1 {
154        id: id.to_string(),
155        ok: false,
156        result: None,
157        error: Some(ErrorV1 {
158            code: "tool_error",
159            message: release::not_persisted_message(),
160        }),
161    }
162}
163
164impl Session {
165    fn capabilities(&self) -> Vec<&'static str> {
166        let mut result = READ_TOOLS.to_vec();
167        if self.policy.selected_gitlab.is_some() {
168            result.push("ctx_provider");
169        }
170        if self.policy.allow_write {
171            result.extend_from_slice(WRITE_TOOLS);
172        }
173        if self.policy.allow_exec {
174            result.extend_from_slice(EXEC_TOOLS);
175        }
176        result.sort_unstable();
177        result
178    }
179
180    fn permitted(&self, tool: &str) -> bool {
181        READ_TOOLS.contains(&tool)
182            || (self.policy.selected_gitlab.is_some() && tool == "ctx_provider")
183            || (self.policy.allow_write && WRITE_TOOLS.contains(&tool))
184            || (self.policy.allow_exec && EXEC_TOOLS.contains(&tool))
185    }
186
187    /// One tool call of the session. It runs inside ONE admission capture
188    /// that carries the call's policy snapshot, taken here at entry (as the
189    /// MCP dispatcher does): source admission in the handler, the release
190    /// point and the frame's receipt decide under it. The capture is left
191    /// for the frame writer.
192    fn call(&self, tool: &str, arguments: &Map<String, Value>) -> Result<ToolOutput, ErrorV1> {
193        lean_ctx::core::policy::runtime::REQUEST_PROJECT.sync_scope(
194            std::cell::RefCell::new(Some(PathBuf::from(&self.root))),
195            || {
196                let frame_call = release::FrameCall::open(tool);
197                let capture = frame_call.capture();
198                *self
199                    .frame_call
200                    .lock()
201                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(frame_call);
202                lean_ctx::core::context_admission::capture::ADMISSIONS
203                    .sync_scope(Some(capture), || self.call_released(tool, arguments))
204            },
205        )
206    }
207
208    fn call_released(
209        &self,
210        tool: &str,
211        arguments: &Map<String, Value>,
212    ) -> Result<ToolOutput, ErrorV1> {
213        lean_ctx::core::policy::runtime::REQUEST_PROJECT.sync_scope(
214            std::cell::RefCell::new(Some(PathBuf::from(&self.root))),
215            || {
216                let result = self.call_scoped(tool, arguments);
217                match result {
218                    Ok(output) => {
219                        let output = protect_output(tool, output)?;
220                        if output.mode.as_deref() != Some("error") {
221                            self.consolidate_memory();
222                        }
223                        // Maintenance can cross a policy refresh. Recheck before
224                        // returning the previously admitted application output.
225                        if let Some(authority) = &self.source_authority {
226                            source::verify_authority(Path::new(&self.root), authority)?;
227                        }
228                        let output = protect_output(tool, output)?;
229                        crate::tools::ctx_provider::ensure_snapshot_output(
230                            tool,
231                            Some(arguments),
232                            &output.text,
233                        )
234                        .map_err(|_| output_withheld())?;
235                        Ok(output)
236                    }
237                    // Handler/path diagnostics can contain source data. Keep the
238                    // stable error code, but never return unchecked diagnostics.
239                    Err(mut error) => {
240                        if lean_ctx::core::policy::runtime::is_active()
241                            && !(error.code == "tool_error" && error.message == OUTPUT_WITHHELD)
242                        {
243                            error.message = "Tool request withheld under the current policy".into();
244                        }
245                        Err(error)
246                    }
247                }
248            },
249        )
250    }
251
252    fn consolidate_memory(&self) {
253        use lean_ctx::core::{autonomy, consolidation_engine};
254        let calls = self
255            .accepted_calls
256            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
257            .wrapping_add(1);
258        if !autonomy::should_auto_consolidate(&self.autonomy, calls)
259            || crate::tools::LeanCtxServer::guard_role_and_policy(
260                "ctx_knowledge",
261                lean_ctx::core::agent_identity::current_agent_id(),
262            )
263            .is_some()
264        {
265            return;
266        }
267        // Reuse the canonical project session and locked knowledge store. The
268        // SDK's transient read cache must not replace a concurrent user's task.
269        // Stay in the calling authority scope; detached workers lose its policy
270        // and selected-source bindings. Private curation and persistence already
271        // enforce their lease, input budgets, deadlines and current policy.
272        if consolidation_engine::consolidate_latest(
273            &self.root,
274            consolidation_engine::ConsolidationBudgets::default(),
275        )
276        .is_err()
277        {
278            tracing::debug!("SDK memory maintenance skipped; existing memory retained");
279        }
280    }
281
282    fn call_scoped(
283        &self,
284        tool: &str,
285        arguments: &Map<String, Value>,
286    ) -> Result<ToolOutput, ErrorV1> {
287        if let Some(source) = &self.policy.selected_gitlab {
288            // Source selection is an enduring protected-session commitment:
289            // removing or invalidating its policy must not reopen local reads.
290            let authority = self.source_authority.as_deref().ok_or_else(policy_denied)?;
291            source::verify_authority(Path::new(&self.root), authority)?;
292            if tool == "ctx_provider" {
293                source.authorize_query(arguments)?;
294            }
295        }
296        if !self.permitted(tool) {
297            let code = if WRITE_TOOLS.contains(&tool) || EXEC_TOOLS.contains(&tool) {
298                "permission_denied"
299            } else {
300                "unsupported_capability"
301            };
302            return Err(ErrorV1 {
303                code,
304                message: format!("tool is not permitted: {tool}"),
305            });
306        }
307        // Agent Tools v1 (`sdk/contracts/agent-tools-v1.json`) negotiates
308        // `ctx_symbol` and `ctx_fill`, whose MCP tools were removed after
309        // 3.11.4. The contract stays: a symbol lookup runs as `ctx_search
310        // action=symbol`, and `ctx_fill` answers that it has no successor.
311        let symbol_arguments;
312        let (tool, arguments) = match tool {
313            "ctx_symbol" => {
314                let mut args = arguments.clone();
315                args.insert("action".into(), Value::String("symbol".into()));
316                symbol_arguments = args;
317                ("ctx_search", &symbol_arguments)
318            }
319            "ctx_fill" => {
320                return Err(ErrorV1 {
321                    code: "tool_error",
322                    message: "ctx_fill was removed; select context with ctx_compose and read it \
323                              with ctx_read"
324                        .into(),
325                });
326            }
327            _ => (tool, arguments),
328        };
329        if crate::tools::LeanCtxServer::guard_role_and_policy(
330            tool,
331            lean_ctx::core::agent_identity::current_agent_id(),
332        )
333        .is_some()
334        {
335            return Err(policy_denied());
336        }
337        if tool == "ctx_shell" {
338            return self.call_shell(arguments).map(redact_tool_output);
339        }
340        if crate::tools::LeanCtxServer::guard_egress(
341            tool,
342            Some(arguments),
343            lean_ctx::core::agent_identity::current_agent_id(),
344        )
345        .is_some()
346        {
347            return Err(policy_denied());
348        }
349        let handler = self.registry.get(tool).ok_or_else(|| ErrorV1 {
350            code: "unsupported_capability",
351            message: format!("tool is unavailable: {tool}"),
352        })?;
353        let mut resolved_paths = HashMap::new();
354        if let Some(path) = arguments.get("path").and_then(Value::as_str) {
355            let resolved =
356                lean_ctx::core::path_resolve::resolve_tool_path(Some(&self.root), None, path)
357                    .map_err(|message| ErrorV1 {
358                        code: "path_rejected",
359                        message,
360                    })?;
361            resolved_paths.insert(
362                "path".to_string(),
363                if resolved.is_empty() || resolved == "." {
364                    self.root.clone()
365                } else {
366                    resolved
367                },
368            );
369        }
370
371        let context = ToolContext {
372            project_root: self.root.clone(),
373            resolved_paths,
374            cache: Some(self.cache.clone()),
375            bm25_cache: Some(self.bm25_cache.clone()),
376            session: Some(self.state.clone()),
377            ..Default::default()
378        };
379        handler
380            .handle(arguments, &context)
381            .map_err(|error| ErrorV1 {
382                code: "tool_error",
383                message: error.to_string(),
384            })
385            .map(redact_tool_output)
386    }
387
388    fn call_shell(&self, arguments: &Map<String, Value>) -> Result<ToolOutput, ErrorV1> {
389        let prepared = self.prepare_shell(arguments)?;
390        if crate::tools::LeanCtxServer::guard_egress(
391            "ctx_shell",
392            Some(&prepared),
393            lean_ctx::core::agent_identity::current_agent_id(),
394        )
395        .is_some()
396        {
397            return Err(policy_denied());
398        }
399        let argv = arguments["argv"]
400            .as_array()
401            .expect("validated argv")
402            .iter()
403            .map(|value| value.as_str().expect("validated argv item"))
404            .collect::<Vec<_>>();
405        let cwd = arguments["cwd"].as_str().expect("validated cwd");
406        let resolved_cwd =
407            lean_ctx::core::path_resolve::resolve_tool_path(Some(&self.root), None, cwd).map_err(
408                |message| ErrorV1 {
409                    code: "path_rejected",
410                    message,
411                },
412            )?;
413        let resolved_cwd = if resolved_cwd.is_empty() || resolved_cwd == "." {
414            self.root.as_str()
415        } else {
416            resolved_cwd.as_str()
417        };
418        let environment = arguments["env"].as_object().expect("validated env");
419        let timeout_ms = prepared["timeout_ms"].as_u64().expect("validated timeout");
420        let display = prepared["command"].as_str().expect("prepared command");
421        let executable = resolve_allowed_executable(argv[0], &self.root)?;
422        let mut command = std::process::Command::new(executable);
423        command
424            .args(&argv[1..])
425            .current_dir(resolved_cwd)
426            .stdin(std::process::Stdio::null())
427            .stdout(std::process::Stdio::piped())
428            .stderr(std::process::Stdio::piped())
429            .env_clear();
430        for name in ["PATH", "SYSTEMROOT", "TEMP", "TMP"] {
431            if let Some(value) = std::env::var_os(name) {
432                command.env(name, value);
433            }
434        }
435        command.env("LANG", "C").env("LC_ALL", "C").env("TZ", "UTC");
436        command.envs(
437            environment
438                .iter()
439                .map(|(name, value)| (name, value.as_str().expect("validated env value"))),
440        );
441        // Reauthorize immediately before the side effect, after path resolution.
442        if crate::tools::LeanCtxServer::guard_role_and_policy(
443            "ctx_shell",
444            lean_ctx::core::agent_identity::current_agent_id(),
445        )
446        .is_some()
447        {
448            return Err(policy_denied());
449        }
450        // Q13 (Unix): the call runs in a process group of its own, so a
451        // timeout or output limit ends this call's tree and nothing else
452        // (`wait_with_limits_captured` signals a group only while its leader
453        // is the unreaped child). A guard process is attached to that group:
454        // it ends the group when this Engine lets go of it — after the call,
455        // below — or when the Engine is gone, whatever ended it. A host that
456        // ends the Engine therefore still ends running tool processes.
457        // Windows has no group: the call runs in a Job Object of its own
458        // (below).
459        //
460        // No call runs without its guard, at any moment: the call's program
461        // is executed only once the guard is ready, so an Engine that dies in
462        // between leaves nothing running. If no guard can be attached the
463        // program was never executed and the call is refused.
464        let not_executed = |_: std::io::Error| ErrorV1 {
465            code: "tool_error",
466            message: "shell process could not be executed".to_string(),
467        };
468        #[cfg(unix)]
469        let (child, guard) = {
470            use lean_ctx::shell::containment::Refusal;
471            use std::os::unix::process::CommandExt as _;
472            command.process_group(0);
473            match lean_ctx::shell::containment::start_behind_guard(&mut command) {
474                Ok((child, guard)) => (child, Some(guard)),
475                Err(Refusal::NotStarted(error)) => return Err(not_executed(error)),
476                Err(refusal) => {
477                    let reason = match refusal {
478                        Refusal::NoGuard(reason) => reason,
479                        Refusal::NotLeader | Refusal::NotStarted(_) => {
480                            "the process does not lead a group of its own".to_string()
481                        }
482                    };
483                    tracing::warn!("ctx_shell refused, no process guard: {reason}");
484                    return Err(ErrorV1 {
485                        code: "tool_error",
486                        message: "shell process could not be contained".to_string(),
487                    });
488                }
489            }
490        };
491        // Windows (B7): the call runs in a kill-on-close job of its own, held
492        // for the call (`ProcessTree::spawn_contained`).
493        #[cfg(not(unix))]
494        let (child, call_tree) =
495            lean_ctx::shell::process_tree::ProcessTree::spawn_contained(&mut command)
496                .map_err(not_executed)?;
497        let captured = lean_ctx::shell::exec::wait_with_limits_captured(
498            child,
499            MAX_RESPONSE_BYTES / 2,
500            std::time::Duration::from_millis(timeout_ms),
501            true,
502        );
503        // The call is over: whatever it left running in its group is ended by
504        // the guard, which is then reaped within its own budget.
505        // An end that is not confirmed does not fail the call, but the caller
506        // sees it: a fixed, content-free notice in front of the result.
507        #[cfg(unix)]
508        let containment_notice = guard.and_then(|guard| {
509            let ended = guard.finish();
510            if ended != lean_ctx::shell::containment::GuardOutcome::Ended {
511                tracing::warn!("ctx_shell: the call's process group end is {ended:?}");
512            }
513            lean_ctx::shell::containment::end_notice(ended)
514        });
515        #[cfg(not(unix))]
516        let containment_notice: Option<&'static str> = {
517            drop(call_tree); // closing the call's job ends what it left running
518            None
519        };
520        let completeness = captured.completeness;
521        let output = captured.output;
522        let exit_code = lean_ctx::shell::exit_status::exit_code(output.status);
523        if lean_ctx::core::policy::runtime::is_active()
524            && (std::str::from_utf8(&output.stdout).is_err()
525                || std::str::from_utf8(&output.stderr).is_err())
526        {
527            return Err(output_withheld());
528        }
529        let mut raw_output = String::from_utf8_lossy(&output.stdout).into_owned();
530        raw_output.push_str(&String::from_utf8_lossy(&output.stderr));
531        let original_tokens = lean_ctx::core::tokens::count_tokens(&raw_output);
532        // Admission of the ORIGINAL output, before anything shortens it, and
533        // with what the capture knows about its completeness: a truncated,
534        // timed-out or abandoned capture is decided by the gateway's mode
535        // instead of being compressed as if it were whole.
536        let admission =
537            lean_ctx::core::context_admission::admit_tool_output(&raw_output, completeness);
538        let Some(admitted) = admission.text.as_deref() else {
539            return Err(output_withheld());
540        };
541        let raw_output = crate::server::policy_guard::protect_result("ctx_shell", admitted)
542            .map_err(|_| output_withheld())?;
543        let raw_output = lean_ctx::core::redaction::redact_text_if_enabled(&raw_output);
544        let text = crate::tools::ctx_shell::handle(
545            display,
546            &raw_output,
547            exit_code,
548            lean_ctx::core::protocol::CrpMode::Compact,
549        );
550        // The gateway's content-free notice stays in front of the compressed
551        // text; compression never sees or drops it.
552        let text = match &admission.notice {
553            Some(notice) => format!("{notice}\n{text}"),
554            None => text,
555        };
556        // Q13: same place, same rule, for an unconfirmed end of the call's
557        // process group. Fixed text; nothing of the run is in it.
558        let text = match containment_notice {
559            Some(notice) => format!("{notice}\n{text}"),
560            None => text,
561        };
562        let output_tokens = lean_ctx::core::tokens::count_tokens(&text);
563        Ok(ToolOutput {
564            text,
565            original_tokens,
566            saved_tokens: original_tokens.saturating_sub(output_tokens),
567            mode: Some("shell".to_string()),
568            path: None,
569            changed: false,
570            shell_outcome: Some(ShellOutcome::Exit(exit_code)),
571            content_blocks: None,
572        })
573    }
574
575    fn prepare_shell(&self, arguments: &Map<String, Value>) -> Result<Map<String, Value>, ErrorV1> {
576        const KEYS: &[&str] = &["argv", "cwd", "env", "timeout_ms"];
577        if arguments.keys().any(|key| !KEYS.contains(&key.as_str())) {
578            return Err(invalid_request("shell arguments are invalid"));
579        }
580        let argv = arguments
581            .get("argv")
582            .and_then(Value::as_array)
583            .ok_or_else(|| invalid_request("argv is required"))?;
584        if argv.is_empty() || argv.len() > 256 {
585            return Err(invalid_request("argv length is invalid"));
586        }
587        let argv: Result<Vec<&str>, ErrorV1> = argv
588            .iter()
589            .map(|value| {
590                value
591                    .as_str()
592                    .filter(|item| !item.is_empty() && !item.contains('\0'))
593                    .ok_or_else(|| invalid_request("argv item is invalid"))
594            })
595            .collect();
596        let argv = argv?;
597        if argv[0].contains('/') || argv[0].contains('\\') {
598            return Err(ErrorV1 {
599                code: "permission_denied",
600                message: "executable must be a bare allowlisted name".to_string(),
601            });
602        }
603        let executable = argv[0];
604        if !self
605            .policy
606            .allowed_executables
607            .iter()
608            .any(|allowed| allowed == executable)
609        {
610            return Err(ErrorV1 {
611                code: "permission_denied",
612                message: format!("executable is not allowed: {executable}"),
613            });
614        }
615
616        let timeout_ms = arguments
617            .get("timeout_ms")
618            .and_then(Value::as_u64)
619            .unwrap_or(self.policy.max_timeout_ms);
620        if !(100..=self.policy.max_timeout_ms).contains(&timeout_ms) {
621            return Err(ErrorV1 {
622                code: "permission_denied",
623                message: "shell timeout exceeds policy".to_string(),
624            });
625        }
626        let environment = arguments
627            .get("env")
628            .and_then(Value::as_object)
629            .ok_or_else(|| invalid_request("env must be an object"))?;
630        for (name, value) in environment {
631            if !value.is_string() || !self.policy.allowed_env.contains(name) {
632                return Err(ErrorV1 {
633                    code: "permission_denied",
634                    message: format!("environment variable is not allowed: {name}"),
635                });
636            }
637        }
638        if arguments.get("cwd").and_then(Value::as_str).is_none() {
639            return Err(invalid_request("cwd must be a string"));
640        }
641
642        let mut prepared = arguments.clone();
643        prepared.remove("argv");
644        prepared.insert(
645            "command".to_string(),
646            Value::String(lean_ctx::shell::platform::join_command(
647                &argv
648                    .iter()
649                    .map(|item| (*item).to_string())
650                    .collect::<Vec<_>>(),
651            )),
652        );
653        prepared.insert("timeout_ms".to_string(), Value::from(timeout_ms));
654        Ok(prepared)
655    }
656}
657
658fn redact_tool_output(mut output: ToolOutput) -> ToolOutput {
659    output.text = crate::server::policy_guard::redact_model_text(&output.text);
660    output
661}
662
663fn invalid_request(message: &str) -> ErrorV1 {
664    ErrorV1 {
665        code: "invalid_request",
666        message: message.to_string(),
667    }
668}
669
670fn resolve_allowed_executable(name: &str, root: &str) -> Result<PathBuf, ErrorV1> {
671    let path = std::env::var_os("PATH").ok_or_else(|| ErrorV1 {
672        code: "permission_denied",
673        message: "trusted executable search path is unavailable".to_string(),
674    })?;
675    for directory in std::env::split_paths(&path).filter(|path| path.is_absolute()) {
676        if directory.starts_with(root) {
677            continue;
678        }
679        #[cfg(windows)]
680        let candidates = if Path::new(name).extension().is_some() {
681            vec![directory.join(name)]
682        } else {
683            vec![
684                directory.join(format!("{name}.exe")),
685                directory.join(format!("{name}.com")),
686            ]
687        };
688        #[cfg(not(windows))]
689        let candidates = vec![directory.join(name)];
690        for candidate in candidates {
691            let Ok(canonical) = candidate.canonicalize() else {
692                continue;
693            };
694            if candidate.starts_with(root) || !canonical.is_file() || canonical.starts_with(root) {
695                continue;
696            }
697            #[cfg(unix)]
698            {
699                use std::os::unix::fs::PermissionsExt;
700                if canonical
701                    .metadata()
702                    .map_or(true, |metadata| metadata.permissions().mode() & 0o111 == 0)
703                {
704                    continue;
705                }
706            }
707            return Ok(canonical);
708        }
709    }
710    Err(ErrorV1 {
711        code: "permission_denied",
712        message: format!("allowed executable was not found on trusted PATH: {name}"),
713    })
714}
715
716pub fn cmd_agent_tools(args: &[String]) {
717    let Ok(runtime) = tokio::runtime::Runtime::new() else {
718        eprintln!("engine: runtime_unavailable");
719        std::process::exit(2);
720    };
721    let result = runtime.block_on(async {
722        tokio::task::block_in_place(|| run(args, io::stdin().lock(), io::stdout().lock()))
723    });
724    if let Err(code) = result {
725        eprintln!("engine: {code}");
726        std::process::exit(2);
727    }
728}
729
730fn run<R: BufRead, W: Write>(args: &[String], reader: R, writer: W) -> Result<(), &'static str> {
731    let (root, policy_path) = parse_args(args)?;
732    let root = canonical_root(&root)?;
733    let policy = read_policy(&policy_path)?;
734    let source_authority = policy
735        .selected_gitlab
736        .as_ref()
737        .map(|source| source.initialize(Path::new(&root)))
738        .transpose()?;
739    let mut state = SessionState::new();
740    state.project_root = Some(root.clone());
741    state.shell_cwd = Some(root.clone());
742    let session = Session {
743        root,
744        policy,
745        registry: build_registry(),
746        cache: Arc::new(RwLock::new(SessionCache::new())),
747        state: Arc::new(RwLock::new(state)),
748        bm25_cache: Arc::new(Mutex::new(None)),
749        autonomy: lean_ctx::core::autonomy::AutonomyState::new(),
750        accepted_calls: std::sync::atomic::AtomicU32::new(0),
751        source_authority,
752        frame_call: Mutex::new(None),
753    };
754    serve(&session, reader, writer)
755}
756
757fn serve<R: BufRead, W: Write>(
758    session: &Session,
759    mut reader: R,
760    writer: W,
761) -> Result<(), &'static str> {
762    let mut writer = BufWriter::new(writer);
763    let mut hello_complete = false;
764    while let Some(frame) = read_frame(&mut reader).map_err(|_| "request_read_failed")? {
765        let response = match frame {
766            InputFrame::TooLarge => {
767                error_response("", "request_too_large", "request exceeds size bound")
768            }
769            InputFrame::Data(line) => match serde_json::from_slice::<RequestV1>(&line) {
770                Ok(request) => handle_request(session, request, &mut hello_complete),
771                Err(_) => error_response("", "invalid_request", "request is not strict JSON v1"),
772            },
773        };
774        let should_close = !response.id.is_empty()
775            && response
776                .result
777                .as_ref()
778                .is_some_and(|value| value.get("closed").and_then(Value::as_bool) == Some(true));
779        let mut encoded = serde_json::to_vec(&response).map_err(|_| "response_write_failed")?;
780        if encoded.len().saturating_add(1) > MAX_RESPONSE_BYTES {
781            encoded = serde_json::to_vec(&error_response(
782                &response.id,
783                "response_too_large",
784                "response exceeds size bound",
785            ))
786            .map_err(|_| "response_write_failed")?;
787        }
788        // The frame as it leaves: one line.
789        encoded.push(b'\n');
790        // G08: a `call` frame is a delivery to the host. Its receipt binds
791        // exactly these bytes and is sealed BEFORE they are written; where
792        // it is mandatory and missing, the frame is replaced by an error
793        // frame and the tool result does not leave.
794        let frame_call = session
795            .frame_call
796            .lock()
797            .unwrap_or_else(std::sync::PoisonError::into_inner)
798            .take();
799        let prepared = match frame_call {
800            Some(call) => {
801                let sealed = lean_ctx::core::policy::runtime::REQUEST_PROJECT.sync_scope(
802                    std::cell::RefCell::new(Some(PathBuf::from(&session.root))),
803                    || call.seal(&session.root, &encoded, !response.ok),
804                );
805                if let Ok(prepared) = sealed {
806                    prepared
807                } else {
808                    encoded = serde_json::to_vec(&receipt_missing_response(&response.id))
809                        .map_err(|_| "response_write_failed")?;
810                    encoded.push(b'\n');
811                    None
812                }
813            }
814            None => None,
815        };
816        writer
817            .write_all(&encoded)
818            .map_err(|_| "response_write_failed")?;
819        writer.flush().map_err(|_| "response_write_failed")?;
820        // OBSERVED: the frame was written and flushed to the session
821        // transport. That the host read it is not observable.
822        if let Some(prepared) = &prepared {
823            release::returned(prepared, &encoded);
824        }
825        if should_close {
826            break;
827        }
828    }
829    Ok(())
830}
831
832fn read_frame<R: BufRead>(reader: &mut R) -> io::Result<Option<InputFrame>> {
833    let mut data = Vec::new();
834    let mut too_large = false;
835    loop {
836        let buffer = reader.fill_buf()?;
837        if buffer.is_empty() {
838            return if data.is_empty() && !too_large {
839                Ok(None)
840            } else if too_large {
841                Ok(Some(InputFrame::TooLarge))
842            } else {
843                Ok(Some(InputFrame::Data(data)))
844            };
845        }
846
847        let newline = buffer.iter().position(|byte| *byte == b'\n');
848        let consumed = newline.map_or(buffer.len(), |index| index + 1);
849        let payload = newline.map_or(buffer, |index| &buffer[..index]);
850        if !too_large {
851            if data.len().saturating_add(payload.len()) > MAX_REQUEST_BYTES {
852                data.clear();
853                too_large = true;
854            } else {
855                data.extend_from_slice(payload);
856            }
857        }
858        reader.consume(consumed);
859        if newline.is_some() {
860            return Ok(Some(if too_large {
861                InputFrame::TooLarge
862            } else {
863                InputFrame::Data(data)
864            }));
865        }
866    }
867}
868
869fn handle_request(session: &Session, request: RequestV1, hello: &mut bool) -> ResponseV1<Value> {
870    if validate_id(&request.id).is_err() {
871        return error_response("", "invalid_request", "id is invalid");
872    }
873    match request.op.as_str() {
874        "hello" => {
875            if *hello {
876                return error_response(&request.id, "invalid_state", "hello already completed");
877            }
878            if request.schema_version != Some(SCHEMA_VERSION)
879                || request.transport_version != Some(TRANSPORT_VERSION)
880                || request.agent_tools_interface_version.as_deref() != Some(INTERFACE_VERSION)
881                || request.sdk_version.as_deref().is_none_or(str::is_empty)
882                || request.tool.is_some()
883                || request.arguments.is_some()
884            {
885                return error_response(
886                    &request.id,
887                    "unsupported_interface",
888                    "hello version or shape is unsupported",
889                );
890            }
891            *hello = true;
892            let result = HelloResultV1 {
893                schema_version: SCHEMA_VERSION,
894                transport_version: TRANSPORT_VERSION,
895                agent_tools_interface_version: INTERFACE_VERSION,
896                engine_version: env!("CARGO_PKG_VERSION"),
897                capabilities: session.capabilities(),
898                allow_write: session.policy.allow_write,
899                allow_exec: session.policy.allow_exec,
900            };
901            success_response(
902                &request.id,
903                serde_json::to_value(result).expect("serializable"),
904            )
905        }
906        "call" => {
907            if !*hello {
908                return error_response(&request.id, "invalid_state", "hello is required first");
909            }
910            if request.schema_version.is_some()
911                || request.transport_version.is_some()
912                || request.agent_tools_interface_version.is_some()
913                || request.sdk_version.is_some()
914            {
915                return error_response(&request.id, "invalid_request", "call shape is invalid");
916            }
917            let Some(tool) = request.tool else {
918                return error_response(&request.id, "invalid_request", "tool is required");
919            };
920            let arguments = request.arguments.unwrap_or_default();
921            // `changed` of v1: a write tool applied its change (not a dry run).
922            let applied = WRITE_TOOLS.contains(&tool.as_str())
923                && arguments.get("dry_run").and_then(Value::as_bool) != Some(true);
924            match session.call(&tool, &arguments) {
925                Ok(output) => {
926                    let result = ToolResultV1 {
927                        // v1 clients require original = output + saved. These
928                        // legacy accounting counters are not measured egress.
929                        output_tokens: output.original_tokens.saturating_sub(output.saved_tokens),
930                        original_tokens: output.original_tokens,
931                        saved_tokens: output.saved_tokens,
932                        mode: output.mode,
933                        changed: output.changed || applied,
934                        shell: agent_shell_outcome(output.shell_outcome.as_ref()),
935                        content_blocks: serde_json::to_value(
936                            output.content_blocks.unwrap_or_default(),
937                        )
938                        .expect("serializable content blocks"),
939                        text: output.text,
940                    };
941                    success_response(
942                        &request.id,
943                        serde_json::to_value(result).expect("serializable"),
944                    )
945                }
946                Err(error) => ResponseV1 {
947                    id: request.id,
948                    ok: false,
949                    result: None,
950                    error: Some(error),
951                },
952            }
953        }
954        "close" => {
955            if !*hello
956                || request.schema_version.is_some()
957                || request.transport_version.is_some()
958                || request.agent_tools_interface_version.is_some()
959                || request.sdk_version.is_some()
960                || request.tool.is_some()
961                || request.arguments.is_some()
962            {
963                return error_response(&request.id, "invalid_request", "close shape is invalid");
964            }
965            success_response(&request.id, serde_json::json!({ "closed": true }))
966        }
967        _ => error_response(&request.id, "invalid_request", "unknown operation"),
968    }
969}
970
971fn success_response(id: &str, result: Value) -> ResponseV1<Value> {
972    ResponseV1 {
973        id: id.to_string(),
974        ok: true,
975        result: Some(result),
976        error: None,
977    }
978}
979
980fn agent_shell_outcome(outcome: Option<&ShellOutcome>) -> Option<Value> {
981    match outcome {
982        Some(ShellOutcome::Exit(code)) => Some(serde_json::json!({ "exitCode": code })),
983        Some(other) => other.structured(),
984        None => None,
985    }
986}
987
988fn error_response(id: &str, code: &'static str, message: &str) -> ResponseV1<Value> {
989    ResponseV1 {
990        id: id.to_string(),
991        ok: false,
992        result: None,
993        error: Some(ErrorV1 {
994            code,
995            message: message.to_string(),
996        }),
997    }
998}
999
1000fn validate_id(id: &str) -> Result<(), ()> {
1001    let bytes = id.as_bytes();
1002    if bytes.is_empty() || bytes.len() > MAX_ID_BYTES || id.chars().any(char::is_control) {
1003        Err(())
1004    } else {
1005        Ok(())
1006    }
1007}
1008
1009fn parse_args(args: &[String]) -> Result<(PathBuf, PathBuf), &'static str> {
1010    let mut root = None;
1011    let mut policy = None;
1012    let mut index = 0;
1013    while index < args.len() {
1014        let flag = args[index].as_str();
1015        index += 1;
1016        let value = args.get(index).ok_or("invalid_request")?;
1017        match flag {
1018            "--project-root" if root.is_none() => root = Some(PathBuf::from(value)),
1019            "--policy-file" if policy.is_none() => policy = Some(PathBuf::from(value)),
1020            _ => return Err("invalid_request"),
1021        }
1022        index += 1;
1023    }
1024    Ok((
1025        root.ok_or("invalid_request")?,
1026        policy.ok_or("invalid_request")?,
1027    ))
1028}
1029
1030fn canonical_root(root: &Path) -> Result<String, &'static str> {
1031    if lean_ctx::core::pathutil::is_broad_or_unsafe_root(root) {
1032        return Err("unsafe_root");
1033    }
1034    let root = fs::canonicalize(root).map_err(|_| "unsafe_root")?;
1035    if !root.is_dir() || lean_ctx::core::pathutil::is_broad_or_unsafe_root(&root) {
1036        return Err("unsafe_root");
1037    }
1038    Ok(root.to_string_lossy().into_owned())
1039}
1040
1041fn read_policy(path: &Path) -> Result<PolicyV1, &'static str> {
1042    let metadata = fs::symlink_metadata(path).map_err(|_| "policy_unavailable")?;
1043    if !metadata.file_type().is_file() || metadata.len() > MAX_POLICY_BYTES {
1044        return Err("policy_unavailable");
1045    }
1046    #[cfg(unix)]
1047    {
1048        use std::os::unix::fs::PermissionsExt;
1049        if metadata.permissions().mode() & 0o077 != 0 {
1050            return Err("policy_permissions");
1051        }
1052    }
1053    let bytes = fs::read(path).map_err(|_| "policy_unavailable")?;
1054    let policy: PolicyV1 = serde_json::from_slice(&bytes).map_err(|_| "invalid_policy")?;
1055    let executable_policy_valid = (!policy.allow_exec && policy.allowed_executables.is_empty())
1056        || (policy.allow_exec
1057            && !policy.allowed_executables.is_empty()
1058            && is_canonical_names(&policy.allowed_executables, is_executable_name));
1059    if policy.schema_version != SCHEMA_VERSION
1060        || !executable_policy_valid
1061        || !is_canonical_names(&policy.allowed_env, is_env_name)
1062        || !(100..=120_000).contains(&policy.max_timeout_ms)
1063    {
1064        return Err("invalid_policy");
1065    }
1066    Ok(policy)
1067}
1068
1069fn is_canonical_names(values: &[String], valid: fn(&str) -> bool) -> bool {
1070    values.windows(2).all(|pair| pair[0] < pair[1]) && values.iter().all(|value| valid(value))
1071}
1072
1073fn is_executable_name(value: &str) -> bool {
1074    !value.is_empty()
1075        && Path::new(value).file_name().and_then(|name| name.to_str()) == Some(value)
1076        && value
1077            .chars()
1078            .all(|character| character.is_alphanumeric() || "._+-".contains(character))
1079}
1080
1081fn is_env_name(value: &str) -> bool {
1082    let mut characters = value.chars();
1083    !matches!(
1084        value.to_ascii_uppercase().as_str(),
1085        "COMSPEC"
1086            | "DYLD_INSERT_LIBRARIES"
1087            | "DYLD_LIBRARY_PATH"
1088            | "HOME"
1089            | "LD_LIBRARY_PATH"
1090            | "LD_PRELOAD"
1091            | "PATH"
1092            | "PATHEXT"
1093            | "PYTHONPATH"
1094            | "RUSTC_WRAPPER"
1095            | "SHELL"
1096    ) && characters
1097        .next()
1098        .is_some_and(|first| first.is_alphabetic() || first == '_')
1099        && characters.all(|character| character.is_alphanumeric() || character == '_')
1100}
1101
1102#[cfg(test)]
1103#[path = "agent_tools_cmd_tests.rs"]
1104mod tests;
1105
1106#[cfg(all(test, unix))]
1107mod shell_gate_tests;