1- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" ;
1+ import type { ExtensionAPI , ExtensionContext } from "@earendil-works/pi-coding-agent" ;
22import { StringEnum } from "@earendil-works/pi-ai" ;
33import { Type , type Static } from "typebox" ;
44import { chmodSync , constants , copyFileSync , existsSync , lstatSync , mkdtempSync , realpathSync , renameSync , rmSync } from "node:fs" ;
@@ -7,7 +7,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
77import { fileURLToPath } from "node:url" ;
88
99const scanSchema = Type . Object ( {
10- target : Type . String ( { description : "Path, URL, zip, Git repo, or SKILL.md to scan." } ) ,
10+ target : Type . String ( { description : "Path, URL, zip, Git repo, or SKILL.md to scan. External paths and remote targets require user confirmation. " } ) ,
1111 format : Type . Optional (
1212 StringEnum ( [ "terminal" , "json" , "markdown" , "sarif" ] as const , {
1313 description : "SkillSpector output format. Defaults to terminal." ,
@@ -21,22 +21,12 @@ const scanSchema = Type.Object({
2121 } ) ,
2222 ) ,
2323 model : Type . Optional ( Type . String ( { description : "Optional model override." } ) ) ,
24- yaraRulesDir : Type . Optional ( Type . String ( { description : "Optional extra YARA rules directory." } ) ) ,
24+ yaraRulesDir : Type . Optional ( Type . String ( { description : "Optional extra YARA rules directory. External paths require user confirmation. " } ) ) ,
2525 verbose : Type . Optional ( Type . Boolean ( { description : "Show detailed progress." } ) ) ,
2626} ) ;
2727
2828type SkillSpectorScanParams = Static < typeof scanSchema > ;
2929
30- function isLikelyUrl ( value : string ) : boolean {
31- return / ^ [ a - z ] [ a - z 0 - 9 + . - ] * : \/ \/ / i. test ( value ) || / ^ [ \w . - ] + \/ [ \w . - ] + (?: \. g i t ) ? (?: @ .+ ) ? $ / i. test ( value ) ;
32- }
33-
34- function resolveMaybePath ( ctxCwd : string , value ?: string ) : string | undefined {
35- if ( ! value ) return undefined ;
36- if ( isLikelyUrl ( value ) ) return value ;
37- return isAbsolute ( value ) ? value : resolve ( ctxCwd , value ) ;
38- }
39-
4030function redactSecrets ( value : string ) : string {
4131 return value
4232 . replace ( / ( s k - a n t - [ A - Z a - z 0 - 9 _ - ] { 12 , } ) / g, "[REDACTED_ANTHROPIC_KEY]" )
@@ -71,6 +61,85 @@ function isWithin(root: string, path: string): boolean {
7161 return rel !== ".." && ! rel . startsWith ( `..${ sep } ` ) && ! isAbsolute ( rel ) ;
7262}
7363
64+ async function approveScanInputs (
65+ params : SkillSpectorScanParams ,
66+ ctx : ExtensionContext ,
67+ signal ?: AbortSignal ,
68+ ) : Promise < SkillSpectorScanParams > {
69+ signal ?. throwIfAborted ( ) ;
70+ const workspace = realpathSync ( ctx . cwd ) ;
71+ const prepared = { ...params } ;
72+ const localPaths : Array < { field : "target" | "yaraRulesDir" ; input : string ; resolved ?: string } > = [ ] ;
73+ const requests : string [ ] = [ ] ;
74+ const readRequest = ( field : string , path : string ) =>
75+ `Read external ${ field === "target" ? "scan target" : "YARA rules" } : ${ JSON . stringify ( path ) } ` ;
76+ async function approve ( requests : string [ ] ) : Promise < void > {
77+ if ( ! requests . length ) return ;
78+ signal ?. throwIfAborted ( ) ;
79+ if ( ! ctx . hasUI ) throw new Error ( "External scan inputs require user confirmation in an interactive or RPC session." ) ;
80+ const approved = await ctx . ui . confirm (
81+ "Allow SkillSpector external access?" ,
82+ `${ requests . join ( "\n" ) } \n\nScanned content and matching rule text can appear in the agent conversation.` ,
83+ { signal } ,
84+ ) ;
85+ signal ?. throwIfAborted ( ) ;
86+ if ( ! approved ) throw new Error ( "SkillSpector external access was not approved." ) ;
87+ }
88+ for ( const field of [ "target" , "yaraRulesDir" ] as const ) {
89+ const value = params [ field ] ?. trim ( ) ;
90+ if ( field === "yaraRulesDir" && ! value ) {
91+ prepared [ field ] = undefined ;
92+ continue ;
93+ }
94+ if ( ! value ) throw new Error ( "A scan target is required." ) ;
95+ // Match the CLI's remote forms. A local owner/repo path is not a URL.
96+ const remote = ! isAbsolute ( value ) && ( value . startsWith ( "https://" ) || ( value . startsWith ( "git@" ) && value . endsWith ( ".git" ) ) ) ;
97+ if ( remote ) {
98+ if ( field !== "target" ) throw new Error ( "YARA rules must be a local directory." ) ;
99+ prepared [ field ] = value ;
100+ requests . push ( `Fetch remote scan target: ${ JSON . stringify ( value ) } ` ) ;
101+ } else {
102+ const input = resolve ( ctx . cwd , value ) ;
103+ // Ask before resolving external paths, which can probe the host or access
104+ // a Windows network share even when the file is never opened.
105+ if ( ! isWithin ( resolve ( ctx . cwd ) , input ) ) {
106+ localPaths . push ( { field, input } ) ;
107+ requests . push ( readRequest ( field , input ) ) ;
108+ } else {
109+ let resolved : string ;
110+ try {
111+ resolved = realpathSync ( input ) ;
112+ } catch {
113+ throw new Error ( `Could not resolve ${ field === "target" ? "scan target" : "YARA rules directory" } . Check that it exists and is accessible.` ) ;
114+ }
115+ localPaths . push ( { field, input, resolved } ) ;
116+ if ( ! isWithin ( workspace , resolved ) ) requests . push ( readRequest ( field , resolved ) ) ;
117+ }
118+ }
119+ }
120+ await approve ( requests ) ;
121+ const aliasRequests : string [ ] = [ ] ;
122+ for ( const path of localPaths ) {
123+ try {
124+ path . resolved ??= realpathSync ( path . input ) ;
125+ } catch {
126+ throw new Error ( `Could not resolve ${ path . field === "target" ? "scan target" : "YARA rules directory" } . Check that it exists and is accessible.` ) ;
127+ }
128+ if ( ! isWithin ( resolve ( ctx . cwd ) , path . input ) && ! isWithin ( workspace , path . resolved ) && path . resolved !== path . input ) {
129+ aliasRequests . push ( readRequest ( path . field , path . resolved ) ) ;
130+ }
131+ // Keep the original target path so the CLI can enforce its no-symlink
132+ // input policy. YARA directories are canonicalised by the CLI too.
133+ prepared [ path . field ] = path . field === "target" ? path . input : path . resolved ;
134+ }
135+ await approve ( aliasRequests ) ;
136+ signal ?. throwIfAborted ( ) ;
137+ if ( realpathSync ( ctx . cwd ) !== workspace || localPaths . some ( ( { input, resolved } ) => realpathSync ( input ) !== resolved ) ) {
138+ throw new Error ( "Scan input path changed while awaiting confirmation." ) ;
139+ }
140+ return prepared ;
141+ }
142+
74143function reportOutputPath ( cwd : string , value ?: string ) : string | undefined {
75144 if ( ! value ) return undefined ;
76145 const output = resolve ( cwd , value ) ;
@@ -102,17 +171,16 @@ function publishReport(source: string, destination: string): void {
102171 }
103172}
104173
105- function buildScanArgs ( params : SkillSpectorScanParams , cwd : string , output ?: string ) : string [ ] {
106- const args = [ "scan" , resolveMaybePath ( cwd , params . target ) ?? params . target ] ;
174+ function buildScanArgs ( params : SkillSpectorScanParams , output ?: string ) : string [ ] {
175+ const args = [ "scan" , params . target ] ;
107176 args . push ( "--format" , params . format ?? "terminal" ) ;
108177
109178 const noLlm = params . noLlm ?? true ;
110179 if ( noLlm ) args . push ( "--no-llm" ) ;
111180
112181 if ( output ) args . push ( "--output" , output ) ;
113182
114- const yaraRulesDir = resolveMaybePath ( cwd , params . yaraRulesDir ) ;
115- if ( yaraRulesDir ) args . push ( "--yara-rules-dir" , yaraRulesDir ) ;
183+ if ( params . yaraRulesDir ) args . push ( "--yara-rules-dir" , params . yaraRulesDir ) ;
116184
117185 if ( params . verbose ) args . push ( "--verbose" ) ;
118186 return args ;
@@ -132,10 +200,11 @@ export default function (pi: ExtensionAPI) {
132200 async execute ( _toolCallId , params , signal , onUpdate , ctx ) {
133201 const bin = findSkillSpectorBin ( ) ;
134202 const outputPath = reportOutputPath ( ctx . cwd , params . output ) ;
203+ const prepared = await approveScanInputs ( params , ctx , signal ) ;
135204 const reportDir = outputPath ? mkdtempSync ( join ( tmpdir ( ) , "skillspector-report-" ) ) : undefined ;
136205 const reportPath = reportDir ? join ( reportDir , "report" ) : undefined ;
137206 try {
138- const args = buildScanArgs ( params , ctx . cwd , reportPath ) ;
207+ const args = buildScanArgs ( prepared , reportPath ) ;
139208 const env : Record < string , string > = { } ;
140209
141210 if ( params . provider ) env . SKILLSPECTOR_PROVIDER = params . provider ;
0 commit comments