Skip to main content

start_command/
formal_ai_policy.rs

1//! Bounds and effect policy of the experimental `--use-formal-ai` mode
2//! (issue #255). Mirrors js/src/lib/formal-ai-policy.js.
3//!
4//! Formal AI owns the interpretation of a natural-language request and picks
5//! the tool calls; start owns the permission to run them. Every tool call is a
6//! POSIX command line that start tokenizes itself and runs as an argv vector,
7//! never through a shell, so a call that needs shell syntax (pipes, redirects,
8//! substitutions, globs, variables) is refused instead of being interpreted.
9
10use std::collections::{BTreeMap, BTreeSet, VecDeque};
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13
14use regex::Regex;
15use serde_json::{json, Value};
16
17/// Request text or option combination rejected.
18pub const EXIT_USAGE: i32 = 64;
19/// Formal AI reported that it did not understand the task.
20pub const EXIT_UNSUPPORTED: i32 = 65;
21/// No qualified Formal AI runtime could be resolved.
22pub const EXIT_UNAVAILABLE: i32 = 69;
23/// Malformed response, unknown tool or unverified effect.
24pub const EXIT_PROTOCOL: i32 = 70;
25/// A tool call was refused by the effect policy.
26pub const EXIT_REFUSED: i32 = 77;
27/// Invalid START_FORMAL_AI_* bound.
28pub const EXIT_CONFIG: i32 = 78;
29/// Step, tool, time or retry budget exhausted.
30pub const EXIT_BUDGET: i32 = 124;
31
32pub const EFFECT_MODES: [&str; 2] = ["read-only", "workspace-write"];
33
34/// One START_FORMAL_AI_* bound: environment variable, default and range.
35pub struct BoundSpec {
36    pub key: &'static str,
37    pub env: &'static str,
38    pub value: u64,
39    pub min: u64,
40    pub max: Option<u64>,
41}
42
43pub const BOUND_DEFAULTS: [BoundSpec; 9] = [
44    BoundSpec {
45        key: "maxSteps",
46        env: "START_FORMAL_AI_MAX_STEPS",
47        value: 8,
48        min: 1,
49        max: Some(64),
50    },
51    BoundSpec {
52        key: "maxRetries",
53        env: "START_FORMAL_AI_MAX_RETRIES",
54        value: 1,
55        min: 0,
56        max: Some(5),
57    },
58    BoundSpec {
59        key: "timeoutMs",
60        env: "START_FORMAL_AI_TIMEOUT_MS",
61        value: 120_000,
62        min: 1,
63        max: None,
64    },
65    BoundSpec {
66        key: "toolTimeoutMs",
67        env: "START_FORMAL_AI_TOOL_TIMEOUT_MS",
68        value: 30_000,
69        min: 1,
70        max: None,
71    },
72    BoundSpec {
73        key: "startupTimeoutMs",
74        env: "START_FORMAL_AI_STARTUP_TIMEOUT_MS",
75        value: 30_000,
76        min: 1,
77        max: None,
78    },
79    BoundSpec {
80        key: "maxOutputBytes",
81        env: "START_FORMAL_AI_MAX_OUTPUT_BYTES",
82        value: 64 * 1024,
83        min: 1,
84        max: Some(16 * 1024 * 1024),
85    },
86    BoundSpec {
87        key: "maxRequestChars",
88        env: "START_FORMAL_AI_MAX_REQUEST_CHARS",
89        value: 4096,
90        min: 1,
91        max: None,
92    },
93    BoundSpec {
94        key: "maxMemoryMb",
95        env: "START_FORMAL_AI_MAX_MEMORY_MB",
96        value: 1024,
97        min: 1,
98        max: None,
99    },
100    BoundSpec {
101        key: "maxFingerprintEntries",
102        env: "START_FORMAL_AI_MAX_FINGERPRINT_ENTRIES",
103        value: 5000,
104        min: 1,
105        max: None,
106    },
107];
108
109/// Resolved bounds of one run.
110#[derive(Debug, Clone, PartialEq)]
111pub struct Bounds {
112    pub max_steps: u64,
113    pub max_retries: u64,
114    pub timeout_ms: u64,
115    pub tool_timeout_ms: u64,
116    pub startup_timeout_ms: u64,
117    pub max_output_bytes: u64,
118    pub max_request_chars: u64,
119    pub max_memory_mb: u64,
120    pub max_fingerprint_entries: u64,
121    pub effects: String,
122}
123
124impl Default for Bounds {
125    fn default() -> Self {
126        resolve_bounds_with(|_| None).expect("defaults are valid")
127    }
128}
129
130impl Bounds {
131    /// The bounds as the evidence records them (same keys as JavaScript).
132    pub fn to_json(&self) -> Value {
133        json!({
134            "maxSteps": self.max_steps,
135            "maxRetries": self.max_retries,
136            "timeoutMs": self.timeout_ms,
137            "toolTimeoutMs": self.tool_timeout_ms,
138            "startupTimeoutMs": self.startup_timeout_ms,
139            "maxOutputBytes": self.max_output_bytes,
140            "maxRequestChars": self.max_request_chars,
141            "maxMemoryMb": self.max_memory_mb,
142            "maxFingerprintEntries": self.max_fingerprint_entries,
143            "effects": self.effects,
144        })
145    }
146}
147
148/// Resolve the bounds from the process environment.
149pub fn resolve_bounds() -> Result<Bounds, String> {
150    resolve_bounds_with(|name| std::env::var(name).ok())
151}
152
153/// Resolve the bounds of one run from START_FORMAL_AI_* variables read
154/// through `get`. An invalid value is an error (exit status EXIT_CONFIG).
155pub fn resolve_bounds_with(get: impl Fn(&str) -> Option<String>) -> Result<Bounds, String> {
156    let mut values = BTreeMap::new();
157    for spec in BOUND_DEFAULTS.iter() {
158        let raw = get(spec.env).unwrap_or_default();
159        if raw.is_empty() {
160            values.insert(spec.key, spec.value);
161            continue;
162        }
163        let trimmed = raw.trim();
164        let parsed = if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
165            trimmed.parse::<u64>().ok()
166        } else {
167            None
168        };
169        match parsed {
170            Some(value) if value >= spec.min && spec.max.is_none_or(|max| value <= max) => {
171                values.insert(spec.key, value);
172            }
173            _ => {
174                let max = spec
175                    .max
176                    .map_or_else(|| "any".to_string(), |max| max.to_string());
177                return Err(format!(
178                    "{} must be an integer from {} to {}, got \"{}\"",
179                    spec.env, spec.min, max, raw
180                ));
181            }
182        }
183    }
184    let effects = get("START_FORMAL_AI_EFFECTS")
185        .filter(|value| !value.is_empty())
186        .unwrap_or_else(|| "read-only".to_string())
187        .trim()
188        .to_string();
189    if !EFFECT_MODES.contains(&effects.as_str()) {
190        return Err(format!(
191            "START_FORMAL_AI_EFFECTS must be one of {}, got \"{}\"",
192            EFFECT_MODES.join(", "),
193            effects
194        ));
195    }
196    Ok(Bounds {
197        max_steps: values["maxSteps"],
198        max_retries: values["maxRetries"],
199        timeout_ms: values["timeoutMs"],
200        tool_timeout_ms: values["toolTimeoutMs"],
201        startup_timeout_ms: values["startupTimeoutMs"],
202        max_output_bytes: values["maxOutputBytes"],
203        max_request_chars: values["maxRequestChars"],
204        max_memory_mb: values["maxMemoryMb"],
205        max_fingerprint_entries: values["maxFingerprintEntries"],
206        effects,
207    })
208}
209
210/// Words of a command line and the shell syntax it would have needed.
211#[derive(Debug, Clone, PartialEq, Default)]
212pub struct Tokenized {
213    pub tokens: Vec<String>,
214    pub problems: Vec<String>,
215}
216
217/// Split a command line with POSIX quoting rules. Characters that would make
218/// a shell do more than split words are reported instead of honoured.
219pub fn tokenize_command(line: &str) -> Tokenized {
220    let chars: Vec<char> = line.chars().collect();
221    let mut tokens = Vec::new();
222    let mut problems: Vec<String> = Vec::new();
223    let mut add = |problem: String| {
224        if !problems.contains(&problem) {
225            problems.push(problem);
226        }
227    };
228    let mut current: Option<String> = None;
229    let mut quote: Option<char> = None;
230    let mut i = 0;
231    while i < chars.len() {
232        let ch = chars[i];
233        if quote == Some('\'') {
234            if ch == '\'' {
235                quote = None;
236            } else {
237                current.get_or_insert_with(String::new).push(ch);
238            }
239            i += 1;
240            continue;
241        }
242        if quote == Some('"') {
243            if ch == '"' {
244                quote = None;
245            } else if ch == '\\' && i + 1 < chars.len() && "\"\\$`".contains(chars[i + 1]) {
246                i += 1;
247                current.get_or_insert_with(String::new).push(chars[i]);
248            } else {
249                if ch == '$' || ch == '`' {
250                    add(format!("expansion \"{}\"", ch));
251                }
252                current.get_or_insert_with(String::new).push(ch);
253            }
254            i += 1;
255            continue;
256        }
257        if ch == ' ' || ch == '\t' {
258            if let Some(token) = current.take() {
259                tokens.push(token);
260            }
261            i += 1;
262            continue;
263        }
264        if ch == '\'' || ch == '"' {
265            quote = Some(ch);
266            current.get_or_insert_with(String::new);
267            i += 1;
268            continue;
269        }
270        if ch == '\\' {
271            if i + 1 < chars.len() && chars[i + 1] != '\n' {
272                i += 1;
273                current.get_or_insert_with(String::new).push(chars[i]);
274                i += 1;
275                continue;
276            }
277            add("line continuation".to_string());
278            i += 1;
279            continue;
280        }
281        if "\n\r;&|<>()$`".contains(ch) {
282            let shown = if ch == '\n' {
283                "\\n".to_string()
284            } else {
285                ch.to_string()
286            };
287            add(format!("shell operator \"{}\"", shown));
288        } else if "*?[]{}".contains(ch) {
289            add(format!("unquoted pattern \"{}\"", ch));
290        } else if ch == '~' && current.is_none() {
291            add("home directory expansion \"~\"".to_string());
292        } else if ch == '#' && current.is_none() {
293            add("comment \"#\"".to_string());
294        }
295        current.get_or_insert_with(String::new).push(ch);
296        i += 1;
297    }
298    if quote.is_some() {
299        add("unterminated quote".to_string());
300    }
301    if let Some(token) = current {
302        tokens.push(token);
303    }
304    Tokenized { tokens, problems }
305}
306
307pub const READ_PROGRAMS: [&str; 24] = [
308    "basename",
309    "cat",
310    "cmp",
311    "diff",
312    "dirname",
313    "du",
314    "file",
315    "find",
316    "git",
317    "grep",
318    "head",
319    "ls",
320    "md5sum",
321    "pwd",
322    "readlink",
323    "realpath",
324    "sha256sum",
325    "sort",
326    "stat",
327    "tail",
328    "test",
329    "tree",
330    "uniq",
331    "wc",
332];
333pub const WRITE_PROGRAMS: [&str; 6] = ["cp", "mkdir", "mv", "rm", "rmdir", "touch"];
334const LISTING_PROGRAMS: [&str; 4] = ["ls", "find", "tree", "du"];
335const GIT_SUBCOMMANDS: [&str; 7] = [
336    "branch",
337    "diff",
338    "log",
339    "ls-files",
340    "rev-parse",
341    "show",
342    "status",
343];
344const GIT_BRANCH_FLAGS: [&str; 6] = ["-a", "-r", "-v", "-vv", "--list", "--show-current"];
345
346/// Flags that turn an otherwise read-only program into a writer or waiter.
347fn forbidden_flags(program: &str) -> Option<Regex> {
348    let pattern = match program {
349        "find" => r"^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$",
350        "sort" | "tree" => r"^(-o.*|--output.*)$",
351        "file" => r"^(-C|--compile)$",
352        "tail" => r"^(-f|-F|--follow.*|--retry)$",
353        "git" => r"^(--output.*|--ext-diff|-c|--exec-path.*|--git-dir.*|--work-tree.*)$",
354        "rm" => r"^(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|--no-preserve-root)$",
355        "cp" => r"^(-[a-zA-Z]*[l][a-zA-Z]*|--link|-s|--symbolic-link)$",
356        _ => return None,
357    };
358    Regex::new(pattern).ok()
359}
360
361/// Lexically normalize a path (`.` and `..` resolved without the filesystem).
362fn normalize(path: &Path) -> PathBuf {
363    let mut result = PathBuf::new();
364    for component in path.components() {
365        match component {
366            Component::CurDir => {}
367            Component::ParentDir => {
368                result.pop();
369            }
370            other => result.push(other.as_os_str()),
371        }
372    }
373    result
374}
375
376/// Real path of `target`, resolving its nearest existing ancestor.
377fn real_target(target: &Path) -> PathBuf {
378    let mut existing = target.to_path_buf();
379    let mut rest = Vec::new();
380    while !existing.exists() {
381        match (existing.parent(), existing.file_name()) {
382            (Some(parent), Some(name)) => {
383                rest.push(name.to_os_string());
384                existing = parent.to_path_buf();
385            }
386            _ => return target.to_path_buf(),
387        }
388    }
389    let mut resolved = fs::canonicalize(&existing).unwrap_or(existing);
390    for name in rest.iter().rev() {
391        resolved.push(name);
392    }
393    resolved
394}
395
396/// Path-like operands of a call (values of `--opt=value` included).
397pub fn operands(tokens: &[String]) -> Vec<String> {
398    let mut values = Vec::new();
399    for token in tokens.iter().skip(1) {
400        if token.starts_with('-') {
401            if let Some(eq) = token.find('=') {
402                if eq > 0 && token.starts_with("--") {
403                    values.push(token[eq + 1..].to_string());
404                }
405            }
406            continue;
407        }
408        values.push(token.clone());
409    }
410    values
411}
412
413/// Normalized workspace-relative path of an operand, or None outside it.
414pub fn workspace_path(cwd: &Path, operand: &str) -> Option<String> {
415    let root = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
416    let resolved = real_target(&normalize(&cwd.join(operand)));
417    let relative = resolved.strip_prefix(&root).ok()?;
418    let text = relative.to_string_lossy().to_string();
419    Some(if text.is_empty() {
420        ".".to_string()
421    } else {
422        text
423    })
424}
425
426fn parent_of(target: &str) -> String {
427    match Path::new(target).parent() {
428        Some(parent) if !parent.as_os_str().is_empty() => parent.to_string_lossy().to_string(),
429        _ => ".".to_string(),
430    }
431}
432
433/// What a read observed, so a later change may name it.
434#[derive(Debug, Clone, Default)]
435pub struct PolicyContext {
436    pub cwd: PathBuf,
437    pub effects: String,
438    pub observed: BTreeSet<String>,
439    pub observed_dirs: BTreeSet<String>,
440}
441
442impl PolicyContext {
443    pub fn new(cwd: &Path, effects: &str) -> Self {
444        Self {
445            cwd: cwd.to_path_buf(),
446            effects: effects.to_string(),
447            ..Self::default()
448        }
449    }
450}
451
452/// Decision about one tool call.
453#[derive(Debug, Clone, PartialEq, Default)]
454pub struct Decision {
455    pub allowed: bool,
456    pub reason: Option<String>,
457    pub tokens: Vec<String>,
458    /// `read` or `write` when allowed
459    pub kind: Option<&'static str>,
460    pub targets: Vec<String>,
461}
462
463fn refuse(reason: String, tokens: &[String]) -> Decision {
464    Decision {
465        allowed: false,
466        reason: Some(reason),
467        tokens: tokens.to_vec(),
468        ..Decision::default()
469    }
470}
471
472/// Decide whether a tool call may run.
473pub fn evaluate_command(line: &str, context: &PolicyContext) -> Decision {
474    let Tokenized { tokens, problems } = tokenize_command(line);
475    if !problems.is_empty() {
476        return refuse(
477            format!(
478                "needs shell syntax start does not run: {}",
479                problems.join(", ")
480            ),
481            &tokens,
482        );
483    }
484    let Some(program) = tokens.first().cloned() else {
485        return refuse("empty command".to_string(), &tokens);
486    };
487    let is_write = WRITE_PROGRAMS.contains(&program.as_str());
488    if !READ_PROGRAMS.contains(&program.as_str()) && !is_write {
489        return refuse(
490            format!("program \"{}\" is not in the supported tool set", program),
491            &tokens,
492        );
493    }
494    if is_write && context.effects != "workspace-write" {
495        return refuse(
496            format!(
497                "\"{}\" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)",
498                program
499            ),
500            &tokens,
501        );
502    }
503    if let Some(forbidden) = forbidden_flags(&program) {
504        if let Some(flag) = tokens.iter().skip(1).find(|t| forbidden.is_match(t)) {
505            return refuse(
506                format!("option \"{}\" of {} is not allowed", flag, program),
507                &tokens,
508            );
509        }
510    }
511    if program == "git" {
512        let sub = tokens.get(1).cloned().unwrap_or_default();
513        if !GIT_SUBCOMMANDS.contains(&sub.as_str()) {
514            return refuse(
515                format!("git subcommand \"{}\" is not read-only", sub),
516                &tokens,
517            );
518        }
519        if sub == "branch"
520            && tokens
521                .iter()
522                .skip(2)
523                .any(|t| !GIT_BRANCH_FLAGS.contains(&t.as_str()))
524        {
525            return refuse("git branch may only list branches".to_string(), &tokens);
526        }
527    }
528    let mut targets = Vec::new();
529    for operand in operands(&tokens) {
530        if operand.starts_with('~') {
531            return refuse(
532                format!("\"{}\" is outside the working directory", operand),
533                &tokens,
534            );
535        }
536        // Absolute, escaping and symlinked-out operands are refused: reads and
537        // changes stay inside the working directory.
538        match workspace_path(&context.cwd, &operand) {
539            Some(relative) => targets.push(relative),
540            None => {
541                return refuse(
542                    format!("\"{}\" is outside the working directory", operand),
543                    &tokens,
544                )
545            }
546        }
547    }
548    if is_write {
549        if targets.is_empty() {
550            return refuse(format!("{} needs an explicit target", program), &tokens);
551        }
552        let unobserved: Vec<String> = targets
553            .iter()
554            .filter(|t| {
555                t.as_str() == "."
556                    || !(context.observed.contains(*t)
557                        || context.observed_dirs.contains(&parent_of(t)))
558            })
559            .map(|t| format!("\"{}\"", t))
560            .collect();
561        if !unobserved.is_empty() {
562            return refuse(
563                format!(
564                    "target {} was not observed before the change",
565                    unobserved.join(", ")
566                ),
567                &tokens,
568            );
569        }
570    }
571    Decision {
572        allowed: true,
573        reason: None,
574        tokens,
575        kind: Some(if is_write { "write" } else { "read" }),
576        targets,
577    }
578}
579
580/// Remember what a read call observed, so a later change may name it.
581pub fn record_observation(decision: &Decision, context: &mut PolicyContext) {
582    for target in &decision.targets {
583        context.observed.insert(target.clone());
584    }
585    let listing = decision
586        .tokens
587        .first()
588        .is_some_and(|program| LISTING_PROGRAMS.contains(&program.as_str()));
589    if listing {
590        if decision.targets.is_empty() {
591            context.observed_dirs.insert(".".to_string());
592        }
593        for target in &decision.targets {
594            context.observed_dirs.insert(target.clone());
595        }
596    }
597}
598
599/// Bounded fingerprint of a directory tree (names, types, sizes, mtimes).
600#[derive(Debug, Clone, PartialEq, Default)]
601pub struct Fingerprint {
602    pub entries: BTreeMap<String, String>,
603    pub complete: bool,
604}
605
606/// Bounded breadth-first fingerprint of the working directory; `.git` is
607/// listed but not descended into.
608pub fn fingerprint(cwd: &Path, limit: u64) -> Fingerprint {
609    let mut entries = BTreeMap::new();
610    let mut queue = VecDeque::from([String::new()]);
611    while let Some(relative) = queue.pop_front() {
612        let dir = if relative.is_empty() {
613            cwd.to_path_buf()
614        } else {
615            cwd.join(&relative)
616        };
617        let Ok(read) = fs::read_dir(&dir) else {
618            continue;
619        };
620        let mut names: Vec<String> = read
621            .filter_map(|entry| entry.ok())
622            .map(|entry| entry.file_name().to_string_lossy().to_string())
623            .collect();
624        names.sort();
625        for name in names {
626            if entries.len() as u64 >= limit {
627                return Fingerprint {
628                    entries,
629                    complete: false,
630                };
631            }
632            let child = if relative.is_empty() {
633                name.clone()
634            } else {
635                format!("{}/{}", relative, name)
636            };
637            let Ok(meta) = fs::symlink_metadata(cwd.join(&child)) else {
638                continue;
639            };
640            let kind = if meta.is_dir() {
641                'd'
642            } else if meta.file_type().is_symlink() {
643                'l'
644            } else {
645                'f'
646            };
647            let mtime = meta
648                .modified()
649                .ok()
650                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
651                .map_or(0, |d| d.as_nanos());
652            entries.insert(child.clone(), format!("{}:{}:{}", kind, meta.len(), mtime));
653            if kind == 'd' && name != ".git" {
654                queue.push_back(child);
655            }
656        }
657    }
658    Fingerprint {
659        entries,
660        complete: true,
661    }
662}
663
664/// Differences between two fingerprints.
665#[derive(Debug, Clone, PartialEq)]
666pub struct EffectDiff {
667    pub added: Vec<String>,
668    pub removed: Vec<String>,
669    pub changed: Vec<String>,
670    pub complete: bool,
671}
672
673impl Default for EffectDiff {
674    fn default() -> Self {
675        Self {
676            added: Vec::new(),
677            removed: Vec::new(),
678            changed: Vec::new(),
679            complete: true,
680        }
681    }
682}
683
684impl EffectDiff {
685    pub fn has_effects(&self) -> bool {
686        !(self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty())
687    }
688
689    pub fn to_json(&self) -> Value {
690        json!({
691            "added": self.added,
692            "removed": self.removed,
693            "changed": self.changed,
694            "complete": self.complete,
695        })
696    }
697}
698
699pub fn diff_fingerprints(before: &Fingerprint, after: &Fingerprint) -> EffectDiff {
700    let mut diff = EffectDiff {
701        complete: before.complete && after.complete,
702        ..EffectDiff::default()
703    };
704    for (name, value) in &after.entries {
705        match before.entries.get(name) {
706            None => diff.added.push(name.clone()),
707            Some(old) if old != value => diff.changed.push(name.clone()),
708            _ => {}
709        }
710    }
711    for name in before.entries.keys() {
712        if !after.entries.contains_key(name) {
713            diff.removed.push(name.clone());
714        }
715    }
716    diff
717}
718
719/// Result of reading a change back from the filesystem.
720#[derive(Debug, Clone, PartialEq)]
721pub struct Verification {
722    pub verified: bool,
723    pub detail: String,
724}
725
726impl Verification {
727    pub fn to_json(&self) -> Value {
728        json!({ "verified": self.verified, "detail": self.detail })
729    }
730}
731
732/// Read the result of a successful change back from the filesystem.
733pub fn verify_change(tokens: &[String], cwd: &Path) -> Verification {
734    let program = tokens.first().map(String::as_str).unwrap_or("");
735    let args = operands(tokens);
736    let exists = |p: &str| cwd.join(p).exists();
737    let is_dir = |p: &str| cwd.join(p).is_dir();
738    let mut missing: Vec<String> = Vec::new();
739    match program {
740        "mkdir" => missing = args.iter().filter(|p| !is_dir(p)).cloned().collect(),
741        "touch" => missing = args.iter().filter(|p| !exists(p)).cloned().collect(),
742        "rm" | "rmdir" => {
743            missing = args
744                .iter()
745                .filter(|p| exists(p))
746                .map(|p| format!("{} (still present)", p))
747                .collect()
748        }
749        "cp" | "mv" if !args.is_empty() => {
750            let dest = args[args.len() - 1].clone();
751            let sources = &args[..args.len() - 1];
752            let expected: Vec<String> = if is_dir(&dest) && !sources.is_empty() {
753                sources
754                    .iter()
755                    .map(|s| {
756                        let base = Path::new(s)
757                            .file_name()
758                            .map(|n| n.to_string_lossy().to_string())
759                            .unwrap_or_default();
760                        Path::new(&dest).join(base).to_string_lossy().to_string()
761                    })
762                    .collect()
763            } else {
764                vec![dest]
765            };
766            missing = expected.iter().filter(|p| !exists(p)).cloned().collect();
767            if program == "mv" {
768                missing.extend(
769                    sources
770                        .iter()
771                        .filter(|s| exists(s) && !expected.contains(s))
772                        .map(|s| format!("{} (still present)", s)),
773                );
774            }
775        }
776        _ => {}
777    }
778    if missing.is_empty() {
779        Verification {
780            verified: true,
781            detail: format!("readback confirmed {} {}", program, args.join(" ")),
782        }
783    } else {
784        Verification {
785            verified: false,
786            detail: format!("readback failed: {}", missing.join(", ")),
787        }
788    }
789}
790
791#[cfg(test)]
792#[path = "formal_ai_policy_cases.rs"]
793pub(crate) mod formal_ai_policy_cases;