Skip to content

Commit 490780f

Browse files
fix: bind mutations to accepted plan steps
1 parent 2f49056 commit 490780f

3 files changed

Lines changed: 80 additions & 0 deletions

File tree

‎main.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3879,6 +3879,17 @@ def _execute_turn_action(action: str, args: Any, *, operation_scope: str,
38793879
)
38803880
args = str(args)
38813881
operation_id = _operation_id(operation_scope, canonical, args)
3882+
if (_is_state_changing_action(canonical, args)
3883+
and _interaction_controller.state().get("executing_plan")):
3884+
allowed, reason = tasks.mutation_matches_current_task(canonical, args)
3885+
if not allowed:
3886+
result = f"Error: action does not match the accepted plan; {reason}."
3887+
_notify_tool_execute(canonical, args, result)
3888+
return _make_execution_receipt(
3889+
action=canonical, args=args, authorized=False, started_at=started_at,
3890+
success=False, result=result, operation_scope=operation_scope,
3891+
failure="plan_action_mismatch",
3892+
)
38823893
if _is_state_changing_action(canonical, args) and operation_id in successful_operations:
38833894
result = "Error: duplicate successful state-changing action refused for this turn."
38843895
_notify_tool_execute(canonical, args, result)

‎task_engine.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,25 @@ def _match_receipt_task(self, action: str, args: str) -> dict[str, Any] | None:
317317
return previous
318318
return None
319319

320+
def mutation_matches_current_task(self, action: str, args: str) -> tuple[bool, str]:
321+
"""Authorize a mutation only when it belongs to the next ordered task."""
322+
current = next((
323+
task for task in self.tasks
324+
if canonical_status(task.get("status", "pending")) in {"pending", "running"}
325+
), None)
326+
if current is None:
327+
return False, "the accepted plan has no pending task"
328+
if self._receipt_match_score(current, action, args) > 0:
329+
return True, ""
330+
if _ordered_plan_info(current) is None or not _ordered_action_compatible(
331+
current.get("description", ""), action):
332+
return False, f"the next accepted step is: {current.get('description', '')}"
333+
if _receipt_action(action) == "write_file":
334+
target = str(args).split("|", 1)[0].strip().replace("\\", "/").rsplit("/", 1)[-1]
335+
if not target or target.lower() not in str(current.get("description", "")).lower():
336+
return False, f"the next accepted step does not name {target or 'that file'}"
337+
return True, ""
338+
320339
def record_evidence(self, *, task_id: str | None = None, action: str, result: str, success: bool,
321340
acceptance: str | None = None, args: str | None = None,
322341
receipt_id: str | None = None) -> dict[str, Any]:

‎tests/test_interaction.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,56 @@ def test_read_only_modes_deny_mutating_and_dynamic_tools(self):
169169
main._execution_capability_token = previous_token
170170
main._set_workspace_root(previous_root)
171171

172+
def test_accepted_plan_rejects_unrelated_mutation_before_execution(self):
173+
previous = (
174+
main.tasks, main._interaction_controller, main._execution_capability_token,
175+
main._get_workspace_root(),
176+
)
177+
with tempfile.TemporaryDirectory() as directory:
178+
root = Path(directory)
179+
store = EventStore(root / "state.sqlite3")
180+
manager = TaskManager(store, workspace_id="bound", session_id="surface:tui")
181+
controller = InteractionController(
182+
store, workspace_id="bound", session_id="surface:tui",
183+
)
184+
proposal = controller.propose_plan({
185+
"title": "Create page", "summary": "Create only the accepted page.",
186+
"assumptions": [], "steps": [{
187+
"id": "page", "title": "Create index.html",
188+
"description": "Write index.html with the requested markup.",
189+
"acceptance": ["index.html exists"],
190+
}],
191+
})
192+
controller.accept_plan(
193+
manager, plan_id=proposal["plan_id"], version=proposal["version"],
194+
)
195+
main.tasks = manager
196+
main._interaction_controller = controller
197+
main._set_workspace_root(root)
198+
main._execution_capability_token = issue_capability_token(
199+
"test:accepted-plan", frozenset({"write"}),
200+
)
201+
try:
202+
operations: set[str] = set()
203+
rejected = main._execute_turn_action(
204+
"write_file", "README.md|wrong", operation_scope="bound",
205+
successful_operations=operations,
206+
)
207+
self.assertFalse(rejected.success)
208+
self.assertEqual(rejected.failure, "plan_action_mismatch")
209+
self.assertFalse((root / "README.md").exists())
210+
211+
accepted = main._execute_turn_action(
212+
"write_file", "index.html|ready", operation_scope="bound",
213+
successful_operations=operations,
214+
)
215+
self.assertTrue(accepted.success, accepted.result)
216+
self.assertEqual((root / "index.html").read_text(encoding="utf-8"), "ready")
217+
finally:
218+
(main.tasks, main._interaction_controller, main._execution_capability_token,
219+
previous_root) = previous
220+
main._set_workspace_root(previous_root)
221+
172222
def test_mocked_provider_flow_questions_revision_acceptance_and_agent_receipt(self):
173223
class LearningStub:
174224
def feedback_signal(self, _text): return None

0 commit comments

Comments
 (0)