Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/basic/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<dependency>
<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>6.1.0</version>
<version>8.5.2</version>
</dependency>
</dependencies>

Expand Down
2 changes: 1 addition & 1 deletion examples/explain-decision/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<dependency>
<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.5.0</version>
<version>8.5.2</version>
</dependency>
</dependencies>

Expand Down
2 changes: 1 addition & 1 deletion examples/list-decisions/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<dependency>
<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.5.0</version>
<version>8.5.2</version>
</dependency>
</dependencies>

Expand Down
2 changes: 1 addition & 1 deletion examples/wcp-retry-idempotency/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<dependency>
<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.5.0</version>
<version>8.5.2</version>
</dependency>
</dependencies>

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.5.1</version>
<version>8.5.2</version>
<packaging>jar</packaging>

<name>AxonFlow Java SDK</name>
Expand Down
123 changes: 123 additions & 0 deletions runtime-e2e/error_classification_403/ErrorClassification403Test.java
Original file line number Diff line number Diff line change
@@ -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 <other>)
*
* 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)");
}
}
54 changes: 54 additions & 0 deletions runtime-e2e/error_classification_403/README.md
Original file line number Diff line number Diff line change
@@ -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 <other>)

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)
```
34 changes: 32 additions & 2 deletions src/main/java/com/getaxonflow/sdk/AxonFlow.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -4255,6 +4259,32 @@ private void handleErrorResponse(Response response) throws IOException {
}
}

/**
* Decides whether a 403 error body signals a genuine policy block.
*
* <p>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;
Expand Down
61 changes: 61 additions & 0 deletions src/test/java/com/getaxonflow/sdk/AxonFlowTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading