From 29819526ed5fbf1aacbebac39446b87e0ce50338 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sun, 12 Apr 2026 09:15:33 +0530 Subject: [PATCH 1/2] feat(dotnet): add kill switch and lifecycle management to .NET SDK - Add KillSwitch with arm/disarm, event history, and subscriber notifications - Add LifecycleManager with 8-state machine and validated transitions - Add comprehensive xUnit tests for both components (26 tests) - Update .NET SDK README with usage documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/agent-governance-dotnet/README.md | 57 +++++ .../AgentGovernance/Hypervisor/KillSwitch.cs | 111 ++++++++++ .../Lifecycle/LifecycleManager.cs | 200 ++++++++++++++++++ .../AgentGovernance.Tests/KillSwitchTests.cs | 128 +++++++++++ .../LifecycleManagerTests.cs | 199 +++++++++++++++++ 5 files changed, 695 insertions(+) create mode 100644 packages/agent-governance-dotnet/src/AgentGovernance/Hypervisor/KillSwitch.cs create mode 100644 packages/agent-governance-dotnet/src/AgentGovernance/Lifecycle/LifecycleManager.cs create mode 100644 packages/agent-governance-dotnet/tests/AgentGovernance.Tests/KillSwitchTests.cs create mode 100644 packages/agent-governance-dotnet/tests/AgentGovernance.Tests/LifecycleManagerTests.cs diff --git a/packages/agent-governance-dotnet/README.md b/packages/agent-governance-dotnet/README.md index d95c127d..ef99654f 100644 --- a/packages/agent-governance-dotnet/README.md +++ b/packages/agent-governance-dotnet/README.md @@ -134,6 +134,63 @@ var limits = enforcer.GetLimits(ring); When enabled via `GovernanceOptions.EnableRings`, ring checks are automatically enforced in the middleware pipeline. +### Kill Switch + +Terminate rogue agents immediately with an arm/disarm safety mechanism, event history, and subscriber notifications: + +```csharp +using AgentGovernance.Hypervisor; + +var ks = new KillSwitch(); +ks.Arm(); + +// Subscribe to kill events +ks.OnKill += (_, evt) => + Console.WriteLine($"Killed {evt.AgentId}: {evt.Reason} — {evt.Detail}"); + +// Terminate an agent +var killEvent = ks.Kill("did:mesh:rogue-agent", KillReason.PolicyViolation, "exceeded scope"); + +// Review history +foreach (var e in ks.History) + Console.WriteLine($"{e.Timestamp}: {e.AgentId} — {e.Reason}"); + +ks.Disarm(); // Prevents further kills until re-armed +``` + +| Reason | Description | +|--------|-------------| +| `PolicyViolation` | Agent violated a governance policy | +| `TrustThreshold` | Trust score dropped below threshold | +| `ManualOverride` | Human operator triggered the kill | +| `AnomalyDetected` | Anomalous behaviour detected | +| `ResourceExhaustion` | Resource consumption limits exceeded | + +### Lifecycle Management + +Eight-state lifecycle machine with validated transitions, event logging, and convenience methods: + +```csharp +using AgentGovernance.Lifecycle; + +var mgr = new LifecycleManager("did:mesh:agent-007"); + +mgr.Activate(); // Provisioning → Active +mgr.Suspend("scheduled maintenance"); // Active → Suspended +mgr.Transition(LifecycleState.Active, "maintenance done", "ops"); +mgr.Quarantine("trust breach detected"); // Active → Quarantined +mgr.Decommission("end of life"); // Quarantined → Decommissioning + +// Check transition validity +bool canActivate = mgr.CanTransition(LifecycleState.Active); // false + +// Review full event log +foreach (var evt in mgr.Events) + Console.WriteLine($"{evt.Timestamp}: {evt.FromState} → {evt.ToState} ({evt.Reason})"); +``` + +**Lifecycle states:** Provisioning → Active ↔ Suspended / Rotating / Degraded / Quarantined → Decommissioning → Decommissioned + ### Saga Orchestrator Multi-step transaction governance with automatic compensation on failure: diff --git a/packages/agent-governance-dotnet/src/AgentGovernance/Hypervisor/KillSwitch.cs b/packages/agent-governance-dotnet/src/AgentGovernance/Hypervisor/KillSwitch.cs new file mode 100644 index 00000000..76a23e44 --- /dev/null +++ b/packages/agent-governance-dotnet/src/AgentGovernance/Hypervisor/KillSwitch.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AgentGovernance.Hypervisor; + +/// +/// Reason an agent was terminated via the kill switch. +/// +public enum KillReason +{ + /// Agent violated a governance policy. + PolicyViolation, + + /// Agent's trust score dropped below the acceptable threshold. + TrustThreshold, + + /// A human operator manually triggered the kill. + ManualOverride, + + /// Anomalous behaviour was detected by the monitoring system. + AnomalyDetected, + + /// Agent exceeded resource consumption limits. + ResourceExhaustion +} + +/// +/// Immutable record of a single kill switch activation. +/// +/// DID of the terminated agent. +/// Why the agent was killed. +/// Human-readable detail message. +/// UTC time the kill occurred. +public record KillEvent(string AgentId, KillReason Reason, string Detail, DateTimeOffset Timestamp); + +/// +/// Agent kill switch that can be armed/disarmed. +/// When armed, calling terminates the target agent, +/// records the event, and notifies subscribers. +/// +public class KillSwitch +{ + private readonly List _history = new(); + private readonly object _lock = new(); + + /// + /// Whether the kill switch is currently armed. Only armed switches can kill agents. + /// + public bool IsArmed { get; private set; } + + /// + /// Immutable snapshot of all recorded kill events. + /// + public IReadOnlyList History + { + get + { + lock (_lock) + { + return _history.ToList().AsReadOnly(); + } + } + } + + /// + /// Raised immediately after an agent is killed. + /// + public event EventHandler? OnKill; + + /// + /// Arms the kill switch, enabling to terminate agents. + /// + public void Arm() + { + IsArmed = true; + } + + /// + /// Disarms the kill switch. Subsequent calls to will throw. + /// + public void Disarm() + { + IsArmed = false; + } + + /// + /// Terminates the specified agent, records the event, and raises . + /// + /// DID of the agent to terminate. + /// Reason for the kill. + /// Human-readable detail message. + /// The recorded . + /// Thrown when the switch is not armed. + public KillEvent Kill(string agentId, KillReason reason, string detail) + { + if (!IsArmed) + { + throw new InvalidOperationException("Kill switch is not armed."); + } + + var killEvent = new KillEvent(agentId, reason, detail, DateTimeOffset.UtcNow); + + lock (_lock) + { + _history.Add(killEvent); + } + + OnKill?.Invoke(this, killEvent); + return killEvent; + } +} diff --git a/packages/agent-governance-dotnet/src/AgentGovernance/Lifecycle/LifecycleManager.cs b/packages/agent-governance-dotnet/src/AgentGovernance/Lifecycle/LifecycleManager.cs new file mode 100644 index 00000000..ccf1bee1 --- /dev/null +++ b/packages/agent-governance-dotnet/src/AgentGovernance/Lifecycle/LifecycleManager.cs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AgentGovernance.Lifecycle; + +/// +/// Eight-state lifecycle model for governed agents. +/// +public enum LifecycleState +{ + /// Agent is being provisioned and is not yet ready. + Provisioning, + + /// Agent is running and accepting work. + Active, + + /// Agent is temporarily paused (e.g., policy hold). + Suspended, + + /// Agent credentials or keys are being rotated. + Rotating, + + /// Agent is running in a degraded capacity. + Degraded, + + /// Agent is isolated due to a security or trust concern. + Quarantined, + + /// Agent shutdown is in progress. + Decommissioning, + + /// Agent has been fully decommissioned. + Decommissioned +} + +/// +/// Immutable record of a lifecycle state transition. +/// +/// DID of the agent. +/// Previous lifecycle state. +/// New lifecycle state. +/// Human-readable reason for the transition. +/// UTC time of the transition. +/// Identifier of the actor that initiated the transition. +public record LifecycleEvent( + string AgentId, + LifecycleState FromState, + LifecycleState ToState, + string Reason, + DateTimeOffset Timestamp, + string InitiatedBy); + +/// +/// Manages the lifecycle of a single governed agent using an eight-state +/// machine with validated transitions. +/// +public class LifecycleManager +{ + private static readonly Dictionary> ValidTransitions = new() + { + [LifecycleState.Provisioning] = new() + { + LifecycleState.Active, + LifecycleState.Decommissioning + }, + [LifecycleState.Active] = new() + { + LifecycleState.Suspended, + LifecycleState.Rotating, + LifecycleState.Degraded, + LifecycleState.Quarantined, + LifecycleState.Decommissioning + }, + [LifecycleState.Suspended] = new() + { + LifecycleState.Active, + LifecycleState.Quarantined, + LifecycleState.Decommissioning + }, + [LifecycleState.Rotating] = new() + { + LifecycleState.Active, + LifecycleState.Degraded, + LifecycleState.Decommissioning + }, + [LifecycleState.Degraded] = new() + { + LifecycleState.Active, + LifecycleState.Quarantined, + LifecycleState.Decommissioning + }, + [LifecycleState.Quarantined] = new() + { + LifecycleState.Active, + LifecycleState.Decommissioning + }, + [LifecycleState.Decommissioning] = new() + { + LifecycleState.Decommissioned + }, + [LifecycleState.Decommissioned] = new() + }; + + private readonly string _agentId; + private readonly List _events = new(); + private readonly object _lock = new(); + + /// + /// Creates a new for the given agent. + /// The agent starts in . + /// + /// DID of the agent to manage. + public LifecycleManager(string agentId) + { + _agentId = agentId; + State = LifecycleState.Provisioning; + } + + /// + /// Current lifecycle state of the agent. + /// + public LifecycleState State { get; private set; } + + /// + /// Immutable snapshot of all recorded lifecycle events. + /// + public IReadOnlyList Events + { + get + { + lock (_lock) + { + return _events.ToList().AsReadOnly(); + } + } + } + + /// + /// Returns whether a transition from the current state to is valid. + /// + public bool CanTransition(LifecycleState toState) + { + return ValidTransitions.TryGetValue(State, out var targets) && targets.Contains(toState); + } + + /// + /// Transitions the agent to a new state if the transition is valid. + /// + /// The target state. + /// Human-readable reason for the transition. + /// Identifier of the actor initiating the transition. + /// The recorded . + /// Thrown when the transition is not allowed. + public LifecycleEvent Transition(LifecycleState toState, string reason, string initiatedBy) + { + if (!CanTransition(toState)) + { + throw new InvalidOperationException( + $"Cannot transition from {State} to {toState}."); + } + + var fromState = State; + State = toState; + + var evt = new LifecycleEvent(_agentId, fromState, toState, reason, DateTimeOffset.UtcNow, initiatedBy); + + lock (_lock) + { + _events.Add(evt); + } + + return evt; + } + + // ── Convenience methods ────────────────────────────────────── + + /// + /// Transitions the agent to . + /// + public LifecycleEvent Activate(string reason = "Ready") + => Transition(LifecycleState.Active, reason, "system"); + + /// + /// Transitions the agent to . + /// + public LifecycleEvent Suspend(string reason) + => Transition(LifecycleState.Suspended, reason, "system"); + + /// + /// Transitions the agent to . + /// + public LifecycleEvent Quarantine(string reason) + => Transition(LifecycleState.Quarantined, reason, "system"); + + /// + /// Transitions the agent to . + /// + public LifecycleEvent Decommission(string reason) + => Transition(LifecycleState.Decommissioning, reason, "system"); +} diff --git a/packages/agent-governance-dotnet/tests/AgentGovernance.Tests/KillSwitchTests.cs b/packages/agent-governance-dotnet/tests/AgentGovernance.Tests/KillSwitchTests.cs new file mode 100644 index 00000000..5f95e5cb --- /dev/null +++ b/packages/agent-governance-dotnet/tests/AgentGovernance.Tests/KillSwitchTests.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AgentGovernance.Hypervisor; +using Xunit; + +namespace AgentGovernance.Tests; + +public class KillSwitchTests +{ + [Fact] + public void NewKillSwitch_IsNotArmed() + { + var ks = new KillSwitch(); + + Assert.False(ks.IsArmed); + } + + [Fact] + public void Arm_SetsIsArmedTrue() + { + var ks = new KillSwitch(); + ks.Arm(); + + Assert.True(ks.IsArmed); + } + + [Fact] + public void Disarm_SetsIsArmedFalse() + { + var ks = new KillSwitch(); + ks.Arm(); + ks.Disarm(); + + Assert.False(ks.IsArmed); + } + + [Fact] + public void Kill_WhenDisarmed_Throws() + { + var ks = new KillSwitch(); + + Assert.Throws( + () => ks.Kill("agent-1", KillReason.ManualOverride, "test")); + } + + [Fact] + public void Kill_WhenArmed_ReturnsEvent() + { + var ks = new KillSwitch(); + ks.Arm(); + + var evt = ks.Kill("agent-1", KillReason.PolicyViolation, "exceeded scope"); + + Assert.Equal("agent-1", evt.AgentId); + Assert.Equal(KillReason.PolicyViolation, evt.Reason); + Assert.Equal("exceeded scope", evt.Detail); + Assert.True(evt.Timestamp <= DateTimeOffset.UtcNow); + } + + [Fact] + public void Kill_FiresOnKillEvent() + { + var ks = new KillSwitch(); + ks.Arm(); + + KillEvent? received = null; + ks.OnKill += (_, e) => received = e; + + ks.Kill("agent-1", KillReason.AnomalyDetected, "drift"); + + Assert.NotNull(received); + Assert.Equal("agent-1", received!.AgentId); + Assert.Equal(KillReason.AnomalyDetected, received.Reason); + } + + [Fact] + public void History_TracksAllKills() + { + var ks = new KillSwitch(); + ks.Arm(); + + ks.Kill("agent-1", KillReason.PolicyViolation, "v1"); + ks.Kill("agent-2", KillReason.TrustThreshold, "v2"); + ks.Kill("agent-3", KillReason.ResourceExhaustion, "v3"); + + Assert.Equal(3, ks.History.Count); + Assert.Equal("agent-1", ks.History[0].AgentId); + Assert.Equal("agent-2", ks.History[1].AgentId); + Assert.Equal("agent-3", ks.History[2].AgentId); + } + + [Fact] + public void History_IsEmpty_WhenNoKills() + { + var ks = new KillSwitch(); + + Assert.Empty(ks.History); + } + + [Fact] + public void ArmDisarmArm_AllowsKillAfterRearm() + { + var ks = new KillSwitch(); + ks.Arm(); + ks.Disarm(); + ks.Arm(); + + var evt = ks.Kill("agent-1", KillReason.ManualOverride, "re-armed"); + + Assert.Equal("agent-1", evt.AgentId); + } + + [Fact] + public void Kill_AllReasons_Accepted() + { + var ks = new KillSwitch(); + ks.Arm(); + + foreach (var reason in Enum.GetValues()) + { + var evt = ks.Kill($"agent-{reason}", reason, reason.ToString()); + Assert.Equal(reason, evt.Reason); + } + + Assert.Equal(Enum.GetValues().Length, ks.History.Count); + } +} diff --git a/packages/agent-governance-dotnet/tests/AgentGovernance.Tests/LifecycleManagerTests.cs b/packages/agent-governance-dotnet/tests/AgentGovernance.Tests/LifecycleManagerTests.cs new file mode 100644 index 00000000..dc89d030 --- /dev/null +++ b/packages/agent-governance-dotnet/tests/AgentGovernance.Tests/LifecycleManagerTests.cs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AgentGovernance.Lifecycle; +using Xunit; + +namespace AgentGovernance.Tests; + +public class LifecycleManagerTests +{ + private const string AgentId = "did:mesh:lifecycle-test"; + + [Fact] + public void NewManager_StartsInProvisioning() + { + var mgr = new LifecycleManager(AgentId); + + Assert.Equal(LifecycleState.Provisioning, mgr.State); + Assert.Empty(mgr.Events); + } + + [Fact] + public void Activate_FromProvisioning_Succeeds() + { + var mgr = new LifecycleManager(AgentId); + + var evt = mgr.Activate(); + + Assert.Equal(LifecycleState.Active, mgr.State); + Assert.Equal(LifecycleState.Provisioning, evt.FromState); + Assert.Equal(LifecycleState.Active, evt.ToState); + Assert.Equal("Ready", evt.Reason); + Assert.Equal(AgentId, evt.AgentId); + } + + [Fact] + public void Suspend_FromActive_Succeeds() + { + var mgr = new LifecycleManager(AgentId); + mgr.Activate(); + + var evt = mgr.Suspend("policy hold"); + + Assert.Equal(LifecycleState.Suspended, mgr.State); + Assert.Equal("policy hold", evt.Reason); + } + + [Fact] + public void Quarantine_FromActive_Succeeds() + { + var mgr = new LifecycleManager(AgentId); + mgr.Activate(); + + var evt = mgr.Quarantine("trust breach"); + + Assert.Equal(LifecycleState.Quarantined, mgr.State); + Assert.Equal("trust breach", evt.Reason); + } + + [Fact] + public void Decommission_FromActive_Succeeds() + { + var mgr = new LifecycleManager(AgentId); + mgr.Activate(); + + var evt = mgr.Decommission("end of life"); + + Assert.Equal(LifecycleState.Decommissioning, mgr.State); + Assert.Equal("end of life", evt.Reason); + } + + [Fact] + public void FullLifecycle_ProvisionToDecommissioned() + { + var mgr = new LifecycleManager(AgentId); + + mgr.Activate(); + mgr.Suspend("maintenance"); + mgr.Transition(LifecycleState.Active, "maintenance complete", "ops"); + mgr.Decommission("retiring"); + mgr.Transition(LifecycleState.Decommissioned, "done", "ops"); + + Assert.Equal(LifecycleState.Decommissioned, mgr.State); + Assert.Equal(5, mgr.Events.Count); + } + + [Fact] + public void InvalidTransition_Throws() + { + var mgr = new LifecycleManager(AgentId); + + // Provisioning → Quarantined is not allowed + Assert.Throws( + () => mgr.Quarantine("should fail")); + } + + [Fact] + public void Decommissioned_CannotTransition() + { + var mgr = new LifecycleManager(AgentId); + mgr.Activate(); + mgr.Decommission("retiring"); + mgr.Transition(LifecycleState.Decommissioned, "done", "ops"); + + Assert.Throws( + () => mgr.Activate("should fail")); + } + + [Fact] + public void CanTransition_ReturnsCorrectly() + { + var mgr = new LifecycleManager(AgentId); + + Assert.True(mgr.CanTransition(LifecycleState.Active)); + Assert.True(mgr.CanTransition(LifecycleState.Decommissioning)); + Assert.False(mgr.CanTransition(LifecycleState.Suspended)); + Assert.False(mgr.CanTransition(LifecycleState.Quarantined)); + } + + [Fact] + public void Transition_RecordsInitiatedBy() + { + var mgr = new LifecycleManager(AgentId); + + var evt = mgr.Transition(LifecycleState.Active, "init", "admin-user"); + + Assert.Equal("admin-user", evt.InitiatedBy); + } + + [Fact] + public void ConvenienceMethods_UseSystemAsInitiator() + { + var mgr = new LifecycleManager(AgentId); + var evt = mgr.Activate(); + + Assert.Equal("system", evt.InitiatedBy); + } + + [Fact] + public void Events_AreImmutableSnapshot() + { + var mgr = new LifecycleManager(AgentId); + mgr.Activate(); + + var snapshot = mgr.Events; + mgr.Suspend("pause"); + + // Snapshot should not reflect the new event + Assert.Single(snapshot); + Assert.Equal(2, mgr.Events.Count); + } + + [Fact] + public void Rotating_FromActive_Succeeds() + { + var mgr = new LifecycleManager(AgentId); + mgr.Activate(); + + var evt = mgr.Transition(LifecycleState.Rotating, "key rotation", "security"); + + Assert.Equal(LifecycleState.Rotating, mgr.State); + Assert.Equal("key rotation", evt.Reason); + } + + [Fact] + public void Degraded_FromActive_Succeeds() + { + var mgr = new LifecycleManager(AgentId); + mgr.Activate(); + + var evt = mgr.Transition(LifecycleState.Degraded, "partial failure", "monitor"); + + Assert.Equal(LifecycleState.Degraded, mgr.State); + } + + [Fact] + public void Quarantine_FromDegraded_Succeeds() + { + var mgr = new LifecycleManager(AgentId); + mgr.Activate(); + mgr.Transition(LifecycleState.Degraded, "failing", "monitor"); + + mgr.Quarantine("escalation"); + + Assert.Equal(LifecycleState.Quarantined, mgr.State); + } + + [Fact] + public void Active_FromQuarantined_Succeeds() + { + var mgr = new LifecycleManager(AgentId); + mgr.Activate(); + mgr.Quarantine("incident"); + + mgr.Transition(LifecycleState.Active, "cleared", "ops"); + + Assert.Equal(LifecycleState.Active, mgr.State); + } +} From e36b3e20b2f183b6d7ca893a6806252e648d6520 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sun, 12 Apr 2026 09:16:58 +0530 Subject: [PATCH 2/2] feat(rust): add execution rings and lifecycle management to Rust SDK Add two new modules to the agentmesh Rust crate: - rings.rs: Four-level execution privilege ring model (Admin/Standard/ Restricted/Sandboxed) with per-agent assignment and per-ring action permissions, ported from the Python hypervisor enforcer. - lifecycle.rs: Eight-state agent lifecycle manager (Provisioning through Decommissioned) with validated state transitions and event history, matching the lifecycle model used across other SDK languages. Both modules include comprehensive unit tests and are re-exported from the crate root. README updated with API tables and usage examples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent-mesh/sdks/rust/agentmesh/README.md | 64 ++++ .../agent-mesh/sdks/rust/agentmesh/src/lib.rs | 4 + .../sdks/rust/agentmesh/src/lifecycle.rs | 350 ++++++++++++++++++ .../sdks/rust/agentmesh/src/rings.rs | 232 ++++++++++++ 4 files changed, 650 insertions(+) create mode 100644 packages/agent-mesh/sdks/rust/agentmesh/src/lifecycle.rs create mode 100644 packages/agent-mesh/sdks/rust/agentmesh/src/rings.rs diff --git a/packages/agent-mesh/sdks/rust/agentmesh/README.md b/packages/agent-mesh/sdks/rust/agentmesh/README.md index b916cbba..2e1a07ea 100644 --- a/packages/agent-mesh/sdks/rust/agentmesh/README.md +++ b/packages/agent-mesh/sdks/rust/agentmesh/README.md @@ -195,6 +195,70 @@ policies: window: "60s" ``` +### Execution Rings (`rings.rs`) + +Four-level privilege model inspired by hardware protection rings. + +| Function / Method | Description | +|---|---| +| `RingEnforcer::new()` | Create a new enforcer with no assignments | +| `enforcer.assign(agent_id, ring)` | Assign an agent to a ring | +| `enforcer.get_ring(agent_id)` | Get assigned ring (if any) | +| `enforcer.check_access(agent_id, action)` | Check if action is permitted | +| `enforcer.set_ring_permissions(ring, actions)` | Configure allowed actions for a ring | + +Ring levels: + +| Ring | Level | Access | +|------|-------|--------| +| `Admin` | 0 | All actions allowed | +| `Standard` | 1 | Configurable actions | +| `Restricted` | 2 | Configurable actions | +| `Sandboxed` | 3 | All actions denied | + +```rust +use agentmesh::{RingEnforcer, Ring}; + +let mut enforcer = RingEnforcer::new(); +enforcer.set_ring_permissions(Ring::Standard, vec!["data.read".into(), "data.write".into()]); +enforcer.assign("my-agent", Ring::Standard); + +assert!(enforcer.check_access("my-agent", "data.read")); +assert!(!enforcer.check_access("my-agent", "shell:rm")); +``` + +### Agent Lifecycle (`lifecycle.rs`) + +Eight-state lifecycle model tracking an agent from provisioning through decommissioning. + +| Function / Method | Description | +|---|---| +| `LifecycleManager::new(agent_id)` | Create a new manager (starts in `Provisioning`) | +| `manager.state()` | Get current lifecycle state | +| `manager.events()` | Get recorded transition events | +| `manager.transition(to, reason, initiated_by)` | Transition to a new state | +| `manager.can_transition(to)` | Check if a transition is valid | +| `manager.activate(reason)` | Convenience: transition to `Active` | +| `manager.suspend(reason)` | Convenience: transition to `Suspended` | +| `manager.quarantine(reason)` | Convenience: transition to `Quarantined` | +| `manager.decommission(reason)` | Convenience: transition to `Decommissioning` | + +Lifecycle states: `Provisioning` -> `Active` <-> `Suspended` / `Rotating` / `Degraded` -> `Quarantined` -> `Decommissioning` -> `Decommissioned` + +```rust +use agentmesh::{LifecycleManager, LifecycleState}; + +let mut mgr = LifecycleManager::new("my-agent"); +mgr.activate("initial boot").unwrap(); +assert_eq!(mgr.state(), LifecycleState::Active); + +mgr.suspend("maintenance window").unwrap(); +assert_eq!(mgr.state(), LifecycleState::Suspended); + +mgr.activate("maintenance complete").unwrap(); +assert_eq!(mgr.events().len(), 3); +``` + ## License See repository root [LICENSE](../../../../LICENSE). diff --git a/packages/agent-mesh/sdks/rust/agentmesh/src/lib.rs b/packages/agent-mesh/sdks/rust/agentmesh/src/lib.rs index af1f5c36..22ea34ee 100644 --- a/packages/agent-mesh/sdks/rust/agentmesh/src/lib.rs +++ b/packages/agent-mesh/sdks/rust/agentmesh/src/lib.rs @@ -20,15 +20,19 @@ pub mod audit; pub mod identity; +pub mod lifecycle; pub mod mcp; pub mod policy; +pub mod rings; pub mod trust; pub mod types; pub use audit::AuditLogger; pub use identity::{AgentIdentity, PublicIdentity}; +pub use lifecycle::{LifecycleEvent, LifecycleManager, LifecycleState}; pub use mcp::*; pub use policy::{PolicyEngine, PolicyError}; +pub use rings::{Ring, RingEnforcer}; pub use trust::{TrustConfig, TrustManager}; pub use types::{ AuditEntry, AuditFilter, CandidateDecision, ConflictResolutionStrategy, GovernanceResult, diff --git a/packages/agent-mesh/sdks/rust/agentmesh/src/lifecycle.rs b/packages/agent-mesh/sdks/rust/agentmesh/src/lifecycle.rs new file mode 100644 index 00000000..a40bffee --- /dev/null +++ b/packages/agent-mesh/sdks/rust/agentmesh/src/lifecycle.rs @@ -0,0 +1,350 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Agent lifecycle management -- an eight-state model tracking an agent from +//! provisioning through decommissioning. + +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The eight lifecycle states an agent can occupy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LifecycleState { + /// Agent is being provisioned (initial state). + Provisioning, + /// Agent is fully operational. + Active, + /// Agent is temporarily suspended. + Suspended, + /// Agent credentials are being rotated. + Rotating, + /// Agent is running in a degraded mode. + Degraded, + /// Agent has been quarantined due to policy violations or anomalies. + Quarantined, + /// Agent is in the process of being decommissioned. + Decommissioning, + /// Agent has been permanently decommissioned (terminal state). + Decommissioned, +} + +/// A recorded lifecycle transition event. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifecycleEvent { + /// State before the transition. + pub from: LifecycleState, + /// State after the transition. + pub to: LifecycleState, + /// Human-readable reason for the transition. + pub reason: String, + /// Who or what initiated the transition. + pub initiated_by: String, + /// Unix timestamp (seconds) when the transition occurred. + pub timestamp: u64, +} + +/// Manages the lifecycle of a single agent. +pub struct LifecycleManager { + agent_id: String, + state: LifecycleState, + events: Vec, +} + +impl LifecycleManager { + /// Create a new lifecycle manager for the given agent. + /// + /// The initial state is [`LifecycleState::Provisioning`]. + pub fn new(agent_id: &str) -> Self { + Self { + agent_id: agent_id.to_string(), + state: LifecycleState::Provisioning, + events: Vec::new(), + } + } + + /// Return the current lifecycle state. + pub fn state(&self) -> LifecycleState { + self.state + } + + /// Return the agent identifier. + pub fn agent_id(&self) -> &str { + &self.agent_id + } + + /// Return all recorded lifecycle events. + pub fn events(&self) -> &[LifecycleEvent] { + &self.events + } + + /// Attempt to transition the agent to `to`. + /// + /// Returns the resulting [`LifecycleEvent`] on success, or an error + /// message describing why the transition is not allowed. + pub fn transition( + &mut self, + to: LifecycleState, + reason: &str, + initiated_by: &str, + ) -> Result<&LifecycleEvent, String> { + if !self.can_transition(to) { + return Err(format!( + "invalid transition from {:?} to {:?}", + self.state, to + )); + } + + let event = LifecycleEvent { + from: self.state, + to, + reason: reason.to_string(), + initiated_by: initiated_by.to_string(), + timestamp: epoch_now(), + }; + self.state = to; + self.events.push(event); + Ok(self.events.last().expect("just pushed")) + } + + /// Check whether transitioning from the current state to `to` is valid. + pub fn can_transition(&self, to: LifecycleState) -> bool { + allowed_transitions(self.state).contains(&to) + } + + /// Convenience: transition to [`LifecycleState::Active`]. + pub fn activate(&mut self, reason: &str) -> Result<&LifecycleEvent, String> { + self.transition(LifecycleState::Active, reason, "system") + } + + /// Convenience: transition to [`LifecycleState::Suspended`]. + pub fn suspend(&mut self, reason: &str) -> Result<&LifecycleEvent, String> { + self.transition(LifecycleState::Suspended, reason, "system") + } + + /// Convenience: transition to [`LifecycleState::Quarantined`]. + pub fn quarantine(&mut self, reason: &str) -> Result<&LifecycleEvent, String> { + self.transition(LifecycleState::Quarantined, reason, "system") + } + + /// Convenience: transition to [`LifecycleState::Decommissioning`]. + pub fn decommission(&mut self, reason: &str) -> Result<&LifecycleEvent, String> { + self.transition(LifecycleState::Decommissioning, reason, "system") + } +} + +/// Return the set of states reachable from `from`. +fn allowed_transitions(from: LifecycleState) -> &'static [LifecycleState] { + use LifecycleState::*; + match from { + Provisioning => &[Active], + Active => &[Suspended, Rotating, Degraded, Decommissioning], + Suspended => &[Active, Decommissioning], + Rotating => &[Active], + Degraded => &[Active, Quarantined, Decommissioning], + Quarantined => &[Active, Decommissioning], + Decommissioning => &[Decommissioned], + Decommissioned => &[], + } +} + +fn epoch_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initial_state_is_provisioning() { + let mgr = LifecycleManager::new("agent-1"); + assert_eq!(mgr.state(), LifecycleState::Provisioning); + assert_eq!(mgr.agent_id(), "agent-1"); + assert!(mgr.events().is_empty()); + } + + #[test] + fn test_activate_from_provisioning() { + let mut mgr = LifecycleManager::new("agent-1"); + let event = mgr.activate("initial activation").unwrap(); + assert_eq!(event.from, LifecycleState::Provisioning); + assert_eq!(event.to, LifecycleState::Active); + assert_eq!(event.reason, "initial activation"); + assert_eq!(mgr.state(), LifecycleState::Active); + } + + #[test] + fn test_suspend_from_active() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + let event = mgr.suspend("maintenance window").unwrap(); + assert_eq!(event.from, LifecycleState::Active); + assert_eq!(event.to, LifecycleState::Suspended); + assert_eq!(mgr.state(), LifecycleState::Suspended); + } + + #[test] + fn test_reactivate_from_suspended() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + mgr.suspend("pause").unwrap(); + let event = mgr.activate("resume").unwrap(); + assert_eq!(event.from, LifecycleState::Suspended); + assert_eq!(event.to, LifecycleState::Active); + } + + #[test] + fn test_quarantine_from_degraded() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + mgr.transition(LifecycleState::Degraded, "high error rate", "monitor") + .unwrap(); + let event = mgr.quarantine("policy violation detected").unwrap(); + assert_eq!(event.from, LifecycleState::Degraded); + assert_eq!(event.to, LifecycleState::Quarantined); + } + + #[test] + fn test_decommission_flow() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + mgr.decommission("end of life").unwrap(); + assert_eq!(mgr.state(), LifecycleState::Decommissioning); + + mgr.transition(LifecycleState::Decommissioned, "cleanup done", "system") + .unwrap(); + assert_eq!(mgr.state(), LifecycleState::Decommissioned); + } + + #[test] + fn test_decommissioned_is_terminal() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + mgr.decommission("bye").unwrap(); + mgr.transition(LifecycleState::Decommissioned, "done", "system") + .unwrap(); + + let result = mgr.activate("try again"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("invalid transition from Decommissioned")); + } + + #[test] + fn test_invalid_transition_from_provisioning() { + let mut mgr = LifecycleManager::new("agent-1"); + let result = mgr.suspend("not allowed"); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_transition_returns_descriptive_error() { + let mut mgr = LifecycleManager::new("agent-1"); + let err = mgr.suspend("nope").unwrap_err(); + assert!(err.contains("Provisioning")); + assert!(err.contains("Suspended")); + } + + #[test] + fn test_can_transition_returns_true_for_valid() { + let mut mgr = LifecycleManager::new("agent-1"); + assert!(mgr.can_transition(LifecycleState::Active)); + assert!(!mgr.can_transition(LifecycleState::Suspended)); + + mgr.activate("boot").unwrap(); + assert!(mgr.can_transition(LifecycleState::Suspended)); + assert!(mgr.can_transition(LifecycleState::Rotating)); + assert!(mgr.can_transition(LifecycleState::Degraded)); + assert!(mgr.can_transition(LifecycleState::Decommissioning)); + assert!(!mgr.can_transition(LifecycleState::Quarantined)); + } + + #[test] + fn test_rotating_returns_to_active() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + mgr.transition(LifecycleState::Rotating, "key rotation", "security") + .unwrap(); + assert_eq!(mgr.state(), LifecycleState::Rotating); + + mgr.activate("rotation complete").unwrap(); + assert_eq!(mgr.state(), LifecycleState::Active); + } + + #[test] + fn test_event_history_records_all_transitions() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + mgr.suspend("pause").unwrap(); + mgr.activate("resume").unwrap(); + + let events = mgr.events(); + assert_eq!(events.len(), 3); + assert_eq!(events[0].to, LifecycleState::Active); + assert_eq!(events[1].to, LifecycleState::Suspended); + assert_eq!(events[2].to, LifecycleState::Active); + } + + #[test] + fn test_event_timestamps_are_monotonic() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + mgr.suspend("pause").unwrap(); + mgr.activate("resume").unwrap(); + + let events = mgr.events(); + for window in events.windows(2) { + assert!(window[1].timestamp >= window[0].timestamp); + } + } + + #[test] + fn test_lifecycle_state_serde_roundtrip() { + let state = LifecycleState::Quarantined; + let json = serde_json::to_string(&state).unwrap(); + let deserialized: LifecycleState = serde_json::from_str(&json).unwrap(); + assert_eq!(state, deserialized); + } + + #[test] + fn test_lifecycle_event_serde_roundtrip() { + let event = LifecycleEvent { + from: LifecycleState::Active, + to: LifecycleState::Suspended, + reason: "maintenance".to_string(), + initiated_by: "admin".to_string(), + timestamp: 1700000000, + }; + let json = serde_json::to_string(&event).unwrap(); + let deserialized: LifecycleEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.from, event.from); + assert_eq!(deserialized.to, event.to); + assert_eq!(deserialized.reason, event.reason); + } + + #[test] + fn test_quarantined_can_reactivate() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + mgr.transition(LifecycleState::Degraded, "issues", "monitor") + .unwrap(); + mgr.quarantine("violation").unwrap(); + mgr.activate("cleared").unwrap(); + assert_eq!(mgr.state(), LifecycleState::Active); + } + + #[test] + fn test_quarantined_can_decommission() { + let mut mgr = LifecycleManager::new("agent-1"); + mgr.activate("boot").unwrap(); + mgr.transition(LifecycleState::Degraded, "issues", "monitor") + .unwrap(); + mgr.quarantine("violation").unwrap(); + mgr.decommission("permanent removal").unwrap(); + assert_eq!(mgr.state(), LifecycleState::Decommissioning); + } +} diff --git a/packages/agent-mesh/sdks/rust/agentmesh/src/rings.rs b/packages/agent-mesh/sdks/rust/agentmesh/src/rings.rs new file mode 100644 index 00000000..8fa6d8cf --- /dev/null +++ b/packages/agent-mesh/sdks/rust/agentmesh/src/rings.rs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Execution privilege rings — a four-level access-control model inspired by +//! hardware protection rings. +//! +//! | Ring | Level | Description | +//! |------|-------|-------------| +//! | `Admin` | 0 | Full tool access | +//! | `Standard` | 1 | Scoped tool access (configurable) | +//! | `Restricted` | 2 | Read-only + approved writes (configurable) | +//! | `Sandboxed` | 3 | No external access | + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Execution privilege ring. +/// +/// Lower numeric values imply higher privilege — matching the classic +/// ring-0 / ring-3 convention used in OS kernels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Ring { + /// Ring 0 — full tool access. + Admin = 0, + /// Ring 1 — scoped tool access (actions are configurable). + Standard = 1, + /// Ring 2 — read-only plus approved writes (actions are configurable). + Restricted = 2, + /// Ring 3 — no external access. + Sandboxed = 3, +} + +/// Manages per-agent ring assignments and per-ring action permissions. +/// +/// # Access semantics +/// +/// * **Ring 0 (`Admin`)** — every action is implicitly allowed. +/// * **Ring 3 (`Sandboxed`)** — every action is implicitly denied. +/// * **Ring 1 / Ring 2** — allowed only if the action appears in the +/// ring's configured permission set. +/// * Unknown agents (not yet assigned) are denied by default. +pub struct RingEnforcer { + assignments: HashMap, + permissions: HashMap>, +} + +impl RingEnforcer { + /// Create a new enforcer with no assignments and no custom permissions. + pub fn new() -> Self { + Self { + assignments: HashMap::new(), + permissions: HashMap::new(), + } + } + + /// Assign an agent to a specific ring. + pub fn assign(&mut self, agent_id: &str, ring: Ring) { + self.assignments.insert(agent_id.to_string(), ring); + } + + /// Return the ring currently assigned to the agent, if any. + pub fn get_ring(&self, agent_id: &str) -> Option { + self.assignments.get(agent_id).copied() + } + + /// Check whether `agent_id` is allowed to perform `action`. + /// + /// Returns `false` for unknown agents. + pub fn check_access(&self, agent_id: &str, action: &str) -> bool { + match self.get_ring(agent_id) { + Some(Ring::Admin) => true, + Some(Ring::Sandboxed) => false, + Some(ring) => self + .permissions + .get(&ring) + .map_or(false, |allowed| allowed.iter().any(|a| a == action)), + None => false, + } + } + + /// Configure the set of allowed actions for a given ring. + /// + /// Only meaningful for `Standard` and `Restricted` rings — `Admin` + /// always allows and `Sandboxed` always denies regardless of this + /// setting. + pub fn set_ring_permissions(&mut self, ring: Ring, allowed_actions: Vec) { + self.permissions.insert(ring, allowed_actions); + } +} + +impl Default for RingEnforcer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_admin_ring_allows_everything() { + let mut enforcer = RingEnforcer::new(); + enforcer.assign("root-agent", Ring::Admin); + assert!(enforcer.check_access("root-agent", "any.action")); + assert!(enforcer.check_access("root-agent", "shell:rm")); + assert!(enforcer.check_access("root-agent", "deploy.prod")); + } + + #[test] + fn test_sandboxed_ring_denies_everything() { + let mut enforcer = RingEnforcer::new(); + enforcer.assign("sandbox-agent", Ring::Sandboxed); + assert!(!enforcer.check_access("sandbox-agent", "data.read")); + assert!(!enforcer.check_access("sandbox-agent", "any.action")); + } + + #[test] + fn test_standard_ring_with_permissions() { + let mut enforcer = RingEnforcer::new(); + enforcer.assign("agent-1", Ring::Standard); + enforcer.set_ring_permissions( + Ring::Standard, + vec!["data.read".to_string(), "data.write".to_string()], + ); + + assert!(enforcer.check_access("agent-1", "data.read")); + assert!(enforcer.check_access("agent-1", "data.write")); + assert!(!enforcer.check_access("agent-1", "shell:rm")); + } + + #[test] + fn test_restricted_ring_with_permissions() { + let mut enforcer = RingEnforcer::new(); + enforcer.assign("agent-2", Ring::Restricted); + enforcer.set_ring_permissions(Ring::Restricted, vec!["data.read".to_string()]); + + assert!(enforcer.check_access("agent-2", "data.read")); + assert!(!enforcer.check_access("agent-2", "data.write")); + } + + #[test] + fn test_unknown_agent_denied() { + let enforcer = RingEnforcer::new(); + assert!(!enforcer.check_access("unknown-agent", "data.read")); + } + + #[test] + fn test_get_ring_returns_none_for_unknown() { + let enforcer = RingEnforcer::new(); + assert_eq!(enforcer.get_ring("unknown"), None); + } + + #[test] + fn test_get_ring_returns_assigned() { + let mut enforcer = RingEnforcer::new(); + enforcer.assign("admin-agent", Ring::Admin); + enforcer.assign("sandbox-agent", Ring::Sandboxed); + assert_eq!(enforcer.get_ring("admin-agent"), Some(Ring::Admin)); + assert_eq!(enforcer.get_ring("sandbox-agent"), Some(Ring::Sandboxed)); + } + + #[test] + fn test_reassign_ring_overrides() { + let mut enforcer = RingEnforcer::new(); + enforcer.assign("agent", Ring::Admin); + assert!(enforcer.check_access("agent", "anything")); + + enforcer.assign("agent", Ring::Sandboxed); + assert!(!enforcer.check_access("agent", "anything")); + } + + #[test] + fn test_standard_ring_no_permissions_denies() { + let mut enforcer = RingEnforcer::new(); + enforcer.assign("agent", Ring::Standard); + // No permissions configured for Standard → deny + assert!(!enforcer.check_access("agent", "data.read")); + } + + #[test] + fn test_ring_ordering() { + assert!(Ring::Admin < Ring::Standard); + assert!(Ring::Standard < Ring::Restricted); + assert!(Ring::Restricted < Ring::Sandboxed); + } + + #[test] + fn test_ring_serde_roundtrip() { + let ring = Ring::Restricted; + let json = serde_json::to_string(&ring).unwrap(); + let deserialized: Ring = serde_json::from_str(&json).unwrap(); + assert_eq!(ring, deserialized); + } + + #[test] + fn test_multiple_agents_different_rings() { + let mut enforcer = RingEnforcer::new(); + enforcer.assign("admin", Ring::Admin); + enforcer.assign("standard", Ring::Standard); + enforcer.assign("restricted", Ring::Restricted); + enforcer.assign("sandboxed", Ring::Sandboxed); + + enforcer.set_ring_permissions(Ring::Standard, vec!["data.read".to_string()]); + enforcer.set_ring_permissions(Ring::Restricted, vec!["data.read".to_string()]); + + assert!(enforcer.check_access("admin", "shell:rm")); + assert!(enforcer.check_access("standard", "data.read")); + assert!(!enforcer.check_access("standard", "shell:rm")); + assert!(enforcer.check_access("restricted", "data.read")); + assert!(!enforcer.check_access("restricted", "shell:rm")); + assert!(!enforcer.check_access("sandboxed", "data.read")); + } + + #[test] + fn test_set_ring_permissions_replaces_previous() { + let mut enforcer = RingEnforcer::new(); + enforcer.assign("agent", Ring::Standard); + enforcer.set_ring_permissions(Ring::Standard, vec!["data.read".to_string()]); + assert!(enforcer.check_access("agent", "data.read")); + + enforcer.set_ring_permissions(Ring::Standard, vec!["data.write".to_string()]); + assert!(!enforcer.check_access("agent", "data.read")); + assert!(enforcer.check_access("agent", "data.write")); + } + + #[test] + fn test_default_impl() { + let enforcer = RingEnforcer::default(); + assert_eq!(enforcer.get_ring("any"), None); + } +}