feat: enhance OAuth flow validation and improve security checks; add credential read blocking and git operation safeguards
This commit is contained in:
@@ -38,11 +38,14 @@ impl Tool for GitOperator {
|
||||
|
||||
/// Run `git <operation> [args...]` and return its combined output.
|
||||
///
|
||||
/// Flow: extract `operation` + `args` → spawn `git <operation> <args>` → trim and
|
||||
/// join stdout/stderr.
|
||||
/// Flow: extract `operation` + `args` → gate through `shell_filter::git`
|
||||
/// to block destructive operations → spawn `git <operation> <args>` →
|
||||
/// trim and join stdout/stderr.
|
||||
///
|
||||
/// Why: no allowlist here — the model may run any git subcommand; destructive
|
||||
/// operations are blocked upstream by `shell_filter::git`, not by this tool.
|
||||
/// Why: reconstructing the command string for the shell filter prevents
|
||||
/// the model (or a subagent) from running destructive git operations
|
||||
/// that would otherwise bypass the filter by going through this tool
|
||||
/// instead of the `bash` tool.
|
||||
///
|
||||
/// Return: trimmed combined output on success; error including exit code and
|
||||
/// stderr on failure.
|
||||
@@ -59,6 +62,12 @@ impl Tool for GitOperator {
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| anyhow!("missing required argument: args"))?;
|
||||
// Gate through the destructive git filter — same filter used by
|
||||
// the `bash` tool, so destructive operations are blocked regardless
|
||||
// of which tool the model uses.
|
||||
let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" "));
|
||||
crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter)
|
||||
.map_err(|e| anyhow!("blocked: {}", e))?;
|
||||
let output = Command::new("git")
|
||||
.arg(&operation)
|
||||
.args(&arg_list)
|
||||
|
||||
+28
-1
@@ -203,7 +203,34 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
|
||||
} else {
|
||||
base.join(path)
|
||||
};
|
||||
let canon = abs.canonicalize().unwrap_or(abs);
|
||||
// Resolve the path with canonicalisation. For non-existent files
|
||||
// (e.g. the write tool creating a new file), canonicalise the base
|
||||
// workspace root first and then resolve parent-dir (`../`) traversal
|
||||
// component-by-component so that `Path::starts_with` cannot be
|
||||
// bypassed by unnormalised intermediate segments.
|
||||
let canon = match abs.canonicalize() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
let base_canon = workspaces
|
||||
.iter()
|
||||
.filter_map(|w| w.canonicalize().ok())
|
||||
.next()
|
||||
.unwrap_or_else(|| base.clone());
|
||||
let mut resolved = base_canon.clone();
|
||||
if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
|
||||
for comp in rel_components.components() {
|
||||
match comp {
|
||||
std::path::Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
c => resolved.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved
|
||||
}
|
||||
};
|
||||
if workspaces.iter().any(|w| canon.starts_with(w)) {
|
||||
Ok(canon)
|
||||
} else {
|
||||
|
||||
@@ -4,11 +4,14 @@ use anyhow::Result;
|
||||
|
||||
/// Reject shell commands whose lowercased form contains any known credential-read pattern.
|
||||
///
|
||||
/// Flow: lowercase the command → for each pattern, substring-match → bail with the
|
||||
/// matching pattern on the first hit.
|
||||
/// Flow: lowercase the command → strip shell quoting (`''` / `""`) → for each
|
||||
/// pattern, substring-match on both the raw and quote-stripped commands →
|
||||
/// bail with the matching pattern on the first hit.
|
||||
///
|
||||
/// Why: catches `cat ~/.ssh/id_rsa`, `grep token= foo.txt`, `.git-credentials`,
|
||||
/// cloud-CLI credential paths, etc., before the bash tool spawns anything.
|
||||
/// Quoting is stripped because bash concatenates adjacent quotes, so the
|
||||
/// model could insert quotes between characters to bypass substring matching.
|
||||
///
|
||||
/// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise.
|
||||
pub fn check_credential_read(cmd: &str) -> Result<()> {
|
||||
@@ -34,10 +37,73 @@ pub fn check_credential_read(cmd: &str) -> Result<()> {
|
||||
"password=",
|
||||
];
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
let cmd_no_quotes: String = cmd_lower.chars()
|
||||
.filter(|&c| c != '\'' && c != '"')
|
||||
.collect();
|
||||
for pattern in &patterns {
|
||||
if cmd_lower.contains(pattern) {
|
||||
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) {
|
||||
anyhow::bail!("credential read blocked: '{}'", pattern);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_block_ssh_key_read() {
|
||||
assert!(check_credential_read("cat ~/.ssh/id_rsa").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_ssh_key_read_with_quote_bypass() {
|
||||
assert!(check_credential_read("cat ~/.ssh/id_r''sa").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_git_credentials() {
|
||||
assert!(check_credential_read("cat .git-credentials").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_password_eq() {
|
||||
assert!(check_credential_read("echo password=secret123").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_token_eq() {
|
||||
assert!(check_credential_read("echo token=ghp_abc123").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_aws_credentials() {
|
||||
assert!(check_credential_read("cat ~/.aws/credentials").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_gcloud_credentials() {
|
||||
assert!(check_credential_read("cat ~/.config/gcloud/credentials.json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_ls_home() {
|
||||
assert!(check_credential_read("ls -la ~").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_git_clone() {
|
||||
assert!(check_credential_read("git clone https://github.com/user/repo.git").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_cargo_build() {
|
||||
assert!(check_credential_read("cargo build 2>&1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_read_own_source() {
|
||||
assert!(check_credential_read("cat src/main.rs").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ use anyhow::Result;
|
||||
|
||||
/// Reject shell commands whose lowercased form contains any known destructive git pattern.
|
||||
///
|
||||
/// Flow: lowercase the command → for each pattern, substring-match → bail with the
|
||||
/// matching pattern on the first hit.
|
||||
/// Flow: lowercase the command → strip shell quoting (`''` / `""`) → for each
|
||||
/// pattern, substring-match on both the raw and quote-stripped commands →
|
||||
/// bail with the matching pattern on the first hit.
|
||||
///
|
||||
/// Why: hard-resets, force-pushes, `clean -fdx`, `filter-branch`, etc. can destroy
|
||||
/// uncommitted work or rewrite shared history; the bash tool refuses to run them.
|
||||
/// Quoting is stripped before matching because bash concatenates adjacent quoted
|
||||
/// strings (`--for''ce` → `--force`), and substring matching on the raw command
|
||||
/// would miss the bypass.
|
||||
///
|
||||
/// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise.
|
||||
pub fn check_git_destructive(cmd: &str) -> Result<()> {
|
||||
@@ -17,10 +21,10 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
|
||||
"reset --hard",
|
||||
"clean -fdx",
|
||||
"clean -fd",
|
||||
"clean -fX",
|
||||
"clean -fx",
|
||||
"branch -D",
|
||||
"branch -d",
|
||||
"branch --delete --force",
|
||||
"checkout -f",
|
||||
"checkout --force",
|
||||
"switch -f",
|
||||
"restore --force",
|
||||
@@ -32,18 +36,133 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
|
||||
"filter-branch",
|
||||
"gc --prune",
|
||||
"gc --aggressive",
|
||||
"push -f",
|
||||
"push --delete",
|
||||
"push --force",
|
||||
"push origin :",
|
||||
"push +refs",
|
||||
"push +",
|
||||
"push --mirror",
|
||||
"push --tags --force",
|
||||
];
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
let cmd_no_quotes: String = cmd_lower.chars()
|
||||
.filter(|&c| c != '\'' && c != '"')
|
||||
.collect();
|
||||
for pattern in &patterns {
|
||||
if cmd_lower.contains(pattern) {
|
||||
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) {
|
||||
anyhow::bail!("destructive git operation blocked: '{}'", pattern);
|
||||
}
|
||||
}
|
||||
// Additional check: any `+` prefixed refspec in a `git push` is a
|
||||
// force push, regardless of whether it immediately follows `push`
|
||||
// (e.g. `git push origin +main`). Use the quote-stripped form so
|
||||
// that `push or''igin +ma''in` also matches.
|
||||
if cmd_no_quotes.contains("push") {
|
||||
let push_end = cmd_no_quotes.find("push").map(|i| i + 4).unwrap_or(0);
|
||||
let after_push = &cmd_no_quotes[push_end..];
|
||||
if after_push.contains('+') {
|
||||
anyhow::bail!("destructive git operation blocked: force push via +refspec");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_block_push_force_long() {
|
||||
assert!(check_git_destructive("git push --force origin main").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_push_force_short() {
|
||||
assert!(check_git_destructive("git push -f origin main").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_push_force_with_quote_bypass() {
|
||||
assert!(check_git_destructive("git push --for''ce origin main").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_push_force_prefix() {
|
||||
assert!(check_git_destructive("git push origin +main").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_branch_delete() {
|
||||
assert!(check_git_destructive("git branch -d feature").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_branch_force_delete() {
|
||||
assert!(check_git_destructive("git branch -D feature").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_reset_hard() {
|
||||
assert!(check_git_destructive("git reset --hard HEAD~3").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_checkout_force_long() {
|
||||
assert!(check_git_destructive("git checkout --force HEAD").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_checkout_force_short() {
|
||||
assert!(check_git_destructive("git checkout -f HEAD").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_clean_fdx() {
|
||||
assert!(check_git_destructive("git clean -fdx").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_filter_branch() {
|
||||
assert!(check_git_destructive("git filter-branch --force").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_gc_prune() {
|
||||
assert!(check_git_destructive("git gc --prune=now").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_stash_drop() {
|
||||
assert!(check_git_destructive("git stash drop stash@{0}").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_switch_force() {
|
||||
assert!(check_git_destructive("git switch -f main").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_git_status() {
|
||||
assert!(check_git_destructive("git status").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_git_log() {
|
||||
assert!(check_git_destructive("git log --oneline").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_git_diff() {
|
||||
assert!(check_git_destructive("git diff HEAD").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_git_add() {
|
||||
assert!(check_git_destructive("git add src/main.rs").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_git_commit() {
|
||||
assert!(check_git_destructive("git commit -m 'fix bug'").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user