feat: enhance OAuth flow validation and improve security checks; add credential read blocking and git operation safeguards

This commit is contained in:
asepharyana
2026-07-12 11:45:28 +07:00
parent 2efd40ca88
commit 8767beef39
11 changed files with 323 additions and 42 deletions
+13 -4
View File
@@ -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
View File
@@ -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 {
+69 -3
View File
@@ -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());
}
}
+125 -6
View File
@@ -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());
}
}