use std::path::Path;
use super::path_tokens::unresolved_target;
use super::read_only_lex::{Tok, Word, WordKind, lex};
use super::read_only_programs::{Arg, check_command};
use super::worktree_remove::DispatchIdentity;
pub(super) const READ_ONLY_DISPATCH_AGENTS: &[&str] = &[
"research",
"code-critic",
"code-analyzer",
"security",
"Explore",
"Plan",
];
pub(crate) fn evaluate_read_only_dispatch_command(
command: &str,
payload: &serde_json::Value,
) -> Option<String> {
let agent = DispatchIdentity::from_payload(payload).agent_type?;
if !READ_ONLY_DISPATCH_AGENTS.contains(&agent) {
return None;
}
let session = payload
.get("session_id")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty());
judge_in(command, session)
.err()
.map(|what| deny_reason(agent, &what))
}
#[cfg(test)]
pub(super) fn judge(command: &str) -> Result<(), String> {
judge_in(command, None)
}
fn judge_in(command: &str, session: Option<&str>) -> Result<(), String> {
let toks = lex(command)?;
let mut p = Parser {
toks: &toks,
at: 0,
session,
};
p.skip_seps();
p.cd_prefix()?;
if toks[p.at..].contains(&Tok::AndIf) {
return Err("`&&` other than after one leading `cd <dir>`".into());
}
if p.keyword("if") {
p.if_shape()?;
} else if p.keyword("for") {
p.for_shape()?;
} else {
p.pipeline(None)?;
}
p.skip_seps();
if p.at < toks.len() {
return Err(
"more than one command, which a read-only agent runs one call at a time".into(),
);
}
Ok(())
}
struct Parser<'a> {
toks: &'a [Tok],
at: usize,
session: Option<&'a str>,
}
impl Parser<'_> {
fn skip_seps(&mut self) {
while self.toks.get(self.at) == Some(&Tok::Sep) {
self.at += 1;
}
}
fn keyword(&mut self, kw: &str) -> bool {
let hit = matches!(self.toks.get(self.at), Some(Tok::Word(w)) if w.is_keyword(kw));
if hit {
self.at += 1;
}
hit
}
fn expect(&mut self, kw: &str) -> Result<(), String> {
if self.keyword(kw) {
return Ok(());
}
Err(format!("a compound command without its `{kw}`"))
}
fn cd_prefix(&mut self) -> Result<(), String> {
if !matches!(self.toks.get(self.at), Some(Tok::Word(w)) if w.is_keyword("cd")) {
return Ok(());
}
let dir = match self.toks.get(self.at + 1..self.at + 4) {
Some([Tok::Word(d), Tok::AndIf, Tok::Word(_)]) if !d.pattern => d.lit(),
_ => None,
};
let plain = dir.filter(|d| {
!d.is_empty() && !d.starts_with('-') && unresolved_target(Path::new(d)).is_none()
});
if plain.is_none() {
return Err("a `cd` other than `cd <plain directory> && <read>`".into());
}
self.at += 3;
Ok(())
}
fn separator(&mut self) -> Result<(), String> {
if self.toks.get(self.at) != Some(&Tok::Sep) {
return Err("a compound command missing a `;`".into());
}
self.skip_seps();
Ok(())
}
fn if_shape(&mut self) -> Result<(), String> {
self.pipeline(None)?;
self.separator()?;
self.expect("then")?;
self.body(None, &["else", "fi"])?;
if self.keyword("else") {
self.body(None, &["fi"])?;
}
self.expect("fi")
}
fn for_shape(&mut self) -> Result<(), String> {
let name = match self.toks.get(self.at) {
Some(Tok::Word(w)) if w.bare && w.lit().is_some_and(|t| LOOP_NAMES.contains(&t)) => {
w.text.clone()
}
_ => return Err("a `for` loop variable outside the guard's name list".into()),
};
self.at += 1;
self.expect("in")?;
let mut words = 0;
while let Some(Tok::Word(w)) = self.toks.get(self.at) {
let flag_like = w.lit().is_none_or(|t| t.starts_with('-'));
if w.pattern || (w.kind != WordKind::Expanding && flag_like) {
return Err("a `for` list word that is not a literal operand".into());
}
words += 1;
self.at += 1;
}
if words == 0 {
return Err("a `for` loop with an empty list".into());
}
self.separator()?;
self.expect("do")?;
self.body(Some(&name), &["done"])?;
self.expect("done")
}
fn body(&mut self, var: Option<&str>, end: &[&str]) -> Result<(), String> {
loop {
self.pipeline(var)?;
self.separator()?;
let at_end = matches!(
self.toks.get(self.at),
Some(Tok::Word(w)) if end.iter().any(|kw| w.is_keyword(kw))
);
if at_end {
return Ok(());
}
}
}
fn pipeline(&mut self, var: Option<&str>) -> Result<(), String> {
let mut stage = 0;
loop {
let args = self.command(var)?;
check_command(&args, stage > 0, self.session)?;
stage += 1;
if self.toks.get(self.at) != Some(&Tok::Pipe) {
return Ok(());
}
self.at += 1;
}
}
fn command(&mut self, var: Option<&str>) -> Result<Vec<Arg>, String> {
let mut args = Vec::new();
let mut pattern = false;
while let Some(Tok::Word(w)) = self.toks.get(self.at) {
if args.is_empty() && is_reserved(w) {
return Err(format!("the shell keyword `{}` here", w.text));
}
pattern |= w.pattern;
args.push(to_arg(w, var)?);
self.at += 1;
}
while self.toks.get(self.at) == Some(&Tok::StderrRedirect) {
self.at += 1;
}
if args.is_empty() {
return Err("an empty command".into());
}
if pattern && !matches!(args[0].text(), Some("rg" | "grep")) {
return Err(
"a `\\` escape or `$` inside double quotes outside an `rg`/`grep` argument".into(),
);
}
Ok(args)
}
}
fn to_arg(w: &Word, var: Option<&str>) -> Result<Arg, String> {
match &w.kind {
WordKind::Lit => Ok(Arg::Lit {
text: w.text.clone(),
bare: w.bare,
}),
WordKind::Var(name) if Some(name.as_str()) == var => Ok(Arg::Operand),
WordKind::Var(_) => Err("a variable other than the `for` loop's own".into()),
WordKind::Expanding => Err("a glob or `~` outside a `for` list".into()),
}
}
fn is_reserved(w: &Word) -> bool {
const RESERVED: &[&str] = &[
"if", "then", "else", "elif", "fi", "for", "in", "do", "done", "while", "until", "case",
"esac", "select", "function", "coproc", "time", "repeat", "foreach", "end", "!", "{", "}",
"[[", "]]",
];
w.bare && w.lit().is_some_and(|t| RESERVED.contains(&t))
}
const LOOP_NAMES: &[&str] = &[
"f", "file", "p", "plist", "d", "dir", "x", "i", "n", "line", "name", "item",
];
fn deny_reason(agent: &str, what: &str) -> String {
format!(
"Read-only dispatch refused a command (#8439): `{agent}` is a read-only agent, and this \
command has {what}. A read-only agent runs only allowlisted reads, one per call, with \
literal arguments: git [-C <dir>] status/log/diff/show/grep/rev-parse/ls-files/\
merge-base/ls-remote/branch --list/worktree list, and `GH_CONFIG_DIR=<dir> git \
ls-remote` to use the project's gh credential; cat/head/tail/wc/ls/grep/rg; find without \
-exec/-delete/-fprint; sed -n with a print script; plutil -p/-lint; defaults read; \
launchctl print/list; tmux capture-pane -p; cargo metadata/tree; gh issue view/list, \
gh pr view/list/diff/checks, gh run view/list, gh api (GET only); date [-u] [+format]; echo; pwd; \
cp/rm whose every operand is a literal absolute path inside this session's scratchpad. A \
pipe into cat/head/tail/wc/grep/rg/sed is allowed, and so are `2>&1`, `2>/dev/null` \
and one leading `cd <dir> &&` with a literal path. Write every path out literally \
— `sed -n '10,20p' src/lib.rs`, never `sed -n '10,20p' \"$F\"`; a path held in a \
shell variable is refused. An rg/grep pattern may carry `\\` \
escapes and an end-of-line `$` inside quotes; single quotes are the safest. If the \
task needs a write, report back so the PM dispatches a writing agent."
)
}
#[cfg(test)]
#[path = "read_only_allow_tests.rs"]
mod read_only_allow_tests;