Autorisation des outils

Contrôlez les outils qu’un agent peut exécuter et déterminez quand l’approbation humaine est requise. ADK-Rust fournit quatre mécanismes — de la simple confirmation par outil à RBAC complète — qui fonctionnent avec CLI, le serveur web et le protocole A2A.

Comparaison rapide

MécanismeCas d’utilisationGranularitéExécution
Politique de confirmation des outilsApprobation interactive dans CLI/le webPar outil ou pour tous les outilsSuspend l’exécution et émet un événement
BeforeToolCallbackPoint de contrôle programmatique / auditLogique personnalisée par appelDécision synchrone, sans suspension
Contrôle d’accès (RBAC)Sécurité d’entreprise basée sur les rôlesPar utilisateur, par outilRefus avant exécution
Interruptions du grapheFlux de travail d’approbation complexesPoint de contrôle par nœudPersiste l’état, reprend ultérieurement

Politique de confirmation des outils

Le mécanisme intégré de supervision humaine. Lorsqu’un outil nécessitant une confirmation est appelé, l’agent se met en pause, émet un événement ToolConfirmationRequest et attend une décision Approve ou Deny lors de l’exécution suivante.

Configuration

use adk_agent::LlmAgentBuilder;
use std::sync::Arc;

let agent = LlmAgentBuilder::new("assistant")
    .model(model)
    .instruction("You are a helpful assistant with file and email tools.")
    .tool(Arc::new(search_tool))
    .tool(Arc::new(delete_file_tool))
    .tool(Arc::new(send_email_tool))
    // Require confirmation for dangerous tools
    .require_tool_confirmation("delete_file")
    .require_tool_confirmation("send_email")
    .build()?;

// Or require confirmation for ALL tool calls:
// .require_tool_confirmation_for_all()

Fonctionnement

  1. Le LLM décide d’appeler delete_file avec les arguments {"path": "/data/report.csv"}
  2. L’agent émet un Event avec :
    {
      "actions": {
        "toolConfirmation": {
          "toolName": "delete_file",
          "functionCallId": "call_abc123",
          "args": {"path": "/data/report.csv"}
        }
      }
    }
  3. Le flux de l’agent se termine — l’exécution est mise en pause
  4. Votre interface affiche à l’utilisateur : « L’agent souhaite supprimer /data/report.csv. Autoriser ? »
  5. Lors de l’Runner::run() suivante, transmettez la décision associée à l’identifiant de l’appel de fonction provenant de la requête :
use adk_core::{RunConfig, ToolConfirmationDecision};
use std::collections::HashMap;

let mut decisions = HashMap::new();
decisions.insert(
    "call_abc123".to_string(), // functionCallId from the request, not the tool name
    ToolConfirmationDecision::Approve, // or Deny
);

// The runner picks up the decision and continues

En cas de refus, l’outil est ignoré et le LLM reçoit un message tel que « L’exécution de l’outil a été refusée par l’utilisateur », afin de pouvoir adapter son approche.

Les décisions autorisent un seul appel précis

Une décision s’applique au seul appel pour lequel elle a été demandée. L’associer au nom de l’outil ferait qu’une seule approbation autoriserait tous les appels de cet outil ; ainsi, une approbation pour delete_file sur un chemin temporaire autoriserait également un appel ciblant autre chose. Deux appels au même outil lors d’un même tour nécessitent donc deux décisions.

Un identifiant d’appel inconnu signifie « aucune décision », ce qui laisse l’appel en attente de confirmation. En cas d’échec, le comportement consiste toujours à demander à nouveau plutôt qu’à exécuter.

Lier une décision à ses arguments

Lorsqu’une décision transite par quelque chose que vous ne contrôlez pas — un navigateur, une file d’attente ou un service d’approbation externe — l’identifiant de l’appel pourrait être réutilisé avec des arguments différents. Liez la décision aux arguments pour lesquels elle a été accordée :

use adk_core::{RunConfig, ToolConfirmationDecision, tool_call_fingerprint};
use serde_json::json;
use std::collections::HashMap;

let approved_args = json!({ "path": "/data/report.csv" });

let mut decisions = HashMap::new();
decisions.insert("call_abc123".to_string(), ToolConfirmationDecision::Approve);

let mut fingerprints = HashMap::new();
fingerprints.insert(
    "call_abc123".to_string(),
    tool_call_fingerprint("delete_file", &approved_args),
);

let config = RunConfig::builder()
    .tool_confirmation_decisions(decisions)
    .tool_confirmation_fingerprints(fingerprints)
    .build();

Si l’appel reçu ne correspond pas à l’empreinte, la décision est ignorée et l’appel est considéré comme non confirmé. tool_call_fingerprint est canonique quel que soit l’ordre des clés ; un objet d’arguments sérialisé à nouveau correspond donc toujours.

Pour les décisions qui doivent s’appliquer selon une politique plutôt qu’à chaque appel, implémentez un ToolConfirmationHandler au lieu d’élargir la map statique.

Exemple de CLI

Un agent terminal qui demande une confirmation avant d’exécuter des outils :

use adk_agent::LlmAgentBuilder;
use adk_core::{
    Content, Event, RunConfig, ToolConfirmationDecision,
    SessionId, UserId,
};
use adk_runner::Runner;
use adk_session::InMemorySessionService;
use adk_model::GeminiModel;
use adk_tool::tool;
use futures::StreamExt;
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::{self, Write};
use std::sync::Arc;

#[derive(Deserialize, JsonSchema)]
struct DeleteArgs {
    /// File path to delete
    path: String,
}

/// Delete a file from the filesystem.
#[tool]
async fn delete_file(args: DeleteArgs) -> Result<serde_json::Value, adk_core::AdkError> {
    // In production, actually delete the file
    Ok(serde_json::json!({"deleted": args.path}))
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenvy::dotenv().ok();
    let api_key = std::env::var("GOOGLE_API_KEY")?;
    let model = GeminiModel::new(&api_key, "gemini-3.7-flash")?;

    let agent = LlmAgentBuilder::new("file-manager")
        .model(Arc::new(model))
        .instruction("You help manage files. Use delete_file when asked to remove files.")
        .tool(Arc::new(DeleteFile))
        .require_tool_confirmation("delete_file")
        .build()?;

    let session_service = Arc::new(InMemorySessionService::new());
    let runner = Runner::new(adk_runner::RunnerConfig {
        app_name: "file-manager".to_string(),
        agent: Arc::new(agent),
        session_service: session_service.clone(),
        ..Default::default()
    })?;

    let user_id = UserId::new("user-1")?;
    let session_id = SessionId::new("session-1")?;

    // Create session
    session_service.create(adk_session::CreateRequest {
        app_name: "file-manager".to_string(),
        user_id: "user-1".to_string(),
        session_id: Some("session-1".to_string()),
        state: HashMap::new(),
    }).await?;

    println!("File Manager (type 'quit' to exit)");
    loop {
        print!("> ");
        io::stdout().flush()?;
        let mut input = String::new();
        io::stdin().read_line(&mut input)?;
        let input = input.trim();
        if input == "quit" { break; }

        let content = Content::new("user").with_text(input);
        let mut stream = runner.run(
            user_id.clone(), session_id.clone(), content,
        ).await?;

        while let Some(result) = stream.next().await {
            let event = result?;

            // Check if the agent is requesting tool confirmation
            if let Some(ref confirmation) = event.actions.tool_confirmation {
                println!(
                    "\n⚠️  The agent wants to run '{}' with args: {}",
                    confirmation.tool_name,
                    serde_json::to_string_pretty(&confirmation.args)?
                );
                print!("Allow? [y/n]: ");
                io::stdout().flush()?;

                let mut answer = String::new();
                io::stdin().read_line(&mut answer)?;

                let decision = if answer.trim().eq_ignore_ascii_case("y") {
                    ToolConfirmationDecision::Approve
                } else {
                    ToolConfirmationDecision::Deny
                };

                // Re-run with the decision
                let mut decisions = HashMap::new();
                // Keyed by the call ID, so the decision authorizes only this call.
                if let Some(call_id) = confirmation.function_call_id.clone() {
                    decisions.insert(call_id, decision);
                }

                let content = Content::new("user").with_text("");
                let mut resume_stream = runner.run(
                    user_id.clone(), session_id.clone(), content,
                ).await?;

                while let Some(result) = resume_stream.next().await {
                    let event = result?;
                    if let Some(ref content) = event.llm_response.content {
                        for part in &content.parts {
                            if let Some(text) = part.text() {
                                print!("{text}");
                            }
                        }
                    }
                }
                println!();
            } else if let Some(ref content) = event.llm_response.content {
                for part in &content.parts {
                    if let Some(text) = part.text() {
                        print!("{text}");
                    }
                }
            }
        }
        println!();
    }
    Ok(())
}

Exemple de serveur web

Un endpoint SSE qui diffuse les événements vers l’interface frontend. Lorsqu’un événement toolConfirmation arrive, l’interface frontend affiche une boîte de dialogue d’approbation et renvoie la décision :

use adk_agent::LlmAgentBuilder;
use adk_core::{
    Content, RunConfig, ToolConfirmationDecision, SessionId, UserId,
};
use adk_runner::Runner;
use adk_session::InMemorySessionService;
use axum::{Json, Router, extract::State, response::sse::{Event, Sse}};
use axum::routing::post;
use futures::StreamExt;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    runner: Arc<Runner>,
}

#[derive(Deserialize)]
struct ChatRequest {
    message: String,
    user_id: String,
    session_id: String,
    /// Tool confirmation decisions from the previous turn
    #[serde(default)]
    tool_decisions: HashMap<String, String>, // "tool_name" -> "approve"|"deny"
}

async fn chat_handler(
    State(state): State<AppState>,
    Json(req): Json<ChatRequest>,
) -> Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>> {
    let runner = state.runner.clone();
    let user_id = UserId::new(&req.user_id).unwrap();
    let session_id = SessionId::new(&req.session_id).unwrap();
    let content = Content::new("user").with_text(&req.message);

    let stream = async_stream::stream! {
        let mut event_stream = match runner.run(user_id, session_id, content).await {
            Ok(s) => s,
            Err(e) => {
                yield Ok(Event::default().data(
                    serde_json::json!({"error": e.to_string()}).to_string()
                ));
                return;
            }
        };

        while let Some(result) = event_stream.next().await {
            match result {
                Ok(event) => {
                    // Emit tool confirmation request to frontend
                    if let Some(ref confirmation) = event.actions.tool_confirmation {
                        yield Ok(Event::default()
                            .event("tool_confirmation")
                            .data(serde_json::json!({
                                "toolName": confirmation.tool_name,
                                "args": confirmation.args,
                                "functionCallId": confirmation.function_call_id,
                            }).to_string()));
                    }

                    // Emit text content
                    if let Some(ref content) = event.llm_response.content {
                        for part in &content.parts {
                            if let Some(text) = part.text() {
                                yield Ok(Event::default()
                                    .event("text")
                                    .data(serde_json::json!({"text": text}).to_string()));
                            }
                        }
                    }
                }
                Err(e) => {
                    yield Ok(Event::default().data(
                        serde_json::json!({"error": e.to_string()}).to_string()
                    ));
                }
            }
        }

        yield Ok(Event::default().event("done").data("{}".to_string()));
    };

    Sse::new(stream)
}

// Frontend JavaScript (conceptual):
//
// const source = new EventSource('/api/chat');
// source.addEventListener('tool_confirmation', (e) => {
//   const data = JSON.parse(e.data);
//   showConfirmDialog(data.toolName, data.args, (approved) => {
//     fetch('/api/chat', {
//       method: 'POST',
//       body: JSON.stringify({
//         message: '',
//         tool_decisions: { [data.toolName]: approved ? 'approve' : 'deny' }
//       })
//     });
//   });
// });

BeforeToolCallback

Pour l’autorisation programmatique — vérifier les permissions, appeler un service d’authentification externe ou journaliser à des fins d’audit. Aucune interaction utilisateur n’est nécessaire.

use adk_agent::LlmAgentBuilder;
use adk_core::{BeforeToolCallback, CallbackContext, Content};
use std::sync::Arc;

let agent = LlmAgentBuilder::new("assistant")
    .model(model)
    .tool(Arc::new(my_tool))
    .before_tool_callback(Box::new(|ctx: Arc<dyn CallbackContext>| {
        Box::pin(async move {
            let tool_name = ctx.tool_name().unwrap_or("unknown");
            let tool_input = ctx.tool_input();

            // Log for audit
            tracing::info!(tool = tool_name, "tool execution requested");

            // Custom authorization logic
            let user_scopes = ctx.user_scopes();
            if tool_name == "admin_action" && !user_scopes.contains(&"admin".to_string()) {
                // Return Some(Content) to skip the tool
                return Ok(Some(
                    Content::new("tool")
                        .with_text("Permission denied: admin scope required")
                ));
            }

            Ok(None) // Allow execution
        })
    }))
    .build()?;

Valeurs de retour :

  • Ok(None) — autoriser l’exécution de l’outil
  • Ok(Some(content)) — ignorer l’outil et envoyer ce contenu à LLM à la place
  • Err(e) — interrompre toute l’exécution de l’agent

Contrôle d’accès

Pour les RBAC d’entreprise avec des permissions basées sur les rôles. Consultez Contrôle d’accès pour la documentation complète.

use adk_auth::{AccessControl, Role, Permission, ToolExt};

let ac = AccessControl::builder()
    .role(Role::new("analyst")
        .allow(Permission::Tool("search".into()))
        .allow(Permission::Tool("summarize".into()))
        .deny(Permission::Tool("delete_file".into())))
    .role(Role::new("admin")
        .allow(Permission::AllTools))
    .assign("alice@co.com", "admin")
    .assign("bob@co.com", "analyst")
    .build()?;

// Wrap tools with automatic permission checking
let protected_tool = my_tool.with_access_control(Arc::new(ac));

Interruptions de graphe

Pour les workflows d’approbation complexes où l’exécution doit conserver son état et reprendre ultérieurement. Consultez Agents de graphe pour la documentation complète.

Les agents de graphe prennent en charge les interruptions basées sur des points de contrôle : l’exécution se met en pause au niveau d’un nœud, conserve son état dans un magasin de points de contrôle, puis reprend après la saisie humaine — même après le redémarrage des serveurs.

Confirmation d’outil native au graphe

Un AgentNode préserve la politique standard de confirmation d’outil lorsqu’il s’exécute dans un CompiledGraph. Au lieu d’aplatir le graphe en un flux d’événements Runner, le graphe enregistre son propre front dans un point de contrôle et émet un événement personnalisé structuré qui peut être lu avec GraphToolConfirmationPause::from_stream_event.

use adk_agent::LlmAgentBuilder;
use adk_core::{RunConfig, ToolConfirmationDecision};
use adk_graph::{
    checkpoint::MemoryCheckpointer,
    edge::{END, START},
    graph::StateGraph,
    node::{AgentNode, ExecutionConfig},
    state::State,
    interrupt::GraphToolConfirmationPause,
    stream::StreamMode,
};
use futures::StreamExt;
use std::{collections::HashMap, sync::Arc};

let agent = LlmAgentBuilder::new("file_manager")
    .model(model)
    .tool(delete_file_tool)
    .require_tool_confirmation("delete_file")
    .build()?;

let graph = StateGraph::with_channels(&["messages"])
    .add_node(AgentNode::new(Arc::new(agent)))
    .add_edge(START, "file_manager")
    .add_edge("file_manager", END)
    .compile()?
    .with_checkpointer(MemoryCheckpointer::new());

let mut events = Box::pin(graph.stream(
    State::new(),
    ExecutionConfig::new("delete-report"),
    StreamMode::Debug,
));

let pause = loop {
    match events.next().await.transpose()? {
        Some(event) => {
            if let Some(pause) = GraphToolConfirmationPause::from_stream_event(&event) {
                break pause;
            }
        }
        None => unreachable!("the graph must pause before the tool runs"),
    }
};

// Present `pause.request.tool_name` and `pause.request.args` to the approver. A decision
// is scoped to this exact function call ID; bind its arguments as well when it
// crosses an untrusted boundary.
let call_id = pause.request.function_call_id.expect("LLM tool calls have an ID");
let decisions = HashMap::from([(call_id, ToolConfirmationDecision::Approve)]);

// The checkpoint is selected automatically by thread ID. `pause.checkpoint_id` is
// available for audit records or an explicit `with_resume_from` call.
drop(events);
let final_events = graph.stream_with_run_config(
    State::new(),
    ExecutionConfig::new("delete-report"),
    StreamMode::Debug,
    RunConfig::builder().tool_confirmation_decisions(decisions).build(),
);
# let _ = pause;
# let _ = final_events;

Le graphe conserve le cycle de vie des nœuds, l’état intermédiaire, les sous-graphes imbriqués et la frontière en attente. Les nœuds terminés en même temps que la demande de confirmation sont sauvegardés par point de contrôle et ne sont pas rejoués après l’approbation. L’agent lui-même utilise la même sémantique de décision RunConfig qu’une exécution ADK normale.

Combinaison des mécanismes

Ces mécanismes se combinent naturellement :

let agent = LlmAgentBuilder::new("secure-assistant")
    .model(model)
    // RBAC: deny unauthorized users entirely
    .tool(Arc::new(search_tool.with_access_control(Arc::new(ac))))
    // Callback: audit all tool calls
    .before_tool_callback(audit_callback())
    // Confirmation: require human approval for destructive ops
    .require_tool_confirmation("delete_file")
    .require_tool_confirmation("send_email")
    .build()?;

Ordre d’évaluation :

  1. Vérification RBAC (si l’enveloppe ProtectedTool est utilisée) — refuse l’accès aux utilisateurs non autorisés
  2. BeforeToolCallback — barrière programmatique, peut ignorer ou interrompre l’exécution
  3. ToolConfirmationPolicy — met en pause pour demander une approbation humaine si nécessaire
  4. L’outil s’exécute
  5. AfterToolCallback / AfterToolCallbackFull — inspection après exécution

Précédent : ← Contrôle d’accès | Suivant : Garde-fous →