Skip to main content

speccoder_runtime/acp/
govern.rs

1//! An external agent's calls under speccoder's policy: its permission
2//! requests, and the file and terminal access speccoder offers it.
3//!
4//! Each call is decided as a native attempt's call is — the same
5//! classification ([`Policy`]), the same objects, the same grants, the same
6//! person through the same [`Approver`] — and recorded in the attempt's
7//! tool-call log in the native shape, so `task approve`, the report and the
8//! audit read it alike. A permission request names no stable tool, so it is
9//! classified from its declared kind and arguments, never its title; one
10//! that cannot be classified goes to a person. With nobody to ask, the
11//! first call that needs a person ends the attempt `needs_approval`, saved
12//! with that call for `speccoder task approve`.
13
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16
17use serde_json::{Value, json};
18
19use super::calls::ToolRequest;
20use crate::approvals::{
21    Answer, Approver, Decision as Said, PresentContext, Request, Scope, Who, present,
22};
23use crate::progress::{Event, Progress};
24use crate::tools::WorkspaceTools;
25use crate::tools::policy::{Decision, Object, Policy};
26
27/// A command allowed to run: its argv, directory and environment.
28pub type Command = (Vec<String>, PathBuf, Vec<(String, String)>);
29
30/// The tool name a call speccoder cannot classify is recorded under.
31pub const AGENT_CALL: &str = "agent_call";
32
33/// One of the agent's permission options.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct PermissionOption {
36    /// Its id.
37    pub id: String,
38    /// `allow_once`, `allow_always`, `reject_once`, `reject_always`.
39    pub kind: String,
40}
41
42/// What a call came to.
43#[derive(Debug, Clone, PartialEq)]
44pub enum Verdict {
45    /// It may run (checked again against `object` where it has one).
46    Allow(Option<Object>),
47    /// It is refused, and why.
48    Deny(String),
49    /// A person is needed and nobody can be asked (or the person stopped
50    /// the task): the attempt ends.
51    Stop,
52}
53
54/// Who decides for one attempt, and what they decided.
55pub struct Governor {
56    root: PathBuf,
57    tools: WorkspaceTools,
58    policy: Policy,
59    approver: Option<Arc<dyn Approver>>,
60    present: PresentContext,
61    task: String,
62    task_title: String,
63    run: Option<String>,
64    frozen_additions: bool,
65    progress: Option<(String, Arc<dyn Progress>)>,
66    /// Files copied from the checkout (`[worktree] include`).
67    copied: Vec<String>,
68    /// The agent (its preset id), whose own actions a grant names.
69    agent: String,
70    /// Milliseconds a person has spent deciding.
71    asking: std::sync::atomic::AtomicU64,
72    /// Since when a person is being asked now.
73    asking_since: Mutex<Option<std::time::Instant>>,
74    state: Mutex<State>,
75}
76
77impl std::fmt::Debug for Governor {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("Governor")
80            .field("root", &self.root)
81            .field("task", &self.task)
82            .finish_non_exhaustive()
83    }
84}
85
86#[derive(Debug, Default)]
87struct State {
88    /// Objects a person approved for one call each.
89    once: Vec<Object>,
90    /// Objects a person approved for the run or the task, with the scope.
91    remembered: Vec<(Object, &'static str)>,
92    /// Every decision and every call the agent reported, in order.
93    log: Vec<Value>,
94    /// The call waiting for a person nobody could ask.
95    pending: Option<Value>,
96    /// The agent's words since its last call (the claim a person sees).
97    said: String,
98    /// The attempt is ending: nothing more is allowed.
99    stopped: bool,
100    /// Prompts shown to a person.
101    asked: u32,
102    /// Permission requests answered, by the agent's call id.
103    decided: std::collections::HashSet<String>,
104    /// Of them, allowed.
105    allowed: std::collections::HashSet<String>,
106    /// The request being decided: its log entries carry its id.
107    request: Option<String>,
108    /// Calls the agent reported changing something without asking.
109    unasked: u32,
110}
111
112/// What a [`Governor`] is built from.
113pub struct Setup {
114    /// The worktree (canonical).
115    pub root: PathBuf,
116    /// The attempt's tools (with the sandboxed runner).
117    pub tools: WorkspaceTools,
118    /// The attempt's policy, with the task's decisions.
119    pub policy: Policy,
120    /// Who answers a call that needs a person; `None` with `--no-prompt`.
121    pub approver: Option<Arc<dyn Approver>>,
122    /// What a person is shown about the sandbox.
123    pub present: PresentContext,
124    /// `feature/task`.
125    pub task: String,
126    /// The task's title.
127    pub task_title: String,
128    /// The run.
129    pub run: Option<String>,
130    /// `[tests] frozen = "additions"`.
131    pub frozen_additions: bool,
132    /// Objects approved for every task of this run.
133    pub run_approved: Vec<Object>,
134    /// Objects approved for one call each.
135    pub approved_once: Vec<Object>,
136    /// The task id and where its events go.
137    pub progress: Option<(String, Arc<dyn Progress>)>,
138    /// Files copied from the checkout (`[worktree] include`),
139    /// worktree-relative.
140    pub copied: Vec<String>,
141    /// The agent (its preset id, or `acp`).
142    pub agent: String,
143}
144
145/// A command line as an argv: split on ASCII whitespace when it is a plain
146/// command, else given to `sh -c` whole — which a person decides, so a
147/// shell's meaning (or a look-alike space) is never guessed.
148#[must_use]
149pub fn argv_of_line(line: &str) -> Vec<String> {
150    const SHELL: &[char] = &[
151        '|', '&', ';', '<', '>', '(', ')', '$', '`', '\\', '"', '\'', '*', '?', '[', ']', '{', '}',
152        '~', '!', '#', '\n', '=',
153    ];
154    let line = line.trim_matches(|c: char| c.is_ascii_whitespace());
155    let odd_space = line
156        .chars()
157        .any(|c| c.is_whitespace() && !c.is_ascii_whitespace());
158    if line.contains(SHELL)
159        || odd_space
160        || line
161            .chars()
162            .any(|c| c.is_control() && !c.is_ascii_whitespace())
163    {
164        return vec!["sh".to_owned(), "-c".to_owned(), line.to_owned()];
165    }
166    line.split_ascii_whitespace().map(str::to_owned).collect()
167}
168
169/// A shell line of plain commands joined by `;`, `&&`, `||` or newlines:
170/// its commands, each plain; `None` for anything else a shell would read.
171#[must_use]
172pub fn commands_of_line(line: &str) -> Option<Vec<Vec<String>>> {
173    let mut parts = Vec::new();
174    let mut rest = line.trim();
175    while !rest.is_empty() {
176        let at = ["&&", "||", ";", "\n"]
177            .iter()
178            .filter_map(|sep| rest.find(sep).map(|i| (i, sep.len())))
179            .min();
180        let (part, next) = match at {
181            Some((i, n)) => (&rest[..i], &rest[i + n..]),
182            None => (rest, ""),
183        };
184        let argv = argv_of_line(part);
185        if argv.is_empty() {
186            return None;
187        }
188        if argv.first().is_some_and(|p| p == "sh") && argv.get(1).is_some_and(|f| f == "-c") {
189            return None;
190        }
191        parts.push(argv);
192        rest = next.trim();
193    }
194    (!parts.is_empty()).then_some(parts)
195}
196
197/// A shell started with one line (`["/bin/zsh", "-lc", "…"]`, `sh -c …`):
198/// that line.
199fn shell_line(argv: &[String]) -> Option<&str> {
200    let [shell, flag, line] = argv else {
201        return None;
202    };
203    let name = shell.rsplit('/').next().unwrap_or(shell);
204    (matches!(name, "sh" | "bash" | "zsh" | "dash")
205        && matches!(flag.as_str(), "-c" | "-lc" | "-ic"))
206    .then_some(line.as_str())
207}
208
209/// Programs a plain-read line may run, with the options they may take
210/// (`N`: the option takes a number).
211const READS: &[(&str, &[&str])] = &[
212    ("cat", &["-n"]),
213    ("head", &["-n N", "-c N"]),
214    ("tail", &["-n N", "-c N"]),
215    ("wc", &["-l", "-w", "-c", "-m"]),
216];
217
218/// Keys that hold a command, at any depth of a request's input.
219const COMMAND_KEYS: &[&str] = &[
220    "command", "commands", "cmd", "argv", "args", "script", "shell", "bash",
221];
222
223/// Keys that name a file, at any depth of a request's input.
224const PATH_KEYS: &[&str] = &[
225    "path",
226    "paths",
227    "file_path",
228    "filePath",
229    "fileName",
230    "file",
231    "files",
232    "notebook_path",
233    "destination",
234    "new_path",
235    "newPath",
236    "old_path",
237    "source",
238    "target",
239    "cwd",
240    "workdir",
241    "directory",
242    "dir",
243];
244
245/// The input keys each kind of request may carry; any other key, or a
246/// value of another shape, and a person decides.
247fn known_keys(kind: &str) -> Option<&'static [&'static str]> {
248    Some(match kind {
249        "read" => &[
250            "path",
251            "paths",
252            "file_path",
253            "filePath",
254            "fileName",
255            "line",
256            "limit",
257            "offset",
258            "start_line",
259            "end_line",
260        ],
261        "search" => &[
262            "pattern",
263            "query",
264            "path",
265            "paths",
266            "glob",
267            "include",
268            "file_path",
269        ],
270        "edit" => &[
271            "path",
272            "file_path",
273            "filePath",
274            "fileName",
275            "old_string",
276            "new_string",
277            "replace_all",
278            "content",
279            "file_text",
280            "diff",
281            "edits",
282        ],
283        "delete" => &["path", "paths", "file_path", "filePath", "fileName"],
284        "move" => &[
285            "path",
286            "file_path",
287            "source",
288            "destination",
289            "new_path",
290            "newPath",
291            "target",
292        ],
293        "think" => &[
294            "thought",
295            "thoughts",
296            "todos",
297            "plan",
298            "text",
299            "description",
300        ],
301        _ => return None,
302    })
303}
304
305/// Keys inside each of an edit's `edits[]`.
306const EDIT_KEYS: &[&str] = &[
307    "path",
308    "file_path",
309    "old_string",
310    "new_string",
311    "replace_all",
312];
313
314/// File names that are never read without a person, beyond the
315/// policy's credentials: what commonly holds a credential.
316const SECRET_NAMES: &[&str] = &[
317    "**/.git-credentials",
318    "**/*.tfstate*",
319    "**/*credentials*",
320    "**/*.p12",
321    "**/*.pfx",
322    "**/*.kdbx",
323    "**/id_*",
324    "**/.netrc",
325    "**/.pgpass",
326    "**/.npmrc",
327    "**/.pypirc",
328    "**/.dockercfg",
329    "**/.docker/config.json",
330    "**/kubeconfig",
331    "**/.kube/config",
332    "**/.htpasswd",
333    "**/*secret*",
334    "**/token",
335    "**/*.token",
336    "**/token.txt",
337    "**/*.jks",
338    "**/*.keystore",
339    "**/deploy_key*",
340];
341
342fn secret_name(rel: &str) -> bool {
343    static SET: std::sync::OnceLock<Option<globset::GlobSet>> = std::sync::OnceLock::new();
344    SET.get_or_init(|| {
345        let mut b = globset::GlobSetBuilder::new();
346        for g in SECRET_NAMES {
347            if let Ok(glob) = globset::GlobBuilder::new(g).case_insensitive(true).build() {
348                b.add(glob);
349            }
350        }
351        b.build().ok()
352    })
353    .as_ref()
354    .is_none_or(|s| s.is_match(rel))
355}
356
357/// The one file a plain-read line (`cat src/lib.rs`, `head -n 5 x`) reads;
358/// `None` for anything else — another program, another option, `--`, `-`,
359/// several files.
360#[must_use]
361pub fn plain_read(argv: &[String]) -> Option<String> {
362    let (program, args) = argv.split_first()?;
363    let (_, options) = READS.iter().find(|(p, _)| p == program)?;
364    let mut file = None;
365    let mut words = args.iter();
366    while let Some(a) = words.next() {
367        if let Some(flag) = a.strip_prefix('-') {
368            if options.contains(&a.as_str()) {
369                continue;
370            }
371            // `-n 5`, `-n5`: the letter, then its number.
372            let mut chars = flag.chars();
373            let letter = chars.next()?;
374            let rest: String = chars.collect();
375            if !options.contains(&format!("-{letter} N").as_str()) {
376                return None;
377            }
378            let number = if rest.is_empty() {
379                words.next()?.clone()
380            } else {
381                rest
382            };
383            if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit()) {
384                return None;
385            }
386            continue;
387        }
388        if file.replace(a.clone()).is_some() {
389            return None;
390        }
391    }
392    file
393}
394
395/// The call kind of a person's "reads for this task".
396pub const READS_GRANT: &str = "reads";
397
398/// JSON with every object's keys sorted: one spelling for one value.
399fn canonical(v: &Value) -> String {
400    fn sorted(v: &Value) -> Value {
401        match v {
402            Value::Object(o) => {
403                let mut keys: Vec<&String> = o.keys().collect();
404                keys.sort();
405                let mut out = serde_json::Map::new();
406                for k in keys {
407                    out.insert(k.clone(), sorted(&o[k]));
408                }
409                Value::Object(out)
410            }
411            Value::Array(a) => Value::Array(a.iter().map(sorted).collect()),
412            other => other.clone(),
413        }
414    }
415    sorted(v).to_string()
416}
417
418/// Whether an `execute` request carries only what a plain read needs:
419/// its command (and Copilot's copy of it as `commands`), a working
420/// directory that is the worktree, a timeout, a description.
421fn execute_keeps_to(raw: &Value, argv: &[String]) -> bool {
422    let Some(o) = raw.as_object() else {
423        return false;
424    };
425    let line = argv.join(" ");
426    o.iter().all(|(k, v)| match k.as_str() {
427        "command" => true,
428        "commands" => v.as_array().is_some_and(|a| {
429            a.len() == 1
430                && a[0].as_str().is_some_and(|c| {
431                    c.split_ascii_whitespace().collect::<Vec<_>>().join(" ") == line
432                })
433        }),
434        "cwd" | "working_directory" | "workdir" => {
435            v.as_str().is_some_and(|c| c.is_empty() || c == ".")
436        }
437        "timeout" => v.is_u64(),
438        "description" => v.is_string(),
439        _ => false,
440    })
441}
442
443/// Whether a file's text looks like it holds a credential: a private key,
444/// a URL with a user and password, a well-known token prefix, or a
445/// key/secret/password/token set to a value.
446#[must_use]
447pub fn holds_a_credential(text: &str) -> bool {
448    static PATTERNS: std::sync::OnceLock<Option<regex::RegexSet>> = std::sync::OnceLock::new();
449    PATTERNS
450        .get_or_init(|| {
451            regex::RegexSet::new([
452                r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
453                r"[a-zA-Z][a-zA-Z0-9+.-]*://[^\s/:@]+:[^\s/@]+@",
454                r"\bAKIA[0-9A-Z]{16}\b",
455                r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}",
456                r"\bgithub_pat_[A-Za-z0-9_]{20,}",
457                r"\bxox[abprs]-[A-Za-z0-9-]{10,}",
458                r"\bsk-[A-Za-z0-9_-]{16,}",
459                r#"(?i)(api[_-]?key|secret|passw(or)?d|token|private[_-]?key)["']?\s*[:=]\s*["']?[^\s"']{4,}"#,
460            ])
461            .ok()
462        })
463        .as_ref()
464        .is_none_or(|set| set.is_match(text))
465}
466
467/// Why a command the agent runs itself needs a person.
468pub const RUNS_ITSELF: &str = "the agent runs it itself, outside speccoder's sandbox";
469
470/// What a permission request comes to.
471#[derive(Debug, Clone, PartialEq)]
472enum Judged {
473    /// A plain read of this one file, covered by a person's "reads for
474    /// this task" (its scope).
475    Granted(String, &'static str),
476    /// Nothing outside the turn (pure thinking).
477    Think,
478    /// Refused, whoever asks.
479    Deny(String),
480    /// A person decides; a decision covers exactly this object.
481    Person(String, Object),
482}
483
484/// Every string a key of `keys` holds anywhere in `v`.
485fn strings_at(v: &Value, keys: &[&str], out: &mut Vec<Value>) {
486    match v {
487        Value::Object(o) => {
488            for (k, x) in o {
489                if keys.contains(&k.as_str()) {
490                    out.push(x.clone());
491                }
492                strings_at(x, keys, out);
493            }
494        }
495        Value::Array(a) => a.iter().for_each(|x| strings_at(x, keys, out)),
496        _ => {}
497    }
498}
499
500/// A command a value holds: a line, or an argv.
501fn command_of(v: &Value) -> Vec<Vec<String>> {
502    match v {
503        Value::String(line) => vec![argv_of_line(line)],
504        Value::Array(a) if a.iter().all(Value::is_string) => {
505            vec![
506                a.iter()
507                    .filter_map(|v| v.as_str().map(str::to_owned))
508                    .collect(),
509            ]
510        }
511        Value::Array(a) => a.iter().flat_map(command_of).collect(),
512        // A command in a shape speccoder does not read: unreadable, never
513        // dropped (a person sees it).
514        other => vec![vec!["sh".to_owned(), "-c".to_owned(), other.to_string()]],
515    }
516}
517
518/// Whether `raw` keeps to `kind`'s keys, each of the shape it should have.
519fn keeps_to(kind: &str, raw: &Value) -> bool {
520    let Some(keys) = known_keys(kind) else {
521        return false;
522    };
523    let Some(o) = raw.as_object() else {
524        return raw.is_null();
525    };
526    o.iter().all(|(k, v)| {
527        keys.contains(&k.as_str())
528            && match k.as_str() {
529                "line" | "limit" | "offset" | "start_line" | "end_line" => v.is_u64(),
530                "replace_all" => v.is_boolean(),
531                "paths" => v.as_array().is_some_and(|a| a.iter().all(Value::is_string)),
532                "todos" => v.is_array() || v.is_string(),
533                "edits" => v.as_array().is_some_and(|a| {
534                    a.iter().all(|e| {
535                        e.as_object().is_some_and(|e| {
536                            e.iter().all(|(k, v)| {
537                                EDIT_KEYS.contains(&k.as_str())
538                                    && (v.is_string() || (k == "replace_all" && v.is_boolean()))
539                            })
540                        })
541                    })
542                }),
543                _ => v.is_string(),
544            }
545    })
546}
547
548impl Governor {
549    /// The governor for one attempt.
550    #[must_use]
551    pub fn new(s: Setup) -> Self {
552        let remembered = s
553            .run_approved
554            .into_iter()
555            .map(|o| (o, "run-grant"))
556            .collect();
557        Self {
558            root: s.root,
559            tools: s.tools,
560            policy: s.policy,
561            approver: s.approver,
562            present: s.present,
563            task: s.task,
564            task_title: s.task_title,
565            run: s.run,
566            frozen_additions: s.frozen_additions,
567            progress: s.progress,
568            copied: s.copied,
569            agent: s.agent,
570            asking: std::sync::atomic::AtomicU64::new(0),
571            asking_since: Mutex::new(None),
572            state: Mutex::new(State {
573                once: s.approved_once,
574                remembered,
575                ..State::default()
576            }),
577        }
578    }
579
580    /// The worktree.
581    #[must_use]
582    pub fn root(&self) -> &Path {
583        &self.root
584    }
585
586    /// The decisions and calls so far.
587    #[must_use]
588    pub fn log(&self) -> Vec<Value> {
589        self.state.lock().map(|s| s.log.clone()).unwrap_or_default()
590    }
591
592    /// The call waiting for a person nobody could ask, if one is.
593    #[must_use]
594    pub fn pending(&self) -> Option<Value> {
595        self.state.lock().ok().and_then(|s| s.pending.clone())
596    }
597
598    /// Whether the attempt is ending.
599    #[must_use]
600    pub fn stopped(&self) -> bool {
601        self.state.lock().map_or(true, |s| s.stopped)
602    }
603
604    /// Prompts a person was shown.
605    #[must_use]
606    pub fn asked(&self) -> u32 {
607        self.state.lock().map_or(0, |s| s.asked)
608    }
609
610    /// How long a person has spent deciding so far: the attempt's clock
611    /// stands while they do.
612    #[must_use]
613    pub fn asking(&self) -> std::time::Duration {
614        let done =
615            std::time::Duration::from_millis(self.asking.load(std::sync::atomic::Ordering::SeqCst));
616        let now = self
617            .asking_since
618            .lock()
619            .ok()
620            .and_then(|s| s.map(|t| t.elapsed()))
621            .unwrap_or_default();
622        done + now
623    }
624
625    /// The agent's words since its last call.
626    pub fn said(&self, text: &str) {
627        if let Ok(mut s) = self.state.lock() {
628            s.said.push_str(text);
629        }
630    }
631
632    /// The agent reported a call it makes: recorded beside the decisions,
633    /// as the agent's own (`decision: "reported"`) — its claim, never
634    /// evidence. Masked, its long values cut; one that changed something
635    /// without a decision of speccoder's is counted ([`Governor::unasked`]).
636    pub fn reported(&self, call: &ToolRequest) {
637        let mut arguments = call.raw_input.clone().unwrap_or(Value::Null);
638        cap(&mut arguments);
639        crate::secrets::mask_json(&mut arguments);
640        let ok = call.status.as_deref() == Some("completed");
641        let mut held = Vec::new();
642        strings_at(
643            call.raw_input.as_ref().unwrap_or(&Value::Null),
644            COMMAND_KEYS,
645            &mut held,
646        );
647        let effect = matches!(
648            call.kind.as_deref(),
649            Some("edit" | "delete" | "move" | "execute")
650        ) || !held.is_empty();
651        let entry = json!({
652            "tool": format!("agent:{}", call.kind.as_deref().unwrap_or("other")),
653            "arguments": arguments,
654            "title": crate::secrets::mask(&call.shown_title()),
655            "decision": "reported",
656            "ok": ok,
657            "by": "agent",
658            "call": call.id,
659        });
660        self.emit(&entry);
661        let unasked = if let Ok(mut s) = self.state.lock() {
662            s.said.clear();
663            // Done without an allowed request: its own settings let it. A
664            // failed one counts only when it never asked (a refusal fails).
665            let failed = call.status.as_deref() == Some("failed");
666            let new = effect
667                && ((ok && !s.allowed.contains(&call.id))
668                    || (failed && !s.decided.contains(&call.id)));
669            if new {
670                s.unasked += 1;
671            }
672            s.log.push(entry);
673            new.then_some(s.unasked)
674        } else {
675            None
676        };
677        if let Some(calls) = unasked {
678            self.event(Event::AgentUnasked {
679                task: self.task_id(),
680                calls,
681            });
682        }
683    }
684
685    /// Calls the agent reported changing something (an edit, a command)
686    /// that never reached speccoder as a request: its own settings let it
687    /// decide them.
688    #[must_use]
689    pub fn unasked(&self) -> u32 {
690        self.state.lock().map_or(0, |s| s.unasked)
691    }
692
693    /// Ends the attempt: nothing more is allowed.
694    pub fn stop(&self) {
695        if let Ok(mut s) = self.state.lock() {
696            s.stopped = true;
697        }
698    }
699
700    /// A path the agent named, relative to the worktree as it resolves — a
701    /// symlink followed, a relative path against the worktree; refused when
702    /// it lands outside.
703    fn rel(&self, path: &Path) -> Result<String, String> {
704        let full = if path.is_relative() {
705            self.root.join(path)
706        } else {
707            path.to_path_buf()
708        };
709        // Its nearest existing ancestor resolved (symlinks, `/tmp` and
710        // `/private/tmp`), the rest as written — never `..`.
711        let mut base = full.clone();
712        let mut rest = Vec::new();
713        while std::fs::symlink_metadata(&base).is_err() {
714            match (
715                base.file_name().map(std::ffi::OsStr::to_os_string),
716                base.parent(),
717            ) {
718                (Some(name), Some(parent)) => {
719                    rest.push(name);
720                    base = parent.to_path_buf();
721                }
722                _ => break,
723            }
724        }
725        let outside = || {
726            format!(
727                "`{}` is outside the worktree; an attempt works only inside it",
728                path.display()
729            )
730        };
731        let mut canonical = std::fs::canonicalize(&base).map_err(|_| outside())?;
732        for name in rest.iter().rev() {
733            if name == ".." {
734                return Err(outside());
735            }
736            canonical.push(name);
737        }
738        let r = canonical
739            .strip_prefix(&self.root)
740            .map_err(|_| outside())?
741            .to_string_lossy()
742            .replace('\\', "/");
743        Ok(if r.is_empty() { ".".to_owned() } else { r })
744    }
745
746    /// Whether a person's "reads for this task" covers the agent reading
747    /// `rel` itself: one existing regular file inside the worktree, no
748    /// symlink on its way, no other name for it (a hard link), under the
749    /// read cap; never `.git/` or speccoder's own, never ignored by git (an
750    /// error from git counts as ignored), never a credential by name or by
751    /// content, never a file copied from your checkout, never one the
752    /// secret masking would change — the agent's own read is not masked.
753    fn plain_file(&self, rel: &str) -> bool {
754        use std::os::unix::fs::MetadataExt as _;
755        // Any letter case: on a case-insensitive file system `.ENV` is `.env`.
756        let folded = rel.to_lowercase();
757        if self.policy.protected().is_secret(rel)
758            || self.policy.protected().is_secret(&folded)
759            || secret_name(rel)
760            || self.copied.iter().any(|c| c.eq_ignore_ascii_case(rel))
761            || folded
762                .split('/')
763                .any(|part| part == ".git" || part.starts_with(".speccoder"))
764        {
765            return false;
766        }
767        let mut at = self.root.clone();
768        for part in Path::new(rel).components() {
769            at.push(part);
770            match std::fs::symlink_metadata(&at) {
771                Ok(m) if !m.file_type().is_symlink() => {}
772                _ => return false,
773            }
774        }
775        let Ok(m) = std::fs::symlink_metadata(&at) else {
776            return false;
777        };
778        if !m.is_file() || m.nlink() != 1 || m.len() > crate::tools::READ_CAP as u64 {
779            return false;
780        }
781        // 1: not ignored; 0 (ignored) or anything else (an error): no.
782        let not_ignored = std::process::Command::new("git")
783            .args(["check-ignore", "-q", "--"])
784            .arg(rel)
785            .current_dir(&self.root)
786            .status()
787            .is_ok_and(|s| s.code() == Some(1));
788        if !not_ignored {
789            return false;
790        }
791        std::fs::read(&at).is_ok_and(|b| {
792            let text = String::from_utf8_lossy(&b);
793            crate::secrets::mask(&text) == text && !holds_a_credential(&text)
794        })
795    }
796
797    /// What a permission request comes to: only a plain read of one plain
798    /// file is allowed without a person; a credential, a protected path or
799    /// a place outside the worktree is refused; everything else is a
800    /// person's, decided once for exactly what it names.
801    fn judge(&self, call: &ToolRequest) -> Judged {
802        let raw = call.raw_input.clone().unwrap_or(Value::Null);
803        let kind = call.kind.as_deref().unwrap_or("other");
804        // Every command, anywhere in it.
805        let mut held = Vec::new();
806        strings_at(&raw, COMMAND_KEYS, &mut held);
807        let mut argvs: Vec<Vec<String>> = Vec::new();
808        for v in &held {
809            for argv in command_of(v) {
810                match shell_line(&argv).and_then(commands_of_line) {
811                    Some(parts) => argvs.extend(parts),
812                    None => argvs.push(argv),
813                }
814            }
815        }
816        argvs.retain(|a| !a.is_empty());
817        // Every path, anywhere in it, and its locations.
818        let mut named = Vec::new();
819        strings_at(&raw, PATH_KEYS, &mut named);
820        let mut paths: Vec<PathBuf> = call.locations.clone();
821        for v in &named {
822            match v {
823                Value::String(p) => paths.push(PathBuf::from(p)),
824                Value::Array(a) => {
825                    paths.extend(a.iter().filter_map(Value::as_str).map(PathBuf::from))
826                }
827                _ => {}
828            }
829        }
830        if let Some(changes) = raw.get("changes").and_then(Value::as_object) {
831            paths.extend(changes.keys().map(PathBuf::from));
832        }
833        let mut rels = Vec::new();
834        for p in &paths {
835            match self.rel(p) {
836                Ok(r) => rels.push(r),
837                Err(e) => return Judged::Deny(e),
838            }
839        }
840        // A plain-read line's file is a path it names too.
841        let reading = argvs.len() == 1 && plain_read(&argvs[0]).is_some();
842        if reading && let Some(file) = plain_read(&argvs[0]) {
843            match self.rel(Path::new(&file)) {
844                Ok(r) => rels.push(r),
845                Err(e) => return Judged::Deny(e),
846            }
847        }
848        rels.sort();
849        rels.dedup();
850        let writes = matches!(kind, "edit" | "delete" | "move");
851        for r in &rels {
852            if self.policy.protected().is_secret(r)
853                || self.policy.protected().is_secret(&r.to_lowercase())
854            {
855                return Judged::Deny(format!("`{r}` looks like a credential"));
856            }
857            if writes && let Some(class) = self.policy.write_protected(r) {
858                return Judged::Deny(format!(
859                    "`{r}` is protected: {}",
860                    self.policy.protected().reason(class)
861                ));
862            }
863        }
864        // A grant covers exactly this request again: its kind, its whole
865        // input and its locations — nothing normalised away.
866        let exact = Object::Agent {
867            agent: self.agent.clone(),
868            call: kind.to_owned(),
869            argvs: argvs.clone(),
870            paths: rels.clone(),
871            input: crate::integrity::sha256_hex(
872                canonical(&json!([kind, raw, call.locations])).as_bytes(),
873            ),
874        };
875        if kind == "fetch" {
876            return Judged::Deny(
877                "fetching from the network: an attempt's commands run without it".to_owned(),
878            );
879        }
880        if kind == "switch_mode" {
881            return Judged::Deny(
882                "the agent is kept in the mode that asks before acting".to_owned(),
883            );
884        }
885        if kind == "think" && argvs.is_empty() && keeps_to("think", &raw) {
886            return Judged::Think;
887        }
888        // A read of one file: what a person may allow for the whole task.
889        let read_shaped = match kind {
890            "read" => argvs.is_empty() && keeps_to("read", &raw) && rels.len() == 1,
891            "execute" => reading && rels.len() == 1 && execute_keeps_to(&raw, &argvs[0]),
892            _ => false,
893        };
894        if read_shaped && self.plain_file(&rels[0]) {
895            let reads = self.reads_object();
896            if let Some(scope) = self.granted(&reads) {
897                return Judged::Granted(rels[0].clone(), scope);
898            }
899            return Judged::Person(format!("the agent reads `{}` itself", rels[0]), reads);
900        }
901        let why = if !argvs.is_empty() || kind == "execute" {
902            RUNS_ITSELF.to_owned()
903        } else if !keeps_to(kind, &raw) {
904            "speccoder does not know everything this call carries".to_owned()
905        } else {
906            match kind {
907                "read" | "search" => "the agent reads files itself".to_owned(),
908                "edit" | "delete" | "move" => format!("the agent changes files itself ({kind})"),
909                _ => "no rule can tell what this call does".to_owned(),
910            }
911        };
912        Judged::Person(why, exact)
913    }
914
915    /// The object of a person's "reads for this task".
916    fn reads_object(&self) -> Object {
917        Object::Agent {
918            agent: self.agent.clone(),
919            call: READS_GRANT.to_owned(),
920            argvs: Vec::new(),
921            paths: Vec::new(),
922            input: String::new(),
923        }
924    }
925
926    /// Answers a permission request: the agent's allow-once option when
927    /// allowed (a plain read, or a person's yes), its reject option when
928    /// not, `None` (cancelled) when the attempt ends here.
929    pub fn permission(&self, call: &ToolRequest, options: &[PermissionOption]) -> Option<String> {
930        let pick = |kinds: &[&str]| {
931            kinds
932                .iter()
933                .find_map(|k| options.iter().find(|o| o.kind == *k))
934                .map(|o| o.id.clone())
935        };
936        let reject = || pick(&["reject_once", "reject_always"]);
937        if let Ok(mut s) = self.state.lock() {
938            s.decided.insert(call.id.clone());
939            s.request = Some(call.id.clone());
940        }
941        let args = call_args(call);
942        let verdict = if self.stopped() {
943            Verdict::Stop
944        } else {
945            match self.judge(call) {
946                Judged::Granted(rel, scope) => {
947                    self.record(json!({ "tool": "read_file", "arguments": { "path": rel }, "decision": "allow", "ok": true, "by": "person", "approved_by": "person", "scope": scope, "object": self.reads_object(), "rule": "your reads for this task" }));
948                    Verdict::Allow(None)
949                }
950                Judged::Think => {
951                    self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "allow", "by": "rule", "rule": "thinking" }));
952                    Verdict::Allow(None)
953                }
954                Judged::Deny(reason) => {
955                    self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "deny", "reason": reason, "by": "rule", "rule": "agent call" }));
956                    Verdict::Deny(reason)
957                }
958                Judged::Person(reason, object) => self.person(&args, &reason, object),
959            }
960        };
961        let answer = match &verdict {
962            // Never an option the agent would remember: a later call must
963            // reach speccoder again.
964            Verdict::Allow(_) => pick(&["allow_once"]).or_else(|| {
965                self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "deny", "reason": "the agent offered no allow-once option", "by": "rule", "rule": "agent options" }));
966                reject()
967            }),
968            Verdict::Deny(_) => reject(),
969            Verdict::Stop => None,
970        };
971        if answer.is_some()
972            && answer == pick(&["allow_once"])
973            && let Ok(mut s) = self.state.lock()
974        {
975            s.allowed.insert(call.id.clone());
976        }
977        self.settle_request(
978            call,
979            match (&verdict, &answer) {
980                (Verdict::Allow(_), Some(a)) if Some(a) == pick(&["allow_once"]).as_ref() => {
981                    "allow"
982                }
983                (Verdict::Stop, _) => "approval",
984                _ => "deny",
985            },
986        );
987        answer
988    }
989
990    /// Ends the request being decided, said as one line.
991    fn settle_request(&self, call: &ToolRequest, decision: &str) {
992        let request = self.state.lock().ok().and_then(|mut s| s.request.take());
993        if request.is_some() {
994            let target = crate::secrets::mask(&call.shown_title());
995            self.emit(&json!({
996                "tool": format!("agent:{}", call.kind.as_deref().unwrap_or("other")),
997                "arguments": {},
998                "title": target,
999                "decision": decision,
1000                "ok": decision == "allow",
1001            }));
1002        }
1003    }
1004
1005    /// A decision that could not be made (it failed): refused, and the
1006    /// request closed.
1007    pub fn abandon(&self, call: &ToolRequest) {
1008        self.record(json!({ "tool": AGENT_CALL, "arguments": call_args(call), "decision": "deny", "reason": "the decision failed", "by": "rule", "rule": "failure" }));
1009        self.settle_request(call, "deny");
1010    }
1011
1012    /// A call that is a person's: their grant for exactly `object`, their
1013    /// rejection of it, or the question.
1014    fn person(&self, args: &Value, reason: &str, object: Object) -> Verdict {
1015        if let Some(r) = self.policy.rejection(&object) {
1016            let why = format!("a person rejected this: {}", r.reason);
1017            self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "deny", "reason": why, "object": object, "by": "person", "scope": "task-grant" }));
1018            return Verdict::Deny(why);
1019        }
1020        if let Some(scope) = self.granted(&object) {
1021            self.record(json!({ "tool": AGENT_CALL, "arguments": args, "decision": "allow", "ok": true, "object": object, "by": "person", "approved_by": "person", "scope": scope }));
1022            return Verdict::Allow(Some(object));
1023        }
1024        let mut request = self.request(AGENT_CALL, args, reason, Some(&object));
1025        // A read: "for this task" is every plain read of the task.
1026        if matches!(&object, Object::Agent { call, .. } if call == READS_GRANT) {
1027            request.kind = READS_GRANT.to_owned();
1028        }
1029        self.ask(AGENT_CALL, args, Some(object), reason, request)
1030    }
1031
1032    /// The grant that covers `object` here: the task's (by the policy),
1033    /// one call's (used up), or the run's or one made in this attempt.
1034    fn granted(&self, object: &Object) -> Option<&'static str> {
1035        if self.policy.approved().iter().any(|a| a == object) {
1036            return Some("task-grant");
1037        }
1038        let mut s = self.state.lock().ok()?;
1039        if let Some(at) = s.once.iter().position(|r| r == object) {
1040            s.once.remove(at);
1041            return Some("once-grant");
1042        }
1043        s.remembered
1044            .iter()
1045            .find(|(r, _)| r == object)
1046            .map(|(_, scope)| *scope)
1047    }
1048
1049    /// Decides one speccoder tool call as a native attempt would.
1050    pub fn decide(&self, tool: &str, args: &Value, call: Option<&ToolRequest>) -> Verdict {
1051        let _ = call;
1052        self.decide_as(tool, args)
1053    }
1054
1055    /// [`Governor::decide`], for a call speccoder itself carries out
1056    /// (a file, a terminal command in its sandbox).
1057    fn decide_as(&self, tool: &str, args: &Value) -> Verdict {
1058        if self.stopped() {
1059            return Verdict::Deny("the attempt is ending".to_owned());
1060        }
1061        let mut decision = self.policy.base(tool, args);
1062        // `[tests] frozen = "additions"`: a write that keeps every line of
1063        // the frozen test is allowed.
1064        let mut additions_only = false;
1065        if self.frozen_additions
1066            && tool == "write_file"
1067            && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1068            && let (Some(path), Some(new)) = (args["path"].as_str(), args["content"].as_str())
1069            && let Ok(old) = std::fs::read_to_string(self.root.join(path))
1070            && crate::integrity::only_additions(&old, new)
1071        {
1072            decision = Decision::Allow;
1073            additions_only = true;
1074        }
1075        let mut object: Option<Object> = None;
1076        if matches!(decision, Decision::Approval(_)) {
1077            let resolved = self.tools.resolve(tool, args);
1078            if resolved["ok"] == true {
1079                object = serde_json::from_value(resolved["object"].clone()).ok();
1080                decision = self.policy.classify(tool, args, object.as_ref());
1081            } else {
1082                decision = Decision::Deny(
1083                    resolved["error"]
1084                        .as_str()
1085                        .unwrap_or("the call's object cannot be resolved")
1086                        .to_owned(),
1087                );
1088            }
1089        }
1090        let mut person: Option<Value> = None;
1091        if decision == Decision::Allow && object.is_some() {
1092            person = Some(
1093                json!({ "approved_by": "person", "scope": "task-grant", "grant": { "scope": "task", "run": self.run } }),
1094            );
1095        }
1096        if matches!(decision, Decision::Approval(_))
1097            && let Some(o) = &object
1098            && let Ok(mut s) = self.state.lock()
1099        {
1100            if let Some(at) = s.once.iter().position(|r| r == o) {
1101                s.once.remove(at);
1102                person = Some(
1103                    json!({ "approved_by": "person", "scope": "once-grant", "grant": { "scope": "once", "run": self.run } }),
1104                );
1105                decision = Decision::Allow;
1106            } else if let Some((_, scope)) = s.remembered.iter().find(|(r, _)| r == o) {
1107                person = Some(
1108                    json!({ "approved_by": "person", "scope": scope, "grant": { "scope": scope.trim_end_matches("-grant"), "run": self.run } }),
1109                );
1110                decision = Decision::Allow;
1111            }
1112        }
1113        decision = self.policy.settle(decision);
1114        match decision {
1115            Decision::Allow => {
1116                let mut entry =
1117                    json!({ "tool": tool, "arguments": args, "decision": "allow", "ok": true });
1118                if tool == "run_command" {
1119                    entry["executed_by"] = json!("speccoder");
1120                    entry["sandboxed"] = json!(true);
1121                }
1122                self.decided_by(&mut entry, object.as_ref(), person.as_ref(), tool, args);
1123                if additions_only {
1124                    entry["frozen_additions"] = json!(true);
1125                }
1126                self.record(entry);
1127                Verdict::Allow(object)
1128            }
1129            Decision::Deny(reason) => {
1130                let mut entry = json!({ "tool": tool, "arguments": args, "decision": "deny", "reason": reason });
1131                self.decided_by(&mut entry, object.as_ref(), person.as_ref(), tool, args);
1132                self.record(entry);
1133                Verdict::Deny(reason)
1134            }
1135            Decision::Approval(reason) => {
1136                let request = self.request(tool, args, &reason, object.as_ref());
1137                self.ask(tool, args, object, &reason, request)
1138            }
1139        }
1140    }
1141
1142    /// Who decided an entry: a person (by a grant), or a rule.
1143    fn decided_by(
1144        &self,
1145        entry: &mut Value,
1146        object: Option<&Object>,
1147        person: Option<&Value>,
1148        tool: &str,
1149        args: &Value,
1150    ) {
1151        if let Some(o) = object {
1152            entry["object"] = json!(o);
1153        }
1154        match person.and_then(Value::as_object) {
1155            Some(p) => {
1156                entry["by"] = json!("person");
1157                if let Some(e) = entry.as_object_mut() {
1158                    e.extend(p.clone());
1159                }
1160            }
1161            None => {
1162                entry["by"] = json!("rule");
1163                entry["rule"] = json!(self.policy.rule(tool, args, object));
1164            }
1165        }
1166    }
1167
1168    /// The question for a call that needs a person, as a native attempt
1169    /// builds it.
1170    fn request(&self, tool: &str, args: &Value, reason: &str, object: Option<&Object>) -> Request {
1171        let kind = crate::approvals::kind_of(tool, args);
1172        let risk = crate::approvals::risk(tool, reason);
1173        let said = self
1174            .state
1175            .lock()
1176            .map(|s| s.said.clone())
1177            .unwrap_or_default();
1178        let changed = object.and_then(|o| {
1179            self.policy
1180                .approved()
1181                .iter()
1182                .find_map(|earlier| o.changed_from(earlier))
1183        });
1184        Request {
1185            task: self.task.clone(),
1186            task_title: self.task_title.clone(),
1187            turn: 0,
1188            tool: tool.to_owned(),
1189            arguments: args.clone(),
1190            kind: kind.to_owned(),
1191            reason: reason.to_owned(),
1192            object: object.cloned(),
1193            changed,
1194            risk: risk.to_owned(),
1195            claim: crate::approvals::claim(&crate::secrets::mask(&said)),
1196            options: crate::approvals::options(risk, object.is_some(), false, kind, risk),
1197            run: self.run.clone(),
1198            ..Request::default()
1199        }
1200    }
1201
1202    /// Asks a person, or ends the attempt with the call waiting.
1203    fn ask(
1204        &self,
1205        tool: &str,
1206        args: &Value,
1207        object: Option<Object>,
1208        reason: &str,
1209        mut request: Request,
1210    ) -> Verdict {
1211        self.event(Event::ApprovalRequested {
1212            task: self.task_id(),
1213            call: String::new(),
1214            tool: tool.to_owned(),
1215            what: format!("{tool} {}", crate::progress::target(tool, args))
1216                .chars()
1217                .filter(|c| !c.is_control())
1218                .take(60)
1219                .collect(),
1220            class: object
1221                .as_ref()
1222                .map(|o| o.class().as_str().to_owned())
1223                .unwrap_or_default(),
1224            object: object.as_ref().map(Object::short).unwrap_or_default(),
1225        });
1226        let Some(approver) = &self.approver else {
1227            return self.wait(tool, args, object, reason, request);
1228        };
1229        if let Ok(mut s) = self.state.lock() {
1230            s.asked += 1;
1231            request.asked = s.asked;
1232        }
1233        let context = PresentContext {
1234            root: Some(self.root.clone()),
1235            ..self.present.clone()
1236        };
1237        let began = std::time::Instant::now();
1238        if let Ok(mut s) = self.asking_since.lock() {
1239            *s = Some(began);
1240        }
1241        let answer: Answer = approver.ask(&present(&request, &context));
1242        self.asking.fetch_add(
1243            u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX),
1244            std::sync::atomic::Ordering::SeqCst,
1245        );
1246        if let Ok(mut s) = self.asking_since.lock() {
1247            *s = None;
1248        }
1249        // The attempt ended while the person decided (the agent left its
1250        // mode, the time was up): nothing is allowed any more.
1251        let answer = if self.stopped() && answer.decision == Said::Approve {
1252            Answer::reject("the attempt ended while you decided", answer.by)
1253        } else {
1254            answer
1255        };
1256        let by = answer.by.clone();
1257        let approver_v = serde_json::to_value(&by).unwrap_or(Value::Null);
1258        let operator = by.as_ref().map(Who::view);
1259        self.event(Event::ApprovalDecided {
1260            task: self.task_id(),
1261            call: String::new(),
1262            decision: if answer.decision == Said::Approve {
1263                "approve"
1264            } else {
1265                "reject"
1266            }
1267            .to_owned(),
1268            scope: (answer.decision == Said::Approve).then(|| answer.scope.name().to_owned()),
1269            by: by
1270                .as_ref()
1271                .map_or_else(|| "unknown".to_owned(), |w| w.name.clone()),
1272            how: by
1273                .as_ref()
1274                .map_or_else(|| "unknown".to_owned(), |w| w.basis.clone()),
1275            class: object
1276                .as_ref()
1277                .map(|o| o.class().as_str().to_owned())
1278                .unwrap_or_default(),
1279            object: object.as_ref().map(Object::short).unwrap_or_default(),
1280        });
1281        let mut entry =
1282            json!({ "tool": tool, "arguments": args, "reason": reason, "by": "person" });
1283        if let Some(o) = &object {
1284            entry["object"] = json!(o);
1285        }
1286        match answer.decision {
1287            Said::Approve => {
1288                entry["decision"] = json!("allow");
1289                entry["ok"] = json!(true);
1290                entry["approved_by"] = json!("person");
1291                entry["scope"] = json!(answer.scope.name());
1292                entry["note"] = json!(answer.note);
1293                entry["approver"] = approver_v;
1294                entry["operator"] = json!(operator);
1295                if answer.always {
1296                    entry["always"] = json!(true);
1297                }
1298                if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1299                    entry["grant"] = json!({ "scope": answer.scope.name(), "run": self.run });
1300                    if let Ok(mut s) = self.state.lock() {
1301                        s.remembered.push((
1302                            o.clone(),
1303                            if answer.scope == Scope::Run {
1304                                "run-grant"
1305                            } else {
1306                                "task-grant"
1307                            },
1308                        ));
1309                    }
1310                }
1311                self.record(entry);
1312                Verdict::Allow(object)
1313            }
1314            Said::Reject => {
1315                let why = answer
1316                    .reason
1317                    .unwrap_or_else(|| "no reason given".to_owned());
1318                entry["decision"] = json!("deny");
1319                entry["rejected_by"] = json!("person");
1320                entry["approver"] = approver_v;
1321                entry["operator"] = json!(operator);
1322                entry["rejection"] = json!(why);
1323                self.record(entry);
1324                Verdict::Deny(format!("a person rejected this: {why}"))
1325            }
1326            // The task ends here; the call waits for `task approve`.
1327            Said::Stop => self.wait(tool, args, object, reason, request),
1328        }
1329    }
1330
1331    /// The call waits for a person: the attempt ends `needs_approval`.
1332    fn wait(
1333        &self,
1334        tool: &str,
1335        args: &Value,
1336        object: Option<Object>,
1337        reason: &str,
1338        request: Request,
1339    ) -> Verdict {
1340        let mut entry =
1341            json!({ "tool": tool, "arguments": args, "decision": "approval", "reason": reason });
1342        if let Some(o) = &object {
1343            entry["object"] = json!(o);
1344        }
1345        self.record(entry);
1346        if let Ok(mut s) = self.state.lock() {
1347            if s.pending.is_none() {
1348                s.pending = Some(json!({
1349                    "tool": tool,
1350                    "arguments": args,
1351                    "reason": reason,
1352                    "object": object,
1353                    "run": self.run,
1354                    "request": request,
1355                }));
1356            }
1357            s.stopped = true;
1358        }
1359        Verdict::Stop
1360    }
1361
1362    /// `fs/read_text_file`: a file in the worktree, read through the
1363    /// workspace tools (credentials refused, copied secrets masked).
1364    ///
1365    /// # Errors
1366    ///
1367    /// Why it is refused or cannot be read.
1368    pub async fn read_text_file(
1369        &self,
1370        path: &Path,
1371        line: Option<u32>,
1372        limit: Option<u32>,
1373    ) -> Result<String, String> {
1374        let rel = self.rel(path)?;
1375        let start = line.map(|l| l.max(1) as usize);
1376        let end = limit.map(|n| start.unwrap_or(1) + (n as usize).saturating_sub(1));
1377        let args = json!({ "path": rel, "start_line": start, "end_line": end });
1378        match self.decide("read_file", &json!({ "path": rel }), None) {
1379            Verdict::Allow(_) => {}
1380            Verdict::Deny(r) => return Err(format!("denied: {r}")),
1381            Verdict::Stop => return Err("the attempt is ending".to_owned()),
1382        }
1383        let out = self.tools.run("read_file", &args).await;
1384        if out["ok"] != true {
1385            return Err(out["error"].as_str().unwrap_or("not readable").to_owned());
1386        }
1387        if out["binary"] == true {
1388            return Err(format!("`{rel}` is not a text file"));
1389        }
1390        let mut content = out["content"].as_str().unwrap_or_default().to_owned();
1391        // The tool joins lines with `\n`; the file's last one is kept.
1392        if out["end_line"] == out["total_lines"]
1393            && out["truncated"] != true
1394            && std::fs::read(self.root.join(&rel)).is_ok_and(|b| b.ends_with(b"\n"))
1395        {
1396            content.push('\n');
1397        }
1398        Ok(content)
1399    }
1400
1401    /// `fs/write_text_file`: a file in the worktree, written through the
1402    /// workspace tools (protected paths refused, frozen tests a person's).
1403    ///
1404    /// # Errors
1405    ///
1406    /// Why it is refused or cannot be written.
1407    pub async fn write_text_file(&self, path: &Path, content: &str) -> Result<(), String> {
1408        let rel = self.rel(path)?;
1409        let args = json!({ "path": rel, "content": content });
1410        let object = match self.decide("write_file", &args, None) {
1411            Verdict::Allow(o) => o,
1412            Verdict::Deny(r) => return Err(format!("denied: {r}")),
1413            Verdict::Stop => {
1414                return Err("the attempt is ending: this write waits for a person".to_owned());
1415            }
1416        };
1417        let mut call = args;
1418        if let Some(o) = object {
1419            call[crate::tools::EXPECT] = json!(o);
1420        }
1421        let out = self.tools.run("write_file", &call).await;
1422        if out["ok"] == true {
1423            Ok(())
1424        } else {
1425            Err(out["error"].as_str().unwrap_or("not written").to_owned())
1426        }
1427    }
1428
1429    /// `terminal/create`: whether the command may run, and how — its argv,
1430    /// directory and scrubbed environment.
1431    ///
1432    /// # Errors
1433    ///
1434    /// Why it is refused.
1435    pub fn command(&self, argv: Vec<String>, cwd: Option<&Path>) -> Result<Command, String> {
1436        let mut args = json!({ "argv": argv });
1437        if let Some(c) = cwd {
1438            args["cwd"] = json!(self.rel(c)?);
1439        }
1440        let object = match self.decide("run_command", &args, None) {
1441            Verdict::Allow(o) => o,
1442            Verdict::Deny(r) => return Err(format!("denied: {r}")),
1443            Verdict::Stop => {
1444                return Err("the attempt is ending: this command waits for a person".to_owned());
1445            }
1446        };
1447        let dir = self.tools.command_cwd(args["cwd"].as_str()).map_err(|e| {
1448            e["error"]
1449                .as_str()
1450                .unwrap_or("no such directory")
1451                .to_owned()
1452        })?;
1453        // Decided on an object: it runs only as that object.
1454        if let Some(Object::Command { executable, .. }) = object {
1455            let now = self.tools.resolve("run_command", &args);
1456            if now["object"]["executable"] != json!(executable) {
1457                return Err(format!(
1458                    "the object changed since it was approved: {executable} → {}",
1459                    now["object"]["executable"]
1460                ));
1461            }
1462        }
1463        Ok((
1464            crate::tools::policy::argv_of(&args),
1465            dir,
1466            self.tools.command_env(None),
1467        ))
1468    }
1469
1470    fn task_id(&self) -> String {
1471        self.task
1472            .rsplit('/')
1473            .next()
1474            .unwrap_or(&self.task)
1475            .to_owned()
1476    }
1477
1478    fn event(&self, e: Event) {
1479        if let Some((_, p)) = &self.progress {
1480            p.event(&e);
1481        }
1482    }
1483
1484    fn emit(&self, entry: &Value) {
1485        let mut entry = entry.clone();
1486        cap(&mut entry);
1487        crate::secrets::mask_json(&mut entry);
1488        let entry = &entry;
1489        if let Some((task, p)) = &self.progress {
1490            let tool = entry["tool"].as_str().unwrap_or_default();
1491            p.event(&Event::ToolCall {
1492                task: task.clone(),
1493                turn: 0,
1494                tool: tool.to_owned(),
1495                target: match entry["title"].as_str() {
1496                    Some(t) => crate::progress::target("", &json!({ "path": t })),
1497                    None => crate::progress::target(tool, &entry["arguments"]),
1498                },
1499                decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
1500                ok: entry["ok"].as_bool(),
1501                tail: Vec::new(),
1502            });
1503        }
1504    }
1505
1506    fn record(&self, mut entry: Value) {
1507        entry["via"] = json!("acp");
1508        // An action the agent takes itself: said so.
1509        if entry["object"]["kind"] == "agent" {
1510            entry["executed_by"] = json!("agent");
1511            entry["sandboxed"] = json!(false);
1512        }
1513        // What the agent sent is kept masked, and cut.
1514        cap(&mut entry);
1515        crate::secrets::mask_json(&mut entry);
1516        let Ok(mut s) = self.state.lock() else { return };
1517        // A permission request's decisions are said once, as one line.
1518        match &s.request {
1519            Some(id) => entry["request"] = json!(id),
1520            None => self.emit(&entry),
1521        }
1522        s.log.push(entry);
1523    }
1524}
1525
1526/// What a person sees of a call no rule covers (masked, long values cut).
1527fn call_args(call: &ToolRequest) -> Value {
1528    let mut raw = call.raw_input.clone().unwrap_or(Value::Null);
1529    cap(&mut raw);
1530    let mut v = json!({
1531        "title": call.shown_title(),
1532        "kind": call.kind,
1533        "rawInput": raw,
1534        "locations": call.locations,
1535    });
1536    crate::secrets::mask_json(&mut v);
1537    v
1538}
1539
1540/// The most of one string of an agent's reported input that is kept.
1541const KEPT: usize = 4096;
1542
1543/// Cuts every string of `v` to [`KEPT`] bytes (a file's whole content is
1544/// not the report's to keep).
1545fn cap(v: &mut Value) {
1546    match v {
1547        Value::String(s) if s.len() > KEPT => {
1548            let mut at = KEPT;
1549            while !s.is_char_boundary(at) {
1550                at -= 1;
1551            }
1552            let n = s.len();
1553            s.truncate(at);
1554            s.push_str(&format!("… [{n} bytes]"));
1555        }
1556        Value::Array(a) => a.iter_mut().for_each(cap),
1557        Value::Object(o) => o.values_mut().for_each(cap),
1558        _ => {}
1559    }
1560}
1561
1562#[cfg(test)]
1563mod tests {
1564    use super::*;
1565
1566    /// A worktree with a file, credentials, a copied file, a hard link and
1567    /// symlinks out of it.
1568    struct Tree {
1569        _d: tempfile::TempDir,
1570        _o: tempfile::TempDir,
1571        root: PathBuf,
1572        out: PathBuf,
1573    }
1574
1575    fn tree() -> Tree {
1576        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1577        let o = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1578        let root = std::fs::canonicalize(d.path()).unwrap_or_else(|e| panic!("{e}"));
1579        let out = std::fs::canonicalize(o.path()).unwrap_or_else(|e| panic!("{e}"));
1580        let w = |p: &str, t: &str| std::fs::write(root.join(p), t).unwrap_or_default();
1581        std::fs::create_dir_all(root.join("src")).unwrap_or_default();
1582        w("src/lib.rs", "x\n");
1583        w(".env", "TOKEN=abc\n");
1584        w("settings.local", "TOKEN=abc\n");
1585        w(".git-credentials", "https://u:p@example.com\n");
1586        w("terraform.tfstate", "{}\n");
1587        std::fs::write(out.join("credentials"), "secret\n").unwrap_or_default();
1588        std::fs::hard_link(out.join("credentials"), root.join("hard.txt")).unwrap_or_default();
1589        std::os::unix::fs::symlink(out.join("credentials"), root.join("notes.txt"))
1590            .unwrap_or_default();
1591        std::os::unix::fs::symlink(&out, root.join("docs")).unwrap_or_default();
1592        let _ = std::process::Command::new("git")
1593            .args(["init", "-q"])
1594            .current_dir(&root)
1595            .output();
1596        Tree {
1597            _d: d,
1598            _o: o,
1599            root,
1600            out,
1601        }
1602    }
1603
1604    fn governor_with(root: &Path, run_approved: Vec<Object>) -> Governor {
1605        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1606        let policy = Policy::new(
1607            &[argv("cargo test"), argv("git diff")],
1608            &[argv("make deploy")],
1609        );
1610        let tools = WorkspaceTools::new(root, policy.clone()).unwrap_or_else(|e| panic!("{e}"));
1611        Governor::new(Setup {
1612            root: root.to_path_buf(),
1613            tools,
1614            policy,
1615            approver: None,
1616            present: PresentContext::default(),
1617            task: "f/T1".to_owned(),
1618            task_title: "t".to_owned(),
1619            run: None,
1620            frozen_additions: false,
1621            run_approved,
1622            approved_once: Vec::new(),
1623            progress: None,
1624            copied: vec!["settings.local".to_owned()],
1625            agent: "copilot".to_owned(),
1626        })
1627    }
1628
1629    const OPTIONS: [(&str, &str); 2] = [("allow", "allow_once"), ("reject", "reject_once")];
1630
1631    fn options() -> Vec<PermissionOption> {
1632        OPTIONS
1633            .iter()
1634            .map(|(id, kind)| PermissionOption {
1635                id: (*id).to_owned(),
1636                kind: (*kind).to_owned(),
1637            })
1638            .collect()
1639    }
1640
1641    fn call(kind: &str, raw: Value, locations: Vec<PathBuf>) -> ToolRequest {
1642        ToolRequest {
1643            id: "c1".to_owned(),
1644            title: Some("Read file".to_owned()),
1645            kind: Some(kind.to_owned()),
1646            raw_input: Some(raw),
1647            locations,
1648            status: None,
1649        }
1650    }
1651
1652    /// What the agent is answered for one request, by a fresh governor:
1653    /// `allow`, `reject`, or `None` (a person's, nobody to ask).
1654    fn answer(t: &Tree, kind: &str, raw: Value, locations: Vec<PathBuf>) -> Option<String> {
1655        governor_with(&t.root, Vec::new()).permission(&call(kind, raw, locations), &options())
1656    }
1657
1658    fn allow() -> Option<String> {
1659        Some("allow".to_owned())
1660    }
1661
1662    /// The person's "reads for this task", as a grant.
1663    fn reads(t: &Tree) -> Governor {
1664        let g = governor_with(&t.root, Vec::new());
1665        let r = g.reads_object();
1666        governor_with(&t.root, vec![r])
1667    }
1668
1669    const PLAIN_READS: [(&str, &str); 5] = [
1670        ("execute", r#"{ "command": "cat src/lib.rs" }"#),
1671        ("execute", r#"{ "command": "cat\tsrc/lib.rs" }"#),
1672        ("execute", r#"{ "command": "head -n 5 src/lib.rs" }"#),
1673        (
1674            "execute",
1675            r#"{ "command": ["/bin/zsh", "-lc", "cat src/lib.rs"] }"#,
1676        ),
1677        ("read", r#"{ "path": "src/lib.rs", "line": 1, "limit": 5 }"#),
1678    ];
1679
1680    #[test]
1681    fn nothing_the_agent_does_itself_is_allowed_without_a_person() {
1682        let t = tree();
1683        for (kind, raw) in PLAIN_READS {
1684            let raw: Value = serde_json::from_str(raw).unwrap_or_default();
1685            assert_eq!(answer(&t, kind, raw.clone(), vec![]), None, "{raw}");
1686        }
1687    }
1688
1689    #[test]
1690    fn a_persons_reads_for_the_task_cover_plain_reads_of_plain_files_only() {
1691        let t = tree();
1692        for (kind, raw) in PLAIN_READS {
1693            let raw: Value = serde_json::from_str(raw).unwrap_or_default();
1694            assert_eq!(
1695                reads(&t).permission(&call(kind, raw.clone(), vec![]), &options()),
1696                allow(),
1697                "{raw}"
1698            );
1699        }
1700        let w = |p: &str, text: &str| {
1701            if let Some(dir) = Path::new(p).parent() {
1702                std::fs::create_dir_all(t.root.join(dir)).unwrap_or_default();
1703            }
1704            std::fs::write(t.root.join(p), text).unwrap_or_default();
1705        };
1706        w("deploy_key", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n");
1707        w(
1708            "notes/key.pem.txt",
1709            "-----BEGIN RSA PRIVATE KEY-----\nabc\n",
1710        );
1711        w("config.ini", "password: hunter2hunter2\n");
1712        w(
1713            ".git/config",
1714            "[remote]\nurl = https://user:pass@example.com/r.git\n",
1715        );
1716        w(
1717            "conf/remote.txt",
1718            "url = https://user:pass@example.com/r.git\n",
1719        );
1720        w("secrets.yaml", "x: 1\n");
1721        w(".htpasswd", "admin:$apr1$abc$def\n");
1722        w("token.txt", "abcdefabcdefabcdef\n");
1723        w("ci.txt", "GH=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345\n");
1724        w(".speccoder/x", "x\n");
1725        for file in [
1726            "deploy_key",
1727            "notes/key.pem.txt",
1728            "config.ini",
1729            ".git/config",
1730            "conf/remote.txt",
1731            "secrets.yaml",
1732            ".htpasswd",
1733            "token.txt",
1734            "ci.txt",
1735            ".speccoder/x",
1736            "settings.local",
1737            "hard.txt",
1738            "terraform.tfstate",
1739        ] {
1740            let raw = json!({ "command": format!("cat {file}") });
1741            assert_ne!(
1742                reads(&t).permission(&call("execute", raw, vec![]), &options()),
1743                allow(),
1744                "{file}"
1745            );
1746        }
1747        // A command in a request of another kind never takes the read path,
1748        // nor an execute with more than a plain read needs.
1749        for (kind, raw) in [
1750            ("edit", json!({ "command": "cat src/lib.rs" })),
1751            ("delete", json!({ "command": "cat src/lib.rs" })),
1752            ("move", json!({ "command": "cat src/lib.rs" })),
1753            ("other", json!({ "command": "cat src/lib.rs" })),
1754            ("think", json!({ "command": "cat src/lib.rs" })),
1755            (
1756                "switch_mode",
1757                json!({ "command": "cat src/lib.rs", "mode": "bypassPermissions" }),
1758            ),
1759            ("fetch", json!({ "command": "cat src/lib.rs" })),
1760            ("read", json!({ "command": "cat src/lib.rs" })),
1761            (
1762                "execute",
1763                json!({ "command": "cat lib.rs", "working_directory": "src" }),
1764            ),
1765            (
1766                "execute",
1767                json!({ "command": "cat src/lib.rs", "env": { "LD_PRELOAD": "x" } }),
1768            ),
1769            (
1770                "execute",
1771                json!({ "command": "cat src/lib.rs", "stdin": "x" }),
1772            ),
1773        ] {
1774            assert_ne!(
1775                reads(&t).permission(&call(kind, raw.clone(), vec![]), &options()),
1776                allow(),
1777                "{kind} {raw}"
1778            );
1779        }
1780    }
1781
1782    /// A person who answers from a list, then no.
1783    #[derive(Debug)]
1784    struct Answers(Mutex<Vec<Answer>>);
1785
1786    impl Approver for Answers {
1787        fn ask(&self, _: &crate::approvals::Presentation) -> Answer {
1788            let mut a = self
1789                .0
1790                .lock()
1791                .unwrap_or_else(std::sync::PoisonError::into_inner);
1792            if a.is_empty() {
1793                Answer::reject("no", None)
1794            } else {
1795                a.remove(0)
1796            }
1797        }
1798    }
1799
1800    #[test]
1801    fn for_this_task_covers_exactly_that_request_again() {
1802        let t = tree();
1803        for (granted, same, other) in [
1804            (
1805                json!({ "command": "make a && make b" }),
1806                json!({ "command": "make a && make b" }),
1807                json!({ "command": "make a; make b" }),
1808            ),
1809            (
1810                json!({ "command": "make" }),
1811                json!({ "command": "make" }),
1812                json!({ "command": "make", "cwd": "src" }),
1813            ),
1814            (
1815                json!({ "command": "make" }),
1816                json!({ "command": "make" }),
1817                json!({ "command": "make", "env": { "A": "1" } }),
1818            ),
1819            (
1820                json!({ "path": "src/a", "destination": "src/b" }),
1821                json!({ "path": "src/a", "destination": "src/b" }),
1822                json!({ "path": "src/b", "destination": "src/a" }),
1823            ),
1824            (
1825                json!({ "file_path": "src/lib.rs", "content": "a" }),
1826                json!({ "file_path": "src/lib.rs", "content": "a" }),
1827                json!({ "file_path": "src/lib.rs", "content": "b" }),
1828            ),
1829        ] {
1830            let kind = if granted.get("command").is_some() {
1831                "execute"
1832            } else if granted.get("destination").is_some() {
1833                "move"
1834            } else {
1835                "edit"
1836            };
1837            let mut g = governor_with(&t.root, Vec::new());
1838            g.approver = Some(Arc::new(Answers(Mutex::new(vec![Answer::approve(
1839                Scope::Task,
1840                None,
1841                None,
1842            )]))));
1843            assert_eq!(
1844                g.permission(&call(kind, granted.clone(), vec![]), &options()),
1845                allow()
1846            );
1847            assert_eq!(
1848                g.permission(&call(kind, same, vec![]), &options()),
1849                allow(),
1850                "{granted}"
1851            );
1852            assert_eq!(
1853                g.permission(&call(kind, other.clone(), vec![]), &options()),
1854                Some("reject".to_owned()),
1855                "{other}"
1856            );
1857        }
1858    }
1859
1860    #[test]
1861    fn a_request_naming_anything_outside_or_a_credential_is_refused() {
1862        let t = tree();
1863        let src = || vec![PathBuf::from("src/lib.rs")];
1864        let refused = Some("reject".to_owned());
1865        for (kind, raw, loc) in [
1866            ("read", json!({ "path": t.out.join("credentials") }), src()),
1867            (
1868                "edit",
1869                json!({ "file_path": t.out.join("x"), "content": "x" }),
1870                src(),
1871            ),
1872            (
1873                "move",
1874                json!({ "path": "src/lib.rs", "destination": t.out.join("lib.rs") }),
1875                vec![],
1876            ),
1877            (
1878                "edit",
1879                json!({ "edits": [{ "path": t.out.join("x"), "new_string": "x" }] }),
1880                src(),
1881            ),
1882            (
1883                "edit",
1884                json!({ "command": "ls", "file_path": t.out.join("x") }),
1885                vec![],
1886            ),
1887            ("edit", json!({ "file_path": "notes.txt" }), vec![]),
1888            ("read", json!({ "path": "notes.txt" }), vec![]),
1889            ("execute", json!({ "command": "cat notes.txt" }), vec![]),
1890            ("execute", json!({ "command": "cat .env" }), vec![]),
1891            ("execute", json!({ "command": "cat .ENV" }), vec![]),
1892            (
1893                "execute",
1894                json!({ "command": "cat src/lib.rs", "cwd": t.out }),
1895                vec![],
1896            ),
1897        ] {
1898            assert_eq!(answer(&t, kind, raw.clone(), loc), refused, "{kind} {raw}");
1899        }
1900    }
1901
1902    #[test]
1903    fn a_grant_of_speccoders_own_calls_never_covers_the_agents_and_back() {
1904        let t = tree();
1905        // `make deploy` granted for the run, as a sandboxed command.
1906        let tools =
1907            WorkspaceTools::new(&t.root, Policy::default()).unwrap_or_else(|e| panic!("{e}"));
1908        let native: Object = serde_json::from_value(
1909            tools.resolve("run_command", &json!({ "argv": ["make", "deploy"] }))["object"].clone(),
1910        )
1911        .unwrap_or_else(|e| panic!("{e}"));
1912        let g = governor_with(&t.root, vec![native]);
1913        assert_eq!(
1914            g.permission(
1915                &call("execute", json!({ "command": "make deploy" }), vec![]),
1916                &options()
1917            ),
1918            None
1919        );
1920        // The agent's own action, granted by a person for the task: it
1921        // never lets speccoder's terminal run that command.
1922        let mut g = governor_with(&t.root, Vec::new());
1923        g.approver = Some(Arc::new(Answers(Mutex::new(vec![Answer::approve(
1924            Scope::Task,
1925            None,
1926            None,
1927        )]))));
1928        let lint = call("execute", json!({ "command": "make lint" }), vec![]);
1929        assert_eq!(g.permission(&lint, &options()), allow());
1930        assert_eq!(g.permission(&lint, &options()), allow());
1931        assert!(
1932            g.command(vec!["make".to_owned(), "lint".to_owned()], None)
1933                .is_err()
1934        );
1935    }
1936
1937    #[test]
1938    fn a_refused_or_unasked_change_the_agent_made_counts_as_deciding_alone() {
1939        let t = tree();
1940        let g = governor_with(&t.root, Vec::new());
1941        let mut refused = call(
1942            "edit",
1943            json!({ "file_path": t.out.join("x"), "content": "x" }),
1944            vec![],
1945        );
1946        refused.id = "d1".to_owned();
1947        assert_eq!(
1948            g.permission(&refused, &options()),
1949            Some("reject".to_owned())
1950        );
1951        refused.status = Some("completed".to_owned());
1952        g.reported(&refused);
1953        let mut other = call("other", json!({ "command": "rm -rf x" }), vec![]);
1954        other.id = "u1".to_owned();
1955        other.status = Some("completed".to_owned());
1956        g.reported(&other);
1957        let mut failed = call("execute", json!({ "command": "rm -rf x" }), vec![]);
1958        failed.id = "u2".to_owned();
1959        failed.status = Some("failed".to_owned());
1960        g.reported(&failed);
1961        assert_eq!(g.unasked(), 3);
1962        // An allowed read is no such change, nor a refused one that failed.
1963        let g = reads(&t);
1964        let mut read = call("read", json!({ "path": "src/lib.rs" }), vec![]);
1965        read.id = "r1".to_owned();
1966        assert_eq!(g.permission(&read, &options()), allow());
1967        read.status = Some("completed".to_owned());
1968        g.reported(&read);
1969        refused.id = "d2".to_owned();
1970        refused.status = None;
1971        let _ = g.permission(&refused, &options());
1972        refused.status = Some("failed".to_owned());
1973        g.reported(&refused);
1974        assert_eq!(g.unasked(), 0);
1975    }
1976
1977    /// A person who says yes — while the attempt ends meanwhile.
1978    #[derive(Debug)]
1979    struct YesTooLate(Arc<std::sync::OnceLock<Arc<Governor>>>);
1980
1981    impl Approver for YesTooLate {
1982        fn ask(&self, _: &crate::approvals::Presentation) -> Answer {
1983            if let Some(g) = self.0.get() {
1984                g.stop();
1985            }
1986            Answer::approve(Scope::Task, None, None)
1987        }
1988    }
1989
1990    #[test]
1991    fn a_yes_after_the_attempt_ended_allows_nothing() {
1992        let t = tree();
1993        let cell = Arc::new(std::sync::OnceLock::new());
1994        let mut g = governor_with(&t.root, Vec::new());
1995        g.approver = Some(Arc::new(YesTooLate(Arc::clone(&cell))));
1996        let g = Arc::new(g);
1997        let _ = cell.set(Arc::clone(&g));
1998        let c = call("execute", json!({ "command": "make lint" }), vec![]);
1999        assert_eq!(g.permission(&c, &options()), Some("reject".to_owned()));
2000    }
2001
2002    #[test]
2003    fn what_the_governor_records_is_masked_and_cut() {
2004        let t = tree();
2005        crate::secrets::register("GOV_TOKEN", "tok-Zx81-secret-value-gov");
2006        let g = governor_with(&t.root, Vec::new());
2007        let big = format!("tok-Zx81-secret-value-gov{}", "x".repeat(10_000));
2008        let mut c = call(
2009            "edit",
2010            json!({ "file_path": "src/lib.rs", "content": big }),
2011            vec![],
2012        );
2013        c.title = Some("Write tok-Zx81-secret-value-gov".to_owned());
2014        let _ = g.permission(&c, &options());
2015        let log = serde_json::to_string(&g.log()).unwrap_or_default();
2016        assert!(!log.contains("tok-Zx81-secret-value-gov"), "{log}");
2017        assert!(log.len() < 9000, "{}", log.len());
2018    }
2019
2020    #[test]
2021    fn a_plain_command_is_split_and_a_shell_line_is_left_to_sh() {
2022        assert_eq!(
2023            argv_of_line("cargo test -p core"),
2024            ["cargo", "test", "-p", "core"]
2025        );
2026        assert_eq!(argv_of_line("ls | grep x"), ["sh", "-c", "ls | grep x"]);
2027        assert_eq!(
2028            argv_of_line("git commit -m \"x\""),
2029            ["sh", "-c", "git commit -m \"x\""]
2030        );
2031        assert_eq!(argv_of_line("A=1 make"), ["sh", "-c", "A=1 make"]);
2032        assert_eq!(
2033            commands_of_line("cat src/lib.rs; ls && cargo test"),
2034            Some(vec![
2035                vec!["cat".to_owned(), "src/lib.rs".to_owned()],
2036                vec!["ls".to_owned()],
2037                vec!["cargo".to_owned(), "test".to_owned()],
2038            ])
2039        );
2040        assert_eq!(commands_of_line("cat a | grep b"), None);
2041        assert_eq!(commands_of_line("echo $(id)"), None);
2042    }
2043}