返回 CodeWhale
tool_result_retrieval.rs
根目录 / crates / tui / src / tools / tool_result_retrieval.rs
1 //! `retrieve_tool_result` - selective retrieval for spilled tool outputs.
2 //!
3 //! Exact tool evidence is retained under its origin session. Historical
4 //! payloads in the global `tool_outputs/` compatibility directory are readable
5 //! only when a digest-bound ownership sidecar proves they belong to the active
6 //! session.
7
8 use std::path::PathBuf;
9
10 use async_trait::async_trait;
11 use serde_json::{Value, json};
12
13 use super::spec::{
14 ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_str, optional_u64,
15 required_str,
16 };
17
18 const DEFAULT_MAX_BYTES: usize = 8 * 1024;
19 const HARD_MAX_BYTES: usize = 128 * 1024;
20 const DEFAULT_LINE_COUNT: usize = 40;
21 const HARD_LINE_COUNT: usize = 500;
22 const DEFAULT_MAX_MATCHES: usize = 20;
23 const HARD_MAX_MATCHES: usize = 100;
24 const DEFAULT_CONTEXT_LINES: usize = 1;
25 const HARD_CONTEXT_LINES: usize = 5;
26
27 /// Retrieve summaries or slices of a prior spilled tool result.
28 pub struct RetrieveToolResultTool;
29
30 #[async_trait]
31 impl ToolSpec for RetrieveToolResultTool {
32 fn name(&self) -> &'static str {
33 "retrieve_tool_result"
34 }
35
36 fn description(&self) -> &'static str {
37 "Inspect retained tool evidence with strict session ownership and bounds. Accepts an artifact id, validated session-relative path, or an ownership-proven legacy call/SHA reference. Unowned legacy-global evidence fails closed. Modes: metadata, summary, head, tail, lines, query, bytes. bytes returns a bounded base64 slice for exact text or binary recovery."
38 }
39
40 fn input_schema(&self) -> Value {
41 json!({
42 "type": "object",
43 "properties": {
44 "ref": {
45 "type": "string",
46 "description": "Session-owned artifact id (`art_<id>`) or validated artifact-relative path. Legacy call-id/SHA references work only when origin-session ownership was recorded."
47 },
48 "mode": {
49 "type": "string",
50 "enum": ["metadata", "summary", "head", "tail", "lines", "query", "bytes"],
51 "description": "Retrieval mode. Defaults to summary."
52 },
53 "query": {
54 "type": "string",
55 "description": "Case-insensitive substring to search for when mode=query."
56 },
57 "lines": {
58 "type": "string",
59 "description": "Line selector for mode=lines, e.g. \"10\" or \"10-40\"."
60 },
61 "start_line": {
62 "type": "integer",
63 "description": "1-based first line for mode=lines."
64 },
65 "end_line": {
66 "type": "integer",
67 "description": "1-based final line for mode=lines."
68 },
69 "line_count": {
70 "type": "integer",
71 "description": "Number of lines for head/tail modes. Default 40, hard cap 500."
72 },
73 "max_bytes": {
74 "type": "integer",
75 "description": "Maximum bytes of excerpt text returned. Default 8192, hard cap 131072."
76 },
77 "max_matches": {
78 "type": "integer",
79 "description": "Maximum query matches or signal lines returned. Default 20, hard cap 100."
80 },
81 "context_lines": {
82 "type": "integer",
83 "description": "Extra lines around each query match. Default 1, hard cap 5."
84 },
85 "generation": {
86 "type": "integer",
87 "minimum": 1,
88 "description": "Optional expected evidence generation; mismatches fail closed."
89 },
90 "offset": {
91 "type": "integer",
92 "minimum": 0,
93 "description": "Zero-based byte offset for mode=bytes."
94 },
95 "length": {
96 "type": "integer",
97 "minimum": 1,
98 "description": "Byte count for mode=bytes, capped at max_bytes."
99 }
100 },
101 "required": ["ref"]
102 })
103 }
104
105 fn capabilities(&self) -> Vec<ToolCapability> {
106 vec![ToolCapability::ReadOnly]
107 }
108
109 fn supports_parallel(&self) -> bool {
110 true
111 }
112
113 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
114 let reference = required_str(&input, "ref")?.trim();
115 if reference.is_empty() {
116 return Err(ToolError::invalid_input("ref cannot be empty"));
117 }
118
119 let mode = optional_str(&input, "mode")?
120 .unwrap_or("summary")
121 .trim()
122 .to_ascii_lowercase();
123 let max_bytes = clamp_u64(
124 optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES as u64)?,
125 1,
126 HARD_MAX_BYTES,
127 );
128 let resolved = resolve_spillover_reference(reference, &context.state_namespace)?;
129 let legacy_ownership = if resolved.kind == ResolvedReferenceKind::LegacyGlobal {
130 Some(authorize_legacy_spillover(
131 &resolved.path,
132 &context.state_namespace,
133 )?)
134 } else {
135 None
136 };
137 let bytes = tokio::fs::read(&resolved.path).await.map_err(|_| {
138 ToolError::execution_failed("evidence is missing or no longer retained")
139 })?;
140 if let Some(ownership) = legacy_ownership {
141 let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
142 if ownership.size_bytes != size
143 || ownership.digest != crate::hashing::sha256_hex(&bytes)
144 {
145 return Err(ToolError::execution_failed(
146 "legacy evidence content is corrupt",
147 ));
148 }
149 }
150 let evidence = validate_evidence_if_present(
151 reference,
152 &resolved.path,
153 &bytes,
154 &context.state_namespace,
155 &input,
156 )?;
157 if mode == "metadata" {
158 return ToolResult::json(&json!({
159 "ref": reference,
160 "available": true,
161 "total_bytes": bytes.len(),
162 "evidence": evidence,
163 }))
164 .map_err(|err| ToolError::execution_failed(err.to_string()));
165 }
166 if mode == "bytes" {
167 use base64::Engine as _;
168 let offset = input.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
169 let requested = input
170 .get("length")
171 .and_then(Value::as_u64)
172 .unwrap_or(max_bytes as u64) as usize;
173 let end = offset
174 .saturating_add(requested.min(max_bytes))
175 .min(bytes.len());
176 let slice = bytes.get(offset.min(bytes.len())..end).unwrap_or_default();
177 return ToolResult::json(&json!({
178 "ref": reference,
179 "mode": "bytes",
180 "offset": offset,
181 "returned_bytes": slice.len(),
182 "total_bytes": bytes.len(),
183 "encoding": "base64",
184 "data": base64::engine::general_purpose::STANDARD.encode(slice),
185 }))
186 .map_err(|err| ToolError::execution_failed(err.to_string()));
187 }
188 let content = String::from_utf8(bytes).map_err(|_| {
189 ToolError::execution_failed(
190 "evidence encoding is binary; bounded text inspection is unavailable",
191 )
192 })?;
193
194 let lines: Vec<&str> = content.lines().collect();
195 let payload = match mode.as_str() {
196 "summary" => build_summary_payload(reference, &content, &lines, &input, max_bytes)?,
197 "head" => build_head_tail_payload(reference, "head", &lines, &input, max_bytes)?,
198 "tail" => build_head_tail_payload(reference, "tail", &lines, &input, max_bytes)?,
199 "lines" => build_lines_payload(reference, &lines, &input, max_bytes)?,
200 "query" => build_query_payload(reference, &lines, &input, max_bytes)?,
201 other => {
202 return Err(ToolError::invalid_input(format!(
203 "unsupported mode `{other}` (expected metadata, summary, head, tail, lines, query, or bytes)"
204 )));
205 }
206 };
207
208 ToolResult::json(&payload).map_err(|err| {
209 ToolError::execution_failed(format!("failed to serialize result: {err}"))
210 })
211 }
212 }
213
214 fn validate_evidence_if_present(
215 reference: &str,
216 path: &std::path::Path,
217 bytes: &[u8],
218 session_id: &str,
219 input: &Value,
220 ) -> Result<Option<crate::tools::large_output_router::EvidenceArtifact>, ToolError> {
221 let handle = path
222 .file_stem()
223 .and_then(|stem| stem.to_str())
224 .filter(|stem| stem.starts_with("art_"))
225 .or_else(|| {
226 reference
227 .trim()
228 .starts_with("art_")
229 .then(|| reference.trim())
230 });
231 let Some(handle) = handle else {
232 return Ok(None);
233 };
234 let metadata =
235 match crate::tools::large_output_router::read_evidence_metadata(session_id, handle) {
236 Ok(metadata) => metadata,
237 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
238 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
239 return Err(ToolError::permission_denied(
240 "evidence belongs to another session",
241 ));
242 }
243 Err(_) => return Err(ToolError::execution_failed("evidence metadata is corrupt")),
244 };
245 if metadata.origin_session != session_id || metadata.handle != handle {
246 return Err(ToolError::permission_denied(
247 "evidence belongs to another session",
248 ));
249 }
250 if metadata.redacted {
251 return Err(ToolError::permission_denied("evidence has been redacted"));
252 }
253 if crate::tools::large_output_router::evidence_is_expired(
254 &metadata,
255 crate::tools::large_output_router::unix_millis_now(),
256 ) {
257 return Err(ToolError::execution_failed(
258 "evidence retention has expired",
259 ));
260 }
261 if input
262 .get("generation")
263 .and_then(Value::as_u64)
264 .is_some_and(|generation| generation != u64::from(metadata.generation))
265 {
266 return Err(ToolError::execution_failed(
267 "evidence generation does not match",
268 ));
269 }
270 if metadata.size_bytes != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
271 || metadata.digest != crate::hashing::sha256_hex(bytes)
272 {
273 return Err(ToolError::execution_failed("evidence content is corrupt"));
274 }
275 Ok(Some(metadata))
276 }
277
278 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
279 enum ResolvedReferenceKind {
280 ActiveSession,
281 LegacyGlobal,
282 }
283
284 #[derive(Debug, Clone, PartialEq, Eq)]
285 struct ResolvedSpilloverReference {
286 path: PathBuf,
287 kind: ResolvedReferenceKind,
288 }
289
290 fn authorize_legacy_spillover(
291 path: &std::path::Path,
292 session_id: &str,
293 ) -> Result<crate::tools::truncate::LegacySpilloverOwnership, ToolError> {
294 if session_id.trim().is_empty() {
295 return Err(ToolError::permission_denied(
296 "legacy evidence has no verifiable session owner",
297 ));
298 }
299 let ownership = match crate::tools::truncate::read_legacy_spillover_ownership(path) {
300 Ok(ownership) => ownership,
301 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
302 return Err(ToolError::permission_denied(
303 "legacy evidence has no verifiable session owner",
304 ));
305 }
306 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
307 return Err(ToolError::permission_denied(
308 "legacy evidence ownership proof is invalid",
309 ));
310 }
311 Err(_) => {
312 return Err(ToolError::execution_failed(
313 "legacy evidence ownership metadata is corrupt",
314 ));
315 }
316 };
317 if ownership.origin_session != session_id {
318 return Err(ToolError::permission_denied(
319 "legacy evidence belongs to another session",
320 ));
321 }
322 Ok(ownership)
323 }
324
325 /// Resolve a tool-result ref without weakening its ownership boundary.
326 ///
327 /// Current-session artifacts always win over same-named global compatibility
328 /// files. A legacy call-id, SHA, relative path, or absolute path can resolve,
329 /// but the caller must validate its ownership sidecar before reading bytes.
330 fn resolve_spillover_reference(
331 reference: &str,
332 session_id: &str,
333 ) -> Result<ResolvedSpilloverReference, ToolError> {
334 let root = crate::tools::truncate::spillover_root()
335 .ok_or_else(|| ToolError::execution_failed("retained evidence storage is unavailable"))?;
336 let root_canonical = root.canonicalize().ok();
337
338 // Resolve the session's `artifacts/` directory.
339 // `session_artifact_absolute_path(sid, p)` returns
340 // `~/.codewhale/sessions/<sid>/<p>` — so passing the literal
341 // `ARTIFACTS_DIR_NAME` ("artifacts") gets us the real artifacts
342 // root. An earlier draft passed `Path::new(".")` and took
343 // `.parent()`, which landed one directory too high (`<sid>` instead
344 // of `<sid>/artifacts`) and silently broke every bare `art_<id>`
345 // ref — only the legacy-spillover fallback survived. The test
346 // `resolves_art_prefix_to_legacy_spillover_id` masked it because
347 // it ONLY wrote a legacy spillover file. The new test
348 // `resolves_art_prefix_via_session_artifacts` exercises the real
349 // path.
350 let session_artifacts_root = if !session_id.is_empty() {
351 crate::artifacts::session_artifact_absolute_path(
352 session_id,
353 std::path::Path::new(crate::artifacts::ARTIFACTS_DIR_NAME),
354 )
355 } else {
356 None
357 };
358 let session_artifacts_root_canonical = session_artifacts_root
359 .as_ref()
360 .and_then(|p| p.canonicalize().ok());
361
362 let trimmed = reference.trim();
363 let stripped = trimmed
364 .strip_prefix("tool_result:")
365 .unwrap_or(trimmed)
366 .trim();
367
368 let mut tried = 0_usize;
369 let try_path = |candidate: PathBuf, tried: &mut usize| -> Option<ResolvedSpilloverReference> {
370 *tried = (*tried).saturating_add(1);
371
372 // Reject symlinks at the leaf BEFORE canonicalizing so an
373 // attacker who can write under `<sid>/artifacts/` cannot
374 // plant a symlink to `/etc/passwd` and read it back through
375 // `retrieve_tool_result`. canonicalize() would happily
376 // follow such a link and then pass the `starts_with(root)`
377 // check because of the resolved-then-compare order. Both session and
378 // compatibility roots reject leaf symlinks. Legacy
379 // compatibility files also need a separate ownership sidecar below.
380 if let Ok(meta) = std::fs::symlink_metadata(&candidate)
381 && meta.file_type().is_symlink()
382 {
383 return None;
384 }
385
386 let canonical = candidate.canonicalize().ok()?;
387 if !canonical.is_file() {
388 return None;
389 }
390 let inside_legacy = root_canonical
391 .as_ref()
392 .is_some_and(|root| canonical.starts_with(root));
393 let inside_session = session_artifacts_root_canonical
394 .as_ref()
395 .is_some_and(|root| canonical.starts_with(root));
396 if inside_session {
397 Some(ResolvedSpilloverReference {
398 path: canonical,
399 kind: ResolvedReferenceKind::ActiveSession,
400 })
401 } else if inside_legacy {
402 Some(ResolvedSpilloverReference {
403 path: canonical,
404 kind: ResolvedReferenceKind::LegacyGlobal,
405 })
406 } else {
407 None
408 }
409 };
410
411 // Form 1/3: absolute path. Validate it lives under one of the allowed roots.
412 let raw_path = PathBuf::from(stripped);
413 if raw_path.is_absolute() {
414 if let Some(found) = try_path(raw_path, &mut tried) {
415 return Ok(found);
416 }
417 return Err(ToolError::permission_denied(
418 "evidence path is not owned by the active session",
419 ));
420 }
421
422 // Session artifact paths take priority over legacy-global lookups.
423 let looks_like_path = stripped.ends_with(".txt")
424 || stripped.contains('/')
425 || (std::path::MAIN_SEPARATOR != '/' && stripped.contains(std::path::MAIN_SEPARATOR));
426 if looks_like_path {
427 if let Some(sa_root) = session_artifacts_root.as_ref() {
428 let rel = stripped.strip_prefix("artifacts/").unwrap_or(stripped);
429 if let Some(found) = try_path(sa_root.join(rel), &mut tried) {
430 return Ok(found);
431 }
432 }
433 if let Some(found) = try_path(root.join(stripped), &mut tried) {
434 return Ok(found);
435 }
436 return Err(not_found(reference, tried));
437 }
438
439 if let Some(sa_root) = session_artifacts_root.as_ref() {
440 let file_name = if stripped.starts_with("art_") {
441 format!("{stripped}.txt")
442 } else {
443 format!("art_{stripped}.txt")
444 };
445 if let Some(found) = try_path(sa_root.join(file_name), &mut tried) {
446 return Ok(found);
447 }
448 }
449
450 // `sha:<hex>` or bare 64-hex resolves only as legacy-global evidence and
451 // therefore still requires an ownership sidecar in the caller.
452 let sha_candidate = stripped
453 .strip_prefix("sha:")
454 .or_else(|| stripped.strip_prefix("sha_"))
455 .unwrap_or(stripped)
456 .trim();
457 if crate::tools::truncate::is_valid_sha256(&sha_candidate.to_ascii_lowercase())
458 && let Some(p) = crate::tools::truncate::sha_spillover_path(sha_candidate)
459 && let Some(found) = try_path(p, &mut tried)
460 {
461 return Ok(found);
462 }
463
464 // Compatibility lookup: `art_<id>` may name the historical `<id>.txt`.
465 if let Some(stripped_art) = stripped.strip_prefix("art_")
466 && let Some(p) = crate::tools::truncate::spillover_path(stripped_art)
467 && let Some(found) = try_path(p, &mut tried)
468 {
469 return Ok(found);
470 }
471
472 if let Some(path) = crate::tools::truncate::spillover_path(stripped)
473 && let Some(found) = try_path(path, &mut tried)
474 {
475 return Ok(found);
476 }
477
478 Err(not_found(reference, tried))
479 }
480
481 /// Missing evidence is distinct without revealing storage roots or generated
482 /// session identifiers to the model.
483 fn not_found(reference: &str, tried: usize) -> ToolError {
484 ToolError::execution_failed(format!(
485 "retained evidence `{reference}` was not found for the active session \
486 ({tried} bounded candidate forms checked). Use the session-owned \
487 `art_<id>` handle from the original receipt."
488 ))
489 }
490
491 fn build_summary_payload(
492 reference: &str,
493 content: &str,
494 lines: &[&str],
495 input: &Value,
496 max_bytes: usize,
497 ) -> Result<Value, ToolError> {
498 let max_matches = clamp_u64(
499 optional_u64(input, "max_matches", DEFAULT_MAX_MATCHES as u64)?,
500 1,
501 HARD_MAX_MATCHES,
502 );
503 let signal_lines = collect_signal_lines(lines, max_matches);
504 let head_count = DEFAULT_LINE_COUNT.min(lines.len());
505 let tail_count = DEFAULT_LINE_COUNT.min(lines.len());
506 let head = render_numbered_lines(
507 lines
508 .iter()
509 .take(head_count)
510 .enumerate()
511 .map(|(idx, line)| (idx + 1, *line)),
512 max_bytes / 2,
513 );
514 let tail_start = lines.len().saturating_sub(tail_count);
515 let tail = render_numbered_lines(
516 lines
517 .iter()
518 .enumerate()
519 .skip(tail_start)
520 .map(|(idx, line)| (idx + 1, *line)),
521 max_bytes / 2,
522 );
523
524 Ok(json!({
525 "ref": reference,
526 "mode": "summary",
527 "total_bytes": content.len(),
528 "total_lines": lines.len(),
529 "non_empty_lines": lines.iter().filter(|line| !line.trim().is_empty()).count(),
530 "signal_lines": signal_lines,
531 "head": head,
532 "tail": tail,
533 "hint": "Use mode=head, tail, lines, or query to retrieve a narrower slice."
534 }))
535 }
536
537 fn build_head_tail_payload(
538 reference: &str,
539 mode: &str,
540 lines: &[&str],
541 input: &Value,
542 max_bytes: usize,
543 ) -> Result<Value, ToolError> {
544 let count = clamp_u64(
545 optional_u64(input, "line_count", DEFAULT_LINE_COUNT as u64)?,
546 1,
547 HARD_LINE_COUNT,
548 );
549 let selected: Vec<(usize, &str)> = if mode == "head" {
550 lines
551 .iter()
552 .take(count)
553 .enumerate()
554 .map(|(idx, line)| (idx + 1, *line))
555 .collect()
556 } else {
557 let start = lines.len().saturating_sub(count);
558 lines
559 .iter()
560 .enumerate()
561 .skip(start)
562 .map(|(idx, line)| (idx + 1, *line))
563 .collect()
564 };
565 let excerpt = render_numbered_lines(selected.iter().copied(), max_bytes);
566
567 Ok(json!({
568 "ref": reference,
569 "mode": mode,
570 "total_lines": lines.len(),
571 "line_count": count,
572 "excerpt": excerpt,
573 }))
574 }
575
576 fn build_lines_payload(
577 reference: &str,
578 lines: &[&str],
579 input: &Value,
580 max_bytes: usize,
581 ) -> Result<Value, ToolError> {
582 let (start, end) = parse_line_selector(input)?;
583 let excerpt = if start > lines.len() {
584 String::new()
585 } else {
586 let end = end.min(lines.len());
587 render_numbered_lines(
588 lines
589 .iter()
590 .enumerate()
591 .skip(start - 1)
592 .take(end.saturating_sub(start) + 1)
593 .map(|(idx, line)| (idx + 1, *line)),
594 max_bytes,
595 )
596 };
597
598 Ok(json!({
599 "ref": reference,
600 "mode": "lines",
601 "total_lines": lines.len(),
602 "start_line": start,
603 "end_line": end.min(lines.len()),
604 "excerpt": excerpt,
605 }))
606 }
607
608 fn build_query_payload(
609 reference: &str,
610 lines: &[&str],
611 input: &Value,
612 max_bytes: usize,
613 ) -> Result<Value, ToolError> {
614 let query = optional_str(input, "query")?
615 .map(str::trim)
616 .filter(|q| !q.is_empty())
617 .ok_or_else(|| ToolError::invalid_input("query is required when mode=query"))?;
618 let query_lower = query.to_lowercase();
619 let max_matches = clamp_u64(
620 optional_u64(input, "max_matches", DEFAULT_MAX_MATCHES as u64)?,
621 1,
622 HARD_MAX_MATCHES,
623 );
624 let context_lines = clamp_u64(
625 optional_u64(input, "context_lines", DEFAULT_CONTEXT_LINES as u64)?,
626 0,
627 HARD_CONTEXT_LINES,
628 );
629
630 let mut matched_lines = 0usize;
631 let mut results = Vec::new();
632 for (idx, line) in lines.iter().enumerate() {
633 if !line.to_lowercase().contains(&query_lower) {
634 continue;
635 }
636 matched_lines += 1;
637 if results.len() >= max_matches {
638 continue;
639 }
640 let start = idx.saturating_sub(context_lines);
641 let end = (idx + context_lines).min(lines.len().saturating_sub(1));
642 let excerpt = render_numbered_lines(
643 lines
644 .iter()
645 .enumerate()
646 .skip(start)
647 .take(end.saturating_sub(start) + 1)
648 .map(|(line_idx, text)| (line_idx + 1, *text)),
649 max_bytes / max_matches.max(1),
650 );
651 results.push(json!({
652 "line": idx + 1,
653 "excerpt": excerpt,
654 }));
655 }
656
657 Ok(json!({
658 "ref": reference,
659 "mode": "query",
660 "query": query,
661 "total_lines": lines.len(),
662 "matched_lines": matched_lines,
663 "matches_returned": results.len(),
664 "results": results,
665 }))
666 }
667
668 fn parse_line_selector(input: &Value) -> Result<(usize, usize), ToolError> {
669 let explicit_start = input.get("start_line").and_then(Value::as_u64);
670 let explicit_end = input.get("end_line").and_then(Value::as_u64);
671 if explicit_start.is_some() || explicit_end.is_some() {
672 let start = explicit_start.ok_or_else(|| {
673 ToolError::invalid_input("start_line is required when end_line is supplied")
674 })?;
675 let end = explicit_end.unwrap_or(start);
676 return validate_line_range(start as usize, end as usize);
677 }
678
679 let spec = optional_str(input, "lines")?
680 .map(str::trim)
681 .filter(|s| !s.is_empty())
682 .ok_or_else(|| {
683 ToolError::invalid_input(
684 "mode=lines requires `lines` (for example \"10-40\") or start_line/end_line",
685 )
686 })?;
687
688 if let Some((start, end)) = spec.split_once('-') {
689 let start = parse_positive_line(start.trim(), "lines start")?;
690 let end = parse_positive_line(end.trim(), "lines end")?;
691 validate_line_range(start, end)
692 } else {
693 let line = parse_positive_line(spec, "lines")?;
694 validate_line_range(line, line)
695 }
696 }
697
698 fn validate_line_range(start: usize, end: usize) -> Result<(usize, usize), ToolError> {
699 if start == 0 || end == 0 {
700 return Err(ToolError::invalid_input("line numbers are 1-based"));
701 }
702 if end < start {
703 return Err(ToolError::invalid_input(
704 "end_line must be greater than or equal to start_line",
705 ));
706 }
707 Ok((start, end))
708 }
709
710 fn parse_positive_line(raw: &str, field: &str) -> Result<usize, ToolError> {
711 raw.parse::<usize>().map_err(|_| {
712 ToolError::invalid_input(format!("{field} must be a positive integer line number"))
713 })
714 }
715
716 fn collect_signal_lines(lines: &[&str], max_matches: usize) -> Vec<Value> {
717 let mut out = Vec::new();
718 for (idx, line) in lines.iter().enumerate() {
719 if !is_signal_line(line) {
720 continue;
721 }
722 out.push(json!({
723 "line": idx + 1,
724 "text": truncate_line(line.trim(), 300),
725 }));
726 if out.len() >= max_matches {
727 break;
728 }
729 }
730 out
731 }
732
733 fn is_signal_line(line: &str) -> bool {
734 let lower = line.to_lowercase();
735 [
736 "error",
737 "failed",
738 "failure",
739 "panic",
740 "warning",
741 "exception",
742 "traceback",
743 "assertion",
744 "exit code",
745 "test result",
746 "thread '",
747 ]
748 .iter()
749 .any(|needle| lower.contains(needle))
750 }
751
752 fn render_numbered_lines<'a>(
753 lines: impl IntoIterator<Item = (usize, &'a str)>,
754 max_bytes: usize,
755 ) -> String {
756 let mut rendered = String::new();
757 for (line_no, line) in lines {
758 rendered.push_str(&format!("{line_no}: {line}\n"));
759 if rendered.len() > max_bytes {
760 break;
761 }
762 }
763 truncate_text(&rendered, max_bytes)
764 }
765
766 fn truncate_text(text: &str, max_bytes: usize) -> String {
767 if text.len() <= max_bytes {
768 return text.trim_end_matches('\n').to_string();
769 }
770 let note = "\n[truncated to max_bytes]";
771 let budget = max_bytes.saturating_sub(note.len()).max(1);
772 let cut = (0..=budget)
773 .rev()
774 .find(|idx| text.is_char_boundary(*idx))
775 .unwrap_or(0);
776 format!("{}{}", text[..cut].trim_end_matches('\n'), note)
777 }
778
779 fn truncate_line(line: &str, max_chars: usize) -> String {
780 if line.chars().count() <= max_chars {
781 return line.to_string();
782 }
783 let mut out: String = line.chars().take(max_chars.saturating_sub(3)).collect();
784 out.push_str("...");
785 out
786 }
787
788 fn clamp_u64(value: u64, min: usize, max: usize) -> usize {
789 (value as usize).clamp(min, max)
790 }
791
792 #[cfg(test)]
793 mod tests {
794 use super::*;
795 use std::fs;
796 use std::sync::MutexGuard;
797 use tempfile::tempdir;
798
799 struct SpilloverRootGuard {
800 prior: Option<PathBuf>,
801 }
802
803 impl Drop for SpilloverRootGuard {
804 fn drop(&mut self) {
805 crate::tools::truncate::set_test_spillover_root(self.prior.take());
806 }
807 }
808
809 fn set_spillover_root(path: PathBuf) -> SpilloverRootGuard {
810 let prior = crate::tools::truncate::set_test_spillover_root(Some(path));
811 SpilloverRootGuard { prior }
812 }
813
814 fn context() -> ToolContext {
815 let tmp = tempdir().unwrap();
816 ToolContext::new(tmp.path())
817 }
818
819 fn test_lock() -> MutexGuard<'static, ()> {
820 crate::tools::truncate::TEST_SPILLOVER_GUARD
821 .lock()
822 .unwrap_or_else(|err| err.into_inner())
823 }
824
825 fn execute_tool(input: Value) -> Result<ToolResult, ToolError> {
826 let runtime = tokio::runtime::Builder::new_current_thread()
827 .enable_all()
828 .build()
829 .unwrap();
830 runtime.block_on(RetrieveToolResultTool.execute(input, &context()))
831 }
832
833 fn execute_tool_in_session(input: Value, session_id: &str) -> Result<ToolResult, ToolError> {
834 let runtime = tokio::runtime::Builder::new_current_thread()
835 .enable_all()
836 .build()
837 .unwrap();
838 let mut context = context();
839 context.state_namespace = session_id.to_string();
840 runtime.block_on(RetrieveToolResultTool.execute(input, &context))
841 }
842
843 fn publish_test_evidence(
844 session_id: &str,
845 handle: &str,
846 bytes: &[u8],
847 expired: bool,
848 ) -> crate::tools::large_output_router::EvidenceArtifact {
849 let relative = crate::artifacts::session_artifact_relative_path(handle);
850 crate::artifacts::write_session_relative_immutable(session_id, &relative, bytes).unwrap();
851 let now = crate::tools::large_output_router::unix_millis_now();
852 let artifact = crate::tools::large_output_router::EvidenceArtifact {
853 handle: handle.to_string(),
854 digest: crate::hashing::sha256_hex(bytes),
855 size_bytes: bytes.len() as u64,
856 content_type: "application/octet-stream".to_string(),
857 tool_name: "exec_shell".to_string(),
858 call_id: handle.trim_start_matches("art_").to_string(),
859 origin_session: session_id.to_string(),
860 generation: 1,
861 redacted: false,
862 encoding: "binary".to_string(),
863 retention_state: if expired {
864 crate::tools::large_output_router::EvidenceRetentionState::Expired
865 } else {
866 crate::tools::large_output_router::EvidenceRetentionState::Live
867 },
868 created_at_unix_ms: now,
869 retain_until_unix_ms: now.saturating_add(60_000),
870 storage_path: relative,
871 };
872 crate::tools::large_output_router::publish_evidence_metadata(session_id, &artifact)
873 .unwrap();
874 artifact
875 }
876
877 fn write_owned_legacy(id: &str, content: &str, session_id: &str) -> PathBuf {
878 let path = crate::tools::truncate::write_spillover(id, content).unwrap();
879 crate::tools::truncate::publish_legacy_spillover_ownership(
880 &path,
881 session_id,
882 content.as_bytes(),
883 )
884 .unwrap();
885 path
886 }
887
888 fn write_owned_sha(content: &str, session_id: &str) -> (String, PathBuf) {
889 let sha = crate::hashing::sha256_hex(content.as_bytes());
890 let path = crate::tools::truncate::write_sha_spillover(&sha, content).unwrap();
891 crate::tools::truncate::publish_legacy_spillover_ownership(
892 &path,
893 session_id,
894 content.as_bytes(),
895 )
896 .unwrap();
897 (sha, path)
898 }
899
900 #[test]
901 fn summary_reads_spillover_by_tool_call_id() {
902 let _lock = test_lock();
903 let tmp = tempdir().unwrap();
904 let _guard = set_spillover_root(tmp.path().join("tool_outputs"));
905 let session_id = "session-legacy-summary";
906 write_owned_legacy(
907 "call-abc",
908 "checking crate\nerror[E0425]: missing value\nwarning: unused import\nfinished",
909 session_id,
910 );
911
912 let result = execute_tool_in_session(json!({"ref": "call-abc"}), session_id).unwrap();
913
914 assert!(result.success);
915 let body: Value = serde_json::from_str(&result.content).unwrap();
916 assert_eq!(body["mode"], "summary");
917 assert!(body["signal_lines"].to_string().contains("error[E0425]"));
918 assert!(body["signal_lines"].to_string().contains("warning"));
919 }
920
921 #[test]
922 fn adaptive_evidence_binary_bytes_are_exact_and_bounded() {
923 let _spill = test_lock();
924 let _artifact = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
925 .lock()
926 .unwrap_or_else(|err| err.into_inner());
927 let tmp = tempdir().unwrap();
928 let _root = set_spillover_root(tmp.path().join("tool_outputs"));
929 let prior =
930 crate::artifacts::set_test_artifact_sessions_root(Some(tmp.path().join("sessions")));
931 struct Restore(Option<PathBuf>);
932 impl Drop for Restore {
933 fn drop(&mut self) {
934 crate::artifacts::set_test_artifact_sessions_root(self.0.take());
935 }
936 }
937 let _restore = Restore(prior);
938 let bytes = b"\0\xffbinary\nDEEP_SENTINEL\x80tail";
939 publish_test_evidence("session-a", "art_call-binary", bytes, false);
940
941 let result = execute_tool_in_session(
942 json!({"ref": "art_call-binary", "mode": "bytes", "offset": 0, "length": 1024}),
943 "session-a",
944 )
945 .unwrap();
946 let body: Value = serde_json::from_str(&result.content).unwrap();
947 use base64::Engine as _;
948 let decoded = base64::engine::general_purpose::STANDARD
949 .decode(body["data"].as_str().unwrap())
950 .unwrap();
951 assert_eq!(decoded, bytes);
952 assert_eq!(body["total_bytes"], bytes.len());
953 }
954
955 #[test]
956 fn adaptive_evidence_retrieves_after_restart_without_memory_state() {
957 let _spill = test_lock();
958 let _artifact = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
959 .lock()
960 .unwrap_or_else(|err| err.into_inner());
961 let tmp = tempdir().unwrap();
962 let _root = set_spillover_root(tmp.path().join("tool_outputs"));
963 let prior =
964 crate::artifacts::set_test_artifact_sessions_root(Some(tmp.path().join("sessions")));
965 struct Restore(Option<PathBuf>);
966 impl Drop for Restore {
967 fn drop(&mut self) {
968 crate::artifacts::set_test_artifact_sessions_root(self.0.take());
969 }
970 }
971 let _restore = Restore(prior);
972
973 let bytes = b"restart-proof\nDEEP_RESTART_SENTINEL\nend";
974 publish_test_evidence("session-restart", "art_call-restart", bytes, false);
975
976 // Construct two independent contexts to model process teardown and
977 // resume. Retrieval must depend only on the sealed session artifact
978 // and metadata, never an in-memory routing table from publication.
979 let first = execute_tool_in_session(
980 json!({"ref": "art_call-restart", "mode": "metadata"}),
981 "session-restart",
982 )
983 .unwrap();
984 drop(first);
985 let resumed = execute_tool_in_session(
986 json!({"ref": "art_call-restart", "mode": "bytes", "length": 4096}),
987 "session-restart",
988 )
989 .unwrap();
990 let body: Value = serde_json::from_str(&resumed.content).unwrap();
991 use base64::Engine as _;
992 let decoded = base64::engine::general_purpose::STANDARD
993 .decode(body["data"].as_str().unwrap())
994 .unwrap();
995 assert_eq!(decoded, bytes);
996 assert_eq!(body["total_bytes"], bytes.len());
997 }
998
999 #[test]
1000 fn adaptive_evidence_distinguishes_corrupt_expired_and_generation_mismatch() {
1001 let _spill = test_lock();
1002 let _artifact = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
1003 .lock()
1004 .unwrap_or_else(|err| err.into_inner());
1005 let tmp = tempdir().unwrap();
1006 let _root = set_spillover_root(tmp.path().join("tool_outputs"));
1007 let prior =
1008 crate::artifacts::set_test_artifact_sessions_root(Some(tmp.path().join("sessions")));
1009 struct Restore(Option<PathBuf>);
1010 impl Drop for Restore {
1011 fn drop(&mut self) {
1012 crate::artifacts::set_test_artifact_sessions_root(self.0.take());
1013 }
1014 }
1015 let _restore = Restore(prior);
1016
1017 publish_test_evidence("session-a", "art_call-expired", b"expired", true);
1018 let expired = execute_tool_in_session(json!({"ref": "art_call-expired"}), "session-a")
1019 .unwrap_err()
1020 .to_string();
1021 assert!(expired.contains("expired"), "{expired}");
1022
1023 let artifact = publish_test_evidence("session-a", "art_call-corrupt", b"original", false);
1024 let absolute =
1025 crate::artifacts::session_artifact_absolute_path("session-a", &artifact.storage_path)
1026 .unwrap();
1027 std::fs::write(absolute, b"changed").unwrap();
1028 let corrupt = execute_tool_in_session(json!({"ref": "art_call-corrupt"}), "session-a")
1029 .unwrap_err()
1030 .to_string();
1031 assert!(corrupt.contains("corrupt"), "{corrupt}");
1032
1033 publish_test_evidence("session-a", "art_call-generation", b"stable", false);
1034 let mismatch = execute_tool_in_session(
1035 json!({"ref": "art_call-generation", "generation": 2}),
1036 "session-a",
1037 )
1038 .unwrap_err()
1039 .to_string();
1040 assert!(mismatch.contains("generation"), "{mismatch}");
1041 }
1042
1043 #[test]
1044 fn query_returns_matching_line_with_context() {
1045 let _lock = test_lock();
1046 let tmp = tempdir().unwrap();
1047 let _guard = set_spillover_root(tmp.path().join("tool_outputs"));
1048 let session_id = "session-legacy-query";
1049 write_owned_legacy(
1050 "call-query",
1051 "one\ntwo before\nneedle here\nafter\nlast",
1052 session_id,
1053 );
1054
1055 let result = execute_tool_in_session(
1056 json!({
1057 "ref": "tool_result:call-query",
1058 "mode": "query",
1059 "query": "needle",
1060 "context_lines": 1
1061 }),
1062 session_id,
1063 )
1064 .unwrap();
1065
1066 let body: Value = serde_json::from_str(&result.content).unwrap();
1067 assert_eq!(body["matched_lines"], 1);
1068 let rendered = body["results"].to_string();
1069 assert!(rendered.contains("2: two before"));
1070 assert!(rendered.contains("3: needle here"));
1071 assert!(rendered.contains("4: after"));
1072 }
1073
1074 #[test]
1075 fn lines_mode_accepts_filename_inside_spillover_root() {
1076 let _lock = test_lock();
1077 let tmp = tempdir().unwrap();
1078 let root = tmp.path().join("tool_outputs");
1079 let _guard = set_spillover_root(root.clone());
1080 let session_id = "session-legacy-lines";
1081 write_owned_legacy("call-lines", "a\nb\nc\nd", session_id);
1082
1083 let result = execute_tool_in_session(
1084 json!({
1085 "ref": "call-lines.txt",
1086 "mode": "lines",
1087 "lines": "2-3"
1088 }),
1089 session_id,
1090 )
1091 .unwrap();
1092
1093 let body: Value = serde_json::from_str(&result.content).unwrap();
1094 let excerpt = body["excerpt"].as_str().unwrap();
1095 assert!(excerpt.contains("2: b"));
1096 assert!(excerpt.contains("3: c"));
1097 assert!(!excerpt.contains("1: a"));
1098 assert!(!excerpt.contains("4: d"));
1099 }
1100
1101 #[test]
1102 fn rejects_path_outside_spillover_root() {
1103 let _lock = test_lock();
1104 let tmp = tempdir().unwrap();
1105 let root = tmp.path().join("tool_outputs");
1106 fs::create_dir_all(&root).unwrap();
1107 let outside = tmp.path().join("outside.txt");
1108 fs::write(&outside, "secret").unwrap();
1109 let _guard = set_spillover_root(root);
1110
1111 let err = execute_tool(json!({"ref": outside.display().to_string()})).unwrap_err();
1112
1113 // Unauthorized is distinct but non-leaking: no outside path detail is
1114 // echoed beyond the caller-supplied ref.
1115 let msg = err.to_string();
1116 assert!(
1117 msg.contains("authorize") && msg.contains("active session"),
1118 "expected non-leaking authorization diagnostic, got: {msg}"
1119 );
1120 }
1121
1122 #[test]
1123 fn resolves_sha_reference_from_wire_dedup() {
1124 // A SHA-keyed lookup — emulates what happens when the model
1125 // sees a `<TOOL_RESULT_REF sha="..." />` block and passes the
1126 // SHA to retrieve_tool_result.
1127 let _lock = test_lock();
1128 let tmp = tempdir().unwrap();
1129 let _guard = set_spillover_root(tmp.path().join("tool_outputs"));
1130 let body = "checking crate ... error[E0425]: cannot find value\n".repeat(80);
1131 let session_id = "session-legacy-sha";
1132 let (sha, _) = write_owned_sha(&body, session_id);
1133
1134 // Form: `sha:<hex>`
1135 let result =
1136 execute_tool_in_session(json!({"ref": format!("sha:{sha}")}), session_id).unwrap();
1137 assert!(result.success, "sha:<hex> form should resolve");
1138
1139 // Form: bare 64-hex
1140 let result = execute_tool_in_session(json!({"ref": &sha}), session_id).unwrap();
1141 assert!(result.success, "bare 64-hex form should resolve");
1142 }
1143
1144 #[test]
1145 fn resolves_art_prefix_to_legacy_spillover_id() {
1146 // The model commonly sees `id: art_call_xyz` in artifact
1147 // ref blocks. retrieve_tool_result should strip the `art_`
1148 // prefix and find the legacy `<id>.txt` file if no
1149 // session-artifact equivalent exists.
1150 let _lock = test_lock();
1151 let tmp = tempdir().unwrap();
1152 let _guard = set_spillover_root(tmp.path().join("tool_outputs"));
1153 let session_id = "session-legacy-art-prefix";
1154 write_owned_legacy("call_xyz", "line1\nline2\nline3", session_id);
1155
1156 let result = execute_tool_in_session(json!({"ref": "art_call_xyz"}), session_id).unwrap();
1157 assert!(result.success, "art_ prefix should resolve to legacy id");
1158 }
1159
1160 #[test]
1161 fn unowned_and_foreign_legacy_spillovers_fail_closed_without_leaking_content() {
1162 let _lock = test_lock();
1163 let tmp = tempdir().unwrap();
1164 let _guard = set_spillover_root(tmp.path().join("tool_outputs"));
1165 let sentinel = "SESSION_A_PRIVATE_SENTINEL";
1166
1167 crate::tools::truncate::write_spillover("call-unowned", sentinel).unwrap();
1168 let unowned =
1169 execute_tool_in_session(json!({"ref": "call-unowned", "mode": "bytes"}), "session-b")
1170 .unwrap_err()
1171 .to_string();
1172 assert!(unowned.contains("no verifiable session owner"), "{unowned}");
1173 assert!(!unowned.contains(sentinel), "{unowned}");
1174 assert!(
1175 !unowned.contains(tmp.path().to_string_lossy().as_ref()),
1176 "{unowned}"
1177 );
1178
1179 write_owned_legacy("call-foreign", sentinel, "session-a");
1180 let foreign =
1181 execute_tool_in_session(json!({"ref": "call-foreign", "mode": "bytes"}), "session-b")
1182 .unwrap_err()
1183 .to_string();
1184 assert!(foreign.contains("another session"), "{foreign}");
1185 assert!(!foreign.contains(sentinel), "{foreign}");
1186 assert!(
1187 !foreign.contains(tmp.path().to_string_lossy().as_ref()),
1188 "{foreign}"
1189 );
1190 }
1191
1192 #[test]
1193 fn owned_legacy_digest_mismatch_is_distinct_from_unauthorized() {
1194 let _lock = test_lock();
1195 let tmp = tempdir().unwrap();
1196 let _guard = set_spillover_root(tmp.path().join("tool_outputs"));
1197 let path = write_owned_legacy("call-corrupt-owned", "original", "session-a");
1198 std::fs::write(path, "changed").unwrap();
1199
1200 let error = execute_tool_in_session(json!({"ref": "call-corrupt-owned"}), "session-a")
1201 .unwrap_err()
1202 .to_string();
1203 assert!(error.contains("content is corrupt"), "{error}");
1204 assert!(!error.contains("another session"), "{error}");
1205 }
1206
1207 #[test]
1208 fn not_found_error_lists_tried_candidates_and_accepted_forms() {
1209 let _lock = test_lock();
1210 let tmp = tempdir().unwrap();
1211 let _guard = set_spillover_root(tmp.path().join("tool_outputs"));
1212 fs::create_dir_all(tmp.path().join("tool_outputs")).unwrap();
1213
1214 let err = execute_tool(json!({"ref": "definitely_missing_id"})).unwrap_err();
1215 let msg = err.to_string();
1216 assert!(msg.contains("not found"), "got: {msg}");
1217 assert!(msg.contains("active session"), "got: {msg}");
1218 assert!(msg.contains("art_<id>"), "got: {msg}");
1219 assert!(!msg.contains("tool_outputs"), "storage root leaked: {msg}");
1220 assert!(
1221 !msg.contains(tmp.path().to_string_lossy().as_ref()),
1222 "path leaked: {msg}"
1223 );
1224 }
1225
1226 #[test]
1227 fn resolves_art_prefix_via_session_artifacts() {
1228 let _lock = test_lock();
1229 let tmp = tempdir().unwrap();
1230 let _spill_guard = set_spillover_root(tmp.path().join("tool_outputs"));
1231 let _art_guard = {
1232 let prior = crate::artifacts::set_test_artifact_sessions_root(Some(
1233 tmp.path().join("sessions"),
1234 ));
1235 scopeguard_for_test(prior)
1236 };
1237 let session_id = "session-abc";
1238 let body = "this is the canonical session artifact body, not a legacy file";
1239 crate::artifacts::write_session_artifact(session_id, "art_call_real", body).unwrap();
1240
1241 let runtime = tokio::runtime::Builder::new_current_thread()
1242 .enable_all()
1243 .build()
1244 .unwrap();
1245 let workspace_tmp = tempdir().unwrap();
1246 let ctx = ToolContext::new(workspace_tmp.path()).with_state_namespace(session_id);
1247 let result = runtime
1248 .block_on(RetrieveToolResultTool.execute(json!({"ref": "art_call_real"}), &ctx))
1249 .expect("art_<id> should resolve via session artifacts");
1250 assert!(result.success);
1251 let payload: Value = serde_json::from_str(&result.content).unwrap();
1252 assert!(
1253 payload
1254 .to_string()
1255 .contains("canonical session artifact body"),
1256 "summary should pull from session artifact, got: {payload}"
1257 );
1258 }
1259
1260 #[cfg(unix)]
1261 #[test]
1262 fn rejects_symlink_inside_session_artifacts() {
1263 let _lock = test_lock();
1264 let tmp = tempdir().unwrap();
1265 let _spill_guard = set_spillover_root(tmp.path().join("tool_outputs"));
1266 let _art_guard = {
1267 let prior = crate::artifacts::set_test_artifact_sessions_root(Some(
1268 tmp.path().join("sessions"),
1269 ));
1270 scopeguard_for_test(prior)
1271 };
1272 let session_id = "session-xyz";
1273 // Plant a sensitive file outside the artifact dir.
1274 let secret = tmp.path().join("secret.txt");
1275 fs::write(&secret, "do not leak").unwrap();
1276 // Create the artifact dir, then drop a symlink inside it
1277 // pointing at the secret.
1278 let art_dir = tmp
1279 .path()
1280 .join("sessions")
1281 .join(session_id)
1282 .join("artifacts");
1283 fs::create_dir_all(&art_dir).unwrap();
1284 std::os::unix::fs::symlink(&secret, art_dir.join("art_evil.txt")).unwrap();
1285
1286 let runtime = tokio::runtime::Builder::new_current_thread()
1287 .enable_all()
1288 .build()
1289 .unwrap();
1290 let workspace_tmp = tempdir().unwrap();
1291 let ctx = ToolContext::new(workspace_tmp.path()).with_state_namespace(session_id);
1292 let result =
1293 runtime.block_on(RetrieveToolResultTool.execute(json!({"ref": "art_evil"}), &ctx));
1294 let err = result.expect_err("symlink artifact must not resolve");
1295 assert!(
1296 err.to_string().contains("not found"),
1297 "expected `not found`, got: {err}"
1298 );
1299 }
1300
1301 struct ArtifactRootGuard {
1302 prior: Option<PathBuf>,
1303 }
1304 impl Drop for ArtifactRootGuard {
1305 fn drop(&mut self) {
1306 crate::artifacts::set_test_artifact_sessions_root(self.prior.take());
1307 }
1308 }
1309 fn scopeguard_for_test(prior: Option<PathBuf>) -> ArtifactRootGuard {
1310 ArtifactRootGuard { prior }
1311 }
1312 }
1313
1313 lines RUST