paperclipai/paperclip · error · ToolGatewayHttpError

access.reasonCode

access.reasonCode

Error message

access.explanation

What it means

This 403 ToolGatewayHttpError is thrown during a board-side approval review (commitToolActionReview path) when the reviewer approves an execute-on-approve tool request. Just before committing the approval, the policy service re-decides access against the current session/tool/parameters; if the live decision denies the action (and is not 'require_approval'), the approval is blocked with the policy's explanation and reasonCode. This prevents a human approver from green-lighting an action that current policy forbids.

Solutions

  1. Read access.explanation/reasonCode in the error to identify the denying policy rule; either update the policy to allow the tool or decline/re-create the request under the current rules.
  2. Reject the stale pending request and have the agent re-issue the tool call so a new request is created and evaluated under current policy.
  3. Verify the tool's policy classification and the agent's role in the board settings before re-approving.
  4. If the tool's remote connection definition changed, expect 'approved_tool_target_changed' instead and request a fresh review.

Example fix

// before: approving a stale request blocked by new policy
await approveToolActionRequest(requestId); // 403 access.explanation
// after: re-decide first; if denied, reject and re-request
const access = await policyService.decide(policyInputForTool({ session, tool, parameters }));
if (!access.allowed && access.decision !== "require_approval") {
  await rejectToolActionRequest(requestId, "policy now denies this tool");
} else {
  await approveToolActionRequest(requestId);
}
Defensive patterns

Strategy: validation

Validate before calling

// reviewer-side pre-check before approving
const access = await policyService.decide(policyInputForTool({ session, tool, parameters }));
if (!access.allowed && access.decision !== "require_approval") {
  await rejectToolActionRequest(requestId, `Policy now denies: ${access.explanation}`);
  return;
}

Type guard

function canApprove(access: { allowed: boolean; decision: string }): boolean {
  return access.allowed || access.decision === "require_approval";
}

Try / catch

try {
  await approveToolActionRequest(requestId);
} catch (err) {
  if (err?.status === 403 && typeof err?.code === "string") {
    // policy denies under current rules: reject the stale request and ask the agent to re-issue
    await rejectToolActionRequest(requestId, err.message);
  } else throw err;
}

Prevention

When it happens

Trigger: Approving a pending tool action request (executionOnApprove === true, non-test origin) where the policy engine now denies the tool for the agent/company — e.g. the tool's policy class changed to denied, the agent's permissions were reduced, or parameter/connection-scoped rules forbid the arguments after the request was created.

Common situations: A policy update landed between when the agent requested the action and when the operator clicked approve; the approval queue contains stale requests created before a tool was restricted; the remote connection's capabilities or permission snapshot changed so the re-decision denies.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/daf23bb121dff3b4. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/tool-gateway.ts:9473

          !approvalSnapshotsMatch(
            signedPayload.approvalSnapshot,
            await connectedRemoteApprovalSnapshot(session, tool),
          )
        )
          throw new ToolGatewayHttpError(
            409,
            "Tool definition or connection changed; request a new review",
            "approved_tool_target_changed",
          );
        const access = await policyService.decide(
          policyInputForTool({
            session,
            tool,
            parameters: signedPayload.arguments,
          }),
        );
        if (!access.allowed && access.decision !== "require_approval")
          throw new ToolGatewayHttpError(
            403,
            access.explanation,
            access.reasonCode,
          );
      }
      const updated = await commitToolActionReview(db, {
        ...input,
        decision: "approved",
      });
      await reflectToolActionInteractionLifecycle({
        actionRequestId: updated.id,
        status: "approved",
      });
      // A test-tab ask-first request has no agent run to carry out the parked
      // call, so approving it is what runs it. Execute against the signed
      // arguments and record the result on the invocation for the live panel.
      if (isTestOriginInvocation(invocation)) {
        await runApprovedTestInvocation(

View on GitHub (pinned to 3f1d897a7c)