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); + } +} 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); + } +}