1use 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 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
150fn 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 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 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 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 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 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 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 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 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 #[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 #[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); 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 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 let text = match &admission.notice {
553 Some(notice) => format!("{notice}\n{text}"),
554 None => text,
555 };
556 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 encoded.push(b'\n');
790 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 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 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 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;