Skip to content

fix(security_gate): give a refused approval a reason the model can read - #6810

Merged
senamakel merged 4 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/deny-reason-reaches-model
Sep 30, 2026
Merged

senamakel merged 4 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/deny-reason-reaches-model

Conversation

@M3gA-Mind

@M3gA-Mind M3gA-Mind commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • When the approval flow refuses a tool call on the tinyagents SecurityGate path, the model now gets a Deny with a reason. Before, it got Prompted { approved: false }, which carries none.
  • The new text says the action must not be performed this turn and must not be done another way (shell, CLI, another tool). It names no human and no timeout.
  • This deliberately changes the Prompted-vs-Deny choice documented on park_for_approval. A refused approval now returns Deny; an approval still returns Prompted { approved: true }.

Problem

  • park_for_approval logged GateOutcome::Deny { reason } and returned Prompted { approved: false }. The harness (tinyagents-harness/src/agent_loop/tools.rs ~905) reads denial_reason(), which is None for Prompted, and falls back to "tool call was not approved".
  • Seen live: after a Deny on a Calendar action, the model asked the user to approve. After a Deny on a Gist create, it ran shell: gh gist create --public and published anyway. shell never prompts while [autonomy] enabled = false (the default).
  • The older ApprovalSecurityMiddleware path already passes the gate's reason to the model. Only this adapter dropped it.

Solution

  • The GateOutcome → GateDecision mapping moves into a pure fn, decision_for_outcome. park_for_approval still writes the audit id on Allow, then calls it.
  • Allow → Prompted { approved: true }, as before. Deny { .. } → GateDecision::deny("This action was refused and must not be performed this turn — do not retry this call and do not achieve the same result another way (shell, CLI, another tool). Tell the user it was not done.").
  • No [policy-denied] marker, on purpose. middleware::repeated_failure classifies that marker as a policy failure with a zero recovery budget. That pauses the turn before the model can answer, and the user sees "I stopped this turn early…" instead of an acknowledgement. The first push carried the marker, and CI's approval_gate_deny_flow and approval_gate_timeout e2e tests caught exactly this. The unmarked text classifies as Unknown, the same as the old fallback, so the model gets the reason and still answers. The unit test pins that classification.
  • Tradeoff: timeout and dropped channel also reach this path as GateOutcome::Deny, so they get the same neutral text. Passing the gate's own text through would say "User denied" or "timed out", which breaks the no-human-hint intent of the original design. Before this change, those cases got the bare fallback string.
  • The gate's own reason text (gate_intercept_decision.rs) and the middleware path are unchanged. So is tests/raw_coverage/.../tool_registry_approval_raw_coverage_e2e.rs:1603, which still asserts "User denied" on the gate's text.
  • tinyagents documents Deny { reason } as host-authored text that is safe to show a model, so no vendor change is needed.

Submission Checklist

  • Tests added or updated: a_refused_approval_is_a_deny_the_model_can_read covers the human-deny and timeout reasons; an_approved_park_is_still_prompted_and_allowed covers approval. Revert-check: with GateDecision::Prompted { approved: false } put back in the Deny arm of decision_for_outcome, a_refused_approval_is_a_deny_the_model_can_read fails at security_gate_tests.rs:475, the .expect("a refused approval carries a reason for the model") on denial_reason(). The approve test still passes. E2E: agent_harness_e2e::approval_gate_deny_flow and approval_gate_timeout now also assert that the model's follow-up request carries the refusal text and not the "tool call was not approved" fallback (assert_model_saw_the_refusal). With the same revert, both fail at agent_harness_e2e.rs:184 ("the model must be told the refused call may not be done another way"); approve and installed still pass.
  • Diff coverage ≥ 80%: the changed mapping is covered by the two unit tests. park_for_approval needs the process-global ApprovalGate, which unit tests must not install, so it was already uncovered.
  • N/A: behaviour-only change; no feature rows added, removed or renamed in the coverage matrix.
  • N/A: no matrix feature IDs affected.
  • No new external network dependencies introduced.
  • N/A: does not touch a release-cut smoke surface.
  • Linked issue closed via Closes #6808 below.

Impact

  • Desktop / any host with an installed ApprovalGate: after a refused, timed-out or cancelled approval, the model gets an explicit do-not-do-it-another-way instruction instead of "tool call was not approved".
  • Security: this narrows behaviour. It targets the observed bypass of a Deny by redoing the call through shell. It is prompt text, not enforcement.
  • CI heads-up (inherited, not fixed here): the EX63 lane tests this PR merged onto current main, which includes chore(vendor): repoint all vendor modules to their merged upstream main #6809. chore(vendor): repoint all vendor modules to their merged upstream main #6809 bumped every vendor submodule and merged before its own EX63 lane ran. On that merge, static:module-pins, rust-fmt, rust-layout, agent-runtime-boundary and ignored-tests-ratchet all fail, and each names only files this PR does not touch. Five nextest failures do not involve the approval path: orchestrator_advertises_direct_mcp_tools, orchestrator_cannot_install_a_skill_through_the_raw_registry_tool, orchestrator_hands_skill_installs_to_skill_setup_directly, cwd_jail_e2e::default_backend_is_named_and_available and credential_scrub::browser_confirmation_token_survives_without_exempting_page_credentials. I believe they come from chore(vendor): repoint all vendor modules to their merged upstream main #6809's bumps, but no main run exists to prove it.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: fix/deny-reason-reaches-model
  • Commit SHA: 4689841

Validation Run

  • N/A: pnpm --filter openhuman-app format:check, no frontend change
  • N/A: pnpm typecheck, no frontend change
  • Focused tests: RUST_MIN_STACK=67108864 cargo test -p openhuman --lib -- agent::tinyagents::host::security_gate security::approval agent::tinyagents::policy_denial → 161 passed, 0 failed; cargo test -p openhuman-cli --test agent_harness_e2e -- approval_gate → 4 passed (deny, timeout, approve, installed), including the new request assertion
  • Rust fmt/check: rustfmt --check on both touched files is clean; cargo clippy -p openhuman --lib -- -D warnings is clean
  • N/A: Tauri fmt/check, no Tauri change

Validation Blocked

  • command: cargo clippy -p openhuman --lib --tests -- -D warnings and cargo fmt --check
  • error: both fail on files this PR does not touch (e.g. agent/goals/store.rs import order). The only --tests clippy hits in a touched file are at security_gate_tests.rs:342/368, which predate this diff.
  • impact: none from this PR

Behavior Changes

  • Intended behavior change: a refused approval returns GateDecision::Deny with neutral, route-closing text instead of Prompted { approved: false }.
  • User-visible effect: after a Deny, the agent says the action was not done, instead of asking the user to approve again or redoing it through shell.

Parity Contract

  • Legacy behavior preserved: approval path, audit-id bookkeeping, ApprovalSecurityMiddleware, and the gate's own reason strings.
  • Guard/fallback/dispatch parity checks: the only consumer that matches on Prompted is authorize_tool's channel stage, which matches approved: true and returns any other decision as-is.

Duplicate / Superseded PR Handling

  • Duplicate PR(s): none
  • Canonical PR: this
  • Resolution (closed/superseded/updated): N/A

Summary by CodeRabbit

  • Bug Fixes
    • Approval refusals and timeouts now consistently block the requested action and provide clear guidance not to retry it or attempt it another way.
    • Approved requests continue to proceed through the existing approval flow.

park_for_approval turned GateOutcome::Deny into Prompted { approved: false },
which carries no reason, so the harness told the model only "tool call was
not approved". After a Deny the model asked the user to approve again, or
reached the same result through shell.

The GateOutcome -> GateDecision mapping moves into decision_for_outcome. A
refusal is now a Deny whose text names no human and no timeout and rules out
other routes. Approvals and the audit id are unchanged; the middleware path
and the gate's own reason text are untouched.

Closes tinyhumansai#6808
@tinysweeper

tinysweeper Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Tiny Sweeper review

⚠️ Review failed for 4689841a15bf. the review of #6810 did not finish within 900s

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 4a738ad3-d66a-4894-989e-ddf91533b37c

📥 Commits

Reviewing files that changed from the base of the PR and between c7905bf and 4689841.

📒 Files selected for processing (3)
  • crates/openhuman-core/src/agent/tinyagents/host/security_gate.rs
  • crates/openhuman-core/src/agent/tinyagents/host/security_gate_tests.rs
  • tests/agent_harness_e2e.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Approval refusals and timeouts now return a Deny decision with model-facing instructions. Approved outcomes remain Prompted { approved: true }. Unit and end-to-end tests cover these outcomes and the refusal guidance sent to the model.

Changes

Approval gate outcomes

Layer / File(s) Summary
Map approval outcomes and verify decisions
crates/openhuman-core/src/agent/tinyagents/host/security_gate.rs, crates/openhuman-core/src/agent/tinyagents/host/security_gate_tests.rs, tests/agent_harness_e2e.rs
decision_for_outcome maps refusals and timeouts to Deny with instructions not to perform the action or use another route. Approved outcomes remain Prompted { approved: true }. Unit and end-to-end tests check both outcomes and the refusal guidance received by the model.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~12 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: senamakel

Merge Risk: ⚪ Minimal · up to 46898

The refusal and timeout flows deliver the intended guidance to the model, and approved outcomes remain allowed prompts. No merge-blocking risk was identified.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 46898

Refused actions now receive explicit instructions against retrying or using another route. Approval and audit behavior remain intact. No new authorization weakness was established, but downstream handling was not fully verified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The behavioral change applies to tool calls reaching this host's approval flow, not merely the file writes used in tests. The mapper consumes an already-settled approval outcome and does not itself grant additional execution authority.

Security Findings and Attack Paths

  • inferred — The alternate-route prohibition is guidance, not a new host-enforced equivalence boundary across tools. The code documents a pre-existing shell retry path; this PR strengthens the refusal message but adds no turn-scoped tracking of equivalent actions. This limitation is not established as introduced or worsened by the change.

Trust Boundaries and Controls

  • observed — A channel-required approval continues only on Prompted(true); refusal or expiry returns immediately and cannot be widened by later authorization stages. Channel-required approval also fails closed when no approval flow is installed. The separate Allow fallback inside park_for_approval predates this change.

Resilience and Maintainability Implications

  • inferred — The pure outcome mapper leaves approval identity correlation and waiter ownership with their existing components. Transition inspection found no new reservation, recovery, or cleanup state. Existing concurrent-abort and timeout tests support terminal-denial behavior, but do not establish executed runtime results for this revision.

Hardening Proposals

  • proposed — If refusal must prohibit equivalent actions across tools as a security guarantee, enforce that restriction at the execution boundary with turn-scoped refusal state rather than relying solely on returned guidance.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: refused approvals now provide a reason the model can read.
Linked Issues check ✅ Passed The PR meets [#6808]. The tinyagents SecurityGate maps refusal to GateDecision::Deny with text that forbids the action this turn and forbids retrying or using another route. The text does not iden…
Out of Scope Changes check ✅ Passed The changes remain within [#6808]. The marker removal and its classification assertion support the requested model-facing refusal behavior. The end-to-end checks verify that the model receives the ref…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

A rabbit checks the gate at night
A refusal now comes through in sight
No second path, no retry call
Approved prompts still pass the wall
The tests confirm each choice is right
Then hops away beneath the moonlight

Comment @coderabbitai help to get the list of available commands.

gate_decision allows every command class while [autonomy] enabled = false,
the default; it is not tied to the full tier.

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tinysweeper found nothing blocking, but could not review everything, so this is not an approval: crates/openhuman-core/src/agent/tinyagents/host/security_gate.rs, crates/openhuman-core/src/agent/tinyagents/host/security_gate_tests.rs.

             $0.0050 · 42,279 in / 11,409 out · 12,544 cached (30%) · ladder/vectors, deepseek/deepseek-v4-flash · 479 embedded
tests:       $0.0011 · 13,625 in / 3,252 out  · 9,728 cached (71%)  · deepseek/deepseek-v4-flash
description: $0.0011 · 6,572 in  / 3,743 out  · 2,304 cached (35%)  · deepseek/deepseek-v4-flash
e2e:         $0.0020 · 18,116 in / 1,884 out  · 0 cached (0%)       · deepseek/deepseek-v4-flash

Comment thread crates/openhuman-core/src/agent/tinyagents/host/security_gate.rs
@tinysweeper tinysweeper Bot added the priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. label Sep 30, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Sep 30, 2026
…lass

The [policy-denied] marker classifies as a policy failure with a zero
recovery budget in repeated_failure, which pauses the turn before the model
can answer. approval_gate_deny_flow and approval_gate_timeout caught it: the
turn ended with the no-progress summary instead of the model's reply. The
unmarked text classifies as Unknown, as the harness fallback did, so the
model gets the reason and still answers this turn.
…ason

approval_gate_deny_flow and approval_gate_timeout checked only the turn's
final text, which a scripted model produces whatever it was sent. They now
also assert the follow-up request carries the gate's refusal and not the
reason-less "tool call was not approved" fallback.
@senamakel
senamakel merged commit 695e749 into tinyhumansai:main Sep 30, 2026
34 of 37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Human Deny reaches the model only as 'tool call was not approved'

2 participants