Skip to content

Commit 8b62cd5

Browse files
fix: prevent secret placeholder writeback on file read/write tools (#1333) (#1343)
* fix: suppress output redaction on file tools to prevent secret placeholder writeback (#1333) SecretOutputRedactor was replacing secret values with ***REDACTED*** placeholders in file_read output before it reached the model. When the model subsequently wrote that content back via file_write/file_edit, the placeholders were persisted to disk, destroying real secret values in config files. The fix moves redaction out of the model-facing path for file tools while keeping it on the observability path (spill files, logs, transcripts): - Add SuppressOutputRedaction flag to INetclawTool; FileReadTool opts in - DispatchingToolExecutor splits model-facing (raw) vs spill (redacted) result when the flag is set - ToolOutputSpill gains a two-param overload so spill files always use redacted content even when the inline result is unredacted - Consolidate file_write logic into FileEditTool as a Content parameter mode; FileWriteTool becomes a thin backward-compatible delegate * 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 e78426c commit 8b62cd5

10 files changed

Lines changed: 297 additions & 77 deletions

File tree

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,97 @@ public async Task Small_output_is_redacted_without_spilling()
143143
Assert.DoesNotContain("saved to", result); // small → no spill
144144
}
145145

146+
[Fact]
147+
public async Task File_read_preserves_secret_values_for_model()
148+
{
149+
var sessionDir = Path.Combine(Path.GetTempPath(), "nc-disp-" + Guid.NewGuid().ToString("N"));
150+
Directory.CreateDirectory(sessionDir);
151+
try
152+
{
153+
// file_read suppresses output redaction so the model sees real
154+
// content and can write it back without corrupting secrets (#1333).
155+
var file = Path.Combine(sessionDir, "appsettings.json");
156+
await File.WriteAllTextAsync(file,
157+
"""{"secretKey": "real-secret-value", "name": "myapp"}""",
158+
CancellationToken.None);
159+
var toolCall = new FunctionCallContent("call-secret", "file_read",
160+
ToolInput.Create("Path", file));
161+
var context = new Netclaw.Tools.ToolExecutionContext("slack/thread-1", sessionDir)
162+
{
163+
Audience = TrustAudience.Personal,
164+
};
165+
166+
var result = await _executor.ExecuteAsync(toolCall, context, CancellationToken.None);
167+
168+
Assert.Contains("real-secret-value", result);
169+
Assert.DoesNotContain("***REDACTED***", result);
170+
}
171+
finally
172+
{
173+
Directory.Delete(sessionDir, recursive: true);
174+
}
175+
}
176+
177+
[Fact]
178+
public async Task Shell_output_still_redacts_secrets()
179+
{
180+
// Shell output continues to be redacted — only file tools suppress it.
181+
var toolCall = new FunctionCallContent("call-shell-secret", "shell_execute",
182+
ToolInput.Create("Command", "echo API_KEY=secret123"));
183+
var context = new Netclaw.Tools.ToolExecutionContext("signalr/thread-1", null)
184+
{
185+
Audience = TrustAudience.Personal,
186+
};
187+
188+
var result = await _executor.ExecuteAsync(toolCall, context, CancellationToken.None);
189+
190+
Assert.Contains("***REDACTED***", result);
191+
Assert.DoesNotContain("secret123", result);
192+
}
193+
194+
[Fact]
195+
public async Task File_read_spill_file_is_redacted_even_when_model_result_is_not()
196+
{
197+
var sessionDir = Path.Combine(Path.GetTempPath(), "nc-disp-" + Guid.NewGuid().ToString("N"));
198+
Directory.CreateDirectory(sessionDir);
199+
try
200+
{
201+
// Create a file with a secret that exceeds the shell's verbose
202+
// budget so it spills. file_read uses the content budget (12000)
203+
// so we need a bigger file. Use a custom executor with a small
204+
// content budget to force a spill.
205+
var file = Path.Combine(sessionDir, "big-config.json");
206+
var bigContent = $$"""{"secretKey": "real-secret-value", "data": "{{new string('x', 15000)}}"}""";
207+
await File.WriteAllTextAsync(file, bigContent, CancellationToken.None);
208+
209+
var toolCall = new FunctionCallContent("call-spill-secret", "file_read",
210+
ToolInput.Create("Path", file));
211+
var context = new Netclaw.Tools.ToolExecutionContext("slack/thread-1", sessionDir)
212+
{
213+
Audience = TrustAudience.Personal,
214+
MaxInlineToolResultChars = 500,
215+
};
216+
217+
var result = await _executor.ExecuteAsync(toolCall, context, CancellationToken.None);
218+
219+
// The inline result (model-facing) should NOT contain the redacted sentinel
220+
Assert.DoesNotContain("***REDACTED***", result);
221+
// But it should be truncated (spilled)
222+
Assert.Contains("output saved to", result);
223+
224+
// The spill file on disk SHOULD be redacted
225+
var spillPath = Path.Combine(sessionDir, "tool-calls", "call-spill-secret.log");
226+
Assert.True(File.Exists(spillPath));
227+
var spillContent = await File.ReadAllTextAsync(spillPath, CancellationToken.None);
228+
Assert.Contains("***REDACTED***", spillContent);
229+
Assert.DoesNotContain("real-secret-value", spillContent);
230+
}
231+
finally
232+
{
233+
Directory.Delete(sessionDir, recursive: true);
234+
}
235+
}
236+
146237
[Fact]
147238
public async Task Content_tool_under_default_budget_not_spilled()
148239
{

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,78 @@ public async Task Concurrent_edits_to_same_file_all_apply()
180180
Assert.Contains($"slot{i} = EDITED;", content);
181181
}
182182

183+
// ── Full-write mode via Content parameter ──
184+
185+
[Fact]
186+
public async Task Content_creates_new_file()
187+
{
188+
var filePath = Path.Combine(_dir.Path, "new-via-content.txt");
189+
190+
var result = await _tool.ExecuteAsync(ToolInput.Create("Path", filePath, "Content", "full write"), CreatePersonalContext(), CancellationToken.None);
191+
192+
Assert.Contains("Successfully wrote", result);
193+
Assert.Equal("full write", await File.ReadAllTextAsync(filePath, TestContext.Current.CancellationToken));
194+
}
195+
196+
[Fact]
197+
public async Task Content_overwrites_existing_file()
198+
{
199+
var filePath = Path.Combine(_dir.Path, "existing.txt");
200+
await File.WriteAllTextAsync(filePath, "old", TestContext.Current.CancellationToken);
201+
202+
var result = await _tool.ExecuteAsync(ToolInput.Create("Path", filePath, "Content", "new"), CreatePersonalContext(), CancellationToken.None);
203+
204+
Assert.Contains("Successfully wrote", result);
205+
Assert.Equal("new", await File.ReadAllTextAsync(filePath, TestContext.Current.CancellationToken));
206+
}
207+
208+
[Fact]
209+
public async Task Content_creates_parent_directories()
210+
{
211+
var filePath = Path.Combine(_dir.Path, "sub", "dir", "deep.txt");
212+
213+
var result = await _tool.ExecuteAsync(ToolInput.Create("Path", filePath, "Content", "deep"), CreatePersonalContext(), CancellationToken.None);
214+
215+
Assert.Contains("Successfully wrote", result);
216+
Assert.Equal("deep", await File.ReadAllTextAsync(filePath, TestContext.Current.CancellationToken));
217+
}
218+
219+
[Fact]
220+
public async Task Content_and_OldString_are_mutually_exclusive()
221+
{
222+
var filePath = Path.Combine(_dir.Path, "conflict.txt");
223+
224+
var result = await _tool.ExecuteAsync(ToolInput.Create(
225+
"Path", filePath, "Content", "full", "OldString", "old", "NewString", "new"), CreatePersonalContext(), CancellationToken.None);
226+
227+
Assert.Contains("mutually exclusive", result);
228+
Assert.False(File.Exists(filePath));
229+
}
230+
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+
183255
private ToolExecutionContext CreatePersonalContext()
184256
=> new("signalr/thread-1", _sessionDir)
185257
{

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: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,21 @@ public async Task<string> ExecuteAsync(FunctionCallContent toolCall, ToolExecuti
6767
? await tool.ExecuteAsync(toolCall.Arguments, context, ct)
6868
: await tool.ExecuteAsync(toolCall.Arguments, ct);
6969

70-
result = SecretOutputRedactor.Redact(result);
70+
var redacted = SecretOutputRedactor.Redact(result);
71+
72+
// Tools that suppress output redaction (e.g. file_read) return the
73+
// raw result to the model so read-modify-write cycles don't corrupt
74+
// secret values with ***REDACTED*** placeholders. The spill file
75+
// (persisted to disk) always uses the redacted version.
76+
var modelFacing = tool.SuppressOutputRedaction ? result : redacted;
77+
7178
// Single, uniform bounding+spill point for every tool (main session and
7279
// sub-agents both funnel through here): cap the inline result to the
7380
// tool's budget and, when it overflows, spill the full redacted result
7481
// to a session file and steer the model to read a slice. Tools only
7582
// bound their own capture for memory safety; they do not window or spill.
7683
result = await ToolOutputSpill.BoundAndSpillAsync(
77-
result, toolCall.CallId, ResolveInlineBudget(tool, context), context, ct);
84+
modelFacing, redacted, toolCall.CallId, ResolveInlineBudget(tool, context), context, ct);
7885

7986
sw.Stop();
8087
_logger.LogInformation(
@@ -133,12 +140,13 @@ public async IAsyncEnumerable<ToolCallUpdate> ExecuteStreamAsync(
133140
case ToolCompletedUpdate completed:
134141
sw.Stop();
135142
var redacted = SecretOutputRedactor.Redact(completed.Result);
136-
redacted = await ToolOutputSpill.BoundAndSpillAsync(
137-
redacted, toolCall.CallId, ResolveInlineBudget(tool, context), context, ct);
143+
var modelResult = tool.SuppressOutputRedaction ? completed.Result : redacted;
144+
modelResult = await ToolOutputSpill.BoundAndSpillAsync(
145+
modelResult, redacted, toolCall.CallId, ResolveInlineBudget(tool, context), context, ct);
138146
_logger.LogInformation(
139147
"Tool executed: {ToolName} ({Duration}ms, {ResultLength} chars)",
140-
toolCall.Name, sw.ElapsedMilliseconds, redacted.Length);
141-
yield return new ToolCompletedUpdate(redacted);
148+
toolCall.Name, sw.ElapsedMilliseconds, modelResult.Length);
149+
yield return new ToolCompletedUpdate(modelResult);
142150
break;
143151
case ToolActivityUpdate { OutputChunk: not null } activity:
144152
yield return activity with { OutputChunk = SecretOutputRedactor.Redact(activity.OutputChunk) };

src/Netclaw.Actors/Tools/FileEditTool.cs

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
namespace Netclaw.Actors.Tools;
1313

1414
/// <summary>
15-
/// Makes targeted text replacements in an existing file without rewriting the entire file.
16-
/// Matches literal text (not regex). Fails if OldString is not found or is ambiguous.
15+
/// Edits files: targeted text replacement (OldString → NewString) or full content
16+
/// write (Content alone). When Content is supplied without OldString, the file is
17+
/// created or overwritten — this subsumes the former <c>file_write</c> tool.
1718
/// </summary>
1819
[NetclawTool(ToolName,
19-
"Make targeted text replacements in an existing file without rewriting the entire file",
20+
"Edit a file with targeted text replacement (OldString/NewString), or write entire content (Content). " +
21+
"For targeted edits, matches literal text (not regex) and fails if OldString is not found or is ambiguous. " +
22+
"For full writes, creates the file and parent directories if needed.",
2023
Grant = "file")]
2124
public sealed partial class FileEditTool : NetclawTool<FileEditTool.Params>
2225
{
@@ -26,10 +29,11 @@ public sealed partial class FileEditTool : NetclawTool<FileEditTool.Params>
2629
private readonly ScopedFileAccessPolicy _fileAccessPolicy;
2730

2831
public record Params(
29-
[property: Description("Absolute path to the file to edit")] string Path,
30-
[property: Description("The exact text to find in the file")] string OldString,
31-
[property: Description("The text to replace it with (must differ from OldString; use empty string to delete)")] string NewString,
32-
[property: Description("Replace all occurrences instead of just the first (default: false)")] bool? ReplaceAll = null);
32+
[property: Description("Absolute path to the file")] string Path,
33+
[property: Description("The exact text to find in the file (omit when using Content for a full write)")] string? OldString = null,
34+
[property: Description("The text to replace OldString with (must differ from OldString; use empty string to delete)")] string? NewString = null,
35+
[property: Description("Replace all occurrences instead of just the first (default: false)")] bool? ReplaceAll = null,
36+
[property: Description("Full content to write to the file, creating parent directories if needed. Mutually exclusive with OldString/NewString.")] string? Content = null);
3337

3438
public FileEditTool(ToolPathPolicy? pathPolicy = null)
3539
{
@@ -51,13 +55,64 @@ protected override async Task<string> ExecuteAsync(Params args, ToolExecutionCon
5155
if (string.IsNullOrWhiteSpace(args.Path))
5256
return "Error: 'Path' parameter is required.";
5357

58+
// Full-write mode: Content is supplied without OldString
59+
if (args.Content is not null)
60+
{
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. " +
63+
"Use Content for a full file write, or OldString/NewString for targeted replacement.";
64+
65+
return await WriteFileAsync(args.Path, args.Content, context, ct);
66+
}
67+
68+
// Targeted edit mode: OldString → NewString
5469
if (string.IsNullOrEmpty(args.OldString))
55-
return "Error: 'OldString' parameter is required and must not be empty.";
70+
return "Error: either 'OldString' (for targeted edit) or 'Content' (for full write) is required.";
71+
72+
if (args.NewString is null)
73+
return "Error: 'NewString' is required when using OldString for targeted replacement.";
5674

5775
if (args.NewString == args.OldString)
5876
return "Error: 'NewString' must be different from 'OldString'.";
5977

60-
if (!_fileAccessPolicy.TryResolveWritePath(args.Path, context, out var authorizedPath, out var accessError))
78+
return await EditFileAsync(args.Path, args.OldString, args.NewString, args.ReplaceAll == true, context, ct);
79+
}
80+
81+
internal async Task<string> WriteFileAsync(string path, string content, ToolExecutionContext context, CancellationToken ct)
82+
{
83+
if (!_fileAccessPolicy.TryResolveWritePath(path, context, out var authorizedPath, out var accessError))
84+
return accessError;
85+
86+
if (_pathPolicy?.IsDenied(authorizedPath) == true)
87+
return FileToolErrors.ControlPlaneWriteDenied(authorizedPath);
88+
89+
try
90+
{
91+
var directory = System.IO.Path.GetDirectoryName(authorizedPath);
92+
if (!string.IsNullOrEmpty(directory))
93+
Directory.CreateDirectory(directory);
94+
95+
var bytes = Encoding.UTF8.GetBytes(content);
96+
97+
return await FileMutationGate.RunExclusiveAsync(authorizedPath, async () =>
98+
{
99+
await File.WriteAllBytesAsync(authorizedPath, bytes, ct);
100+
return $"Successfully wrote {bytes.Length} bytes to {authorizedPath}";
101+
}, ct);
102+
}
103+
catch (UnauthorizedAccessException)
104+
{
105+
return $"Error: Permission denied: {authorizedPath}";
106+
}
107+
catch (IOException ex)
108+
{
109+
return $"Error writing file: {ex.Message}";
110+
}
111+
}
112+
113+
private async Task<string> EditFileAsync(string path, string oldString, string newString, bool replaceAll, ToolExecutionContext context, CancellationToken ct)
114+
{
115+
if (!_fileAccessPolicy.TryResolveWritePath(path, context, out var authorizedPath, out var accessError))
61116
return accessError;
62117

63118
if (_pathPolicy?.IsDenied(authorizedPath) == true)
@@ -74,8 +129,7 @@ protected override async Task<string> ExecuteAsync(Params args, ToolExecutionCon
74129
{
75130
var content = await File.ReadAllTextAsync(authorizedPath, Encoding.UTF8, ct);
76131

77-
var replaceAll = args.ReplaceAll == true;
78-
var occurrences = CountOccurrences(content, args.OldString);
132+
var occurrences = CountOccurrences(content, oldString);
79133

80134
if (occurrences == 0)
81135
return $"Error: The specified text was not found in {authorizedPath}";
@@ -89,17 +143,16 @@ protected override async Task<string> ExecuteAsync(Params args, ToolExecutionCon
89143

90144
if (replaceAll)
91145
{
92-
newContent = content.Replace(args.OldString, args.NewString, StringComparison.Ordinal);
146+
newContent = content.Replace(oldString, newString, StringComparison.Ordinal);
93147
replacementCount = occurrences;
94148
}
95149
else
96150
{
97-
// Single replacement at first occurrence
98-
var index = content.IndexOf(args.OldString, StringComparison.Ordinal);
151+
var index = content.IndexOf(oldString, StringComparison.Ordinal);
99152
newContent = string.Concat(
100153
content.AsSpan(0, index),
101-
args.NewString,
102-
content.AsSpan(index + args.OldString.Length));
154+
newString,
155+
content.AsSpan(index + oldString.Length));
103156
replacementCount = 1;
104157
}
105158

src/Netclaw.Actors/Tools/FileReadTool.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public sealed partial class FileReadTool : NetclawTool<FileReadTool.Params>
3333
private static readonly Encoding StrictUtf32Be = new UTF32Encoding(bigEndian: true, byteOrderMark: true, throwOnInvalidCharacters: true);
3434
private static readonly Encoding Windows1252 = new Windows1252Encoding();
3535

36+
public override bool SuppressOutputRedaction => true;
37+
3638
private readonly ToolConfig _config;
3739
private readonly ToolPathPolicy? _pathPolicy;
3840
private readonly ScopedFileAccessPolicy _fileAccessPolicy;

0 commit comments

Comments
 (0)