Policy-as-code (deny-overrides, fail-closed) for AI agent tool calls, in Java. The Java port of actionguard (Rust) — the pattern security teams already use for cloud infrastructure (OPA/Rego, AWS Cedar: a policy decision point in front of every action, deny-overrides, fail-closed by default) applied to agent tool calls instead of API requests.
guardflow-java validates what an agent says; actionguard-java validates what it's about to do, before it does it.
Not published to Maven Central — pull it straight from GitHub via JitPack:
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>com.github.thaicn1712</groupId>
<artifactId>actionguard-java</artifactId>
<version>main-SNAPSHOT</version> <!-- or a tagged release -->
</dependency>import io.github.thaicn1712.actionguard.*;
import io.github.thaicn1712.actionguard.policies.*;
PolicySet policies = PolicySet.create()
.with(new AllowList("read_file", "search", "send_email"))
.with(new DenyList("rm_rf", "drop_table", "shell_exec"))
.with(new ArgMatchesRegex("read_file", "path", "^/workspace/.*"));
ToolCall call = new ToolCall("read_file", Map.of("path", "/workspace/notes.txt"));
switch (policies.check(call)) {
case Decision.Allow ignored -> runTheTool(call);
case Decision.Deny deny -> System.out.println("blocked: " + deny.reason());
}Fail-closed by default: if no policy explicitly allows a call, it's denied — the same default OPA and every serious authorization system ships with, and the opposite of what most hand-rolled if (command.contains("rm")) checks do.
Deny-overrides: any policy voting Deny blocks the call outright, even if another policy voted Allow — you can't accidentally allowlist your way past an explicit deny rule.
AllowList, DenyList (deny-overrides an allow list), ArgMatchesRegex (scopes a string argument of one named tool to a pattern, abstaining for every other tool), CustomAsyncPolicy (wraps a function as an AsyncPolicy).
For checks that need a model call — "does this action match what the user actually asked for" — AsyncPolicy wraps a blocking check, evaluated only after every sync policy has already voted:
AsyncPolicySet policies = AsyncPolicySet.fromSync(syncPolicies)
.withAsync(new CustomAsyncPolicy("matches_intent", call -> {
return callIsConsistentWithUserRequest(userRequest, call)
? new Vote.Allow()
: new Vote.Deny("not consistent with the user's request");
}));
Decision decision = policies.check(call);AsyncPolicy.vote is a plain blocking call rather than CompletableFuture chaining — on modern Java, run it on a virtual thread and blocking costs nothing, the same idiom as AsyncValidator in guardflow-java. Sync policies always run first: an explicit sync Deny short-circuits before any async policy — and therefore any network call — runs at all.
mvn -q compile exec:java -Dexec.mainClass=io.github.thaicn1712.actionguard.examples.AgentDispatcherExampleMIT