Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions secator/ai/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ def _build_action_display(action: Dict) -> str:
return ""


def _is_approved(response) -> bool:
# Explicit allow-list: only a normalized "allow" answer approves. None, "deny",
# or any unexpected token denies (fail closed) — so a new backend or a refactored
# answer vocabulary can't silently approve via a "not deny" gap.
return bool(response) and response.get("answer") == "allow"


def check_guardrails_sync(action: Dict, ctx: ActionContext) -> Tuple[Optional[str], List]:
"""Non-generator wrapper for check_guardrails.

Expand Down Expand Up @@ -255,7 +262,7 @@ def check_guardrails(action: Dict, ctx: ActionContext):
if is_remote:
yield ctx.backend.build_pending_prompt(**ask_kwargs)
response = ctx.backend.ask_user(**ask_kwargs) if ctx.backend else None
if response is None or response.get("answer") == "deny":
if not _is_approved(response):
return "Action denied: shell command not approved"
if parse_failed:
return None
Expand All @@ -279,7 +286,7 @@ def check_guardrails(action: Dict, ctx: ActionContext):
if is_remote:
yield ctx.backend.build_pending_prompt(**ask_kwargs)
response = ctx.backend.ask_user(**ask_kwargs) if ctx.backend else None
if response is None or response.get("answer") == "deny":
if not _is_approved(response):
return f"Action denied: target {target} not approved"

# Handle path prompts
Expand All @@ -302,7 +309,7 @@ def check_guardrails(action: Dict, ctx: ActionContext):
if is_remote:
yield ctx.backend.build_pending_prompt(**ask_kwargs)
response = ctx.backend.ask_user(**ask_kwargs) if ctx.backend else None
if response is None or response.get("answer") == "deny":
if not _is_approved(response):
return f"Action denied: {access_type} access to {path} not approved"

# Re-check to see if more layers need prompting
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/test_ai_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,38 @@ def test_unresolved_after_max_rounds_denies(self):
self.assertIn("unresolved", denial)


class TestApprovalAllowList(unittest.TestCase):
"""Ask-loop approval must be an explicit allow-list: only "allow" proceeds."""

def _run(self, answer):
from secator.ai.actions import check_guardrails_sync
ask = MagicMock(decision="ask", shell_command="somecmd", targets=[], paths=[], reason="needs approval")
allow = MagicMock(decision="allow", shell_command="", targets=[], paths=[], reason="")
engine = MagicMock()
engine.check_action.side_effect = [ask, allow]
backend = MagicMock()
backend.ask_user.return_value = None if answer is None else {"answer": answer}
ctx = ActionContext(targets=['t.com'], model='m')
ctx.permission_engine = engine
ctx.backend = backend
denial, _items = check_guardrails_sync({"action": "shell", "command": "somecmd"}, ctx)
return denial

def test_allow_proceeds(self):
self.assertIsNone(self._run("allow"))

def test_deny_denies(self):
self.assertIsNotNone(self._run("deny"))

def test_unexpected_answer_denies(self):
# an out-of-vocabulary token must NOT be treated as approval (fail closed)
self.assertIsNotNone(self._run("sure"))
self.assertIsNotNone(self._run("allow_all_typo"))

def test_none_response_denies(self):
self.assertIsNotNone(self._run(None))


@unittest.skipUnless(ADDONS_ENABLED['ai'], 'ai addon not installed')
class TestSubagentFanoutCap(unittest.TestCase):
"""H4: recursion depth + per-turn fan-out caps on AI-subagent spawns."""
Expand Down