2020``allow_always``, another adapter may mint anything) while the four kinds are
2121the protocol's. An id raven invented would come back as "declined" from codex
2222and as anything at all from the rest.
23+
24+ "Always approve" stops at the host's own refusals. Approving is the answer
25+ for the ask tier, where nobody is there to be asked; a deny rule needs nobody,
26+ and approving past one handed a sub-agent every command the operator had
27+ refused raven itself. So a request naming a command is first read against the
28+ two rulings the host's gate consults before any mode: the builtin deny list
29+ with ``tools.exec.extraDenyPatterns``, and a ``deny`` rule in
30+ ``permissions.tools``. A match is answered with the agent's reject option.
31+ This sees only what an agent asks about -- a call its own gate allows never
32+ reaches here -- which is why raven's own products also carry the host's
33+ refusals in their rendered config (``product_render.inherit_host_denials``).
2334"""
2435
2536from __future__ import annotations
2637
2738from collections .abc import Awaitable , Callable
39+ from functools import lru_cache
2840from typing import Any
2941
3042from loguru import logger
@@ -72,9 +84,96 @@ def permission_outcome(params: dict[str, Any]) -> dict[str, Any]:
7284 return {"outcome" : "cancelled" }
7385
7486
87+ def refusal_outcome (params : dict [str , Any ]) -> dict [str , Any ]:
88+ """The ``outcome`` that refuses one request: a reject option, else cancel.
89+
90+ Cancelling is the last resort because codex cancels the whole turn on it;
91+ it is still the answer when nothing else was offered, since selecting an
92+ allow option would run the command being refused.
93+ """
94+ options = _options (params )
95+ for kind in ("reject_once" , "reject_always" ):
96+ for option in options :
97+ option_id = option .get ("optionId" )
98+ if option .get ("kind" ) == kind and isinstance (option_id , str ):
99+ return {"outcome" : "selected" , "optionId" : option_id }
100+ return {"outcome" : "cancelled" }
101+
102+
103+ def requested_commands (params : dict [str , Any ]) -> list [str ]:
104+ """Every spelling of the shell command one permission request names.
105+
106+ Read from the places the dialects read (``acp_dialects``): the spec's
107+ ``toolCall.rawInput.command``, as sent and with the quotes codex wraps it
108+ in taken off, and codex's parsed ``commandActions``. All of them, not the
109+ first: a compound command parses into one action per segment, and the
110+ segment a rule refuses need not be the first one.
111+ """
112+ found : list [str ] = []
113+ tool = params .get ("toolCall" )
114+ raw = tool .get ("rawInput" ) if isinstance (tool , dict ) else None
115+ command = raw .get ("command" ) if isinstance (raw , dict ) else None
116+ if isinstance (command , str ):
117+ found += [command .strip (), command .strip ().strip ('"' ).strip ()]
118+ meta = params .get ("_meta" )
119+ codex = meta .get ("codex" ) if isinstance (meta , dict ) else None
120+ codex_params = codex .get ("params" ) if isinstance (codex , dict ) else None
121+ actions = codex_params .get ("commandActions" ) if isinstance (codex_params , dict ) else None
122+ for action in actions if isinstance (actions , list ) else []:
123+ text = action .get ("command" ) if isinstance (action , dict ) else None
124+ if isinstance (text , str ):
125+ found .append (text .strip ())
126+ return list (dict .fromkeys (text for text in found if text ))
127+
128+
129+ @lru_cache (maxsize = 1 )
130+ def _host_policy () -> tuple [Any , Any ]:
131+ """The host's live config and builtin rulings, built once per process.
132+
133+ Both re-read the config file on every call (by content), so an operator
134+ tightening a rule binds the next request without a restart.
135+ """
136+ from raven .config .live import LiveConfig , exec_extra_deny_patterns
137+ from raven .permissions import BuiltinRulings
138+
139+ live = LiveConfig ()
140+ return live , BuiltinRulings (extra_deny_source = lambda : exec_extra_deny_patterns (live ))
141+
142+
143+ def host_refusal (params : dict [str , Any ]) -> str | None :
144+ """Why the host's own deny rules refuse this request's command, or None.
145+
146+ A parse error is not a refusal here: the host's gate hands that back to
147+ its own model to fix, and an adapter's quoting is not the sub-agent's
148+ model's to fix.
149+ """
150+ commands = requested_commands (params )
151+ if not commands :
152+ return None
153+ from raven .config .live import permissions_config
154+ from raven .contracts .permissions import DecisionSource , Deny , Tier
155+ from raven .permissions .rules import user_tier
156+
157+ live , builtin = _host_policy ()
158+ rules = permissions_config (live ).tools
159+ for command in commands :
160+ call = {"command" : command }
161+ ruling = builtin .ruling ("exec" , call )
162+ if isinstance (ruling , Deny ) and ruling .source is DecisionSource .BUILTIN_DENY :
163+ return f"{ command } : { ruling .reason } "
164+ if user_tier ("exec" , call , rules ) is Tier .DENY :
165+ return f"{ command } : blocked by a deny rule in the host's permissions config"
166+ return None
167+
168+
75169def auto_approver (name : str , observe : Callable [[str , dict [str , Any ]], Awaitable [None ]] | None = None ) -> "Any" :
76170 """An ``on_request`` handler that approves permissions and refuses the rest.
77171
172+ A permission whose command the host's deny rules refuse is the exception
173+ (:func:`host_refusal`) and is answered with a reject option. A check that
174+ raises refuses too: the request still has to be answered, and approving a
175+ command nobody could check is the failure this exception exists to stop.
176+
78177 Refusing the rest is deliberate: ``fs/read_text_file`` and its siblings are
79178 advertised as unsupported in ``CLIENT_CAPABILITIES``, and answering one
80179 here would claim a capability raven does not serve.
@@ -89,10 +188,19 @@ def auto_approver(name: str, observe: Callable[[str, dict[str, Any]], Awaitable[
89188 async def handle (method : str , params : dict [str , Any ]) -> Any :
90189 if method != PERMISSION_METHOD :
91190 return UNHANDLED
92- outcome = permission_outcome (params )
191+ try :
192+ refusal = host_refusal (params )
193+ except Exception : # noqa: BLE001 - an unanswered request cancels the turn
194+ logger .exception ("acp agent {!r}: checking the host's deny rules failed; refusing" , name )
195+ refusal = "the host's deny rules could not be checked"
93196 tool = params .get ("toolCall" )
94197 title = tool .get ("title" ) or tool .get ("kind" ) if isinstance (tool , dict ) else None
95- logger .debug ("acp agent {!r}: approving {} ({})" , name , title or "a tool call" , outcome )
198+ if refusal is not None :
199+ outcome = refusal_outcome (params )
200+ logger .info ("acp agent {!r}: refusing {} ({})" , name , refusal , outcome )
201+ else :
202+ outcome = permission_outcome (params )
203+ logger .debug ("acp agent {!r}: approving {} ({})" , name , title or "a tool call" , outcome )
96204 if observe is not None :
97205 try :
98206 await observe (method , params )
@@ -135,4 +243,12 @@ async def handle(method: str, params: dict[str, Any]) -> Any:
135243 return handle
136244
137245
138- __all__ = ["PERMISSION_METHOD" , "auto_approver" , "permission_outcome" , "request_dispatcher" ]
246+ __all__ = [
247+ "PERMISSION_METHOD" ,
248+ "auto_approver" ,
249+ "host_refusal" ,
250+ "permission_outcome" ,
251+ "refusal_outcome" ,
252+ "request_dispatcher" ,
253+ "requested_commands" ,
254+ ]
0 commit comments