agent-orchestrator/agor-pro/src/branch_policy.rs
Hibryda 738574b9f0 fix(security): resolve all HIGH/MEDIUM/LOW audit findings
Rust fixes (HIGH):
- symbols.rs: path validation (reject near-root, 50K file limit, symlink filter)
- memory.rs: FTS5 query quoting (prevent operator injection), 1000 fragment cap, content length limit, transaction wrapping
- budget.rs: atomic check-and-reserve via transaction, input validation, index on budget_log
- export.rs: safe UTF-8 truncation via chars().take()
- git_context.rs: reject paths starting with '-' (flag injection)
- branch_policy.rs: action validation (block|warn only), path validation

Rust fixes (MEDIUM):
- export.rs: named column access (positional→named)
- budget.rs: named column access, negative value guards

Svelte fixes:
- AccountSwitcher: 2-step confirmation before account switch
- ProjectMemory: expand/collapse content, 2-step delete confirm, tags split fix
- CodeIntelligence: min 2-char symbol query, CodeSymbol rename, aria-labels
- BudgetManager: 10M upper bound, aria-label on input, named constants
- SessionExporter: timeout cleanup on destroy, aria-live feedback
- AnalyticsDashboard: SVG aria-label, removed unused import, named constant
2026-03-17 03:56:44 +01:00

214 lines
6.8 KiB
Rust

// SPDX-License-Identifier: LicenseRef-Commercial
// Branch Policy Enforcement — block agent sessions on protected branches.
use rusqlite::params;
use serde::Serialize;
use std::process::Command;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchPolicy {
pub id: i64,
pub pattern: String,
pub action: String,
pub reason: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PolicyDecision {
pub allowed: bool,
pub branch: String,
pub matched_policy: Option<BranchPolicy>,
pub reason: String,
}
fn ensure_tables(conn: &rusqlite::Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS pro_branch_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pattern TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'block',
reason TEXT NOT NULL DEFAULT ''
);"
).map_err(|e| format!("Failed to create branch_policies table: {e}"))?;
// Seed default policies if table is empty
let count: i64 = conn.query_row(
"SELECT COUNT(*) AS cnt FROM pro_branch_policies", [], |row| row.get("cnt")
).unwrap_or(0);
if count == 0 {
conn.execute_batch(
"INSERT INTO pro_branch_policies (pattern, action, reason) VALUES
('main', 'block', 'Protected branch: direct work on main is not allowed'),
('master', 'block', 'Protected branch: direct work on master is not allowed'),
('release/*', 'block', 'Protected branch: release branches require PRs');"
).map_err(|e| format!("Failed to seed default policies: {e}"))?;
}
Ok(())
}
/// Simple glob matching: supports `*` at the end of a pattern (e.g., `release/*`).
fn glob_match(pattern: &str, value: &str) -> bool {
if pattern == value {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
return value.starts_with(prefix);
}
false
}
fn get_current_branch(project_path: &str) -> Result<String, String> {
if project_path.starts_with('-') {
return Err("Invalid project path: cannot start with '-'".into());
}
let output = Command::new("git")
.args(["-C", project_path, "branch", "--show-current"])
.output()
.map_err(|e| format!("Failed to run git: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err("Not a git repository or git not available".into())
}
}
#[tauri::command]
pub fn pro_branch_check(project_path: String) -> Result<PolicyDecision, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let branch = get_current_branch(&project_path)?;
let mut stmt = conn.prepare(
"SELECT id, pattern, action, reason FROM pro_branch_policies"
).map_err(|e| format!("Query failed: {e}"))?;
let policies: Vec<BranchPolicy> = stmt.query_map([], |row| {
Ok(BranchPolicy {
id: row.get("id")?,
pattern: row.get("pattern")?,
action: row.get("action")?,
reason: row.get("reason")?,
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
for policy in &policies {
if glob_match(&policy.pattern, &branch) {
let allowed = policy.action != "block";
return Ok(PolicyDecision {
allowed,
branch: branch.clone(),
matched_policy: Some(policy.clone()),
reason: policy.reason.clone(),
});
}
}
Ok(PolicyDecision {
allowed: true,
branch,
matched_policy: None,
reason: "No matching policy".into(),
})
}
#[tauri::command]
pub fn pro_branch_policy_list() -> Result<Vec<BranchPolicy>, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let mut stmt = conn.prepare(
"SELECT id, pattern, action, reason FROM pro_branch_policies ORDER BY id"
).map_err(|e| format!("Query failed: {e}"))?;
let rows = stmt.query_map([], |row| {
Ok(BranchPolicy {
id: row.get("id")?,
pattern: row.get("pattern")?,
action: row.get("action")?,
reason: row.get("reason")?,
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(rows)
}
#[tauri::command]
pub fn pro_branch_policy_add(pattern: String, action: Option<String>, reason: Option<String>) -> Result<i64, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let act = action.unwrap_or_else(|| "block".into());
if !["block", "warn"].contains(&act.as_str()) {
return Err(format!("Invalid action '{}': must be 'block' or 'warn'", act));
}
let rsn = reason.unwrap_or_default();
conn.execute(
"INSERT INTO pro_branch_policies (pattern, action, reason) VALUES (?1, ?2, ?3)",
params![pattern, act, rsn],
).map_err(|e| format!("Failed to add policy: {e}"))?;
Ok(conn.last_insert_rowid())
}
#[tauri::command]
pub fn pro_branch_policy_remove(id: i64) -> Result<(), String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
conn.execute("DELETE FROM pro_branch_policies WHERE id = ?1", params![id])
.map_err(|e| format!("Failed to remove policy: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_policy_decision_serializes_camel_case() {
let d = PolicyDecision {
allowed: false,
branch: "main".into(),
matched_policy: Some(BranchPolicy {
id: 1,
pattern: "main".into(),
action: "block".into(),
reason: "Protected".into(),
}),
reason: "Protected".into(),
};
let json = serde_json::to_string(&d).unwrap();
assert!(json.contains("matchedPolicy"));
assert!(json.contains("\"allowed\":false"));
}
#[test]
fn test_branch_policy_serializes_camel_case() {
let p = BranchPolicy {
id: 1,
pattern: "release/*".into(),
action: "block".into(),
reason: "No direct commits".into(),
};
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"pattern\":\"release/*\""));
assert!(json.contains("\"action\":\"block\""));
}
#[test]
fn test_glob_match() {
assert!(glob_match("main", "main"));
assert!(!glob_match("main", "main2"));
assert!(glob_match("release/*", "release/v1.0"));
assert!(glob_match("release/*", "release/hotfix"));
assert!(!glob_match("release/*", "feature/test"));
assert!(!glob_match("master", "main"));
}
}