[loop-cycle-1] feat: tool allowlist for autonomous operation (#69)

Add config/allowlist.yaml — YAML-driven gate that auto-approves bounded
tool calls when no human is present.

When Timmy runs with --autonomous or stdin is not a terminal, tool calls
are checked against allowlist: matched → auto-approved, else → rejected.

Changes:
  - config/allowlist.yaml: shell prefixes, deny patterns, path rules
  - tool_safety.py: is_allowlisted() checks tools against YAML rules
  - cli.py: --autonomous flag, _is_interactive() detection
  - 44 new allowlist tests, 8 updated CLI tests

Closes #69
This commit is contained in:
2026-03-14 17:39:48 -04:00
parent 3c671de446
commit d28e2f4a7e
10 changed files with 617 additions and 115 deletions

View File

@@ -177,8 +177,11 @@ def test_handle_tool_confirmation_approve():
mock_agent = MagicMock()
mock_agent.continue_run.return_value = completed_run
# Simulate user typing "y" at the prompt
with patch("timmy.cli.typer.confirm", return_value=True):
# Simulate user typing "y" at the prompt (mock interactive terminal)
with (
patch("timmy.cli._is_interactive", return_value=True),
patch("timmy.cli.typer.confirm", return_value=True),
):
result = _handle_tool_confirmation(mock_agent, paused_run, "cli")
mock_req.confirm.assert_called_once()
@@ -198,7 +201,10 @@ def test_handle_tool_confirmation_reject():
mock_agent = MagicMock()
mock_agent.continue_run.return_value = completed_run
with patch("timmy.cli.typer.confirm", return_value=False):
with (
patch("timmy.cli._is_interactive", return_value=True),
patch("timmy.cli.typer.confirm", return_value=False),
):
_handle_tool_confirmation(mock_agent, paused_run, "cli")
mock_req.reject.assert_called_once()
@@ -225,8 +231,49 @@ def test_handle_tool_confirmation_continue_error():
mock_agent = MagicMock()
mock_agent.continue_run.side_effect = Exception("connection lost")
with patch("timmy.cli.typer.confirm", return_value=True):
with (
patch("timmy.cli._is_interactive", return_value=True),
patch("timmy.cli.typer.confirm", return_value=True),
):
result = _handle_tool_confirmation(mock_agent, paused_run, "cli")
# Should return the original paused run, not crash
assert result is paused_run
def test_handle_tool_confirmation_autonomous_allowlisted():
"""In autonomous mode, allowlisted tools should be auto-approved."""
paused_run, mock_req = _make_paused_run(
tool_name="shell", tool_args={"command": "pytest tests/ -x"}
)
completed_run = MagicMock()
completed_run.status = "COMPLETED"
completed_run.active_requirements = []
mock_agent = MagicMock()
mock_agent.continue_run.return_value = completed_run
with patch("timmy.cli.is_allowlisted", return_value=True):
_handle_tool_confirmation(mock_agent, paused_run, "cli", autonomous=True)
mock_req.confirm.assert_called_once()
mock_req.reject.assert_not_called()
def test_handle_tool_confirmation_autonomous_not_allowlisted():
"""In autonomous mode, non-allowlisted tools should be auto-rejected."""
paused_run, mock_req = _make_paused_run(tool_name="shell", tool_args={"command": "rm -rf /"})
completed_run = MagicMock()
completed_run.status = "COMPLETED"
completed_run.active_requirements = []
mock_agent = MagicMock()
mock_agent.continue_run.return_value = completed_run
with patch("timmy.cli.is_allowlisted", return_value=False):
_handle_tool_confirmation(mock_agent, paused_run, "cli", autonomous=True)
mock_req.reject.assert_called_once()
mock_req.confirm.assert_not_called()