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 = lean_ctx::core::context_admission::admit_tool_output_as(
537            &raw_output,
538            completeness,
539            &lean_ctx::core::patterns::output_class(display),
540        );
541        let Some(admitted) = admission.text.as_deref() else {
542            return Err(output_withheld());
543        };
544        let raw_output = crate::server::policy_guard::protect_result("ctx_shell", admitted)
545            .map_err(|_| output_withheld())?;
546        let raw_output = lean_ctx::core::redaction::redact_text_if_enabled(&raw_output);
547        let text = crate::tools::ctx_shell::handle(
548            display,
549            &raw_output,
550            exit_code,
551            lean_ctx::core::protocol::CrpMode::Compact,
552        );
553        // The gateway's content-free notice stays in front of the compressed
554        // text; compression never sees or drops it.
555        let text = match &admission.notice {
556            Some(notice) => format!("{notice}\n{text}"),
557            None => text,
558        };
559        // Q13: same place, same rule, for an unconfirmed end of the call's
560        // process group. Fixed text; nothing of the run is in it.
561        let text = match containment_notice {
562            Some(notice) => format!("{notice}\n{text}"),
563            None => text,
564        };
565        let output_tokens = lean_ctx::core::tokens::count_tokens(&text);
566        Ok(ToolOutput {
567            text,
568            original_tokens,
569            saved_tokens: original_tokens.saturating_sub(output_tokens),
570            mode: Some("shell".to_string()),
571            path: None,
572            changed: false,
573            shell_outcome: Some(ShellOutcome::Exit(exit_code)),
574            content_blocks: None,
575        })
576    }
577
578    fn prepare_shell(&self, arguments: &Map<String, Value>) -> Result<Map<String, Value>, ErrorV1> {
579        const KEYS: &[&str] = &["argv", "cwd", "env", "timeout_ms"];
580        if arguments.keys().any(|key| !KEYS.contains(&key.as_str())) {
581            return Err(invalid_request("shell arguments are invalid"));
582        }
583        let argv = arguments
584            .get("argv")
585            .and_then(Value::as_array)
586            .ok_or_else(|| invalid_request("argv is required"))?;
587        if argv.is_empty() || argv.len() > 256 {
588            return Err(invalid_request("argv length is invalid"));
589        }
590        let argv: Result<Vec<&str>, ErrorV1> = argv
591            .iter()
592            .map(|value| {
593                value
594                    .as_str()
595                    .filter(|item| !item.is_empty() && !item.contains('\0'))
596                    .ok_or_else(|| invalid_request("argv item is invalid"))
597            })
598            .collect();
599        let argv = argv?;
600        if argv[0].contains('/') || argv[0].contains('\\') {
601            return Err(ErrorV1 {
602                code: "permission_denied",
603                message: "executable must be a bare allowlisted name".to_string(),
604            });
605        }
606        let executable = argv[0];
607        if !self
608            .policy
609            .allowed_executables
610            .iter()
611            .any(|allowed| allowed == executable)
612        {
613            return Err(ErrorV1 {
614                code: "permission_denied",
615                message: format!("executable is not allowed: {executable}"),
616            });
617        }
618
619        let timeout_ms = arguments
620            .get("timeout_ms")
621            .and_then(Value::as_u64)
622            .unwrap_or(self.policy.max_timeout_ms);
623        if !(100..=self.policy.max_timeout_ms).contains(&timeout_ms) {
624            return Err(ErrorV1 {
625                code: "permission_denied",
626                message: "shell timeout exceeds policy".to_string(),
627            });
628        }
629        let environment = arguments
630            .get("env")
631            .and_then(Value::as_object)
632            .ok_or_else(|| invalid_request("env must be an object"))?;
633        for (name, value) in environment {
634            if !value.is_string() || !self.policy.allowed_env.contains(name) {
635                return Err(ErrorV1 {
636                    code: "permission_denied",
637                    message: format!("environment variable is not allowed: {name}"),
638                });
639            }
640        }
641        if arguments.get("cwd").and_then(Value::as_str).is_none() {
642            return Err(invalid_request("cwd must be a string"));
643        }
644
645        let mut prepared = arguments.clone();
646        prepared.remove("argv");
647        prepared.insert(
648            "command".to_string(),
649            Value::String(lean_ctx::shell::platform::join_command(
650                &argv
651                    .iter()
652                    .map(|item| (*item).to_string())
653                    .collect::<Vec<_>>(),
654            )),
655        );
656        prepared.insert("timeout_ms".to_string(), Value::from(timeout_ms));
657        Ok(prepared)
658    }
659}
660
661fn redact_tool_output(mut output: ToolOutput) -> ToolOutput {
662    output.text = crate::server::policy_guard::redact_model_text(&output.text);
663    output
664}
665
666fn invalid_request(message: &str) -> ErrorV1 {
667    ErrorV1 {
668        code: "invalid_request",
669        message: message.to_string(),
670    }
671}
672
673fn resolve_allowed_executable(name: &str, root: &str) -> Result<PathBuf, ErrorV1> {
674    let path = std::env::var_os("PATH").ok_or_else(|| ErrorV1 {
675        code: "permission_denied",
676        message: "trusted executable search path is unavailable".to_string(),
677    })?;
678    for directory in std::env::split_paths(&path).filter(|path| path.is_absolute()) {
679        if directory.starts_with(root) {
680            continue;
681        }
682        #[cfg(windows)]
683        let candidates = if Path::new(name).extension().is_some() {
684            vec![directory.join(name)]
685        } else {
686            vec![
687                directory.join(format!("{name}.exe")),
688                directory.join(format!("{name}.com")),
689            ]
690        };
691        #[cfg(not(windows))]
692        let candidates = vec![directory.join(name)];
693        for candidate in candidates {
694            let Ok(canonical) = candidate.canonicalize() else {
695                continue;
696            };
697            if candidate.starts_with(root) || !canonical.is_file() || canonical.starts_with(root) {
698                continue;
699            }
700            #[cfg(unix)]
701            {
702                use std::os::unix::fs::PermissionsExt;
703                if canonical
704                    .metadata()
705                    .map_or(true, |metadata| metadata.permissions().mode() & 0o111 == 0)
706                {
707                    continue;
708                }
709            }
710            return Ok(canonical);
711        }
712    }
713    Err(ErrorV1 {
714        code: "permission_denied",
715        message: format!("allowed executable was not found on trusted PATH: {name}"),
716    })
717}
718
719pub fn cmd_agent_tools(args: &[String]) {
720    let Ok(runtime) = tokio::runtime::Runtime::new() else {
721        eprintln!("engine: runtime_unavailable");
722        std::process::exit(2);
723    };
724    let result = runtime.block_on(async {
725        tokio::task::block_in_place(|| run(args, io::stdin().lock(), io::stdout().lock()))
726    });
727    if let Err(code) = result {
728        eprintln!("engine: {code}");
729        std::process::exit(2);
730    }
731}
732
733fn run<R: BufRead, W: Write>(args: &[String], reader: R, writer: W) -> Result<(), &'static str> {
734    let (root, policy_path) = parse_args(args)?;
735    let root = canonical_root(&root)?;
736    let policy = read_policy(&policy_path)?;
737    let source_authority = policy
738        .selected_gitlab
739        .as_ref()
740        .map(|source| source.initialize(Path::new(&root)))
741        .transpose()?;
742    let mut state = SessionState::new();
743    state.project_root = Some(root.clone());
744    state.shell_cwd = Some(root.clone());
745    let session = Session {
746        root,
747        policy,
748        registry: build_registry(),
749        cache: Arc::new(RwLock::new(SessionCache::new())),
750        state: Arc::new(RwLock::new(state)),
751        bm25_cache: Arc::new(Mutex::new(None)),
752        autonomy: lean_ctx::core::autonomy::AutonomyState::new(),
753        accepted_calls: std::sync::atomic::AtomicU32::new(0),
754        source_authority,
755        frame_call: Mutex::new(None),
756    };
757    serve(&session, reader, writer)
758}
759
760fn serve<R: BufRead, W: Write>(
761    session: &Session,
762    mut reader: R,
763    writer: W,
764) -> Result<(), &'static str> {
765    let mut writer = BufWriter::new(writer);
766    let mut hello_complete = false;
767    while let Some(frame) = read_frame(&mut reader).map_err(|_| "request_read_failed")? {
768        let response = match frame {
769            InputFrame::TooLarge => {
770                error_response("", "request_too_large", "request exceeds size bound")
771            }
772            InputFrame::Data(line) => match serde_json::from_slice::<RequestV1>(&line) {
773                Ok(request) => handle_request(session, request, &mut hello_complete),
774                Err(_) => error_response("", "invalid_request", "request is not strict JSON v1"),
775            },
776        };
777        let should_close = !response.id.is_empty()
778            && response
779                .result
780                .as_ref()
781                .is_some_and(|value| value.get("closed").and_then(Value::as_bool) == Some(true));
782        let mut encoded = serde_json::to_vec(&response).map_err(|_| "response_write_failed")?;
783        if encoded.len().saturating_add(1) > MAX_RESPONSE_BYTES {
784            encoded = serde_json::to_vec(&error_response(
785                &response.id,
786                "response_too_large",
787                "response exceeds size bound",
788            ))
789            .map_err(|_| "response_write_failed")?;
790        }
791        // The frame as it leaves: one line.
792        encoded.push(b'\n');
793        // G08: a `call` frame is a delivery to the host. Its receipt binds
794        // exactly these bytes and is sealed BEFORE they are written; where
795        // it is mandatory and missing, the frame is replaced by an error
796        // frame and the tool result does not leave.
797        let frame_call = session
798            .frame_call
799            .lock()
800            .unwrap_or_else(std::sync::PoisonError::into_inner)
801            .take();
802        let prepared = match frame_call {
803            Some(call) => {
804                let sealed = lean_ctx::core::policy::runtime::REQUEST_PROJECT.sync_scope(
805                    std::cell::RefCell::new(Some(PathBuf::from(&session.root))),
806                    || call.seal(&session.root, &encoded, !response.ok),
807                );
808                if let Ok(prepared) = sealed {
809                    prepared
810                } else {
811                    encoded = serde_json::to_vec(&receipt_missing_response(&response.id))
812                        .map_err(|_| "response_write_failed")?;
813                    encoded.push(b'\n');
814                    None
815                }
816            }
817            None => None,
818        };
819        writer
820            .write_all(&encoded)
821            .map_err(|_| "response_write_failed")?;
822        writer.flush().map_err(|_| "response_write_failed")?;
823        // OBSERVED: the frame was written and flushed to the session
824        // transport. That the host read it is not observable.
825        if let Some(prepared) = &prepared {
826            release::returned(prepared, &encoded);
827        }
828        if should_close {
829            break;
830        }
831    }
832    Ok(())
833}
834
835fn read_frame<R: BufRead>(reader: &mut R) -> io::Result<Option<InputFrame>> {
836    let mut data = Vec::new();
837    let mut too_large = false;
838    loop {
839        let buffer = reader.fill_buf()?;
840        if buffer.is_empty() {
841            return if data.is_empty() && !too_large {
842                Ok(None)
843            } else if too_large {
844                Ok(Some(InputFrame::TooLarge))
845            } else {
846                Ok(Some(InputFrame::Data(data)))
847            };
848        }
849
850        let newline = buffer.iter().position(|byte| *byte == b'\n');
851        let consumed = newline.map_or(buffer.len(), |index| index + 1);
852        let payload = newline.map_or(buffer, |index| &buffer[..index]);
853        if !too_large {
854            if data.len().saturating_add(payload.len()) > MAX_REQUEST_BYTES {
855                data.clear();
856                too_large = true;
857            } else {
858                data.extend_from_slice(payload);
859            }
860        }
861        reader.consume(consumed);
862        if newline.is_some() {
863            return Ok(Some(if too_large {
864                InputFrame::TooLarge
865            } else {
866                InputFrame::Data(data)
867            }));
868        }
869    }
870}
871
872fn handle_request(session: &Session, request: RequestV1, hello: &mut bool) -> ResponseV1<Value> {
873    if validate_id(&request.id).is_err() {
874        return error_response("", "invalid_request", "id is invalid");
875    }
876    match request.op.as_str() {
877        "hello" => {
878            if *hello {
879                return error_response(&request.id, "invalid_state", "hello already completed");
880            }
881            if request.schema_version != Some(SCHEMA_VERSION)
882                || request.transport_version != Some(TRANSPORT_VERSION)
883                || request.agent_tools_interface_version.as_deref() != Some(INTERFACE_VERSION)
884                || request.sdk_version.as_deref().is_none_or(str::is_empty)
885                || request.tool.is_some()
886                || request.arguments.is_some()
887            {
888                return error_response(
889                    &request.id,
890                    "unsupported_interface",
891                    "hello version or shape is unsupported",
892                );
893            }
894            *hello = true;
895            let result = HelloResultV1 {
896                schema_version: SCHEMA_VERSION,
897                transport_version: TRANSPORT_VERSION,
898                agent_tools_interface_version: INTERFACE_VERSION,
899                engine_version: env!("CARGO_PKG_VERSION"),
900                capabilities: session.capabilities(),
901                allow_write: session.policy.allow_write,
902                allow_exec: session.policy.allow_exec,
903            };
904            success_response(
905                &request.id,
906                serde_json::to_value(result).expect("serializable"),
907            )
908        }
909        "call" => {
910            if !*hello {
911                return error_response(&request.id, "invalid_state", "hello is required first");
912            }
913            if request.schema_version.is_some()
914                || request.transport_version.is_some()
915                || request.agent_tools_interface_version.is_some()
916                || request.sdk_version.is_some()
917            {
918                return error_response(&request.id, "invalid_request", "call shape is invalid");
919            }
920            let Some(tool) = request.tool else {
921                return error_response(&request.id, "invalid_request", "tool is required");
922            };
923            let arguments = request.arguments.unwrap_or_default();
924            // `changed` of v1: a write tool applied its change (not a dry run).
925            let applied = WRITE_TOOLS.contains(&tool.as_str())
926                && arguments.get("dry_run").and_then(Value::as_bool) != Some(true);
927            match session.call(&tool, &arguments) {
928                Ok(output) => {
929                    let result = ToolResultV1 {
930                        // v1 clients require original = output + saved. These
931                        // legacy accounting counters are not measured egress.
932                        output_tokens: output.original_tokens.saturating_sub(output.saved_tokens),
933                        original_tokens: output.original_tokens,
934                        saved_tokens: output.saved_tokens,
935                        mode: output.mode,
936                        changed: output.changed || applied,
937                        shell: agent_shell_outcome(output.shell_outcome.as_ref()),
938                        content_blocks: serde_json::to_value(
939                            output.content_blocks.unwrap_or_default(),
940                        )
941                        .expect("serializable content blocks"),
942                        text: output.text,
943                    };
944                    success_response(
945                        &request.id,
946                        serde_json::to_value(result).expect("serializable"),
947                    )
948                }
949                Err(error) => ResponseV1 {
950                    id: request.id,
951                    ok: false,
952                    result: None,
953                    error: Some(error),
954                },
955            }
956        }
957        "close" => {
958            if !*hello
959                || request.schema_version.is_some()
960                || request.transport_version.is_some()
961                || request.agent_tools_interface_version.is_some()
962                || request.sdk_version.is_some()
963                || request.tool.is_some()
964                || request.arguments.is_some()
965            {
966                return error_response(&request.id, "invalid_request", "close shape is invalid");
967            }
968            success_response(&request.id, serde_json::json!({ "closed": true }))
969        }
970        _ => error_response(&request.id, "invalid_request", "unknown operation"),
971    }
972}
973
974fn success_response(id: &str, result: Value) -> ResponseV1<Value> {
975    ResponseV1 {
976        id: id.to_string(),
977        ok: true,
978        result: Some(result),
979        error: None,
980    }
981}
982
983fn agent_shell_outcome(outcome: Option<&ShellOutcome>) -> Option<Value> {
984    match outcome {
985        Some(ShellOutcome::Exit(code)) => Some(serde_json::json!({ "exitCode": code })),
986        Some(other) => other.structured(),
987        None => None,
988    }
989}
990
991fn error_response(id: &str, code: &'static str, message: &str) -> ResponseV1<Value> {
992    ResponseV1 {
993        id: id.to_string(),
994        ok: false,
995        result: None,
996        error: Some(ErrorV1 {
997            code,
998            message: message.to_string(),
999        }),
1000    }
1001}
1002
1003fn validate_id(id: &str) -> Result<(), ()> {
1004    let bytes = id.as_bytes();
1005    if bytes.is_empty() || bytes.len() > MAX_ID_BYTES || id.chars().any(char::is_control) {
1006        Err(())
1007    } else {
1008        Ok(())
1009    }
1010}
1011
1012fn parse_args(args: &[String]) -> Result<(PathBuf, PathBuf), &'static str> {
1013    let mut root = None;
1014    let mut policy = None;
1015    let mut index = 0;
1016    while index < args.len() {
1017        let flag = args[index].as_str();
1018        index += 1;
1019        let value = args.get(index).ok_or("invalid_request")?;
1020        match flag {
1021            "--project-root" if root.is_none() => root = Some(PathBuf::from(value)),
1022            "--policy-file" if policy.is_none() => policy = Some(PathBuf::from(value)),
1023            _ => return Err("invalid_request"),
1024        }
1025        index += 1;
1026    }
1027    Ok((
1028        root.ok_or("invalid_request")?,
1029        policy.ok_or("invalid_request")?,
1030    ))
1031}
1032
1033fn canonical_root(root: &Path) -> Result<String, &'static str> {
1034    if lean_ctx::core::pathutil::is_broad_or_unsafe_root(root) {
1035        return Err("unsafe_root");
1036    }
1037    let root = fs::canonicalize(root).map_err(|_| "unsafe_root")?;
1038    if !root.is_dir() || lean_ctx::core::pathutil::is_broad_or_unsafe_root(&root) {
1039        return Err("unsafe_root");
1040    }
1041    Ok(root.to_string_lossy().into_owned())
1042}
1043
1044fn read_policy(path: &Path) -> Result<PolicyV1, &'static str> {
1045    let metadata = fs::symlink_metadata(path).map_err(|_| "policy_unavailable")?;
1046    if !metadata.file_type().is_file() || metadata.len() > MAX_POLICY_BYTES {
1047        return Err("policy_unavailable");
1048    }
1049    #[cfg(unix)]
1050    {
1051        use std::os::unix::fs::PermissionsExt;
1052        if metadata.permissions().mode() & 0o077 != 0 {
1053            return Err("policy_permissions");
1054        }
1055    }
1056    let bytes = fs::read(path).map_err(|_| "policy_unavailable")?;
1057    let policy: PolicyV1 = serde_json::from_slice(&bytes).map_err(|_| "invalid_policy")?;
1058    let executable_policy_valid = (!policy.allow_exec && policy.allowed_executables.is_empty())
1059        || (policy.allow_exec
1060            && !policy.allowed_executables.is_empty()
1061            && is_canonical_names(&policy.allowed_executables, is_executable_name));
1062    if policy.schema_version != SCHEMA_VERSION
1063        || !executable_policy_valid
1064        || !is_canonical_names(&policy.allowed_env, is_env_name)
1065        || !(100..=120_000).contains(&policy.max_timeout_ms)
1066    {
1067        return Err("invalid_policy");
1068    }
1069    Ok(policy)
1070}
1071
1072fn is_canonical_names(values: &[String], valid: fn(&str) -> bool) -> bool {
1073    values.windows(2).all(|pair| pair[0] < pair[1]) && values.iter().all(|value| valid(value))
1074}
1075
1076fn is_executable_name(value: &str) -> bool {
1077    !value.is_empty()
1078        && Path::new(value).file_name().and_then(|name| name.to_str()) == Some(value)
1079        && value
1080            .chars()
1081            .all(|character| character.is_alphanumeric() || "._+-".contains(character))
1082}
1083
1084fn is_env_name(value: &str) -> bool {
1085    let mut characters = value.chars();
1086    !matches!(
1087        value.to_ascii_uppercase().as_str(),
1088        "COMSPEC"
1089            | "DYLD_INSERT_LIBRARIES"
1090            | "DYLD_LIBRARY_PATH"
1091            | "HOME"
1092            | "LD_LIBRARY_PATH"
1093            | "LD_PRELOAD"
1094            | "PATH"
1095            | "PATHEXT"
1096            | "PYTHONPATH"
1097            | "RUSTC_WRAPPER"
1098            | "SHELL"
1099    ) && characters
1100        .next()
1101        .is_some_and(|first| first.is_alphabetic() || first == '_')
1102        && characters.all(|character| character.is_alphanumeric() || character == '_')
1103}
1104
1105#[cfg(test)]
1106#[path = "agent_tools_cmd_tests.rs"]
1107mod tests;
1108
1109#[cfg(all(test, unix))]
1110mod shell_gate_tests;