Skip to content

Commit 80d6231

Browse files
ZuyiZhouclaude
andcommitted
fix(*): a command the host denies stays denied once it is handed to a sub-agent
The host answered every session/request_permission from an ACP sub-agent with the most permissive option, and a product launcher rendered its own config without the host's permissions. So a host deny rule bound the host's own tool calls and nothing it dispatched: with curl denied on the host, a curl handed to Raven-Code ran (measured end to end). Refusals now travel on both sides, since a refusal needs nobody to answer: - product_render.write_rendered merges the host's deny entries of permissions.tools and tools.exec.extraDenyPatterns into every product render, strictest wins, so the product's own gate refuses without asking. Every launcher ends in that call. - the host's approver reads the command a request names (rawInput.command, codex commandActions) against the host's builtin deny list and deny rules, live, and answers a match with the agent's reject option. - raven's ACP server now sends a shell call's command as rawInput.command, redacted, so a raven host can read it. The ask tier and the mode are still not carried: the approver keeps approving what no deny rule names. Co-authored-by: Claude (claude-opus-5-5) <noreply@anthropic.com>
1 parent 5b3788d commit 80d6231

14 files changed

Lines changed: 785 additions & 19 deletions

‎CONTEXT.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2268,13 +2268,19 @@ statement about the agent right now.
22682268
**Unattended Approval** (`raven/acp_client/permissions.py`):
22692269
How raven answers an ACP Subagent's `session/request_permission`: it approves, choosing
22702270
from the options the agent offered by their protocol `kind` (`allow_always`, then
2271-
`allow_once`) and never by `optionId`, which is the agent's own vocabulary. There is no
2272-
third answer - a dispatch has no operator and no surface that could render a prompt - and
2273-
*not* answering is not one either: measured on `codex-acp`, any error to this request,
2271+
`allow_once`) and never by `optionId`, which is the agent's own vocabulary. It never asks
2272+
a person - a dispatch has no operator and no surface that could render a prompt - and
2273+
*not* answering is no answer either: measured on `codex-acp`, any error to this request,
22742274
including the `method not found` raven used to send, cancels the whole turn. Presets that
22752275
take a launch-time never-ask setting carry it too, so the question is not asked at all.
22762276
The same trust boundary the cli transport already ran under (`codex -a never`,
22772277
`claude --permission-mode auto`), stated in one place instead of per command template.
2278+
The one request it refuses is one naming a shell command (`toolCall.rawInput.command`,
2279+
codex's `commandActions`) that the host's own deny rules refuse - the builtin list with
2280+
`tools.exec.extraDenyPatterns`, or a `deny` entry in `permissions.tools`: refusing needs no
2281+
operator, so it is answered with the agent's reject option. Raven's own products carry the
2282+
same refusals in their rendered config (`product_render.inherit_host_denials`), because a call
2283+
their own gate allows never reaches this handler.
22782284
Distinct from what raven still refuses: `fs/read_text_file` and its siblings are declared
22792285
unsupported in `CLIENT_CAPABILITIES`, and a handler returning `UNHANDLED` is how they stay
22802286
that way.

‎docs-site/docs/permissions.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,14 @@ might execute inside its process.
108108
`session/request_permission`.
109109
- **Outbound ACP:** Raven's unattended client automatically prefers the
110110
child's `allow_always` or `allow_once` option. Enabling this agent is not a
111-
promise of human review for each internal operation.
111+
promise of human review for each internal operation. Your refusals do
112+
travel: a request whose shell command matches your `deny` rules or
113+
`tools.exec.extraDenyPatterns` is answered with the child's reject option,
114+
and Raven's own agents (Raven-Code and its siblings) carry those rules in
115+
their rendered config, so they refuse such a call without asking. A
116+
third-party agent is held only for what it asks about, and a rule you add
117+
reaches an already running Raven agent when it is next launched. The ask
118+
tier and the mode are not carried.
112119
- **CLI and other backends:** inspect their command, environment, working
113120
directory, and native permission policy. Host permission rules are not
114121
automatically inherited across every backend.

‎docs-site/docs/permissions.zh.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,11 @@ Raven 的权限门管理自身注册表调用,不是外部 Agent 进程内每
9191

9292
- **入站 ACP:** Raven 可通过 `session/request_permission` 请求编辑器或宿主审批。
9393
- **出站 ACP:** Raven 无人值守客户端自动优先选择子 Agent 的 `allow_always` 或
94-
`allow_once`;启用 Agent 不代表每个内部操作都有人审阅。
94+
`allow_once`;启用 Agent 不代表每个内部操作都有人审阅。拒绝规则会传过去:请求中的
95+
shell 命令命中你的 `deny` 规则或 `tools.exec.extraDenyPatterns` 时,Raven 选择子
96+
Agent 的拒绝选项;Raven 自家 Agent(Raven-Code 等)的渲染配置也带上这些规则,不经
97+
询问即拒绝。第三方 Agent 只在它发出请求的调用上受约束;新增的规则要等正在运行的
98+
Raven Agent 下次启动才生效。ask 档和权限模式不会传递。
9599
- **CLI 和其他后端:** 检查启动命令、环境、工作目录及原生权限策略。宿主规则不会
96100
自动跨所有后端继承。
97101
- **A2A:** 共享 bearer token 接纳远程操作者。宿主工具门仍有效,但当前入站 A2A

‎docs-site/docs/protocol-backends.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ requests and client disconnects.
8888

8989
The packaged schema fixtures and tests in `tests/test_acp_schema.py` help check
9090
frame shapes. The current outbound client auto-approves offered permission
91-
options; integrations must disclose that trust model, not promise interactive
92-
human review.
91+
options, except that a request whose `toolCall.rawInput.command` the host's
92+
deny rules refuse is answered with a reject option; integrations must disclose
93+
that trust model, not promise interactive human review.
9394

9495
## Integrate A2A safely
9596

‎docs-site/docs/protocol-backends.zh.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ ACP client 使用可选功能前先协商。Prompt 等待期间并发处理请
7272
无人回答和客户端断开。
7373

7474
打包 schema fixtures 和 `tests/test_acp_schema.py` 检查帧形状。当前出站客户端
75-
自动批准对方提供的权限选项,集成应说明这一信任模型,不能承诺人工逐操作审批。
75+
自动批准对方提供的权限选项;但请求中的 `toolCall.rawInput.command` 被宿主拒绝规则
76+
命中时,改选拒绝选项。集成应说明这一信任模型,不能承诺人工逐操作审批。
7677

7778
## 安全集成 A2A { #integrate-a2a-safely }
7879

‎raven/acp/permissions.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
ALLOW_KIND = "allow_once"
5454
SESSION_KIND = "allow_always"
5555
REJECT_KIND = "reject_once"
56+
#: The ``approval_kind`` the shell tool declares (``ExecTool``).
57+
EXEC_KIND = "shell.exec"
5658

5759

5860
# The two outcomes that are a person's answer. Every other name in this file is
@@ -113,10 +115,14 @@ async def await_approval(
113115
) -> ApprovalOutcome:
114116
"""Ask, and return the grant the client's user chose.
115117
116-
``suggested_pattern`` and the prompt's view (``kind``, ``family``,
117-
``origin``, ``evidence``) are accepted for the responder contract and
118-
unused: this wire has no editor a human could confirm a rule in, and
119-
``session/request_permission`` has its own shape for what a client shows.
118+
``suggested_pattern`` and most of the prompt's view (``family``,
119+
``origin``) are accepted for the responder contract and unused: this
120+
wire has no editor a human could confirm a rule in, and
121+
``session/request_permission`` has its own shape for what a client
122+
shows. A shell call's command is the exception and rides as the spec's
123+
``rawInput.command``, because the title is prose and the client may be
124+
a raven whose own deny rules have to read the command
125+
(``raven/acp_client/permissions.py``).
120126
121127
Fails closed on every path. The signature is the one the permission
122128
gate calls, including the keyword-only arguments, so this object can be
@@ -156,6 +162,9 @@ async def await_approval(
156162
}
157163
if request["_meta"] is None:
158164
del request["_meta"]
165+
shell_command = (evidence or {}).get("command") if kind == EXEC_KIND else None
166+
if isinstance(shell_command, str) and shell_command.strip():
167+
request["toolCall"]["rawInput"] = {"command": redact.redact(shell_command)}
159168

160169
try:
161170
result = await self._outbound.call("session/request_permission", request, timeout=self._timeout_s)
@@ -242,4 +251,4 @@ def _record(self, outcome: str, choice: ApprovalChoice = ApprovalChoice.DENY) ->
242251
return ApprovalOutcome(choice=choice, answered=outcome in _ANSWERED)
243252

244253

245-
__all__ = ["ALLOW_KIND", "REJECT_KIND", "AcpPermissionBroker"]
254+
__all__ = ["ALLOW_KIND", "EXEC_KIND", "REJECT_KIND", "AcpPermissionBroker"]

‎raven/acp_client/permissions.py‎

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,23 @@
2020
``allow_always``, another adapter may mint anything) while the four kinds are
2121
the protocol's. An id raven invented would come back as "declined" from codex
2222
and 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

2536
from __future__ import annotations
2637

2738
from collections.abc import Awaitable, Callable
39+
from functools import lru_cache
2840
from typing import Any
2941

3042
from 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+
75169
def 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+
]

‎raven/config/product_render.py‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
the raven data directory, the work stays where the work is), seeding a
1111
workspace file once, assembling the
1212
``acp.modes`` catalogue from overlay files, sweeping stale renders by pid
13-
liveness, and writing the rendered copy owner-only.
13+
liveness, and writing the rendered copy owner-only with the host's deny
14+
rules merged in.
1415
1516
What stays in each product is its tables and its judgement: which secrets go
1617
where, what its modes are called, how a mode's budget resolves, what must
@@ -31,6 +32,7 @@
3132
import hashlib
3233
import json
3334
import os
35+
import re
3436
from pathlib import Path
3537
from typing import Any, Callable, Iterable
3638

@@ -182,6 +184,81 @@ def inherit_llm(config: dict, host: dict) -> str:
182184
)
183185

184186

187+
_DENY = "deny"
188+
_EXTRA_DENY_KEYS = ("extraDenyPatterns", "extra_deny_patterns")
189+
190+
191+
def _compiles(pattern: str) -> bool:
192+
try:
193+
re.compile(pattern)
194+
except re.error:
195+
return False
196+
return True
197+
198+
199+
def inherit_host_denials(config: dict, host: dict) -> list[str]:
200+
"""Carry every refusal the host operator configured into the product config.
201+
202+
A product runs as a raven of its own, and its permission gate reads only
203+
the rendered file. So a deny rule written on the host bound the host's own
204+
tool calls and nothing the host dispatched: the product's gate had never
205+
heard of it, and the host answers a product's approval requests itself
206+
(``raven/acp_client/permissions.py``), which is how a command the host
207+
refuses ran anyway once it was handed to a product.
208+
209+
Only refusals travel -- the ``deny`` entries of ``permissions.tools`` and
210+
``tools.exec.extraDenyPatterns`` -- because they are the half of a policy
211+
that needs nobody to answer it. A host ``allow`` would loosen the product,
212+
and a host ``ask`` would only reach an approver that grants it.
213+
214+
Merged into what the product already says, never replacing it, and a
215+
refusal always wins: a product ``exec`` rule written as one tier becomes a
216+
table with that tier as its ``*`` fallback, so the host's patterns can sit
217+
beside it. An extra deny pattern that does not compile is left behind, as
218+
the host's own live reader leaves it (``BuiltinRulings``), rather than
219+
stopping the product from starting. Returns what was carried.
220+
"""
221+
carried: list[str] = []
222+
host_permissions = host.get("permissions")
223+
host_tools = host_permissions.get("tools") if isinstance(host_permissions, dict) else None
224+
for tool, entry in (host_tools if isinstance(host_tools, dict) else {}).items():
225+
if entry == _DENY:
226+
patterns: list[str] = []
227+
elif isinstance(entry, dict):
228+
patterns = [pattern for pattern, tier in entry.items() if tier == _DENY and isinstance(pattern, str)]
229+
if not patterns:
230+
continue
231+
else:
232+
continue
233+
tools = config.setdefault("permissions", {}).setdefault("tools", {})
234+
own = tools.get(tool)
235+
if not patterns:
236+
tools[tool] = _DENY
237+
carried.append(tool)
238+
continue
239+
if own == _DENY:
240+
continue
241+
table = dict(own) if isinstance(own, dict) else ({"*": own} if isinstance(own, str) else {})
242+
for pattern in patterns:
243+
table[pattern] = _DENY
244+
carried.append(f"{tool} {pattern}")
245+
tools[tool] = table
246+
247+
host_node = host.get("tools")
248+
host_exec = host_node.get("exec") if isinstance(host_node, dict) else None
249+
host_exec = host_exec if isinstance(host_exec, dict) else {}
250+
extras = next((host_exec[key] for key in _EXTRA_DENY_KEYS if key in host_exec), None)
251+
wanted = [p for p in extras if isinstance(p, str) and _compiles(p)] if isinstance(extras, list) else []
252+
if wanted:
253+
exec_node = config.setdefault("tools", {}).setdefault("exec", {})
254+
key = next((key for key in _EXTRA_DENY_KEYS if key in exec_node), _EXTRA_DENY_KEYS[0])
255+
own_extras = exec_node.get(key) if isinstance(exec_node.get(key), list) else []
256+
added = [pattern for pattern in wanted if pattern not in own_extras]
257+
exec_node[key] = [*own_extras, *added]
258+
carried.extend(added)
259+
return carried
260+
261+
185262
def inherit_media_image(config: dict, host: dict) -> dict:
186263
"""Take the host raven's image-generation section, and keep following it.
187264
@@ -470,7 +547,12 @@ def write_rendered(config: dict, root: Path) -> Path:
470547
config file's own parent, so wherever this file goes, sessions and cache
471548
go too. Owner-only because the render is where the secrets landed; the
472549
pid in the name is what :func:`sweep_stale_renders` reads back.
550+
551+
The host's refusals are merged in here rather than by each launcher
552+
(:func:`inherit_host_denials`): every product render ends in this call, so
553+
a launcher cannot write a config that forgot them.
473554
"""
555+
inherit_host_denials(config, host_config())
474556
rendered = root / f".config.rendered.{os.getpid()}.json"
475557
fd = os.open(rendered, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
476558
os.fchmod(fd, 0o600)

0 commit comments

Comments
 (0)