From acb70a8c38b2e03d6733e217f71b768d6ad4991e Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Wed, 17 Jun 2026 11:49:38 +0530 Subject: [PATCH] feat(rbac): Swift SPM capability-gating + denial handling (DEVA11Y-534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0025 row 13: client capability-gating for the Swift SPM plugin. RBAC (DEVA11Y-518) is enforced server-side and surfaced by the headless browserstack-cli, which this plugin downloads and runs as a subprocess. All WebSocket work — auth, the connect/profile/handshake exchange that ships the `capabilities` set + `effectiveRole`, and capability-gating of lint/scan/set-config — lives in that CLI, not in this thin wrapper. The plugin therefore has no WebSocket message-decoding path or Codable response model of its own to gate on; capability decisions are read by the CLI from the server's capability set and never re-encoded here. The one RBAC signal the wrapper observes is the CLI's exit code. The CLI exits PERMISSION_DENIED (3) on a denied action (mirrors ExitCodes.PERMISSION_DENIED in accessibility-devtools-cli) and has already written the role-aware "Permission denied: …" detail to stderr. runCLI now detects exit code 3 and surfaces a clear, role-aware denial message instead of a generic failure, preserving the exit code so CI and the SPM build phase can distinguish a denial from a lint failure (1) or a tooling error (2). Generic exit-code forwarding is unchanged for every other status. Pre-RBAC CLIs (rollout gated by LINTER_RBAC_ENFORCEMENT_ENABLED on the server) never emit code 3, so the default path and backward compatibility are preserved. Co-Authored-By: Claude Opus 4.8 --- .../BrowserStackAccessibilityLint.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift index c6705a1..d30c189 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -582,6 +582,23 @@ private func isAlpineLinux() -> Bool { private func isAlpineLinux() -> Bool { false } #endif +// MARK: - RBAC capability gating (ADR-0025) + +// The headless CLI performs all WebSocket work — authentication, the +// connect/profile/handshake exchange (where the server ships the +// `capabilities` set + `effectiveRole`), and capability-gating of +// lint/scan/set-config. This SPM plugin is a thin wrapper that downloads +// and invokes that CLI as a subprocess, so it has no WebSocket message- +// decoding path and no Codable response model of its own to gate on: +// capability decisions are read by the CLI from the server's capability set, +// never re-encoded here. The one RBAC signal the wrapper sees is the CLI's +// exit code. `browserstack-cli` exits `PERMISSION_DENIED` (3) on a denied +// action (mirrors ExitCodes.PERMISSION_DENIED in the headless CLI) and has +// already written the role-aware "Permission denied: …" detail to stderr. +// Pre-RBAC CLIs (rollout gated by LINTER_RBAC_ENFORCEMENT_ENABLED on the +// server) never emit this code, so the default path is unchanged. +private let browserstackCLIPermissionDeniedExitCode: Int32 = 3 + // MARK: - CLI invocation private func runCLI(executableURL: URL, arguments: [String], workingDirectory: PackagePlugin.Path) async throws { @@ -601,6 +618,17 @@ private func isAlpineLinux() -> Bool { false } } let status = process.terminationStatus + if status == browserstackCLIPermissionDeniedExitCode { + // Surface the RBAC denial as a clear, role-aware outcome rather than + // a generic failure. The CLI has already printed the specific + // "Permission denied: …" reason to stderr; preserve its exit code so + // CI and the SPM build phase can distinguish a denial from a lint + // failure (exit 1) or a tooling error (exit 2). + forwardExit( + code: status, + message: "BrowserStack Accessibility: your account's role is not permitted to run this action. See the \"Permission denied\" detail above, or contact your workspace admin to request access." + ) + } guard status == 0 else { forwardExit(code: status, message: "") }