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
23 changes: 22 additions & 1 deletion a2a/src/main/java/com/google/adk/a2a/executor/AgentExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.google.adk.a2a.converters.EventConverter;
import com.google.adk.a2a.converters.PartConverter;
import com.google.adk.agents.BaseAgent;
import com.google.adk.agents.CallerIdentity;
import com.google.adk.agents.RunConfig;
import com.google.adk.apps.App;
import com.google.adk.artifacts.BaseArtifactService;
Expand All @@ -34,7 +35,9 @@
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.genai.types.Content;
import com.google.genai.types.CustomMetadata;
import io.a2a.server.ServerCallContext;
import io.a2a.server.agentexecution.RequestContext;
import io.a2a.server.auth.User;
import io.a2a.server.events.EventQueue;
import io.a2a.server.tasks.TaskUpdater;
import io.a2a.spec.Artifact;
Expand Down Expand Up @@ -298,7 +301,7 @@ private String getUserId(RequestContext ctx) {
* RunConfig#customMetadata()}.
*/
private RunConfig runConfigWithA2aMetadata(RequestContext ctx) {
RunConfig runConfig = agentExecutorConfig.runConfig();
RunConfig runConfig = withCallerIdentity(agentExecutorConfig.runConfig(), ctx);
MessageSendParams params = ctx.getParams();
Map<String, Object> requestMetadata = params == null ? null : params.metadata();
if (requestMetadata == null || requestMetadata.isEmpty()) {
Expand All @@ -309,6 +312,24 @@ private RunConfig runConfigWithA2aMetadata(RequestContext ctx) {
return runConfig.toBuilder().customMetadata(customMetadata).build();
}

/**
* Copies the caller the transport authenticated onto the run config, so a {@link
* com.google.adk.agents.ConfirmationApprover} can tell an operator from a peer.
*
* <p>The peer-supplied context id this class derives a user id from says nothing about who the
* sender is; only the server call context does.
*/
private static RunConfig withCallerIdentity(RunConfig runConfig, RequestContext ctx) {
ServerCallContext callContext = ctx.getCallContext();
User user = callContext == null ? null : callContext.getUser();
if (user == null) {
return runConfig.toBuilder().callerIdentity(CallerIdentity.unauthenticated()).build();
}
return runConfig.toBuilder()
.callerIdentity(CallerIdentity.of(user.isAuthenticated(), user.getUsername()))
.build();
}

private Maybe<Session> prepareSession(
RequestContext ctx, String appName, BaseSessionService service) {
return service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import static org.mockito.Mockito.when;

import com.google.adk.agents.BaseAgent;
import com.google.adk.agents.CallerIdentity;
import com.google.adk.agents.InvocationContext;
import com.google.adk.agents.RunConfig;
import com.google.adk.apps.App;
Expand All @@ -35,10 +36,13 @@
import com.google.adk.sessions.InMemorySessionService;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.genai.types.Content;
import com.google.genai.types.Part;
import io.a2a.server.ServerCallContext;
import io.a2a.server.agentexecution.RequestContext;
import io.a2a.server.auth.User;
import io.a2a.server.events.EventQueue;
import io.a2a.spec.Message;
import io.a2a.spec.MessageSendParams;
Expand Down Expand Up @@ -464,6 +468,89 @@ public void execute_withoutRequestMetadata_leavesRunConfigCustomMetadataEmpty()
assertThat(runConfig.customMetadata()).doesNotContainKey("a2a_metadata");
}

@Test
public void execute_withAuthenticatedCaller_stampsThatIdentity() {
testAgent.setEventsToEmit(Flowable.empty());
AgentExecutor executor = executorForIdentityTest();
RequestContext ctx = createRequestContext();
when(ctx.getCallContext())
.thenReturn(
new ServerCallContext(
new User() {
@Override
public boolean isAuthenticated() {
return true;
}

@Override
public String getUsername() {
return "operator";
}
},
ImmutableMap.of(),
ImmutableSet.of()));

executor.execute(ctx, eventQueue);

CallerIdentity caller =
testAgent.lastInvocationContext.runConfig().callerIdentity().orElseThrow();
assertThat(caller.authenticated()).isTrue();
assertThat(caller.name()).hasValue("operator");
}

@Test
public void execute_withUnauthenticatedCaller_stampsUnauthenticated() {
testAgent.setEventsToEmit(Flowable.empty());
AgentExecutor executor = executorForIdentityTest();
RequestContext ctx = createRequestContext();
when(ctx.getCallContext())
.thenReturn(
new ServerCallContext(
new User() {
@Override
public boolean isAuthenticated() {
return false;
}

@Override
public String getUsername() {
return "";
}
},
ImmutableMap.of(),
ImmutableSet.of()));

executor.execute(ctx, eventQueue);

CallerIdentity caller =
testAgent.lastInvocationContext.runConfig().callerIdentity().orElseThrow();
assertThat(caller.authenticated()).isFalse();
assertThat(caller.name()).isEmpty();
}

@Test
public void execute_withoutCallContext_stampsUnauthenticated() {
// A transport that supplies no call context must not leave the identity absent, or the
// processor would have nothing to judge.
testAgent.setEventsToEmit(Flowable.empty());
AgentExecutor executor = executorForIdentityTest();

executor.execute(createRequestContext(), eventQueue);

CallerIdentity caller =
testAgent.lastInvocationContext.runConfig().callerIdentity().orElseThrow();
assertThat(caller.authenticated()).isFalse();
}

private AgentExecutor executorForIdentityTest() {
return new AgentExecutor.Builder()
.agentExecutorConfig(AgentExecutorConfig.builder().build())
.app(App.builder().name("test_app").rootAgent(testAgent).build())
.sessionService(new InMemorySessionService())
.artifactService(new InMemoryArtifactService())
.build();
}

private RequestContext createRequestContext() {
Message message =
new Message.Builder()
Expand Down
76 changes: 76 additions & 0 deletions core/src/main/java/com/google/adk/agents/CallerIdentity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.agents;

import com.google.auto.value.AutoValue;
import java.util.Optional;
import org.jspecify.annotations.Nullable;

/**
* What the transport knows about the sender of the current request.
*
* <p>Three states: {@link #absent()} where no transport identity reached this invocation, {@link
* #unauthenticated()} where a transport saw the sender and did not authenticate it, and {@link
* #authenticatedAs(String)} where it did. A {@link ConfirmationApprover} decides which of them may
* satisfy a human tool confirmation.
*/
@AutoValue
public abstract class CallerIdentity {

/**
* Whether the transport told us anything about the sender at all.
*
* <p>False means no transport identity reached this invocation, which is the ordinary shape for
* an in-process run or a surface that does not carry one -- not a claim that the sender is
* anonymous.
*/
public abstract boolean transportKnowsSender();

/** Whether the transport authenticated the sender. */
public abstract boolean authenticated();

/** The authenticated name, present only when {@link #authenticated()} is true. */
public abstract Optional<String> name();

/** No transport identity reached this invocation. */
public static CallerIdentity absent() {
return new AutoValue_CallerIdentity(false, false, Optional.empty());
}

/** A sender the transport saw but did not authenticate. */
public static CallerIdentity unauthenticated() {
return new AutoValue_CallerIdentity(true, false, Optional.empty());
}

/** A sender the transport authenticated as {@code name}. */
public static CallerIdentity authenticatedAs(String name) {
return new AutoValue_CallerIdentity(true, true, Optional.of(name));
}

/**
* Maps a transport's own view of the sender onto this type.
*
* <p>Fails closed: a name is kept only alongside {@code authenticated}, so an unauthenticated
* name cannot be mistaken for evidence, and an authenticated sender whose transport supplies no
* usable name is reported as unauthenticated rather than as an anonymous authenticated one.
*/
public static CallerIdentity of(boolean authenticated, @Nullable String name) {
return authenticated && name != null && !name.isEmpty()
? authenticatedAs(name)
: unauthenticated();
}
}
56 changes: 56 additions & 0 deletions core/src/main/java/com/google/adk/agents/ConfirmationApprover.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.agents;

import com.google.genai.types.FunctionCall;

/**
* Decides whether the sender of a request may satisfy a human tool confirmation.
*
* <p>A confirmation stands in for a human operator's consent, but a request arriving over a
* machine-to-machine transport is recorded under the same {@code user} role as one a human typed,
* so the flow cannot tell them apart on its own. The default is {@link #REJECT_UNAUTHENTICATED};
* {@link #ALLOW_ALL} restores the historical behavior.
*/
@FunctionalInterface
public interface ConfirmationApprover {

/** Accepts every sender, including an unauthenticated peer. */
ConfirmationApprover ALLOW_ALL = (caller, call) -> true;

/**
* Rejects a sender the transport saw and did not authenticate, and accepts one it never saw.
*
* <p>The default. A transport that reports an unauthenticated sender is one anybody can reach, so
* its "approval" is worth nothing; a surface that carries no identity at all is unchanged, which
* keeps in-process and local runs working.
*/
ConfirmationApprover REJECT_UNAUTHENTICATED =
(caller, call) -> !caller.transportKnowsSender() || caller.authenticated();

/** Accepts only a sender the transport authenticated, rejecting one it never saw. */
ConfirmationApprover AUTHENTICATED_ONLY = (caller, call) -> caller.authenticated();

/**
* Returns whether {@code caller} may approve {@code originalCall}.
*
* @param caller the sender of the request carrying the confirmation; an unauthenticated sender is
* {@link CallerIdentity#unauthenticated()}, never null
* @param originalCall the tool call awaiting confirmation, so a policy can decide per tool
*/
boolean canApprove(CallerIdentity caller, FunctionCall originalCall);
}
Loading
Loading