mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 18:17:39 -10:00
execpolicy,engine,docs: align shell spelling classification, safe-command guards and ANSI-C expansion
Tighten shell classification, safe-command admission, flag filtering, and ANSI-C escape decoding so command classification matches POSIX shell tokenization and auto-review boundaries: - Treat any `$` expansion (including zsh `$=v`, `$~v`, `$^v`), non-plain command words (`=name`, glued globs `/bin/(r)m`, `rm#`), glued parentheses (outside `name()` and `name=(`), shell builtins (`alias`, `enable`, `setopt`, `unsetopt`, `emulate`, `shopt`, `autoload`, `zmodload`, `hash` with operands), `$'`...'` words, and PowerShell `iex`/`Invoke-Expression` as dynamic; refine quoted fallback parsing and document deny-rule coverage in AUTHORIZATION_ORDER.md. - Require no shell composition and a leading primary command token (no env assignments or redirection prefixes) before admitting codewhale read-only invocations, and parse readonly tokens with strict shlex quoting. - Normalize Windows trailing backslashes once before lexing and splitting in is_safe_command so both paths inspect the same command form. - Guard rg --hostname-bin, ag --pager, info -o/--output, less/more output and log-file flags, and git stash list writable options under literal-word rules. - Decode ANSI-C escapes (including control/newline escapes and nested escaped quotes) in shell expansion and multi-word shell-c destroyer-floor walking. - Add CHANGELOG.md entry synced to crates/tui/CHANGELOG.md and regression unit tests across codewhale-execpolicy and codewhale-engine. Checks (local, hermetic): - cargo fmt --all -- --check: exit 0 - sh scripts/preflight.sh --check: preflight: OK - nextest (-p codewhale-execpolicy -p codewhale-engine -E 'package(codewhale-execpolicy) | test(execpolicy) | test(auto_review) | test(command_safety) | test(shell_expand)'): 338 passed, 7164 skipped - clippy (-p codewhale-execpolicy -p codewhale-engine --lib --tests -- -D warnings): exit 0 - Independent pro review (a4abbeec-c415-488b-b351-74cbbc98b3d3, 3d745acf-f658-4c11-a4ff-d6a67e9705e0): LAND Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
dee906d48a
commit
c5f449df11
9 files changed
+763
-73
No files matched your search
@@ -134,6 +134,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Credential redaction:** text, JSON, and workflow-disclosure redaction now masks multi-line private-key blocks across PGP armor headers (including `Comment:` URLs), longer preambles, and 16-character wrapped lines; masks credential values inside nested or escaped JSON assignments, escaped quotes, `\u003c`/`\u003e` delimiters, and spaced or nested JSON objects and arrays; and treats plural token keys (`API_TOKENS`, `ACCESS_TOKENS`) and credential-shaped `tokens` values as credentials while keeping model and tokenizer metric names (`max_tokens`, `tokenizer`, `token_limit`, `tokens=5`) visible.
|
||||
- Child processes now inherit only the standard locale variables (`LC_ALL`, `LC_CTYPE` and the other locale categories, plus `LC_TERMINAL` and `LC_TERMINAL_VERSION`) and a listed set of `DOTNET_` host, SDK and runtime settings. Other `DOTNET_` variables, including application configuration keys, are no longer passed to child processes or MCP servers.
|
||||
- **Deny rules and command safety:** a command whose command word is not a plain literal, or that redefines a command name earlier in the same call, is now refused while a deny rule is configured; the background destroyer hold reads commands the shell tokenizer rejects and decodes ANSI-C escapes inside shell payloads; and `sed`, `find`, `sort`, `rg`, `fd`, `uniq`, `env`, `less`, `more`, `ag`, `info` and `git` count as read-only only when their words are literal and use read-only flags, with `codewhale --version` and Windows trailing-backslash paths normalized before checking.
|
||||
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
|
||||
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
|
||||
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
|
||||
|
||||
@@ -2889,6 +2889,17 @@ mod tests {
|
||||
"cat junk | dd of=/dev/sda",
|
||||
"timeout 30 mkfs /dev/sda1",
|
||||
r#"echo \";mkfs.ext4 /dev/sda1"#,
|
||||
r#"mk""fs.ext4 -L \; /dev/sda1"#,
|
||||
r#"shr""ed /dev/sda \;"#,
|
||||
r#"d""d if=/dev/zero of=/dev/sda 2>\;"#,
|
||||
r#"wip""efs -a /dev/sda \"#,
|
||||
r#"m\kfs.ext4 /dev/sda1 \;"#,
|
||||
"mk\"\"fs.ext4 /dev/sda1\necho \"",
|
||||
r#"sh -c $'echo x\nwipefs -a /dev/sda'"#,
|
||||
r#"sh -c $'echo x\nmkfs.ext4 /dev/sda1'"#,
|
||||
r#"sh -c $'echo x\nshred /dev/sda'"#,
|
||||
r#"sh -c $'echo x\ndd if=/dev/zero of=/dev/sda'"#,
|
||||
r#"sh -c $'echo x\rwipefs -a /dev/sda'"#,
|
||||
] {
|
||||
let ctx = ctx_for(
|
||||
"exec_shell",
|
||||
@@ -2900,6 +2911,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_review_asks_for_prefixed_writable_and_windows_backslash_commands() {
|
||||
let policy = AutoReviewPolicy::default();
|
||||
for command in [
|
||||
"FOO=1>/tmp/out codewhale --version",
|
||||
"PATH=/tmp/bin codewhale --version",
|
||||
"LD_PRELOAD=/tmp/lib.so codewhale --version",
|
||||
"git stash list --output=/tmp/out",
|
||||
"rg --hostname-bin=/tmp/tool foo",
|
||||
"less -o /tmp/out file",
|
||||
"less -O /tmp/out file",
|
||||
"less --log-file=/tmp/out file",
|
||||
"less --LOG-FILE=/tmp/out file",
|
||||
"ag --pager=\"sh -c id\" foo",
|
||||
"info -o /tmp/out",
|
||||
r"find C:\ -delete",
|
||||
r"rg C:\ --pre=id foo",
|
||||
r"find \\?\ -delete",
|
||||
r"rg \\?\ --pre=id foo",
|
||||
] {
|
||||
let ctx = ctx_for(
|
||||
"exec_shell",
|
||||
json!({ "command": command }),
|
||||
RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
);
|
||||
let decision = policy.evaluate(&ctx);
|
||||
assert_eq!(
|
||||
decision.action,
|
||||
AutoReviewAction::AskUser,
|
||||
"{command:?} should require review in Auto mode: {decision:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn destroyer_held(workspace: &std::path::Path, command: &str) -> bool {
|
||||
let ctx = AutoReviewContext::from_tool_call(
|
||||
"exec_shell",
|
||||
|
||||
@@ -564,7 +564,9 @@ pub fn is_parallel_readonly_command(command: &str) -> bool {
|
||||
/// posture. Keys on the arity-aware canonical form, the literal-program
|
||||
/// hardener, the env-prefix rejection, and the per-command option tables.
|
||||
fn readonly_tokens_admitted(trimmed: &str) -> bool {
|
||||
let tokens = shell_words(trimmed);
|
||||
let Some(tokens) = shlex::split(trimmed) else {
|
||||
return false;
|
||||
};
|
||||
let Some(start) = primary_token_index(&tokens) else {
|
||||
return false;
|
||||
};
|
||||
@@ -2087,12 +2089,31 @@ fn split_command_segments(command: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn shell_words(segment: &str) -> Vec<String> {
|
||||
shlex::split(segment).unwrap_or_else(|| {
|
||||
segment
|
||||
.split_whitespace()
|
||||
.map(|token| token.trim_matches(['"', '\'']).to_string())
|
||||
.collect()
|
||||
})
|
||||
shlex::split(segment).unwrap_or_else(|| lenient_words(segment, false))
|
||||
}
|
||||
|
||||
fn lenient_words(segment: &str, unescape: bool) -> Vec<String> {
|
||||
segment
|
||||
.split_whitespace()
|
||||
.map(|token| {
|
||||
token
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '"' | '\'') && !(unescape && *ch == '\\'))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn shell_word_readings(segment: &str) -> Vec<Vec<String>> {
|
||||
let parsed = if segment.contains("$'") {
|
||||
crate::shell_expand::split_ansi_c_shell_words(segment).or_else(|| shlex::split(segment))
|
||||
} else {
|
||||
shlex::split(segment)
|
||||
};
|
||||
match parsed {
|
||||
Some(words) => vec![words],
|
||||
None => vec![lenient_words(segment, false), lenient_words(segment, true)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is one direct, literal rm invocation. Only that shape may
|
||||
@@ -2161,22 +2182,32 @@ fn walk_invocations(command: &str, quote_blind: bool) -> Option<Vec<Vec<String>>
|
||||
return false;
|
||||
}
|
||||
for segment in split_command_segments(command) {
|
||||
let words = shell_words(&segment);
|
||||
for (index, word) in words.iter().enumerate() {
|
||||
let mut argv = words[index..].to_vec();
|
||||
argv[0] = command_word(word);
|
||||
out.push(argv);
|
||||
// Re-parse a word only when it can be a command line of its
|
||||
// own: the script body of `sh -c`/`bash -lc`, where nested
|
||||
// whitespace, `;`, and `|` are real separators. Anything else
|
||||
// is an argument — `echo 'x & rm -rf /etc'` names no `rm`, and
|
||||
// re-parsing it both invented one and failed readable commands
|
||||
// closed once a `&` made the word reproduce itself.
|
||||
if word.contains(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '&' | '|'))
|
||||
&& let Some(script) = nested_shell_script(&words, index)
|
||||
&& !collect(&script, depth + 1, quote_blind, out)
|
||||
{
|
||||
return false;
|
||||
for words in shell_word_readings(&segment) {
|
||||
for (index, word) in words.iter().enumerate() {
|
||||
let mut argv = words[index..].to_vec();
|
||||
argv[0] = command_word(word);
|
||||
out.push(argv);
|
||||
// Re-parse a word only when it can be a command line of its
|
||||
// own: the script body of `sh -c`/`bash -lc`, where nested
|
||||
// whitespace, `;`, and `|` are real separators. Anything else
|
||||
// is an argument — `echo 'x & rm -rf /etc'` names no `rm`, and
|
||||
// re-parsing it both invented one and failed readable commands
|
||||
// closed once a `&` made the word reproduce itself.
|
||||
if word.contains(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '&' | '|'))
|
||||
&& let Some(script) = nested_shell_script(&words, index)
|
||||
&& !collect(&script, depth + 1, quote_blind, out)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if quote_blind && depth == 0 {
|
||||
for words in crate::shell_expand::expand_command(command).words {
|
||||
for (index, word) in words.iter().enumerate() {
|
||||
let mut argv = words[index..].to_vec();
|
||||
argv[0] = command_word(word);
|
||||
out.push(argv);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2503,31 +2534,24 @@ fn target_contains_parent_escape(target: &str) -> bool {
|
||||
|
||||
/// Check if a command is known to be safe
|
||||
fn is_safe_command(command: &str) -> bool {
|
||||
let tokens = shell_words(command);
|
||||
if let Some(start) = primary_token_index(&tokens) {
|
||||
let refs = tokens[start..]
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
if contains_shell_composition(command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let normalized = normalize_windows_command_paths(command);
|
||||
let tokens = shell_words(&normalized);
|
||||
if primary_token_index(&tokens) == Some(0) {
|
||||
let refs = tokens.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
if is_codewhale_readonly_invocation(&refs) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// `starts_with` tests the WHOLE command line, so without this guard any
|
||||
// pipeline or redirection beginning with a safe word was classified Safe
|
||||
// and skipped the destructive floor entirely — `echo x | rm -rf "$HOME"`
|
||||
// reported Safe. `shell_params_are_auto_review_routine` already refuses
|
||||
// shell composition for exactly this reason ("Do not let shell composition
|
||||
// hide an unsafe second stage"); this is the same rule, applied where the
|
||||
// classification is actually made.
|
||||
if contains_shell_composition(command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let literal_words = lex_readonly_command(normalized.trim())
|
||||
.is_ok_and(|segments| segments.len() == 1 && segments[0].redirects.is_empty());
|
||||
SAFE_COMMANDS.iter().any(|entry| {
|
||||
leading_words_match(&tokens, entry).is_some_and(|words| {
|
||||
safe_command_arguments_are_read_only(entry, &tokens, &tokens[words..])
|
||||
safe_command_arguments_are_read_only(entry, &tokens, &tokens[words..], literal_words)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -2548,7 +2572,12 @@ fn leading_words_match(tokens: &[String], entry: &str) -> Option<usize> {
|
||||
/// Arguments that turn an otherwise read-only [`SAFE_COMMANDS`] entry into a
|
||||
/// command that runs other programs or writes files. `tokens` is the whole
|
||||
/// command and `args` what follows the entry's words.
|
||||
fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[String]) -> bool {
|
||||
fn safe_command_arguments_are_read_only(
|
||||
entry: &str,
|
||||
tokens: &[String],
|
||||
args: &[String],
|
||||
literal_words: bool,
|
||||
) -> bool {
|
||||
let has = |names: &[&str]| args.iter().any(|arg| names.contains(&arg.as_str()));
|
||||
let list_mode = has(&["-l", "--list"]);
|
||||
let only_options = |allowed: &[&str], operands_ok: bool| {
|
||||
@@ -2560,7 +2589,7 @@ fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[
|
||||
}
|
||||
})
|
||||
};
|
||||
match entry {
|
||||
let read_only = match entry {
|
||||
// `env CMD` runs CMD; only the bare listing is a read.
|
||||
"env" => only_options(&["-0", "--null"], false),
|
||||
// `-exec`, `-fprint`, `-delete`, … act on every match.
|
||||
@@ -2572,15 +2601,26 @@ fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[
|
||||
"uniq" => is_agent_readonly_uniq(tokens),
|
||||
// `hostname NAME` sets the host name.
|
||||
"hostname" => args.iter().all(|arg| arg.starts_with('-')),
|
||||
// `--pre CMD` runs a preprocessor on every file searched.
|
||||
"rg" => !args.iter().any(|arg| arg.starts_with("--pre")),
|
||||
"rg" => !args
|
||||
.iter()
|
||||
.any(|arg| arg.starts_with("--pre") || arg.starts_with("--hostname-bin")),
|
||||
"ag" => !args.iter().any(|arg| arg.starts_with("--pager")),
|
||||
"info" => !args.iter().any(|arg| {
|
||||
arg == "--output"
|
||||
|| arg.starts_with("--output=")
|
||||
|| (arg.starts_with('-') && !arg.starts_with("--") && arg.contains('o'))
|
||||
}),
|
||||
// `-x` / `-X` / `--exec*` run a command per match.
|
||||
"fd" => !args.iter().any(|arg| {
|
||||
arg.starts_with("--exec")
|
||||
|| (arg.starts_with('-') && !arg.starts_with("--") && arg.contains(['x', 'X']))
|
||||
}),
|
||||
// `+CMD` runs a pager command at startup, which can reach a shell.
|
||||
"less" | "more" => !args.iter().any(|arg| arg.starts_with('+')),
|
||||
"less" | "more" => !args.iter().any(|arg| {
|
||||
let lower = arg.to_ascii_lowercase();
|
||||
arg.starts_with('+')
|
||||
|| lower.starts_with("--log-file")
|
||||
|| (arg.starts_with('-') && !arg.starts_with("--") && lower.contains('o'))
|
||||
}),
|
||||
// `-P`/`-H`/`-C` choose the pager, browser or config that man runs.
|
||||
"man" => only_options(
|
||||
&[
|
||||
@@ -2598,7 +2638,9 @@ fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[
|
||||
true,
|
||||
),
|
||||
// `--output` writes the diff or log to a file.
|
||||
"git diff" | "git log" | "git show" => !args.iter().any(|arg| arg.starts_with("--output")),
|
||||
"git diff" | "git log" | "git show" | "git stash list" => {
|
||||
!args.iter().any(|arg| arg.starts_with("--output"))
|
||||
}
|
||||
// Listing forms only: other forms delete, rename or create refs.
|
||||
"git branch" => only_options(
|
||||
&[
|
||||
@@ -2626,8 +2668,9 @@ fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[
|
||||
.all(|arg| !arg.starts_with('-') || matches!(arg.as_str(), "--push" | "--all")),
|
||||
Some(_) => false,
|
||||
},
|
||||
_ => true,
|
||||
}
|
||||
_ => return true,
|
||||
};
|
||||
read_only && literal_words
|
||||
}
|
||||
|
||||
/// A prefix grants one command's ordinary arguments. The scanner owns shell
|
||||
@@ -2657,10 +2700,10 @@ pub(crate) fn prefix_grant_is_eligible(
|
||||
// Global Git options may precede the read subcommand; --output still
|
||||
// changes its authority. Reuse the existing guard for that flag.
|
||||
(tokens[0] != "git"
|
||||
|| safe_command_arguments_are_read_only("git log", tokens, &tokens[1..]))
|
||||
|| safe_command_arguments_are_read_only("git log", tokens, &tokens[1..], true))
|
||||
&& SAFE_COMMANDS.iter().all(|entry| {
|
||||
leading_words_match(tokens, entry).is_none_or(|words| {
|
||||
safe_command_arguments_are_read_only(entry, tokens, &tokens[words..])
|
||||
safe_command_arguments_are_read_only(entry, tokens, &tokens[words..], true)
|
||||
})
|
||||
})
|
||||
};
|
||||
@@ -4316,3 +4359,203 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shell_reading_tests {
|
||||
use super::*;
|
||||
|
||||
const HIDDEN_DESTROYERS: &[(&str, &str)] = &[
|
||||
(r#"mk""fs.ext4 -L \; /dev/sda1"#, "mkfs.ext4"),
|
||||
(r#"shr""ed /dev/sda \;"#, "shred"),
|
||||
(r#"d""d if=/dev/zero of=/dev/sda 2>\;"#, "dd"),
|
||||
(r#"wip""efs -a /dev/sda \"#, "wipefs"),
|
||||
(r#"r""m -rf /etc \;"#, "rm"),
|
||||
(r#"m\kfs.ext4 /dev/sda1 \;"#, "mkfs.ext4"),
|
||||
("mk\"\"fs.ext4 /dev/sda1\necho \"", "mkfs.ext4"),
|
||||
(r#"sh -c $'echo x\nwipefs -a /dev/sda'"#, "wipefs"),
|
||||
(r#"sh -c $'echo x\nmkfs.ext4 /dev/sda1'"#, "mkfs.ext4"),
|
||||
(r#"sh -c $'echo x\012shred /dev/sda'"#, "shred"),
|
||||
(r#"sh -c $'echo x\x0add if=/dev/zero of=/dev/sda'"#, "dd"),
|
||||
];
|
||||
|
||||
fn names(argvs: Vec<Vec<String>>) -> Vec<String> {
|
||||
argvs.into_iter().map(|argv| argv[0].clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroyer_walkers_read_commands_the_tokenizer_rejects() {
|
||||
let mut missed = Vec::new();
|
||||
for (command, name) in HIDDEN_DESTROYERS {
|
||||
for (label, walk) in [
|
||||
("invocations", command_invocations as fn(&str) -> _),
|
||||
("floor", destroyer_floor_invocations),
|
||||
] {
|
||||
let found = names(walk(command).expect("readable"));
|
||||
if !found.iter().any(|found| found == name) {
|
||||
missed.push(format!("{label}: {command:?} hides {name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missed.is_empty(), "not read: {missed:#?}");
|
||||
let cr_found = names(
|
||||
destroyer_floor_invocations(r#"sh -c $'echo x\rshred /dev/sda'"#).expect("readable"),
|
||||
);
|
||||
assert!(
|
||||
cr_found.iter().any(|name| name == "shred"),
|
||||
"missing shred: {cr_found:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroyer_walkers_keep_quoted_text_as_data() {
|
||||
for command in [
|
||||
r#"git commit -m "mkfs.ext4 /dev/sda1""#,
|
||||
r#"echo ";mkfs.ext4 /dev/sda1""#,
|
||||
r#"echo "dd if=/dev/zero of=/dev/sda""#,
|
||||
r"dir C:\",
|
||||
r#"git commit -m $'echo x\nmkfs.ext4 /dev/sda1'"#,
|
||||
] {
|
||||
for walk in [command_invocations, destroyer_floor_invocations] {
|
||||
let found = names(walk(command).expect("readable"));
|
||||
assert!(
|
||||
!found
|
||||
.iter()
|
||||
.any(|name| name.starts_with("mkfs") || name == "dd"),
|
||||
"{command:?} was read as a command: {found:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argument_grammar_entries_need_words_the_shell_will_not_rewrite() {
|
||||
let mut safe_shapes = Vec::new();
|
||||
for command in [
|
||||
r#"sed -n 1p {-e,"w out.txt"} in.txt"#,
|
||||
r#"sed -n 1p $'-e' "w out.txt" in.txt"#,
|
||||
"sed -n 1p $'-i' in.txt",
|
||||
"sort in.txt {-o,out.txt}",
|
||||
"sort in.txt $'-o' out.txt",
|
||||
"find . {-delete,-print}",
|
||||
"find . $'-delete'",
|
||||
"rg foo {--pre=./x.sh,}",
|
||||
"rg foo $'--pre=./x.sh' .",
|
||||
"fd . {-x,rm}",
|
||||
"git log {--output=out.txt,}",
|
||||
"uniq in.txt {out.txt,}",
|
||||
] {
|
||||
if analyze_command(command).level == SafetyLevel::Safe {
|
||||
safe_shapes.push(command);
|
||||
}
|
||||
}
|
||||
assert!(safe_shapes.is_empty(), "classified Safe: {safe_shapes:#?}");
|
||||
for command in [
|
||||
"ls *.rs",
|
||||
"cat src/*.rs",
|
||||
"echo $HOME",
|
||||
"sed -n 1,5p file.txt",
|
||||
"sort in.txt",
|
||||
"find . -name '*.rs'",
|
||||
"rg foo src",
|
||||
"git log --oneline -5",
|
||||
"git diff HEAD~1",
|
||||
] {
|
||||
assert_eq!(
|
||||
analyze_command(command).level,
|
||||
SafetyLevel::Safe,
|
||||
"{command:?} was not classified Safe"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_table_rejects_prefixes_writable_options_and_windows_trailing_backslashes() {
|
||||
for command in [
|
||||
"FOO=1>/tmp/out codewhale --version",
|
||||
"FOO=1&>/tmp/out codewhale --version",
|
||||
"FOO=1>>/tmp/out codewhale --version",
|
||||
"env ->/tmp/out codewhale --version",
|
||||
"PATH=/tmp/bin codewhale --version",
|
||||
"LD_PRELOAD=/tmp/lib.so codewhale --version",
|
||||
"DYLD_INSERT_LIBRARIES=/tmp/lib.dylib codewhale --version",
|
||||
"env -P/tmp/bin codewhale --version",
|
||||
"env LD_PRELOAD=/tmp/lib.so codewhale --version",
|
||||
"env \"-S/bin/sh -c id\" codewhale --version",
|
||||
"codewhale '--version",
|
||||
"git stash list --output=/tmp/out",
|
||||
"git stash list --output /tmp/out",
|
||||
"git stash list $FLAGS",
|
||||
"ag --pager=\"sh -c id\" foo",
|
||||
"ag --pager id foo",
|
||||
"ag $FLAGS foo",
|
||||
"info -o /tmp/out",
|
||||
"info -o/tmp/out",
|
||||
"info --output=/tmp/out",
|
||||
"info --output /tmp/out",
|
||||
"info $FLAGS",
|
||||
"rg --hostname-bin=/tmp/tool foo",
|
||||
"rg --hostname-bin /tmp/tool foo",
|
||||
"less -o /tmp/out file",
|
||||
"less -O /tmp/out file",
|
||||
"less -o/tmp/out file",
|
||||
"less -O/tmp/out file",
|
||||
"less -No/tmp/out file",
|
||||
"less --log-file=/tmp/out file",
|
||||
"less --log-file /tmp/out file",
|
||||
"less --LOG-FILE=/tmp/out file",
|
||||
"more -o /tmp/out file",
|
||||
"more --log-file=/tmp/out file",
|
||||
"less $FLAGS file",
|
||||
r"find C:\ -delete",
|
||||
r"rg C:\ --pre=id foo",
|
||||
r"rg C:\ --hostname-bin=id foo",
|
||||
r"find \\?\ -delete",
|
||||
r"rg \\?\ --pre=id foo",
|
||||
r"less C:\ -o /tmp/out",
|
||||
] {
|
||||
assert!(
|
||||
!matches!(
|
||||
analyze_command(command).level,
|
||||
SafetyLevel::Safe | SafetyLevel::WorkspaceSafe
|
||||
),
|
||||
"{command:?} must not be classified Safe"
|
||||
);
|
||||
assert!(
|
||||
!is_parallel_readonly_command(command),
|
||||
"{command:?} must not be parallel read-only"
|
||||
);
|
||||
}
|
||||
for command in [
|
||||
"git stash list --output=/tmp/out",
|
||||
"ag --pager=id foo",
|
||||
"info -o /tmp/out",
|
||||
"rg --hostname-bin=id foo",
|
||||
"less -o /tmp/out file",
|
||||
"less --LOG-FILE=/tmp/out file",
|
||||
] {
|
||||
let expansion = crate::shell_expand::expand_command(command);
|
||||
assert!(
|
||||
!prefix_grant_is_eligible(command, &expansion),
|
||||
"{command:?} must not be prefix-grant eligible"
|
||||
);
|
||||
}
|
||||
for command in [
|
||||
"codewhale --version",
|
||||
"codew -V",
|
||||
"git stash list",
|
||||
"ag foo src",
|
||||
"info coreutils",
|
||||
"less -R file.txt",
|
||||
"more file.txt",
|
||||
r"dir C:\",
|
||||
r"find C:\ -name '*.rs'",
|
||||
r"rg foo C:\src",
|
||||
] {
|
||||
assert_eq!(
|
||||
analyze_command(command).level,
|
||||
SafetyLevel::Safe,
|
||||
"{command:?} should stay Safe"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,10 @@
|
||||
//! Because nothing is evaluated, some command words cannot be known without
|
||||
//! running the shell: a parameter or command substitution (`$v`, `${v}`,
|
||||
//! `$(…)`), a glob or brace expansion (`r[m]`, `{rm,-f,x}`), ANSI-C escapes,
|
||||
//! and a shell that reads its script from a pipe, a here-string, or a process
|
||||
//! substitution. [`Expansion::dynamic`] reports that case so the policy engine
|
||||
//! a command word that is not a plain literal (`=rm`, `/bin/(r)m`), a builtin
|
||||
//! that rebinds names (`alias`, `hash x=…`, `setopt`), and a shell that reads
|
||||
//! its script from a pipe, a here-string, or a process substitution.
|
||||
//! [`Expansion::dynamic`] reports that case so the policy engine
|
||||
//! can fail closed instead of matching deny rules against text the shell will
|
||||
//! rewrite. [`Expansion::nested`] reports that some code runs inside another
|
||||
//! command (a substitution, `eval`, a `-c` payload, `find -exec`), which is
|
||||
@@ -800,6 +802,10 @@ const RESERVED_PREFIXES: &[&str] = &[
|
||||
"if", "then", "elif", "else", "do", "while", "until", "!", "coproc",
|
||||
];
|
||||
|
||||
const REBINDING_BUILTINS: &[&str] = &[
|
||||
"alias", "enable", "setopt", "unsetopt", "emulate", "shopt", "autoload", "zmodload",
|
||||
];
|
||||
|
||||
/// Shells whose `-c` argument is a command line to be parsed, not an operand.
|
||||
const SHELL_NAMES: &[&str] = &[
|
||||
"sh", "bash", "zsh", "dash", "ksh", "ksh93", "mksh", "ash", "fish", "csh", "tcsh", "rbash",
|
||||
@@ -811,6 +817,7 @@ const SHELL_NAMES: &[&str] = &[
|
||||
pub struct Expansion {
|
||||
/// Every command line found, as [`expanded_commands`] returns them.
|
||||
pub commands: Vec<String>,
|
||||
pub words: Vec<Vec<String>>,
|
||||
/// A command word (or a shell's script) is only known at run time, so no
|
||||
/// deny rule can be matched against it reliably.
|
||||
pub dynamic: bool,
|
||||
@@ -852,6 +859,7 @@ fn expanded_commands_for_platform(command: &str, windows: bool) -> Vec<String> {
|
||||
fn expand_for_platform(command: &str, windows: bool) -> Expansion {
|
||||
let mut expander = Expander {
|
||||
out: Vec::new(),
|
||||
out_words: Vec::new(),
|
||||
seen: HashSet::new(),
|
||||
literal_backslashes: windows,
|
||||
dynamic: false,
|
||||
@@ -870,6 +878,7 @@ fn expand_for_platform(command: &str, windows: bool) -> Expansion {
|
||||
}
|
||||
Expansion {
|
||||
commands: expander.out,
|
||||
words: expander.out_words,
|
||||
dynamic: expander.dynamic,
|
||||
nested: expander.nested,
|
||||
arguments_dynamic: expander.arguments_dynamic,
|
||||
@@ -880,6 +889,7 @@ fn expand_for_platform(command: &str, windows: bool) -> Expansion {
|
||||
|
||||
struct Expander {
|
||||
out: Vec<String>,
|
||||
out_words: Vec<Vec<String>>,
|
||||
seen: HashSet<String>,
|
||||
literal_backslashes: bool,
|
||||
dynamic: bool,
|
||||
@@ -902,6 +912,7 @@ struct WordBuf {
|
||||
brace: bool,
|
||||
/// An unquoted `[` appeared, so the word may be a bracket glob.
|
||||
bracket: bool,
|
||||
odd: bool,
|
||||
}
|
||||
|
||||
impl WordBuf {
|
||||
@@ -918,6 +929,7 @@ impl WordBuf {
|
||||
|| (self.brace && (self.text.contains(',') || self.text.contains("..")));
|
||||
line.words.push(std::mem::take(&mut self.text));
|
||||
line.dynamic.push(dynamic);
|
||||
line.odd.push(self.odd);
|
||||
}
|
||||
self.started = false;
|
||||
}
|
||||
@@ -925,6 +937,7 @@ impl WordBuf {
|
||||
self.dynamic = false;
|
||||
self.brace = false;
|
||||
self.bracket = false;
|
||||
self.odd = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,6 +947,7 @@ impl WordBuf {
|
||||
struct Line {
|
||||
words: Vec<String>,
|
||||
dynamic: Vec<bool>,
|
||||
odd: Vec<bool>,
|
||||
heredoc: bool,
|
||||
}
|
||||
|
||||
@@ -965,6 +979,13 @@ impl Expander {
|
||||
}
|
||||
self.seen.insert(joined.clone());
|
||||
self.out.push(joined);
|
||||
self.out_words.push(
|
||||
tokens
|
||||
.iter()
|
||||
.filter(|token| !token.is_empty())
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Word-split `input` into command lines and record each one, recursing
|
||||
@@ -1080,18 +1101,14 @@ impl Expander {
|
||||
uncertain |= i >= n;
|
||||
i = (i + 1).min(n);
|
||||
}
|
||||
// `$'…'` (ANSI-C quoting) is literal text with C escapes. The
|
||||
// escapes are not decoded here, so a word that uses them is
|
||||
// not known statically (`$'\x72m'` is `rm`).
|
||||
'$' if i + 1 < n && chars[i + 1] == '\'' => {
|
||||
word.started = true;
|
||||
word.quoted = true;
|
||||
word.dynamic = true;
|
||||
i += 2;
|
||||
while i < n && chars[i] != '\'' {
|
||||
if chars[i] == '\\' && i + 1 < n {
|
||||
word.dynamic = true;
|
||||
word.text.push(chars[i + 1]);
|
||||
i += 2;
|
||||
i = push_ansi_c_escape(&chars, i, &mut word.text);
|
||||
} else {
|
||||
word.text.push(chars[i]);
|
||||
i += 1;
|
||||
@@ -1195,6 +1212,18 @@ impl Expander {
|
||||
// command list of its own, not part of the surrounding word.
|
||||
'(' | ')' => {
|
||||
self.control = true;
|
||||
let glued = if c == '(' {
|
||||
(word.started || !word.text.is_empty())
|
||||
&& chars.get(i + 1) != Some(&')')
|
||||
&& !is_array_assignment_prefix(&word.text)
|
||||
} else {
|
||||
!(i > 0 && chars[i - 1] == '(')
|
||||
&& chars.get(i + 1).is_some_and(|next| {
|
||||
!next.is_whitespace()
|
||||
&& !matches!(next, ';' | '|' | '&' | '(' | ')' | '<' | '>')
|
||||
})
|
||||
};
|
||||
uncertain |= glued;
|
||||
word.flush(&mut line);
|
||||
end_command(&mut commands, &mut line);
|
||||
word.redirect_operand = false;
|
||||
@@ -1293,6 +1322,9 @@ impl Expander {
|
||||
'{' => word.brace = true,
|
||||
_ => {}
|
||||
}
|
||||
if !is_plain_word_char(c, !word.started && word.text.is_empty()) {
|
||||
word.odd = true;
|
||||
}
|
||||
word.text.push(c);
|
||||
word.started = true;
|
||||
i += 1;
|
||||
@@ -1355,12 +1387,22 @@ impl Expander {
|
||||
{
|
||||
self.dynamic = true;
|
||||
}
|
||||
if line.odd[head] && !matches!(tokens[head].as_str(), "[" | "[[") {
|
||||
self.dynamic = true;
|
||||
}
|
||||
let name = command_name(&tokens[head]);
|
||||
let args_dynamic = dynamic[head + 1..].iter().any(|dynamic| *dynamic);
|
||||
self.arguments_dynamic |= args_dynamic;
|
||||
match name.as_str() {
|
||||
// `eval …` takes a *command line* as data. Parse it.
|
||||
"eval" => self.code_payload(&tokens[head + 1..].join(" "), args_dynamic, depth),
|
||||
"eval" | "iex" | "invoke-expression" => {
|
||||
self.dynamic |= name != "eval" && head + 1 == tokens.len();
|
||||
self.code_payload(&tokens[head + 1..].join(" "), args_dynamic, depth);
|
||||
}
|
||||
_ if REBINDING_BUILTINS.contains(&name.as_str()) => self.dynamic = true,
|
||||
"hash" => {
|
||||
self.dynamic |= tokens[head + 1..].iter().any(|token| token != "-r");
|
||||
}
|
||||
"source" | "." => match tokens.get(head + 1) {
|
||||
None if !line.heredoc => self.dynamic = true,
|
||||
Some(_) if dynamic[head + 1] => self.dynamic = true,
|
||||
@@ -1513,6 +1555,7 @@ impl Expander {
|
||||
dynamic: (start..end)
|
||||
.map(|at| line.dynamic[at] || tokens[at].contains("{}"))
|
||||
.collect(),
|
||||
odd: line.odd[start..end].to_vec(),
|
||||
heredoc: false,
|
||||
};
|
||||
self.record(&payload, depth + 1);
|
||||
@@ -1627,13 +1670,28 @@ fn mark_replacement_operands(tokens: &[String], heads: &[usize], dynamic: &mut [
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `$` followed by `next` starts a parameter expansion (`$v`, `$1`,
|
||||
/// `$@`, `$"…"`), as opposed to a literal dollar sign.
|
||||
/// True unless the `$` is a literal one: it ends the word or precedes a
|
||||
/// separator. Everything else (`$v`, `$1`, `$@`, `$=v`, `$~v`, `$^v`, `$"…"`,
|
||||
/// and any flag character a shell may add) is read as an expansion.
|
||||
fn starts_parameter(next: Option<&char>) -> bool {
|
||||
next.is_some_and(|c| {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '_' | '@' | '*' | '#' | '?' | '$' | '!' | '-' | '"')
|
||||
})
|
||||
next.is_some_and(|c| !c.is_whitespace() && !matches!(c, ';' | '|' | '&' | ')' | '<' | '>'))
|
||||
}
|
||||
|
||||
fn is_plain_word_char(c: char, leading: bool) -> bool {
|
||||
c.is_alphanumeric()
|
||||
|| matches!(c, '_' | '.' | '/' | '+' | ':' | '@' | ',' | '-')
|
||||
|| (leading && c == '~')
|
||||
}
|
||||
|
||||
fn is_array_assignment_prefix(text: &str) -> bool {
|
||||
let name = text
|
||||
.strip_suffix("+=")
|
||||
.or_else(|| text.strip_suffix('='))
|
||||
.unwrap_or("");
|
||||
!name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
|
||||
}
|
||||
|
||||
/// Unquoted heredocs expand substitutions, but quotes and ordinary lines are
|
||||
@@ -1708,7 +1766,7 @@ fn end_command(commands: &mut Vec<Line>, line: &mut Line) {
|
||||
heredoc: line.heredoc,
|
||||
..Line::default()
|
||||
};
|
||||
for (word, dynamic) in line.words.into_iter().zip(line.dynamic) {
|
||||
for ((word, dynamic), odd) in line.words.into_iter().zip(line.dynamic).zip(line.odd) {
|
||||
if matches!(word.as_str(), "{" | "}") {
|
||||
push_command(commands, &mut piece);
|
||||
continue;
|
||||
@@ -1718,6 +1776,7 @@ fn end_command(commands: &mut Vec<Line>, line: &mut Line) {
|
||||
}
|
||||
piece.words.push(word);
|
||||
piece.dynamic.push(dynamic);
|
||||
piece.odd.push(odd);
|
||||
}
|
||||
push_command(commands, &mut piece);
|
||||
}
|
||||
@@ -1777,6 +1836,18 @@ fn read_substitution(chars: &[char], open_at: usize) -> (String, usize, bool) {
|
||||
let c = chars[i];
|
||||
match c {
|
||||
'\\' => i += 2,
|
||||
'$' if chars.get(i + 1) == Some(&'\'') => {
|
||||
i += 2;
|
||||
while i < n && chars[i] != '\'' {
|
||||
if chars[i] == '\\' && i + 1 < n {
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
certain &= i < n;
|
||||
i += 1;
|
||||
}
|
||||
'\'' => {
|
||||
i += 1;
|
||||
while i < n && chars[i] != '\'' {
|
||||
@@ -1845,6 +1916,216 @@ fn read_substitution(chars: &[char], open_at: usize) -> (String, usize, bool) {
|
||||
(chars[start..n.max(start)].iter().collect(), n, false)
|
||||
}
|
||||
|
||||
fn push_ansi_c_escape(chars: &[char], backslash_idx: usize, out: &mut String) -> usize {
|
||||
let next = chars[backslash_idx + 1];
|
||||
match next {
|
||||
'a' => {
|
||||
out.push('\x07');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'b' => {
|
||||
out.push('\x08');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'e' | 'E' => {
|
||||
out.push('\x1b');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'f' => {
|
||||
out.push('\x0c');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'n' => {
|
||||
out.push('\n');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'r' => {
|
||||
out.push('\r');
|
||||
backslash_idx + 2
|
||||
}
|
||||
't' => {
|
||||
out.push('\t');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'v' => {
|
||||
out.push('\x0b');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'\\' | '\'' | '"' | '?' => {
|
||||
out.push(next);
|
||||
backslash_idx + 2
|
||||
}
|
||||
'0'..='7' => {
|
||||
let mut val = 0u32;
|
||||
let mut end = backslash_idx + 1;
|
||||
while end < chars.len() && end < backslash_idx + 4 && ('0'..='7').contains(&chars[end])
|
||||
{
|
||||
val = (val << 3) | (chars[end] as u32 - '0' as u32);
|
||||
end += 1;
|
||||
}
|
||||
out.push(char::from((val & 0xff) as u8));
|
||||
end
|
||||
}
|
||||
'x' => {
|
||||
let mut val = 0u32;
|
||||
let mut end = backslash_idx + 2;
|
||||
while end < chars.len() && end < backslash_idx + 4 {
|
||||
if let Some(digit) = chars[end].to_digit(16) {
|
||||
val = (val << 4) | digit;
|
||||
end += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if end > backslash_idx + 2 {
|
||||
out.push(char::from((val & 0xff) as u8));
|
||||
end
|
||||
} else {
|
||||
out.push('\\');
|
||||
out.push('x');
|
||||
backslash_idx + 2
|
||||
}
|
||||
}
|
||||
'u' | 'U' => {
|
||||
let max_digits = if next == 'u' { 4 } else { 8 };
|
||||
let mut val = 0u32;
|
||||
let mut end = backslash_idx + 2;
|
||||
while end < chars.len() && end < backslash_idx + 2 + max_digits {
|
||||
if let Some(digit) = chars[end].to_digit(16) {
|
||||
val = (val << 4) | digit;
|
||||
end += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if end > backslash_idx + 2 {
|
||||
if let Some(ch) = char::from_u32(val) {
|
||||
out.push(ch);
|
||||
} else {
|
||||
out.extend(&chars[backslash_idx..end]);
|
||||
}
|
||||
end
|
||||
} else {
|
||||
out.push('\\');
|
||||
out.push(next);
|
||||
backslash_idx + 2
|
||||
}
|
||||
}
|
||||
'c' if backslash_idx + 2 < chars.len()
|
||||
&& chars[backslash_idx + 2].is_ascii()
|
||||
&& chars[backslash_idx + 2] != '\'' =>
|
||||
{
|
||||
out.push(char::from((chars[backslash_idx + 2] as u8) & 0x1f));
|
||||
backslash_idx + 3
|
||||
}
|
||||
_ => {
|
||||
out.push('\\');
|
||||
out.push(next);
|
||||
backslash_idx + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn split_ansi_c_shell_words(segment: &str) -> Option<Vec<String>> {
|
||||
let chars: Vec<char> = segment.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut i = 0;
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut started = false;
|
||||
while i < n {
|
||||
let ch = chars[i];
|
||||
if ch.is_whitespace() {
|
||||
if started {
|
||||
words.push(std::mem::take(&mut current));
|
||||
started = false;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if ch == '#' && !started {
|
||||
while i < n && chars[i] != '\n' {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
started = true;
|
||||
match ch {
|
||||
'$' if i + 1 < n && chars[i + 1] == '\'' => {
|
||||
i += 2;
|
||||
while i < n && chars[i] != '\'' {
|
||||
if chars[i] == '\\' && i + 1 < n {
|
||||
i = push_ansi_c_escape(&chars, i, &mut current);
|
||||
} else {
|
||||
current.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'\'' => {
|
||||
i += 1;
|
||||
while i < n && chars[i] != '\'' {
|
||||
current.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'"' => {
|
||||
i += 1;
|
||||
while i < n && chars[i] != '"' {
|
||||
if chars[i] == '\\' {
|
||||
if i + 1 >= n {
|
||||
return None;
|
||||
}
|
||||
let next = chars[i + 1];
|
||||
if matches!(next, '"' | '\\' | '$' | '`') {
|
||||
current.push(next);
|
||||
i += 2;
|
||||
} else if next == '\n' {
|
||||
i += 2;
|
||||
} else {
|
||||
current.push('\\');
|
||||
current.push(next);
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
current.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'\\' => {
|
||||
if i + 1 >= n {
|
||||
return None;
|
||||
}
|
||||
if chars[i + 1] != '\n' {
|
||||
current.push(chars[i + 1]);
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
_ => {
|
||||
current.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if started {
|
||||
words.push(current);
|
||||
}
|
||||
Some(words)
|
||||
}
|
||||
|
||||
/// The final path component, so `/usr/bin/sudo` reads as `sudo`.
|
||||
fn basename(token: &str) -> &str {
|
||||
token
|
||||
@@ -1857,6 +2138,7 @@ fn basename(token: &str) -> &str {
|
||||
fn is_env_assignment(token: &str) -> bool {
|
||||
match token.split_once('=') {
|
||||
Some((name, _)) => {
|
||||
let name = name.strip_suffix('+').unwrap_or(name);
|
||||
!name.is_empty()
|
||||
&& !name.starts_with('-')
|
||||
&& name
|
||||
@@ -2168,6 +2450,22 @@ mod tests {
|
||||
expanded_commands_for_platform(command, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_windows_command_words_are_plain_literals() {
|
||||
for command in [
|
||||
r"C:\Windows\System32\where.exe git",
|
||||
r"C:\Git\bin\bash.exe -lc ls",
|
||||
r".\build.bat --release",
|
||||
r"\\server\share\tool.exe /q",
|
||||
] {
|
||||
assert!(
|
||||
!expand_for_platform(command, true).dynamic,
|
||||
"{command} was unresolved"
|
||||
);
|
||||
}
|
||||
assert!(expand_for_platform(r"%COMSPEC% /c dir", true).dynamic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_scan_retains_native_paths_and_posix_deny_candidates() {
|
||||
for (command, expected) in [
|
||||
@@ -2445,4 +2743,36 @@ mod tests {
|
||||
fn plain_command_expands_to_itself() {
|
||||
assert_eq!(expand("git status -s"), vec!["git status -s".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_c_escapes_decode_control_and_newline_characters() {
|
||||
for (command, expected_head) in [
|
||||
(r#"sh -c $'echo x\nwipefs -a /dev/sda'"#, "wipefs"),
|
||||
(r#"sh -c $'echo x\nmkfs.ext4 /dev/sda1'"#, "mkfs.ext4"),
|
||||
(r#"sh -c $'echo x\rwipefs -a /dev/sda'"#, "wipefs"),
|
||||
(r#"sh -c $'echo x\x0amkfs.ext4 /dev/sda1'"#, "mkfs.ext4"),
|
||||
(r#"sh -c $'echo x\012shred /dev/sda'"#, "shred"),
|
||||
(r#"sh -c $'echo x\u000add if=/dev/zero of=/dev/sda'"#, "dd"),
|
||||
(r#"sh -c $'echo x\cJwipefs -a /dev/sda'"#, "wipefs"),
|
||||
(r#"$(sh -c $'echo \'x\'\nwipefs -a /dev/sda')"#, "wipefs"),
|
||||
] {
|
||||
let expansion = expand_for_platform(command, false);
|
||||
assert!(
|
||||
expansion
|
||||
.words
|
||||
.iter()
|
||||
.any(|words| words.first().map(String::as_str) == Some(expected_head)),
|
||||
"{command}: {:?}",
|
||||
expansion.words
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
expand_for_platform(r#"sh -c $'echo\ta\tb'"#, false)
|
||||
.words
|
||||
.iter()
|
||||
.any(|words| words == &["echo", "a", "b"])
|
||||
);
|
||||
assert!(expand_for_platform("$'rm' -f f", false).dynamic);
|
||||
assert!(expand_for_platform("$'\\x72m' f", false).dynamic);
|
||||
}
|
||||
}
|
||||
@@ -317,7 +317,7 @@ fn read_prefixes_do_not_inherit_write_arguments_but_preserve_read_flags_and_buil
|
||||
#[test]
|
||||
fn exact_grants_round_trip_quoted_data_and_reviewed_redirections() {
|
||||
for command in [
|
||||
r#"git log "$FLAGS""#,
|
||||
"git log '$FLAGS'",
|
||||
"cargo test 2>&1",
|
||||
"cargo test &>result.log",
|
||||
r#"grep -E "a|b" src"#,
|
||||
|
||||
@@ -135,6 +135,25 @@ const HIDDEN_RM: &[&str] = &[
|
||||
"sg wheel 'rm x'",
|
||||
"gtimeout 5 rm x",
|
||||
"gnice -n 5 rm x",
|
||||
"v=rm; $=v -f f",
|
||||
"v=rm; $~v -f f",
|
||||
"v=rm; $^v -f f",
|
||||
"$'rm' -f f",
|
||||
"/bin/(r)m -f f",
|
||||
"/bin/r(m) -f f",
|
||||
"/usr/bin/rm(N) -f f",
|
||||
"setopt equals; =rm -f f",
|
||||
"=rm -f f",
|
||||
"rm# x",
|
||||
"hash x=/bin/rm; x -f f",
|
||||
"hash -p /bin/rm x; x -f f",
|
||||
"alias x=rm\nx -f f",
|
||||
"shopt -s expand_aliases\nalias x=rm\nx -f f",
|
||||
"emulate -L sh; rm x",
|
||||
"iex 'rm f'",
|
||||
"Invoke-Expression 'rm f'",
|
||||
"iex ('r'+'m f')",
|
||||
"'rm f' | iex",
|
||||
];
|
||||
|
||||
/// Literal spellings that were already denied and must stay denied.
|
||||
@@ -243,6 +262,17 @@ fn ordinary_commands_stay_allowed_next_to_a_deny_rule() {
|
||||
"for ((i = 0; i < 3; i++)); do echo $i; done",
|
||||
"echo $(echo \")\")",
|
||||
"watch -n 5 ls",
|
||||
"arr=(a b)",
|
||||
"arr+=(c)",
|
||||
"f() { ls; }",
|
||||
"f(){ ls; }",
|
||||
"g++ x.cc",
|
||||
"set -euo pipefail",
|
||||
"hash -r",
|
||||
"grep foo$ file",
|
||||
"~/bin/tool --flag=a%b",
|
||||
"./a.out x=1",
|
||||
"FOO=bar ls",
|
||||
] {
|
||||
let decision = engine
|
||||
.check(context(command, AskForApproval::Never))
|
||||
@@ -383,3 +413,32 @@ fn typed_deny_rule_skips_global_options_before_the_subcommand() {
|
||||
};
|
||||
assert!(engine.check(elsewhere).expect("policy check").allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subcommand_deny_rules_hold_against_glob_group_spellings() {
|
||||
let engines = [
|
||||
ExecPolicyEngine::new(vec![], vec!["git push".to_string()]),
|
||||
ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(vec![
|
||||
ToolAskRule {
|
||||
action: PermissionAction::Deny,
|
||||
..ToolAskRule::exec_shell("git push")
|
||||
},
|
||||
])]),
|
||||
];
|
||||
for engine in &engines {
|
||||
for command in [
|
||||
"git push",
|
||||
"/usr/bin/git(N) push",
|
||||
"/usr/bin/(g)it push",
|
||||
"g=git; $=g push",
|
||||
] {
|
||||
assert!(denied(engine, command), "{command} must be denied");
|
||||
}
|
||||
for command in ["git status", "git log --oneline -3"] {
|
||||
let decision = engine
|
||||
.check(context(command, AskForApproval::Never))
|
||||
.expect("policy check");
|
||||
assert!(decision.allow, "{command:?} was denied: {decision:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Credential redaction:** text, JSON, and workflow-disclosure redaction now masks multi-line private-key blocks across PGP armor headers (including `Comment:` URLs), longer preambles, and 16-character wrapped lines; masks credential values inside nested or escaped JSON assignments, escaped quotes, `\u003c`/`\u003e` delimiters, and spaced or nested JSON objects and arrays; and treats plural token keys (`API_TOKENS`, `ACCESS_TOKENS`) and credential-shaped `tokens` values as credentials while keeping model and tokenizer metric names (`max_tokens`, `tokenizer`, `token_limit`, `tokens=5`) visible.
|
||||
- Child processes now inherit only the standard locale variables (`LC_ALL`, `LC_CTYPE` and the other locale categories, plus `LC_TERMINAL` and `LC_TERMINAL_VERSION`) and a listed set of `DOTNET_` host, SDK and runtime settings. Other `DOTNET_` variables, including application configuration keys, are no longer passed to child processes or MCP servers.
|
||||
- **Deny rules and command safety:** a command whose command word is not a plain literal, or that redefines a command name earlier in the same call, is now refused while a deny rule is configured; the background destroyer hold reads commands the shell tokenizer rejects and decodes ANSI-C escapes inside shell payloads; and `sed`, `find`, `sort`, `rg`, `fd`, `uniq`, `env`, `less`, `more`, `ag`, `info` and `git` count as read-only only when their words are literal and use read-only flags, with `codewhale --version` and Windows trailing-backslash paths normalized before checking.
|
||||
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
|
||||
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
|
||||
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
|
||||
|
||||
@@ -75,6 +75,16 @@ same-layer deny.
|
||||
For chained shell commands, a trusted prefix never approves the whole chain. A
|
||||
typed deny that wins for any individual segment blocks the full invocation.
|
||||
|
||||
Deny rules match commands by name against a static reading of the command
|
||||
line. While any deny rule is configured, a command whose command word cannot be
|
||||
read statically is refused (or prompts, where a person always sees the prompt):
|
||||
a variable or substitution, a glob or brace list, a parenthesis glued to the
|
||||
word, a word that is not a plain literal, or a call that rebinds names first
|
||||
(`alias`, `hash`, `setopt`, `shopt`). Deny rules do not cover a script file, a
|
||||
copy of a program under another name, or another program that does the same
|
||||
job; the limits are listed at the top of `crates/execpolicy/src/shell_expand.rs`.
|
||||
Containment is the sandbox's job, not the deny list's.
|
||||
|
||||
## Approval posture and missing prompts
|
||||
|
||||
The execution-policy result is combined with the registered-tool baseline; the
|
||||
|
||||
@@ -238,8 +238,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/AUTHORIZATION_ORDER.md",
|
||||
"sha256": "98502e067461b71f0ff8cd0d9bc2c86826647e99329fb74bd02b6088f5566536",
|
||||
"content": "# Authorization order\n\n> 阅读简体中文版:[zh_hans/AUTHORIZATION_ORDER.md](zh_hans/AUTHORIZATION_ORDER.md)。\n\nCodewhale combines tool availability, hooks, typed permission rules, approval\nposture, repository policy, and sandboxing. An approval from one layer is not a\nuniversal bypass: a later safety layer can still require review or block the\ncall, and an approval is not an operating-system sandbox grant.\n\nThis page records the order implemented by the interactive engine's\nmodel-requested tool path. Entrypoints that use only part of that path, such as\nthe core runtime's direct tool API, keep the relative order of the layers they\ndo use.\n\n## Model tool-call pipeline\n\nThe interactive engine evaluates a model-requested tool call in this order:\n\n| Order | Layer | Result |\n|---:|---|---|\n| 1 | Effective configuration and posture | User settings, command/runtime overrides, and the project overlay resolve before the turn. A project overlay may tighten `approval_policy`, `sandbox_mode`, or shell availability, but may not loosen them. |\n| 2 | Mode and tool admission | Plan-mode restrictions, input parse errors, per-command tool deny/allow lists, caller restrictions, and missing execution registrations fail before policy rules are considered. A tool present in both command lists is denied. |\n| 3 | Preparation, then `tool_call_before` hooks | Registry preparation is side-effect free. Foreground hooks then fold as `deny > ask > allow`, and a strict matching hook that produces no verdict fails closed. The last `updatedInput` wins; the engine prepares the rewritten input again before later gates inspect it. |\n| 4 | Registered-tool baseline | The prepared tool's `ApprovalRequirement` establishes its ordinary approval need. A hook `ask` is applied after that assignment so the baseline cannot erase it. Non-bypassable registered holds remain forced in postures that can prompt; Full Access auto-approves them instead of opening a contradictory modal. Plan mode also blocks write-capable tools here. |\n| 5 | Typed `permissions.toml` rule | A matching `deny` blocks. A matching `allow` may clear only ordinary registry approval; it cannot clear a hook `ask` or a non-bypassable registered hold. A matching `ask` forces review only in a posture that can prompt. Full Access/auto-approval is not downgraded into a prompt, while an explicit typed `deny` still blocks. |\n| 6 | Auto-review policy and built-in safety floor | Configured block rules run before the built-in floor, then configured allow rules and the deterministic fallback. This layer runs after typed permissions and can add a prompt or block, but cannot remove an earlier hold. The built-in floor refuses a shell command, shell input or verifier containing a hidden Unicode character, and holds publish-like actions, destructive background/headless actions and `curl`/`wget` credential uploads with a prompt no grant or allow rule can answer (a block in Auto-Review and Never). Full Access skips the built-in floor. |\n| 7 | Repository law | Protected path invariants can only add a prompt or block. A repo-law prompt becomes a hard block in Full Access, which has no contradictory approval modal. |\n| 8 | Human approval | A remaining prompt is sent to the approval channel. Denial stops the call. Approval authorizes this planned call; session and persistent choices affect later matching calls but do not erase a later gate from this call. |\n| 9 | Tool authority and execution sandbox | Worker authority envelopes, native tool path checks, and the selected OS or external sandbox still apply during execution. A sandbox denial remains a denial unless the user separately authorizes a supported elevation path. |\n\nThe ordering is intentionally monotonic after the typed permission layer:\nauto-review and repository law can tighten a result, not turn a previous block\nor prompt into an unreviewed execution. There are explicit posture choices\ninside those layers—for example, Full Access does not create an interactive\npublish prompt—but those choices do not let an earlier remembered grant erase\na hold that the layer actually produced.\n\n## Typed permission-rule selection\n\n`permissions.toml` is currently a sibling of the active user `config.toml`.\nThere is no project-local permission-rule source today. An optional `workspace`\nfield scopes one user rule to a repository; it does not create a project\noverlay. `/permissions` reports that source, matcher, scope, and whether the\nscope applies to the current workspace.\n\nThe execution-policy engine evaluates matching rules as follows:\n\n1. Normalize the tool, command, workspace, and any workspace-relative path.\n Unsafe or external paths do not become matchable file rules.\n2. Check denied command prefixes against the whole command and every chained\n segment. These hard prefix denies are merged across rulesets and always win.\n3. Compute a trusted-prefix candidate for an unchained sheLine truncated
|
||||
"sha256": "770cdfc48b9c5ff4af13ccba6913decbf21c2b27d21415ce3086eb42db5e8a66",
|
||||
"content": "# Authorization order\n\n> 阅读简体中文版:[zh_hans/AUTHORIZATION_ORDER.md](zh_hans/AUTHORIZATION_ORDER.md)。\n\nCodewhale combines tool availability, hooks, typed permission rules, approval\nposture, repository policy, and sandboxing. An approval from one layer is not a\nuniversal bypass: a later safety layer can still require review or block the\ncall, and an approval is not an operating-system sandbox grant.\n\nThis page records the order implemented by the interactive engine's\nmodel-requested tool path. Entrypoints that use only part of that path, such as\nthe core runtime's direct tool API, keep the relative order of the layers they\ndo use.\n\n## Model tool-call pipeline\n\nThe interactive engine evaluates a model-requested tool call in this order:\n\n| Order | Layer | Result |\n|---:|---|---|\n| 1 | Effective configuration and posture | User settings, command/runtime overrides, and the project overlay resolve before the turn. A project overlay may tighten `approval_policy`, `sandbox_mode`, or shell availability, but may not loosen them. |\n| 2 | Mode and tool admission | Plan-mode restrictions, input parse errors, per-command tool deny/allow lists, caller restrictions, and missing execution registrations fail before policy rules are considered. A tool present in both command lists is denied. |\n| 3 | Preparation, then `tool_call_before` hooks | Registry preparation is side-effect free. Foreground hooks then fold as `deny > ask > allow`, and a strict matching hook that produces no verdict fails closed. The last `updatedInput` wins; the engine prepares the rewritten input again before later gates inspect it. |\n| 4 | Registered-tool baseline | The prepared tool's `ApprovalRequirement` establishes its ordinary approval need. A hook `ask` is applied after that assignment so the baseline cannot erase it. Non-bypassable registered holds remain forced in postures that can prompt; Full Access auto-approves them instead of opening a contradictory modal. Plan mode also blocks write-capable tools here. |\n| 5 | Typed `permissions.toml` rule | A matching `deny` blocks. A matching `allow` may clear only ordinary registry approval; it cannot clear a hook `ask` or a non-bypassable registered hold. A matching `ask` forces review only in a posture that can prompt. Full Access/auto-approval is not downgraded into a prompt, while an explicit typed `deny` still blocks. |\n| 6 | Auto-review policy and built-in safety floor | Configured block rules run before the built-in floor, then configured allow rules and the deterministic fallback. This layer runs after typed permissions and can add a prompt or block, but cannot remove an earlier hold. The built-in floor refuses a shell command, shell input or verifier containing a hidden Unicode character, and holds publish-like actions, destructive background/headless actions and `curl`/`wget` credential uploads with a prompt no grant or allow rule can answer (a block in Auto-Review and Never). Full Access skips the built-in floor. |\n| 7 | Repository law | Protected path invariants can only add a prompt or block. A repo-law prompt becomes a hard block in Full Access, which has no contradictory approval modal. |\n| 8 | Human approval | A remaining prompt is sent to the approval channel. Denial stops the call. Approval authorizes this planned call; session and persistent choices affect later matching calls but do not erase a later gate from this call. |\n| 9 | Tool authority and execution sandbox | Worker authority envelopes, native tool path checks, and the selected OS or external sandbox still apply during execution. A sandbox denial remains a denial unless the user separately authorizes a supported elevation path. |\n\nThe ordering is intentionally monotonic after the typed permission layer:\nauto-review and repository law can tighten a result, not turn a previous block\nor prompt into an unreviewed execution. There are explicit posture choices\ninside those layers—for example, Full Access does not create an interactive\npublish prompt—but those choices do not let an earlier remembered grant erase\na hold that the layer actually produced.\n\n## Typed permission-rule selection\n\n`permissions.toml` is currently a sibling of the active user `config.toml`.\nThere is no project-local permission-rule source today. An optional `workspace`\nfield scopes one user rule to a repository; it does not create a project\noverlay. `/permissions` reports that source, matcher, scope, and whether the\nscope applies to the current workspace.\n\nThe execution-policy engine evaluates matching rules as follows:\n\n1. Normalize the tool, command, workspace, and any workspace-relative path.\n Unsafe or external paths do not become matchable file rules.\n2. Check denied command prefixes against the whole command and every chained\n segment. These hard prefix denies are merged across rulesets and always win.\n3. Compute a trusted-prefix candidate for an unchained sheLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -254,8 +254,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/AUTHORIZATION_ORDER.md",
|
||||
"sha256": "98502e067461b71f0ff8cd0d9bc2c86826647e99329fb74bd02b6088f5566536",
|
||||
"content": "# Authorization order\n\n> 阅读简体中文版:[zh_hans/AUTHORIZATION_ORDER.md](zh_hans/AUTHORIZATION_ORDER.md)。\n\nCodewhale combines tool availability, hooks, typed permission rules, approval\nposture, repository policy, and sandboxing. An approval from one layer is not a\nuniversal bypass: a later safety layer can still require review or block the\ncall, and an approval is not an operating-system sandbox grant.\n\nThis page records the order implemented by the interactive engine's\nmodel-requested tool path. Entrypoints that use only part of that path, such as\nthe core runtime's direct tool API, keep the relative order of the layers they\ndo use.\n\n## Model tool-call pipeline\n\nThe interactive engine evaluates a model-requested tool call in this order:\n\n| Order | Layer | Result |\n|---:|---|---|\n| 1 | Effective configuration and posture | User settings, command/runtime overrides, and the project overlay resolve before the turn. A project overlay may tighten `approval_policy`, `sandbox_mode`, or shell availability, but may not loosen them. |\n| 2 | Mode and tool admission | Plan-mode restrictions, input parse errors, per-command tool deny/allow lists, caller restrictions, and missing execution registrations fail before policy rules are considered. A tool present in both command lists is denied. |\n| 3 | Preparation, then `tool_call_before` hooks | Registry preparation is side-effect free. Foreground hooks then fold as `deny > ask > allow`, and a strict matching hook that produces no verdict fails closed. The last `updatedInput` wins; the engine prepares the rewritten input again before later gates inspect it. |\n| 4 | Registered-tool baseline | The prepared tool's `ApprovalRequirement` establishes its ordinary approval need. A hook `ask` is applied after that assignment so the baseline cannot erase it. Non-bypassable registered holds remain forced in postures that can prompt; Full Access auto-approves them instead of opening a contradictory modal. Plan mode also blocks write-capable tools here. |\n| 5 | Typed `permissions.toml` rule | A matching `deny` blocks. A matching `allow` may clear only ordinary registry approval; it cannot clear a hook `ask` or a non-bypassable registered hold. A matching `ask` forces review only in a posture that can prompt. Full Access/auto-approval is not downgraded into a prompt, while an explicit typed `deny` still blocks. |\n| 6 | Auto-review policy and built-in safety floor | Configured block rules run before the built-in floor, then configured allow rules and the deterministic fallback. This layer runs after typed permissions and can add a prompt or block, but cannot remove an earlier hold. The built-in floor refuses a shell command, shell input or verifier containing a hidden Unicode character, and holds publish-like actions, destructive background/headless actions and `curl`/`wget` credential uploads with a prompt no grant or allow rule can answer (a block in Auto-Review and Never). Full Access skips the built-in floor. |\n| 7 | Repository law | Protected path invariants can only add a prompt or block. A repo-law prompt becomes a hard block in Full Access, which has no contradictory approval modal. |\n| 8 | Human approval | A remaining prompt is sent to the approval channel. Denial stops the call. Approval authorizes this planned call; session and persistent choices affect later matching calls but do not erase a later gate from this call. |\n| 9 | Tool authority and execution sandbox | Worker authority envelopes, native tool path checks, and the selected OS or external sandbox still apply during execution. A sandbox denial remains a denial unless the user separately authorizes a supported elevation path. |\n\nThe ordering is intentionally monotonic after the typed permission layer:\nauto-review and repository law can tighten a result, not turn a previous block\nor prompt into an unreviewed execution. There are explicit posture choices\ninside those layers—for example, Full Access does not create an interactive\npublish prompt—but those choices do not let an earlier remembered grant erase\na hold that the layer actually produced.\n\n## Typed permission-rule selection\n\n`permissions.toml` is currently a sibling of the active user `config.toml`.\nThere is no project-local permission-rule source today. An optional `workspace`\nfield scopes one user rule to a repository; it does not create a project\noverlay. `/permissions` reports that source, matcher, scope, and whether the\nscope applies to the current workspace.\n\nThe execution-policy engine evaluates matching rules as follows:\n\n1. Normalize the tool, command, workspace, and any workspace-relative path.\n Unsafe or external paths do not become matchable file rules.\n2. Check denied command prefixes against the whole command and every chained\n segment. These hard prefix denies are merged across rulesets and always win.\n3. Compute a trusted-prefix candidate for an unchained sheLine truncated
|
||||
"sha256": "770cdfc48b9c5ff4af13ccba6913decbf21c2b27d21415ce3086eb42db5e8a66",
|
||||
"content": "# Authorization order\n\n> 阅读简体中文版:[zh_hans/AUTHORIZATION_ORDER.md](zh_hans/AUTHORIZATION_ORDER.md)。\n\nCodewhale combines tool availability, hooks, typed permission rules, approval\nposture, repository policy, and sandboxing. An approval from one layer is not a\nuniversal bypass: a later safety layer can still require review or block the\ncall, and an approval is not an operating-system sandbox grant.\n\nThis page records the order implemented by the interactive engine's\nmodel-requested tool path. Entrypoints that use only part of that path, such as\nthe core runtime's direct tool API, keep the relative order of the layers they\ndo use.\n\n## Model tool-call pipeline\n\nThe interactive engine evaluates a model-requested tool call in this order:\n\n| Order | Layer | Result |\n|---:|---|---|\n| 1 | Effective configuration and posture | User settings, command/runtime overrides, and the project overlay resolve before the turn. A project overlay may tighten `approval_policy`, `sandbox_mode`, or shell availability, but may not loosen them. |\n| 2 | Mode and tool admission | Plan-mode restrictions, input parse errors, per-command tool deny/allow lists, caller restrictions, and missing execution registrations fail before policy rules are considered. A tool present in both command lists is denied. |\n| 3 | Preparation, then `tool_call_before` hooks | Registry preparation is side-effect free. Foreground hooks then fold as `deny > ask > allow`, and a strict matching hook that produces no verdict fails closed. The last `updatedInput` wins; the engine prepares the rewritten input again before later gates inspect it. |\n| 4 | Registered-tool baseline | The prepared tool's `ApprovalRequirement` establishes its ordinary approval need. A hook `ask` is applied after that assignment so the baseline cannot erase it. Non-bypassable registered holds remain forced in postures that can prompt; Full Access auto-approves them instead of opening a contradictory modal. Plan mode also blocks write-capable tools here. |\n| 5 | Typed `permissions.toml` rule | A matching `deny` blocks. A matching `allow` may clear only ordinary registry approval; it cannot clear a hook `ask` or a non-bypassable registered hold. A matching `ask` forces review only in a posture that can prompt. Full Access/auto-approval is not downgraded into a prompt, while an explicit typed `deny` still blocks. |\n| 6 | Auto-review policy and built-in safety floor | Configured block rules run before the built-in floor, then configured allow rules and the deterministic fallback. This layer runs after typed permissions and can add a prompt or block, but cannot remove an earlier hold. The built-in floor refuses a shell command, shell input or verifier containing a hidden Unicode character, and holds publish-like actions, destructive background/headless actions and `curl`/`wget` credential uploads with a prompt no grant or allow rule can answer (a block in Auto-Review and Never). Full Access skips the built-in floor. |\n| 7 | Repository law | Protected path invariants can only add a prompt or block. A repo-law prompt becomes a hard block in Full Access, which has no contradictory approval modal. |\n| 8 | Human approval | A remaining prompt is sent to the approval channel. Denial stops the call. Approval authorizes this planned call; session and persistent choices affect later matching calls but do not erase a later gate from this call. |\n| 9 | Tool authority and execution sandbox | Worker authority envelopes, native tool path checks, and the selected OS or external sandbox still apply during execution. A sandbox denial remains a denial unless the user separately authorizes a supported elevation path. |\n\nThe ordering is intentionally monotonic after the typed permission layer:\nauto-review and repository law can tighten a result, not turn a previous block\nor prompt into an unreviewed execution. There are explicit posture choices\ninside those layers—for example, Full Access does not create an interactive\npublish prompt—but those choices do not let an earlier remembered grant erase\na hold that the layer actually produced.\n\n## Typed permission-rule selection\n\n`permissions.toml` is currently a sibling of the active user `config.toml`.\nThere is no project-local permission-rule source today. An optional `workspace`\nfield scopes one user rule to a repository; it does not create a project\noverlay. `/permissions` reports that source, matcher, scope, and whether the\nscope applies to the current workspace.\n\nThe execution-policy engine evaluates matching rules as follows:\n\n1. Normalize the tool, command, workspace, and any workspace-relative path.\n Unsafe or external paths do not become matchable file rules.\n2. Check denied command prefixes against the whole command and every chained\n segment. These hard prefix denies are merged across rulesets and always win.\n3. Compute a trusted-prefix candidate for an unchained sheLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/TELEMETRY.md",
|
||||
|
||||
Reference in new issue
Block a user