Add plan approval gate to block file changes without approved plan

- Add check_plan_approval_gate() in tools/plan.rs that runs after each tool call
- Detects file changes via git status --porcelain when plan exists but not approved
- Reverts changes: git checkout for modified files, rm for new untracked files
- Returns blocking message instructing LLM to create/approve plan first
- Add ApprovalGateResult enum with Allowed/Blocked/NotGitRepo variants
- Add set_session_id() and set_working_dir() methods on Agent for testing
- Add integration test using MockProvider to simulate blocked write_file
This commit is contained in:
Dhanji R. Prasanna
2026-02-05 11:34:10 +11:00
parent add8060526
commit c347a73cbd
5 changed files with 423 additions and 3 deletions

View File

@@ -822,3 +822,129 @@ async fn test_llm_repeats_text_before_each_tool_call() {
preamble_count
);
}
// =============================================================================
// Plan Approval Gate Tests
// =============================================================================
/// Test: File changes are blocked when plan exists but is not approved
///
/// Scenario:
/// 1. Create a plan (unapproved)
/// 2. LLM tries to write a file
/// 3. The file change should be reverted and a blocking message returned
#[tokio::test]
async fn test_plan_approval_gate_blocks_unapproved_changes() {
use g3_core::tools::plan::{write_plan, Plan, PlanItem, Checks, Check, PlanState};
use std::fs;
// Create a temp directory that IS a git repo
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
// Initialize git repo
std::process::Command::new("git")
.args(["init"])
.current_dir(temp_path)
.output()
.expect("Failed to init git repo");
// Configure git user for the repo (needed for commits)
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(temp_path)
.output()
.expect("Failed to configure git email");
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(temp_path)
.output()
.expect("Failed to configure git name");
// Create an initial commit so we have a clean state
let readme_path = temp_path.join("README.md");
fs::write(&readme_path, "# Test").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(temp_path)
.output()
.expect("Failed to git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(temp_path)
.output()
.expect("Failed to git commit");
// Use absolute path so the file is written to the temp git repo
let new_file_path = temp_path.join("new_file.txt");
// Create a mock provider that will try to write a file
let provider = MockProvider::new()
.with_native_tool_calling(true)
.with_response(MockResponse::native_tool_call(
"write_file",
serde_json::json!({
"file_path": new_file_path.to_string_lossy(),
"content": "This should be blocked!"
}),
))
.with_default_response(MockResponse::text("I tried to write a file."));
// Create agent with a specific session ID
let mut registry = ProviderRegistry::new();
registry.register(provider);
let config = g3_config::Config::default();
let mut agent = Agent::new_for_test(config, NullUiWriter, registry)
.await
.expect("Failed to create agent");
// Set a session ID so the plan can be found
let session_id = "test-approval-gate-session";
agent.set_session_id(session_id.to_string());
// Set the working directory to the temp git repo
agent.set_working_dir(temp_path.to_string_lossy().to_string());
// Create an unapproved plan for this session
let mut plan = Plan::new("test-plan");
plan.items.push(PlanItem {
id: "I1".to_string(),
description: "Test item".to_string(),
state: PlanState::Todo,
touches: vec!["src/test.rs".to_string()],
checks: Checks {
happy: Check::new("happy", "target"),
negative: Check::new("negative", "target"),
boundary: Check::new("boundary", "target"),
},
evidence: vec![],
notes: None,
});
// Note: NOT calling plan.approve() - plan is unapproved
write_plan(session_id, &plan).expect("Failed to write plan");
// Execute task - the LLM will try to write a file
let result = agent.execute_task(
"Write a new file",
None, // language
false // auto_execute
).await;
assert!(result.is_ok(), "Task should complete (with blocking message): {:?}", result.err());
// The new file should NOT exist (it was reverted)
assert!(
!new_file_path.exists(),
"New file should have been reverted/deleted"
);
// Check that the blocking message was returned
let history = &agent.get_context_window().conversation_history;
let has_blocking_message = history
.iter()
.any(|m| m.content.contains("IMPLEMENTATION BLOCKED"));
assert!(has_blocking_message, "Should have blocking message in context");
}