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
214 lines
6.8 KiB
Rust
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"));
|
|
}
|
|
}
|