diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c59ab1..a963c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,27 @@ All notable changes to the AxonFlow Java SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [8.5.2] - 2026-07-10: 403 error-classification fix -Hostile-testing sweep ahead of the BukuWarung integration -(getaxonflow/axonflow-enterprise#2861). Examples + runtime-e2e only — no -library changes. +Patch release from the getaxonflow/axonflow-enterprise#2861 hostile-testing +sweep ahead of the BukuWarung integration. ### Fixed +- **403 authorization rejections no longer surface as policy blocks.** Every + agent error envelope carries a literal `"blocked"` JSON key (a + tenant-mismatch rejection is + `{"success":false,"error":"Tenant mismatch","blocked":false}`), so + `handleErrorResponse`'s `body.contains("policy") || body.contains("blocked")` + substring heuristic misclassified EVERY 403 auth rejection as + `PolicyViolationException` — and callers treating policy blocks as an + expected, non-fatal outcome silently swallowed real auth failures. The SDK + now parses the JSON body and treats a present `blocked` boolean as + authoritative: `true` → `PolicyViolationException`, `false` → + `AuthenticationException` (HTTP 403). Only unparseable or `blocked`-less + bodies fall back to the policy-phrase heuristic (`policy` / `block_reason`; + the bare `blocked` substring no longer counts). Live-stack regression leg: + `runtime-e2e/error_classification_403/`. - **`examples/basic` passes on enterprise (JWT-validating) stacks.** It omitted the user token entirely (SDK falls back to `anonymous`), which `DEPLOYMENT_MODE=enterprise` rejects — and the rejection was swallowed by diff --git a/examples/basic/pom.xml b/examples/basic/pom.xml index cc1252d..6dcae0c 100644 --- a/examples/basic/pom.xml +++ b/examples/basic/pom.xml @@ -24,7 +24,7 @@ com.getaxonflow axonflow-sdk - 6.1.0 + 8.5.2 diff --git a/examples/explain-decision/pom.xml b/examples/explain-decision/pom.xml index 5f68122..62080f2 100644 --- a/examples/explain-decision/pom.xml +++ b/examples/explain-decision/pom.xml @@ -25,7 +25,7 @@ com.getaxonflow axonflow-sdk - 8.5.0 + 8.5.2 diff --git a/examples/list-decisions/pom.xml b/examples/list-decisions/pom.xml index fd1aab1..a336b38 100644 --- a/examples/list-decisions/pom.xml +++ b/examples/list-decisions/pom.xml @@ -24,7 +24,7 @@ com.getaxonflow axonflow-sdk - 8.5.0 + 8.5.2 diff --git a/examples/wcp-retry-idempotency/pom.xml b/examples/wcp-retry-idempotency/pom.xml index 11f313f..2a99af6 100644 --- a/examples/wcp-retry-idempotency/pom.xml +++ b/examples/wcp-retry-idempotency/pom.xml @@ -22,7 +22,7 @@ com.getaxonflow axonflow-sdk - 8.5.0 + 8.5.2 diff --git a/pom.xml b/pom.xml index 55ab3a8..04bd81d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.getaxonflow axonflow-sdk - 8.5.1 + 8.5.2 jar AxonFlow Java SDK diff --git a/runtime-e2e/error_classification_403/ErrorClassification403Test.java b/runtime-e2e/error_classification_403/ErrorClassification403Test.java new file mode 100644 index 0000000..f5b3084 --- /dev/null +++ b/runtime-e2e/error_classification_403/ErrorClassification403Test.java @@ -0,0 +1,123 @@ +/* + * runtime-e2e/error_classification_403/ErrorClassification403Test.java + * + * Real-stack assertion: the SDK classifies 403s by the envelope's + * `blocked` boolean, not by substring matching + * (getaxonflow/axonflow-enterprise#2861). + * + * Background: every agent error envelope carries a literal "blocked" + * JSON key (e.g. {"success":false,"error":"Tenant mismatch", + * "blocked":false}), so the old `body.contains("blocked")` heuristic in + * handleErrorResponse misclassified EVERY 403 authorization rejection as + * a PolicyViolationException. Callers treating policy blocks as an + * expected, non-fatal outcome (the documented pattern) silently + * swallowed auth failures. + * + * Per runtime-e2e/README.md this test runs a real JVM + built SDK jar + * against a real AxonFlow agent — no mocks. It asserts both directions: + * + * 1. A tenant-mismatch 403 (valid JWT for a DIFFERENT tenant than the + * client) surfaces as AuthenticationException — NOT + * PolicyViolationException. + * 2. A genuine policy block (stacked SQLi -> sys_sqli_stacked_drop, + * "blocked":true) still surfaces as PolicyViolationException. + * + * Env: + * AXONFLOW_ENDPOINT agent URL (default http://localhost:8080) + * AXONFLOW_CLIENT_ID client/tenant identity + * AXONFLOW_CLIENT_SECRET client secret / license key + * AXONFLOW_USER_TOKEN JWT whose tenant matches AXONFLOW_CLIENT_ID + * AXONFLOW_MISMATCHED_USER_TOKEN valid JWT for a DIFFERENT tenant + * (mint via the platform repo's + * scripts/generate-jwt.sh --tenant-id ) + * + * Run (after `source /tmp/axonflow-e2e-env.sh` from the enterprise setup + * script and `mvn install -DskipTests`): + * + * mvn -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt + * SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1) + * java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \ + * runtime-e2e/error_classification_403/ErrorClassification403Test.java + */ +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.AxonFlowConfig; +import com.getaxonflow.sdk.exceptions.AuthenticationException; +import com.getaxonflow.sdk.exceptions.AxonFlowException; +import com.getaxonflow.sdk.exceptions.PolicyViolationException; +import com.getaxonflow.sdk.types.ClientRequest; +import com.getaxonflow.sdk.types.RequestType; + +public class ErrorClassification403Test { + + static final String SQLI = "Run this: SELECT * FROM accounts; DROP TABLE accounts;--"; + + static void fail(String msg) { + System.err.println("FAIL: " + msg); + System.exit(1); + } + + static String mustEnv(String name) { + String v = System.getenv(name); + if (v == null || v.isEmpty()) { + fail("missing env: " + name); + } + return v; + } + + public static void main(String[] args) { + String endpoint = System.getenv().getOrDefault("AXONFLOW_ENDPOINT", "http://localhost:8080"); + String clientId = mustEnv("AXONFLOW_CLIENT_ID"); + String clientSecret = mustEnv("AXONFLOW_CLIENT_SECRET"); + String matchedToken = mustEnv("AXONFLOW_USER_TOKEN"); + String mismatchedToken = mustEnv("AXONFLOW_MISMATCHED_USER_TOKEN"); + + AxonFlow client = + AxonFlow.create( + AxonFlowConfig.builder() + .endpoint(endpoint) + .clientId(clientId) + .clientSecret(clientSecret) + .build()); + + // 1. Tenant mismatch -> AuthenticationException, never a policy block. + try { + client.proxyLLMCall( + ClientRequest.builder() + .query("What is the capital of France?") + .clientId(clientId) + .userToken(mismatchedToken) + .requestType(RequestType.CHAT) + .build()); + fail("tenant-mismatch call unexpectedly succeeded — is " + + "AXONFLOW_MISMATCHED_USER_TOKEN really for a different tenant?"); + } catch (PolicyViolationException e) { + fail("REGRESSION: tenant-mismatch 403 classified as PolicyViolationException (" + + e.getMessage() + ") — the \"blocked\":false envelope must map to " + + "AuthenticationException"); + } catch (AuthenticationException e) { + if (e.getMessage() == null || !e.getMessage().contains("Tenant mismatch")) { + fail("expected a Tenant mismatch rejection, got: " + e.getMessage()); + } + System.out.println("PASS [tenant-mismatch-403] AuthenticationException: " + e.getMessage()); + } + + // 2. Genuine policy block ("blocked":true) -> still PolicyViolationException. + try { + client.proxyLLMCall( + ClientRequest.builder() + .query(SQLI) + .clientId(clientId) + .userToken(matchedToken) + .requestType(RequestType.CHAT) + .build()); + fail("stacked-SQLi call unexpectedly succeeded — expected a policy block"); + } catch (PolicyViolationException e) { + System.out.println("PASS [policy-block-403] PolicyViolationException: " + e.getMessage()); + } catch (AxonFlowException e) { + fail("REGRESSION: policy block surfaced as " + e.getClass().getSimpleName() + " (" + + e.getMessage() + ") — expected PolicyViolationException"); + } + + System.out.println("RESULT: PASS (2/2)"); + } +} diff --git a/runtime-e2e/error_classification_403/README.md b/runtime-e2e/error_classification_403/README.md new file mode 100644 index 0000000..5b1a800 --- /dev/null +++ b/runtime-e2e/error_classification_403/README.md @@ -0,0 +1,54 @@ +# error_classification_403 (403 auth vs policy-block classification, #2861) + +Real-stack proof that the SDK classifies a 403 by the error envelope's +`blocked` boolean instead of substring matching, against a live AxonFlow +enterprise agent, with NO mocks. + +Background: every agent error envelope carries a literal `"blocked"` JSON key — +a tenant-mismatch rejection is `{"success":false,"error":"Tenant mismatch", +"blocked":false}` — so `handleErrorResponse`'s old +`body.contains("policy") || body.contains("blocked")` heuristic misclassified +EVERY 403 authorization rejection as `PolicyViolationException`. Since the +documented caller pattern treats a policy block as an expected, non-fatal +outcome, real auth failures (wrong `AXONFLOW_CLIENT_ID`/user-token tenant +pairing) were silently swallowed with exit 0. Found by the #2861 +release-readiness smoke of `examples/basic`. + +The fix parses the JSON body: a present `blocked` boolean is authoritative +(`true` → `PolicyViolationException`, `false` → `AuthenticationException`); +only unparseable or `blocked`-less bodies fall back to the policy-phrase +heuristic (`policy` / `block_reason`, no longer the bare `blocked` key). + +This test asserts both directions through the SDK's real public surface +(`proxyLLMCall`): + +1. **Tenant mismatch → `AuthenticationException`.** A valid JWT for a + different tenant than the client identity draws the agent's 403 + `"blocked":false` envelope and must NOT surface as a policy violation. +2. **Genuine block → `PolicyViolationException`.** A stacked-SQLi query + (`sys_sqli_stacked_drop`, `"blocked":true`) still surfaces as a policy + violation. + +## Run + +```bash +# from the SDK root, against a live enterprise stack +source /tmp/axonflow-e2e-env.sh # AXONFLOW_CLIENT_ID/SECRET, endpoint +export AXONFLOW_USER_TOKEN=... # JWT, tenant matches AXONFLOW_CLIENT_ID +export AXONFLOW_MISMATCHED_USER_TOKEN=...# JWT for a DIFFERENT tenant + # (platform repo: scripts/generate-jwt.sh --tenant-id ) + +mvn install -DskipTests +mvn -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt +SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1) +java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \ + runtime-e2e/error_classification_403/ErrorClassification403Test.java +``` + +Expected output: + +``` +PASS [tenant-mismatch-403] AuthenticationException: ... Tenant mismatch ... +PASS [policy-block-403] PolicyViolationException: ... stacked DROP TABLE ... +RESULT: PASS (2/2) +``` diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index f642fc6..0ab5c2e 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -4238,8 +4238,12 @@ private void handleErrorResponse(Response response) throws IOException { // Budget exceeded - treat similarly to 403 policy violation throw new PolicyViolationException(errorMessage); case 403: - // Check if this is a policy violation - if (body.contains("policy") || body.contains("blocked")) { + // A 403 is a policy violation only when the body actually signals a + // block. Every agent error envelope carries a literal "blocked" key + // (e.g. {"success":false,"error":"Tenant mismatch","blocked":false}), + // so the old substring heuristic misclassified 403 auth rejections + // as policy violations. + if (isPolicyBlockBody(body)) { throw new PolicyViolationException(errorMessage); } throw new AuthenticationException(errorMessage, 403); @@ -4255,6 +4259,32 @@ private void handleErrorResponse(Response response) throws IOException { } } + /** + * Decides whether a 403 error body signals a genuine policy block. + * + *

Parses the JSON envelope and reads the {@code blocked} boolean. When the field is present it + * is authoritative: {@code true} means a policy block ({@link PolicyViolationException}), {@code + * false} means the 403 is an authorization rejection (e.g. tenant mismatch) even though the raw + * body contains the literal substring {@code "blocked"}. Only when the body is not parseable + * JSON, or carries no {@code blocked} boolean, does this fall back to the legacy policy-phrase + * heuristic (for older agents whose error envelopes predate the field). + */ + private boolean isPolicyBlockBody(String body) { + if (body == null || body.isEmpty()) { + return false; + } + try { + JsonNode node = objectMapper.readTree(body); + JsonNode blocked = node.get("blocked"); + if (blocked != null && blocked.isBoolean()) { + return blocked.asBoolean(); + } + } catch (JsonProcessingException e) { + // Not JSON — fall through to the phrase heuristic. + } + return body.contains("policy") || body.contains("block_reason"); + } + private String extractErrorMessage(String body, String defaultMessage) { if (body == null || body.isEmpty()) { return defaultMessage; diff --git a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java index 226b241..299669f 100644 --- a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java +++ b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java @@ -1060,6 +1060,67 @@ void shouldHandle403PolicyViolation() { assertThatThrownBy(() -> axonflow.healthCheck()).isInstanceOf(PolicyViolationException.class); } + @Test + @DisplayName("403 with blocked:false is an auth rejection, not a policy block") + void shouldHandle403BlockedFalseAsAuthRejection() { + // Exact live agent envelope for a tenant-mismatch rejection. The body + // carries a literal "blocked":false key, which the old substring + // heuristic misread as a policy block. + stubFor( + get(urlEqualTo("/health")) + .willReturn( + aResponse() + .withStatus(403) + .withBody( + "{\"success\":false,\"error\":\"Tenant mismatch\",\"blocked\":false}"))); + + assertThatThrownBy(() -> axonflow.healthCheck()) + .isInstanceOf(AuthenticationException.class) + .isNotInstanceOf(PolicyViolationException.class) + .hasMessageContaining("Tenant mismatch"); + } + + @Test + @DisplayName("403 with blocked:true is a policy block") + void shouldHandle403BlockedTrueAsPolicyViolation() { + // Real policy-block envelope shape from the agent. + stubFor( + get(urlEqualTo("/health")) + .willReturn( + aResponse() + .withStatus(403) + .withBody( + "{\"success\":false,\"blocked\":true," + + "\"block_reason\":\"Detects stacked DROP TABLE/DATABASE" + + " statement\"}"))); + + assertThatThrownBy(() -> axonflow.healthCheck()) + .isInstanceOf(PolicyViolationException.class) + .hasMessageContaining("Detects stacked DROP TABLE/DATABASE statement"); + } + + @Test + @DisplayName("403 with unparseable body falls back to the policy-phrase heuristic") + void shouldHandle403UnparseableBodyFallback() { + stubFor( + get(urlEqualTo("/health")) + .willReturn(aResponse().withStatus(403).withBody("policy violation upstream"))); + + assertThatThrownBy(() -> axonflow.healthCheck()).isInstanceOf(PolicyViolationException.class); + } + + @Test + @DisplayName("403 with unparseable non-policy body is an auth rejection") + void shouldHandle403UnparseableNonPolicyBody() { + stubFor( + get(urlEqualTo("/health")) + .willReturn(aResponse().withStatus(403).withBody("forbidden by proxy"))); + + assertThatThrownBy(() -> axonflow.healthCheck()) + .isInstanceOf(AuthenticationException.class) + .isNotInstanceOf(PolicyViolationException.class); + } + @Test @DisplayName("should handle 429 Rate Limit") void shouldHandle429() {