Skip to content

Commit 69911aa

Browse files
committed
fix: address code review findings from #1333 redaction PR
- Redact tool results in SessionLogActor before writing to session.log so file_read's SuppressOutputRedaction doesn't leak secrets to the observability log on disk - Tighten FileEditTool Content mutual-exclusivity guard to also reject NewString and ReplaceAll (not just OldString) when Content is supplied - Always redact streaming ToolActivityUpdate chunks regardless of SuppressOutputRedaction — activity chunks are progressive display, not content the model writes back - Fix stale doc comment in ToolOutputSpill that claimed inputs are always pre-redacted - Normalize error message casing in FileWriteTool ('path' → 'Path')
1 parent d8d2091 commit 69911aa

6 files changed

Lines changed: 36 additions & 17 deletions

File tree

src/Netclaw.Actors.Tests/Tools/FileEditToolTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,30 @@ public async Task Content_and_OldString_are_mutually_exclusive()
228228
Assert.False(File.Exists(filePath));
229229
}
230230

231+
[Fact]
232+
public async Task Content_and_NewString_are_mutually_exclusive()
233+
{
234+
var filePath = Path.Combine(_dir.Path, "conflict2.txt");
235+
236+
var result = await _tool.ExecuteAsync(ToolInput.Create(
237+
"Path", filePath, "Content", "full", "NewString", "new"), CreatePersonalContext(), CancellationToken.None);
238+
239+
Assert.Contains("mutually exclusive", result);
240+
Assert.False(File.Exists(filePath));
241+
}
242+
243+
[Fact]
244+
public async Task Content_and_ReplaceAll_are_mutually_exclusive()
245+
{
246+
var filePath = Path.Combine(_dir.Path, "conflict3.txt");
247+
248+
var result = await _tool.ExecuteAsync(ToolInput.Create(
249+
"Path", filePath, "Content", "full", "ReplaceAll", true), CreatePersonalContext(), CancellationToken.None);
250+
251+
Assert.Contains("mutually exclusive", result);
252+
Assert.False(File.Exists(filePath));
253+
}
254+
231255
private ToolExecutionContext CreatePersonalContext()
232256
=> new("signalr/thread-1", _sessionDir)
233257
{

src/Netclaw.Actors/Sessions/SessionLogActor.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Akka.Event;
88
using Netclaw.Actors.Protocol;
99
using Netclaw.Actors.SubAgents;
10+
using Netclaw.Security;
1011

1112
namespace Netclaw.Actors.Sessions;
1213

@@ -107,7 +108,7 @@ private void OnOutput(SessionOutput output)
107108
{
108109
TextOutput text => $"Assistant: {TextTruncation.EllipsisAppend(text.Text, 1000)}",
109110
ToolCallOutput toolCall => FormatToolCall(toolCall),
110-
ToolResultOutput toolResult => $"Tool result: {toolResult.ToolName} (call={toolResult.CallId}) → {TextTruncation.EllipsisAppend(toolResult.Result, 1000)}",
111+
ToolResultOutput toolResult => $"Tool result: {toolResult.ToolName} (call={toolResult.CallId}) → {TextTruncation.EllipsisAppend(SecretOutputRedactor.Redact(toolResult.Result), 1000)}",
111112
ThinkingOutput thinking => $"Thinking: {TextTruncation.EllipsisAppend(thinking.Text, 1000)}",
112113
ThinkingDeltaOutput thinkingDelta => $"Thinking delta: {TextTruncation.EllipsisAppend(thinkingDelta.Delta, 1000)}",
113114
UsageOutput usage => $"Usage: in={usage.InputTokens} out={usage.OutputTokens} cached={usage.CachedInputTokens} reasoning={usage.ReasoningTokens} context={usage.UsagePercent:P0}",

src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,7 @@ public async IAsyncEnumerable<ToolCallUpdate> ExecuteStreamAsync(
149149
yield return new ToolCompletedUpdate(modelResult);
150150
break;
151151
case ToolActivityUpdate { OutputChunk: not null } activity:
152-
yield return activity with
153-
{
154-
OutputChunk = tool.SuppressOutputRedaction
155-
? activity.OutputChunk
156-
: SecretOutputRedactor.Redact(activity.OutputChunk)
157-
};
152+
yield return activity with { OutputChunk = SecretOutputRedactor.Redact(activity.OutputChunk) };
158153
break;
159154
default:
160155
yield return update;

src/Netclaw.Actors/Tools/FileEditTool.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ protected override async Task<string> ExecuteAsync(Params args, ToolExecutionCon
5858
// Full-write mode: Content is supplied without OldString
5959
if (args.Content is not null)
6060
{
61-
if (args.OldString is not null)
62-
return "Error: 'Content' and 'OldString' are mutually exclusive. " +
61+
if (args.OldString is not null || args.NewString is not null || args.ReplaceAll is not null)
62+
return "Error: 'Content' and 'OldString'/'NewString'/'ReplaceAll' are mutually exclusive. " +
6363
"Use Content for a full file write, or OldString/NewString for targeted replacement.";
6464

6565
return await WriteFileAsync(args.Path, args.Content, context, ct);

src/Netclaw.Actors/Tools/FileWriteTool.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ protected override Task<string> ExecuteAsync(Params args, CancellationToken ct)
4545
protected override Task<string> ExecuteAsync(Params args, ToolExecutionContext context, CancellationToken ct)
4646
{
4747
if (string.IsNullOrWhiteSpace(args.Path))
48-
return Task.FromResult("Error: 'path' parameter is required.");
48+
return Task.FromResult("Error: 'Path' parameter is required.");
4949

5050
return _editTool.WriteFileAsync(args.Path, args.Content, context, ct);
5151
}

src/Netclaw.Actors/Tools/ToolOutputSpill.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
namespace Netclaw.Actors.Tools;
1111

1212
/// <summary>
13-
/// Bounds an already-redacted tool result to the inline budget <c>N</c>
13+
/// Bounds a tool result to the inline budget <c>N</c>
1414
/// (<see cref="ToolExecutionContext.MaxInlineToolResultChars"/>) and, when it
1515
/// exceeds <c>N</c>, spills the full result to
1616
/// <c>{SessionDirectory}/tool-calls/{toolCallId}.log</c> and steers the model to
@@ -19,12 +19,11 @@ namespace Netclaw.Actors.Tools;
1919
/// <remarks>
2020
/// Called from <c>DispatchingToolExecutor</c> for <i>every</i> tool, right after
2121
/// the central redaction — so bounding + spill happen once, uniformly, for the
22-
/// main session and sub-agents alike (both funnel through the dispatcher), and
23-
/// the spilled file is redacted for free. Tools only bound their own <i>capture</i>
24-
/// for memory safety; they do not window or spill. The input here is already
25-
/// redacted, so this method does NOT redact again. <c>file_read</c> keeps its
26-
/// result at or under <c>N</c> and steers to the original file, so it never spills
27-
/// here (its content is its own backing store).
22+
/// main session and sub-agents alike (both funnel through the dispatcher). Tools
23+
/// only bound their own <i>capture</i> for memory safety; they do not window or
24+
/// spill. The two-param overload accepts separate model-facing and spill content
25+
/// so that tools with <c>SuppressOutputRedaction</c> can return raw results to the
26+
/// model while still writing redacted content to the spill file on disk.
2827
/// </remarks>
2928
internal static class ToolOutputSpill
3029
{

0 commit comments

Comments
 (0)