Skip to content

Add sandbox-awareness recovery: nudge agent after repeated tool denials #5

@tbrandenburg

Description

@tbrandenburg

Summary

When the agent repeatedly hits permission denials or tool errors (e.g. trying to read outside the workspace), it often retries the same failing path instead of adapting. A nudge after 2–3 consecutive failures would remind it that it may be in a sandboxed environment and guide it toward an alternative approach.

Motivation

Observed in practice: the agent attempted to read files under ~/.cache/ (not in the allowed directory list), triggered two permission dialogs, both were denied, and the agent kept retrying instead of looking for the same information inside the workspace. A timely injected prompt would have short-circuited this loop.

Proposed Behaviour

After 2 consecutive tool call denials or permission-related failures in the same session, inject a promptAsync nudge:

"Several tool calls have been denied or failed. You may be running in a sandboxed environment where file access is restricted to the project working directory. Avoid retrying the same path — find an alternative approach within the allowed workspace."

Reset the counter when the session transitions back to running (new user message detected via session.status).

Implementation

Two signals are available and should be combined:

Primary: permission.replied event

// event hook — fires after user responds to a permission dialog
if (event.type === "permission.replied" && event.properties.response === "deny") {
  incrementDenyCount(event.properties.sessionID)
}

EventPermissionReplied shape (from SDK types):

{ type: "permission.replied"; properties: { sessionID: string; permissionID: string; response: string } }

Directly observes the user's deny decision. No string matching needed.

Fallback: tool.execute.after hook

"tool.execute.after": async (input, output) => {
  if (/permission denied|EACCES|Operation not permitted|not allowed/i.test(output.output)) {
    incrementToolErrorCount(input.sessionID)
  }
}

Catches cases where the permission system is bypassed entirely (auto-deny rules, OS-level sandbox restrictions). output.output is the raw string result of the tool call.

Reset on new activity

// in the existing event handler
if (event.type === "session.status" && event.properties.status.type === "running") {
  resetDenyCounts(event.properties.sessionID)
}

Threshold

Default: 2 consecutive failures before nudging. Configurable via OPENCODE_DENY_THRESHOLD env var (same pattern as OPENCODE_IDLE_THRESHOLD_MS).

Out of scope

  • Auto-allowing or auto-denying permissions — the plugin must never change output.status in permission.ask; it only observes outcomes.
  • Permanent system prompt injection via experimental.chat.system.transform — preventive hints add tokens to every request regardless of whether an issue exists; the reactive approach is preferred.

Acceptance Criteria

  • After 2 consecutive denials/errors in a session, a nudge prompt is injected via promptAsync
  • Counter resets when the session goes back to running
  • Threshold is configurable via env var
  • Unit tests cover: single denial (no nudge), threshold reached (nudge fires), reset on running, tool.execute.after error pattern matching
  • E2E test is not required (permission dialogs are hard to trigger programmatically without mocking)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions