1use std::io::{self, BufRead, IsTerminal, Write};
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use serde_json::{Map, Value};
6
7use crate::config::{Config, DEFAULT_API_KEY_ENV};
8use crate::context::resolve_boot_context_with_api_key_env;
9use crate::model::{ChatMessage, ChatToolCall};
10use crate::protocol::{EventSink, ProtocolEvent, ProtocolWriter};
11use crate::provider::{Provider, ProviderTurn};
12use crate::redaction::{
13 conflicts_with_tui_literal, is_structural_key, redact_secret, redaction_marker,
14};
15use crate::session::Session;
16
17#[derive(Debug)]
18struct CliOptions {
19 session: Option<String>,
20 list_sessions: bool,
21 jsonl: bool,
22 tui: bool,
23}
24
25#[derive(Debug, Deserialize)]
26struct InputRecord {
27 #[serde(rename = "type")]
28 record_type: String,
29 text: Option<String>,
30}
31
32const MAX_TOOL_ROUNDS: usize = 32;
33const MAX_TOOL_CALLS_PER_MESSAGE: usize = 64;
34const USER_CANCEL_REASON: &str = "user_cancelled";
35const PROVIDER_PHASE: &str = "provider_stream";
36const COMMAND_PHASE: &str = "cmd";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum FrontendMode {
40 Jsonl,
41 Tui,
42}
43
44pub fn run_cli<R, W, E>(args: &[String], input: R, output: W, diagnostics: E) -> i32
45where
46 R: BufRead,
47 W: Write,
48 E: Write,
49{
50 let home = match home_directory() {
51 Ok(home) => home,
52 Err(error) => {
53 let mut diagnostics = diagnostics;
54 write_diagnostic(&mut diagnostics, &error);
55 return 1;
56 }
57 };
58 let cwd = match std::env::current_dir() {
59 Ok(cwd) => cwd,
60 Err(_error) => {
61 let mut diagnostics = diagnostics;
62 write_diagnostic(&mut diagnostics, "unable to resolve cwd");
63 return 1;
64 }
65 };
66 run_cli_at_home_with_terminals(
67 args,
68 input,
69 output,
70 diagnostics,
71 &home,
72 &cwd,
73 io::stdin().is_terminal(),
74 io::stdout().is_terminal(),
75 )
76}
77
78pub fn run_cli_at_home<R, W, E>(
79 args: &[String],
80 input: R,
81 output: W,
82 diagnostics: E,
83 home: &Path,
84 cwd: &Path,
85) -> i32
86where
87 R: BufRead,
88 W: Write,
89 E: Write,
90{
91 run_cli_at_home_with_terminals(args, input, output, diagnostics, home, cwd, false, false)
94}
95
96#[allow(clippy::too_many_arguments)]
97fn run_cli_at_home_with_terminals<R, W, E>(
98 args: &[String],
99 input: R,
100 output: W,
101 mut diagnostics: E,
102 home: &Path,
103 cwd: &Path,
104 stdin_is_tty: bool,
105 stdout_is_tty: bool,
106) -> i32
107where
108 R: BufRead,
109 W: Write,
110 E: Write,
111{
112 let options = match parse_args(args) {
113 Ok(options) => options,
114 Err(error) => {
115 let mut diagnostics = diagnostics;
116 write_diagnostic(&mut diagnostics, &error);
117 return 2;
118 }
119 };
120 let mode = match resolve_mode(args, stdin_is_tty, stdout_is_tty) {
121 Ok(mode) => mode,
122 Err(error) => {
123 write_diagnostic(&mut diagnostics, &error);
124 return 2;
125 }
126 };
127
128 if options.list_sessions {
129 let mut protocol = ProtocolWriter::new(output);
130 if let Err(error) = Config::ensure_exists(home) {
131 write_diagnostic(&mut diagnostics, &error.to_string());
132 return 1;
133 }
134 return match Session::list(home) {
135 Ok(sessions) => {
136 for session in sessions {
137 if let Err(error) = protocol.emit_serializable(&session) {
138 write_diagnostic(
139 &mut diagnostics,
140 &format!("unable to write session metadata: {error}"),
141 );
142 return 1;
143 }
144 }
145 0
146 }
147 Err(error) => {
148 write_diagnostic(&mut diagnostics, &error.to_string());
149 1
150 }
151 };
152 }
153
154 let (session, provider, resumed) = if let Some(id) = options.session.as_deref() {
155 let session = match Session::resume(home, id) {
156 Ok(session) => session,
157 Err(error) => {
158 write_diagnostic(&mut diagnostics, &error.to_string());
159 return 1;
160 }
161 };
162 let provider = match Provider::new(&session.llm) {
163 Ok(provider) => provider,
164 Err(error) => {
165 write_diagnostic(&mut diagnostics, &error.to_string());
166 return 1;
167 }
168 };
169 if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
170 write_diagnostic_safe(
171 &mut diagnostics,
172 "API key conflicts with terminal UI literals",
173 Some(provider.api_key()),
174 );
175 return 1;
176 }
177 (session, provider, true)
178 } else {
179 let config = match Config::load_or_create(home) {
180 Ok(config) => config,
181 Err(error) => {
182 write_diagnostic(&mut diagnostics, &error.to_string());
183 return 1;
184 }
185 };
186 let configured_secret = configured_api_key(&config);
187 let api_key_env = configured_api_key_env(&config);
188 let llm = match config.resolved_llm() {
189 Ok(llm) => llm,
190 Err(error) => {
191 write_diagnostic_safe(
192 &mut diagnostics,
193 &error.to_string(),
194 configured_secret.as_deref(),
195 );
196 return 1;
197 }
198 };
199 let provider = match Provider::new(&llm) {
200 Ok(provider) => provider,
201 Err(error) => {
202 write_diagnostic_safe(
203 &mut diagnostics,
204 &error.to_string(),
205 configured_secret.as_deref(),
206 );
207 return 1;
208 }
209 };
210 if mode == FrontendMode::Tui && conflicts_with_tui_literal(provider.api_key()) {
211 write_diagnostic_safe(
212 &mut diagnostics,
213 "API key conflicts with terminal UI literals",
214 Some(provider.api_key()),
215 );
216 return 1;
217 }
218 let safe_cwd = match std::fs::canonicalize(cwd) {
219 Ok(cwd) if !cwd.display().to_string().contains(provider.api_key()) => cwd,
220 Ok(_) => {
221 write_diagnostic_safe(
222 &mut diagnostics,
223 "session header rejected",
224 Some(provider.api_key()),
225 );
226 return 1;
227 }
228 Err(_) => {
229 write_diagnostic_safe(
230 &mut diagnostics,
231 "unable to resolve session cwd",
232 Some(provider.api_key()),
233 );
234 return 1;
235 }
236 };
237 let context = match resolve_boot_context_with_api_key_env(
238 home,
239 &safe_cwd,
240 &config.system_prompt,
241 api_key_env.as_deref(),
242 ) {
243 Ok(context) => context,
244 Err(error) => {
245 write_diagnostic_safe(
246 &mut diagnostics,
247 &error.to_string(),
248 configured_secret.as_deref(),
249 );
250 return 1;
251 }
252 };
253 let boot_system_prompt = redact_secret(&context.system_prompt, Some(provider.api_key()));
254 let session = match Session::create_with_secret(
255 home,
256 &safe_cwd,
257 boot_system_prompt,
258 llm,
259 Some(provider.api_key()),
260 ) {
261 Ok(session) => session,
262 Err(error) => {
263 write_diagnostic_safe(
264 &mut diagnostics,
265 &error.to_string(),
266 Some(provider.api_key()),
267 );
268 return 1;
269 }
270 };
271 (session, provider, false)
272 };
273
274 let harness = Harness { session, provider };
275 if mode == FrontendMode::Tui {
276 return match crate::tui::run(harness, resumed, output) {
277 Ok(()) => 0,
278 Err(error) => {
279 write_diagnostic(&mut diagnostics, &error);
280 1
281 }
282 };
283 }
284
285 let mut protocol = ProtocolWriter::new(output);
286 let mut harness = harness;
287 if let Err(error) = protocol.session(&harness.session.id, resumed) {
288 write_diagnostic_safe(
289 &mut diagnostics,
290 &format!("unable to write session event: {error}"),
291 Some(harness.provider.api_key()),
292 );
293 return 1;
294 }
295
296 for line in input.lines() {
297 let line = match line {
298 Ok(line) => line,
299 Err(error) => {
300 write_diagnostic_safe(
301 &mut diagnostics,
302 &format!("unable to read stdin: {error}"),
303 Some(harness.provider.api_key()),
304 );
305 return 1;
306 }
307 };
308 if line.trim().is_empty() {
309 continue;
310 }
311 let text = match parse_input_message(&line) {
312 Ok(text) => text,
313 Err(error) => {
314 let error = redact_secret(&error, Some(harness.provider.api_key()));
315 if let Err(write_error) = protocol.error(&error) {
316 write_diagnostic_safe(
317 &mut diagnostics,
318 &format!("unable to write protocol error: {write_error}"),
319 Some(harness.provider.api_key()),
320 );
321 return 1;
322 }
323 continue;
324 }
325 };
326 if let Err(error) = harness.handle_message(&text, &mut protocol, None) {
327 let error = redact_secret(&error, Some(harness.provider.api_key()));
328 if let Err(write_error) = protocol.error(&error) {
329 write_diagnostic_safe(
330 &mut diagnostics,
331 &format!("unable to write protocol error: {write_error}"),
332 Some(harness.provider.api_key()),
333 );
334 return 1;
335 }
336 }
337 }
338
339 0
340}
341
342pub fn resolve_mode(
343 args: &[String],
344 stdin_is_tty: bool,
345 stdout_is_tty: bool,
346) -> Result<FrontendMode, String> {
347 let options = parse_args(args)?;
348 if options.list_sessions {
349 if options.tui {
350 return Err("--tui cannot be combined with --list-sessions".to_owned());
351 }
352 return Ok(FrontendMode::Jsonl);
353 }
354 if options.tui && !(stdin_is_tty && stdout_is_tty) {
355 return Err("--tui requires a terminal on stdin and stdout".to_owned());
356 }
357 if options.tui {
358 Ok(FrontendMode::Tui)
359 } else if options.jsonl || !(stdin_is_tty && stdout_is_tty) {
360 Ok(FrontendMode::Jsonl)
361 } else {
362 Ok(FrontendMode::Tui)
363 }
364}
365
366pub(crate) struct Harness {
367 pub(crate) session: Session,
368 pub(crate) provider: Provider,
369}
370
371impl Harness {
372 pub(crate) fn handle_message<S: EventSink>(
373 &mut self,
374 text: &str,
375 sink: &mut S,
376 cancellation: Option<&crate::cancellation::CancellationToken>,
377 ) -> Result<(), String> {
378 let secret = self.provider.api_key().to_owned();
379 let user_message = ChatMessage::user(redact_secret(text, Some(&secret)));
380 if let Err(error) = self.session.append_message(user_message) {
381 if cancellation.is_some_and(|token| token.is_cancelled()) {
382 let interruption = self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
383 return interruption
384 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
385 }
386 return Err(error.to_string());
387 }
388
389 let mut tool_rounds = 0;
390 let mut tool_calls: usize = 0;
391 loop {
392 if cancellation.is_some_and(|token| token.is_cancelled()) {
393 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
394 }
395 if tool_rounds >= MAX_TOOL_ROUNDS {
396 return Err(format!(
397 "tool loop exceeded maximum of {MAX_TOOL_ROUNDS} provider rounds"
398 ));
399 }
400 let messages = self.session.provider_messages();
401 let mut raw_content = String::new();
402 let mut redactor = SecretRedactor::new(&secret);
403 let stream_result = {
404 let mut on_text = |delta: &str| {
405 raw_content.push_str(delta);
406 redactor.push(delta, |safe_delta| {
407 sink.emit_event(&ProtocolEvent::AssistantDelta {
408 text: safe_delta.to_owned(),
409 })
410 })
411 };
412 match cancellation {
413 Some(token) => {
414 self.provider
415 .stream_chat_cancellable(&messages, &mut on_text, token)
416 }
417 None => self.provider.stream_chat(&messages, &mut on_text),
418 }
419 };
420 redactor
421 .finish(|safe_delta| {
422 sink.emit_event(&ProtocolEvent::AssistantDelta {
423 text: safe_delta.to_owned(),
424 })
425 })
426 .map_err(|error| format!("unable to write assistant delta: {error}"))?;
427 let turn = match stream_result {
428 Ok(turn) => turn,
429 Err(error)
430 if cancellation.is_some_and(|token| token.is_cancelled())
431 || error.is_cancelled() =>
432 {
433 let partial = error.partial_turn().cloned().unwrap_or(ProviderTurn {
434 content: raw_content,
435 tool_calls: Vec::new(),
436 reasoning_details: Vec::new(),
437 });
438 return self.interrupt(
439 sink,
440 PROVIDER_PHASE,
441 &partial.content,
442 &partial.tool_calls,
443 Vec::new(),
444 );
445 }
446 Err(error) => return Err(error.to_string()),
447 };
448 let canceled_after_stream = cancellation.is_some_and(|token| token.is_cancelled());
449
450 if turn.tool_calls.iter().any(|call| call.name != "cmd") {
451 if canceled_after_stream {
452 return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
453 }
454 return Err("provider requested an unsupported tool".to_owned());
455 }
456 if tool_calls.saturating_add(turn.tool_calls.len()) > MAX_TOOL_CALLS_PER_MESSAGE {
457 if canceled_after_stream {
458 return self.interrupt(sink, PROVIDER_PHASE, &turn.content, &[], Vec::new());
459 }
460 return Err(format!(
461 "tool call budget exceeded maximum of {MAX_TOOL_CALLS_PER_MESSAGE} calls per input message"
462 ));
463 }
464
465 let safe_tool_calls = turn
466 .tool_calls
467 .iter()
468 .map(|call| ChatToolCall {
469 id: redact_secret(&call.id, Some(&secret)),
470 name: redact_secret(&call.name, Some(&secret)),
471 arguments: redact_tool_arguments(&call.arguments, &secret),
472 })
473 .collect::<Vec<_>>();
474 let assistant_content = redact_secret(&turn.content, Some(&secret));
475 let safe_reasoning_details = redact_reasoning_details(&turn.reasoning_details, &secret);
476 let mut assistant =
477 ChatMessage::assistant(assistant_content.clone(), safe_tool_calls.clone());
478 assistant.reasoning_details = safe_reasoning_details;
479 if let Err(error) = self.session.append_message(assistant) {
480 if cancellation.is_some_and(|token| token.is_cancelled()) {
481 let interruption = self.interrupt(
482 sink,
483 PROVIDER_PHASE,
484 &assistant_content,
485 &turn.tool_calls,
486 Vec::new(),
487 );
488 return interruption
489 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
490 }
491 return Err(error.to_string());
492 }
493
494 if safe_tool_calls.is_empty() {
495 if canceled_after_stream || cancellation.is_some_and(|token| !token.try_complete())
496 {
497 return self.interrupt(sink, PROVIDER_PHASE, "", &[], Vec::new());
498 }
499 sink.emit_event(&ProtocolEvent::TurnEnd)
500 .map_err(|error| format!("unable to write turn end: {error}"))?;
501 return Ok(());
502 }
503
504 tool_rounds += 1;
505 tool_calls += safe_tool_calls.len();
506 for safe_call in &safe_tool_calls {
507 sink.emit_event(&ProtocolEvent::ToolCall {
508 id: safe_call.id.clone(),
509 name: safe_call.name.clone(),
510 arguments: safe_call.arguments.clone(),
511 })
512 .map_err(|error| format!("unable to write tool call: {error}"))?;
513 }
514 for index in 0..turn.tool_calls.len() {
515 let raw_call = &turn.tool_calls[index];
516 let safe_call = &safe_tool_calls[index];
517 let result = if cancellation.is_some_and(|token| token.is_cancelled()) {
518 crate::command::canceled_result(&safe_call.arguments, &secret)
519 } else {
520 crate::command::execute_with_cancellation(
521 &raw_call.arguments,
522 &self.session.cwd,
523 self.provider.api_key_env(),
524 Some(&secret),
525 cancellation,
526 )
527 };
528 let result = redact_json_value(
529 serde_json::to_value(result)
530 .map_err(|error| format!("unable to encode cmd result: {error}"))?,
531 &secret,
532 );
533 let tool_content = serde_json::to_string(&result)
534 .map_err(|error| format!("unable to encode tool result: {error}"))?;
535 let tool_message = ChatMessage::tool(
536 safe_call.id.clone(),
537 safe_call.name.clone(),
538 redact_secret(&tool_content, Some(&secret)),
539 );
540 let observation = crate::session::SessionToolResult {
541 id: safe_call.id.clone(),
542 name: safe_call.name.clone(),
543 result: result.clone(),
544 };
545 if let Err(error) = self.session.append_message(tool_message) {
546 if cancellation.is_some_and(|token| token.is_cancelled()) {
547 let interruption =
548 self.interrupt(sink, COMMAND_PHASE, "", &[], vec![observation]);
549 return interruption
550 .map_err(|interrupt_error| format!("{error}; {interrupt_error}"));
551 }
552 return Err(error.to_string());
553 }
554 sink.emit_event(&ProtocolEvent::ToolResult {
555 id: safe_call.id.clone(),
556 name: safe_call.name.clone(),
557 result: result.clone(),
558 })
559 .map_err(|error| format!("unable to write tool result: {error}"))?;
560 if cancellation.is_some_and(|token| token.is_cancelled()) {
561 for pending_call in safe_tool_calls.iter().skip(index + 1) {
562 let pending_result = redact_json_value(
563 serde_json::to_value(crate::command::canceled_result(
564 &pending_call.arguments,
565 &secret,
566 ))
567 .map_err(|error| format!("unable to encode cmd result: {error}"))?,
568 &secret,
569 );
570 let pending_content = serde_json::to_string(&pending_result)
571 .map_err(|error| format!("unable to encode tool result: {error}"))?;
572 let pending_message = ChatMessage::tool(
573 pending_call.id.clone(),
574 pending_call.name.clone(),
575 redact_secret(&pending_content, Some(&secret)),
576 );
577 let pending_observation = crate::session::SessionToolResult {
578 id: pending_call.id.clone(),
579 name: pending_call.name.clone(),
580 result: pending_result.clone(),
581 };
582 if let Err(error) = self.session.append_message(pending_message) {
583 if cancellation.is_some_and(|token| token.is_cancelled()) {
584 let interruption = self.interrupt(
585 sink,
586 COMMAND_PHASE,
587 "",
588 &[],
589 vec![pending_observation],
590 );
591 return interruption.map_err(|interrupt_error| {
592 format!("{error}; {interrupt_error}")
593 });
594 }
595 return Err(error.to_string());
596 }
597 sink.emit_event(&ProtocolEvent::ToolResult {
598 id: pending_call.id.clone(),
599 name: pending_call.name.clone(),
600 result: pending_result.clone(),
601 })
602 .map_err(|error| format!("unable to write tool result: {error}"))?;
603 }
604 return self.interrupt(sink, COMMAND_PHASE, "", &[], Vec::new());
605 }
606 }
607 }
608 }
609
610 fn interrupt<S: EventSink>(
611 &mut self,
612 sink: &mut S,
613 phase: &str,
614 assistant_text: &str,
615 tool_calls: &[ChatToolCall],
616 tool_results: Vec<crate::session::SessionToolResult>,
617 ) -> Result<(), String> {
618 let secret = self.provider.api_key();
619 let safe_tool_calls = tool_calls
620 .iter()
621 .filter(|call| call.name == "cmd")
622 .map(|call| safe_partial_tool_call(call, secret))
623 .collect::<Vec<_>>();
624 let safe_tool_results = tool_results.clone();
625 let interruption = crate::session::InterruptionRecord {
626 timestamp: 0,
627 reason: USER_CANCEL_REASON.to_owned(),
628 phase: phase.to_owned(),
629 assistant_text: redact_secret(assistant_text, Some(secret)),
630 tool_calls: safe_tool_calls.clone(),
631 tool_results,
632 };
633 let persistence_error = self.session.append_interruption(interruption).err();
634 let mut event_error = None;
635 for call in &safe_tool_calls {
636 if let Err(error) = sink.emit_event(&ProtocolEvent::ToolCall {
637 id: call.id.clone(),
638 name: call.name.clone(),
639 arguments: call.arguments.clone(),
640 }) {
641 event_error.get_or_insert(error);
642 }
643 }
644 for observation in &safe_tool_results {
645 if let Err(error) = sink.emit_event(&ProtocolEvent::ToolResult {
646 id: observation.id.clone(),
647 name: observation.name.clone(),
648 result: observation.result.clone(),
649 }) {
650 event_error.get_or_insert(error);
651 }
652 }
653 if let Err(error) = sink.emit_event(&ProtocolEvent::TurnInterrupted {
654 reason: USER_CANCEL_REASON.to_owned(),
655 phase: phase.to_owned(),
656 }) {
657 event_error.get_or_insert(error);
658 }
659 match (persistence_error, event_error) {
660 (None, None) => Ok(()),
661 (Some(error), None) => Err(format!("unable to persist interruption: {error}")),
662 (None, Some(error)) => Err(format!("unable to write interruption event: {error}")),
663 (Some(persistence), Some(event)) => Err(format!(
664 "unable to persist interruption: {persistence}; unable to write interruption event: {event}"
665 )),
666 }
667 }
668}
669
670struct SecretRedactor {
671 secret_text: String,
672 secret: Vec<char>,
673 marker: String,
674 pending: String,
675}
676
677impl SecretRedactor {
678 fn new(secret: &str) -> Self {
679 Self {
680 secret_text: secret.to_owned(),
681 secret: secret.chars().collect(),
682 marker: redaction_marker(secret).unwrap_or_default(),
683 pending: String::new(),
684 }
685 }
686
687 fn push<F>(&mut self, text: &str, mut emit: F) -> io::Result<()>
688 where
689 F: FnMut(&str) -> io::Result<()>,
690 {
691 if self.secret.is_empty() {
692 return emit(text);
693 }
694
695 let mut output = String::new();
696 for character in text.chars() {
697 self.pending.push(character);
698 if self.pending.chars().eq(self.secret.iter().copied()) {
699 self.pending.clear();
700 output.push_str(&self.marker);
701 continue;
702 }
703 if self.pending_is_secret_prefix() {
704 continue;
705 }
706
707 let pending = self.pending.chars().collect::<Vec<_>>();
708 let suffix_len = (1..pending.len())
709 .rev()
710 .find(|length| {
711 pending[pending.len() - length..].iter().copied().eq(self
712 .secret
713 .iter()
714 .copied()
715 .take(*length))
716 })
717 .unwrap_or(0);
718 let safe_len = pending.len() - suffix_len;
719 output.extend(pending[..safe_len].iter());
720 self.pending = pending[safe_len..].iter().collect();
721 }
722
723 if output.is_empty() {
724 Ok(())
725 } else {
726 let safe_output = redact_secret(&output, Some(&self.secret_text));
727 emit(&safe_output)
728 }
729 }
730
731 fn finish<F>(&mut self, mut emit: F) -> io::Result<()>
732 where
733 F: FnMut(&str) -> io::Result<()>,
734 {
735 let pending = std::mem::take(&mut self.pending);
736 if pending.is_empty() {
737 return Ok(());
738 }
739 let safe_pending = redact_secret(&pending, Some(&self.secret_text));
740 emit(&safe_pending)
741 }
742
743 fn pending_is_secret_prefix(&self) -> bool {
744 let length = self.pending.chars().count();
745 length < self.secret.len()
746 && self
747 .pending
748 .chars()
749 .zip(self.secret.iter().copied())
750 .all(|(pending, secret)| pending == secret)
751 }
752}
753
754fn redact_tool_arguments(arguments: &str, secret: &str) -> String {
755 let fallback = || "{}".to_owned();
756 let Ok(value) = serde_json::from_str::<Value>(arguments) else {
757 return fallback();
758 };
759 let Some(object) = value.as_object() else {
760 return fallback();
761 };
762 if object.len() != 1 || !object.get("command").is_some_and(Value::is_string) {
763 return fallback();
764 }
765 let redacted = redact_json_value(value, secret);
766 serde_json::to_string(&redacted).unwrap_or_else(|_| fallback())
767}
768
769fn safe_partial_tool_call(call: &ChatToolCall, secret: &str) -> ChatToolCall {
770 let arguments = if serde_json::from_str::<Value>(&call.arguments)
771 .ok()
772 .and_then(|value| value.as_object().cloned())
773 .is_some_and(|object| object.len() == 1 && object.contains_key("command"))
774 {
775 redact_tool_arguments(&call.arguments, secret)
776 } else {
777 "{}".to_owned()
781 };
782 ChatToolCall {
783 id: redact_secret(&call.id, Some(secret)),
784 name: redact_secret(&call.name, Some(secret)),
785 arguments,
786 }
787}
788
789fn redact_json_value(value: Value, secret: &str) -> Value {
790 match value {
791 Value::String(text) => Value::String(redact_secret(&text, Some(secret))),
792 Value::Array(values) => Value::Array(
793 values
794 .into_iter()
795 .map(|value| redact_json_value(value, secret))
796 .collect(),
797 ),
798 Value::Object(object) => {
799 let marker = redaction_marker(secret).unwrap_or_default();
800 let mut redacted = Map::new();
801 for (key, value) in object {
802 let mut safe_key = if is_structural_key(&key) {
803 key
804 } else {
805 redact_secret(&key, Some(secret))
806 };
807 if redacted.contains_key(&safe_key) {
808 if marker.is_empty() {
809 continue;
810 }
811 while redacted.contains_key(&safe_key) {
812 safe_key.push_str(&marker);
813 }
814 }
815 redacted.insert(safe_key, redact_json_value(value, secret));
816 }
817 Value::Object(redacted)
818 }
819 value => value,
820 }
821}
822
823fn redact_reasoning_details(details: &[Value], secret: &str) -> Option<Vec<Value>> {
824 if details.is_empty() {
825 return None;
826 }
827 match redact_json_value(Value::Array(details.to_vec()), secret) {
828 Value::Array(details) => Some(details),
829 _ => None,
830 }
831}
832
833fn parse_args(args: &[String]) -> Result<CliOptions, String> {
834 let mut options = CliOptions {
835 session: None,
836 list_sessions: false,
837 jsonl: false,
838 tui: false,
839 };
840 let mut index = 0;
841 while index < args.len() {
842 match args[index].as_str() {
843 "--session" => {
844 if options.list_sessions || options.session.is_some() {
845 return Err("--session cannot be combined or repeated".to_owned());
846 }
847 index += 1;
848 let Some(id) = args.get(index) else {
849 return Err("--session requires an id".to_owned());
850 };
851 options.session = Some(id.clone());
852 }
853 "--list-sessions" => {
854 if options.session.is_some() || options.list_sessions {
855 return Err("--list-sessions cannot be combined or repeated".to_owned());
856 }
857 options.list_sessions = true;
858 }
859 "--jsonl" => {
860 if options.jsonl || options.tui {
861 return Err("--jsonl cannot be combined or repeated".to_owned());
862 }
863 options.jsonl = true;
864 }
865 "--tui" => {
866 if options.tui || options.jsonl {
867 return Err("--tui cannot be combined or repeated".to_owned());
868 }
869 options.tui = true;
870 }
871 "--help" | "-h" => {
872 return Err(
873 "usage: lucy [--jsonl|--tui] [--session <id>] [--list-sessions]".to_owned(),
874 );
875 }
876 _ => return Err("unknown argument".to_owned()),
877 }
878 index += 1;
879 }
880 Ok(options)
881}
882
883fn parse_input_message(line: &str) -> Result<String, String> {
884 let record: InputRecord = serde_json::from_str(line)
885 .map_err(|_| "input must be a JSONL message record".to_owned())?;
886 if record.record_type != "message" {
887 return Err("input record type must be message".to_owned());
888 }
889 record
890 .text
891 .ok_or_else(|| "message record requires a text string".to_owned())
892}
893
894fn home_directory() -> Result<PathBuf, String> {
895 std::env::var_os("HOME")
896 .map(PathBuf::from)
897 .ok_or_else(|| "HOME is not set; Lucy needs a user home directory".to_owned())
898}
899
900fn configured_api_key_env(config: &Config) -> Option<String> {
901 let api_key_env = config
902 .llm
903 .api_key_env
904 .as_deref()
905 .unwrap_or(DEFAULT_API_KEY_ENV)
906 .trim();
907 (!api_key_env.is_empty()).then(|| api_key_env.to_owned())
908}
909
910fn configured_api_key(config: &Config) -> Option<String> {
911 configured_api_key_env(config)
912 .and_then(|api_key_env| std::env::var(api_key_env).ok())
913 .filter(|secret| !secret.is_empty())
914}
915
916fn write_diagnostic_safe<W: Write>(diagnostics: &mut W, message: &str, secret: Option<&str>) {
917 write_diagnostic_safe_with_environment(
918 diagnostics,
919 message,
920 secret,
921 std::env::vars().map(|(_, value)| value),
922 );
923}
924
925fn write_diagnostic_safe_with_environment<W, I>(
926 diagnostics: &mut W,
927 message: &str,
928 secret: Option<&str>,
929 environment_values: I,
930) where
931 W: Write,
932 I: IntoIterator<Item = String>,
933{
934 let mut safe_line = format!("lucy: {message}");
935 safe_line = redact_secret(&safe_line, secret);
936 let mut environment_secrets = environment_values
937 .into_iter()
938 .filter(|value| !value.is_empty())
939 .collect::<Vec<_>>();
940 environment_secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
941 for environment_secret in environment_secrets {
942 safe_line = redact_secret(&safe_line, Some(&environment_secret));
943 }
944 let _ = writeln!(diagnostics, "{safe_line}");
945}
946
947fn write_diagnostic<W: Write>(diagnostics: &mut W, message: &str) {
948 write_diagnostic_safe(diagnostics, message, None);
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use std::io::Cursor;
955
956 #[test]
957 fn parses_only_message_records() {
958 assert_eq!(
959 parse_input_message(r#"{"type":"message","text":"hello"}"#).expect("message"),
960 "hello"
961 );
962 assert!(parse_input_message(r#"{"type":"event","text":"hello"}"#).is_err());
963 assert_eq!(
964 parse_input_message(r#"{"type":"message","text":""}"#).expect("empty message"),
965 ""
966 );
967 }
968
969 #[test]
970 fn resolves_terminal_and_forced_modes() {
971 assert_eq!(
972 resolve_mode(&[], true, true).expect("default TUI"),
973 FrontendMode::Tui
974 );
975 assert_eq!(
976 resolve_mode(&[], true, false).expect("automatic JSONL"),
977 FrontendMode::Jsonl
978 );
979 assert_eq!(
980 resolve_mode(&["--jsonl".to_owned()], true, true).expect("forced JSONL"),
981 FrontendMode::Jsonl
982 );
983 assert!(resolve_mode(&["--tui".to_owned()], true, false).is_err());
984 }
985
986 #[test]
987 fn redactor_does_not_leak_a_secret_across_deltas() {
988 let mut redactor = SecretRedactor::new("secret");
989 let mut output = Vec::new();
990 redactor
991 .push("prefix sec", |text| {
992 output.push(text.to_owned());
993 Ok(())
994 })
995 .expect("push");
996 redactor
997 .push("ret suffix", |text| {
998 output.push(text.to_owned());
999 Ok(())
1000 })
1001 .expect("push");
1002 redactor
1003 .finish(|text| {
1004 output.push(text.to_owned());
1005 Ok(())
1006 })
1007 .expect("finish");
1008 let output = output.join("");
1009 assert_eq!(
1010 output,
1011 format!("prefix {} suffix", redaction_marker("secret").unwrap())
1012 );
1013 assert!(!output.contains("secret"));
1014 }
1015
1016 #[test]
1017 fn redactor_handles_secrets_introduced_by_protocol_json_escaping() {
1018 let mut redactor = SecretRedactor::new("n0");
1019 let mut output = String::new();
1020 redactor
1021 .push("\n0", |text| {
1022 output.push_str(text);
1023 Ok(())
1024 })
1025 .expect("push");
1026 redactor
1027 .finish(|text| {
1028 output.push_str(text);
1029 Ok(())
1030 })
1031 .expect("finish");
1032 assert!(!output.contains("n0"));
1033 assert_eq!(output, redaction_marker("n0").unwrap());
1034 }
1035
1036 #[test]
1037 fn redactor_does_not_emit_a_secret_when_it_completes_at_a_delta_boundary() {
1038 let mut redactor = SecretRedactor::new("secret");
1039 let mut output = Vec::new();
1040 redactor
1041 .push("xsecre", |text| {
1042 output.push(text.to_owned());
1043 Ok(())
1044 })
1045 .expect("first delta");
1046 redactor
1047 .push("t", |text| {
1048 output.push(text.to_owned());
1049 Ok(())
1050 })
1051 .expect("second delta");
1052 redactor
1053 .finish(|text| {
1054 output.push(text.to_owned());
1055 Ok(())
1056 })
1057 .expect("finish");
1058 let output = output.join("");
1059 assert_eq!(output, format!("x{}", redaction_marker("secret").unwrap()));
1060 assert!(!output.contains("secret"));
1061 }
1062
1063 #[test]
1064 fn streaming_redaction_handles_marker_collision_keys_at_delta_boundaries() {
1065 for secret in ["REDACTED", "[REDACTED]"] {
1066 let mut redactor = SecretRedactor::new(secret);
1067 let split = secret.len() / 2;
1068 let (first, second) = secret.split_at(split);
1069 let mut output = String::new();
1070 redactor
1071 .push(first, |text| {
1072 output.push_str(text);
1073 Ok(())
1074 })
1075 .expect("first delta");
1076 redactor
1077 .push(second, |text| {
1078 output.push_str(text);
1079 Ok(())
1080 })
1081 .expect("second delta");
1082 redactor
1083 .finish(|text| {
1084 output.push_str(text);
1085 Ok(())
1086 })
1087 .expect("finish");
1088 assert!(!output.contains(secret));
1089 assert!(output.len() <= secret.len());
1090 }
1091 }
1092
1093 #[test]
1094 fn malformed_tool_arguments_use_a_safe_copy() {
1095 let secret = "provider-secret";
1096 let escaped = secret
1097 .chars()
1098 .map(|character| format!(r#"\u{:04x}"#, character as u32))
1099 .collect::<String>();
1100 let arguments = format!(r#"{{"command":"{escaped}""#);
1101 let safe = redact_tool_arguments(&arguments, secret);
1102 assert_eq!(safe, "{}");
1103 serde_json::from_str::<Value>(&safe).expect("safe arguments JSON");
1104 assert!(!safe.contains(secret));
1105 assert!(!safe.contains(&escaped));
1106 for invalid in ["[]", "{\"command\":1}", "{\"other\":\"value\"}"] {
1107 assert_eq!(redact_tool_arguments(invalid, secret), "{}");
1108 }
1109 }
1110
1111 #[test]
1112 fn structured_redaction_preserves_tool_and_result_schema_keys() {
1113 let secret = "provider-secret";
1114 let value = serde_json::json!({
1115 "command": "printf provider-secret",
1116 "stdout": "provider-secret",
1117 "stderr": "ordinary",
1118 "exit_code": 0,
1119 "timed_out": false,
1120 "stdout_truncated": false,
1121 "stderr_truncated": false,
1122 "unknown-provider-secret": "provider-secret"
1123 });
1124 let redacted = redact_json_value(value, secret);
1125 for key in [
1126 "command",
1127 "stdout",
1128 "stderr",
1129 "exit_code",
1130 "timed_out",
1131 "stdout_truncated",
1132 "stderr_truncated",
1133 ] {
1134 assert!(redacted.get(key).is_some(), "missing schema key: {key}");
1135 }
1136 let encoded = serde_json::to_string(&redacted).expect("redacted JSON");
1137 assert!(!encoded.contains(secret));
1138 assert!(redacted.get("unknown-provider-secret").is_none());
1139 }
1140
1141 #[test]
1142 fn structured_redaction_preserves_typed_values_even_for_a_pathological_key() {
1143 let value = serde_json::json!({
1144 "exit_code": 0,
1145 "timed_out": false,
1146 "stdout_truncated": true,
1147 "error": null,
1148 });
1149 let redacted = redact_json_value(value, "0");
1150 assert!(redacted["exit_code"].is_number());
1151 assert!(redacted["timed_out"].is_boolean());
1152 assert!(redacted["stdout_truncated"].is_boolean());
1153 assert!(redacted["error"].is_null());
1154 }
1155
1156 #[test]
1157 fn reasoning_details_are_recursively_redacted_before_persistence() {
1158 let details = vec![serde_json::json!({
1159 "type": "reasoning.text",
1160 "text": "provider-secret",
1161 "nested": [{"value": "provider-secret"}],
1162 "provider-secret": "provider-secret"
1163 })];
1164 let redacted = redact_reasoning_details(&details, "provider-secret")
1165 .expect("non-empty reasoning details");
1166 let redacted = Value::Array(redacted);
1167 let encoded = serde_json::to_string(&redacted).expect("reasoning details JSON");
1168 assert!(!encoded.contains("provider-secret"));
1169 assert_eq!(redacted[0]["type"], "reasoning.text");
1170 assert_eq!(redacted[0]["text"], "[REDACTED]");
1171 assert_eq!(redacted[0]["nested"][0]["value"], "[REDACTED]");
1172 assert!(redacted[0].get("provider-secret").is_none());
1173 }
1174
1175 #[test]
1176 fn malformed_input_error_does_not_echo_secret_bearing_input() {
1177 let error =
1178 parse_input_message(r#"{"type":"message","text":"provider-secret","unexpected":}"#)
1179 .expect_err("invalid input");
1180 assert!(!error.contains("provider-secret"));
1181 }
1182
1183 #[test]
1184 fn malformed_input_is_an_error_event_and_not_diagnostic_json() {
1185 let mut output = Vec::new();
1186 let error = parse_input_message("not json").expect_err("invalid input");
1187 let mut protocol = ProtocolWriter::new(&mut output);
1188 protocol.error(&error).expect("error event");
1189 assert_eq!(String::from_utf8_lossy(&output).lines().count(), 1);
1190 let _ = Cursor::new("");
1191 }
1192
1193 #[test]
1194 fn early_diagnostic_scrubbing_removes_short_values_from_the_complete_line() {
1195 let secret = "lucy";
1196 let mut diagnostics = Vec::new();
1197 write_diagnostic_safe_with_environment(
1198 &mut diagnostics,
1199 secret,
1200 None,
1201 vec![secret.to_owned()],
1202 );
1203 let diagnostics = String::from_utf8(diagnostics).expect("diagnostic UTF-8");
1204 assert!(!diagnostics.contains(secret));
1205 }
1206}