Skip to content

Commit d2534f2

Browse files
committed
Merge heartbeat, contract governance, and audit fixes
2 parents 0f2a02c + b57e7e1 commit d2534f2

59 files changed

Lines changed: 5733 additions & 139 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/OpenClaw.Agent/AgentRuntime.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ public AgentRuntime(
8888
long sessionTokenBudget = 0,
8989
MemoryRecallConfig? recall = null,
9090
IToolSandbox? toolSandbox = null,
91-
GatewayConfig? gatewayConfig = null)
91+
GatewayConfig? gatewayConfig = null,
92+
ToolUsageTracker? toolUsageTracker = null)
9293
{
9394
_chatClient = chatClient;
9495
_tools = tools;
@@ -129,7 +130,8 @@ public AgentRuntime(
129130
metrics,
130131
logger,
131132
config: gatewayConfig,
132-
toolSandbox: toolSandbox);
133+
toolSandbox: toolSandbox,
134+
toolUsageTracker: toolUsageTracker);
133135
_cachedToolDeclarations = _toolExecutor.ToolDeclarations;
134136
_sessionTokenBudget = sessionTokenBudget;
135137
_recall = recall;
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
using System.Text.Json;
2+
using Microsoft.Extensions.Logging;
3+
using OpenClaw.Core.Abstractions;
4+
using OpenClaw.Core.Models;
5+
using OpenClaw.Agent.Tools;
6+
7+
namespace OpenClaw.Agent;
8+
9+
/// <summary>
10+
/// Enforces contract-scoped tool restrictions at tool-call time.
11+
/// Checks path-scoped capabilities and tool call count limits.
12+
/// </summary>
13+
public sealed class ContractScopeHook : IToolHookWithContext
14+
{
15+
private readonly Func<string, ContractPolicy?> _contractResolver;
16+
private readonly Func<string, int> _toolCallCounter;
17+
private readonly ILogger _logger;
18+
19+
public string Name => "ContractScope";
20+
21+
/// <param name="contractResolver">Resolves a session ID to its contract policy (or null).</param>
22+
/// <param name="toolCallCounter">Returns the current tool call count for a session ID.</param>
23+
/// <param name="logger">Logger instance.</param>
24+
public ContractScopeHook(
25+
Func<string, ContractPolicy?> contractResolver,
26+
Func<string, int> toolCallCounter,
27+
ILogger logger)
28+
{
29+
_contractResolver = contractResolver;
30+
_toolCallCounter = toolCallCounter;
31+
_logger = logger;
32+
}
33+
34+
public ValueTask<bool> BeforeExecuteAsync(string toolName, string arguments, CancellationToken ct)
35+
=> ValueTask.FromResult(true); // No-op for non-context path; context variant handles enforcement.
36+
37+
public ValueTask<bool> BeforeExecuteAsync(ToolHookContext context, CancellationToken ct)
38+
{
39+
var policy = _contractResolver(context.SessionId);
40+
if (policy is null)
41+
return ValueTask.FromResult(true);
42+
43+
// Check MaxToolCalls
44+
if (policy.MaxToolCalls > 0)
45+
{
46+
var count = _toolCallCounter(context.SessionId);
47+
if (count >= policy.MaxToolCalls)
48+
{
49+
_logger.LogInformation(
50+
"ContractScope: denied tool {Tool} for session {Session} — MaxToolCalls ({Max}) reached",
51+
context.ToolName, context.SessionId, policy.MaxToolCalls);
52+
return ValueTask.FromResult(false);
53+
}
54+
}
55+
56+
// Check scoped capabilities (path restrictions)
57+
var scope = FindScope(policy, context.ToolName);
58+
if (scope is not null && scope.AllowedPaths.Length > 0)
59+
{
60+
if (TryExtractPathArgument(context.ToolName, context.ArgumentsJson, out var path) &&
61+
!string.IsNullOrWhiteSpace(path))
62+
{
63+
if (!IsPathAllowed(path!, scope.AllowedPaths))
64+
{
65+
_logger.LogInformation(
66+
"ContractScope: denied tool {Tool} path {Path} for session {Session} — outside scoped paths",
67+
context.ToolName, path, context.SessionId);
68+
return ValueTask.FromResult(false);
69+
}
70+
}
71+
}
72+
73+
return ValueTask.FromResult(true);
74+
}
75+
76+
public ValueTask AfterExecuteAsync(string toolName, string arguments, string result, TimeSpan duration, bool failed, CancellationToken ct)
77+
=> ValueTask.CompletedTask;
78+
79+
public ValueTask AfterExecuteAsync(ToolHookContext context, string result, TimeSpan duration, bool failed, CancellationToken ct)
80+
=> ValueTask.CompletedTask;
81+
82+
private static ScopedCapability? FindScope(ContractPolicy policy, string toolName)
83+
{
84+
foreach (var scope in policy.ScopedCapabilities)
85+
{
86+
if (string.Equals(scope.ToolName, toolName, StringComparison.Ordinal))
87+
return scope;
88+
}
89+
return null;
90+
}
91+
92+
private static bool IsPathAllowed(string path, string[] allowedPaths)
93+
{
94+
var expanded = ExpandTilde(path);
95+
var full = ToolPathPolicy.ResolveRealPath(expanded);
96+
97+
var comparison = OperatingSystem.IsWindows()
98+
? StringComparison.OrdinalIgnoreCase
99+
: StringComparison.Ordinal;
100+
101+
foreach (var allowed in allowedPaths)
102+
{
103+
var allowedExpanded = ExpandTilde(allowed.Trim());
104+
var allowedFull = Path.GetFullPath(allowedExpanded);
105+
106+
if (string.Equals(full, allowedFull, comparison))
107+
return true;
108+
109+
var root = allowedFull.EndsWith(Path.DirectorySeparatorChar)
110+
? allowedFull
111+
: allowedFull + Path.DirectorySeparatorChar;
112+
113+
if (full.StartsWith(root, comparison))
114+
return true;
115+
}
116+
117+
return false;
118+
}
119+
120+
private static bool TryExtractPathArgument(string toolName, string arguments, out string? path)
121+
{
122+
path = null;
123+
var prop = toolName switch
124+
{
125+
"git" => "cwd",
126+
_ => "path"
127+
};
128+
129+
try
130+
{
131+
using var doc = JsonDocument.Parse(arguments);
132+
if (doc.RootElement.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.String)
133+
{
134+
path = p.GetString();
135+
return !string.IsNullOrWhiteSpace(path);
136+
}
137+
}
138+
catch { }
139+
140+
return false;
141+
}
142+
143+
private static string ExpandTilde(string path)
144+
{
145+
if (path.StartsWith("~/", StringComparison.Ordinal) || path == "~")
146+
{
147+
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
148+
return path.Length == 1 ? home : Path.Combine(home, path[2..]);
149+
}
150+
return path;
151+
}
152+
}

src/OpenClaw.Agent/IAgentRuntimeFactory.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public sealed class AgentRuntimeFactoryContext
2727
public required bool RequireToolApproval { get; init; }
2828
public required IReadOnlyList<string> ApprovalRequiredTools { get; init; }
2929
public IToolSandbox? ToolSandbox { get; init; }
30+
public ToolUsageTracker? ToolUsageTracker { get; init; }
3031
}
3132

3233
public interface IAgentRuntimeFactory

src/OpenClaw.Agent/NativeAgentRuntimeFactory.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ public sealed class NativeAgentRuntimeFactory : IAgentRuntimeFactory
99
private AgentRuntime CreateRuntime(
1010
Microsoft.Extensions.AI.IChatClient chatClient,
1111
IReadOnlyList<OpenClaw.Core.Abstractions.ITool> tools,
12-
AgentRuntimeFactoryContext context)
12+
AgentRuntimeFactoryContext context,
13+
OpenClaw.Core.Observability.ToolUsageTracker? toolUsageTracker = null)
1314
=> new(
1415
chatClient,
1516
tools,
@@ -35,11 +36,13 @@ private AgentRuntime CreateRuntime(
3536
sessionTokenBudget: context.Config.SessionTokenBudget,
3637
recall: context.Config.Memory.Recall,
3738
toolSandbox: context.ToolSandbox,
38-
gatewayConfig: context.Config);
39+
gatewayConfig: context.Config,
40+
toolUsageTracker: toolUsageTracker);
3941

4042
public IAgentRuntime Create(AgentRuntimeFactoryContext context)
4143
{
42-
IAgentRuntime agentRuntime = CreateRuntime(context.ChatClient, context.Tools, context);
44+
var toolUsageTracker = context.ToolUsageTracker;
45+
IAgentRuntime agentRuntime = CreateRuntime(context.ChatClient, context.Tools, context, toolUsageTracker);
4346

4447
if (!context.Config.Delegation.Enabled || context.Config.Delegation.Profiles.Count == 0)
4548
return agentRuntime;
@@ -55,6 +58,6 @@ public IAgentRuntime Create(AgentRuntimeFactoryContext context)
5558
logger: context.Logger,
5659
recall: context.Config.Memory.Recall);
5760

58-
return CreateRuntime(context.ChatClient, [.. context.Tools, delegateTool], context);
61+
return CreateRuntime(context.ChatClient, [.. context.Tools, delegateTool], context, toolUsageTracker);
5962
}
6063
}

src/OpenClaw.Agent/OpenClawToolExecutor.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public sealed class OpenClawToolExecutor
3030
private readonly ILogger? _logger;
3131
private readonly GatewayConfig _config;
3232
private readonly IToolSandbox? _toolSandbox;
33+
private readonly ToolUsageTracker? _toolUsageTracker;
3334

3435
public OpenClawToolExecutor(
3536
IReadOnlyList<ITool> tools,
@@ -40,7 +41,8 @@ public OpenClawToolExecutor(
4041
RuntimeMetrics? metrics = null,
4142
ILogger? logger = null,
4243
GatewayConfig? config = null,
43-
IToolSandbox? toolSandbox = null)
44+
IToolSandbox? toolSandbox = null,
45+
ToolUsageTracker? toolUsageTracker = null)
4446
{
4547
_toolsByName = tools.ToDictionary(t => t.Name, StringComparer.Ordinal);
4648
_toolDeclarations = tools.Select(CreateDeclaration).Cast<AITool>().ToArray();
@@ -63,6 +65,7 @@ public OpenClawToolExecutor(
6365
}
6466
};
6567
_toolSandbox = toolSandbox;
68+
_toolUsageTracker = toolUsageTracker;
6669
}
6770

6871
public IList<AITool> ToolDeclarations => _toolDeclarations;
@@ -213,6 +216,7 @@ public async Task<ToolExecutionResult> ExecuteAsync(
213216

214217
_metrics?.IncrementToolCalls();
215218
turnCtx.RecordToolCall(sw.Elapsed, toolFailed, toolTimedOut);
219+
_toolUsageTracker?.RecordToolCall(tool.Name, sw.Elapsed, toolFailed, toolTimedOut);
216220
_logger?.LogDebug("[{CorrelationId}] Tool {Tool} completed in {Duration}ms ok={Ok}",
217221
turnCtx.CorrelationId,
218222
tool.Name,

src/OpenClaw.Channels/TelegramChannel.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using OpenClaw.Core.Abstractions;
77
using OpenClaw.Core.Http;
88
using OpenClaw.Core.Models;
9+
using OpenClaw.Core.Security;
910

1011
namespace OpenClaw.Channels;
1112

@@ -26,9 +27,7 @@ public TelegramChannel(TelegramChannelConfig config, ILogger<TelegramChannel> lo
2627
_logger = logger;
2728
_http = HttpClientFactory.Create();
2829

29-
var tokenSource = config.BotTokenRef.StartsWith("env:")
30-
? Environment.GetEnvironmentVariable(config.BotTokenRef[4..])
31-
: config.BotToken;
30+
var tokenSource = SecretResolver.Resolve(config.BotTokenRef) ?? config.BotToken;
3231

3332
_botToken = tokenSource ?? throw new InvalidOperationException("Telegram bot token not configured or missing from environment.");
3433
}

src/OpenClaw.Channels/WebSocketChannel.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,15 @@ public async ValueTask DisposeAsync()
280280
{
281281
// ignore
282282
}
283+
284+
try
285+
{
286+
kvp.Value.SendLock.Dispose();
287+
}
288+
catch
289+
{
290+
// ignore
291+
}
283292
}
284293

285294
_connections.Clear();
@@ -319,13 +328,15 @@ private bool TryAddConnection(string clientId, WebSocket ws, IPAddress? remoteIp
319328
{
320329
_connectionsPerIp.AddOrUpdate(state.IpKey, 0, (_, c) => Math.Max(0, c - 1));
321330
Interlocked.Decrement(ref _connectionCount);
331+
state.SendLock.Dispose();
322332
return false;
323333
}
324334

325335
if (!_connections.TryAdd(clientId, state))
326336
{
327337
_connectionsPerIp.AddOrUpdate(state.IpKey, 0, (_, c) => Math.Max(0, c - 1));
328338
Interlocked.Decrement(ref _connectionCount);
339+
state.SendLock.Dispose();
329340
return false;
330341
}
331342

@@ -338,6 +349,7 @@ private void RemoveConnection(string clientId, ConnectionState state)
338349
Interlocked.Decrement(ref _connectionCount);
339350
_connectionsPerIp.AddOrUpdate(state.IpKey, 0, (_, c) => Math.Max(0, c - 1));
340351
try { state.Socket.Dispose(); } catch { /* ignore */ }
352+
try { state.SendLock.Dispose(); } catch { /* ignore */ }
341353
}
342354

343355
private async Task<string?> ReceiveFullTextMessageAsync(WebSocket ws, CancellationToken ct)

src/OpenClaw.Cli/OpenClawHttpClient.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,17 @@ public async Task<string> StreamChatCompletionAsync(
2020
CancellationToken cancellationToken)
2121
=> await _inner.StreamChatCompletionAsync(request, onText, cancellationToken);
2222

23+
public Task<HeartbeatPreviewResponse> GetHeartbeatAsync(CancellationToken cancellationToken)
24+
=> _inner.GetHeartbeatAsync(cancellationToken);
25+
26+
public Task<HeartbeatPreviewResponse> PreviewHeartbeatAsync(HeartbeatConfigDto request, CancellationToken cancellationToken)
27+
=> _inner.PreviewHeartbeatAsync(request, cancellationToken);
28+
29+
public Task<HeartbeatPreviewResponse> SaveHeartbeatAsync(HeartbeatConfigDto request, CancellationToken cancellationToken)
30+
=> _inner.SaveHeartbeatAsync(request, cancellationToken);
31+
32+
public Task<HeartbeatStatusResponse> GetHeartbeatStatusAsync(CancellationToken cancellationToken)
33+
=> _inner.GetHeartbeatStatusAsync(cancellationToken);
34+
2335
public void Dispose() => _inner.Dispose();
2436
}

0 commit comments

Comments
 (0)