@lazyingart/agintiflow 0.20.73 → 0.20.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.73",
3
+ "version": "0.20.75",
4
4
  "type": "module",
5
5
  "description": "Low-cost, project-aware Web and CLI agents with DeepSeek/Venice/OpenAI routing, visible tool calls, durable sessions, scouts, AAPS, SCS, and guarded local execution.",
6
6
  "license": "Apache-2.0",
@@ -78,6 +78,31 @@ try {
78
78
  };
79
79
  const repair = repairModelMessageHistory(staleDeepSeekState, { provider: "deepseek" });
80
80
  assert(repair.changed, "stale DeepSeek history was not repaired");
81
+
82
+ const interleavedToolState = {
83
+ messages: [
84
+ { role: "system", content: "system" },
85
+ { role: "user", content: "do guarded writes" },
86
+ {
87
+ role: "assistant",
88
+ content: "",
89
+ tool_calls: [
90
+ { id: "call-a", type: "function", function: { name: "write_file", arguments: "{\"path\":\".env\",\"content\":\"TOKEN=blocked\"}" } },
91
+ { id: "call-b", type: "function", function: { name: "write_file", arguments: "{\"path\":\"notes/ok.md\",\"content\":\"ok\"}" } },
92
+ ],
93
+ },
94
+ { role: "tool", tool_call_id: "call-a", content: "{\"ok\":false,\"blocked\":true}" },
95
+ { role: "user", content: "Loop guard: do not repeat the blocked call." },
96
+ { role: "tool", tool_call_id: "call-b", content: "{\"ok\":false,\"skipped\":true}" },
97
+ ],
98
+ };
99
+ const interleavedRepair = repairModelMessageHistory(interleavedToolState, { provider: "openai" });
100
+ assert(interleavedRepair.changed, "interleaved tool-call history repair did not report a change");
101
+ const roles = interleavedToolState.messages.map((message) => `${message.role}:${message.tool_call_id || ""}`);
102
+ assert(
103
+ roles.join("|") === "system:|user:|assistant:|tool:call-a|tool:call-b|user:",
104
+ `interleaved tool-call history was not repaired into provider-valid order: ${roles.join("|")}`
105
+ );
81
106
  assert(
82
107
  staleDeepSeekState.messages.every(
83
108
  (message) => message.role !== "assistant" || message.reasoning_content || message.reasoningContent
@@ -530,6 +555,54 @@ try {
530
555
  });
531
556
  assert(envRun.events.some((event) => event.type === "tool.blocked"), ".env guardrail did not emit tool.blocked");
532
557
 
558
+ const secretContentResult = await executeWorkspaceTool(
559
+ "write_file",
560
+ {
561
+ path: "notes/secret-leak-report.md",
562
+ content: "Content attempted: DEMO_SECRET_TOKEN=aginti_fake_do_not_use\n",
563
+ mode: "create",
564
+ },
565
+ {
566
+ commandCwd: workspace,
567
+ allowFileTools: true,
568
+ }
569
+ );
570
+ assert(secretContentResult.blocked && secretContentResult.category === "workspace-content", "write_file secret-like content was not blocked");
571
+ await fs
572
+ .access(path.join(workspace, "notes/secret-leak-report.md"))
573
+ .then(() => {
574
+ throw new Error("secret-like report content was written despite content guardrails");
575
+ })
576
+ .catch((error) => {
577
+ if (error.code !== "ENOENT") throw error;
578
+ });
579
+
580
+ const redactedContentResult = await executeWorkspaceTool(
581
+ "write_file",
582
+ {
583
+ path: "notes/redacted-report.md",
584
+ content: "Content attempted: DEMO_SECRET_TOKEN=[REDACTED]\n",
585
+ mode: "create",
586
+ },
587
+ {
588
+ commandCwd: workspace,
589
+ allowFileTools: true,
590
+ }
591
+ );
592
+ assert(redactedContentResult.ok, "write_file should allow already-redacted secret placeholders");
593
+
594
+ const secretPatchResult = await executeWorkspaceTool(
595
+ "apply_patch",
596
+ {
597
+ patch: ["*** Begin Patch", "*** Add File: notes/secret-patch.md", "+DEMO_SECRET_TOKEN=aginti_fake_do_not_use", "*** End Patch"].join("\n"),
598
+ },
599
+ {
600
+ commandCwd: workspace,
601
+ allowFileTools: true,
602
+ }
603
+ );
604
+ assert(secretPatchResult.blocked && secretPatchResult.category === "workspace-content", "apply_patch secret-like additions were not blocked");
605
+
533
606
  const outsideRun = await runMock("Create file: ../outside-workspace.txt with blocked content.", "coding-block-outside");
534
607
  await fs
535
608
  .access(path.join(tempRoot, "outside-workspace.txt"))
@@ -548,6 +621,7 @@ try {
548
621
  workspace,
549
622
  checks: [
550
623
  "deepseek_history_repair",
624
+ "interleaved_tool_history_repair",
551
625
  "blocked_tool_batch_short_circuit",
552
626
  "deepseek_pro_patch_route",
553
627
  "runtime_time_context",
@@ -576,6 +650,9 @@ try {
576
650
  "patch_guardrail",
577
651
  "patch_move_no_overwrite",
578
652
  "block_env",
653
+ "block_secret_write_content",
654
+ "allow_redacted_write_content",
655
+ "block_secret_patch_content",
579
656
  "block_outside",
580
657
  ],
581
658
  },
@@ -213,14 +213,23 @@ function normalizeUrlPath(relativePath) {
213
213
  function preserveAssistantMessage(message) {
214
214
  const preserved = {
215
215
  role: "assistant",
216
- content: message.content || "",
217
- tool_calls: message.tool_calls,
216
+ content: redactSensitiveText(message.content || ""),
217
+ tool_calls: Array.isArray(message.tool_calls)
218
+ ? message.tool_calls.map((call) => ({
219
+ ...call,
220
+ function: {
221
+ ...(call.function || {}),
222
+ arguments:
223
+ typeof call.function?.arguments === "string"
224
+ ? redactSensitiveText(call.function.arguments)
225
+ : call.function?.arguments,
226
+ },
227
+ }))
228
+ : message.tool_calls,
218
229
  };
219
230
 
220
231
  const reasoningContent = message.reasoning_content || message.reasoningContent;
221
- if (reasoningContent) {
222
- preserved.reasoning_content = reasoningContent;
223
- }
232
+ if (reasoningContent) preserved.reasoning_content = redactSensitiveText(reasoningContent);
224
233
 
225
234
  return preserved;
226
235
  }
@@ -233,6 +242,7 @@ export function repairModelMessageHistory(state, config = {}) {
233
242
  convertedAssistantMessages: 0,
234
243
  droppedToolMessages: 0,
235
244
  incompleteToolCallMessages: 0,
245
+ reorderedInterleavedMessages: 0,
236
246
  };
237
247
  }
238
248
 
@@ -241,6 +251,7 @@ export function repairModelMessageHistory(state, config = {}) {
241
251
  let convertedAssistantMessages = 0;
242
252
  let droppedToolMessages = 0;
243
253
  let incompleteToolCallMessages = 0;
254
+ let reorderedInterleavedMessages = 0;
244
255
 
245
256
  for (let index = 0; index < state.messages.length; index += 1) {
246
257
  const message = state.messages[index];
@@ -254,12 +265,21 @@ export function repairModelMessageHistory(state, config = {}) {
254
265
  const expectedIds = toolCalls.map((call) => String(call?.id || "")).filter(Boolean);
255
266
  const expected = new Set(expectedIds);
256
267
  const followingToolMessages = [];
268
+ const deferredInterleavedMessages = [];
257
269
  const duplicateOrUnexpectedToolMessages = [];
258
270
  const seen = new Set();
259
271
  let cursor = index + 1;
260
272
 
261
- while (cursor < state.messages.length && state.messages[cursor]?.role === "tool") {
262
- const toolMessage = state.messages[cursor];
273
+ while (cursor < state.messages.length) {
274
+ const nextMessage = state.messages[cursor];
275
+ if (nextMessage?.role !== "tool") {
276
+ const complete = expectedIds.length > 0 && expectedIds.every((id) => seen.has(id));
277
+ if (complete || nextMessage?.role === "assistant") break;
278
+ deferredInterleavedMessages.push(nextMessage);
279
+ cursor += 1;
280
+ continue;
281
+ }
282
+ const toolMessage = nextMessage;
263
283
  const toolCallId = String(toolMessage.tool_call_id || "");
264
284
  if (expected.has(toolCallId) && !seen.has(toolCallId)) {
265
285
  followingToolMessages.push(toolMessage);
@@ -275,12 +295,18 @@ export function repairModelMessageHistory(state, config = {}) {
275
295
  droppedAssistantMessages += 1;
276
296
  droppedToolMessages += followingToolMessages.length + duplicateOrUnexpectedToolMessages.length;
277
297
  if (!completeToolResults) incompleteToolCallMessages += 1;
298
+ if (deferredInterleavedMessages.length) {
299
+ repaired.push(...deferredInterleavedMessages);
300
+ reorderedInterleavedMessages += deferredInterleavedMessages.length;
301
+ }
278
302
  index = cursor - 1;
279
303
  continue;
280
304
  }
281
305
 
282
306
  repaired.push(preserveAssistantMessage(message));
283
307
  repaired.push(...followingToolMessages);
308
+ repaired.push(...deferredInterleavedMessages);
309
+ reorderedInterleavedMessages += deferredInterleavedMessages.length;
284
310
  droppedToolMessages += duplicateOrUnexpectedToolMessages.length;
285
311
  index = cursor - 1;
286
312
  continue;
@@ -320,6 +346,7 @@ export function repairModelMessageHistory(state, config = {}) {
320
346
  droppedAssistantMessages > 0 ||
321
347
  convertedAssistantMessages > 0 ||
322
348
  droppedToolMessages > 0 ||
349
+ reorderedInterleavedMessages > 0 ||
323
350
  repaired.length !== state.messages.length;
324
351
 
325
352
  if (changed) {
@@ -332,6 +359,7 @@ export function repairModelMessageHistory(state, config = {}) {
332
359
  convertedAssistantMessages,
333
360
  droppedToolMessages,
334
361
  incompleteToolCallMessages,
362
+ reorderedInterleavedMessages,
335
363
  };
336
364
  }
337
365
 
@@ -1372,7 +1400,7 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
1372
1400
  return result;
1373
1401
  }
1374
1402
  case "finish":
1375
- return { ok: true, done: true, result: String(args.result || ""), toolName: "finish" };
1403
+ return { ok: true, done: true, result: redactSensitiveText(String(args.result || "")), toolName: "finish" };
1376
1404
  default:
1377
1405
  throw new Error(`Unknown tool: ${toolCall.function.name}`);
1378
1406
  }
@@ -1481,7 +1509,7 @@ export async function runAgent(config) {
1481
1509
  taskProfile: config.taskProfile,
1482
1510
  goal: config.goal,
1483
1511
  });
1484
- state.plan = scsPlan.plan;
1512
+ state.plan = redactSensitiveText(scsPlan.plan);
1485
1513
  state.meta.scs = scsPlan.scs;
1486
1514
  state.messages.push({
1487
1515
  role: "user",
@@ -1496,7 +1524,7 @@ export async function runAgent(config) {
1496
1524
  await store.appendEvent("scs.committee.plan_drafted", {
1497
1525
  phase: scsPlan.scs.phase,
1498
1526
  phaseGoal: scsPlan.scs.phaseGoal,
1499
- plan: scsPlan.plan,
1527
+ plan: state.plan,
1500
1528
  acceptanceCriteria: scsPlan.scs.acceptanceCriteria,
1501
1529
  });
1502
1530
  await store.appendEvent(`scs.student.${scsPlan.scs.student.decision}`, scsPlan.scs.student);
@@ -1504,16 +1532,16 @@ export async function runAgent(config) {
1504
1532
  phase: scsPlan.scs.phase,
1505
1533
  phaseGoal: scsPlan.scs.phaseGoal,
1506
1534
  });
1507
- await store.savePlan(scsPlan.plan);
1508
- await store.appendEvent("plan.created", { plan: scsPlan.plan, scs: true });
1535
+ await store.savePlan(state.plan);
1536
+ await store.appendEvent("plan.created", { plan: state.plan, scs: true });
1509
1537
  await store.saveState(state);
1510
- observers.event("plan.created", { plan: scsPlan.plan, scs: true });
1538
+ observers.event("plan.created", { plan: state.plan, scs: true });
1511
1539
  observers.event("scs.student.approve_plan", scsPlan.scs.student);
1512
1540
  emitConsole(config, `SCS: student approved phase plan (${Math.round((scsPlan.scs.student.confidence || 0) * 100)}%).`, {
1513
1541
  kind: "meta",
1514
1542
  });
1515
1543
  } else {
1516
- const plan = await createPlan(client, config, state);
1544
+ const plan = redactSensitiveText(await createPlan(client, config, state));
1517
1545
  state.plan = plan;
1518
1546
  await store.savePlan(plan);
1519
1547
  await store.appendEvent("plan.created", { plan });
@@ -1718,7 +1746,7 @@ export async function runAgent(config) {
1718
1746
 
1719
1747
  await store.appendEvent("model.responded", {
1720
1748
  step,
1721
- content: assistantMessage.content || "",
1749
+ content: redactSensitiveText(assistantMessage.content || ""),
1722
1750
  toolCalls: (assistantMessage.tool_calls || []).map((call) => ({
1723
1751
  id: call.id,
1724
1752
  name: call.function.name,
@@ -1727,7 +1755,7 @@ export async function runAgent(config) {
1727
1755
  });
1728
1756
  observers.event("model.responded", {
1729
1757
  step,
1730
- content: assistantMessage.content || "",
1758
+ content: redactSensitiveText(assistantMessage.content || ""),
1731
1759
  });
1732
1760
 
1733
1761
  const toolCalls = assistantMessage.tool_calls || [];
@@ -1740,7 +1768,7 @@ export async function runAgent(config) {
1740
1768
  await store.saveState(state);
1741
1769
  continue;
1742
1770
  }
1743
- const fallback = assistantMessage.content?.trim() || "No tool call returned.";
1771
+ const fallback = redactSensitiveText(assistantMessage.content?.trim() || "No tool call returned.");
1744
1772
  if (config.scsActive) {
1745
1773
  const decision = await reviewScsFinish(client, config, state, fallback, {
1746
1774
  events: await store.loadEvents(),
@@ -1792,6 +1820,7 @@ export async function runAgent(config) {
1792
1820
  }
1793
1821
 
1794
1822
  let continueForQueuedInput = false;
1823
+ const postBatchToolResults = [];
1795
1824
  for (let toolIndex = 0; toolIndex < toolCalls.length; toolIndex += 1) {
1796
1825
  const toolCall = toolCalls[toolIndex];
1797
1826
  throwIfAborted(config);
@@ -1801,44 +1830,7 @@ export async function runAgent(config) {
1801
1830
  tool_call_id: toolCall.id,
1802
1831
  content: JSON.stringify(toolResult),
1803
1832
  });
1804
- await applyToolLoopGuard(state, toolResult, store, observers);
1805
-
1806
- if (config.scsActive && shouldReviewToolResult(toolResult, state)) {
1807
- const decision = await reviewScsToolResult(client, config, state, toolResult, {
1808
- events: await store.loadEvents(),
1809
- taskProfile: config.taskProfile,
1810
- goal: config.goal,
1811
- });
1812
- state.meta.scs = state.meta.scs || { enabled: true, mode: config.enableScs || "on", active: true };
1813
- state.meta.scs.monitorReviews = (state.meta.scs.monitorReviews || 0) + 1;
1814
- state.meta.scs.lastStudentDecision = decision;
1815
- await store.appendEvent(`scs.student.${decision.decision}`, {
1816
- ...decision,
1817
- toolName: toolResult.toolName,
1818
- });
1819
- observers.event(`scs.student.${decision.decision}`, {
1820
- decision: decision.decision,
1821
- reason: decision.reason,
1822
- toolName: toolResult.toolName,
1823
- });
1824
- if (decision.decision === "rethink_plan" || decision.decision === "reject_phase") {
1825
- state.messages.push({
1826
- role: "user",
1827
- content: [
1828
- "SCS student monitor requested a rethink based on tool evidence.",
1829
- `Decision: ${decision.decision}`,
1830
- `Reason: ${decision.reason || "No reason provided."}`,
1831
- decision.nextRequiredAction ? `Next required action: ${decision.nextRequiredAction}` : "",
1832
- "Supervisor: do not repeat the same failed call. Adjust within the approved phase or finish with a concrete blocker if the phase is invalidated.",
1833
- ]
1834
- .filter(Boolean)
1835
- .join("\n"),
1836
- });
1837
- emitConsole(config, `SCS: ${decision.decision} after ${toolResult.toolName}: ${decision.reason || "reviewed"}`, {
1838
- kind: "meta",
1839
- });
1840
- }
1841
- }
1833
+ postBatchToolResults.push(toolResult);
1842
1834
 
1843
1835
  if (toolResult.toolName === "run_command") {
1844
1836
  observers.log("command.output", {
@@ -1956,6 +1948,47 @@ export async function runAgent(config) {
1956
1948
  }
1957
1949
  }
1958
1950
 
1951
+ for (const toolResult of postBatchToolResults) {
1952
+ await applyToolLoopGuard(state, toolResult, store, observers);
1953
+
1954
+ if (config.scsActive && shouldReviewToolResult(toolResult, state)) {
1955
+ const decision = await reviewScsToolResult(client, config, state, toolResult, {
1956
+ events: await store.loadEvents(),
1957
+ taskProfile: config.taskProfile,
1958
+ goal: config.goal,
1959
+ });
1960
+ state.meta.scs = state.meta.scs || { enabled: true, mode: config.enableScs || "on", active: true };
1961
+ state.meta.scs.monitorReviews = (state.meta.scs.monitorReviews || 0) + 1;
1962
+ state.meta.scs.lastStudentDecision = decision;
1963
+ await store.appendEvent(`scs.student.${decision.decision}`, {
1964
+ ...decision,
1965
+ toolName: toolResult.toolName,
1966
+ });
1967
+ observers.event(`scs.student.${decision.decision}`, {
1968
+ decision: decision.decision,
1969
+ reason: decision.reason,
1970
+ toolName: toolResult.toolName,
1971
+ });
1972
+ if (decision.decision === "rethink_plan" || decision.decision === "reject_phase") {
1973
+ state.messages.push({
1974
+ role: "user",
1975
+ content: [
1976
+ "SCS student monitor requested a rethink based on tool evidence.",
1977
+ `Decision: ${decision.decision}`,
1978
+ `Reason: ${decision.reason || "No reason provided."}`,
1979
+ decision.nextRequiredAction ? `Next required action: ${decision.nextRequiredAction}` : "",
1980
+ "Supervisor: do not repeat the same failed call. Adjust within the approved phase or finish with a concrete blocker if the phase is invalidated.",
1981
+ ]
1982
+ .filter(Boolean)
1983
+ .join("\n"),
1984
+ });
1985
+ emitConsole(config, `SCS: ${decision.decision} after ${toolResult.toolName}: ${decision.reason || "reviewed"}`, {
1986
+ kind: "meta",
1987
+ });
1988
+ }
1989
+ }
1990
+ }
1991
+
1959
1992
  if (continueForQueuedInput) continue;
1960
1993
 
1961
1994
  if (config.scsActive && shouldReviewScsProgress(step, state)) {
@@ -69,6 +69,7 @@ export function formatBehaviorContractForPrompt({ mode = "runtime" } = {}) {
69
69
  "Make surgical edits: no drive-by refactors, unrelated formatting churn, or deletion of code you did not need to touch.",
70
70
  "Define or infer concrete success criteria for non-trivial work, then run focused checks or state why checks are unavailable.",
71
71
  "Respect the permission contract: if a tool is blocked or returns permissionAdvice, stop and present the exact suggestedCommand/approval path instead of retrying variants or inventing CLI flags.",
72
+ "Protect secrets aggressively: never repeat token/key/password/secret values from prompts, files, tool output, plans, final answers, reports, diffs, or artifacts. Redact the value as [REDACTED] and use dedicated key storage such as `aginti keys set` when credentials are needed.",
72
73
  "Keep artifacts durable and discoverable with descriptive non-conflicting names; never overwrite unless the user clearly asked.",
73
74
  "When reporting shell, language, runtime, build, or test results, name the actual environment used (host vs Docker, relevant interpreter/tool path/version when it matters). Do not claim compatibility across untested runtimes, hosts, containers, or language versions; state the caveat or run an explicit check.",
74
75
  "Do not self-invoke AgInTiFlow with npx/npm exec or nested aginti commands from inside the agent shell; it can resolve stale project packages or create recursive sessions. Use current runtime evidence, project/session files, or ask for a host-side diagnostic instead.",
@@ -6,6 +6,7 @@ import { engineeringGuidanceForTask } from "./engineering-guidance.js";
6
6
  import { formatSkillsForPrompt, selectSkillsForGoal } from "./skill-library.js";
7
7
  import { platformInfo, platformLabel } from "./platform.js";
8
8
  import { formatBehaviorContractForPrompt } from "./behavior-contract.js";
9
+ import { redactSensitiveText } from "./redaction.js";
9
10
 
10
11
  export function createClient(config) {
11
12
  if (config.provider === "mock") {
@@ -558,7 +559,7 @@ export async function createPlan(client, config, state) {
558
559
  requestOptions(config)
559
560
  );
560
561
 
561
- return response.choices[0]?.message?.content?.trim() || "1. Inspect the page.\n2. Use the smallest safe action.\n3. Finish with a concise answer.";
562
+ return redactSensitiveText(response.choices[0]?.message?.content?.trim() || "1. Inspect the page.\n2. Use the smallest safe action.\n3. Finish with a concise answer.");
562
563
  }
563
564
 
564
565
  export async function requestNextStep(client, config, messages) {
package/src/redaction.js CHANGED
@@ -19,6 +19,11 @@ export function redactSensitiveText(value) {
19
19
  return text;
20
20
  }
21
21
 
22
+ export function hasSensitiveText(value) {
23
+ const text = String(value ?? "");
24
+ return redactSensitiveText(text) !== text;
25
+ }
26
+
22
27
  export function redactValue(value) {
23
28
  if (typeof value === "string") return redactSensitiveText(value);
24
29
  if (Array.isArray(value)) return value.map((item) => redactValue(item));
@@ -1,7 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { redactSensitiveText } from "./redaction.js";
4
+ import { hasSensitiveText, redactSensitiveText } from "./redaction.js";
5
5
 
6
6
  export const WORKSPACE_TOOL_NAMES = ["inspect_project", "list_files", "read_file", "search_files", "write_file", "apply_patch"];
7
7
  export const WORKSPACE_WRITE_TOOL_NAMES = ["write_file", "apply_patch"];
@@ -188,6 +188,37 @@ function pathPolicy(toolName, relativePath) {
188
188
  return { allowed: true };
189
189
  }
190
190
 
191
+ function secretContentPolicy(content) {
192
+ if (!hasSensitiveText(content)) return { allowed: true };
193
+ return {
194
+ allowed: false,
195
+ reason:
196
+ "Write content appears to contain token-like secret text. Redact secret values as [REDACTED] or use dedicated key storage such as `aginti keys set`; do not write credentials into workspace files.",
197
+ category: "workspace-content",
198
+ };
199
+ }
200
+
201
+ function writeContentPolicy(toolName, args, operations = null) {
202
+ if (toolName === "write_file") return secretContentPolicy(args.content || "");
203
+ if (toolName !== "apply_patch") return { allowed: true };
204
+
205
+ if (typeof args.patch === "string" && args.patch.trim()) {
206
+ for (const operation of operations || []) {
207
+ if (operation.type === "add") {
208
+ const policy = secretContentPolicy(operation.content || "");
209
+ if (!policy.allowed) return policy;
210
+ }
211
+ for (const hunk of operation.hunks || []) {
212
+ const policy = secretContentPolicy(hunk.replace || "");
213
+ if (!policy.allowed) return policy;
214
+ }
215
+ }
216
+ return { allowed: true };
217
+ }
218
+
219
+ return secretContentPolicy(args.replace ?? "");
220
+ }
221
+
191
222
  export function checkWorkspaceToolUse(toolName, args, config) {
192
223
  if (!WORKSPACE_TOOL_NAMES.includes(toolName)) return { allowed: true };
193
224
  if (!config.allowFileTools) {
@@ -196,17 +227,26 @@ export function checkWorkspaceToolUse(toolName, args, config) {
196
227
 
197
228
  try {
198
229
  if (toolName === "apply_patch" && typeof args.patch === "string" && args.patch.trim()) {
199
- for (const operation of parsePatchDocument(args.patch)) {
230
+ const operations = parsePatchDocument(args.patch);
231
+ for (const operation of operations) {
200
232
  for (const candidate of [operation.path, operation.newPath].filter(Boolean)) {
201
233
  const target = resolveWorkspacePath(config, candidate);
202
234
  const policy = pathPolicy(toolName, target.relativePath);
203
235
  if (!policy.allowed) return policy;
204
236
  }
205
237
  }
238
+ const contentPolicy = writeContentPolicy(toolName, args, operations);
239
+ if (!contentPolicy.allowed) return contentPolicy;
206
240
  return { allowed: true };
207
241
  }
208
242
  const target = resolveWorkspacePath(config, args.path || ".");
209
- return pathPolicy(toolName, target.relativePath);
243
+ const policy = pathPolicy(toolName, target.relativePath);
244
+ if (!policy.allowed) return policy;
245
+ if (WORKSPACE_WRITE_TOOL_NAMES.includes(toolName)) {
246
+ const contentPolicy = writeContentPolicy(toolName, args);
247
+ if (!contentPolicy.allowed) return contentPolicy;
248
+ }
249
+ return { allowed: true };
210
250
  } catch (error) {
211
251
  return {
212
252
  allowed: false,
@@ -591,6 +631,8 @@ async function writeChange(target, nextContent, action, details = {}) {
591
631
  if (Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
592
632
  throw new Error(`Write is too large for safe workspace tools: ${target.relativePath}`);
593
633
  }
634
+ const contentPolicy = secretContentPolicy(content);
635
+ if (!contentPolicy.allowed) throw new Error(contentPolicy.reason);
594
636
 
595
637
  await fs.mkdir(path.dirname(target.absolutePath), { recursive: true });
596
638
  let beforeText = "";