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 = 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 let text = match &admission.notice {
556 Some(notice) => format!("{notice}\n{text}"),
557 None => text,
558 };
559 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 encoded.push(b'\n');
793 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 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 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 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;