fix: #159 — run_turn_loop no longer hardcodes empty denied_tools; permission denials now parity-match bootstrap_session

#159: multi-turn sessions had a silent security asymmetry: denied_tools
were always empty in run_turn_loop, even though bootstrap_session inferred
them from the routed matches. Result: any tool gated as 'destructive'
(bash-family commands, rm, etc) would silently appear unblocked across all
turns in multi-turn mode, giving a false 'clean' permission picture to any
claw consuming TurnResult.permission_denials.

Fix: compute denied_tools once at loop start via _infer_permission_denials,
then pass the same denials to every submit_message call (both timeout and
legacy unbounded paths). This mirrors the existing bootstrap_session pattern.

Acceptance: run_turn_loop('run bash ls').permission_denials now matches
what bootstrap_session returns — both infer the same denials from the
routed matches. Multi-turn security posture is symmetric.

Tests (tests/test_run_turn_loop_permissions.py, 2 tests):
- test_turn_loop_surfaces_permission_denials_like_bootstrap: Symmetry
  check confirming both paths infer identical denials for destructive tools
- test_turn_loop_with_continuation_preserves_denials: Denials inferred at
  loop start are passed consistently to all turns; captured via mock and
  verified non-empty

Full suite: 82/82 passing, zero regression.

Closes ROADMAP #159.
This commit is contained in:
YeonGyu-Kim
2026-04-22 17:50:21 +09:00
parent d453eedae6
commit 178c8fac28
2 changed files with 101 additions and 2 deletions

View File

@@ -204,6 +204,9 @@ class PortRuntime:
matches = self.route_prompt(prompt, limit=limit)
command_names = tuple(match.name for match in matches if match.kind == 'command')
tool_names = tuple(match.name for match in matches if match.kind == 'tool')
# #159: infer permission denials from the routed matches, not hardcoded empty tuple.
# Multi-turn sessions must have the same security posture as bootstrap_session.
denied_tools = tuple(self._infer_permission_denials(matches))
results: list[TurnResult] = []
deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
@@ -225,7 +228,8 @@ class PortRuntime:
if deadline is None:
# Legacy path: unbounded call, preserves existing behaviour exactly.
result = engine.submit_message(turn_prompt, command_names, tool_names, ())
# #159: pass inferred denied_tools (no longer hardcoded empty tuple)
result = engine.submit_message(turn_prompt, command_names, tool_names, denied_tools)
else:
remaining = deadline - time.monotonic()
if remaining <= 0:
@@ -233,7 +237,7 @@ class PortRuntime:
break
assert executor is not None
future = executor.submit(
engine.submit_message, turn_prompt, command_names, tool_names, ()
engine.submit_message, turn_prompt, command_names, tool_names, denied_tools
)
try:
result = future.result(timeout=remaining)