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
12 changes: 12 additions & 0 deletions .cspell-repo-terms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,15 @@ Nanvix
reqwest
requireapproval
uncallable
cloudevents
cloudEvents
CloudEvents
datacontenttype
otlp
OTLP
OtlpEventSink
StdoutEventSink
GovernanceEventSink
SignedGovernanceEvent
GovernanceEventCategory
EventSink
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// Copyright (c) Microsoft Corporation. Licensed under the MIT License.

using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

namespace AgentGovernance.EventSink;

/// <summary>
/// Categories of governance events emitted through the <see cref="IGovernanceEventSink"/> SPI.
/// Values follow the <c>ai.agentmesh.&lt;category&gt;</c> CloudEvents type convention.
/// </summary>
public enum GovernanceEventCategory
{
/// <summary>A policy allow/deny/warn/require-approval outcome was produced.</summary>
PolicyDecision,

/// <summary>A policy violation (breach) was detected.</summary>
PolicyBreach,

/// <summary>An agent identity claim or verification result was produced.</summary>
IdentityAssertion,

/// <summary>A tool call was intercepted before execution.</summary>
ToolInvocation,

/// <summary>A sandbox lifecycle event occurred (create, execute, destroy).</summary>
SandboxEvent,

/// <summary>A hash-chain audit entry was emitted.</summary>
AuditChain,
}

/// <summary>
/// CloudEvents 1.0 envelope with HMAC-SHA256 tamper-evidence signature.
/// </summary>
/// <remarks>
/// <para>
/// Fields follow the <see href="https://github.com/cloudevents/spec">CloudEvents specification</see>.
/// The <see cref="Signature"/> extension field is an HMAC-SHA256 of the canonical form:
/// <code>
/// "{Type}\n{Source}\n{Time:O}\n{Id}\n{DataJson}"
/// </code>
/// When no signing key is supplied, <see cref="Signature"/> is left empty.
/// </para>
/// <para>
/// <b>Quick start:</b>
/// <code>
/// var evt = SignedGovernanceEvent.Build(
/// GovernanceEventCategory.PolicyDecision,
/// source: "did:agentmesh:agent-1",
/// subject: "tool:file_write",
/// data: new() { ["decision"] = "deny", ["reason"] = "path blocked" });
/// </code>
/// </para>
/// </remarks>
public sealed class SignedGovernanceEvent
{
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = false,
};

/// <summary>CloudEvents specification version. Always <c>"1.0"</c>.</summary>
public string SpecVersion { get; init; } = "1.0";

/// <summary>Unique event identifier (format: <c>evt-{guid}</c>).</summary>
public string Id { get; init; } = $"evt-{Guid.NewGuid():N}";

/// <summary>
/// CloudEvents type string, e.g. <c>"ai.agentmesh.policy.decision"</c>.
/// </summary>
public string Type { get; init; } = string.Empty;

/// <summary>Agent DID or service URI that produced the event.</summary>
public string Source { get; init; } = string.Empty;

/// <summary>UTC timestamp of when the event occurred.</summary>
public DateTimeOffset Time { get; init; } = DateTimeOffset.UtcNow;

/// <summary>Content type of <see cref="Data"/>. Always <c>"application/json"</c>.</summary>
public string DataContentType { get; init; } = "application/json";

/// <summary>
/// Tool name, resource path, or other context-specific subject string.
/// </summary>
public string Subject { get; init; } = string.Empty;

/// <summary>Event-specific payload.</summary>
public Dictionary<string, object> Data { get; init; } = new();

/// <summary>
/// HMAC-SHA256 hex signature of the canonical form. Empty when unsigned.
/// </summary>
public string Signature { get; init; } = string.Empty;

/// <summary>
/// Returns the canonical CloudEvents type string for a <see cref="GovernanceEventCategory"/>.
/// </summary>
public static string CloudEventType(GovernanceEventCategory category) => category switch
{
GovernanceEventCategory.PolicyDecision => "ai.agentmesh.policy.decision",
GovernanceEventCategory.PolicyBreach => "ai.agentmesh.policy.breach",
GovernanceEventCategory.IdentityAssertion => "ai.agentmesh.identity.assertion",
GovernanceEventCategory.ToolInvocation => "ai.agentmesh.tool.invocation",
GovernanceEventCategory.SandboxEvent => "ai.agentmesh.sandbox.event",
GovernanceEventCategory.AuditChain => "ai.agentmesh.audit.chain",
_ => $"ai.agentmesh.{category.ToString().ToLowerInvariant()}",
};

/// <summary>
/// Constructs and optionally signs a <see cref="SignedGovernanceEvent"/>.
/// </summary>
/// <param name="category">The governance event category.</param>
/// <param name="source">Agent DID or service URI.</param>
/// <param name="subject">Tool name, resource, or subject string.</param>
/// <param name="data">Arbitrary event payload. Defaults to an empty dictionary.</param>
/// <param name="signingKey">
/// Raw bytes used as the HMAC-SHA256 signing key.
/// When <c>null</c> or empty, the event is unsigned (<see cref="Signature"/> is empty).
/// </param>
/// <returns>A fully constructed <see cref="SignedGovernanceEvent"/>.</returns>
public static SignedGovernanceEvent Build(
GovernanceEventCategory category,
string source,
string subject = "",
Dictionary<string, object>? data = null,
byte[]? signingKey = null)
{
var now = DateTimeOffset.UtcNow;
var id = $"evt-{Guid.NewGuid():N}";
var type = CloudEventType(category);
var payload = data ?? new Dictionary<string, object>();
var dataJson = JsonSerializer.Serialize(payload, _jsonOptions);

var sig = string.Empty;
if (signingKey is { Length: > 0 })
{
var canonical = $"{type}\n{source}\n{now:O}\n{id}\n{dataJson}";
using var hmac = new HMACSHA256(signingKey);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
sig = Convert.ToHexString(hash).ToLowerInvariant();
}

return new SignedGovernanceEvent
{
Id = id,
Type = type,
Source = source,
Time = now,
Subject = subject,
Data = payload,
Signature = sig,
};
}

/// <summary>
/// Verifies the HMAC-SHA256 <see cref="Signature"/> against <paramref name="signingKey"/>.
/// </summary>
/// <param name="signingKey">The key used to verify the signature.</param>
/// <returns>
/// <c>true</c> if the signature is valid; <c>false</c> if invalid or the event is unsigned.
/// </returns>
public bool VerifySignature(byte[] signingKey)
{
if (string.IsNullOrEmpty(Signature)) return false;

var dataJson = JsonSerializer.Serialize(Data, _jsonOptions);
var canonical = $"{Type}\n{Source}\n{Time:O}\n{Id}\n{dataJson}";
using var hmac = new HMACSHA256(signingKey);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
var expected = Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(Signature),
Encoding.ASCII.GetBytes(expected));
}

/// <inheritdoc />
public override string ToString() =>
$"[{Time:O}] {Type} source={Source} subject={Subject} signed={!string.IsNullOrEmpty(Signature)}";
}

/// <summary>
/// Provider interface (SPI) for governance event routing.
/// </summary>
/// <remarks>
/// <para>
/// One async method — <see cref="EmitAsync"/> — takes a <see cref="SignedGovernanceEvent"/>
/// and forwards it to the configured backend. Mirrors the
/// <see cref="AgentGovernance.Sandbox.ISandboxProvider"/> shape for consistency.
/// </para>
/// <para>
/// Reference implementations:
/// <list type="bullet">
/// <item><see cref="StdoutEventSink"/> — JSON to stdout (dev/CI)</item>
/// <item><see cref="OtlpEventSink"/> — OTLP ActivitySource (Defender, Sentinel, Splunk, …)</item>
/// </list>
/// </para>
/// <para>
/// <b>Quick start:</b>
/// <code>
/// IGovernanceEventSink sink = new StdoutEventSink();
/// var evt = SignedGovernanceEvent.Build(
/// GovernanceEventCategory.PolicyDecision,
/// source: "did:agentmesh:agent-1",
/// subject: "tool:file_write",
/// data: new() { ["decision"] = "deny" });
/// await sink.EmitAsync(evt);
/// </code>
/// </para>
/// </remarks>
public interface IGovernanceEventSink
{
/// <summary>
/// Emit a governance event to the configured backend.
/// </summary>
/// <param name="governanceEvent">The signed event to forward.</param>
ValueTask EmitAsync(SignedGovernanceEvent governanceEvent);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft Corporation. Licensed under the MIT License.

using System.Diagnostics;
using System.Text.Json;
using System.Threading.Tasks;

namespace AgentGovernance.EventSink;

/// <summary>
/// Reference <see cref="IGovernanceEventSink"/> that emits governance events via
/// <see cref="ActivitySource"/> — the built-in .NET distributed tracing API that
/// any OTLP-compatible backend can collect without additional NuGet packages.
/// </summary>
/// <remarks>
/// <para>
/// OTLP-compatible backends include: Microsoft Defender for Cloud, Microsoft Sentinel,
/// Splunk, Datadog, Honeycomb, Dynatrace, Grafana Tempo, and any OpenTelemetry Collector.
/// </para>
/// <para>
/// <b>Wiring with OpenTelemetry SDK:</b>
/// <code>
/// using var tracerProvider = Sdk.CreateTracerProviderBuilder()
/// .AddSource(OtlpEventSink.ActivitySourceName)
/// .AddOtlpExporter()
/// .Build();
///
/// var sink = new OtlpEventSink();
/// </code>
/// </para>
/// </remarks>
public sealed class OtlpEventSink : IGovernanceEventSink
{
/// <summary>
/// The <see cref="ActivitySource"/> name used for all governance events.
/// Register this with your OTEL TracerProvider to collect events.
/// </summary>
public const string ActivitySourceName = "AgentGovernance.EventSink";

private static readonly ActivitySource _source =
new(ActivitySourceName, "1.0.0");

private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = false,
};

/// <summary>
/// Emit a governance event as an <see cref="Activity"/> event on an OTEL span.
/// </summary>
/// <remarks>
/// <para>
/// Each call creates a short-lived span under <see cref="ActivitySourceName"/>
/// and attaches the event payload as span attributes. When no OTEL exporter
/// is listening (i.e. no <see cref="ActivityListener"/> is registered for this
/// source), the call is a safe no-op.
/// </para>
/// </remarks>
public ValueTask EmitAsync(SignedGovernanceEvent governanceEvent)
{
ArgumentNullException.ThrowIfNull(governanceEvent);

using var activity = _source.StartActivity(
governanceEvent.Type,
ActivityKind.Internal);

if (activity is not null)
{
activity.SetTag("agt.governance.event.id", governanceEvent.Id);
activity.SetTag("agt.governance.event.type", governanceEvent.Type);
activity.SetTag("agt.governance.event.source", governanceEvent.Source);
activity.SetTag("agt.governance.event.subject", governanceEvent.Subject);
activity.SetTag("agt.governance.event.signed", !string.IsNullOrEmpty(governanceEvent.Signature));
activity.SetTag("agt.governance.event.specversion", governanceEvent.SpecVersion);
activity.SetTag("agt.governance.event.time", governanceEvent.Time.ToString("O"));
activity.SetTag("agt.governance.event.datacontenttype", governanceEvent.DataContentType);

// Attach the full CloudEvents JSON payload as a span event for backends
// that surface span events (Jaeger, Zipkin, Datadog APM, etc.).
var dataJson = JsonSerializer.Serialize(governanceEvent.Data, _jsonOptions);
var tags = new ActivityTagsCollection
{
{ "event.domain", "agent_governance" },
{ "event.body", dataJson },
};
activity.AddEvent(new ActivityEvent("governance_event", tags: tags));
}

return ValueTask.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft Corporation. Licensed under the MIT License.

using System.Text.Json;
using System.Threading.Tasks;

namespace AgentGovernance.EventSink;

/// <summary>
/// Reference <see cref="IGovernanceEventSink"/> that writes governance events as
/// JSON lines to <see cref="Console.Out"/>.
/// </summary>
/// <remarks>
/// <para>
/// Suitable for development, CI pipelines, and container environments where stdout
/// is collected by a log aggregator (Fluentd, Vector, Logstash, AWS CloudWatch Logs, etc.).
/// </para>
/// <para>
/// <b>Example output</b> (one JSON line per event):
/// <code>
/// {"specVersion":"1.0","id":"evt-abc123","type":"ai.agentmesh.policy.decision","source":"did:agentmesh:agent-1",...}
/// </code>
/// </para>
/// </remarks>
public sealed class StdoutEventSink : IGovernanceEventSink
{
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

/// <summary>
/// Write the event as a single JSON line to <see cref="Console.Out"/>.
/// </summary>
public ValueTask EmitAsync(SignedGovernanceEvent governanceEvent)
{
ArgumentNullException.ThrowIfNull(governanceEvent);

var json = JsonSerializer.Serialize(new
{
specVersion = governanceEvent.SpecVersion,
id = governanceEvent.Id,
type = governanceEvent.Type,
source = governanceEvent.Source,
time = governanceEvent.Time.ToString("O"),
dataContentType = governanceEvent.DataContentType,
subject = governanceEvent.Subject,
data = governanceEvent.Data,
signature = governanceEvent.Signature,
}, _jsonOptions);

Console.WriteLine(json);

return ValueTask.CompletedTask;
}
}
Loading
Loading