Skip to main content

lean_ctx_server/tools/registered/
ctx_git_read.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use rmcp::ErrorData;
6use rmcp::model::Tool;
7use serde_json::{Map, Value, json};
8
9use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_int, get_str};
10use lean_ctx::core::git::{clone, repo_url, run_git};
11use lean_ctx::core::protocol::append_savings;
12use lean_ctx::core::tokens::count_tokens;
13use lean_ctx::tool_defs::tool_def;
14
15const DEFAULT_MAX_TOKENS: usize = 6000;
16const MAX_TREE_LINES: usize = 400;
17const MAX_GREP_LINES: usize = 200;
18/// Tool identity recorded with the admission of remote repository content.
19const TOOL: &str = "ctx_git_read";
20
21/// `ctx_git_read` — read a remote repository via a cached shallow clone instead
22/// of scraping its web page. Modes: overview / tree / read / grep.
23pub struct CtxGitReadTool;
24
25impl McpTool for CtxGitReadTool {
26    fn name(&self) -> &'static str {
27        "ctx_git_read"
28    }
29
30    fn tool_def(&self) -> Tool {
31        tool_def(
32            "ctx_git_read",
33            "Read remote git repos via cached shallow clone (not HTML scraping).\n\
34             modes: overview (tree + README) | tree (file list) | read (file content) | grep (search).\n\
35             Accepts repo URLs and GitHub/GitLab blob/tree links (ref+path auto-detected).\n\
36             https-only, SSRF-guarded. Prefer over ctx_url_read for whole-repo access.",
37            json!({
38                "type": "object",
39                "properties": {
40                    "url": { "type": "string", "description": "https repo URL (blob/tree links auto-detect ref+path)" },
41                    "mode": {
42                        "type": "string",
43                        "enum": ["overview", "tree", "read", "grep"],
44                        "description": "overview|tree|read|grep"
45                    },
46                    "path": { "type": "string", "description": "Path within repo" },
47                    "ref": { "type": "string", "description": "Branch/tag/commit (overrides URL ref)" },
48                    "query": { "type": "string", "description": "Search term for grep mode" },
49                    "max_tokens": { "type": "integer", "description": "Token budget (default: 6000)" },
50                    "timeout_secs": { "type": "integer", "description": "Timeout seconds (default: 90, max: 300)" }
51                },
52                "allOf": [
53                    {
54                        "if": { "properties": { "mode": { "const": "grep" } }, "required": ["mode"] },
55                        "then": { "required": ["mode", "query"] }
56                    }
57                ],
58                "required": ["url"]
59            }),
60        )
61    }
62
63    fn handle(
64        &self,
65        args: &Map<String, Value>,
66        _ctx: &ToolContext,
67    ) -> Result<ToolOutput, ErrorData> {
68        let url = get_str(args, "url")
69            .ok_or_else(|| ErrorData::invalid_params("url is required", None))?;
70
71        let mut repo = repo_url::parse(&url).ok_or_else(|| {
72            ErrorData::invalid_params(
73                "not a recognized https repo URL (expected https://<host>/<owner>/<repo>[/blob|tree/<ref>/<path>])",
74                None,
75            )
76        })?;
77        if let Some(r) = get_str(args, "ref") {
78            repo.git_ref = Some(r);
79        }
80
81        let path = get_str(args, "path").or_else(|| repo.subpath.clone());
82        let mode = get_str(args, "mode").unwrap_or_else(|| {
83            if path.is_some() {
84                "read".to_string()
85            } else {
86                "overview".to_string()
87            }
88        });
89        let max_tokens = get_int(args, "max_tokens")
90            .map_or(DEFAULT_MAX_TOKENS, |n| n.clamp(200, 50_000) as usize);
91        let timeout = Duration::from_secs(
92            get_int(args, "timeout_secs").map_or(clone::DEFAULT_CLONE_TIMEOUT_SECS, |n| {
93                n.clamp(5, 300) as u64
94            }),
95        );
96        let query = get_str(args, "query");
97
98        let result = tokio::task::block_in_place(|| {
99            let dir = clone::ensure_repo(&repo, timeout)?;
100            match mode.as_str() {
101                "overview" => render_overview(&dir, &repo, max_tokens),
102                "tree" => render_tree(&dir, path.as_deref()),
103                "read" => {
104                    let p = path
105                        .as_deref()
106                        .ok_or_else(|| "read mode requires 'path'".to_string())?;
107                    render_read(&dir, &repo, p, max_tokens)
108                }
109                "grep" => {
110                    let q = query
111                        .as_deref()
112                        .ok_or_else(|| "grep mode requires 'query'".to_string())?;
113                    render_grep(&dir, q, path.as_deref())
114                }
115                other => Err(format!(
116                    "invalid mode '{other}' (use: overview, tree, read, grep)"
117                )),
118            }
119        });
120
121        match result {
122            Ok(rendered) => {
123                let sent = count_tokens(&rendered.body);
124                let saved = rendered.original_tokens.saturating_sub(sent);
125                let text = append_savings(&rendered.body, rendered.original_tokens, sent);
126                Ok(ToolOutput {
127                    text,
128                    original_tokens: rendered.original_tokens,
129                    saved_tokens: saved,
130                    mode: Some(mode),
131                    path: Some(rendered.label),
132                    changed: false,
133                    shell_outcome: None,
134                    content_blocks: None,
135                })
136            }
137            Err(e) => Err(ErrorData::invalid_params(
138                format!("ctx_git_read failed: {e}"),
139                None,
140            )),
141        }
142    }
143}
144
145struct Rendered {
146    body: String,
147    original_tokens: usize,
148    label: String,
149}
150
151fn render_overview(
152    dir: &Path,
153    repo: &repo_url::RepoRef,
154    max_tokens: usize,
155) -> Result<Rendered, String> {
156    let files = list_files(dir, None)?;
157    let total = files.len();
158    let top = top_level_summary(&files);
159
160    let mut body = format!(
161        "# {} ({} files)\n\nRef: {}\n\n## Top-level\n{}\n",
162        repo.project_path(),
163        total,
164        repo.git_ref.as_deref().unwrap_or("default (HEAD)"),
165        top
166    );
167    match find_and_read_readme(dir) {
168        Some((_, Ok(readme))) if !readme.is_empty() => {
169            body.push_str("\n## README\n");
170            body.push_str(&readme);
171        }
172        Some((name, Err(reason))) => {
173            // A README that is withheld is named as such; the overview never
174            // looks as if the repository had none.
175            body.push_str("\n## README\n");
176            body.push_str(&crate::tools::admitted_sources::withheld_line(
177                name, &reason,
178            ));
179        }
180        Some((_, Ok(_))) | None => {}
181    }
182    let original_tokens = count_tokens(&body);
183    Ok(Rendered {
184        body: budget(&body, max_tokens),
185        original_tokens,
186        label: format!("{} overview", repo.project_path()),
187    })
188}
189
190fn render_tree(dir: &Path, subpath: Option<&str>) -> Result<Rendered, String> {
191    let files = list_files(dir, subpath)?;
192    let original_tokens = count_tokens(&files.join("\n"));
193    let shown: Vec<&String> = files.iter().take(MAX_TREE_LINES).collect();
194    let mut body = shown
195        .iter()
196        .map(|s| s.as_str())
197        .collect::<Vec<_>>()
198        .join("\n");
199    if files.len() > shown.len() {
200        body.push_str(&format!(
201            "\n… {} more file(s) (narrow with `path`)",
202            files.len() - shown.len()
203        ));
204    }
205    Ok(Rendered {
206        body,
207        original_tokens,
208        label: format!("tree {}", subpath.unwrap_or(".")),
209    })
210}
211
212fn render_read(
213    dir: &Path,
214    repo: &repo_url::RepoRef,
215    rel: &str,
216    max_tokens: usize,
217) -> Result<Rendered, String> {
218    let file = safe_join(dir, rel)?;
219    if file.is_dir() {
220        // A directory was requested in read mode — show its listing instead.
221        return render_tree(dir, Some(rel));
222    }
223    // Admission precedes the header, the token count and the budget cut: the
224    // whole file is judged, then only admitted text is rendered.
225    let content = admit_clone_file(dir, rel)
226        .map_err(|reason| format!("{rel}: {reason}"))?
227        .text;
228    let header = format!(
229        "// {} @ {}\n",
230        rel,
231        repo.git_ref.as_deref().unwrap_or("HEAD")
232    );
233    let body = format!("{header}{content}");
234    let original_tokens = count_tokens(&body);
235    Ok(Rendered {
236        body: budget(&body, max_tokens),
237        original_tokens,
238        label: format!("{}:{}", repo.project_path(), rel),
239    })
240}
241
242fn render_grep(dir: &Path, query: &str, subpath: Option<&str>) -> Result<Rendered, String> {
243    let mut args: Vec<&str> = vec![
244        "grep",
245        "--no-color",
246        "-n",
247        "-I",
248        "-i",
249        "--heading",
250        "-e",
251        query,
252    ];
253    if let Some(p) = subpath {
254        args.push("--");
255        args.push(p);
256    }
257    let out = run_git(&args, dir, Duration::from_secs(30), &[])?;
258    // git grep exits 1 with no matches — treat that as an empty result, not error.
259    if !out.success && !out.stdout.is_empty() {
260        return Err(out.stderr.trim().to_string());
261    }
262    // The scope is admitted whether or not git found anything: what the
263    // result says about withheld sources must not depend on the query.
264    let scope = list_files(dir, subpath)?;
265    let original_tokens = count_tokens(&out.stdout);
266    Ok(Rendered {
267        body: admitted_grep(dir, query, &out.stdout, &scope),
268        original_tokens,
269        label: format!("grep '{query}'"),
270    })
271}
272
273/// Files of one grep scope that are admitted before selection.
274const MAX_GREP_SCOPE_FILES: usize = 2000;
275/// Original bytes one grep scope may read for admission.
276const MAX_GREP_SCOPE_BYTES: u64 = 64 * 1024 * 1024;
277
278/// Rebuild `git grep --heading -n` output from sources admitted BEFORE
279/// selection (G01), the way `ctx_search` treats a project.
280///
281/// Every file of the searched scope is admitted — independently of whether it
282/// matched. git's output is then used for exactly two things: which file a
283/// group belongs to and which line numbers matched. A line is rendered only
284/// for a file the admission delivered, with its text taken from the admitted
285/// file. A file that is withheld contributes nothing, not even its name, and
286/// the note that reports withheld sources is a count over the WHOLE scope:
287/// the result is the same whether a withheld file matched the query or not,
288/// so a query cannot be used to probe its content. For a scope no rule
289/// touches the result is byte-identical to git's own output.
290fn admitted_grep(dir: &Path, query: &str, stdout: &str, scope: &[String]) -> String {
291    let matched: std::collections::HashSet<&str> = stdout
292        .lines()
293        .filter(|line| matched_line_number(line).is_none())
294        .collect();
295
296    let mut admitted: std::collections::HashMap<&str, Vec<String>> =
297        std::collections::HashMap::new();
298    let mut withheld = 0usize;
299    let mut covered = 0usize;
300    let mut bytes_left = MAX_GREP_SCOPE_BYTES;
301    for file in scope.iter().take(MAX_GREP_SCOPE_FILES) {
302        let Ok(bytes) = read_clone_text_bytes(dir, file) else {
303            // Not a text source (binary, too large, unreadable): git's `-I`
304            // skips it as well; it is neither searched nor withheld.
305            covered += 1;
306            continue;
307        };
308        if bytes.len() as u64 > bytes_left {
309            break;
310        }
311        bytes_left -= bytes.len() as u64;
312        covered += 1;
313        match crate::tools::admitted_sources::admit_remote_file(bytes, file, TOOL) {
314            Ok(source) => {
315                if matched.contains(file.as_str()) {
316                    admitted.insert(
317                        file.as_str(),
318                        source.text.lines().map(str::to_string).collect(),
319                    );
320                }
321            }
322            Err(_) => withheld += 1,
323        }
324    }
325
326    let mut rendered: Vec<String> = Vec::new();
327    // `None`: no heading seen yet, or the current file was not admitted.
328    let mut current: Option<&Vec<String>> = None;
329    for line in stdout.lines() {
330        if rendered.len() >= MAX_GREP_LINES {
331            break;
332        }
333        if let Some(number) = matched_line_number(line) {
334            // A matched line the admitted text does not have is dropped:
335            // git's raw copy of it is never used.
336            if let Some(text) = current.and_then(|lines| lines.get(number - 1)) {
337                rendered.push(format!("{number}:{text}"));
338            }
339            continue;
340        }
341        current = admitted.get(line);
342        if current.is_some() {
343            rendered.push(line.to_string());
344        }
345    }
346
347    let mut body = if rendered.is_empty() {
348        format!("No matches for '{query}'.")
349    } else {
350        rendered.join("\n")
351    };
352    if withheld > 0 {
353        // Same wording as `ctx_search`.
354        body.push_str(&format!("\n({withheld} sources withheld under policy)"));
355    }
356    if covered < scope.len() {
357        body.push_str(&format!(
358            "\n(search covered the first {covered} of {} files — narrow with `path`)",
359            scope.len()
360        ));
361    }
362    body
363}
364
365/// The 1-based line number of a `<n>:<text>` match line; `None` for a heading.
366fn matched_line_number(line: &str) -> Option<usize> {
367    let (number, _) = line.split_once(':')?;
368    number.parse::<usize>().ok().filter(|number| *number > 0)
369}
370
371/// The bytes of one text file of the clone. `Err` (content-free) for a path
372/// outside the clone, an unreadable or oversized file and a binary body —
373/// none of which is inspectable as text.
374fn read_clone_text_bytes(dir: &Path, rel: &str) -> Result<Vec<u8>, String> {
375    let file = safe_join(dir, rel)?;
376    let cap = lean_ctx::core::limits::max_read_bytes() as u64;
377    let size = std::fs::metadata(&file)
378        .map_err(|e| format!("cannot read file: {e}"))?
379        .len();
380    if size > cap {
381        return Err(format!(
382            "file too large ({size} bytes, limit {cap} bytes via LCTX_MAX_READ_BYTES)"
383        ));
384    }
385    let bytes = std::fs::read(&file).map_err(|e| format!("cannot read file: {e}"))?;
386    if lean_ctx::core::binary_detect::has_binary_extension(rel)
387        || lean_ctx::core::text_decode::looks_binary(&bytes[..bytes.len().min(8192)])
388    {
389        return Err("source format is not inspectable as text".to_owned());
390    }
391    Ok(bytes)
392}
393
394/// Read one file of the clone and admit it as a source that is not a project
395/// file, under its repository-relative path. `Err` is a content-free reason.
396fn admit_clone_file(
397    dir: &Path,
398    rel: &str,
399) -> Result<lean_ctx::core::context_admission::SourceAdmission, String> {
400    let bytes = read_clone_text_bytes(dir, rel)?;
401    crate::tools::admitted_sources::admit_remote_file(bytes, rel, TOOL)
402}
403
404// ── helpers ─────────────────────────────────────────────────────────────────
405
406/// Tracked files (gitignore-aware), optionally scoped to a subpath.
407fn list_files(dir: &Path, subpath: Option<&str>) -> Result<Vec<String>, String> {
408    let mut args = vec!["ls-files"];
409    if let Some(p) = subpath {
410        args.push("--");
411        args.push(p);
412    }
413    let out = run_git(&args, dir, Duration::from_secs(20), &[])?.ok_stdout()?;
414    Ok(out
415        .lines()
416        .filter(|l| !l.trim().is_empty())
417        .map(str::to_string)
418        .collect())
419}
420
421fn top_level_summary(files: &[String]) -> String {
422    use std::collections::BTreeMap;
423    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
424    for f in files {
425        let top = f.split('/').next().unwrap_or(f);
426        let key = if top == f.as_str() {
427            top.to_string() // a top-level file
428        } else {
429            format!("{top}/")
430        };
431        *counts.entry(key).or_insert(0) += 1;
432    }
433    counts
434        .into_iter()
435        .take(40)
436        .map(|(k, n)| {
437            if k.ends_with('/') {
438                format!("- {k} ({n})")
439            } else {
440                format!("- {k}")
441            }
442        })
443        .collect::<Vec<_>>()
444        .join("\n")
445}
446
447/// The repository's README, admitted: its name and either the delivered text
448/// or the content-free reason it is withheld for. `None`: there is none.
449fn find_and_read_readme(dir: &Path) -> Option<(&'static str, Result<String, String>)> {
450    for name in [
451        "README.md",
452        "README.MD",
453        "Readme.md",
454        "README",
455        "README.txt",
456    ] {
457        if dir.join(name).is_file() {
458            return Some((
459                name,
460                admit_clone_file(dir, name).map(|admitted| admitted.text),
461            ));
462        }
463    }
464    None
465}
466
467/// Join `rel` under `base`, rejecting any path that escapes `base`.
468fn safe_join(base: &Path, rel: &str) -> Result<PathBuf, String> {
469    let rel = rel.trim_start_matches('/');
470    if rel.split('/').any(|seg| seg == "..") {
471        return Err("path may not contain '..'".to_string());
472    }
473    let joined = base.join(rel);
474    let canon_base = std::fs::canonicalize(base).map_err(|e| e.to_string())?;
475    match std::fs::canonicalize(&joined) {
476        Ok(canon) if canon.starts_with(&canon_base) => Ok(canon),
477        Ok(_) => Err("path escapes the repository".to_string()),
478        Err(e) => Err(format!("path not found: {rel} ({e})")),
479    }
480}
481
482fn budget(content: &str, max_tokens: usize) -> String {
483    let tokens = count_tokens(content);
484    if tokens <= max_tokens {
485        return content.to_string();
486    }
487    let ratio = max_tokens as f64 / tokens as f64;
488    let keep = ((content.chars().count() as f64 * ratio) as usize).max(1);
489    let truncated: String = content.chars().take(keep).collect();
490    lean_ctx::core::recovery::with_original_pointer(
491        "ctx_git_read",
492        "budget",
493        content,
494        format!("{truncated}\n\n…[truncated to ~{max_tokens} tokens]"),
495    )
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn safe_join_blocks_parent_escape() {
504        let tmp = std::env::temp_dir();
505        assert!(safe_join(&tmp, "../etc/passwd").is_err());
506        assert!(safe_join(&tmp, "a/../../b").is_err());
507    }
508
509    #[test]
510    fn top_level_summary_groups_dirs_and_files() {
511        let files = vec![
512            "README.md".to_string(),
513            "src/a.rs".to_string(),
514            "src/b.rs".to_string(),
515            "tests/t.rs".to_string(),
516        ];
517        let s = top_level_summary(&files);
518        assert!(s.contains("- src/ (2)"));
519        assert!(s.contains("- tests/ (1)"));
520        assert!(s.contains("- README.md"));
521    }
522
523    const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
524
525    fn clone_file(dir: &Path, rel: &str, content: &str) {
526        let path = dir.join(rel);
527        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
528        std::fs::write(path, content).unwrap();
529    }
530
531    fn repo() -> repo_url::RepoRef {
532        repo_url::parse("https://github.com/acme/widgets").expect("repo url")
533    }
534
535    fn blocking_policy() -> lean_ctx::core::policy::ResolvedPolicy {
536        lean_ctx::core::policy::load(
537            "name = \"ctx-git-read-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
538             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
539        )
540        .unwrap()
541    }
542
543    /// G01: a file of a remote repository is a source. The old `read` mode
544    /// returned `fs::read_to_string` of the clone verbatim — a key in the
545    /// remote file reached the model, and a file the project policy blocks
546    /// was delivered. A clean file keeps its bytes.
547    #[test]
548    fn a_remote_file_is_admitted_before_it_is_rendered() {
549        let _data = lean_ctx::core::data_dir::isolated_data_dir();
550        let clone = tempfile::tempdir().unwrap();
551        let clean = "pub fn widget() -> u8 {\n    7\n}\n";
552        clone_file(clone.path(), "src/lib.rs", clean);
553        clone_file(
554            clone.path(),
555            "deploy/settings.py",
556            &format!("AWS_ACCESS_KEY_ID = \"{AWS_KEY}\"\nREGION = \"eu-central-2\"\n"),
557        );
558        clone_file(
559            clone.path(),
560            "docs/plan.md",
561            "CONFIDENTIAL\nroadmap-marker for the launch\n",
562        );
563        {
564            let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
565            let rendered = render_read(clone.path(), &repo(), "src/lib.rs", 6000).unwrap();
566            assert_eq!(rendered.body, format!("// src/lib.rs @ HEAD\n{clean}"));
567
568            let rendered = render_read(clone.path(), &repo(), "deploy/settings.py", 6000).unwrap();
569            assert!(!rendered.body.contains(AWS_KEY), "{}", rendered.body);
570            assert!(rendered.body.contains("eu-central-2"), "{}", rendered.body);
571        }
572        let _policy =
573            lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(blocking_policy()));
574        let Err(reason) = render_read(clone.path(), &repo(), "docs/plan.md", 6000) else {
575            panic!("a blocked remote file must not be rendered");
576        };
577        assert!(reason.contains("withheld"), "{reason}");
578        assert!(!reason.contains("roadmap-marker"), "{reason}");
579    }
580
581    /// The README of the overview and every line of a grep result come from
582    /// admitted sources; a withheld file is named, never rendered and never
583    /// silently missing. git's own raw match text is not used.
584    #[test]
585    fn overview_readme_and_grep_lines_come_from_admitted_sources() {
586        let _data = lean_ctx::core::data_dir::isolated_data_dir();
587        let clone = tempfile::tempdir().unwrap();
588        clone_file(
589            clone.path(),
590            "notes.txt",
591            "alpha widget\nbeta\ngamma widget\n",
592        );
593        clone_file(
594            clone.path(),
595            "deploy/settings.py",
596            &format!("# widget deploy\nAWS_ACCESS_KEY_ID = \"{AWS_KEY}\" # widget\n"),
597        );
598        clone_file(
599            clone.path(),
600            "docs/plan.md",
601            "CONFIDENTIAL\nwidget roadmap-marker\n",
602        );
603        clone_file(clone.path(), "README.md", "CONFIDENTIAL\nreadme-marker\n");
604        // What `git grep --heading -n -i -e widget` prints for this clone.
605        let git_output = format!(
606            "deploy/settings.py\n1:# widget deploy\n2:AWS_ACCESS_KEY_ID = \"{AWS_KEY}\" # widget\n\
607             docs/plan.md\n2:widget roadmap-marker\n\
608             notes.txt\n1:alpha widget\n3:gamma widget\n"
609        );
610        // What `git ls-files` lists for this clone (the grep scope).
611        let scope: Vec<String> = [
612            "README.md",
613            "deploy/settings.py",
614            "docs/plan.md",
615            "notes.txt",
616        ]
617        .map(String::from)
618        .to_vec();
619        {
620            // Control: for a scope no rule touches the result is git's output.
621            let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
622            let clean_output = "notes.txt\n1:alpha widget\n3:gamma widget\n";
623            assert_eq!(
624                admitted_grep(clone.path(), "widget", clean_output, &scope),
625                clean_output.trim_end()
626            );
627            let body = admitted_grep(clone.path(), "widget", &git_output, &scope);
628            assert!(!body.contains(AWS_KEY), "{body}");
629            assert!(body.contains("1:# widget deploy"), "{body}");
630        }
631        let _policy =
632            lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(blocking_policy()));
633        let body = admitted_grep(clone.path(), "widget", &git_output, &scope);
634        assert!(!body.contains("roadmap-marker"), "{body}");
635        assert!(
636            !body.contains("docs/plan.md"),
637            "a withheld file is not named: {body}"
638        );
639        assert!(
640            body.contains("notes.txt\n1:alpha widget\n3:gamma widget"),
641            "{body}"
642        );
643        // README.md and docs/plan.md are blocked by the pack. deploy/settings.py
644        // is NOT withheld: a key inside otherwise admissible content is masked
645        // and the rest delivered, with a pack exactly as without one. The
646        // pack's built-in pass and the gateway spell the mask the same way, so
647        // their two orders agree (`compose_active_gateway_output`).
648        let keyed = admit_clone_file(clone.path(), "deploy/settings.py")
649            .expect("a source with a masked key is delivered under a pack");
650        assert_eq!(
651            keyed.text,
652            "# widget deploy\nAWS_ACCESS_KEY_ID = \"[REDACTED:AWS key]\" # widget\n"
653        );
654        assert!(
655            body.contains(
656                "deploy/settings.py\n1:# widget deploy\n\
657                 2:AWS_ACCESS_KEY_ID = \"[REDACTED:AWS key]\" # widget"
658            ),
659            "{body}"
660        );
661        assert!(!body.contains(AWS_KEY), "{body}");
662        // Counted over the scope.
663        assert!(
664            body.ends_with("\n(2 sources withheld under policy)"),
665            "{body}"
666        );
667
668        // No oracle: a query that matches ONLY a withheld file and a query
669        // that matches nothing produce the same result.
670        let only_withheld = admitted_grep(
671            clone.path(),
672            "q",
673            "docs/plan.md\n2:widget roadmap-marker\n",
674            &scope,
675        );
676        let nothing = admitted_grep(clone.path(), "q", "", &scope);
677        assert_eq!(only_withheld, nothing);
678        assert_eq!(
679            nothing,
680            "No matches for 'q'.\n(2 sources withheld under policy)"
681        );
682
683        let (name, readme) = find_and_read_readme(clone.path()).expect("README present");
684        assert_eq!(name, "README.md");
685        let reason = readme.expect_err("a blocked README is withheld");
686        assert!(!reason.contains("readme-marker"), "{reason}");
687    }
688
689    #[test]
690    fn budget_truncates_oversized_content() {
691        let big = "word ".repeat(4000);
692        let out = budget(&big, 50);
693        assert!(out.contains("[truncated"));
694        assert!(count_tokens(&out) < count_tokens(&big));
695    }
696}