diff --git a/README.md b/README.md index cf42ba99..b23926c3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Start here: - **Gateway** with chat UI, admin UI, OpenAI-compatible endpoints, MCP, websocket, health, and diagnostics - **Passive Harness Contracts** for inspectable agent-work plans without changing default chat or approval behavior - **Passive Evidence Bundles** for inspectable run evidence, checks, risks, and human review without default runtime interception +- **Passive Governance Ledger** for durable approval and oversight decisions without auto-approving future actions - **First-class optional Microsoft Agent Framework adapter** for `Runtime.Orchestrator=maf` without a special build - **Durable workflow delegation** through supported workflow backends such as `maf-durable-http` - **CLI and Companion** setup flows for source checkouts and desktop bundles diff --git a/docs/GOVERNANCE_LEDGER.md b/docs/GOVERNANCE_LEDGER.md new file mode 100644 index 00000000..8ad389bd --- /dev/null +++ b/docs/GOVERNANCE_LEDGER.md @@ -0,0 +1,61 @@ +# Governance Ledger + +The Governance Ledger records approval and oversight decisions as durable harness state. It helps operators inspect what was approved or rejected, who made the decision, why, what scope applied, what risk level was involved, and which session, Harness Contract, Evidence Bundle, learning proposal, or approval request the decision related to. + +Governance Ledger entries are passive in this release. They do not change normal chat behavior, provider behavior, quickstart, tool execution, approval semantics, memory behavior, Companion setup, MCP routes, or OpenAI-compatible routes. + +## Relationship to Harness State + +Harness Contract = intended work. + +Evidence Bundle = what happened, what was checked, what remains uncertain, and why the result should or should not be trusted. + +Governance Ledger = human/operator decision history. + +The ledger is separate from the existing approval prompt itself. It records the decision after the existing approval flow decides, so approval failures, denials, timeouts, and requester checks keep their current behavior. + +It is also separate from reusable approval grants. A grant may be consumed by existing approval-grant logic, and the ledger can record that fact, but the ledger does not create or apply grants. + +## Example + +```json +{ + "id": "gov_approval_001", + "decision": "approved", + "status": "active", + "source": "tool_approval", + "actionType": "execute", + "toolName": "shell", + "actionSummary": "Operator approved a shell command after reviewing arguments.", + "argumentSummary": "{\"cmd\":\"dotnet test\"}", + "redactedArguments": "{\"cmd\":\"dotnet test\"}", + "riskLevel": "high", + "scope": "once", + "scopeKey": "apr_123", + "sessionId": "sess_123", + "harnessContractId": "hctr_123", + "evidenceBundleId": "evb_123", + "approvalId": "apr_123", + "channelId": "web", + "senderId": "operator", + "decidedBy": "operator", + "decisionReason": "Tests and rollback plan were reviewed.", + "policyHint": { + "suggestedFutureBehavior": "consider_reusable_grant", + "suggestedScope": "session", + "confidence": "medium", + "requiresReview": true, + "notes": "Informational only; future automation must be explicit." + }, + "tags": ["approval", "governance"] +} +``` + +## What This Does Not Do Yet + +- It does not auto-approve future actions. +- It does not replace existing tool approvals or requester-match checks. +- It does not weaken safety behavior. +- It does not enable full Plan-Execute-Verify mode. +- It does not automatically create Evidence Bundles for every approval. +- It does not use `policyHint` for enforcement; future policy automation must be explicit and opt-in. diff --git a/docs/README.md b/docs/README.md index 15b5e097..2a046722 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,6 +36,7 @@ Use this page as the map. If you are unsure where to go next, the groups below a | [PULSE.md](PULSE.md) | Runtime Pulse scheduled heartbeat turns, `HEARTBEAT.md`, alert suppression, and operator controls. | | [HARNESS_CONTRACTS.md](HARNESS_CONTRACTS.md) | Passive, inspectable Harness Contract records for future plan-execute-verify and evidence workflows. | | [EVIDENCE_BUNDLES.md](EVIDENCE_BUNDLES.md) | Passive evidence records for what happened, what was checked, remaining uncertainty, and operator trust. | +| [GOVERNANCE_LEDGER.md](GOVERNANCE_LEDGER.md) | Passive approval and oversight decision history as durable harness state. | | [governance/sidecar-pattern.md](governance/sidecar-pattern.md) | Optional central tool-governance middleware, sidecar flow, decisions, and audit fields. | | [governance/microsoft-agent-governance.md](governance/microsoft-agent-governance.md) | Microsoft Agent Governance sidecar integration notes and deployment cautions. | | [deployment/TAILSCALE.md](deployment/TAILSCALE.md) | Optional Tailscale Serve private runtime access guidance. | diff --git a/src/OpenClaw.Core/Abstractions/IGovernanceLedgerStore.cs b/src/OpenClaw.Core/Abstractions/IGovernanceLedgerStore.cs new file mode 100644 index 00000000..cfef895e --- /dev/null +++ b/src/OpenClaw.Core/Abstractions/IGovernanceLedgerStore.cs @@ -0,0 +1,11 @@ +using OpenClaw.Core.Models; + +namespace OpenClaw.Core.Abstractions; + +public interface IGovernanceLedgerStore +{ + ValueTask SaveAsync(GovernanceLedgerEntry entry, CancellationToken ct); + ValueTask GetAsync(string id, CancellationToken ct); + ValueTask> ListAsync(GovernanceLedgerListQuery query, CancellationToken ct); + ValueTask RevokeAsync(string id, string revokedBy, string reason, CancellationToken ct); +} diff --git a/src/OpenClaw.Core/Features/FileGovernanceLedgerStore.cs b/src/OpenClaw.Core/Features/FileGovernanceLedgerStore.cs new file mode 100644 index 00000000..7fe5b9aa --- /dev/null +++ b/src/OpenClaw.Core/Features/FileGovernanceLedgerStore.cs @@ -0,0 +1,284 @@ +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; + +namespace OpenClaw.Core.Features; + +public sealed class FileGovernanceLedgerStore : IGovernanceLedgerStore +{ + private readonly string _ledgerPath; + private readonly string _ledgerPathPrefix; + + public FileGovernanceLedgerStore(string storagePath) + { + var root = Path.GetFullPath(storagePath); + _ledgerPath = Path.GetFullPath(Path.Join(root, "harness", "governance")); + _ledgerPathPrefix = _ledgerPath.EndsWith(Path.DirectorySeparatorChar) + ? _ledgerPath + : _ledgerPath + Path.DirectorySeparatorChar; + Directory.CreateDirectory(_ledgerPath); + } + + public ValueTask SaveAsync(GovernanceLedgerEntry entry, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(entry); + EnsureSafeId(entry.Id); + return SaveOneAsync(FileForId(entry.Id), entry, ct); + } + + public ValueTask GetAsync(string id, CancellationToken ct) + { + EnsureSafeId(id); + return LoadOneAsync(FileForId(id), ct); + } + + public async ValueTask> ListAsync(GovernanceLedgerListQuery query, CancellationToken ct) + { + query ??= new GovernanceLedgerListQuery(); + var results = new List(); + IEnumerable files; + try + { + files = new DirectoryInfo(_ledgerPath).EnumerateFiles("*.json"); + } + catch (DirectoryNotFoundException) + { + return []; + } + catch (UnauthorizedAccessException) + { + return []; + } + catch (IOException) + { + return []; + } + + foreach (var file in files) + { + ct.ThrowIfCancellationRequested(); + try + { + var entry = await LoadOneAsync(file, ct); + if (entry is not null && Matches(entry, query)) + results.Add(entry); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + Trace.TraceWarning("Skipping invalid governance ledger file '{0}': {1}", file.FullName, ex.Message); + } + } + + var ordered = results + .OrderByDescending(static item => item.UpdatedAtUtc) + .ThenByDescending(static item => item.CreatedAtUtc); + return query.Limit <= 0 + ? ordered.ToArray() + : ordered.Take(Math.Clamp(query.Limit, 1, 5000)).ToArray(); + } + + public async ValueTask RevokeAsync(string id, string revokedBy, string reason, CancellationToken ct) + { + EnsureSafeId(id); + if (string.IsNullOrWhiteSpace(revokedBy)) + throw new ArgumentException("Governance ledger revocation actor is required.", nameof(revokedBy)); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("Governance ledger revocation reason is required.", nameof(reason)); + + var existing = await LoadOneAsync(FileForId(id), ct); + if (existing is null) + return null; + + var now = DateTimeOffset.UtcNow; + var revoked = new GovernanceLedgerEntry + { + Id = existing.Id, + CreatedAtUtc = existing.CreatedAtUtc, + UpdatedAtUtc = now, + Decision = existing.Decision, + Status = GovernanceDecisionStatuses.Revoked, + Source = existing.Source, + ActionType = existing.ActionType, + ToolName = existing.ToolName, + ActionSummary = existing.ActionSummary, + ArgumentSummary = existing.ArgumentSummary, + RedactedArguments = existing.RedactedArguments, + RiskLevel = existing.RiskLevel, + Scope = existing.Scope, + ScopeKey = existing.ScopeKey, + SessionId = existing.SessionId, + HarnessContractId = existing.HarnessContractId, + EvidenceBundleId = existing.EvidenceBundleId, + LearningProposalId = existing.LearningProposalId, + ApprovalId = existing.ApprovalId, + ActorId = existing.ActorId, + ChannelId = existing.ChannelId, + SenderId = existing.SenderId, + DecidedBy = existing.DecidedBy, + DecisionReason = existing.DecisionReason, + ExpiresAtUtc = existing.ExpiresAtUtc, + RevokedAtUtc = now, + RevokedBy = revokedBy.Trim(), + RevocationReason = reason.Trim(), + PolicyHint = existing.PolicyHint, + Tags = existing.Tags, + Metadata = existing.Metadata + }; + await SaveOneAsync(FileForId(id), revoked, ct); + return revoked; + } + + private FileInfo FileForId(string id) + { + var expectedFileName = $"{EncodeKey(id)}.json"; + var fileName = Path.GetFileName(expectedFileName); + if (string.IsNullOrWhiteSpace(fileName) || !string.Equals(fileName, expectedFileName, StringComparison.Ordinal)) + throw new ArgumentException("Governance ledger id resolves to an unsafe file name.", nameof(id)); + + var path = Path.GetFullPath(Path.Join(_ledgerPath, fileName)); + if (!path.StartsWith(_ledgerPathPrefix, StringComparison.Ordinal)) + throw new ArgumentException("Governance ledger id resolves outside the ledger store.", nameof(id)); + + return new FileInfo(path); + } + + private static bool Matches(GovernanceLedgerEntry entry, GovernanceLedgerListQuery query) + { + if (!string.IsNullOrWhiteSpace(query.Decision) && + !string.Equals(entry.Decision, query.Decision, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.IsNullOrWhiteSpace(query.Status) && + !string.Equals(entry.Status, query.Status, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.IsNullOrWhiteSpace(query.ToolName) && + !string.Equals(entry.ToolName, query.ToolName, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.IsNullOrWhiteSpace(query.ActionType) && + !string.Equals(entry.ActionType, query.ActionType, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.IsNullOrWhiteSpace(query.RiskLevel) && + !string.Equals(entry.RiskLevel, query.RiskLevel, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.IsNullOrWhiteSpace(query.Scope) && + !string.Equals(entry.Scope, query.Scope, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.IsNullOrWhiteSpace(query.SessionId) && + !string.Equals(entry.SessionId, query.SessionId, StringComparison.Ordinal)) + return false; + + if (!string.IsNullOrWhiteSpace(query.ActorId) && + !string.Equals(entry.ActorId, query.ActorId, StringComparison.Ordinal)) + return false; + + if (!string.IsNullOrWhiteSpace(query.ChannelId) && + !string.Equals(entry.ChannelId, query.ChannelId, StringComparison.Ordinal)) + return false; + + if (!string.IsNullOrWhiteSpace(query.DecidedBy) && + !string.Equals(entry.DecidedBy, query.DecidedBy, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.IsNullOrWhiteSpace(query.Tag) && + (entry.Tags?.Any(tag => string.Equals(tag, query.Tag, StringComparison.OrdinalIgnoreCase)) != true)) + return false; + + if (query.CreatedFromUtc is { } fromUtc && entry.CreatedAtUtc < fromUtc) + return false; + + if (query.CreatedToUtc is { } toUtc && entry.CreatedAtUtc > toUtc) + return false; + + return true; + } + + private static async ValueTask LoadOneAsync(FileInfo file, CancellationToken ct) + { + if (!file.Exists) + return default; + + try + { + await using var stream = file.OpenRead(); + return await JsonSerializer.DeserializeAsync(stream, CoreJsonContext.Default.GovernanceLedgerEntry, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (JsonException) + { + return default; + } + catch (IOException) + { + return default; + } + catch (UnauthorizedAccessException) + { + return default; + } + } + + private static async ValueTask SaveOneAsync(FileInfo file, GovernanceLedgerEntry entry, CancellationToken ct) + { + file.Directory?.Create(); + var tempFile = new FileInfo($"{file.FullName}.{Guid.NewGuid():N}.tmp"); + var tempPath = tempFile.FullName; + try + { + await using (var stream = tempFile.Open(FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + await JsonSerializer.SerializeAsync(stream, entry, CoreJsonContext.Default.GovernanceLedgerEntry, ct); + } + + tempFile.MoveTo(file.FullName, overwrite: true); + } + finally + { + var cleanupFile = new FileInfo(tempPath); + try + { + if (cleanupFile.Exists) + cleanupFile.Delete(); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + Trace.TraceWarning("Failed to delete temp governance ledger file '{0}': {1}", cleanupFile.FullName, ex); + } + } + } + + private static void EnsureSafeId(string id) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Governance ledger id is required.", nameof(id)); + + if (id.Length > 128) + throw new ArgumentException("Governance ledger id is too long.", nameof(id)); + + if (!id.All(static ch => char.IsLetterOrDigit(ch) || ch is '_' or '-' or '.')) + throw new ArgumentException("Governance ledger id contains unsafe characters.", nameof(id)); + } + + private static string EncodeKey(string key) + { + var bytes = Encoding.UTF8.GetBytes(key); + return Convert.ToBase64String(bytes) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + } +} diff --git a/src/OpenClaw.Core/Models/GovernanceLedgerModels.cs b/src/OpenClaw.Core/Models/GovernanceLedgerModels.cs new file mode 100644 index 00000000..a74003b4 --- /dev/null +++ b/src/OpenClaw.Core/Models/GovernanceLedgerModels.cs @@ -0,0 +1,145 @@ +namespace OpenClaw.Core.Models; + +public static class GovernanceDecisions +{ + public const string Approved = "approved"; + public const string Rejected = "rejected"; + public const string Escalated = "escalated"; + public const string Expired = "expired"; + public const string Revoked = "revoked"; + public const string Unknown = "unknown"; +} + +public static class GovernanceDecisionStatuses +{ + public const string Active = "active"; + public const string Expired = "expired"; + public const string Revoked = "revoked"; + public const string Superseded = "superseded"; +} + +public static class GovernanceScopes +{ + public const string Once = "once"; + public const string Session = "session"; + public const string Actor = "actor"; + public const string Channel = "channel"; + public const string Project = "project"; + public const string Tool = "tool"; + public const string Global = "global"; + public const string Unknown = "unknown"; +} + +public static class GovernanceRiskLevels +{ + public const string Unknown = "unknown"; + public const string Low = "low"; + public const string Medium = "medium"; + public const string High = "high"; + public const string Critical = "critical"; +} + +public static class GovernanceLedgerSources +{ + public const string Manual = "manual"; + public const string ToolApproval = "tool_approval"; + public const string ApprovalTimeout = "approval_timeout"; + public const string ApprovalGrantConsumed = "approval_grant_consumed"; + public const string HarnessContract = "harness_contract"; + public const string EvidenceReview = "evidence_review"; + public const string LearningProposal = "learning_proposal"; + public const string Unknown = "unknown"; +} + +public sealed class GovernanceLedgerEntry +{ + public string Id { get; init; } = ""; + public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public string Decision { get; init; } = GovernanceDecisions.Unknown; + public string Status { get; init; } = GovernanceDecisionStatuses.Active; + public string Source { get; init; } = GovernanceLedgerSources.Manual; + public string? ActionType { get; init; } + public string? ToolName { get; init; } + public string ActionSummary { get; init; } = ""; + public string? ArgumentSummary { get; init; } + public string? RedactedArguments { get; init; } + public string RiskLevel { get; init; } = GovernanceRiskLevels.Unknown; + public string Scope { get; init; } = GovernanceScopes.Unknown; + public string? ScopeKey { get; init; } + public string? SessionId { get; init; } + public string? HarnessContractId { get; init; } + public string? EvidenceBundleId { get; init; } + public string? LearningProposalId { get; init; } + public string? ApprovalId { get; init; } + public string? ActorId { get; init; } + public string? ChannelId { get; init; } + public string? SenderId { get; init; } + public string? DecidedBy { get; init; } + public string? DecisionReason { get; init; } + public DateTimeOffset? ExpiresAtUtc { get; init; } + public DateTimeOffset? RevokedAtUtc { get; init; } + public string? RevokedBy { get; init; } + public string? RevocationReason { get; init; } + public GovernancePolicyHint? PolicyHint { get; init; } + public IReadOnlyList Tags { get; init; } = []; + public GovernanceLedgerMetadata? Metadata { get; init; } +} + +public sealed class GovernancePolicyHint +{ + public string? SuggestedFutureBehavior { get; init; } + public string? SuggestedScope { get; init; } + public string? Confidence { get; init; } + public bool RequiresReview { get; init; } = true; + public string? Notes { get; init; } +} + +public sealed class GovernanceLedgerMetadata +{ + public string? CreatedBy { get; init; } + public string? CorrelationId { get; init; } + public Dictionary Properties { get; init; } = []; +} + +public sealed class GovernanceLedgerListQuery +{ + public string? Decision { get; init; } + public string? Status { get; init; } + public string? ToolName { get; init; } + public string? ActionType { get; init; } + public string? RiskLevel { get; init; } + public string? Scope { get; init; } + public string? SessionId { get; init; } + public string? ActorId { get; init; } + public string? ChannelId { get; init; } + public string? DecidedBy { get; init; } + public string? Tag { get; init; } + public DateTimeOffset? CreatedFromUtc { get; init; } + public DateTimeOffset? CreatedToUtc { get; init; } + public int Limit { get; init; } = 100; +} + +public sealed class GovernanceLedgerRevokeRequest +{ + public string? RevokedBy { get; init; } + public string? Reason { get; init; } +} + +public sealed class GovernanceLedgerListResponse +{ + public IReadOnlyList Items { get; init; } = []; +} + +public sealed class GovernanceLedgerDetailResponse +{ + public GovernanceLedgerEntry? Entry { get; init; } +} + +public sealed class GovernanceLedgerMutationResponse +{ + public bool Success { get; init; } + public GovernanceLedgerEntry? Entry { get; init; } + public string Message { get; init; } = ""; + public string? Error { get; init; } +} diff --git a/src/OpenClaw.Core/Models/OperatorGovernanceModels.cs b/src/OpenClaw.Core/Models/OperatorGovernanceModels.cs index cb62af8d..c14bd94d 100644 --- a/src/OpenClaw.Core/Models/OperatorGovernanceModels.cs +++ b/src/OpenClaw.Core/Models/OperatorGovernanceModels.cs @@ -469,6 +469,7 @@ public sealed class TrajectoryExportRecord public string? FailureCode { get; init; } public string? FailureMessage { get; init; } public EvidenceBundle? EvidenceBundle { get; init; } + public GovernanceLedgerEntry? GovernanceLedgerEntry { get; init; } public bool Anonymized { get; init; } } diff --git a/src/OpenClaw.Core/Models/Session.cs b/src/OpenClaw.Core/Models/Session.cs index d248c044..b2fe165d 100644 --- a/src/OpenClaw.Core/Models/Session.cs +++ b/src/OpenClaw.Core/Models/Session.cs @@ -603,6 +603,15 @@ public sealed class SessionDelegationChildSummary [JsonSerializable(typeof(EvidenceBundleListResponse))] [JsonSerializable(typeof(EvidenceBundleDetailResponse))] [JsonSerializable(typeof(EvidenceBundleMutationResponse))] +[JsonSerializable(typeof(GovernanceLedgerEntry))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(GovernancePolicyHint))] +[JsonSerializable(typeof(GovernanceLedgerMetadata))] +[JsonSerializable(typeof(GovernanceLedgerListQuery))] +[JsonSerializable(typeof(GovernanceLedgerRevokeRequest))] +[JsonSerializable(typeof(GovernanceLedgerListResponse))] +[JsonSerializable(typeof(GovernanceLedgerDetailResponse))] +[JsonSerializable(typeof(GovernanceLedgerMutationResponse))] [JsonSerializable(typeof(WebhooksConfig))] [JsonSerializable(typeof(WebhookEndpointConfig))] [JsonSerializable(typeof(List))] diff --git a/src/OpenClaw.Gateway/AdminObservabilityService.cs b/src/OpenClaw.Gateway/AdminObservabilityService.cs index 63202cec..119219f8 100644 --- a/src/OpenClaw.Gateway/AdminObservabilityService.cs +++ b/src/OpenClaw.Gateway/AdminObservabilityService.cs @@ -28,6 +28,7 @@ internal sealed class AdminObservabilityService private readonly ISessionAdminStore _sessionAdminStore; private readonly IRedactionPipeline _redaction; private readonly EvidenceBundleService? _evidenceBundles; + private readonly GovernanceLedgerService? _governanceLedger; public AdminObservabilityService( GatewayStartupContext startup, @@ -37,7 +38,8 @@ public AdminObservabilityService( ToolUsageTracker toolUsage, ISessionAdminStore sessionAdminStore, IRedactionPipeline? redaction = null, - EvidenceBundleService? evidenceBundles = null) + EvidenceBundleService? evidenceBundles = null, + GovernanceLedgerService? governanceLedger = null) { _startup = startup; _runtime = runtime; @@ -47,6 +49,7 @@ public AdminObservabilityService( _sessionAdminStore = sessionAdminStore; _redaction = redaction ?? new NoopRedactionPipeline(); _evidenceBundles = evidenceBundles; + _governanceLedger = governanceLedger; } public async Task BuildInsightsAsync( @@ -226,6 +229,7 @@ public async Task BuildSeriesAsync( public async Task ExportAuditBundleAsync( DateTimeOffset? fromUtc, DateTimeOffset? toUtc, + bool includeGovernance, CancellationToken ct) { var (startUtc, endUtc, warnings) = NormalizeRange(fromUtc, toUtc, defaultWindow: TimeSpan.FromDays(30), applyRetention: true); @@ -238,8 +242,9 @@ public async Task ExportAuditBundleAsync( var sessionMetadata = _runtime.Operations.SessionMetadata.GetAll().Values .OrderBy(static item => item.SessionId, StringComparer.OrdinalIgnoreCase) .ToList(); + var governance = await LoadGovernanceForRangeAsync(startUtc, endUtc, includeGovernance, ct); var policy = _organizationPolicy.GetSnapshot(); - var files = new[] + var files = new List { "manifest.json", "operator-audit.jsonl", @@ -250,6 +255,21 @@ public async Task ExportAuditBundleAsync( "dead-letter.jsonl", "session-metadata.json" }; + if (includeGovernance) + files.Add("governance-ledger.jsonl"); + + var fileEntryCounts = new Dictionary(StringComparer.Ordinal) + { + ["operator-audit.jsonl"] = operatorAudit.Count, + ["runtime-events.jsonl"] = runtimeEvents.Count, + ["approval-history.jsonl"] = approvals.Count, + ["provider-usage.json"] = providerUsage.Count, + ["provider-routes.json"] = providerRoutes.Count, + ["dead-letter.jsonl"] = deadLetters.Count, + ["session-metadata.json"] = sessionMetadata.Count + }; + if (includeGovernance) + fileEntryCounts["governance-ledger.jsonl"] = governance.Count; var manifest = new AuditExportManifest { @@ -263,16 +283,7 @@ public async Task ExportAuditBundleAsync( OperatorAuditSequenceEnd = operatorAudit.LastOrDefault()?.Sequence, OperatorAuditPreviousEntryHash = operatorAudit.FirstOrDefault()?.PreviousEntryHash, OperatorAuditLastEntryHash = operatorAudit.LastOrDefault()?.EntryHash, - FileEntryCounts = new Dictionary(StringComparer.Ordinal) - { - ["operator-audit.jsonl"] = operatorAudit.Count, - ["runtime-events.jsonl"] = runtimeEvents.Count, - ["approval-history.jsonl"] = approvals.Count, - ["provider-usage.json"] = providerUsage.Count, - ["provider-routes.json"] = providerRoutes.Count, - ["dead-letter.jsonl"] = deadLetters.Count, - ["session-metadata.json"] = sessionMetadata.Count - }, + FileEntryCounts = fileEntryCounts, Warnings = warnings }; @@ -283,6 +294,8 @@ public async Task ExportAuditBundleAsync( WriteJsonlEntry(zip, "operator-audit.jsonl", operatorAudit, CoreJsonContext.Default.OperatorAuditEntry); WriteJsonlEntry(zip, "runtime-events.jsonl", runtimeEvents, CoreJsonContext.Default.RuntimeEventEntry); WriteJsonlEntry(zip, "approval-history.jsonl", approvals, CoreJsonContext.Default.ApprovalHistoryEntry); + if (includeGovernance) + WriteJsonlEntry(zip, "governance-ledger.jsonl", governance, CoreJsonContext.Default.GovernanceLedgerEntry); WriteJsonEntry(zip, "provider-usage.json", providerUsage, CoreJsonContext.Default.ListProviderUsageSnapshot); WriteJsonEntry(zip, "provider-routes.json", providerRoutes, CoreJsonContext.Default.ListProviderRouteHealthSnapshot); WriteJsonlEntry(zip, "dead-letter.jsonl", deadLetters, CoreJsonContext.Default.WebhookDeadLetterEntry); @@ -299,6 +312,7 @@ public async Task ExportTrajectoryJsonlAsync( string? sessionId, bool anonymize, bool includeEvidence, + bool includeGovernance, CancellationToken ct) { DateTimeOffset startUtc; @@ -318,6 +332,7 @@ public async Task ExportTrajectoryJsonlAsync( .ThenBy(static item => item.Id, StringComparer.Ordinal) .ToArray(); var evidenceBySession = await LoadEvidenceBySessionAsync(sessions, startUtc, endUtc, includeEvidence, ct); + var governanceBySession = await LoadGovernanceBySessionAsync(sessions, startUtc, endUtc, includeGovernance, ct); await using var ms = new MemoryStream(); await using (var writer = new StreamWriter(ms, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: true)) { @@ -350,12 +365,74 @@ public async Task ExportTrajectoryJsonlAsync( await WriteTrajectoryRecordAsync(writer, BuildEvidenceTrajectoryRecord(session, bundle, anonymize), ct); } } + + if (governanceBySession.TryGetValue(session.Id, out var governance)) + { + foreach (var entry in governance) + { + await WriteTrajectoryRecordAsync(writer, BuildGovernanceTrajectoryRecord(session, entry, anonymize), ct); + } + } } } return ms.ToArray(); } + private async Task> LoadGovernanceForRangeAsync( + DateTimeOffset startUtc, + DateTimeOffset endUtc, + bool includeGovernance, + CancellationToken ct) + { + if (!includeGovernance || _governanceLedger is null) + return []; + + return await _governanceLedger.ListAsync(new GovernanceLedgerListQuery + { + CreatedFromUtc = startUtc, + CreatedToUtc = endUtc, + Limit = 0 + }, ct); + } + + private async Task>> LoadGovernanceBySessionAsync( + IReadOnlyList sessions, + DateTimeOffset startUtc, + DateTimeOffset endUtc, + bool includeGovernance, + CancellationToken ct) + { + if (!includeGovernance || _governanceLedger is null || sessions.Count == 0) + return new Dictionary>(StringComparer.Ordinal); + + var sessionIds = sessions + .Select(static session => session.Id) + .Where(static id => !string.IsNullOrWhiteSpace(id)) + .ToHashSet(StringComparer.Ordinal); + if (sessionIds.Count == 0) + return new Dictionary>(StringComparer.Ordinal); + + var query = new GovernanceLedgerListQuery + { + SessionId = sessions.Count == 1 ? sessions[0].Id : null, + CreatedFromUtc = startUtc == DateTimeOffset.MinValue ? null : startUtc, + CreatedToUtc = endUtc, + Limit = 0 + }; + var governance = await _governanceLedger.ListAsync(query, ct); + return governance + .Where(entry => !string.IsNullOrWhiteSpace(entry.SessionId) && sessionIds.Contains(entry.SessionId)) + .GroupBy(static entry => entry.SessionId!, StringComparer.Ordinal) + .ToDictionary( + static group => group.Key, + static group => (IReadOnlyList)group + .OrderBy(static item => item.CreatedAtUtc) + .ThenBy(static item => item.Id, StringComparer.Ordinal) + .ToArray(), + StringComparer.Ordinal); + } + private async Task>> LoadEvidenceBySessionAsync( IReadOnlyList sessions, DateTimeOffset startUtc, @@ -536,6 +613,83 @@ private TrajectoryExportRecord BuildEvidenceTrajectoryRecord(Session session, Ev Anonymized = anonymize }; + private TrajectoryExportRecord BuildGovernanceTrajectoryRecord(Session session, GovernanceLedgerEntry entry, bool anonymize) + => new() + { + Type = "governance_ledger_entry", + TimestampUtc = entry.UpdatedAtUtc == default ? entry.CreatedAtUtc : entry.UpdatedAtUtc, + SessionId = ExportSessionId(session.Id, anonymize), + ChannelId = ExportSessionId(session.ChannelId, anonymize), + SenderId = ExportSessionId(session.SenderId, anonymize), + TurnIndex = -1, + GovernanceLedgerEntry = ExportGovernanceLedgerEntry(entry, anonymize), + Anonymized = anonymize + }; + + private GovernanceLedgerEntry ExportGovernanceLedgerEntry(GovernanceLedgerEntry entry, bool anonymize) + { + if (!anonymize) + return entry; + + return new GovernanceLedgerEntry + { + Id = ExportSessionId(entry.Id, anonymize), + CreatedAtUtc = entry.CreatedAtUtc, + UpdatedAtUtc = entry.UpdatedAtUtc, + Decision = entry.Decision, + Status = entry.Status, + Source = entry.Source, + ActionType = ExportText(entry.ActionType, anonymize, _redaction), + ToolName = entry.ToolName, + ActionSummary = ExportText(entry.ActionSummary, anonymize, _redaction) ?? "", + ArgumentSummary = ExportText(entry.ArgumentSummary, anonymize, _redaction), + RedactedArguments = ExportText(entry.RedactedArguments, anonymize, _redaction), + RiskLevel = entry.RiskLevel, + Scope = entry.Scope, + ScopeKey = ExportOptionalId(entry.ScopeKey, anonymize), + SessionId = ExportOptionalId(entry.SessionId, anonymize), + HarnessContractId = ExportOptionalId(entry.HarnessContractId, anonymize), + EvidenceBundleId = ExportOptionalId(entry.EvidenceBundleId, anonymize), + LearningProposalId = ExportOptionalId(entry.LearningProposalId, anonymize), + ApprovalId = ExportOptionalId(entry.ApprovalId, anonymize), + ActorId = ExportOptionalId(entry.ActorId, anonymize), + ChannelId = ExportOptionalId(entry.ChannelId, anonymize), + SenderId = ExportOptionalId(entry.SenderId, anonymize), + DecidedBy = ExportOptionalId(entry.DecidedBy, anonymize), + DecisionReason = ExportText(entry.DecisionReason, anonymize, _redaction), + ExpiresAtUtc = entry.ExpiresAtUtc, + RevokedAtUtc = entry.RevokedAtUtc, + RevokedBy = ExportOptionalId(entry.RevokedBy, anonymize), + RevocationReason = ExportText(entry.RevocationReason, anonymize, _redaction), + PolicyHint = ExportGovernancePolicyHint(entry.PolicyHint, anonymize), + Tags = entry.Tags + .Select(tag => ExportText(tag, anonymize, _redaction)) + .Where(static tag => !string.IsNullOrWhiteSpace(tag)) + .Select(static tag => tag!) + .ToArray(), + Metadata = entry.Metadata is null + ? null + : new GovernanceLedgerMetadata + { + CreatedBy = ExportOptionalId(entry.Metadata.CreatedBy, anonymize), + CorrelationId = ExportOptionalId(entry.Metadata.CorrelationId, anonymize), + Properties = ExportStringDictionary(entry.Metadata.Properties, anonymize) + } + }; + } + + private GovernancePolicyHint? ExportGovernancePolicyHint(GovernancePolicyHint? hint, bool anonymize) + => hint is null + ? null + : new GovernancePolicyHint + { + SuggestedFutureBehavior = ExportText(hint.SuggestedFutureBehavior, anonymize, _redaction), + SuggestedScope = hint.SuggestedScope, + Confidence = hint.Confidence, + RequiresReview = hint.RequiresReview, + Notes = ExportText(hint.Notes, anonymize, _redaction) + }; + private EvidenceBundle ExportEvidenceBundle(EvidenceBundle bundle, bool anonymize) { if (!anonymize) diff --git a/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs b/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs index be4fbfac..40eee01f 100644 --- a/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs +++ b/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs @@ -189,6 +189,7 @@ public static IServiceCollection AddOpenClawCoreServices(this IServiceCollection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => @@ -241,6 +242,8 @@ private static void AddFeatureStores(IServiceCollection services, GatewayConfig services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(_ => new FileEvidenceBundleStore(config.Memory.StoragePath)); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(_ => new FileGovernanceLedgerStore(config.Memory.StoragePath)); + services.AddSingleton(sp => sp.GetRequiredService()); if (string.Equals(config.Memory.Provider, "sqlite", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/OpenClaw.Gateway/Composition/FeatureFallbackServices.cs b/src/OpenClaw.Gateway/Composition/FeatureFallbackServices.cs index 03884f57..c0dbec0e 100644 --- a/src/OpenClaw.Gateway/Composition/FeatureFallbackServices.cs +++ b/src/OpenClaw.Gateway/Composition/FeatureFallbackServices.cs @@ -4,6 +4,7 @@ using OpenClaw.Core.Features; using OpenClaw.Core.Models; using OpenClaw.Core.Observability; +using OpenClaw.Core.Security; using OpenClaw.Gateway; using OpenClaw.Gateway.Bootstrap; @@ -78,6 +79,19 @@ public static EvidenceBundleService ResolveEvidenceBundleService( services.GetService() ?? new RuntimeEventStore(startup.Config.Memory.StoragePath, NullLogger.Instance), NullLogger.Instance); + + public static GovernanceLedgerService ResolveGovernanceLedgerService( + GatewayStartupContext startup, + IServiceProvider services) + => services.GetService() + ?? new GovernanceLedgerService( + services.GetService() + ?? new FileGovernanceLedgerStore(startup.Config.Memory.StoragePath), + services.GetService() + ?? new RuntimeEventStore(startup.Config.Memory.StoragePath, NullLogger.Instance), + services.GetService() + ?? new RedactionPipeline([new BaselineSecretRedactor()]), + NullLogger.Instance); } internal sealed class EmptySessionSearchStore : ISessionSearchStore diff --git a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.GovernanceLedger.cs b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.GovernanceLedger.cs new file mode 100644 index 00000000..3f42da27 --- /dev/null +++ b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.GovernanceLedger.cs @@ -0,0 +1,212 @@ +using System.Text.Json; +using OpenClaw.Core.Models; + +namespace OpenClaw.Gateway.Endpoints; + +internal static partial class AdminEndpoints +{ + private static void MapGovernanceLedgerEndpoints(WebApplication app, AdminEndpointServices services) + { + var startup = services.Startup; + var browserSessions = services.BrowserSessions; + var operations = services.Operations; + var governanceLedger = services.GovernanceLedger; + + app.MapGet("/admin/governance/ledger", async ( + HttpContext ctx, + string? decision = null, + string? status = null, + string? toolName = null, + string? actionType = null, + string? riskLevel = null, + string? scope = null, + string? sessionId = null, + string? actorId = null, + string? channelId = null, + string? decidedBy = null, + string? tag = null, + int limit = 100) => + { + var authResult = AuthorizeOperator(ctx, startup, browserSessions, operations, requireCsrf: false, endpointScope: "admin.governance"); + if (authResult.Failure is not null) + return authResult.Failure; + + var query = new GovernanceLedgerListQuery + { + Decision = decision, + Status = status, + ToolName = toolName, + ActionType = actionType, + RiskLevel = riskLevel, + Scope = scope, + SessionId = sessionId, + ActorId = actorId, + ChannelId = channelId, + DecidedBy = decidedBy, + Tag = tag, + CreatedFromUtc = GetQueryDateTimeOffset(ctx.Request, "createdFromUtc"), + CreatedToUtc = GetQueryDateTimeOffset(ctx.Request, "createdToUtc"), + Limit = limit + }; + var items = await governanceLedger.ListAsync(query, ctx.RequestAborted); + return Results.Json( + new GovernanceLedgerListResponse { Items = items }, + CoreJsonContext.Default.GovernanceLedgerListResponse); + }); + + app.MapGet("/admin/governance/ledger/{id}", async (HttpContext ctx, string id) => + { + var authResult = AuthorizeOperator(ctx, startup, browserSessions, operations, requireCsrf: false, endpointScope: "admin.governance"); + if (authResult.Failure is not null) + return authResult.Failure; + + try + { + var entry = await governanceLedger.GetAsync(id, ctx.RequestAborted); + if (entry is null) + { + return Results.Json( + new GovernanceLedgerMutationResponse { Success = false, Error = "Governance ledger entry not found." }, + CoreJsonContext.Default.GovernanceLedgerMutationResponse, + statusCode: StatusCodes.Status404NotFound); + } + + return Results.Json( + new GovernanceLedgerDetailResponse { Entry = entry }, + CoreJsonContext.Default.GovernanceLedgerDetailResponse); + } + catch (ArgumentException ex) + { + return Results.Json( + new GovernanceLedgerMutationResponse { Success = false, Error = ex.Message }, + CoreJsonContext.Default.GovernanceLedgerMutationResponse, + statusCode: StatusCodes.Status400BadRequest); + } + }); + + app.MapPost("/admin/governance/ledger", async (HttpContext ctx) => + { + var authResult = AuthorizeOperator(ctx, startup, browserSessions, operations, requireCsrf: true, endpointScope: "admin.governance.mutate"); + if (authResult.Failure is not null) + return authResult.Failure; + var auth = authResult.Authorization!; + + JsonBodyReadResult requestPayload; + try + { + requestPayload = await ReadJsonBodyAsync(ctx, CoreJsonContext.Default.GovernanceLedgerEntry); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + return BadGovernanceRequest("Invalid governance ledger JSON payload."); + } + + if (requestPayload.Failure is not null) + return requestPayload.Failure; + + if (requestPayload.Value is null) + { + return Results.Json( + new GovernanceLedgerMutationResponse { Success = false, Error = "Governance ledger entry payload is required." }, + CoreJsonContext.Default.GovernanceLedgerMutationResponse, + statusCode: StatusCodes.Status400BadRequest); + } + + try + { + var created = await governanceLedger.CreateAsync(requestPayload.Value, ctx.RequestAborted); + RecordOperatorAudit( + ctx, + operations, + auth, + "governance_ledger_create", + created.Id, + $"Created governance ledger entry '{created.Id}'.", + success: true, + before: null, + after: created); + + return Results.Json( + new GovernanceLedgerMutationResponse { Success = true, Entry = created, Message = "Governance ledger entry created." }, + CoreJsonContext.Default.GovernanceLedgerMutationResponse, + statusCode: StatusCodes.Status201Created); + } + catch (ArgumentException ex) + { + RecordOperatorAudit(ctx, operations, auth, "governance_ledger_create", requestPayload.Value.Id, ex.Message, success: false, before: null, after: requestPayload.Value); + return Results.Json( + new GovernanceLedgerMutationResponse { Success = false, Error = ex.Message }, + CoreJsonContext.Default.GovernanceLedgerMutationResponse, + statusCode: StatusCodes.Status400BadRequest); + } + }); + + app.MapPost("/admin/governance/ledger/{id}/revoke", async (HttpContext ctx, string id) => + { + var authResult = AuthorizeOperator(ctx, startup, browserSessions, operations, requireCsrf: true, endpointScope: "admin.governance.mutate"); + if (authResult.Failure is not null) + return authResult.Failure; + var auth = authResult.Authorization!; + + JsonBodyReadResult requestPayload; + try + { + requestPayload = await ReadJsonBodyAsync(ctx, CoreJsonContext.Default.GovernanceLedgerRevokeRequest); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + return BadGovernanceRequest("Invalid governance ledger revoke JSON payload."); + } + + if (requestPayload.Failure is not null) + return requestPayload.Failure; + + var request = requestPayload.Value ?? new GovernanceLedgerRevokeRequest(); + try + { + var before = await governanceLedger.GetAsync(id, ctx.RequestAborted); + var revoked = await governanceLedger.RevokeAsync( + id, + request.RevokedBy ?? EndpointHelpers.GetOperatorActorId(ctx, auth), + request.Reason ?? "revoked by operator", + ctx.RequestAborted); + if (revoked is null) + { + return Results.Json( + new GovernanceLedgerMutationResponse { Success = false, Error = "Governance ledger entry not found." }, + CoreJsonContext.Default.GovernanceLedgerMutationResponse, + statusCode: StatusCodes.Status404NotFound); + } + + RecordOperatorAudit( + ctx, + operations, + auth, + "governance_ledger_revoke", + revoked.Id, + $"Revoked governance ledger entry '{revoked.Id}'.", + success: true, + before, + after: revoked); + + return Results.Json( + new GovernanceLedgerMutationResponse { Success = true, Entry = revoked, Message = "Governance ledger entry revoked." }, + CoreJsonContext.Default.GovernanceLedgerMutationResponse); + } + catch (ArgumentException ex) + { + RecordOperatorAudit(ctx, operations, auth, "governance_ledger_revoke", id, ex.Message, success: false, before: null, after: request); + return Results.Json( + new GovernanceLedgerMutationResponse { Success = false, Error = ex.Message }, + CoreJsonContext.Default.GovernanceLedgerMutationResponse, + statusCode: StatusCodes.Status400BadRequest); + } + }); + } + + private static IResult BadGovernanceRequest(string message) + => Results.Json( + new GovernanceLedgerMutationResponse { Success = false, Error = message }, + CoreJsonContext.Default.GovernanceLedgerMutationResponse, + statusCode: StatusCodes.Status400BadRequest); +} diff --git a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Setup.cs b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Setup.cs index 5662e99b..100452f0 100644 --- a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Setup.cs +++ b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Setup.cs @@ -270,6 +270,7 @@ await BuildSetupStatusResponseAsync( var bytes = await observability.ExportAuditBundleAsync( GetQueryDateTimeOffset(ctx.Request, "fromUtc"), GetQueryDateTimeOffset(ctx.Request, "toUtc"), + GetQueryBool(ctx.Request, "includeGovernance") ?? false, ctx.RequestAborted); var fileName = $"openclaw-audit-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.zip"; return Results.File(bytes, "application/zip", fileName); @@ -286,12 +287,14 @@ await BuildSetupStatusResponseAsync( : null; var anonymize = GetQueryBool(ctx.Request, "anonymize") ?? false; var includeEvidence = GetQueryBool(ctx.Request, "includeEvidence") ?? false; + var includeGovernance = GetQueryBool(ctx.Request, "includeGovernance") ?? false; var bytes = await observability.ExportTrajectoryJsonlAsync( GetQueryDateTimeOffset(ctx.Request, "fromUtc"), GetQueryDateTimeOffset(ctx.Request, "toUtc"), sessionId, anonymize, includeEvidence, + includeGovernance, ctx.RequestAborted); var scope = string.IsNullOrWhiteSpace(sessionId) ? "range" : "session"; var fileName = $"openclaw-trajectory-{scope}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.jsonl"; diff --git a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs index 26b895fc..d047090f 100644 --- a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs +++ b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs @@ -55,6 +55,7 @@ private sealed class AdminEndpointServices public LearningService LearningService { get; init; } = null!; public HarnessContractService HarnessContracts { get; init; } = null!; public EvidenceBundleService EvidenceBundles { get; init; } = null!; + public GovernanceLedgerService GovernanceLedger { get; init; } = null!; public IntegrationApiFacade Facade { get; init; } = null!; public ToolPresetResolver ToolPresetResolver { get; init; } = null!; public AdminObservabilityService Observability { get; init; } = null!; diff --git a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.cs b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.cs index f7e11a3d..f6672b8b 100644 --- a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.cs +++ b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.cs @@ -50,6 +50,7 @@ public static void MapOpenClawAdminEndpoints( var learningService = FeatureFallbackServices.ResolveLearningService(startup, app.Services, fallbackFeatureStore); var harnessContracts = FeatureFallbackServices.ResolveHarnessContractService(startup, app.Services); var evidenceBundles = FeatureFallbackServices.ResolveEvidenceBundleService(startup, app.Services); + var governanceLedger = FeatureFallbackServices.ResolveGovernanceLedgerService(startup, app.Services); var facade = IntegrationApiFacade.Create(startup, runtime, app.Services); var sessionMetadataStore = app.Services.GetService() ?? new SessionMetadataStore(startup.Config.Memory.StoragePath, NullLogger.Instance); @@ -63,7 +64,8 @@ public static void MapOpenClawAdminEndpoints( app.Services.GetRequiredService(), sessionAdminStore, app.Services.GetService(), - evidenceBundles); + evidenceBundles, + governanceLedger); var maintenance = app.Services.GetService() ?? new GatewayMaintenanceRuntimeService(startup, runtime, automationService); var providerSmokeRegistry = app.Services.GetService() @@ -109,6 +111,7 @@ runtime.Operations.ModelProfiles as ConfiguredModelProfileRegistry LearningService = learningService, HarnessContracts = harnessContracts, EvidenceBundles = evidenceBundles, + GovernanceLedger = governanceLedger, Facade = facade, ToolPresetResolver = toolPresetResolver, Observability = observability, @@ -133,6 +136,7 @@ runtime.Operations.ModelProfiles as ConfiguredModelProfileRegistry MapProfilesAndLearningEndpoints(app, services); MapHarnessContractEndpoints(app, services); MapEvidenceBundleEndpoints(app, services); + MapGovernanceLedgerEndpoints(app, services); MapRuntimeEndpoints(app, services); MapExternalCliEndpoints(app, services); MapPluginAndChannelEndpoints(app, services); diff --git a/src/OpenClaw.Gateway/Endpoints/ControlEndpoints.cs b/src/OpenClaw.Gateway/Endpoints/ControlEndpoints.cs index c449266b..2f4e8cc1 100644 --- a/src/OpenClaw.Gateway/Endpoints/ControlEndpoints.cs +++ b/src/OpenClaw.Gateway/Endpoints/ControlEndpoints.cs @@ -15,6 +15,7 @@ public static void MapOpenClawControlEndpoints( GatewayAppRuntime runtime) { var browserSessions = app.Services.GetRequiredService(); + var governanceLedger = FeatureFallbackServices.ResolveGovernanceLedgerService(startup, app.Services); var operations = runtime.Operations; app.MapPost("/pairing/approve", (HttpContext ctx, string channelId, string senderId, string code) => @@ -162,7 +163,7 @@ public static void MapOpenClawControlEndpoints( CoreJsonContext.Default.SkillsReloadResponse); }); - app.MapPost("/tools/approve", (HttpContext ctx, string approvalId, bool approved, string? requesterChannelId, string? requesterSenderId) => + app.MapPost("/tools/approve", async (HttpContext ctx, string approvalId, bool approved, string? requesterChannelId, string? requesterSenderId) => { var authResult = EndpointHelpers.AuthorizeOperatorEndpoint(ctx, startup, browserSessions, operations, requireCsrf: true, endpointScope: "admin.approvals.mutate"); if (authResult.Failure is not null) @@ -203,6 +204,18 @@ public static void MapOpenClawControlEndpoints( "http_admin", auth.AuthMode == "browser-session" ? "browser" : "http", auth.AuthMode); + if (governanceLedger is not null) + { + var operatorActorId = EndpointHelpers.GetOperatorActorId(ctx, auth); + await governanceLedger.TryRecordApprovalDecisionAsync( + adminOutcome.Request, + approved, + GovernanceLedgerSources.ToolApproval, + operatorActorId, + "http_admin", + operatorActorId, + ctx.RequestAborted); + } AppendApprovalRuntimeEvent( runtime, adminOutcome.Request, @@ -264,6 +277,17 @@ public static void MapOpenClawControlEndpoints( "http_requester", requesterChannelId, requesterSenderId); + if (governanceLedger is not null) + { + await governanceLedger.TryRecordApprovalDecisionAsync( + outcome.Request, + approved, + GovernanceLedgerSources.ToolApproval, + EndpointHelpers.GetOperatorActorId(ctx, auth), + requesterChannelId, + requesterSenderId, + ctx.RequestAborted); + } AppendApprovalRuntimeEvent( runtime, outcome.Request, diff --git a/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs b/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs index 225b321a..3f18be33 100644 --- a/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs +++ b/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs @@ -276,6 +276,7 @@ private static string GetRequiredRole(string endpointScope) scope.StartsWith("admin.profiles.mutate", StringComparison.Ordinal) || scope.StartsWith("admin.learning.mutate", StringComparison.Ordinal) || scope.StartsWith("admin.harness.mutate", StringComparison.Ordinal) || + scope.StartsWith("admin.governance.mutate", StringComparison.Ordinal) || scope.StartsWith("admin.webhooks.mutate", StringComparison.Ordinal) || scope.StartsWith("admin.automations.mutate", StringComparison.Ordinal) || scope.StartsWith("admin.automations.run", StringComparison.Ordinal) || diff --git a/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs b/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs index 7e8a7d1d..bb649e70 100644 --- a/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs +++ b/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs @@ -140,7 +140,8 @@ private static void MapChatCompletionsEndpoint( runtime, session, approvalChannelId: "openai-http", - senderId: requesterKey); + senderId: requesterKey, + FeatureFallbackServices.ResolveGovernanceLedgerService(startup, app.Services)); if (ShouldHydrateRequestHistory(stableSessionId, session)) { diff --git a/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs b/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs index fd429c4b..9269bc0f 100644 --- a/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs +++ b/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs @@ -129,7 +129,8 @@ private static void MapResponsesEndpoint( runtime, session, approvalChannelId: "openai-http", - senderId: requesterKey); + senderId: requesterKey, + FeatureFallbackServices.ResolveGovernanceLedgerService(startup, app.Services)); var responseId = $"resp-{Guid.NewGuid():N}"[..24]; var createdAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); diff --git a/src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs b/src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs index 3c1e206f..25d44bdf 100644 --- a/src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs +++ b/src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs @@ -42,6 +42,7 @@ public void Start( LearningService? learningService, GatewayAutomationService? automationService, ContractGovernanceService? contractGovernance, + GovernanceLedgerService? governanceLedger, AudioTranscriptionService? audioTranscriptionService = null) { _ = isNonLoopbackBind; @@ -139,6 +140,17 @@ await pipeline.OutboundWriter.WriteAsync(new OutboundMessage "chat", msg.ChannelId, msg.SenderId); + if (governanceLedger is not null) + { + await governanceLedger.TryRecordApprovalDecisionAsync( + decisionOutcome.Request, + msg.Approved.Value, + GovernanceLedgerSources.ToolApproval, + msg.SenderId, + msg.ChannelId, + msg.SenderId, + lifetime.ApplicationStopping); + } RecordApprovalDecisionEvent( operations, decisionOutcome.Request, @@ -212,6 +224,17 @@ await pipeline.OutboundWriter.WriteAsync(new OutboundMessage "chat", msg.ChannelId, msg.SenderId); + if (governanceLedger is not null) + { + await governanceLedger.TryRecordApprovalDecisionAsync( + decisionOutcome.Request, + approved, + GovernanceLedgerSources.ToolApproval, + msg.SenderId, + msg.ChannelId, + msg.SenderId, + lifetime.ApplicationStopping); + } RecordApprovalDecisionEvent( operations, decisionOutcome.Request, @@ -507,6 +530,7 @@ await FinalizeAutomationRunAsync(new AutomationRunCompletion session, msg.ChannelId, msg.SenderId, + governanceLedger, async (request, preview, ct) => { if (msg.ChannelId == "websocket" && wsChannel.IsClientUsingEnvelopes(msg.SenderId)) diff --git a/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs b/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs index d815e36d..92774a8e 100644 --- a/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs +++ b/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs @@ -40,6 +40,7 @@ public static void Start( LearningService? learningService = null, GatewayAutomationService? automationService = null, ContractGovernanceService? contractGovernance = null, + GovernanceLedgerService? governanceLedger = null, AudioTranscriptionService? audioTranscriptionService = null) { new GatewaySessionCleanupWorker().Start(lifetime, logger, sessionManager); @@ -69,6 +70,7 @@ public static void Start( learningService, automationService, contractGovernance, + governanceLedger, audioTranscriptionService); new GatewayOutboundDeliveryWorker().Start( diff --git a/src/OpenClaw.Gateway/GatewayPaymentApprovalService.cs b/src/OpenClaw.Gateway/GatewayPaymentApprovalService.cs index c9881f63..b460ff6c 100644 --- a/src/OpenClaw.Gateway/GatewayPaymentApprovalService.cs +++ b/src/OpenClaw.Gateway/GatewayPaymentApprovalService.cs @@ -10,15 +10,18 @@ internal sealed class GatewayPaymentApprovalService : IPaymentApprovalService private readonly GatewayConfig _config; private readonly ToolApprovalService _approvals; private readonly ApprovalAuditStore _audit; + private readonly GovernanceLedgerService? _governanceLedger; public GatewayPaymentApprovalService( GatewayConfig config, ToolApprovalService approvals, - ApprovalAuditStore audit) + ApprovalAuditStore audit, + GovernanceLedgerService? governanceLedger = null) { _config = config; _approvals = approvals; _audit = audit; + _governanceLedger = governanceLedger; } public async ValueTask RequestApprovalAsync(ApprovalRequest request, CancellationToken ct) @@ -59,6 +62,28 @@ public async ValueTask RequestApprovalAsync(ApprovalRequest requ outcome.Result == ToolApprovalWaitResult.TimedOut ? "timeout" : "payment", actorChannelId: null, actorSenderId: null); + if (_governanceLedger is not null) + { + if (outcome.Result == ToolApprovalWaitResult.TimedOut) + { + await _governanceLedger.TryRecordExpiredAsync( + outcome.Request, + GovernanceLedgerSources.ApprovalTimeout, + decidedBy: "timeout", + ct); + } + else + { + await _governanceLedger.TryRecordApprovalDecisionAsync( + outcome.Request, + approved, + GovernanceLedgerSources.ToolApproval, + decidedBy: "payment", + actorChannelId: null, + actorSenderId: null, + ct); + } + } } return new ApprovalResult diff --git a/src/OpenClaw.Gateway/GovernanceLedgerService.cs b/src/OpenClaw.Gateway/GovernanceLedgerService.cs new file mode 100644 index 00000000..78eda9ae --- /dev/null +++ b/src/OpenClaw.Gateway/GovernanceLedgerService.cs @@ -0,0 +1,553 @@ +using System.Linq; +using Microsoft.Extensions.Logging; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; +using OpenClaw.Core.Security; + +namespace OpenClaw.Gateway; + +internal sealed class GovernanceLedgerService +{ + private const int MaxArgumentSummaryChars = 800; + + private readonly IGovernanceLedgerStore _store; + private readonly RuntimeEventStore _runtimeEvents; + private readonly IRedactionPipeline _redaction; + private readonly ILogger _logger; + + public GovernanceLedgerService( + IGovernanceLedgerStore store, + RuntimeEventStore runtimeEvents, + IRedactionPipeline? redaction, + ILogger logger) + { + _store = store; + _runtimeEvents = runtimeEvents; + _redaction = redaction ?? new NoopRedactionPipeline(); + _logger = logger; + } + + public ValueTask CreateAsync(GovernanceLedgerEntry entry, CancellationToken ct) + => RecordDecisionAsync(entry, ct); + + public async ValueTask SaveAsync(GovernanceLedgerEntry entry, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(entry); + + var normalized = Normalize(entry, DateTimeOffset.UtcNow, isNew: false); + await _store.SaveAsync(normalized, ct); + AppendEvent( + normalized, + action: "governance_ledger_entry_recorded", + severity: EventSeverity(normalized), + summary: $"Recorded governance ledger entry '{normalized.Id}'."); + return normalized; + } + + public async ValueTask RecordDecisionAsync(GovernanceLedgerEntry entry, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(entry); + + var normalized = Normalize(entry, DateTimeOffset.UtcNow, isNew: true); + await _store.SaveAsync(normalized, ct); + AppendEvent( + normalized, + action: "governance_ledger_entry_recorded", + severity: EventSeverity(normalized), + summary: $"Recorded governance ledger entry '{normalized.Id}'."); + return normalized; + } + + public ValueTask RecordApprovalAsync( + ToolApprovalRequest request, + string source, + string? decidedBy, + string? actorChannelId, + string? actorSenderId, + CancellationToken ct) + => RecordDecisionAsync(FromToolApproval( + request, + decision: GovernanceDecisions.Approved, + status: GovernanceDecisionStatuses.Active, + source, + decidedBy, + actorChannelId, + actorSenderId, + reason: "approved"), ct); + + public ValueTask RecordRejectionAsync( + ToolApprovalRequest request, + string source, + string? decidedBy, + string? actorChannelId, + string? actorSenderId, + CancellationToken ct) + => RecordDecisionAsync(FromToolApproval( + request, + decision: GovernanceDecisions.Rejected, + status: GovernanceDecisionStatuses.Active, + source, + decidedBy, + actorChannelId, + actorSenderId, + reason: "rejected"), ct); + + public ValueTask RecordExpiredAsync( + ToolApprovalRequest request, + string source, + string? decidedBy, + CancellationToken ct) + => RecordDecisionAsync(FromToolApproval( + request, + decision: GovernanceDecisions.Expired, + status: GovernanceDecisionStatuses.Expired, + source, + decidedBy, + actorChannelId: null, + actorSenderId: null, + reason: "approval timed out"), ct); + + public ValueTask GetAsync(string id, CancellationToken ct) + => _store.GetAsync(id, ct); + + public ValueTask> ListAsync(GovernanceLedgerListQuery query, CancellationToken ct) + => _store.ListAsync(query, ct); + + public async ValueTask RevokeAsync(string id, string revokedBy, string reason, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(revokedBy)) + throw new ArgumentException("Governance ledger revocation actor is required.", nameof(revokedBy)); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("Governance ledger revocation reason is required.", nameof(reason)); + + var revoked = await _store.RevokeAsync( + id, + revokedBy.Trim(), + reason.Trim(), + ct); + if (revoked is null) + return null; + + AppendEvent( + revoked, + action: "governance_ledger_entry_revoked", + severity: "warning", + summary: $"Revoked governance ledger entry '{revoked.Id}'."); + return revoked; + } + + public async ValueTask TryRecordApprovalDecisionAsync( + ToolApprovalRequest request, + bool approved, + string source, + string? decidedBy, + string? actorChannelId, + string? actorSenderId, + CancellationToken ct) + { + try + { + if (approved) + await RecordApprovalAsync(request, source, decidedBy, actorChannelId, actorSenderId, ct); + else + await RecordRejectionAsync(request, source, decidedBy, actorChannelId, actorSenderId, ct); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to record governance ledger approval decision for {ApprovalId}.", request.ApprovalId); + } + } + + public async ValueTask TryRecordExpiredAsync( + ToolApprovalRequest request, + string source, + string? decidedBy, + CancellationToken ct) + { + try + { + await RecordExpiredAsync(request, source, decidedBy, ct); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to record governance ledger timeout for {ApprovalId}.", request.ApprovalId); + } + } + + public async ValueTask TryRecordApprovalGrantConsumedAsync( + ToolApprovalGrant grant, + ToolApprovalRequest request, + CancellationToken ct) + { + try + { + var scope = NormalizeGrantScope(grant.Scope); + await RecordDecisionAsync(new GovernanceLedgerEntry + { + Id = $"gov_{Guid.NewGuid():N}"[..24], + Decision = GovernanceDecisions.Approved, + Status = GovernanceDecisionStatuses.Active, + Source = GovernanceLedgerSources.ApprovalGrantConsumed, + ToolName = request.ToolName, + ActionType = request.Action, + ActionSummary = string.IsNullOrWhiteSpace(request.Summary) + ? $"Reusable approval grant '{grant.Id}' applied for tool '{request.ToolName}'." + : request.Summary, + ArgumentSummary = request.Arguments, + RedactedArguments = request.Arguments, + RiskLevel = DeriveRiskLevel(request), + Scope = scope, + ScopeKey = ScopeKeyForGrant(grant, scope), + SessionId = request.SessionId, + ApprovalId = grant.Id, + ActorId = grant.GrantedBy, + ChannelId = request.ChannelId, + SenderId = request.SenderId, + DecidedBy = grant.GrantedBy, + DecisionReason = "approval grant consumed", + Tags = ["approval", "approval_grant"], + Metadata = new GovernanceLedgerMetadata + { + CorrelationId = grant.Id, + Properties = BuildMetadata( + ("grantId", grant.Id), + ("grantScope", grant.Scope), + ("grantSessionId", grant.SessionId), + ("grantChannelId", grant.ChannelId), + ("grantSenderId", grant.SenderId), + ("grantSource", grant.GrantSource), + ("isMutation", request.IsMutation ? "true" : "false")) + } + }, ct); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to record governance ledger approval grant consumption for {GrantId}.", grant.Id); + } + } + + public GovernanceLedgerEntry FromToolApproval( + ToolApprovalRequest request, + string decision, + string status, + string source, + string? decidedBy, + string? actorChannelId, + string? actorSenderId, + string? reason) + { + ArgumentNullException.ThrowIfNull(request); + + return new GovernanceLedgerEntry + { + Id = $"gov_{Guid.NewGuid():N}"[..24], + Decision = decision, + Status = status, + Source = source, + ToolName = request.ToolName, + ActionType = request.Action, + ActionSummary = string.IsNullOrWhiteSpace(request.Summary) + ? $"Tool approval decision for '{request.ToolName}'." + : request.Summary, + ArgumentSummary = request.Arguments, + RedactedArguments = request.Arguments, + RiskLevel = DeriveRiskLevel(request), + Scope = GovernanceScopes.Once, + ScopeKey = request.ApprovalId, + SessionId = request.SessionId, + ApprovalId = request.ApprovalId, + ActorId = actorSenderId ?? decidedBy, + ChannelId = request.ChannelId, + SenderId = request.SenderId, + DecidedBy = decidedBy ?? actorSenderId, + DecisionReason = reason, + Tags = ["approval"], + Metadata = new GovernanceLedgerMetadata + { + CorrelationId = request.ApprovalId, + Properties = BuildMetadata( + ("actorChannelId", actorChannelId), + ("actorSenderId", actorSenderId), + ("isMutation", request.IsMutation ? "true" : "false")) + } + }; + } + + private GovernanceLedgerEntry Normalize(GovernanceLedgerEntry entry, DateTimeOffset now, bool isNew) + { + var id = string.IsNullOrWhiteSpace(entry.Id) + ? $"gov_{Guid.NewGuid():N}"[..24] + : entry.Id.Trim(); + var createdAt = entry.CreatedAtUtc == default || isNew ? now : entry.CreatedAtUtc; + var decision = NormalizeDecision(entry.Decision); + var status = string.IsNullOrWhiteSpace(entry.Status) + ? StatusForDecision(decision) + : NormalizeStatus(entry.Status); + var redactedArgs = string.IsNullOrWhiteSpace(entry.RedactedArguments) + ? null + : _redaction.Redact(entry.RedactedArguments); + var argumentSummary = string.IsNullOrWhiteSpace(entry.ArgumentSummary) + ? Truncate(redactedArgs) + : Truncate(_redaction.Redact(entry.ArgumentSummary)); + + return new GovernanceLedgerEntry + { + Id = id, + CreatedAtUtc = createdAt, + UpdatedAtUtc = now, + Decision = decision, + Status = status, + Source = NormalizeSource(entry.Source), + ActionType = CleanRedactedOptional(entry.ActionType), + ToolName = CleanRedactedOptional(entry.ToolName), + ActionSummary = CleanRedactedOptional(entry.ActionSummary) ?? "", + ArgumentSummary = argumentSummary, + RedactedArguments = redactedArgs, + RiskLevel = string.IsNullOrWhiteSpace(entry.RiskLevel) + ? GovernanceRiskLevels.Unknown + : NormalizeRiskLevel(entry.RiskLevel), + Scope = string.IsNullOrWhiteSpace(entry.Scope) + ? GovernanceScopes.Unknown + : NormalizeScope(entry.Scope), + ScopeKey = CleanRedactedOptional(entry.ScopeKey), + SessionId = CleanOptional(entry.SessionId), + HarnessContractId = CleanOptional(entry.HarnessContractId), + EvidenceBundleId = CleanOptional(entry.EvidenceBundleId), + LearningProposalId = CleanOptional(entry.LearningProposalId), + ApprovalId = CleanOptional(entry.ApprovalId), + ActorId = CleanOptional(entry.ActorId), + ChannelId = CleanOptional(entry.ChannelId), + SenderId = CleanOptional(entry.SenderId), + DecidedBy = CleanRedactedOptional(entry.DecidedBy), + DecisionReason = CleanRedactedOptional(entry.DecisionReason), + ExpiresAtUtc = entry.ExpiresAtUtc, + RevokedAtUtc = entry.RevokedAtUtc, + RevokedBy = CleanRedactedOptional(entry.RevokedBy), + RevocationReason = CleanRedactedOptional(entry.RevocationReason), + PolicyHint = NormalizePolicyHint(entry.PolicyHint), + Tags = CleanRedactedStrings(entry.Tags), + Metadata = NormalizeMetadata(entry.Metadata) + }; + } + + private void AppendEvent(GovernanceLedgerEntry entry, string action, string severity, string summary) + { + try + { + _runtimeEvents.Append(new RuntimeEventEntry + { + Id = $"evt_{Guid.NewGuid():N}"[..20], + SessionId = entry.SessionId, + ChannelId = entry.ChannelId, + SenderId = entry.SenderId, + CorrelationId = entry.Id, + Component = "harness", + Action = action, + Severity = severity, + Summary = summary, + Metadata = new Dictionary + { + ["governanceLedgerId"] = entry.Id, + ["decision"] = entry.Decision, + ["status"] = entry.Status, + ["riskLevel"] = entry.RiskLevel + } + }); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to append governance ledger runtime event for {EntryId}.", entry.Id); + } + } + + private GovernancePolicyHint? NormalizePolicyHint(GovernancePolicyHint? hint) + => hint is null + ? null + : new GovernancePolicyHint + { + SuggestedFutureBehavior = CleanRedactedOptional(hint.SuggestedFutureBehavior), + SuggestedScope = string.IsNullOrWhiteSpace(hint.SuggestedScope) + ? null + : NormalizeScope(hint.SuggestedScope), + Confidence = CleanRedactedOptional(hint.Confidence), + RequiresReview = hint.RequiresReview, + Notes = CleanRedactedOptional(hint.Notes) + }; + + private GovernanceLedgerMetadata? NormalizeMetadata(GovernanceLedgerMetadata? metadata) + => metadata is null + ? null + : new GovernanceLedgerMetadata + { + CreatedBy = CleanRedactedOptional(metadata.CreatedBy), + CorrelationId = CleanOptional(metadata.CorrelationId), + Properties = metadata.Properties is null + ? [] + : new Dictionary( + metadata.Properties + .Where(static item => !string.IsNullOrWhiteSpace(item.Key)) + .Select(item => new KeyValuePair(item.Key.Trim(), _redaction.Redact(item.Value))), + StringComparer.Ordinal) + }; + + private static IReadOnlyList CleanStrings(IReadOnlyList? items) + => items? + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Select(static item => item.Trim()) + .ToArray() ?? []; + + private IReadOnlyList CleanRedactedStrings(IReadOnlyList? items) + => CleanStrings(items) + .Select(item => _redaction.Redact(item)) + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .ToArray(); + + private static string? CleanOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private string? CleanRedactedOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : _redaction.Redact(value.Trim()); + + private static string Truncate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ""; + + var trimmed = value.Trim(); + return trimmed.Length <= MaxArgumentSummaryChars + ? trimmed + : trimmed[..MaxArgumentSummaryChars] + "..."; + } + + private static string DeriveRiskLevel(ToolApprovalRequest request) + { + var text = $"{request.ToolName} {request.Action}".ToLowerInvariant(); + if (text.Contains("shell", StringComparison.Ordinal) || + text.Contains("process", StringComparison.Ordinal) || + text.Contains("external", StringComparison.Ordinal) || + text.Contains("deploy", StringComparison.Ordinal) || + text.Contains("security", StringComparison.Ordinal) || + text.Contains("public", StringComparison.Ordinal) || + text.Contains("payment", StringComparison.Ordinal)) + return GovernanceRiskLevels.High; + + return request.IsMutation ? GovernanceRiskLevels.Medium : GovernanceRiskLevels.Low; + } + + private static string StatusForDecision(string decision) + => decision is GovernanceDecisions.Expired + ? GovernanceDecisionStatuses.Expired + : decision is GovernanceDecisions.Revoked + ? GovernanceDecisionStatuses.Revoked + : GovernanceDecisionStatuses.Active; + + private static string EventSeverity(GovernanceLedgerEntry entry) + => entry.Decision is GovernanceDecisions.Rejected or GovernanceDecisions.Expired or GovernanceDecisions.Revoked || + entry.Status is GovernanceDecisionStatuses.Expired or GovernanceDecisionStatuses.Revoked + ? "warning" + : "info"; + + private static string NormalizeDecision(string? decision) + => string.IsNullOrWhiteSpace(decision) + ? GovernanceDecisions.Unknown + : decision.Trim().ToLowerInvariant() switch + { + GovernanceDecisions.Approved => GovernanceDecisions.Approved, + GovernanceDecisions.Rejected => GovernanceDecisions.Rejected, + GovernanceDecisions.Escalated => GovernanceDecisions.Escalated, + GovernanceDecisions.Expired => GovernanceDecisions.Expired, + GovernanceDecisions.Revoked => GovernanceDecisions.Revoked, + GovernanceDecisions.Unknown => GovernanceDecisions.Unknown, + _ => throw new ArgumentException($"Unsupported governance decision '{decision}'.", nameof(decision)) + }; + + private static string NormalizeStatus(string? status) + => string.IsNullOrWhiteSpace(status) + ? GovernanceDecisionStatuses.Active + : status.Trim().ToLowerInvariant() switch + { + GovernanceDecisionStatuses.Active => GovernanceDecisionStatuses.Active, + GovernanceDecisionStatuses.Expired => GovernanceDecisionStatuses.Expired, + GovernanceDecisionStatuses.Revoked => GovernanceDecisionStatuses.Revoked, + GovernanceDecisionStatuses.Superseded => GovernanceDecisionStatuses.Superseded, + _ => throw new ArgumentException($"Unsupported governance decision status '{status}'.", nameof(status)) + }; + + private static string NormalizeScope(string? scope) + => string.IsNullOrWhiteSpace(scope) + ? GovernanceScopes.Unknown + : scope.Trim().ToLowerInvariant() switch + { + GovernanceScopes.Once => GovernanceScopes.Once, + GovernanceScopes.Session => GovernanceScopes.Session, + GovernanceScopes.Actor => GovernanceScopes.Actor, + GovernanceScopes.Channel => GovernanceScopes.Channel, + GovernanceScopes.Project => GovernanceScopes.Project, + GovernanceScopes.Tool => GovernanceScopes.Tool, + GovernanceScopes.Global => GovernanceScopes.Global, + GovernanceScopes.Unknown => GovernanceScopes.Unknown, + _ => throw new ArgumentException($"Unsupported governance scope '{scope}'.", nameof(scope)) + }; + + private static string NormalizeRiskLevel(string? riskLevel) + => string.IsNullOrWhiteSpace(riskLevel) + ? GovernanceRiskLevels.Unknown + : riskLevel.Trim().ToLowerInvariant() switch + { + GovernanceRiskLevels.Unknown => GovernanceRiskLevels.Unknown, + GovernanceRiskLevels.Low => GovernanceRiskLevels.Low, + GovernanceRiskLevels.Medium => GovernanceRiskLevels.Medium, + GovernanceRiskLevels.High => GovernanceRiskLevels.High, + GovernanceRiskLevels.Critical => GovernanceRiskLevels.Critical, + _ => throw new ArgumentException($"Unsupported governance risk level '{riskLevel}'.", nameof(riskLevel)) + }; + + private static string NormalizeGrantScope(string? scope) + => string.IsNullOrWhiteSpace(scope) + ? GovernanceScopes.Unknown + : scope.Trim().ToLowerInvariant() switch + { + GovernanceScopes.Once => GovernanceScopes.Once, + GovernanceScopes.Session => GovernanceScopes.Session, + "sender_tool_window" => GovernanceScopes.Actor, + GovernanceScopes.Actor => GovernanceScopes.Actor, + GovernanceScopes.Channel => GovernanceScopes.Channel, + GovernanceScopes.Project => GovernanceScopes.Project, + GovernanceScopes.Tool => GovernanceScopes.Tool, + GovernanceScopes.Global => GovernanceScopes.Global, + _ => GovernanceScopes.Unknown + }; + + private static string? ScopeKeyForGrant(ToolApprovalGrant grant, string scope) + => scope switch + { + GovernanceScopes.Session => CleanOptional(grant.SessionId), + GovernanceScopes.Actor => CleanOptional($"{grant.ChannelId}:{grant.SenderId}:{grant.ToolName}".Trim(':')), + GovernanceScopes.Channel => CleanOptional(grant.ChannelId), + GovernanceScopes.Tool => CleanOptional(grant.ToolName), + GovernanceScopes.Global => "global", + _ => CleanOptional(grant.Id) + }; + + private static string NormalizeSource(string? source) + => string.IsNullOrWhiteSpace(source) + ? GovernanceLedgerSources.Unknown + : source.Trim().ToLowerInvariant() switch + { + GovernanceLedgerSources.Manual => GovernanceLedgerSources.Manual, + GovernanceLedgerSources.ToolApproval => GovernanceLedgerSources.ToolApproval, + GovernanceLedgerSources.ApprovalTimeout => GovernanceLedgerSources.ApprovalTimeout, + GovernanceLedgerSources.ApprovalGrantConsumed => GovernanceLedgerSources.ApprovalGrantConsumed, + GovernanceLedgerSources.HarnessContract => GovernanceLedgerSources.HarnessContract, + GovernanceLedgerSources.EvidenceReview => GovernanceLedgerSources.EvidenceReview, + GovernanceLedgerSources.LearningProposal => GovernanceLedgerSources.LearningProposal, + GovernanceLedgerSources.Unknown => GovernanceLedgerSources.Unknown, + _ => throw new ArgumentException($"Unsupported governance ledger source '{source}'.", nameof(source)) + }; + + private static Dictionary BuildMetadata(params (string Key, string? Value)[] items) + => items + .Where(static item => !string.IsNullOrWhiteSpace(item.Value)) + .ToDictionary(static item => item.Key, static item => item.Value!, StringComparer.Ordinal); +} diff --git a/src/OpenClaw.Gateway/Pipeline/PipelineExtensions.cs b/src/OpenClaw.Gateway/Pipeline/PipelineExtensions.cs index 8af88f57..be4d0be6 100644 --- a/src/OpenClaw.Gateway/Pipeline/PipelineExtensions.cs +++ b/src/OpenClaw.Gateway/Pipeline/PipelineExtensions.cs @@ -116,6 +116,7 @@ private static void StartWorkers(WebApplication app, GatewayStartupContext start app.Services.GetService(), app.Services.GetService(), app.Services.GetService(), + FeatureFallbackServices.ResolveGovernanceLedgerService(startup, app.Services), app.Services.GetService()); } diff --git a/src/OpenClaw.Gateway/ToolApprovalCallbackFactory.cs b/src/OpenClaw.Gateway/ToolApprovalCallbackFactory.cs index 25d14855..0f52c54f 100644 --- a/src/OpenClaw.Gateway/ToolApprovalCallbackFactory.cs +++ b/src/OpenClaw.Gateway/ToolApprovalCallbackFactory.cs @@ -13,6 +13,7 @@ public static ToolApprovalCallback Create( Session session, string approvalChannelId, string senderId, + GovernanceLedgerService? governanceLedger = null, Func? onApprovalRequested = null) => Create( config, @@ -22,6 +23,7 @@ public static ToolApprovalCallback Create( session, approvalChannelId, senderId, + governanceLedger, onApprovalRequested); public static ToolApprovalCallback Create( @@ -32,6 +34,7 @@ public static ToolApprovalCallback Create( Session session, string approvalChannelId, string senderId, + GovernanceLedgerService? governanceLedger = null, Func? onApprovalRequested = null) { var approvalTimeout = TimeSpan.FromSeconds(Math.Clamp(config.Tooling.ToolApprovalTimeoutSeconds, 5, 3600)); @@ -59,6 +62,26 @@ public static ToolApprovalCallback Create( ["scope"] = grant.Scope } }); + if (governanceLedger is not null) + { + await governanceLedger.TryRecordApprovalGrantConsumedAsync( + grant, + new ToolApprovalRequest + { + ApprovalId = grant.Id, + SessionId = session.Id, + ChannelId = approvalChannelId, + SenderId = senderId, + ToolName = toolName, + Arguments = argsJson, + Action = actionDescriptor.Action, + IsMutation = actionDescriptor.IsMutation, + Summary = string.IsNullOrWhiteSpace(actionDescriptor.Summary) + ? $"Reusable approval grant '{grant.Id}' applied for tool '{toolName}'." + : actionDescriptor.Summary + }, + ct); + } return true; } @@ -108,6 +131,14 @@ public static ToolApprovalCallback Create( "timeout", actorChannelId: null, actorSenderId: null); + if (governanceLedger is not null) + { + await governanceLedger.TryRecordExpiredAsync( + outcome.Request, + GovernanceLedgerSources.ApprovalTimeout, + decidedBy: "timeout", + ct); + } RecordApprovalTimedOutEvent(operations, outcome.Request); } diff --git a/src/OpenClaw.Gateway/wwwroot/admin.html b/src/OpenClaw.Gateway/wwwroot/admin.html index e819276b..1223e6a1 100644 --- a/src/OpenClaw.Gateway/wwwroot/admin.html +++ b/src/OpenClaw.Gateway/wwwroot/admin.html @@ -1069,6 +1069,9 @@

Audit Export

+
+ +
Ready.
@@ -1085,6 +1088,7 @@

Trajectory Export

+
Ready.
@@ -1387,6 +1391,71 @@

Evidence Bundles

Evidence Detail

Select an evidence bundle.
+
+
+
+

Governance Ledger

+
Inspect durable approval and oversight decisions without changing approval behavior.
+
+
+ +
+
+
+ + + + + + + + +
+
Loading…
+
+
+
+
+

Governance Detail

+
Policy hints are informational only and do not auto-approve future actions.
+
+
+ +
+
+ +
Select a governance ledger entry.
+
@@ -2137,9 +2206,11 @@

Notes

const observabilitySummaryOutput = document.getElementById('observability-summary-output'); const observabilitySeriesOutput = document.getElementById('observability-series-output'); const auditExportOutput = document.getElementById('audit-export-output'); + const auditIncludeGovernanceInput = document.getElementById('audit-include-governance-input'); const trajectorySessionInput = document.getElementById('trajectory-session-input'); const trajectoryAnonymizeInput = document.getElementById('trajectory-anonymize-input'); const trajectoryIncludeEvidenceInput = document.getElementById('trajectory-include-evidence-input'); + const trajectoryIncludeGovernanceInput = document.getElementById('trajectory-include-governance-input'); const trajectoryExportOutput = document.getElementById('trajectory-export-output'); const migrationReportInput = document.getElementById('migration-report-input'); const migrationReportOutput = document.getElementById('migration-report-output'); @@ -2226,6 +2297,19 @@

Notes

const evidenceContractInput = document.getElementById('evidence-contract-input'); const evidenceConfidenceInput = document.getElementById('evidence-confidence-input'); const evidenceTagInput = document.getElementById('evidence-tag-input'); + const governanceListOutput = document.getElementById('governance-list-output'); + const governanceDetailOutput = document.getElementById('governance-detail-output'); + const governanceRefreshButton = document.getElementById('governance-refresh-button'); + const governanceRevokeButton = document.getElementById('governance-revoke-button'); + const governanceDecisionInput = document.getElementById('governance-decision-input'); + const governanceStatusInput = document.getElementById('governance-status-input'); + const governanceToolInput = document.getElementById('governance-tool-input'); + const governanceRiskInput = document.getElementById('governance-risk-input'); + const governanceScopeInput = document.getElementById('governance-scope-input'); + const governanceSessionInput = document.getElementById('governance-session-input'); + const governanceActorInput = document.getElementById('governance-actor-input'); + const governanceTagInput = document.getElementById('governance-tag-input'); + const governanceRevokeReasonInput = document.getElementById('governance-revoke-reason-input'); const memorySearchInput = document.getElementById('memory-search-input'); const memoryPrefixInput = document.getElementById('memory-prefix-input'); const memoryClassFilterInput = document.getElementById('memory-class-filter-input'); @@ -2357,6 +2441,10 @@

Notes

selectedId: null, detail: null }; + let governanceState = { + selectedId: null, + detail: null + }; let memoryState = { selectedKey: null }; @@ -2454,6 +2542,48 @@

Notes

const button = document.getElementById(id); if (button) button.disabled = !hasRole('admin'); }); + applyGovernanceAccessState(); + } + + function canViewGovernanceLedger() { + return Boolean(currentAuth) && hasRole('viewer'); + } + + function canMutateGovernanceLedger() { + return Boolean(currentAuth) && hasRole('operator'); + } + + function resetGovernanceUi(message) { + governanceState = { + selectedId: null, + detail: null + }; + governanceListOutput.textContent = message; + governanceDetailOutput.innerHTML = `
${escapeHtml(message)}
`; + governanceRevokeReasonInput.value = ''; + } + + function applyGovernanceAccessState() { + const canView = canViewGovernanceLedger(); + const canMutate = canMutateGovernanceLedger(); + [ + governanceDecisionInput, + governanceStatusInput, + governanceToolInput, + governanceRiskInput, + governanceScopeInput, + governanceSessionInput, + governanceActorInput, + governanceTagInput + ].forEach(input => { + input.disabled = !canView; + }); + governanceRevokeReasonInput.disabled = !canMutate; + governanceRefreshButton.disabled = !canView; + governanceRevokeButton.disabled = !canMutate || !governanceState.selectedId || governanceState.detail?.status === 'revoked'; + if (!canView) { + resetGovernanceUi(currentAuth ? 'Access denied.' : 'Login required.'); + } } function updateLoginModeUi() { @@ -2533,6 +2663,7 @@

Notes

csrfToken = null; currentAuth = null; sessionStatus.textContent = 'Logged out'; + resetGovernanceUi('Login required.'); applyRoleGates(); } @@ -3181,6 +3312,7 @@

Notes

async function exportAuditBundle() { const params = buildDateRangeQuery(); + if (auditIncludeGovernanceInput.checked) params.set('includeGovernance', 'true'); const url = `/admin/audit/export${params.toString() ? `?${params.toString()}` : ''}`; const resp = await fetch(url, { credentials: 'same-origin' }); if (!resp.ok) { @@ -3208,6 +3340,7 @@

Notes

if (sessionId) params.set('sessionId', sessionId); if (trajectoryAnonymizeInput.checked) params.set('anonymize', 'true'); if (trajectoryIncludeEvidenceInput.checked) params.set('includeEvidence', 'true'); + if (trajectoryIncludeGovernanceInput.checked) params.set('includeGovernance', 'true'); const url = `/admin/trajectory/export${params.toString() ? `?${params.toString()}` : ''}`; const resp = await fetch(url, { credentials: 'same-origin' }); if (!resp.ok) { @@ -4632,6 +4765,129 @@

Notes

renderEvidenceDetail(data.bundle); } + async function loadGovernanceLedger() { + if (!canViewGovernanceLedger()) { + resetGovernanceUi(currentAuth ? 'Access denied.' : 'Login required.'); + return; + } + + const params = new URLSearchParams(); + if (governanceDecisionInput.value) params.set('decision', governanceDecisionInput.value); + if (governanceStatusInput.value) params.set('status', governanceStatusInput.value); + if (governanceToolInput.value.trim()) params.set('toolName', governanceToolInput.value.trim()); + if (governanceRiskInput.value) params.set('riskLevel', governanceRiskInput.value); + if (governanceScopeInput.value) params.set('scope', governanceScopeInput.value); + if (governanceSessionInput.value.trim()) params.set('sessionId', governanceSessionInput.value.trim()); + if (governanceActorInput.value.trim()) params.set('actorId', governanceActorInput.value.trim()); + if (governanceTagInput.value.trim()) params.set('tag', governanceTagInput.value.trim()); + const query = params.toString(); + const { resp, data } = await api(`/admin/governance/ledger${query ? `?${query}` : ''}`); + if (!resp.ok) { + governanceListOutput.textContent = `Governance ledger request failed (${resp.status})`; + return; + } + + const items = data.items || []; + if (!items.length) { + governanceState.selectedId = null; + governanceState.detail = null; + governanceRevokeReasonInput.value = ''; + governanceDetailOutput.innerHTML = '
Select a governance ledger entry.
'; + applyGovernanceAccessState(); + governanceListOutput.textContent = 'No governance ledger entries matched the current filters.'; + return; + } + + governanceListOutput.innerHTML = items.map(item => ` +
+ ${escapeHtml(item.actionSummary || item.id)} +
${escapeHtml(item.decision || 'unknown')} · ${escapeHtml(item.status || 'active')} · risk ${escapeHtml(item.riskLevel || 'unknown')} · scope ${escapeHtml(item.scope || 'unknown')}
+
Tool: ${escapeHtml(item.toolName || 'none')} · Action: ${escapeHtml(item.actionType || 'none')}
+
Session: ${escapeHtml(item.sessionId || 'none')} · Decided by: ${escapeHtml(item.decidedBy || 'unknown')}
+
Updated: ${escapeHtml(formatDateTime(item.updatedAtUtc || item.createdAtUtc))}
+
+ +
+
+ `).join(''); + } + + function renderGovernanceBlock(label, value) { + if (!value) return ''; + if (Array.isArray(value) && !value.length) return ''; + return `

${escapeHtml(label)}

${escapeHtml(JSON.stringify(value, null, 2))}
`; + } + + function renderGovernanceDetail(entry) { + governanceState.detail = entry; + if (!entry) { + governanceState.selectedId = null; + governanceDetailOutput.innerHTML = '
Select a governance ledger entry.
'; + applyGovernanceAccessState(); + return; + } + + governanceDetailOutput.innerHTML = ` +
+
+ ${escapeHtml(entry.actionSummary || entry.id)} +
${escapeHtml(entry.decision || 'unknown')} · ${escapeHtml(entry.status || 'active')} · risk ${escapeHtml(entry.riskLevel || 'unknown')} · source ${escapeHtml(entry.source || 'unknown')}
+
Tool: ${escapeHtml(entry.toolName || 'none')} · Action: ${escapeHtml(entry.actionType || 'none')}
+
Scope: ${escapeHtml(entry.scope || 'unknown')} · Scope key: ${escapeHtml(entry.scopeKey || 'none')}
+
Session: ${escapeHtml(entry.sessionId || 'none')} · Approval: ${escapeHtml(entry.approvalId || 'none')}
+
Contract: ${escapeHtml(entry.harnessContractId || 'none')} · Evidence: ${escapeHtml(entry.evidenceBundleId || 'none')} · Proposal: ${escapeHtml(entry.learningProposalId || 'none')}
+
Decided by: ${escapeHtml(entry.decidedBy || 'unknown')} · Reason: ${escapeHtml(entry.decisionReason || 'none')}
+
Created: ${escapeHtml(formatDateTime(entry.createdAtUtc))} · Updated: ${escapeHtml(formatDateTime(entry.updatedAtUtc))}
+
Revoked: ${escapeHtml(entry.revokedAtUtc ? formatDateTime(entry.revokedAtUtc) : 'no')} · ${escapeHtml(entry.revocationReason || '')}
+
+ ${renderGovernanceBlock('Redacted Arguments', entry.redactedArguments)} + ${renderGovernanceBlock('Policy Hint', entry.policyHint)} + ${renderGovernanceBlock('Tags', entry.tags)} + ${renderGovernanceBlock('Metadata', entry.metadata)} +
${escapeHtml(JSON.stringify(entry, null, 2))}
+
+ `; + applyGovernanceAccessState(); + } + + async function loadGovernanceLedgerDetail(id) { + if (!id || !canViewGovernanceLedger()) return; + governanceState.selectedId = id; + const { resp, data } = await api(`/admin/governance/ledger/${encodeURIComponent(id)}`); + if (!resp.ok) { + governanceDetailOutput.innerHTML = `
Governance ledger detail failed (${resp.status})
`; + return; + } + + renderGovernanceDetail(data.entry); + } + + async function revokeGovernanceLedgerEntry() { + if (!canMutateGovernanceLedger()) { + showFlash('Governance revoke requires operator access.', 'warn'); + return; + } + + if (!governanceState.selectedId) { + showFlash('Select a governance ledger entry first.', 'warn'); + return; + } + + const { resp, data } = await mutate(`/admin/governance/ledger/${encodeURIComponent(governanceState.selectedId)}/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: governanceRevokeReasonInput.value.trim() || null }) + }); + showFlash(resp.ok ? 'Governance ledger entry revoked.' : `Governance revoke failed (${resp.status}).`, resp.ok ? 'ok' : 'error'); + if (!resp.ok) { + renderJson(governanceDetailOutput, data || `Governance revoke failed (${resp.status})`); + return; + } + + renderGovernanceDetail(data.entry); + await loadGovernanceLedger(); + } + async function loadDoctor() { const { resp, data } = await api('/doctor/text'); doctorOutput.textContent = resp.ok ? data : `Doctor request failed (${resp.status})`; @@ -5273,6 +5529,7 @@

Persisted (${data.persisted.returnedCount})

observabilitySeriesOutput.textContent = 'Login required.'; auditExportOutput.textContent = 'Login required.'; trajectoryExportOutput.textContent = 'Login required.'; + resetGovernanceUi('Login required.'); migrationReportOutput.textContent = 'Load a local migration report after login.'; if (approvalsPollTimer) { clearInterval(approvalsPollTimer); @@ -5314,6 +5571,12 @@

Persisted (${data.persisted.returnedCount})

loadObservability() ]; + if (canViewGovernanceLedger()) { + tasks.push(loadGovernanceLedger()); + } else { + resetGovernanceUi('Access denied.'); + } + if (hasRole('admin')) { tasks.push(loadOperatorAccounts(), loadOrganizationPolicy()); } else { @@ -5424,6 +5687,17 @@

Persisted (${data.persisted.returnedCount})

await loadEvidenceBundles(); }); }); + governanceRefreshButton.addEventListener('click', loadGovernanceLedger); + governanceRevokeButton.addEventListener('click', revokeGovernanceLedgerEntry); + [governanceDecisionInput, governanceStatusInput, governanceRiskInput, governanceScopeInput].forEach(input => { + input.addEventListener('change', loadGovernanceLedger); + }); + [governanceToolInput, governanceSessionInput, governanceActorInput, governanceTagInput].forEach(input => { + input.addEventListener('keydown', async (event) => { + if (event.key !== 'Enter') return; + await loadGovernanceLedger(); + }); + }); document.getElementById('memory-refresh-button').addEventListener('click', loadMemoryNotes); document.getElementById('memory-search-button').addEventListener('click', loadMemoryNotes); document.getElementById('memory-load-button').addEventListener('click', async () => { @@ -5538,6 +5812,16 @@

Persisted (${data.persisted.returnedCount})

await loadEvidenceBundleDetail(decodeURIComponent(encodedId)); }); + governanceListOutput.addEventListener('click', async (event) => { + const button = event.target.closest('button[data-governance-inspect]'); + if (!button) return; + + const encodedId = button.getAttribute('data-governance-inspect'); + if (!encodedId) return; + + await loadGovernanceLedgerDetail(decodeURIComponent(encodedId)); + }); + memoryNotesOutput.addEventListener('click', async (event) => { const inspectButton = event.target.closest('button[data-memory-inspect], button[data-memory-edit]'); if (!inspectButton) return; diff --git a/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs b/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs index 04b897f5..b7d0fcd6 100644 --- a/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs +++ b/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs @@ -393,6 +393,120 @@ public async Task EvidenceBundles_AdminApi_RequiresAuthAndSupportsCreateListDeta Assert.Contains(events, item => item.Action == "evidence_bundle_updated" && item.CorrelationId == "evb_admin"); } + [Fact] + public async Task GovernanceLedger_AdminApi_RequiresAuthAndSupportsCreateListDetailAndRevoke() + { + await using var harness = await CreateHarnessAsync(nonLoopbackBind: true); + + var anonymousResponse = await harness.Client.GetAsync("/admin/governance/ledger"); + Assert.Equal(HttpStatusCode.Unauthorized, anonymousResponse.StatusCode); + + var viewerToken = CreateOperatorToken(harness, OperatorRoleNames.Viewer, "governance-viewer"); + using var viewerListRequest = new HttpRequestMessage(HttpMethod.Get, "/admin/governance/ledger"); + viewerListRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken); + var viewerListResponse = await harness.Client.SendAsync(viewerListRequest); + Assert.Equal(HttpStatusCode.OK, viewerListResponse.StatusCode); + + using var viewerCreateRequest = new HttpRequestMessage(HttpMethod.Post, "/admin/governance/ledger") + { + Content = JsonContent("""{"actionSummary":"viewer cannot create"}""") + }; + viewerCreateRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken); + var viewerCreateResponse = await harness.Client.SendAsync(viewerCreateRequest); + Assert.Equal(HttpStatusCode.Forbidden, viewerCreateResponse.StatusCode); + + var (cookie, csrfToken) = await LoginAsync(harness.Client, harness.AuthToken); + var entryJson = JsonSerializer.Serialize(new GovernanceLedgerEntry + { + Id = "gov_admin", + Decision = GovernanceDecisions.Approved, + Status = GovernanceDecisionStatuses.Active, + Source = GovernanceLedgerSources.Manual, + ToolName = "shell", + ActionType = "write", + ActionSummary = "Operator approved a governed shell action.", + ArgumentSummary = """{"cmd":"echo sk-testsecret123"}""", + RedactedArguments = """{"cmd":"echo sk-testsecret123"}""", + RiskLevel = GovernanceRiskLevels.High, + Scope = GovernanceScopes.Session, + ScopeKey = "session-governance", + SessionId = "session-governance", + ApprovalId = "apr_admin", + DecidedBy = "operator", + DecisionReason = "manual review passed", + Tags = ["approval"] + }, CoreJsonContext.Default.GovernanceLedgerEntry); + + using var missingCsrfRequest = new HttpRequestMessage(HttpMethod.Post, "/admin/governance/ledger") + { + Content = JsonContent(entryJson) + }; + missingCsrfRequest.Headers.Add("Cookie", cookie); + var missingCsrfResponse = await harness.Client.SendAsync(missingCsrfRequest); + Assert.Equal(HttpStatusCode.Unauthorized, missingCsrfResponse.StatusCode); + + using var malformedCreateRequest = new HttpRequestMessage(HttpMethod.Post, "/admin/governance/ledger") + { + Content = new StringContent("{", Encoding.UTF8, "application/json") + }; + malformedCreateRequest.Headers.Add("Cookie", cookie); + malformedCreateRequest.Headers.Add(BrowserSessionAuthService.CsrfHeaderName, csrfToken); + var malformedCreateResponse = await harness.Client.SendAsync(malformedCreateRequest); + Assert.Equal(HttpStatusCode.BadRequest, malformedCreateResponse.StatusCode); + + using var createRequest = new HttpRequestMessage(HttpMethod.Post, "/admin/governance/ledger") + { + Content = JsonContent(entryJson) + }; + createRequest.Headers.Add("Cookie", cookie); + createRequest.Headers.Add(BrowserSessionAuthService.CsrfHeaderName, csrfToken); + var createResponse = await harness.Client.SendAsync(createRequest); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + using var createPayload = await ReadJsonAsync(createResponse); + Assert.True(createPayload.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal(GovernanceDecisions.Approved, createPayload.RootElement.GetProperty("entry").GetProperty("decision").GetString()); + Assert.DoesNotContain("sk-testsecret123", createPayload.RootElement.GetProperty("entry").GetProperty("redactedArguments").GetString()); + + using var detailRequest = new HttpRequestMessage(HttpMethod.Get, "/admin/governance/ledger/gov_admin"); + detailRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", harness.AuthToken); + var detailResponse = await harness.Client.SendAsync(detailRequest); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + using var detailPayload = await ReadJsonAsync(detailResponse); + Assert.Equal("gov_admin", detailPayload.RootElement.GetProperty("entry").GetProperty("id").GetString()); + + using var listRequest = new HttpRequestMessage(HttpMethod.Get, "/admin/governance/ledger?decision=approved&toolName=shell&sessionId=session-governance"); + listRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", harness.AuthToken); + var listResponse = await harness.Client.SendAsync(listRequest); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + using var listPayload = await ReadJsonAsync(listResponse); + Assert.Single(listPayload.RootElement.GetProperty("items").EnumerateArray()); + + using var revokeRequest = new HttpRequestMessage(HttpMethod.Post, "/admin/governance/ledger/gov_admin/revoke") + { + Content = JsonContent("""{"reason":"scope changed"}""") + }; + revokeRequest.Headers.Add("Cookie", cookie); + revokeRequest.Headers.Add(BrowserSessionAuthService.CsrfHeaderName, csrfToken); + var revokeResponse = await harness.Client.SendAsync(revokeRequest); + Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode); + using var revokePayload = await ReadJsonAsync(revokeResponse); + Assert.Equal(GovernanceDecisionStatuses.Revoked, revokePayload.RootElement.GetProperty("entry").GetProperty("status").GetString()); + Assert.Equal("scope changed", revokePayload.RootElement.GetProperty("entry").GetProperty("revocationReason").GetString()); + + using var malformedRevokeRequest = new HttpRequestMessage(HttpMethod.Post, "/admin/governance/ledger/gov_admin/revoke") + { + Content = new StringContent("{", Encoding.UTF8, "application/json") + }; + malformedRevokeRequest.Headers.Add("Cookie", cookie); + malformedRevokeRequest.Headers.Add(BrowserSessionAuthService.CsrfHeaderName, csrfToken); + var malformedRevokeResponse = await harness.Client.SendAsync(malformedRevokeRequest); + Assert.Equal(HttpStatusCode.BadRequest, malformedRevokeResponse.StatusCode); + + var events = harness.Runtime.Operations.RuntimeEvents.Query(new RuntimeEventQuery { Component = "harness", Limit = 10 }); + Assert.Contains(events, item => item.Action == "governance_ledger_entry_recorded" && item.CorrelationId == "gov_admin"); + Assert.Contains(events, item => item.Action == "governance_ledger_entry_revoked" && item.CorrelationId == "gov_admin"); + } + [Fact] public async Task AuthOperatorToken_ExchangeAndRevocation_Work() { @@ -925,6 +1039,16 @@ public async Task AdminAuditExport_WritesExpectedBundleAndClampsToRetentionWindo Summary = "new", Success = true }); + await CreateGovernanceLedgerService(harness).CreateAsync(new GovernanceLedgerEntry + { + Id = "gov_audit_export", + Decision = GovernanceDecisions.Approved, + Status = GovernanceDecisionStatuses.Active, + Source = GovernanceLedgerSources.Manual, + ActionSummary = "Audit export governance record.", + ToolName = "shell", + SessionId = "sess-audit-export" + }, CancellationToken.None); using var exportRequest = new HttpRequestMessage(HttpMethod.Get, $"/admin/audit/export?fromUtc={Uri.EscapeDataString(DateTimeOffset.UtcNow.AddDays(-30).ToString("O"))}"); exportRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token!.Token); @@ -942,6 +1066,7 @@ public async Task AdminAuditExport_WritesExpectedBundleAndClampsToRetentionWindo Assert.Contains("provider-routes.json", names); Assert.Contains("dead-letter.jsonl", names); Assert.Contains("session-metadata.json", names); + Assert.DoesNotContain("governance-ledger.jsonl", names); using var manifestStream = archive.GetEntry("manifest.json")!.Open(); using var manifestDoc = await JsonDocument.ParseAsync(manifestStream); @@ -950,6 +1075,14 @@ public async Task AdminAuditExport_WritesExpectedBundleAndClampsToRetentionWindo manifestDoc.RootElement.GetProperty("warnings").EnumerateArray().Select(static item => item.GetString()).OfType(), value => value.Contains("retention window", StringComparison.OrdinalIgnoreCase)); Assert.True(manifestDoc.RootElement.GetProperty("fileEntryCounts").GetProperty("operator-audit.jsonl").GetInt32() >= 1); + + using var governanceExportRequest = new HttpRequestMessage(HttpMethod.Get, $"/admin/audit/export?includeGovernance=true&fromUtc={Uri.EscapeDataString(DateTimeOffset.UtcNow.AddDays(-1).ToString("O"))}"); + governanceExportRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + var governanceExportResponse = await harness.Client.SendAsync(governanceExportRequest); + governanceExportResponse.EnsureSuccessStatusCode(); + var governanceBytes = await governanceExportResponse.Content.ReadAsByteArrayAsync(); + using var governanceArchive = new ZipArchive(new MemoryStream(governanceBytes), ZipArchiveMode.Read); + Assert.Contains(governanceArchive.Entries, entry => entry.FullName == "governance-ledger.jsonl"); } [Fact] @@ -1047,6 +1180,25 @@ await evidenceService.CreateAsync(new EvidenceBundle } } }, CancellationToken.None); + await CreateGovernanceLedgerService(harness).CreateAsync(new GovernanceLedgerEntry + { + Id = "gov-trajectory", + Decision = GovernanceDecisions.Approved, + Status = GovernanceDecisionStatuses.Active, + Source = GovernanceLedgerSources.Manual, + ActionSummary = "Approved action for alice@example.com with sk-testsecret123.", + ArgumentSummary = """{"email":"alice@example.com","token":"sk-testsecret123"}""", + RedactedArguments = """{"email":"alice@example.com","token":"sk-testsecret123"}""", + ToolName = "web_fetch", + RiskLevel = GovernanceRiskLevels.Medium, + Scope = GovernanceScopes.Session, + SessionId = "sess-trajectory", + ChannelId = "web", + SenderId = "alice@example.com", + DecidedBy = "alice@example.com", + DecisionReason = "reviewed with sk-testsecret123", + Tags = ["alice@example.com", "sk-testsecret123"] + }, CancellationToken.None); using var request = new HttpRequestMessage(HttpMethod.Get, "/admin/trajectory/export?sessionId=sess-trajectory&anonymize=true"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", harness.AuthToken); @@ -1060,6 +1212,7 @@ await evidenceService.CreateAsync(new EvidenceBundle Assert.Contains("\"type\":\"tool_result\"", jsonl); Assert.Contains("anon_", jsonl); Assert.DoesNotContain("\"type\":\"evidence_bundle\"", jsonl); + Assert.DoesNotContain("\"type\":\"governance_ledger_entry\"", jsonl); Assert.DoesNotContain("alice@example.com", jsonl); Assert.DoesNotContain("sk-testsecret123", jsonl); Assert.DoesNotContain("secret-value", jsonl); @@ -1072,9 +1225,22 @@ await evidenceService.CreateAsync(new EvidenceBundle var evidenceJsonl = await evidenceResponse.Content.ReadAsStringAsync(); Assert.Contains("\"type\":\"evidence_bundle\"", evidenceJsonl); Assert.Contains("\"evidenceBundle\"", evidenceJsonl); + Assert.DoesNotContain("\"type\":\"governance_ledger_entry\"", evidenceJsonl); Assert.DoesNotContain("alice@example.com", evidenceJsonl); Assert.DoesNotContain("sk-testsecret123", evidenceJsonl); Assert.DoesNotContain("secret-value", evidenceJsonl); + + using var governanceRequest = new HttpRequestMessage(HttpMethod.Get, "/admin/trajectory/export?sessionId=sess-trajectory&anonymize=true&includeGovernance=true"); + governanceRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", harness.AuthToken); + var governanceResponse = await harness.Client.SendAsync(governanceRequest); + + governanceResponse.EnsureSuccessStatusCode(); + var governanceJsonl = await governanceResponse.Content.ReadAsStringAsync(); + Assert.Contains("\"type\":\"governance_ledger_entry\"", governanceJsonl); + Assert.Contains("\"governanceLedgerEntry\"", governanceJsonl); + Assert.DoesNotContain("alice@example.com", governanceJsonl); + Assert.DoesNotContain("sk-testsecret123", governanceJsonl); + Assert.DoesNotContain("secret-value", governanceJsonl); } [Fact] @@ -3367,6 +3533,12 @@ public async Task Responses_NonStreaming_OpenAiHttpApprovalTimeoutReturnsDeniedR .Single(item => item.EventType == "decision"); Assert.Equal("timeout", decision.DecisionSource); Assert.False(decision.Approved); + + var governance = await CreateGovernanceLedgerService(harness) + .ListAsync(new GovernanceLedgerListQuery { SessionId = approval.SessionId, Decision = GovernanceDecisions.Expired }, CancellationToken.None); + var expired = Assert.Single(governance); + Assert.Equal(GovernanceDecisionStatuses.Expired, expired.Status); + Assert.Equal(approval.ApprovalId, expired.ApprovalId); } [Fact] @@ -3478,6 +3650,37 @@ public async Task ToolsApprovals_AndHistory_AreServed() Assert.Equal("created", historyPayload.RootElement.GetProperty("items")[0].GetProperty("eventType").GetString()); } + [Fact] + public async Task ToolsApprove_RecordsGovernanceLedgerEntry() + { + await using var harness = await CreateHarnessAsync(nonLoopbackBind: true); + var approval = harness.Runtime.ToolApprovalService.Create( + "sess-governance-approve", + "telegram", + "sender1", + "shell", + """{"cmd":"echo sk-testsecret123"}""", + TimeSpan.FromMinutes(5), + action: "execute", + isMutation: true, + summary: "Run a shell command."); + harness.Runtime.ApprovalAuditStore.RecordCreated(approval); + + using var approvalResponse = await SubmitApprovalDecisionAsync(harness.Client, harness.AuthToken, approval, approved: true); + Assert.Equal(HttpStatusCode.OK, approvalResponse.StatusCode); + + using var ledgerRequest = new HttpRequestMessage(HttpMethod.Get, "/admin/governance/ledger?sessionId=sess-governance-approve&decision=approved"); + ledgerRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", harness.AuthToken); + var ledgerResponse = await harness.Client.SendAsync(ledgerRequest); + Assert.Equal(HttpStatusCode.OK, ledgerResponse.StatusCode); + using var ledgerPayload = await ReadJsonAsync(ledgerResponse); + var items = ledgerPayload.RootElement.GetProperty("items").EnumerateArray().ToArray(); + Assert.Single(items); + Assert.Equal(approval.ApprovalId, items[0].GetProperty("approvalId").GetString()); + Assert.Equal(GovernanceRiskLevels.High, items[0].GetProperty("riskLevel").GetString()); + Assert.DoesNotContain("sk-testsecret123", items[0].GetProperty("redactedArguments").GetString()); + } + [Fact] public async Task CompatibilityExport_ReturnsPostureChannelsAndCatalog() { @@ -5399,6 +5602,9 @@ public async Task AdminUiContract_ReferencedRoutes_AreMapped() "/admin/harness/evidence/{id}/items", "/admin/harness/evidence/{id}/checks", "/admin/harness/evidence/{id}/reviews", + "/admin/governance/ledger", + "/admin/governance/ledger/{id}", + "/admin/governance/ledger/{id}/revoke", "/admin/channels/auth", "/admin/channels/{channelId}/auth", "/admin/channels/{channelId}/auth/stream", @@ -5546,6 +5752,13 @@ public async Task CliMigrate_Help_NotesBareAliasRemainsLegacy() payload.RootElement.GetProperty("csrfToken").GetString()!); } + private static GovernanceLedgerService CreateGovernanceLedgerService(GatewayTestHarness harness) + => new( + new FileGovernanceLedgerStore(harness.StoragePath), + harness.Runtime.Operations.RuntimeEvents, + new RedactionPipeline([new BaselineSecretRedactor()]), + NullLogger.Instance); + private const string ShellApprovalArgumentsJson = """{"command":"pwd"}"""; private static ChatTurn BuildAssistantToolTurn(params string[] toolNames) diff --git a/src/OpenClaw.Tests/GovernanceLedgerTests.cs b/src/OpenClaw.Tests/GovernanceLedgerTests.cs new file mode 100644 index 00000000..c687213d --- /dev/null +++ b/src/OpenClaw.Tests/GovernanceLedgerTests.cs @@ -0,0 +1,294 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using OpenClaw.Core.Features; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; +using OpenClaw.Core.Security; +using OpenClaw.Gateway; +using Xunit; + +namespace OpenClaw.Tests; + +public sealed class GovernanceLedgerTests +{ + [Fact] + public void GovernanceLedgerEntry_RoundTrips_WithSourceGeneratedJson() + { + var original = BuildEntry("gov_roundtrip", GovernanceDecisions.Approved, "shell", "sess_roundtrip"); + + var json = JsonSerializer.Serialize(original, CoreJsonContext.Default.GovernanceLedgerEntry); + var restored = JsonSerializer.Deserialize(json, CoreJsonContext.Default.GovernanceLedgerEntry); + + Assert.NotNull(restored); + Assert.Equal(original.Id, restored!.Id); + Assert.Equal(GovernanceDecisions.Approved, restored.Decision); + Assert.Equal(GovernanceDecisionStatuses.Active, restored.Status); + Assert.Equal(GovernanceScopes.Session, restored.Scope); + Assert.Equal("hctr_1", restored.HarnessContractId); + Assert.Equal("evb_1", restored.EvidenceBundleId); + Assert.Equal("review before allowing future reuse", restored.PolicyHint?.Notes); + } + + [Fact] + public async Task FileGovernanceLedgerStore_SavesLoadsAndFilters() + { + var root = CreateTempDir(); + var store = new FileGovernanceLedgerStore(root); + await store.SaveAsync(BuildEntry("gov_approved", GovernanceDecisions.Approved, "shell", "sess_one"), CancellationToken.None); + await store.SaveAsync(BuildEntry("gov_rejected", GovernanceDecisions.Rejected, "file_write", "sess_two"), CancellationToken.None); + + var loaded = await store.GetAsync("gov_approved", CancellationToken.None); + var byDecision = await store.ListAsync(new GovernanceLedgerListQuery { Decision = GovernanceDecisions.Rejected }, CancellationToken.None); + var byTool = await store.ListAsync(new GovernanceLedgerListQuery { ToolName = "shell" }, CancellationToken.None); + var bySession = await store.ListAsync(new GovernanceLedgerListQuery { SessionId = "sess_two" }, CancellationToken.None); + + Assert.NotNull(loaded); + Assert.Equal("gov_approved", loaded!.Id); + Assert.Single(byDecision); + Assert.Equal("gov_rejected", byDecision[0].Id); + Assert.Single(byTool); + Assert.Equal("gov_approved", byTool[0].Id); + Assert.Single(bySession); + Assert.Equal("gov_rejected", bySession[0].Id); + } + + [Fact] + public async Task FileGovernanceLedgerStore_RejectsUnsafeIds() + { + var store = new FileGovernanceLedgerStore(CreateTempDir()); + var unsafeEntry = BuildEntry("../escape", GovernanceDecisions.Approved, "shell", "sess"); + + await Assert.ThrowsAsync(async () => + await store.SaveAsync(unsafeEntry, CancellationToken.None)); + await Assert.ThrowsAsync(async () => + await store.GetAsync("../escape", CancellationToken.None)); + } + + [Fact] + public async Task FileGovernanceLedgerStore_RevokeMarksEntryWithoutDeleting() + { + var store = new FileGovernanceLedgerStore(CreateTempDir()); + await store.SaveAsync(BuildEntry("gov_revoke", GovernanceDecisions.Approved, "shell", "sess"), CancellationToken.None); + + var revoked = await store.RevokeAsync("gov_revoke", "operator", "scope changed", CancellationToken.None); + var loaded = await store.GetAsync("gov_revoke", CancellationToken.None); + + Assert.NotNull(revoked); + Assert.Equal(GovernanceDecisionStatuses.Revoked, revoked!.Status); + Assert.Equal(GovernanceDecisions.Approved, revoked.Decision); + Assert.Equal("operator", revoked.RevokedBy); + Assert.Equal("scope changed", revoked.RevocationReason); + Assert.NotNull(loaded); + } + + [Fact] + public async Task FileGovernanceLedgerStore_RevokeRejectsBlankActorOrReason() + { + var store = new FileGovernanceLedgerStore(CreateTempDir()); + await store.SaveAsync(BuildEntry("gov_revoke_blank", GovernanceDecisions.Approved, "shell", "sess"), CancellationToken.None); + + await Assert.ThrowsAsync(async () => + await store.RevokeAsync("gov_revoke_blank", "", "scope changed", CancellationToken.None)); + await Assert.ThrowsAsync(async () => + await store.RevokeAsync("gov_revoke_blank", "operator", " ", CancellationToken.None)); + } + + [Fact] + public async Task GovernanceLedgerService_RecordsDecisionsAndRuntimeEvents() + { + var root = CreateTempDir(); + var service = CreateService(root); + var approval = BuildApprovalRequest("apr_approved", "shell", isMutation: true); + var rejection = BuildApprovalRequest("apr_rejected", "file_write", isMutation: true); + var expired = BuildApprovalRequest("apr_expired", "web_fetch", isMutation: false); + var grant = BuildApprovalRequest("grant_1", "file_read", isMutation: false); + + var approved = await service.RecordApprovalAsync(approval, GovernanceLedgerSources.ToolApproval, "operator", "web", "operator", CancellationToken.None); + var rejected = await service.RecordRejectionAsync(rejection, GovernanceLedgerSources.ToolApproval, "operator", "web", "operator", CancellationToken.None); + var timedOut = await service.RecordExpiredAsync(expired, GovernanceLedgerSources.ApprovalTimeout, "timeout", CancellationToken.None); + var grantConsumed = await service.RecordApprovalAsync(grant, GovernanceLedgerSources.ApprovalGrantConsumed, "grant-admin", null, "grant-admin", CancellationToken.None); + + Assert.Equal(GovernanceDecisions.Approved, approved.Decision); + Assert.Equal(GovernanceRiskLevels.High, approved.RiskLevel); + Assert.Equal(GovernanceDecisions.Rejected, rejected.Decision); + Assert.Equal(GovernanceDecisions.Expired, timedOut.Decision); + Assert.Equal(GovernanceDecisionStatuses.Expired, timedOut.Status); + Assert.Equal(GovernanceLedgerSources.ApprovalGrantConsumed, grantConsumed.Source); + Assert.DoesNotContain("sk-testsecret123", approved.RedactedArguments ?? ""); + + var events = new RuntimeEventStore(root, NullLogger.Instance) + .Query(new RuntimeEventQuery { Component = "harness", Action = "governance_ledger_entry_recorded", Limit = 10 }); + Assert.Equal(4, events.Count); + } + + [Fact] + public async Task GovernanceLedgerService_RedactsFreeformFieldsAndGrantScope() + { + var root = CreateTempDir(); + var service = CreateService(root); + var redacted = await service.CreateAsync(new GovernanceLedgerEntry + { + Id = "gov_redacted", + Decision = GovernanceDecisions.Approved, + Status = GovernanceDecisionStatuses.Active, + Source = GovernanceLedgerSources.Manual, + ActionSummary = "approved sk-testsecret123", + DecisionReason = "reason sk-testsecret123", + RevocationReason = "revoked sk-testsecret123", + RiskLevel = GovernanceRiskLevels.Medium, + Scope = GovernanceScopes.Session, + Tags = ["tag-sk-testsecret123"], + PolicyHint = new GovernancePolicyHint + { + SuggestedFutureBehavior = "reuse sk-testsecret123", + SuggestedScope = GovernanceScopes.Session, + Notes = "note sk-testsecret123" + }, + Metadata = new GovernanceLedgerMetadata + { + CreatedBy = "operator sk-testsecret123", + CorrelationId = "corr_redacted", + Properties = new Dictionary { ["secret"] = "value sk-testsecret123" } + } + }, CancellationToken.None); + + Assert.DoesNotContain("sk-testsecret123", redacted.ActionSummary); + Assert.DoesNotContain("sk-testsecret123", redacted.DecisionReason ?? ""); + Assert.DoesNotContain("sk-testsecret123", redacted.RevocationReason ?? ""); + Assert.DoesNotContain("sk-testsecret123", redacted.Tags[0]); + Assert.DoesNotContain("sk-testsecret123", redacted.PolicyHint?.SuggestedFutureBehavior ?? ""); + Assert.DoesNotContain("sk-testsecret123", redacted.PolicyHint?.Notes ?? ""); + Assert.DoesNotContain("sk-testsecret123", redacted.Metadata?.CreatedBy ?? ""); + Assert.DoesNotContain("sk-testsecret123", redacted.Metadata?.Properties["secret"] ?? ""); + + await service.TryRecordApprovalGrantConsumedAsync( + new ToolApprovalGrant + { + Id = "grant_scope", + Scope = "session", + SessionId = "sess_grant", + ToolName = "file_read", + GrantedBy = "operator", + GrantSource = "test", + RemainingUses = 2 + }, + BuildApprovalRequest("grant_scope", "file_read", isMutation: false) with + { + SessionId = "sess_grant" + }, + CancellationToken.None); + + var grantEntries = await service.ListAsync(new GovernanceLedgerListQuery { SessionId = "sess_grant", Limit = 0 }, CancellationToken.None); + var grantEntry = Assert.Single(grantEntries); + Assert.Equal(GovernanceLedgerSources.ApprovalGrantConsumed, grantEntry.Source); + Assert.Equal(GovernanceScopes.Session, grantEntry.Scope); + Assert.Equal("sess_grant", grantEntry.ScopeKey); + } + + [Theory] + [InlineData("decision")] + [InlineData("status")] + [InlineData("scope")] + [InlineData("risk")] + [InlineData("source")] + public async Task GovernanceLedgerService_ValidatesSupportedConstants(string invalidField) + { + var service = CreateService(CreateTempDir()); + var entry = invalidField switch + { + "decision" => BuildEntry("gov_invalid", "maybe", "shell", "sess"), + "status" => BuildEntry("gov_invalid", GovernanceDecisions.Approved, "shell", "sess", status: "stale"), + "scope" => BuildEntry("gov_invalid", GovernanceDecisions.Approved, "shell", "sess", scope: "workspace"), + "risk" => BuildEntry("gov_invalid", GovernanceDecisions.Approved, "shell", "sess", riskLevel: "severe"), + "source" => BuildEntry("gov_invalid", GovernanceDecisions.Approved, "shell", "sess", source: "robot"), + _ => BuildEntry("gov_invalid", GovernanceDecisions.Approved, "shell", "sess") + }; + + await Assert.ThrowsAsync(async () => + await service.CreateAsync(entry, CancellationToken.None)); + } + + private static GovernanceLedgerService CreateService(string root) + => new( + new FileGovernanceLedgerStore(root), + new RuntimeEventStore(root, NullLogger.Instance), + new RedactionPipeline([new BaselineSecretRedactor()]), + NullLogger.Instance); + + private static ToolApprovalRequest BuildApprovalRequest(string approvalId, string toolName, bool isMutation) + => new() + { + ApprovalId = approvalId, + SessionId = "sess_governance", + ChannelId = "web", + SenderId = "operator", + ToolName = toolName, + Arguments = """{"cmd":"echo sk-testsecret123"}""", + Action = isMutation ? "write" : "read", + IsMutation = isMutation, + Summary = "Review tool execution." + }; + + private static GovernanceLedgerEntry BuildEntry( + string id, + string decision, + string toolName, + string sessionId, + string status = GovernanceDecisionStatuses.Active, + string source = GovernanceLedgerSources.Manual, + string riskLevel = GovernanceRiskLevels.Medium, + string scope = GovernanceScopes.Session) + => new() + { + Id = id, + Decision = decision, + Status = status, + Source = source, + ActionType = "write", + ToolName = toolName, + ActionSummary = "Operator reviewed a governed action.", + ArgumentSummary = """{"secret":"sk-testsecret123"}""", + RedactedArguments = """{"secret":"sk-testsecret123"}""", + RiskLevel = riskLevel, + Scope = scope, + ScopeKey = sessionId, + SessionId = sessionId, + HarnessContractId = "hctr_1", + EvidenceBundleId = "evb_1", + LearningProposalId = "learn_1", + ApprovalId = "apr_1", + ActorId = "actor", + ChannelId = "web", + SenderId = "operator", + DecidedBy = "operator", + DecisionReason = "acceptable risk", + Tags = ["approval", "governance"], + PolicyHint = new GovernancePolicyHint + { + SuggestedFutureBehavior = "consider_reusable_grant", + SuggestedScope = GovernanceScopes.Session, + Confidence = EvidenceConfidenceLevels.Medium, + RequiresReview = true, + Notes = "review before allowing future reuse" + }, + Metadata = new GovernanceLedgerMetadata + { + CreatedBy = "test", + CorrelationId = "corr_1", + Properties = new Dictionary { ["suite"] = "governance" } + } + }; + + private static string CreateTempDir() + { + var baseDir = Path.Join(Path.GetTempPath(), "openclaw-governance-ledger-tests"); + var leaf = Guid.NewGuid().ToString("N"); + var safeLeaf = Path.GetFileName(leaf); + if (!string.Equals(leaf, safeLeaf, StringComparison.Ordinal)) + throw new InvalidOperationException("Generated temp directory leaf was not a file-name-only value."); + + var path = Path.Join(baseDir, safeLeaf); + Directory.CreateDirectory(path); + return path; + } +}