@@ -9,8 +9,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
99import {
1010 onInternalDiagnosticEvent ,
1111 onDiagnosticEvent ,
12+ onTrustedInternalDiagnosticEvent ,
1213 resetDiagnosticEventsForTest ,
1314 type DiagnosticEventPayload ,
15+ type DiagnosticEventPrivateData ,
1416 type DiagnosticToolLoopEvent ,
1517} from "../infra/diagnostic-events.js" ;
1618import { MAX_PLUGIN_APPROVAL_TIMEOUT_MS } from "../infra/plugin-approvals.js" ;
@@ -1761,3 +1763,142 @@ describe("before_tool_call requireApproval handling", () => {
17611763 expect ( onResolution ) . toHaveBeenCalledWith ( "cancelled" ) ;
17621764 } ) ;
17631765} ) ;
1766+
1767+ describe ( "before_tool_call tool content private-data capture" , ( ) => {
1768+ type TrustedToolEvent = {
1769+ event : DiagnosticEventPayload ;
1770+ privateData : DiagnosticEventPrivateData ;
1771+ } ;
1772+
1773+ beforeEach ( ( ) => {
1774+ resetDiagnosticSessionStateForTest ( ) ;
1775+ resetDiagnosticEventsForTest ( ) ;
1776+ } ) ;
1777+
1778+ async function withTrustedToolEvents (
1779+ run : ( emitted : TrustedToolEvent [ ] , flush : ( ) => Promise < void > ) => Promise < void > ,
1780+ ) {
1781+ const emitted : TrustedToolEvent [ ] = [ ] ;
1782+ const stop = onTrustedInternalDiagnosticEvent ( ( event , _metadata , privateData ) => {
1783+ if ( event . type . startsWith ( "tool.execution." ) ) {
1784+ emitted . push ( { event, privateData } ) ;
1785+ }
1786+ } ) ;
1787+ const flush = ( ) =>
1788+ new Promise < void > ( ( resolve ) => {
1789+ setImmediate ( resolve ) ;
1790+ } ) ;
1791+ try {
1792+ await run ( emitted , flush ) ;
1793+ } finally {
1794+ stop ( ) ;
1795+ }
1796+ }
1797+
1798+ function configWithToolContent (
1799+ fields : { toolInputs ?: boolean ; toolOutputs ?: boolean } = {
1800+ toolInputs : true ,
1801+ toolOutputs : true ,
1802+ } ,
1803+ ) {
1804+ return {
1805+ diagnostics : {
1806+ enabled : true ,
1807+ otel : {
1808+ enabled : true ,
1809+ traces : true ,
1810+ captureContent : { enabled : true , ...fields } ,
1811+ } ,
1812+ } ,
1813+ } as unknown as import ( "../config/types.openclaw.js" ) . OpenClawConfig ;
1814+ }
1815+
1816+ it ( "attaches tool input/output to private data when opted in" , async ( ) => {
1817+ const execute = vi . fn ( ) . mockResolvedValue ( { content : [ { type : "text" , text : "file body" } ] } ) ;
1818+ const tool = wrapToolWithBeforeToolCallHook ( { name : "read" , execute } as any , {
1819+ agentId : "main" ,
1820+ sessionKey : "session-key" ,
1821+ runId : "run-1" ,
1822+ loopDetection : { enabled : false } ,
1823+ config : configWithToolContent ( ) ,
1824+ } ) ;
1825+
1826+ await withTrustedToolEvents ( async ( emitted , flush ) => {
1827+ await tool . execute ( "call-1" , { path : "/etc/secret" } , undefined , undefined ) ;
1828+ await flush ( ) ;
1829+
1830+ const completed = emitted . find ( ( e ) => e . event . type === "tool.execution.completed" ) ;
1831+ expect ( completed ?. privateData . toolContent ?. toolInput ) . toEqual ( { path : "/etc/secret" } ) ;
1832+ expect ( completed ?. privateData . toolContent ?. toolOutput ) . toEqual ( {
1833+ content : [ { type : "text" , text : "file body" } ] ,
1834+ } ) ;
1835+ // Public event payload must never carry raw params/results.
1836+ expect ( JSON . stringify ( completed ?. event ) ) . not . toContain ( "/etc/secret" ) ;
1837+ expect ( JSON . stringify ( completed ?. event ) ) . not . toContain ( "file body" ) ;
1838+ } ) ;
1839+ } ) ;
1840+
1841+ it ( "omits tool content from private data when capture is not configured" , async ( ) => {
1842+ const execute = vi . fn ( ) . mockResolvedValue ( { content : [ { type : "text" , text : "ok" } ] } ) ;
1843+ const tool = wrapToolWithBeforeToolCallHook ( { name : "read" , execute } as any , {
1844+ agentId : "main" ,
1845+ sessionKey : "session-key" ,
1846+ runId : "run-1" ,
1847+ loopDetection : { enabled : false } ,
1848+ } ) ;
1849+
1850+ await withTrustedToolEvents ( async ( emitted , flush ) => {
1851+ await tool . execute ( "call-1" , { path : "/etc/secret" } , undefined , undefined ) ;
1852+ await flush ( ) ;
1853+
1854+ const completed = emitted . find ( ( e ) => e . event . type === "tool.execution.completed" ) ;
1855+ expect ( completed ) . toBeDefined ( ) ;
1856+ expect ( completed ?. privateData . toolContent ) . toBeUndefined ( ) ;
1857+ } ) ;
1858+ } ) ;
1859+
1860+ it ( "captures only opted-in fields and clones away from live params" , async ( ) => {
1861+ const liveParams = { path : "/etc/secret" } ;
1862+ const execute = vi . fn ( ) . mockResolvedValue ( { content : [ { type : "text" , text : "out" } ] } ) ;
1863+ const tool = wrapToolWithBeforeToolCallHook ( { name : "read" , execute } as any , {
1864+ agentId : "main" ,
1865+ sessionKey : "session-key" ,
1866+ runId : "run-1" ,
1867+ loopDetection : { enabled : false } ,
1868+ config : configWithToolContent ( { toolInputs : true , toolOutputs : false } ) ,
1869+ } ) ;
1870+
1871+ await withTrustedToolEvents ( async ( emitted , flush ) => {
1872+ await tool . execute ( "call-1" , liveParams , undefined , undefined ) ;
1873+ await flush ( ) ;
1874+
1875+ const completed = emitted . find ( ( e ) => e . event . type === "tool.execution.completed" ) ;
1876+ expect ( completed ?. privateData . toolContent ?. toolInput ) . toEqual ( { path : "/etc/secret" } ) ;
1877+ expect ( completed ?. privateData . toolContent ?. toolOutput ) . toBeUndefined ( ) ;
1878+ // Captured snapshot is a clone, not the live params object.
1879+ expect ( completed ?. privateData . toolContent ?. toolInput ) . not . toBe ( liveParams ) ;
1880+ } ) ;
1881+ } ) ;
1882+
1883+ it ( "attaches tool input but not output on execution errors" , async ( ) => {
1884+ const execute = vi . fn ( ) . mockRejectedValue ( new Error ( "boom" ) ) ;
1885+ const tool = wrapToolWithBeforeToolCallHook ( { name : "read" , execute } as any , {
1886+ agentId : "main" ,
1887+ sessionKey : "session-key" ,
1888+ runId : "run-1" ,
1889+ loopDetection : { enabled : false } ,
1890+ config : configWithToolContent ( ) ,
1891+ } ) ;
1892+
1893+ await withTrustedToolEvents ( async ( emitted , flush ) => {
1894+ await expect (
1895+ tool . execute ( "call-1" , { path : "/etc/secret" } , undefined , undefined ) ,
1896+ ) . rejects . toThrow ( "boom" ) ;
1897+ await flush ( ) ;
1898+
1899+ const errored = emitted . find ( ( e ) => e . event . type === "tool.execution.error" ) ;
1900+ expect ( errored ?. privateData . toolContent ?. toolInput ) . toEqual ( { path : "/etc/secret" } ) ;
1901+ expect ( errored ?. privateData . toolContent ?. toolOutput ) . toBeUndefined ( ) ;
1902+ } ) ;
1903+ } ) ;
1904+ } ) ;
0 commit comments