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 c9197b32a3
commit f6d99f854f
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.
- If Status is "interviewing": Ask clarifying questions.
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.
Mark each "- [x]" via edit_file. The system will auto-advance phases.
- Plan format (header is written by the system do NOT delete it):
# Active Plan
> Task: <description>
> Status: interviewing | executing
> Status: interviewing | review | executing
> Phase: <current phase number>
## Phase 1: <title>
- [ ] Step 1

View file

@ -823,7 +823,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
}
// 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 {
return "", err
}
@ -993,7 +993,8 @@ func buildPlanReminder(planStatus string) (providers.Message, bool) {
case "interviewing":
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. " +
"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":
content = "[System] The plan is under review. " +
"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,
opts processOptions,
task *activeTask,
planSnapshot string,
) (string, int, error) {
iteration := 0
var finalContent string
@ -1503,7 +1505,7 @@ func (al *AgentLoop) runLLMIteration(
// Snapshot unchecked step count before tool loop so we can detect progress.
preUnchecked := -1 // -1 = not tracking
if agent.ContextBuilder.GetPlanStatus() == "executing" {
if planSnapshot == "executing" {
preUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
}
@ -1756,7 +1758,7 @@ func (al *AgentLoop) runLLMIteration(
curUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
}
if curUnchecked > 0 && !planMarkNudged &&
agent.ContextBuilder.GetPlanStatus() == "executing" {
planSnapshot == "executing" {
planMarkNudged = true
messages = append(messages, providers.Message{
Role: "assistant",
@ -1802,7 +1804,7 @@ func (al *AgentLoop) runLLMIteration(
// message, the tool-result list, or the session store.
// A single compact rejection message is injected instead.
var interviewRejected []string
if isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) {
if isPlanPreExecution(planSnapshot) {
allowed := normalizedToolCalls[:0] // reuse backing array
for _, tc := range normalizedToolCalls {
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.
if iteration > 1 && isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) {
if reminder, ok := buildPlanReminder(agent.ContextBuilder.GetPlanStatus()); ok {
if iteration > 1 && isPlanPreExecution(planSnapshot) {
if reminder, ok := buildPlanReminder(planSnapshot); ok {
messages = append(messages, reminder)
logger.DebugCF("agent", "Injected plan reminder",
map[string]interface{}{
"agent_id": agent.ID,
"iteration": iteration,
"plan_status": agent.ContextBuilder.GetPlanStatus(),
"plan_status": planSnapshot,
})
}
}