@@ -39,8 +39,8 @@ const SYSTEM_PROMPT =
3939
4040const ReportFindingParams = Type . Object ( {
4141 file : Type . String ( { description : 'File path relative to the repository root' } ) ,
42- startLine : Type . Optional ( Type . Integer ( { description : 'Start line of the finding (ignored — only evidence is used for positioning) ' } ) ) ,
43- endLine : Type . Optional ( Type . Integer ( { description : 'End line of the finding (ignored — only evidence is used for positioning) ' } ) ) ,
42+ startLine : Type . Optional ( Type . Integer ( { description : 'Start line of the finding as shown in the read() output. Must match the line where the evidence string begins in the file. Do not use 1 unless the evidence is genuinely on line 1. ' } ) ) ,
43+ endLine : Type . Optional ( Type . Integer ( { description : 'End line of the finding as shown in the read() output. Must match the line where the evidence string ends in the file. ' } ) ) ,
4444 severity : Type . Union ( [ Type . Literal ( 'high' ) , Type . Literal ( 'medium' ) , Type . Literal ( 'low' ) ] ) ,
4545 message : Type . String ( { description : 'Description of the malicious pattern' } ) ,
4646 ruleId : Type . String ( { description : 'Short kebab-case rule identifier, e.g. "reverse-shell"' } ) ,
@@ -145,63 +145,23 @@ export async function runPiAgent({
145145
146146 console . log ( `[pi-agent] scanning ${ changedFiles . length } file(s) with provider ${ provider } , model ${ toolConfig . model } (thinking: ${ toolConfig . thinkingLevel ?? 'medium' } )` ) ;
147147
148- const findings : PiAgentRawFinding [ ] = [ ] ;
149-
150- // Build the report_finding tool — execute() accumulates into the closure array.
151- const reportFindingTool : ToolDefinition = {
152- name : 'report_finding' ,
153- label : 'Report Finding' ,
154- description : 'Report a confirmed security finding. Call once per finding.' ,
155- parameters : ReportFindingParams ,
156- // eslint-disable-next-line @typescript-eslint/no-explicit-any
157- execute : async ( _toolCallId : any , params : any , _signal : any , _onUpdate : any , _ctx : any ) => {
158- const finding = normalizeFinding ( params as RawFindingInput ) ;
159- findings . push ( finding ) ;
160- debug ( 'pi-agent' , `finding recorded: ${ finding . severity . toUpperCase ( ) } ${ finding . file } :${ finding . startLine } [${ finding . ruleId } ]` ) ;
161- return {
162- content : [ { type : 'text' as const , text : 'Finding recorded.' } ] ,
163- details : { } ,
164- } ;
165- } ,
166- } ;
167-
168- const systemPrompt = toolConfig . prompt ?? SYSTEM_PROMPT ;
169-
170- const resourceLoader = new DefaultResourceLoader ( {
171- systemPromptOverride : ( ) => systemPrompt ,
172- appendSystemPromptOverride : ( ) => [ ] ,
173- noExtensions : true ,
174- noSkills : true ,
175- noPromptTemplates : true ,
176- } ) ;
177- await resourceLoader . reload ( ) ;
178-
179- const { session } = await createAgentSession ( {
180- cwd : workspacePath ,
181- tools : createConfinedTools ( workspacePath , {
182- headSha,
183- followImports : toolConfig . followImports ?? true ,
184- } ) ,
185- customTools : [ reportFindingTool ] ,
186- sessionManager : SessionManager . inMemory ( ) ,
187- model,
188- // eslint-disable-next-line @typescript-eslint/no-explicit-any
189- thinkingLevel : ( toolConfig . thinkingLevel ?? 'medium' ) as any ,
190- resourceLoader,
191- } ) ;
192-
193148 const fileList = changedFiles . map ( f => {
194149 const ranges = changedLineRanges . get ( f ) ?? [ ] ;
195150 const rangeStr = ranges . length > 0
196151 ? ` (changed lines: ${ formatLineRanges ( ranges ) } )`
197152 : '' ;
198153 return ` - ${ f } ${ rangeStr } ` ;
199154 } ) . join ( '\n' ) ;
155+
200156 const isCustomPrompt = toolConfig . prompt !== null && toolConfig . prompt !== undefined ;
201157 const initialPrompt = isCustomPrompt
202158 ? `The following files were changed in this PR and require a security review:\n${ fileList } \n\n` +
203- 'Investigate these files thoroughly using the read, grep, find, and ls tools. ' +
204- 'Follow imports and function calls as deeply as needed. ' +
159+ 'Start by reading each changed file in full using the read tool on the actual file path (not the .layne/diff-only/ path). ' +
160+ 'Then broaden your investigation beyond the changed files themselves: read the files they import from, ' +
161+ 'use grep to find where changed functions are called from elsewhere in the codebase, ' +
162+ 'and read any shared utilities, models, or middleware that the changed code interacts with. ' +
163+ 'Understanding the full context around each change — not just the changed lines — reveals vulnerabilities that span multiple files. ' +
164+ 'Follow imports and function calls as deeply as needed to trace data flows from source to sink. ' +
205165 'Use the changed line ranges above to anchor your findings accurately. ' +
206166 'For each confirmed finding, call report_finding.'
207167 : `The following files were changed in this PR and require a security review:\n${ fileList } \n\n` +
@@ -211,33 +171,120 @@ export async function runPiAgent({
211171 'Scan each whole file but use the changed line ranges above to prioritize where to anchor your findings. ' +
212172 'For each confirmed finding, call report_finding. Report only high-confidence confirmed malicious patterns.' ;
213173
174+ const systemPrompt = toolConfig . prompt ?? SYSTEM_PROMPT ;
214175 const timeoutMs = ( toolConfig . timeoutMinutes ?? 3 ) * 60 * 1000 ;
215- let timedOut = false ;
216-
217- const timer = setTimeout ( ( ) => {
218- timedOut = true ;
219- session . abort ( ) . catch ( ( ) => { } ) ;
220- } , timeoutMs ) ;
221-
222- try {
223- await session . prompt ( initialPrompt ) ;
224- } catch ( err ) {
225- if ( ! timedOut ) {
226- console . error ( '[pi-agent] error during scan:' , ( err as Error ) . message ?? err ) ;
176+
177+ // ---------------------------------------------------------------------------
178+ // Session runner — extracted so it can be retried on silent failure
179+ // ---------------------------------------------------------------------------
180+ const runSession = async ( attempt : number ) : Promise < { findings : PiAgentRawFinding [ ] ; hadActivity : boolean ; timedOut : boolean } > => {
181+ const sessionFindings : PiAgentRawFinding [ ] = [ ] ;
182+ let hadActivity = false ;
183+ let timedOut = false ;
184+
185+ const reportFindingTool : ToolDefinition = {
186+ name : 'report_finding' ,
187+ label : 'Report Finding' ,
188+ description : 'Report a confirmed security finding. Call once per finding.' ,
189+ parameters : ReportFindingParams ,
190+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
191+ execute : async ( _toolCallId : any , params : any , _signal : any , _onUpdate : any , _ctx : any ) => {
192+ hadActivity = true ;
193+ const finding = normalizeFinding ( params as RawFindingInput ) ;
194+ sessionFindings . push ( finding ) ;
195+ debug ( 'pi-agent' , `finding recorded: ${ finding . severity . toUpperCase ( ) } ${ finding . file } :${ finding . startLine } [${ finding . ruleId } ]` ) ;
196+ return {
197+ content : [ { type : 'text' as const , text : 'Finding recorded.' } ] ,
198+ details : { } ,
199+ } ;
200+ } ,
201+ } ;
202+
203+ const rawTools = createConfinedTools ( workspacePath , {
204+ headSha,
205+ followImports : toolConfig . followImports ?? true ,
206+ } ) ;
207+
208+ // Wrap each tool's execute to detect model activity (any tool call = session engaged)
209+ const tools = rawTools . map ( tool => ( {
210+ ...tool ,
211+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
212+ execute : async ( ...args : any [ ] ) => {
213+ hadActivity = true ;
214+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
215+ return ( tool . execute as any ) ( ...args ) ;
216+ } ,
217+ } ) ) ;
218+
219+ const resourceLoader = new DefaultResourceLoader ( {
220+ systemPromptOverride : ( ) => systemPrompt ,
221+ appendSystemPromptOverride : ( ) => [ ] ,
222+ noExtensions : true ,
223+ noSkills : true ,
224+ noPromptTemplates : true ,
225+ } ) ;
226+ await resourceLoader . reload ( ) ;
227+
228+ const { session } = await createAgentSession ( {
229+ cwd : workspacePath ,
230+ tools,
231+ customTools : [ reportFindingTool ] ,
232+ sessionManager : SessionManager . inMemory ( ) ,
233+ model,
234+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
235+ thinkingLevel : ( toolConfig . thinkingLevel ?? 'medium' ) as any ,
236+ resourceLoader,
237+ } ) ;
238+
239+ const timer = setTimeout ( ( ) => {
240+ timedOut = true ;
241+ session . abort ( ) . catch ( ( ) => { } ) ;
242+ } , timeoutMs ) ;
243+
244+ try {
245+ await session . prompt ( initialPrompt ) ;
246+ } catch ( err ) {
247+ if ( ! timedOut ) {
248+ console . error ( `[pi-agent] error during scan (attempt ${ attempt } ):` , ( err as Error ) . message ?? err ) ;
249+ }
250+ } finally {
251+ clearTimeout ( timer ) ;
252+ session . dispose ( ) ;
227253 }
228- } finally {
229- clearTimeout ( timer ) ;
230- session . dispose ( ) ;
254+
255+ return { findings : sessionFindings , hadActivity, timedOut } ;
256+ } ;
257+
258+ // ---------------------------------------------------------------------------
259+ // First attempt
260+ // ---------------------------------------------------------------------------
261+ const first = await runSession ( 1 ) ;
262+
263+ if ( first . timedOut ) {
264+ console . warn ( `[pi-agent] scan timed out after ${ toolConfig . timeoutMinutes ?? 3 } m — returning ${ first . findings . length } partial finding(s)` ) ;
231265 }
232266
233- if ( timedOut ) {
234- console . warn ( `[pi-agent] scan timed out after ${ toolConfig . timeoutMinutes ?? 3 } m — returning ${ findings . length } partial finding(s)` ) ;
267+ // Retry once on two failure modes:
268+ // 1. Silent failure — model didn't call any tools at all
269+ // 2. Bad-evidence run — model produced findings but all at line 1, meaning it reported
270+ // from memory rather than reading the actual files (location validator will drop them all)
271+ const silentFailure = first . findings . length === 0 && ! first . hadActivity && ! first . timedOut ;
272+ const badEvidenceRun = first . findings . length > 0 && first . findings . every ( f => f . startLine === 1 && f . endLine === 1 ) ;
273+
274+ let result = first ;
275+ if ( ! first . timedOut && ( silentFailure || badEvidenceRun ) ) {
276+ console . warn ( `[pi-agent] ${ silentFailure ? 'session produced no output' : 'all findings at line 1 (bad evidence)' } — retrying once` ) ;
277+ const retry = await runSession ( 2 ) ;
278+ if ( retry . timedOut ) {
279+ console . warn ( `[pi-agent] retry timed out after ${ toolConfig . timeoutMinutes ?? 3 } m — returning ${ retry . findings . length } partial finding(s)` ) ;
280+ }
281+ result = retry ;
235282 }
236283
237- console . log ( `[pi-agent] ${ findings . length } finding(s)${ timedOut ? ' (partial — timed out)' : '' } :` ) ;
238- for ( const f of findings ) {
284+ console . log ( `[pi-agent] ${ result . findings . length } finding(s):` ) ;
285+ for ( const f of result . findings ) {
239286 console . log ( `[pi-agent] ${ f . severity . toUpperCase ( ) } ${ f . file } :${ f . startLine } -${ f . endLine } [${ f . ruleId } ] ${ f . message } ` ) ;
240287 }
241288
242- return findings ;
289+ return result . findings ;
243290}
0 commit comments