fix: prevent plan mode from bypassing user approval gate

The AI could skip the "review" approval step and jump straight to
"executing" because (1) the system prompt told it to set Status to
"executing" after writing phases, and (2) tool-call filters inside
runLLMIteration read the live plan status, which the AI could mutate
mid-iteration via MEMORY.md edits.

- context.go: instruct AI to transition to "review" (not "executing"),
  document the review state, and list it in the status format
- loop.go: snapshot plan status before the iteration loop and use it
  for all filter/reminder decisions so mid-loop writes cannot disable
  the interview/review gate
- loop.go: strengthen the interviewing reminder to explicitly say
  "change Status to review, do NOT set it to executing"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 01:12:50 +09:00
parent a5ccc1166a
commit e04e8afa01
2 changed files with 13 additions and 10 deletions

View file

@ -85,13 +85,14 @@ Your workspace is at: %s
- NEVER remove or overwrite the header block (# Active Plan, > Task:, > Status:, > Phase:). The system parses these lines to track plan state. - NEVER remove or overwrite the header block (# Active Plan, > Task:, > Status:, > Phase:). The system parses these lines to track plan state.
- If Status is "interviewing": Ask clarifying questions. - If Status is "interviewing": Ask clarifying questions.
After each answer, use edit_file to save findings to ## Context in memory/MEMORY.md. After each answer, use edit_file to save findings to ## Context in memory/MEMORY.md.
When you have enough information, add ## Phase sections with "- [ ]" checkbox steps, and ## Commands section below the header. Then change > Status: to "executing". When you have enough information, add ## Phase sections with "- [ ]" checkbox steps, and ## Commands section below the header. Then change > Status: to "review".
- If Status is "review": The plan is awaiting user approval. Do NOT change Status yourself.
- If Status is "executing": Work through the current Phase's steps. - If Status is "executing": Work through the current Phase's steps.
Mark each "- [x]" via edit_file. The system will auto-advance phases. Mark each "- [x]" via edit_file. The system will auto-advance phases.
- Plan format (header is written by the system do NOT delete it): - Plan format (header is written by the system do NOT delete it):
# Active Plan # Active Plan
> Task: <description> > Task: <description>
> Status: interviewing | executing > Status: interviewing | review | executing
> Phase: <current phase number> > Phase: <current phase number>
## Phase 1: <title> ## Phase 1: <title>
- [ ] Step 1 - [ ] Step 1

View file

@ -823,7 +823,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
} }
// 5. Run LLM iteration loop // 5. Run LLM iteration loop
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts, task) finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts, task, preStatus)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -993,7 +993,8 @@ func buildPlanReminder(planStatus string) (providers.Message, bool) {
case "interviewing": case "interviewing":
content = "[System] You are interviewing the user to build a plan. " + content = "[System] You are interviewing the user to build a plan. " +
"Ask clarifying questions and save findings to ## Context in memory/MEMORY.md using edit_file. " + "Ask clarifying questions and save findings to ## Context in memory/MEMORY.md using edit_file. " +
"When you have enough information, write ## Phase sections with `- [ ]` checkbox steps, and ## Commands section." "When you have enough information, write ## Phase sections with `- [ ]` checkbox steps, and ## Commands section. " +
"Then change > Status: to review. Do NOT set it to executing."
case "review": case "review":
content = "[System] The plan is under review. " + content = "[System] The plan is under review. " +
"Wait for the user to approve or request changes. Do not proceed with execution." "Wait for the user to approve or request changes. Do not proceed with execution."
@ -1493,6 +1494,7 @@ func (al *AgentLoop) runLLMIteration(
messages []providers.Message, messages []providers.Message,
opts processOptions, opts processOptions,
task *activeTask, task *activeTask,
planSnapshot string,
) (string, int, error) { ) (string, int, error) {
iteration := 0 iteration := 0
var finalContent string var finalContent string
@ -1503,7 +1505,7 @@ func (al *AgentLoop) runLLMIteration(
// Snapshot unchecked step count before tool loop so we can detect progress. // Snapshot unchecked step count before tool loop so we can detect progress.
preUnchecked := -1 // -1 = not tracking preUnchecked := -1 // -1 = not tracking
if agent.ContextBuilder.GetPlanStatus() == "executing" { if planSnapshot == "executing" {
preUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]") preUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
} }
@ -1756,7 +1758,7 @@ func (al *AgentLoop) runLLMIteration(
curUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]") curUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
} }
if curUnchecked > 0 && !planMarkNudged && if curUnchecked > 0 && !planMarkNudged &&
agent.ContextBuilder.GetPlanStatus() == "executing" { planSnapshot == "executing" {
planMarkNudged = true planMarkNudged = true
messages = append(messages, providers.Message{ messages = append(messages, providers.Message{
Role: "assistant", Role: "assistant",
@ -1802,7 +1804,7 @@ func (al *AgentLoop) runLLMIteration(
// message, the tool-result list, or the session store. // message, the tool-result list, or the session store.
// A single compact rejection message is injected instead. // A single compact rejection message is injected instead.
var interviewRejected []string var interviewRejected []string
if isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) { if isPlanPreExecution(planSnapshot) {
allowed := normalizedToolCalls[:0] // reuse backing array allowed := normalizedToolCalls[:0] // reuse backing array
for _, tc := range normalizedToolCalls { for _, tc := range normalizedToolCalls {
if isToolAllowedDuringInterview(tc.Name, tc.Arguments) { if isToolAllowedDuringInterview(tc.Name, tc.Arguments) {
@ -2069,14 +2071,14 @@ func (al *AgentLoop) runLLMIteration(
} }
// Inject plan-mode reminder to keep AI focused on interview/review workflow. // Inject plan-mode reminder to keep AI focused on interview/review workflow.
if iteration > 1 && isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) { if iteration > 1 && isPlanPreExecution(planSnapshot) {
if reminder, ok := buildPlanReminder(agent.ContextBuilder.GetPlanStatus()); ok { if reminder, ok := buildPlanReminder(planSnapshot); ok {
messages = append(messages, reminder) messages = append(messages, reminder)
logger.DebugCF("agent", "Injected plan reminder", logger.DebugCF("agent", "Injected plan reminder",
map[string]interface{}{ map[string]interface{}{
"agent_id": agent.ID, "agent_id": agent.ID,
"iteration": iteration, "iteration": iteration,
"plan_status": agent.ContextBuilder.GetPlanStatus(), "plan_status": planSnapshot,
}) })
} }
} }