{"record":{"id":"daf23bb121dff3b4","repo":"paperclipai/paperclip","slug":"access-reasoncode","errorCode":"access.reasonCode","errorMessage":"access.explanation","messagePattern":"access\\.explanation","errorType":"http","errorClass":"ToolGatewayHttpError","httpStatus":403,"severity":"error","filePath":"server/src/services/tool-gateway.ts","lineNumber":9473,"sourceCode":"          !approvalSnapshotsMatch(\n            signedPayload.approvalSnapshot,\n            await connectedRemoteApprovalSnapshot(session, tool),\n          )\n        )\n          throw new ToolGatewayHttpError(\n            409,\n            \"Tool definition or connection changed; request a new review\",\n            \"approved_tool_target_changed\",\n          );\n        const access = await policyService.decide(\n          policyInputForTool({\n            session,\n            tool,\n            parameters: signedPayload.arguments,\n          }),\n        );\n        if (!access.allowed && access.decision !== \"require_approval\")\n          throw new ToolGatewayHttpError(\n            403,\n            access.explanation,\n            access.reasonCode,\n          );\n      }\n      const updated = await commitToolActionReview(db, {\n        ...input,\n        decision: \"approved\",\n      });\n      await reflectToolActionInteractionLifecycle({\n        actionRequestId: updated.id,\n        status: \"approved\",\n      });\n      // A test-tab ask-first request has no agent run to carry out the parked\n      // call, so approving it is what runs it. Execute against the signed\n      // arguments and record the result on the invocation for the live panel.\n      if (isTestOriginInvocation(invocation)) {\n        await runApprovedTestInvocation(","sourceCodeStart":9455,"sourceCodeEnd":9491,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/server/src/services/tool-gateway.ts#L9455-L9491","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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.","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.","Verify the tool's policy classification and the agent's role in the board settings before re-approving.","If the tool's remote connection definition changed, expect 'approved_tool_target_changed' instead and request a fresh review."],"exampleFix":"// before: approving a stale request blocked by new policy\nawait approveToolActionRequest(requestId); // 403 access.explanation\n// after: re-decide first; if denied, reject and re-request\nconst access = await policyService.decide(policyInputForTool({ session, tool, parameters }));\nif (!access.allowed && access.decision !== \"require_approval\") {\n  await rejectToolActionRequest(requestId, \"policy now denies this tool\");\n} else {\n  await approveToolActionRequest(requestId);\n}","handlingStrategy":"validation","validationCode":"// reviewer-side pre-check before approving\nconst access = await policyService.decide(policyInputForTool({ session, tool, parameters }));\nif (!access.allowed && access.decision !== \"require_approval\") {\n  await rejectToolActionRequest(requestId, `Policy now denies: ${access.explanation}`);\n  return;\n}","typeGuard":"function canApprove(access: { allowed: boolean; decision: string }): boolean {\n  return access.allowed || access.decision === \"require_approval\";\n}","tryCatchPattern":"try {\n  await approveToolActionRequest(requestId);\n} catch (err) {\n  if (err?.status === 403 && typeof err?.code === \"string\") {\n    // policy denies under current rules: reject the stale request and ask the agent to re-issue\n    await rejectToolActionRequest(requestId, err.message);\n  } else throw err;\n}","preventionTips":["Surface a live policy verdict in the approval UI so reviewers see denials before clicking approve.","Expire or re-validate long-pending requests after policy changes.","Keep tool policy and connection definitions stable while requests await review.","Route 403 reasonCodes from reviews to the agent so it can adjust and re-request."],"tags":["authorization","http-403","policy","approval-review"],"backgroundTag":"permission-denied","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-22T06:17:15.046Z"}