Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions docs/GOVERNANCE_LEDGER.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
11 changes: 11 additions & 0 deletions src/OpenClaw.Core/Abstractions/IGovernanceLedgerStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using OpenClaw.Core.Models;

namespace OpenClaw.Core.Abstractions;

public interface IGovernanceLedgerStore
{
ValueTask SaveAsync(GovernanceLedgerEntry entry, CancellationToken ct);
ValueTask<GovernanceLedgerEntry?> GetAsync(string id, CancellationToken ct);
ValueTask<IReadOnlyList<GovernanceLedgerEntry>> ListAsync(GovernanceLedgerListQuery query, CancellationToken ct);
ValueTask<GovernanceLedgerEntry?> RevokeAsync(string id, string revokedBy, string reason, CancellationToken ct);
}
284 changes: 284 additions & 0 deletions src/OpenClaw.Core/Features/FileGovernanceLedgerStore.cs
Original file line number Diff line number Diff line change
@@ -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<GovernanceLedgerEntry?> GetAsync(string id, CancellationToken ct)
{
EnsureSafeId(id);
return LoadOneAsync(FileForId(id), ct);
}

public async ValueTask<IReadOnlyList<GovernanceLedgerEntry>> ListAsync(GovernanceLedgerListQuery query, CancellationToken ct)
{
query ??= new GovernanceLedgerListQuery();
var results = new List<GovernanceLedgerEntry>();
IEnumerable<FileInfo> 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<GovernanceLedgerEntry?> 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<GovernanceLedgerEntry?> 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);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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('=');
}
}
Loading