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
26 changes: 26 additions & 0 deletions src/RockBot.A2A.Abstractions/AgentTrustEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace RockBot.A2A;

/// <summary>
/// Per-caller trust record tracking the trust level, approved skills, and
/// interaction history for an external agent identified by <see cref="AgentId"/>.
/// </summary>
public sealed record AgentTrustEntry
{
/// <summary>Canonical unique identifier for the caller (from <see cref="VerifiedAgentIdentity.AgentId"/>).</summary>
public required string AgentId { get; init; }

/// <summary>Current trust level for this caller.</summary>
public required AgentTrustLevel Level { get; init; }

/// <summary>Skill IDs this caller is approved to invoke autonomously (Level 4).</summary>
public IReadOnlyList<string> ApprovedSkills { get; init; } = [];

/// <summary>When this caller was first seen.</summary>
public DateTimeOffset FirstSeen { get; init; }

/// <summary>When the last interaction with this caller occurred.</summary>
public DateTimeOffset LastInteraction { get; init; }

/// <summary>Total number of inbound tasks received from this caller.</summary>
public int InteractionCount { get; init; }
}
20 changes: 20 additions & 0 deletions src/RockBot.A2A.Abstractions/AgentTrustLevel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace RockBot.A2A;

/// <summary>
/// Trust level assigned to an external agent caller. Each caller progresses
/// through these levels independently based on user approval.
/// </summary>
public enum AgentTrustLevel
{
/// <summary>Read-only access; summarize request and notify user.</summary>
Observe = 1,

/// <summary>Same as Observe, but system observes user responses and proposes skill drafts.</summary>
Learn = 2,

/// <summary>System has candidate skills and asks user to approve them.</summary>
Propose = 3,

/// <summary>Approved skills execute autonomously; results reported to user post-hoc.</summary>
Act = 4
}
18 changes: 18 additions & 0 deletions src/RockBot.A2A.Abstractions/IAgentIdentityVerifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using RockBot.Messaging;

namespace RockBot.A2A;

/// <summary>
/// Verifies the identity of an agent from an inbound message envelope.
/// Implementations may inspect headers (tokens, signatures), the Source field,
/// or any other envelope metadata to establish a verified identity.
/// Register a custom implementation via DI to replace the default name-based verifier.
/// </summary>
public interface IAgentIdentityVerifier
{
/// <summary>
/// Verifies the sender identity from the envelope metadata.
/// Returns a <see cref="VerifiedAgentIdentity"/> on success, or throws if verification fails.
/// </summary>
Task<VerifiedAgentIdentity> VerifyAsync(MessageEnvelope envelope, CancellationToken ct);
}
24 changes: 24 additions & 0 deletions src/RockBot.A2A.Abstractions/IAgentTrustStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace RockBot.A2A;

/// <summary>
/// Persistent store for per-caller trust entries. Implementations must be
/// thread-safe — concurrent A2A requests may read/write simultaneously.
/// </summary>
public interface IAgentTrustStore
{
/// <summary>
/// Returns the trust entry for <paramref name="agentId"/>, creating a new
/// entry at <see cref="AgentTrustLevel.Observe"/> if none exists.
/// </summary>
Task<AgentTrustEntry> GetOrCreateAsync(string agentId, CancellationToken ct);

/// <summary>
/// Persists an updated trust entry. The entry is matched by <see cref="AgentTrustEntry.AgentId"/>.
/// </summary>
Task UpdateAsync(AgentTrustEntry entry, CancellationToken ct);

/// <summary>
/// Returns all known trust entries.
/// </summary>
Task<IReadOnlyList<AgentTrustEntry>> ListAsync(CancellationToken ct);
}
40 changes: 40 additions & 0 deletions src/RockBot.A2A.Abstractions/VerifiedAgentIdentity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace RockBot.A2A;

/// <summary>
/// The result of identity verification for an inbound agent message.
/// <see cref="AgentId"/> is the stable key used for trust tracking.
/// </summary>
public sealed record VerifiedAgentIdentity
{
/// <summary>
/// Key used to store/retrieve <see cref="VerifiedAgentIdentity"/> in
/// <see cref="RockBot.Host.MessageHandlerContext.Items"/>.
/// </summary>
public const string ContextKey = "verified-identity";

/// <summary>
/// Canonical unique identifier for the agent. Used as the key in trust stores.
/// For name-based verification this equals the Source string; for registry-backed
/// verification it would be a registry-issued identifier.
/// </summary>
public required string AgentId { get; init; }

/// <summary>Human-readable display name for the agent.</summary>
public required string DisplayName { get; init; }

/// <summary>
/// Who vouched for this identity (e.g. "self", a registry URL, an IdP issuer).
/// </summary>
public string? Issuer { get; init; }

/// <summary>
/// Extensible claims extracted during verification (e.g. roles, scopes, OBO subject).
/// </summary>
public IReadOnlyDictionary<string, string>? Claims { get; init; }

/// <summary>
/// True when identity is based solely on the sender's self-asserted Source string
/// with no cryptographic or registry-backed verification.
/// </summary>
public bool IsSelfAsserted { get; init; }
}
7 changes: 7 additions & 0 deletions src/RockBot.A2A/A2AOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ public sealed class A2AOptions
/// </summary>
public TimeSpan DirectoryEntryTtl { get; set; } = TimeSpan.FromHours(24);

/// <summary>
/// Path to the file where per-caller trust entries are persisted.
/// Relative paths are resolved from <see cref="AppContext.BaseDirectory"/>.
/// Set to null or empty to disable persistence.
/// </summary>
public string? TrustStorePath { get; set; } = "agent-trust.json";

/// <summary>
/// Statically-configured agents that are always included in <c>list_known_agents</c>
/// regardless of whether they have announced themselves on the discovery bus.
Expand Down
10 changes: 10 additions & 0 deletions src/RockBot.A2A/A2AServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ public static AgentHostBuilder AddA2A(
sp => sp.GetRequiredService<AgentDirectory>());
}

// Identity verification — default to name-based; users can override via DI
builder.Services.TryAddSingleton<IAgentIdentityVerifier, NameBasedAgentIdentityVerifier>();

// Trust store — default to file-backed; users can override via DI
builder.Services.TryAddSingleton<IAgentTrustStore>(sp =>
new FileAgentTrustStore(options.TrustStorePath));

// Identity verification middleware — verifies A2A inbound messages
builder.UseMiddleware<IdentityVerificationMiddleware>();

// Summarizer — uses ILlmClient if available, otherwise falls back gracefully
builder.Services.TryAddSingleton<AgentCardSummarizer>();

Expand Down
111 changes: 111 additions & 0 deletions src/RockBot.A2A/FileAgentTrustStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Collections.Concurrent;
using System.Text.Json;

namespace RockBot.A2A;

/// <summary>
/// File-backed trust store that persists <see cref="AgentTrustEntry"/> records as JSON.
/// Thread-safe via <see cref="ConcurrentDictionary{TKey,TValue}"/> with debounced writes.
/// </summary>
internal sealed class FileAgentTrustStore : IAgentTrustStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};

private readonly ConcurrentDictionary<string, AgentTrustEntry> _entries =
new(StringComparer.OrdinalIgnoreCase);

private readonly string? _filePath;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private volatile bool _loaded;

public FileAgentTrustStore(string? filePath)
{
_filePath = string.IsNullOrWhiteSpace(filePath) ? null : filePath;
}

public async Task<AgentTrustEntry> GetOrCreateAsync(string agentId, CancellationToken ct)
{
await EnsureLoadedAsync(ct);

if (_entries.TryGetValue(agentId, out var existing))
return existing;

var entry = new AgentTrustEntry
{
AgentId = agentId,
Level = AgentTrustLevel.Observe,
FirstSeen = DateTimeOffset.UtcNow,
LastInteraction = DateTimeOffset.UtcNow,
InteractionCount = 0
};
entry = _entries.GetOrAdd(agentId, entry);
await PersistAsync(ct);
return entry;
}

public async Task UpdateAsync(AgentTrustEntry entry, CancellationToken ct)
{
await EnsureLoadedAsync(ct);
_entries[entry.AgentId] = entry;
await PersistAsync(ct);
}

public async Task<IReadOnlyList<AgentTrustEntry>> ListAsync(CancellationToken ct)
{
await EnsureLoadedAsync(ct);
return _entries.Values.ToList();
}

private async Task EnsureLoadedAsync(CancellationToken ct)
{
if (_loaded) return;

await _writeLock.WaitAsync(ct);
try
{
if (_loaded) return;

if (_filePath is not null && File.Exists(_filePath))
{
var json = await File.ReadAllTextAsync(_filePath, ct);
var entries = JsonSerializer.Deserialize<List<AgentTrustEntry>>(json, JsonOptions);
if (entries is not null)
{
foreach (var entry in entries)
_entries.TryAdd(entry.AgentId, entry);
}
}

_loaded = true;
}
finally
{
_writeLock.Release();
}
}

private async Task PersistAsync(CancellationToken ct)
{
if (_filePath is null) return;

await _writeLock.WaitAsync(ct);
try
{
var entries = _entries.Values.ToList();
var json = JsonSerializer.Serialize(entries, JsonOptions);
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(_filePath, json, ct);
}
finally
{
_writeLock.Release();
}
}
}
50 changes: 50 additions & 0 deletions src/RockBot.A2A/IdentityVerificationMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Microsoft.Extensions.Logging;
using RockBot.Host;
using RockBot.Messaging;

namespace RockBot.A2A;

/// <summary>
/// Middleware that verifies the identity of inbound A2A messages using
/// <see cref="IAgentIdentityVerifier"/>. Stores the verified identity in
/// <see cref="MessageHandlerContext.Items"/> under the key
/// <see cref="ContextKey"/> for downstream handlers.
/// Only runs on A2A task-related messages; passes all other messages through unchanged.
/// </summary>
internal sealed class IdentityVerificationMiddleware(
IAgentIdentityVerifier verifier,
ILogger<IdentityVerificationMiddleware> logger) : IMiddleware
{
public async Task InvokeAsync(MessageHandlerContext context, MessageHandlerDelegate next)
{
if (!IsA2AMessage(context.Envelope))
{
await next(context);
return;
}

try
{
var identity = await verifier.VerifyAsync(context.Envelope, context.CancellationToken);
context.Items[VerifiedAgentIdentity.ContextKey] = identity;
logger.LogDebug("Verified inbound A2A identity: {AgentId} (self-asserted: {SelfAsserted})",
identity.AgentId, identity.IsSelfAsserted);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Identity verification failed for message {MessageId} from source '{Source}'",
context.Envelope.MessageId, context.Envelope.Source);
context.Result = MessageResult.DeadLetter;
return;
}

await next(context);
}

private static bool IsA2AMessage(MessageEnvelope envelope)
{
var type = envelope.MessageType;
return type.Contains(nameof(AgentTaskRequest), StringComparison.Ordinal) ||
type.Contains(nameof(AgentTaskCancelRequest), StringComparison.Ordinal);
}
}
27 changes: 27 additions & 0 deletions src/RockBot.A2A/NameBasedAgentIdentityVerifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using RockBot.Messaging;

namespace RockBot.A2A;

/// <summary>
/// Prototype identity verifier that trusts the envelope's Source field at face value.
/// Returns <see cref="VerifiedAgentIdentity.IsSelfAsserted"/> = true to indicate
/// no cryptographic or registry-backed verification was performed.
/// Replace via DI with a custom <see cref="IAgentIdentityVerifier"/> for production use.
/// </summary>
internal sealed class NameBasedAgentIdentityVerifier : IAgentIdentityVerifier
{
public Task<VerifiedAgentIdentity> VerifyAsync(MessageEnvelope envelope, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(envelope.Source))
throw new InvalidOperationException("Cannot verify identity: envelope Source is empty.");

var identity = new VerifiedAgentIdentity
{
AgentId = envelope.Source,
DisplayName = envelope.Source,
Issuer = "self",
IsSelfAsserted = true
};
return Task.FromResult(identity);
}
}
43 changes: 43 additions & 0 deletions src/RockBot.Agent/A2A/InboundA2AToolSet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using RockBot.Host;
using RockBot.Memory;

namespace RockBot.Agent.A2A;

/// <summary>
/// Assembles a restricted tool set for inbound A2A task processing at Level 1 (Observe).
/// Only read-oriented tools are exposed: working memory read/list/search, long-term memory
/// search, and a scoped working memory write limited to the a2a-inbox namespace.
/// </summary>
internal static class InboundA2AToolSet
{
/// <summary>
/// Builds the restricted tool list for an inbound A2A task.
/// </summary>
/// <param name="workingMemory">Global working memory instance.</param>
/// <param name="memoryTools">Long-term memory tools (only SearchMemory is included).</param>
/// <param name="taskId">The A2A task ID — used as the working memory namespace.</param>
/// <param name="logger">Logger for tool invocations.</param>
public static IList<AITool> Build(
IWorkingMemory workingMemory,
MemoryTools memoryTools,
string taskId,
ILogger logger)
{
// Working memory scoped to a2a-inbox/{taskId} — writes are contained to this namespace
var wmTools = new WorkingMemoryTools(workingMemory, $"a2a-inbox/{taskId}", logger);

// From working memory tools: include all (read + write scoped to inbox namespace)
var tools = new List<AITool>(wmTools.Tools);

// From long-term memory: include only SearchMemory (read-only)
var searchMemory = memoryTools.Tools
.OfType<AIFunction>()
.FirstOrDefault(f => f.Name == "SearchMemory");
if (searchMemory is not null)
tools.Add(searchMemory);

return tools;
}
}
Loading
Loading