22 * Skill install service.
33 *
44 * Delegates fetching to a SkillSourceProvider, then runs the common pipeline:
5- * validate → security scan → collision check → deploy → persist.
5+ * validate → security checks → collision check → deploy → persist.
66 */
77
88import { readdir } from "node:fs/promises" ;
@@ -26,13 +26,32 @@ export interface ScanResult {
2626 unknown : string [ ] ;
2727}
2828
29+ export interface SecurityCheckEntry {
30+ emoji : string ;
31+ label : string ;
32+ passed : boolean ;
33+ details : string [ ] ;
34+ failureMessage : string ;
35+ }
36+
37+ export interface SecurityReport {
38+ checks : SecurityCheckEntry [ ] ;
39+ allPassed : boolean ;
40+ }
41+
42+ /** Sentinel error signalling the install was cancelled via callback. */
43+ export class InstallCancelledError extends Error {
44+ override name = "InstallCancelledError" ;
45+ constructor ( message = "Installation cancelled." ) {
46+ super ( message ) ;
47+ }
48+ }
49+
2950export interface InstallOptions {
30- /** Skip security scan prompt and install regardless. */
31- force ?: boolean ;
32- /** Abort if any non-text files are found (for scripted usage). */
33- safeOnly ?: boolean ;
34- /** Callback for confirming installation when executable files are found. */
35- confirm ?: ( scan : ScanResult ) => Promise < boolean > ;
51+ /** Skip all security checks and confirmation prompt. */
52+ yolo ?: boolean ;
53+ /** Called with security check results and skill name. Return true to proceed. */
54+ onSecurityCheck ?: ( report : SecurityReport , skillName : string ) => Promise < boolean > ;
3655 /** Callback to let user choose from multiple skills (marketplace repos). */
3756 selectSkill ?: ( skills : { name : string ; description ?: string } [ ] ) => Promise < string | null > ;
3857 /** Override auto-detected provider (e.g., from --github or --clawdhub flag). */
@@ -81,9 +100,15 @@ export async function installSkill(
81100 const validation = await validateSkillStructure ( fetchResult . value . skillDir ) ;
82101 if ( ! validation . ok ) return validation ;
83102
84- // Security scan
85- const scanResult = await enforceSecurityScan ( fetchResult . value . skillDir , options ) ;
86- if ( ! scanResult . ok ) return scanResult ;
103+ // Security checks (unless --yolo)
104+ if ( ! options . yolo ) {
105+ const securityResult = await enforceSecurityChecks (
106+ fetchResult . value . skillDir ,
107+ validation . value . name ,
108+ options ,
109+ ) ;
110+ if ( ! securityResult . ok ) return securityResult ;
111+ }
87112
88113 // Check for name collision
89114 const collisionResult = await checkNameCollision ( shakaHome , validation . value . name ) ;
@@ -127,27 +152,87 @@ export async function scanForExecutableContent(skillPath: string): Promise<ScanR
127152 return result ;
128153}
129154
130- // --- Internal helpers ---
155+ // --- Security checks ---
131156
132- async function enforceSecurityScan (
133- skillPath : string ,
134- options : InstallOptions ,
135- ) : Promise < Result < void , Error > > {
157+ /**
158+ * Run all security checks on a skill directory.
159+ */
160+ export async function runSecurityChecks ( skillPath : string ) : Promise < SecurityReport > {
161+ const checks = await Promise . all ( [
162+ checkExecutables ( skillPath ) ,
163+ checkSuspiciousLinks ( skillPath ) ,
164+ checkHtmlComments ( skillPath ) ,
165+ ] ) ;
166+ return {
167+ checks,
168+ allPassed : checks . every ( ( c ) => c . passed ) ,
169+ } ;
170+ }
171+
172+ async function checkExecutables ( skillPath : string ) : Promise < SecurityCheckEntry > {
136173 const scan = await scanForExecutableContent ( skillPath ) ;
137- const hasRiskyFiles = scan . executable . length > 0 || scan . unknown . length > 0 ;
174+ const hasRisky = scan . executable . length > 0 || scan . unknown . length > 0 ;
175+ return {
176+ emoji : "\u{1F3C3}" ,
177+ label : "No executables" ,
178+ passed : ! hasRisky ,
179+ details : [ ...scan . executable , ...scan . unknown ] ,
180+ failureMessage : "Skill contains executable files, make sure to review it properly." ,
181+ } ;
182+ }
138183
139- if ( ! hasRiskyFiles ) return ok ( undefined ) ;
184+ async function checkSuspiciousLinks ( skillPath : string ) : Promise < SecurityCheckEntry > {
185+ const files = await collectFiles ( skillPath ) ;
186+ const mdFiles = files . filter ( ( f ) => f . toLowerCase ( ) . endsWith ( ".md" ) ) ;
187+ const flagged : string [ ] = [ ] ;
188+ const pattern = / \b ( c u r l | w g e t ) \b / ;
189+ for ( const file of mdFiles ) {
190+ const content = await Bun . file ( join ( skillPath , file ) ) . text ( ) ;
191+ if ( pattern . test ( content ) ) {
192+ flagged . push ( file ) ;
193+ }
194+ }
195+ return {
196+ emoji : "\u{1F517}" ,
197+ label : "No links" ,
198+ passed : flagged . length === 0 ,
199+ details : flagged ,
200+ failureMessage : "Skill contains curl/wget commands, make sure to review it properly." ,
201+ } ;
202+ }
140203
141- if ( options . safeOnly ) {
142- const flagged = [ ...scan . executable , ...scan . unknown ] . join ( ", " ) ;
143- return err ( new Error ( `Skill contains non-text files (${ flagged } ). Aborting (--safe-only).` ) ) ;
204+ async function checkHtmlComments ( skillPath : string ) : Promise < SecurityCheckEntry > {
205+ const files = await collectFiles ( skillPath ) ;
206+ const mdFiles = files . filter ( ( f ) => f . toLowerCase ( ) . endsWith ( ".md" ) ) ;
207+ const flagged : string [ ] = [ ] ;
208+ for ( const file of mdFiles ) {
209+ const content = await Bun . file ( join ( skillPath , file ) ) . text ( ) ;
210+ if ( content . includes ( "<!--" ) ) {
211+ flagged . push ( file ) ;
212+ }
144213 }
214+ return {
215+ emoji : "\u{1F977}" ,
216+ label : "No html comments" ,
217+ passed : flagged . length === 0 ,
218+ details : flagged ,
219+ failureMessage : "Skill has HTML comments in markdown, make sure to review it properly." ,
220+ } ;
221+ }
145222
146- if ( options . force ) return ok ( undefined ) ;
223+ // --- Internal helpers ---
147224
148- const confirmed = options . confirm ? await options . confirm ( scan ) : false ;
149- if ( ! confirmed ) {
150- return err ( new Error ( "Installation cancelled by user." ) ) ;
225+ async function enforceSecurityChecks (
226+ skillPath : string ,
227+ skillName : string ,
228+ options : InstallOptions ,
229+ ) : Promise < Result < void , Error > > {
230+ const report = await runSecurityChecks ( skillPath ) ;
231+ if ( options . onSecurityCheck ) {
232+ const proceed = await options . onSecurityCheck ( report , skillName ) ;
233+ if ( ! proceed ) return err ( new InstallCancelledError ( ) ) ;
234+ } else if ( ! report . allPassed ) {
235+ return err ( new Error ( "Security checks failed." ) ) ;
151236 }
152237 return ok ( undefined ) ;
153238}
0 commit comments