fix(agent-harness): structured policy-block messages with workaround (#4094)#4390
fix(agent-harness): structured policy-block messages with workaround (#4094)#4390M3gA-Mind wants to merge 3 commits into
Conversation
Policy/permission denials in the session tool executor now return a structured message (what was blocked, why, and a concrete workaround / how-to-enable / permitted alternative) plus an explicit instruction to relay it to the user, instead of a bare denial line the agent could dead-end on. Covers the session-policy, per-call permission, pluggable policy Deny, and RequireApproval branches. The message is returned as the failed tool result so it flows back into the turn. Closes tinyhumansai#4094
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 25 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 80e75c6aa0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…path; point workaround at channel_permissions Address review on tinyhumansai#4390: - P1: the real turn flow runs through the tinyagents ToolPolicyMiddleware, which duplicated the denial branches with the old bare strings, so the structured message never reached production turns. Share PolicyDenial (now pub(crate)) and render it in the middleware's channel-permission and pluggable-policy blocks. Add a middleware regression test. - P2: permission-ceiling denials are capped by [agent].channel_permissions, not autonomy. Point the workaround at that config with valid level tokens (read_only/write/execute/dangerous) instead of update_autonomy_settings.
Review follow-up (commit 6450b94)Addressed both Codex findings:
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
sanil-23
left a comment
There was a problem hiding this comment.
Review — #4390 · structured policy-block messages with workaround (#4094)
Verdict: LGTM with one coverage gap worth closing. The change is well-scoped and the follow-up commit (6450b94) correctly fixed both Codex findings — the structured message now reaches the real turn path (the tinyagents ToolPolicyMiddleware), and the workaround points at [agent].channel_permissions with valid tokens rather than the wrong [autonomy] knob. I traced both execution paths and the security/policy layer; the core logic is sound.
Walkthrough
PolicyDenial (new session/policy_denial.rs) renders four denial variants (SessionForbidden, PermissionTooLow, PolicyDenied, ApprovalRequired) into a Blocked / Reason / Workaround / relay-instruction message. It's now shared (pub(crate)) between the two mutually-exclusive tool-exec paths — the legacy agent_tool_exec::run_agent_tool_call (via Agent::turn) and the tinyagents ToolPolicyMiddleware (production turn path). Good de-dup of the rendering; the branch structure is still mirrored across the two files, which is inherent to the #4249 migration living in both worlds.
What I verified
- No message duplication / double-check. The two paths are exclusive: tinyagents tools are registered as adapters and gated by the middleware (which short-circuits before
next.run), soagent_tool_exec's denial branches never fire on the turn path.denials::record/policy.checkrun once per call. ✅ - P2 token correctness.
permission_config_tokenemitsread_only/write/execute/dangerous;parse_permission_levelstrips-/_and lowercases, so every suggested token round-trips. ✅ - No fragile downstream consumers. Grepped
src/+app/src/— nothing pattern-matches the old wording ("blocked by tool policy: requires","action requires ... permission"), so changing the string breaks no parser. ✅ - Surfacing. Denials return as
error: Some(message)⇒success=false, flowing into the timeline as a failed call — consistent with the unknown-tool corrective pattern. ✅
Findings (prioritized)
1. (major — coverage) The middleware pluggable-policy branch is untested on the turn path.
middleware.rs:858-873 (the Deny/RequireApproval → PolicyDenial render inside call()) has no test. The new middleware test channel_permission_block_returns_structured_denial calls channel_permission_block() directly and never exercises the full call() → self.policy.check() deny branch. The equivalent branch in agent_tool_exec.rs is covered (pluggable_policy_denial_returns_structured_message_with_alternative), but that's the other path. Net: the production turn path's pluggable Deny/RequireApproval rendering is unverified, and those changed lines may also miss the ≥80% diff-cover gate. Suggest a middleware test that drives the whole call() with a DenyingToolPolicy (and one RequireApproval) so both the message and the error=Some(..) surfacing are asserted end-to-end.
2. (minor — conflict) Merge-order coordination with sibling #4389. #4389 (change strategy on repeated unproductive tool results) also edits tinyagents/middleware.rs and tinyagents/mod.rs. Different regions, so likely auto-mergeable, but flag the rebase order between the two. (#4386/#4387 touch turn/* and don't overlap; #4419 touches only observability.rs.)
3. (nitpick) Odd phrasing when allowed == PermissionLevel::None. The shared workaround tail reads "…a tool that needs only None access." Only reachable if a channel is capped at None; cosmetic. Could special-case None to "a read-only tool" or drop the tail.
Question for the author
- #4419 tool-timeline interaction: the denial is now a multi-sentence paragraph carried in both
contentanderror. Does the timeline render (or truncate) that cleanly for a blocked call, and is duplicating the string acrosscontent+errorstill the intended shape post-#4419? (This mirrors the pre-existing pattern, so not a regression — just confirming.)
Looks good
- Correct variant selection (
RequireApprovalvsDeny) in both files;reasonborrow lifetime is fine. - Strong unit coverage of all four
PolicyDenialvariants, plus session + pluggable integration tests on theagent_tool_execpath. - Doc comments accurately explain why the workaround targets
channel_permissions, not autonomy — this is exactly the kind of "why" comment the repo asks for.
sanil-23
left a comment
There was a problem hiding this comment.
Requesting changes on one point (details in my review comment above).
Blocker to resolve before merge — untested pluggable-policy render on the production turn path. middleware.rs:858-873 renders the Deny/RequireApproval denial inside call(), but no test exercises it: the new channel_permission_block_returns_structured_denial test calls channel_permission_block() directly and never reaches self.policy.check(). The equivalent branch is covered only on the other (agent_tool_exec) path. Net effect:
- The production turn path's pluggable Deny/RequireApproval message +
error=Some(..)surfacing is unverified. - Those changed lines likely miss the ≥80% diff-cover gate (
.github/workflows/pr-ci.yml), which will block merge anyway.
Please add a ToolPolicyMiddleware test that drives the full call() with a denying policy (and one RequireApproval) asserting the structured message reaches the TaToolResult. Everything else — the P1/P2 fixes, token correctness, the four PolicyDenial variants — looks good; this is the only thing holding it up.
Closes #4094
Problem
When a tool policy / permission boundary blocks an action, the session tool executor returned a bare denial line (e.g.
Tool 'X' blocked by tool policy: requires Execute, channel 'web' allows ReadOnly). The agent could dead-end on it — no clear next step, no way to unblock — leaving the user stuck.Enforcement approach
New
session/policy_denial.rsrenders every policy/permission denial as a structured message:Blocked: <what>. Reason: <why>. Workaround: <how-to-enable / permitted alternative>. Relay this to the user…. The relay instruction is baked in so the agent surfaces an explanation + next step rather than silently halting.agent_tool_exec::run_agent_tool_callnow routes all four block branches through it:config.update_autonomy_settings/[autonomy]), or to fall back to a lower-permission tool.ToolPolicyDeny → carries the policy's reason plus "use a permitted alternative / ask the user to adjust the policy" (matches the issue'srun_script → sandbox restrictionexample).RequireApproval→ tells the agent to ask the user to approve, then retry.The message is returned as the failed tool result, so it flows back into the turn the same way the unknown-tool corrective error is surfaced to the model (sibling PR #4360).
Acceptance
Tests
policy_denial.rsfor each denial variant's rendered message.agent_tool_exec.rs: a session-policy-denied Execute tool and a pluggable-policy-denied call each yield the structured message (reason + workaround + relay instruction) withsuccess == falseand a completion emitted through progress (not a silent drop).Caveat: local test run was blocked by the pre-existing
whisper-rs-sysMetal linker error on this macOS SDK (_OBJC_CLASS_$_MTLResidencySetDescriptor, documented in CLAUDE.md), unrelated to this change — the crate compiles; CI runs the tests. This area overlaps PR #4360 (session/agent_tool_exec.rs); will rebase onto it once it lands.