reskill 1.16.0-beta.1 → 1.16.0

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/dist/cli/index.js CHANGED
@@ -7483,955 +7483,956 @@ const logoutCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('logout'
7483
7483
  }
7484
7484
  });
7485
7485
  /**
7486
- * Publisher - Handle Git information and publish payload building
7486
+ * ContentScanner - Detect malicious patterns in SKILL.md content
7487
7487
  *
7488
- * Extracts Git metadata and builds the payload for publishing to registry.
7489
- */ class PublishError extends Error {
7490
- constructor(message){
7491
- super(message);
7492
- this.name = 'PublishError';
7493
- }
7494
- }
7495
- // ============================================================================
7496
- // Publisher Class
7488
+ * Features:
7489
+ * - Context-aware: skips safe zones (frontmatter, code blocks, quotes, blockquotes)
7490
+ * - 6 built-in detection rules across 3 risk levels
7491
+ * - Configurable: override levels, disable rules, add custom rules
7492
+ * - Pure string operations in scan() — no fs dependency, suitable for server use
7493
+ * - scanFile() convenience method for CLI use
7494
+ */ // ============================================================================
7495
+ // Safe Zone Masking
7497
7496
  // ============================================================================
7498
- class Publisher {
7499
- /**
7500
- * Get Git information from a skill directory
7501
- */ async getGitInfo(skillPath, specifiedTag) {
7502
- const info = {
7503
- isRepo: false,
7504
- remoteUrl: null,
7505
- currentBranch: null,
7506
- currentCommit: null,
7507
- commitDate: null,
7508
- tag: null,
7509
- tagCommit: null,
7510
- isDirty: false,
7511
- sourceRef: null
7512
- };
7513
- // Check if it's a git repository
7514
- try {
7515
- (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git rev-parse --git-dir', {
7516
- cwd: skillPath,
7517
- stdio: 'pipe'
7518
- });
7519
- info.isRepo = true;
7520
- } catch {
7521
- return info;
7522
- }
7523
- // Get remote URL
7524
- try {
7525
- info.remoteUrl = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git remote get-url origin', {
7526
- cwd: skillPath,
7527
- encoding: 'utf-8'
7528
- }).trim();
7529
- // Parse to sourceRef format
7530
- info.sourceRef = this.parseRemoteToSourceRef(info.remoteUrl);
7531
- } catch {
7532
- // No remote configured
7533
- }
7534
- // Get current branch
7535
- try {
7536
- info.currentBranch = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git branch --show-current', {
7537
- cwd: skillPath,
7538
- encoding: 'utf-8'
7539
- }).trim() || null;
7540
- } catch {
7541
- // Detached HEAD or other error
7542
- }
7543
- // Get current commit
7544
- try {
7545
- info.currentCommit = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git rev-parse HEAD', {
7546
- cwd: skillPath,
7547
- encoding: 'utf-8'
7548
- }).trim();
7549
- // Get commit date
7550
- info.commitDate = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git show -s --format=%cI HEAD', {
7551
- cwd: skillPath,
7552
- encoding: 'utf-8'
7553
- }).trim();
7554
- } catch {
7555
- // No commits yet
7556
- }
7557
- // Get tag
7558
- if (specifiedTag) // Use specified tag
7559
- try {
7560
- info.tagCommit = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)(`git rev-parse "${specifiedTag}^{commit}"`, {
7561
- cwd: skillPath,
7562
- encoding: 'utf-8'
7563
- }).trim();
7564
- info.tag = specifiedTag;
7565
- } catch {
7566
- throw new PublishError(`Tag "${specifiedTag}" not found`);
7567
- }
7568
- else // Try to get tag on current commit
7569
- try {
7570
- const tag = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git describe --exact-match --tags HEAD', {
7571
- cwd: skillPath,
7572
- encoding: 'utf-8'
7573
- }).trim();
7574
- info.tag = tag;
7575
- info.tagCommit = info.currentCommit;
7576
- } catch {
7577
- // No tag on current commit
7497
+ /**
7498
+ * Mask safe zones in Markdown content with spaces, preserving line structure.
7499
+ *
7500
+ * Safe zones (content replaced with spaces):
7501
+ * - YAML frontmatter (`---` ... `---` at file start)
7502
+ * - Fenced code blocks (``` or ~~~)
7503
+ * - Indented code blocks (4 spaces / tab after blank line)
7504
+ * - Blockquotes (`> ` prefix)
7505
+ * - Inline code (`` `...` ``)
7506
+ * - Double-quoted text (`"..."`, min 3 chars between quotes)
7507
+ *
7508
+ * Line breaks are preserved so line numbers remain correct.
7509
+ */ function maskSafeZones(content) {
7510
+ const lines = content.split('\n');
7511
+ const result = [];
7512
+ let inFrontmatter = false;
7513
+ let inFencedCode = false;
7514
+ let fenceChar = '';
7515
+ let fenceLength = 0;
7516
+ let prevLineBlank = false;
7517
+ let prevLineIndentedCode = false;
7518
+ for(let i = 0; i < lines.length; i++){
7519
+ const line = lines[i];
7520
+ // --- YAML Frontmatter (only at file start) ---
7521
+ if (0 === i && '---' === line.trim()) {
7522
+ inFrontmatter = true;
7523
+ result.push(maskLine(line));
7524
+ continue;
7578
7525
  }
7579
- // Check if working tree is dirty
7580
- try {
7581
- const status = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git status --porcelain', {
7582
- cwd: skillPath,
7583
- encoding: 'utf-8'
7584
- }).trim();
7585
- info.isDirty = status.length > 0;
7586
- } catch {
7587
- // Ignore errors
7526
+ if (inFrontmatter) {
7527
+ result.push(maskLine(line));
7528
+ if ('---' === line.trim()) inFrontmatter = false;
7529
+ continue;
7588
7530
  }
7589
- return info;
7590
- }
7591
- /**
7592
- * Parse remote URL to sourceRef format (e.g., github:user/repo)
7593
- */ parseRemoteToSourceRef(remoteUrl) {
7594
- // SSH format: git@github.com:user/repo.git
7595
- const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(\.git)?$/);
7596
- if (sshMatch) {
7597
- const [, host, owner, repo] = sshMatch;
7598
- const registry = this.normalizeHost(host);
7599
- return `${registry}:${owner}/${repo.replace(/\.git$/, '')}`;
7531
+ // --- Fenced code blocks (``` or ~~~) ---
7532
+ const fenceMatch = line.match(/^(`{3,}|~{3,})/);
7533
+ if (!inFencedCode && fenceMatch) {
7534
+ inFencedCode = true;
7535
+ fenceChar = fenceMatch[1][0];
7536
+ fenceLength = fenceMatch[1].length;
7537
+ result.push(maskLine(line));
7538
+ prevLineBlank = false;
7539
+ prevLineIndentedCode = false;
7540
+ continue;
7600
7541
  }
7601
- // HTTPS format: https://github.com/user/repo.git
7602
- const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/([^/]+)\/(.+?)(\.git)?$/);
7603
- if (httpsMatch) {
7604
- const [, host, owner, repo] = httpsMatch;
7605
- const registry = this.normalizeHost(host);
7606
- return `${registry}:${owner}/${repo.replace(/\.git$/, '')}`;
7542
+ if (inFencedCode) {
7543
+ result.push(maskLine(line));
7544
+ const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/);
7545
+ if (closeMatch && closeMatch[1][0] === fenceChar && closeMatch[1].length >= fenceLength) inFencedCode = false;
7546
+ prevLineBlank = false;
7547
+ prevLineIndentedCode = false;
7548
+ continue;
7607
7549
  }
7608
- return null;
7609
- }
7610
- /**
7611
- * Normalize host to registry name
7612
- */ normalizeHost(host) {
7613
- if ('github.com' === host) return 'github';
7614
- if ('gitlab.com' === host) return 'gitlab';
7615
- return host;
7616
- }
7617
- /**
7618
- * Build publish payload
7619
- */ buildPayload(skill, gitInfo, integrity) {
7620
- const { skillJson, skillMd, readme, files } = skill;
7621
- const payload = {
7622
- version: skillJson.version,
7623
- description: skillJson.description || '',
7624
- gitRef: gitInfo.tag || gitInfo.currentCommit || 'HEAD',
7625
- gitCommit: gitInfo.tagCommit || gitInfo.currentCommit || '',
7626
- gitCommitDate: gitInfo.commitDate || void 0,
7627
- repositoryUrl: gitInfo.remoteUrl || '',
7628
- sourceRef: gitInfo.sourceRef || '',
7629
- skillJson,
7630
- files,
7631
- entry: skillJson.entry || 'SKILL.md',
7632
- integrity
7633
- };
7634
- // Add optional fields
7635
- if (skillMd) payload.skillMd = {
7636
- name: skillMd.name,
7637
- description: skillMd.description,
7638
- license: skillMd.license,
7639
- compatibility: skillMd.compatibility,
7640
- allowedTools: skillMd.allowedTools
7641
- };
7642
- if (readme) payload.readmePreview = readme;
7643
- if (skillJson.keywords && skillJson.keywords.length > 0) payload.keywords = skillJson.keywords;
7644
- if (skillJson.compatibility) {
7645
- // Filter out undefined values from compatibility
7646
- const compat = {};
7647
- for (const [key, value] of Object.entries(skillJson.compatibility))if (void 0 !== value) compat[key] = value;
7648
- if (Object.keys(compat).length > 0) payload.compatibility = compat;
7550
+ // --- Blockquote ---
7551
+ if (/^>\s?/.test(line)) {
7552
+ result.push(maskLine(line));
7553
+ prevLineBlank = false;
7554
+ prevLineIndentedCode = false;
7555
+ continue;
7649
7556
  }
7650
- return payload;
7651
- }
7652
- /**
7653
- * Format bytes for display
7654
- */ formatBytes(bytes) {
7655
- if (bytes < 1024) return `${bytes} B`;
7656
- if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
7657
- return `${(bytes / 1048576).toFixed(1)} MB`;
7658
- }
7659
- /**
7660
- * Calculate total size of files
7661
- */ calculateTotalSize(skillPath, files) {
7662
- let total = 0;
7663
- for (const file of files){
7664
- const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
7665
- if (external_node_fs_.existsSync(filePath)) {
7666
- const stats = external_node_fs_.statSync(filePath);
7667
- total += stats.size;
7668
- }
7557
+ // --- Indented code block (4 spaces or tab, after blank line) ---
7558
+ if (/^(?: |\t)/.test(line) && (prevLineBlank || prevLineIndentedCode)) {
7559
+ result.push(maskLine(line));
7560
+ prevLineBlank = false;
7561
+ prevLineIndentedCode = true;
7562
+ continue;
7669
7563
  }
7670
- return total;
7564
+ // --- Normal line: mask inline code and double-quoted text ---
7565
+ result.push(maskInline(line));
7566
+ prevLineBlank = '' === line.trim();
7567
+ prevLineIndentedCode = false;
7671
7568
  }
7569
+ return result.join('\n');
7570
+ }
7571
+ /** Replace all characters in a line with spaces (preserving length) */ function maskLine(line) {
7572
+ return ' '.repeat(line.length);
7672
7573
  }
7673
7574
  /**
7674
- * SkillValidator - Validate skills for publishing
7675
- *
7676
- * Following agentskills.io specification: https://agentskills.io/specification
7677
- *
7678
- * Key points:
7679
- * - SKILL.md is the SOLE source of metadata (name, description, version, etc.)
7680
- * - skill.json is NOT used - all metadata comes from SKILL.md frontmatter
7681
- * - Version defaults to "0.0.0" if not specified in SKILL.md
7682
- */ // ============================================================================
7683
- // Constants
7684
- // ============================================================================
7685
- const MAX_NAME_LENGTH = 64;
7686
- const MAX_DESCRIPTION_LENGTH = 1024;
7687
- const MAX_KEYWORDS = 10;
7688
- const SINGLE_CHAR_NAME_PATTERN = /^[a-z0-9]$/;
7689
- const DEFAULT_VERSION = '0.0.0';
7690
- // Default files to include in publish
7691
- const DEFAULT_FILES = [
7692
- 'SKILL.md',
7693
- 'README.md',
7694
- 'LICENSE'
7695
- ];
7575
+ * Mask inline code (`` `...` ``) and double-quoted text (`"..."`) within a line.
7576
+ * Uses regex replacement for efficiency (avoids char-by-char concatenation on long lines).
7577
+ * Single quotes are NOT masked to avoid false matches with apostrophes.
7578
+ */ function maskInline(line) {
7579
+ let result = line;
7580
+ // Inline code: `...`
7581
+ result = result.replace(/`[^`]+`/g, (m)=>' '.repeat(m.length));
7582
+ // Double-quoted text: "..." (min 3 chars between quotes)
7583
+ result = result.replace(/"[^"]{3,}"/g, (m)=>' '.repeat(m.length));
7584
+ return result;
7585
+ }
7696
7586
  // ============================================================================
7697
- // SkillValidator Class
7587
+ // Rule Helpers
7698
7588
  // ============================================================================
7699
- class SkillValidator {
7700
- /**
7701
- * Validate skill name format
7702
- *
7703
- * Requirements:
7704
- * - Lowercase letters, numbers, and hyphens only
7705
- * - 1-64 characters
7706
- * - Cannot start or end with hyphen
7707
- * - Cannot have consecutive hyphens
7708
- */ validateName(name) {
7709
- const errors = [];
7710
- if (!name) {
7711
- errors.push({
7712
- field: 'name',
7713
- message: 'Skill name is required',
7714
- suggestion: 'Add "name" field to SKILL.md frontmatter'
7715
- });
7716
- return {
7717
- valid: false,
7718
- errors,
7719
- warnings: []
7720
- };
7721
- }
7722
- if (name.length > MAX_NAME_LENGTH) errors.push({
7723
- field: 'name',
7724
- message: `Skill name must be at most ${MAX_NAME_LENGTH} characters`,
7725
- suggestion: `Shorten the name to ${MAX_NAME_LENGTH} characters or less`
7726
- });
7727
- // Check for uppercase
7728
- if (/[A-Z]/.test(name)) errors.push({
7729
- field: 'name',
7730
- message: 'Skill name must be lowercase',
7731
- suggestion: `Change "${name}" to "${name.toLowerCase()}"`
7732
- });
7733
- // Check for invalid characters
7734
- if (/[^a-z0-9-]/.test(name)) errors.push({
7735
- field: 'name',
7736
- message: 'Skill name can only contain lowercase letters, numbers, and hyphens',
7737
- suggestion: 'Remove special characters from the name'
7589
+ /** Find lines matching any of the given patterns, return one match per line */ function findLineMatches(content, patterns) {
7590
+ const lines = content.split('\n');
7591
+ const matches = [];
7592
+ for(let i = 0; i < lines.length; i++)for (const pattern of patterns)if (pattern.test(lines[i])) {
7593
+ matches.push({
7594
+ line: i + 1
7738
7595
  });
7739
- // Check pattern for multi-char names
7740
- if (1 === name.length) {
7741
- if (!SINGLE_CHAR_NAME_PATTERN.test(name)) errors.push({
7742
- field: 'name',
7743
- message: 'Single character name must be a lowercase letter or number'
7744
- });
7745
- } else if (name.length > 1) {
7746
- // Check start/end with hyphen
7747
- if (name.startsWith('-')) errors.push({
7748
- field: 'name',
7749
- message: 'Skill name cannot start with a hyphen'
7750
- });
7751
- if (name.endsWith('-')) errors.push({
7752
- field: 'name',
7753
- message: 'Skill name cannot end with a hyphen'
7754
- });
7755
- // Check consecutive hyphens
7756
- if (/--/.test(name)) errors.push({
7757
- field: 'name',
7758
- message: 'Skill name cannot contain consecutive hyphens'
7759
- });
7760
- }
7761
- return {
7762
- valid: 0 === errors.length,
7763
- errors,
7764
- warnings: []
7765
- };
7596
+ break;
7766
7597
  }
7767
- /**
7768
- * Validate version format (semver)
7769
- */ validateVersion(version) {
7770
- const errors = [];
7771
- if (!version) {
7772
- errors.push({
7773
- field: 'version',
7774
- message: 'Version is required',
7775
- suggestion: 'Add "version" field to SKILL.md frontmatter (e.g., "1.0.0")'
7598
+ return matches;
7599
+ }
7600
+ // ============================================================================
7601
+ // Default Rules
7602
+ // ============================================================================
7603
+ const SNIPPET_MAX_LENGTH = 120;
7604
+ /** Built-in detection rules */ const DEFAULT_RULES = [
7605
+ // Rule 1: Prompt Injection (high)
7606
+ {
7607
+ id: 'prompt-injection',
7608
+ level: 'high',
7609
+ message: 'Detected prompt injection attempt',
7610
+ skipSafeZones: true,
7611
+ check: (content)=>findLineMatches(content, [
7612
+ // English patterns
7613
+ /ignore\s+(all\s+)?previous\s+instructions/i,
7614
+ /disregard\s+(all\s+)?(prior|previous|above)\s+(instructions|rules|context)/i,
7615
+ /you\s+are\s+now\s+(?:(?:a|an)\s+)?(?:(?:\w+\s+){0,3}(?:agent|ai|assistant|bot|model|character|persona|entity|system)|DAN\b|jailbr\w*|unrestricted|unfiltered|free\s+from)/i,
7616
+ /from\s+now\s+on[,\s]+you\s+are/i,
7617
+ /new\s+system\s+prompt/i,
7618
+ /override\s+(your|the)\s+(system|safety|security)\s+(prompt|rules|instructions)/i,
7619
+ /forget\s+(?:all\s+)?(?:your\s+)?(?:previous\s+|prior\s+)?(?:instructions|rules|constraints)/i,
7620
+ /(?:you\s+are|you're)\s+(?:now\s+)?entering\s+(?:a\s+)?new\s+(?:mode|context|session)/i,
7621
+ // Chinese patterns (中文提示词注入)
7622
+ /[忽无][略视]\s*(所有\s*)?(之前的?|先前的?|以前的?)?\s*(指令|指示|规则|约束|限制)/,
7623
+ /你现在是/,
7624
+ /从现在开始.{0,10}你是/,
7625
+ /新的系统提示词/,
7626
+ /[覆改]写?\s*(你的|系统)\s*(提示词|规则|指令|安全)/,
7627
+ /忘记\s*(所有\s*)?(之前的?|先前的?)?\s*(指令|指示|规则|约束)/,
7628
+ /进入.{0,5}新的?\s*(模式|上下文|会话)/,
7629
+ /不要遵守.{0,10}(安全|限制|规则|约束)/,
7630
+ /解除.{0,5}(限制|约束|安全)/,
7631
+ /无限制模式/,
7632
+ /安全模式已关闭/
7633
+ ])
7634
+ },
7635
+ // Rule 2: Data Exfiltration (high)
7636
+ {
7637
+ id: 'data-exfiltration',
7638
+ level: 'high',
7639
+ message: 'Detected potential data exfiltration command',
7640
+ skipSafeZones: true,
7641
+ check: (content)=>{
7642
+ const lines = content.split('\n');
7643
+ const matches = [];
7644
+ const commandPattern = /\b(curl|wget|fetch|http\.post|requests\.post|nc\b|ncat|netcat)\b/i;
7645
+ const sensitivePattern = /(\$[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)[A-Z_]*|\$ENV\b|\$\{[^}]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[^}]*\})/i;
7646
+ for(let i = 0; i < lines.length; i++)if (commandPattern.test(lines[i]) && sensitivePattern.test(lines[i])) matches.push({
7647
+ line: i + 1
7776
7648
  });
7777
- return {
7778
- valid: false,
7779
- errors,
7780
- warnings: []
7781
- };
7649
+ return matches;
7782
7650
  }
7783
- // Check for v prefix
7784
- if (version.startsWith('v')) {
7785
- errors.push({
7786
- field: 'version',
7787
- message: 'Version should not have "v" prefix',
7788
- suggestion: `Change "${version}" to "${version.slice(1)}"`
7651
+ },
7652
+ // Rule 3: Content Obfuscation (high) — scans ALL content including safe zones
7653
+ {
7654
+ id: 'obfuscation',
7655
+ level: 'high',
7656
+ message: 'Detected content obfuscation',
7657
+ skipSafeZones: false,
7658
+ check: (content)=>{
7659
+ const matches = [];
7660
+ const lines = content.split('\n');
7661
+ // Zero-width characters (suspicious in any context)
7662
+ const zeroWidthPattern = /[\u200B\u200C\u200D\uFEFF\u2060\u180E]/;
7663
+ for(let i = 0; i < lines.length; i++)if (zeroWidthPattern.test(lines[i])) matches.push({
7664
+ line: i + 1,
7665
+ snippet: 'Zero-width Unicode characters detected'
7789
7666
  });
7790
- return {
7791
- valid: false,
7792
- errors,
7793
- warnings: []
7794
- };
7795
- }
7796
- if (!__WEBPACK_EXTERNAL_MODULE_semver__.valid(version)) errors.push({
7797
- field: 'version',
7798
- message: `Invalid version format: "${version}". Must follow semver (x.y.z)`,
7799
- suggestion: 'Use format like "1.0.0" or "1.0.0-beta.1"'
7800
- });
7801
- return {
7802
- valid: 0 === errors.length,
7803
- errors,
7804
- warnings: []
7805
- };
7806
- }
7807
- /**
7808
- * Validate description
7809
- *
7810
- * Following agentskills.io specification:
7811
- * - Max 1024 characters
7812
- * - Non-empty
7813
- */ validateDescription(description) {
7814
- const errors = [];
7815
- if (!description) {
7816
- errors.push({
7817
- field: 'description',
7818
- message: 'Description is required',
7819
- suggestion: 'Add "description" field to SKILL.md frontmatter'
7667
+ // Long base64-like strings (>200 continuous chars)
7668
+ const base64Pattern = /[A-Za-z0-9+/=]{200,}/;
7669
+ for(let i = 0; i < lines.length; i++)if (base64Pattern.test(lines[i])) matches.push({
7670
+ line: i + 1,
7671
+ snippet: 'Suspicious base64-encoded block detected'
7820
7672
  });
7821
- return {
7822
- valid: false,
7823
- errors,
7824
- warnings: []
7825
- };
7673
+ // Large HTML comments (>200 chars of content)
7674
+ const commentRegex = /<!--([\s\S]{200,}?)-->/g;
7675
+ let match;
7676
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
7677
+ while(null !== (match = commentRegex.exec(content))){
7678
+ const lineNum = content.slice(0, match.index).split('\n').length;
7679
+ matches.push({
7680
+ line: lineNum,
7681
+ snippet: `Large HTML comment block (${match[1].length} chars)`
7682
+ });
7683
+ }
7684
+ return matches;
7826
7685
  }
7827
- if (description.length > MAX_DESCRIPTION_LENGTH) errors.push({
7828
- field: 'description',
7829
- message: `Description must be at most ${MAX_DESCRIPTION_LENGTH} characters`
7830
- });
7831
- // Note: angle brackets are allowed per agentskills.io spec
7832
- return {
7833
- valid: 0 === errors.length,
7834
- errors,
7835
- warnings: []
7836
- };
7837
- }
7838
- /**
7839
- * Load skill information from directory
7840
- *
7841
- * Following agentskills.io specification:
7842
- * - SKILL.md is the SOLE source of metadata
7843
- * - skillJson is synthesized from SKILL.md for backward compatibility with publish API
7844
- */ loadSkill(skillPath) {
7845
- const result = {
7846
- path: skillPath,
7847
- skillJson: null,
7848
- skillMd: null,
7849
- readme: null,
7850
- files: []
7851
- };
7852
- // Load SKILL.md (sole source of metadata)
7853
- const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'SKILL.md');
7854
- if (external_node_fs_.existsSync(skillMdPath)) try {
7855
- result.skillMd = parseSkillMdFile(skillMdPath);
7856
- // Always synthesize skillJson from SKILL.md for backward compatibility
7857
- if (result.skillMd) result.skillJson = this.synthesizeSkillJson(result.skillMd);
7858
- } catch {
7859
- // Will be caught in validation
7686
+ },
7687
+ // Rule 4: Sensitive File Access (medium)
7688
+ {
7689
+ id: 'sensitive-file-access',
7690
+ level: 'medium',
7691
+ message: 'References sensitive file path',
7692
+ skipSafeZones: true,
7693
+ check: (content)=>findLineMatches(content, [
7694
+ /~\/\.ssh\b/,
7695
+ /~\/\.aws\b/,
7696
+ /~\/\.gnupg\b/,
7697
+ /~\/\.config\/gcloud\b/,
7698
+ /\bid_rsa\b/i,
7699
+ /\bid_ed25519\b/i,
7700
+ /\/etc\/passwd\b/,
7701
+ /\/etc\/shadow\b/,
7702
+ /\.env\b(?!\.\w)/
7703
+ ])
7704
+ },
7705
+ // Rule 5: Stealth Instructions (medium) — phrase + action verb matching
7706
+ {
7707
+ id: 'stealth-instructions',
7708
+ level: 'medium',
7709
+ message: 'Detected instruction to hide actions from user',
7710
+ skipSafeZones: true,
7711
+ check: (content)=>{
7712
+ const actionVerbs = 'execute|delete|remove|send|transmit|modify|overwrite|install|download|upload|run|write|create|destroy|drop';
7713
+ const patterns = [
7714
+ // English patterns
7715
+ new RegExp(`silently\\s+(?:${actionVerbs})`, 'i'),
7716
+ new RegExp(`without\\s+telling\\s+the\\s+user.{0,30}(?:${actionVerbs})`, 'i'),
7717
+ new RegExp("(?:do\\s+not|don'?t)\\s+show\\s+.{0,40}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
7718
+ new RegExp("hide\\s+(?:this|the|these|all)\\s+.{0,30}(?:from\\s+the\\s+user|from\\s+user)", 'i'),
7719
+ new RegExp("(?:do\\s+not|don'?t)\\s+mention\\s+.{0,30}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
7720
+ new RegExp("keep\\s+(?:this|it)\\s+(?:a\\s+)?secret\\s+from\\s+(?:the\\s+)?user", 'i'),
7721
+ // Chinese patterns (中文隐蔽指令)
7722
+ /悄悄地?\s*(?:执行|删除|移除|发送|传输|修改|覆盖|安装|下载|上传|运行|写入|创建|销毁|丢弃)/,
7723
+ /不要告诉用户/,
7724
+ /不要让用户知道/,
7725
+ /对用户隐藏/,
7726
+ /在用户不知情的情况下/,
7727
+ /瞒着用户/
7728
+ ];
7729
+ // Safe patterns to exclude (common in legitimate DevOps/automation skills)
7730
+ const safePatterns = [
7731
+ /silently\s+(?:ignore|skip|fail|discard|suppress|continue|pass|drop|swallow)/i,
7732
+ // Chinese safe patterns (中文合法自动化用语)
7733
+ /悄悄地?\s*(?:忽略|跳过|丢弃|抑制|继续|静默)/
7734
+ ];
7735
+ const lines = content.split('\n');
7736
+ const matches = [];
7737
+ for(let i = 0; i < lines.length; i++){
7738
+ const line = lines[i];
7739
+ if (!safePatterns.some((p)=>p.test(line))) {
7740
+ for (const pattern of patterns)if (pattern.test(line)) {
7741
+ matches.push({
7742
+ line: i + 1
7743
+ });
7744
+ break;
7745
+ }
7746
+ }
7747
+ }
7748
+ return matches;
7860
7749
  }
7861
- // Load README.md
7862
- const readmePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'README.md');
7863
- if (external_node_fs_.existsSync(readmePath)) {
7864
- const content = external_node_fs_.readFileSync(readmePath, 'utf-8');
7865
- // Only keep first 500 chars as preview
7866
- result.readme = content.slice(0, 500);
7750
+ },
7751
+ // Rule 6: Oversized Content (low) — scans ALL content
7752
+ {
7753
+ id: 'oversized-content',
7754
+ level: 'low',
7755
+ message: 'Content exceeds recommended size limit',
7756
+ skipSafeZones: false,
7757
+ check: (content)=>{
7758
+ const MAX_SIZE_BYTES = 51200;
7759
+ const sizeBytes = Buffer.byteLength(content, 'utf-8');
7760
+ if (sizeBytes > MAX_SIZE_BYTES) return [
7761
+ {
7762
+ snippet: `Content size: ${(sizeBytes / 1024).toFixed(1)}KB (limit: 50KB)`
7763
+ }
7764
+ ];
7765
+ return [];
7867
7766
  }
7868
- // Scan files (use files array from SKILL.md metadata if available)
7869
- const filesPattern = result.skillMd?.metadata?.files;
7870
- result.files = this.scanFiles(skillPath, filesPattern);
7871
- return result;
7767
+ }
7768
+ ];
7769
+ // ============================================================================
7770
+ // ContentScanner
7771
+ // ============================================================================
7772
+ /** Build the effective rule set from defaults + options */ function buildRuleSet(options) {
7773
+ let rules = DEFAULT_RULES.map((r)=>({
7774
+ ...r
7775
+ }));
7776
+ if (options?.disabledRules?.length) {
7777
+ const disabled = new Set(options.disabledRules);
7778
+ rules = rules.filter((r)=>!disabled.has(r.id));
7779
+ }
7780
+ if (options?.overrides) for (const rule of rules){
7781
+ const override = options.overrides[rule.id];
7782
+ if (override) rule.level = override;
7783
+ }
7784
+ if (options?.customRules?.length) rules.push(...options.customRules);
7785
+ return rules;
7786
+ }
7787
+ /**
7788
+ * Content scanner for SKILL.md files.
7789
+ *
7790
+ * Detects prompt injection, data exfiltration, obfuscation, sensitive file
7791
+ * access, stealth instructions, and oversized content.
7792
+ *
7793
+ * @example
7794
+ * ```typescript
7795
+ * // Default usage (CLI)
7796
+ * const scanner = new ContentScanner();
7797
+ * const result = scanner.scan(content);
7798
+ *
7799
+ * // Custom usage (private registry server)
7800
+ * const scanner = new ContentScanner({
7801
+ * overrides: { 'prompt-injection': 'medium' },
7802
+ * disabledRules: ['stealth-instructions'],
7803
+ * });
7804
+ * ```
7805
+ */ class ContentScanner {
7806
+ rules;
7807
+ constructor(options){
7808
+ this.rules = buildRuleSet(options);
7872
7809
  }
7873
7810
  /**
7874
- * Synthesize a SkillJson object from SKILL.md frontmatter
7875
- *
7876
- * This creates a SkillJson representation from SKILL.md for backward compatibility
7877
- * with the publish API. All metadata comes from SKILL.md.
7878
- */ synthesizeSkillJson(skillMd) {
7879
- // Extract version: first from top-level frontmatter, then from metadata, then default
7880
- const version = skillMd.version || skillMd.metadata?.version || DEFAULT_VERSION;
7881
- // Only include keywords if it's a valid array
7882
- const keywords = Array.isArray(skillMd.metadata?.keywords) ? skillMd.metadata.keywords : void 0;
7811
+ * Scan content string for malicious patterns.
7812
+ * Pure string operation — no file system access.
7813
+ */ scan(content) {
7814
+ const originalLines = content.split('\n');
7815
+ const maskedContent = maskSafeZones(content);
7816
+ const findings = [];
7817
+ for (const rule of this.rules){
7818
+ const targetContent = rule.skipSafeZones ? maskedContent : content;
7819
+ const matches = rule.check(targetContent);
7820
+ for (const match of matches){
7821
+ // Use custom snippet if provided, otherwise generate from original content
7822
+ const snippet = match.snippet ?? (null != match.line ? originalLines[match.line - 1]?.trim().slice(0, SNIPPET_MAX_LENGTH) : void 0);
7823
+ findings.push({
7824
+ rule: rule.id,
7825
+ level: rule.level,
7826
+ message: rule.message,
7827
+ line: match.line,
7828
+ snippet
7829
+ });
7830
+ }
7831
+ }
7832
+ const hasHighRisk = findings.some((f)=>'high' === f.level);
7883
7833
  return {
7884
- name: skillMd.name,
7885
- version,
7886
- description: skillMd.description,
7887
- license: skillMd.license,
7888
- keywords,
7889
- entry: 'SKILL.md'
7834
+ passed: !hasHighRisk,
7835
+ findings
7890
7836
  };
7891
7837
  }
7892
7838
  /**
7893
- * Scan files to include in publish
7894
- *
7895
- * If includePatterns is specified, only include those files/directories.
7896
- * Otherwise, scan all files in the directory (excluding ignored patterns).
7897
- */ scanFiles(skillPath, includePatterns) {
7898
- const files = [];
7899
- const seen = new Set();
7900
- // If includePatterns specified, use selective scanning
7901
- if (includePatterns && includePatterns.length > 0) {
7902
- // Add default files first
7903
- for (const file of DEFAULT_FILES){
7904
- const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
7905
- if (external_node_fs_.existsSync(filePath) && !seen.has(file)) {
7906
- files.push(file);
7907
- seen.add(file);
7908
- }
7909
- }
7910
- // Add files from SKILL.md metadata.files array
7911
- for (const pattern of includePatterns){
7912
- const targetPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, pattern);
7913
- if (external_node_fs_.existsSync(targetPath)) {
7914
- const stat = external_node_fs_.statSync(targetPath);
7915
- if (stat.isDirectory()) // Recursively add all files in directory
7916
- this.addFilesFromDir(skillPath, pattern, files, seen);
7917
- else if (!seen.has(pattern)) {
7918
- files.push(pattern);
7919
- seen.add(pattern);
7920
- }
7921
- }
7922
- }
7923
- } else // No includePatterns: scan entire directory (default behavior)
7924
- this.addFilesFromDir(skillPath, '', files, seen);
7925
- return files;
7839
+ * Scan a file for malicious patterns.
7840
+ * Convenience wrapper that reads the file then calls scan().
7841
+ */ scanFile(filePath) {
7842
+ if (!external_node_fs_.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
7843
+ const content = external_node_fs_.readFileSync(filePath, 'utf-8');
7844
+ return this.scan(content);
7845
+ }
7846
+ }
7847
+ /**
7848
+ * Publisher - Handle Git information and publish payload building
7849
+ *
7850
+ * Extracts Git metadata and builds the payload for publishing to registry.
7851
+ */ class PublishError extends Error {
7852
+ constructor(message){
7853
+ super(message);
7854
+ this.name = 'PublishError';
7926
7855
  }
7856
+ }
7857
+ // ============================================================================
7858
+ // Publisher Class
7859
+ // ============================================================================
7860
+ class Publisher {
7927
7861
  /**
7928
- * Patterns to ignore when scanning directories
7929
- */ static IGNORE_PATTERNS = [
7930
- '.git',
7931
- '.svn',
7932
- '.hg',
7933
- 'node_modules',
7934
- '.DS_Store',
7935
- 'Thumbs.db',
7936
- '.idea',
7937
- '.vscode',
7938
- '*.log',
7939
- '*.tmp',
7940
- '*.swp',
7941
- '*.bak'
7942
- ];
7862
+ * Get Git information from a skill directory
7863
+ */ async getGitInfo(skillPath, specifiedTag) {
7864
+ const info = {
7865
+ isRepo: false,
7866
+ remoteUrl: null,
7867
+ currentBranch: null,
7868
+ currentCommit: null,
7869
+ commitDate: null,
7870
+ tag: null,
7871
+ tagCommit: null,
7872
+ isDirty: false,
7873
+ sourceRef: null
7874
+ };
7875
+ // Check if it's a git repository
7876
+ try {
7877
+ (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git rev-parse --git-dir', {
7878
+ cwd: skillPath,
7879
+ stdio: 'pipe'
7880
+ });
7881
+ info.isRepo = true;
7882
+ } catch {
7883
+ return info;
7884
+ }
7885
+ // Get remote URL
7886
+ try {
7887
+ info.remoteUrl = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git remote get-url origin', {
7888
+ cwd: skillPath,
7889
+ encoding: 'utf-8'
7890
+ }).trim();
7891
+ // Parse to sourceRef format
7892
+ info.sourceRef = this.parseRemoteToSourceRef(info.remoteUrl);
7893
+ } catch {
7894
+ // No remote configured
7895
+ }
7896
+ // Get current branch
7897
+ try {
7898
+ info.currentBranch = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git branch --show-current', {
7899
+ cwd: skillPath,
7900
+ encoding: 'utf-8'
7901
+ }).trim() || null;
7902
+ } catch {
7903
+ // Detached HEAD or other error
7904
+ }
7905
+ // Get current commit
7906
+ try {
7907
+ info.currentCommit = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git rev-parse HEAD', {
7908
+ cwd: skillPath,
7909
+ encoding: 'utf-8'
7910
+ }).trim();
7911
+ // Get commit date
7912
+ info.commitDate = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git show -s --format=%cI HEAD', {
7913
+ cwd: skillPath,
7914
+ encoding: 'utf-8'
7915
+ }).trim();
7916
+ } catch {
7917
+ // No commits yet
7918
+ }
7919
+ // Get tag
7920
+ if (specifiedTag) // Use specified tag
7921
+ try {
7922
+ info.tagCommit = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)(`git rev-parse "${specifiedTag}^{commit}"`, {
7923
+ cwd: skillPath,
7924
+ encoding: 'utf-8'
7925
+ }).trim();
7926
+ info.tag = specifiedTag;
7927
+ } catch {
7928
+ throw new PublishError(`Tag "${specifiedTag}" not found`);
7929
+ }
7930
+ else // Try to get tag on current commit
7931
+ try {
7932
+ const tag = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git describe --exact-match --tags HEAD', {
7933
+ cwd: skillPath,
7934
+ encoding: 'utf-8'
7935
+ }).trim();
7936
+ info.tag = tag;
7937
+ info.tagCommit = info.currentCommit;
7938
+ } catch {
7939
+ // No tag on current commit
7940
+ }
7941
+ // Check if working tree is dirty
7942
+ try {
7943
+ const status = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git status --porcelain', {
7944
+ cwd: skillPath,
7945
+ encoding: 'utf-8'
7946
+ }).trim();
7947
+ info.isDirty = status.length > 0;
7948
+ } catch {
7949
+ // Ignore errors
7950
+ }
7951
+ return info;
7952
+ }
7943
7953
  /**
7944
- * Check if a file/directory should be ignored
7945
- */ shouldIgnore(name) {
7946
- for (const pattern of SkillValidator.IGNORE_PATTERNS)if (pattern.startsWith('*')) {
7947
- // Wildcard pattern (e.g., *.log)
7948
- const ext = pattern.slice(1);
7949
- if (name.endsWith(ext)) return true;
7950
- } else if (name === pattern) return true;
7951
- return false;
7954
+ * Parse remote URL to sourceRef format (e.g., github:user/repo)
7955
+ */ parseRemoteToSourceRef(remoteUrl) {
7956
+ // SSH format: git@github.com:user/repo.git
7957
+ const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(\.git)?$/);
7958
+ if (sshMatch) {
7959
+ const [, host, owner, repo] = sshMatch;
7960
+ const registry = this.normalizeHost(host);
7961
+ return `${registry}:${owner}/${repo.replace(/\.git$/, '')}`;
7962
+ }
7963
+ // HTTPS format: https://github.com/user/repo.git
7964
+ const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/([^/]+)\/(.+?)(\.git)?$/);
7965
+ if (httpsMatch) {
7966
+ const [, host, owner, repo] = httpsMatch;
7967
+ const registry = this.normalizeHost(host);
7968
+ return `${registry}:${owner}/${repo.replace(/\.git$/, '')}`;
7969
+ }
7970
+ return null;
7952
7971
  }
7953
7972
  /**
7954
- * Recursively add files from directory
7955
- */ addFilesFromDir(basePath, dirPath, files, seen) {
7956
- const fullPath = dirPath ? __WEBPACK_EXTERNAL_MODULE_node_path__.join(basePath, dirPath) : basePath;
7957
- const entries = external_node_fs_.readdirSync(fullPath, {
7958
- withFileTypes: true
7959
- });
7960
- for (const entry of entries){
7961
- // Skip ignored files/directories
7962
- if (this.shouldIgnore(entry.name)) continue;
7963
- const relativePath = dirPath ? __WEBPACK_EXTERNAL_MODULE_node_path__.join(dirPath, entry.name) : entry.name;
7964
- if (entry.isDirectory()) this.addFilesFromDir(basePath, relativePath, files, seen);
7965
- else if (!seen.has(relativePath)) {
7966
- files.push(relativePath);
7967
- seen.add(relativePath);
7973
+ * Normalize host to registry name
7974
+ */ normalizeHost(host) {
7975
+ if ('github.com' === host) return 'github';
7976
+ if ('gitlab.com' === host) return 'gitlab';
7977
+ return host;
7978
+ }
7979
+ /**
7980
+ * Build publish payload
7981
+ */ buildPayload(skill, gitInfo, integrity) {
7982
+ const { skillJson, skillMd, readme, files } = skill;
7983
+ const payload = {
7984
+ version: skillJson.version,
7985
+ description: skillJson.description || '',
7986
+ gitRef: gitInfo.tag || gitInfo.currentCommit || 'HEAD',
7987
+ gitCommit: gitInfo.tagCommit || gitInfo.currentCommit || '',
7988
+ gitCommitDate: gitInfo.commitDate || void 0,
7989
+ repositoryUrl: gitInfo.remoteUrl || '',
7990
+ sourceRef: gitInfo.sourceRef || '',
7991
+ skillJson,
7992
+ files,
7993
+ entry: skillJson.entry || 'SKILL.md',
7994
+ integrity
7995
+ };
7996
+ // Add optional fields
7997
+ if (skillMd) payload.skillMd = {
7998
+ name: skillMd.name,
7999
+ description: skillMd.description,
8000
+ license: skillMd.license,
8001
+ compatibility: skillMd.compatibility,
8002
+ allowedTools: skillMd.allowedTools
8003
+ };
8004
+ if (readme) payload.readmePreview = readme;
8005
+ if (skillJson.keywords && skillJson.keywords.length > 0) payload.keywords = skillJson.keywords;
8006
+ if (skillJson.compatibility) {
8007
+ // Filter out undefined values from compatibility
8008
+ const compat = {};
8009
+ for (const [key, value] of Object.entries(skillJson.compatibility))if (void 0 !== value) compat[key] = value;
8010
+ if (Object.keys(compat).length > 0) payload.compatibility = compat;
8011
+ }
8012
+ return payload;
8013
+ }
8014
+ /**
8015
+ * Format bytes for display
8016
+ */ formatBytes(bytes) {
8017
+ if (bytes < 1024) return `${bytes} B`;
8018
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
8019
+ return `${(bytes / 1048576).toFixed(1)} MB`;
8020
+ }
8021
+ /**
8022
+ * Calculate total size of files
8023
+ */ calculateTotalSize(skillPath, files) {
8024
+ let total = 0;
8025
+ for (const file of files){
8026
+ const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
8027
+ if (external_node_fs_.existsSync(filePath)) {
8028
+ const stats = external_node_fs_.statSync(filePath);
8029
+ total += stats.size;
7968
8030
  }
7969
8031
  }
8032
+ return total;
7970
8033
  }
8034
+ }
8035
+ /**
8036
+ * SkillValidator - Validate skills for publishing
8037
+ *
8038
+ * Following agentskills.io specification: https://agentskills.io/specification
8039
+ *
8040
+ * Key points:
8041
+ * - SKILL.md is the SOLE source of metadata (name, description, version, etc.)
8042
+ * - skill.json is NOT used - all metadata comes from SKILL.md frontmatter
8043
+ * - Version defaults to "0.0.0" if not specified in SKILL.md
8044
+ */ // ============================================================================
8045
+ // Constants
8046
+ // ============================================================================
8047
+ const MAX_NAME_LENGTH = 64;
8048
+ const MAX_DESCRIPTION_LENGTH = 1024;
8049
+ const MAX_KEYWORDS = 10;
8050
+ const SINGLE_CHAR_NAME_PATTERN = /^[a-z0-9]$/;
8051
+ const DEFAULT_VERSION = '0.0.0';
8052
+ // Default files to include in publish
8053
+ const DEFAULT_FILES = [
8054
+ 'SKILL.md',
8055
+ 'README.md',
8056
+ 'LICENSE'
8057
+ ];
8058
+ // ============================================================================
8059
+ // SkillValidator Class
8060
+ // ============================================================================
8061
+ class SkillValidator {
7971
8062
  /**
7972
- * Validate a skill directory for publishing
8063
+ * Validate skill name format
7973
8064
  *
7974
- * Following agentskills.io specification:
7975
- * - SKILL.md is the SOLE source of metadata
7976
- * - name and description are REQUIRED in frontmatter
7977
- */ validate(skillPath) {
8065
+ * Requirements:
8066
+ * - Lowercase letters, numbers, and hyphens only
8067
+ * - 1-64 characters
8068
+ * - Cannot start or end with hyphen
8069
+ * - Cannot have consecutive hyphens
8070
+ */ validateName(name) {
7978
8071
  const errors = [];
7979
- const warnings = [];
7980
- // Check SKILL.md exists (REQUIRED per spec)
7981
- const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'SKILL.md');
7982
- if (!external_node_fs_.existsSync(skillMdPath)) {
8072
+ if (!name) {
7983
8073
  errors.push({
7984
- field: 'SKILL.md',
7985
- message: 'SKILL.md not found. This file is required for publishing.',
7986
- suggestion: 'Create a SKILL.md file with name and description in YAML frontmatter'
8074
+ field: 'name',
8075
+ message: 'Skill name is required',
8076
+ suggestion: 'Add "name" field to SKILL.md frontmatter'
7987
8077
  });
7988
8078
  return {
7989
8079
  valid: false,
7990
8080
  errors,
7991
- warnings
8081
+ warnings: []
7992
8082
  };
7993
8083
  }
7994
- // Parse SKILL.md
7995
- let skillMd;
7996
- try {
7997
- skillMd = parseSkillMdFile(skillMdPath);
7998
- if (!skillMd) {
7999
- errors.push({
8000
- field: 'SKILL.md',
8001
- message: 'SKILL.md must have valid YAML frontmatter with name and description',
8002
- suggestion: 'Add frontmatter: ---\\nname: your-skill\\ndescription: Your description\\n---'
8003
- });
8004
- return {
8005
- valid: false,
8006
- errors,
8007
- warnings
8008
- };
8009
- }
8010
- } catch (error) {
8084
+ if (name.length > MAX_NAME_LENGTH) errors.push({
8085
+ field: 'name',
8086
+ message: `Skill name must be at most ${MAX_NAME_LENGTH} characters`,
8087
+ suggestion: `Shorten the name to ${MAX_NAME_LENGTH} characters or less`
8088
+ });
8089
+ // Check for uppercase
8090
+ if (/[A-Z]/.test(name)) errors.push({
8091
+ field: 'name',
8092
+ message: 'Skill name must be lowercase',
8093
+ suggestion: `Change "${name}" to "${name.toLowerCase()}"`
8094
+ });
8095
+ // Check for invalid characters
8096
+ if (/[^a-z0-9-]/.test(name)) errors.push({
8097
+ field: 'name',
8098
+ message: 'Skill name can only contain lowercase letters, numbers, and hyphens',
8099
+ suggestion: 'Remove special characters from the name'
8100
+ });
8101
+ // Check pattern for multi-char names
8102
+ if (1 === name.length) {
8103
+ if (!SINGLE_CHAR_NAME_PATTERN.test(name)) errors.push({
8104
+ field: 'name',
8105
+ message: 'Single character name must be a lowercase letter or number'
8106
+ });
8107
+ } else if (name.length > 1) {
8108
+ // Check start/end with hyphen
8109
+ if (name.startsWith('-')) errors.push({
8110
+ field: 'name',
8111
+ message: 'Skill name cannot start with a hyphen'
8112
+ });
8113
+ if (name.endsWith('-')) errors.push({
8114
+ field: 'name',
8115
+ message: 'Skill name cannot end with a hyphen'
8116
+ });
8117
+ // Check consecutive hyphens
8118
+ if (/--/.test(name)) errors.push({
8119
+ field: 'name',
8120
+ message: 'Skill name cannot contain consecutive hyphens'
8121
+ });
8122
+ }
8123
+ return {
8124
+ valid: 0 === errors.length,
8125
+ errors,
8126
+ warnings: []
8127
+ };
8128
+ }
8129
+ /**
8130
+ * Validate version format (semver)
8131
+ */ validateVersion(version) {
8132
+ const errors = [];
8133
+ if (!version) {
8011
8134
  errors.push({
8012
- field: 'SKILL.md',
8013
- message: `Failed to parse SKILL.md: ${error.message}`,
8014
- suggestion: 'Check the YAML frontmatter syntax is valid'
8135
+ field: 'version',
8136
+ message: 'Version is required',
8137
+ suggestion: 'Add "version" field to SKILL.md frontmatter (e.g., "1.0.0")'
8015
8138
  });
8016
8139
  return {
8017
8140
  valid: false,
8018
8141
  errors,
8019
- warnings
8142
+ warnings: []
8020
8143
  };
8021
8144
  }
8022
- // Validate name from SKILL.md
8023
- const nameResult = this.validateName(skillMd.name);
8024
- errors.push(...nameResult.errors);
8025
- // Validate description from SKILL.md
8026
- const descResult = this.validateDescription(skillMd.description);
8027
- errors.push(...descResult.errors);
8028
- // Check version in SKILL.md
8029
- const skillMdVersion = skillMd.version || skillMd.metadata?.version;
8030
- if (skillMdVersion) {
8031
- // Validate the version from SKILL.md
8032
- const versionResult = this.validateVersion(skillMdVersion);
8033
- errors.push(...versionResult.errors);
8034
- } else warnings.push({
8145
+ // Check for v prefix
8146
+ if (version.startsWith('v')) {
8147
+ errors.push({
8148
+ field: 'version',
8149
+ message: 'Version should not have "v" prefix',
8150
+ suggestion: `Change "${version}" to "${version.slice(1)}"`
8151
+ });
8152
+ return {
8153
+ valid: false,
8154
+ errors,
8155
+ warnings: []
8156
+ };
8157
+ }
8158
+ if (!__WEBPACK_EXTERNAL_MODULE_semver__.valid(version)) errors.push({
8035
8159
  field: 'version',
8036
- message: `No version specified, defaulting to "${DEFAULT_VERSION}"`,
8037
- suggestion: 'Add version in SKILL.md frontmatter'
8038
- });
8039
- // Check keywords count (only if metadata.keywords is a valid array)
8040
- const keywords = skillMd.metadata?.keywords;
8041
- if (Array.isArray(keywords) && keywords.length > MAX_KEYWORDS) warnings.push({
8042
- field: 'keywords',
8043
- message: `Too many keywords (${keywords.length}). Recommended max: ${MAX_KEYWORDS}`
8044
- });
8045
- // Check license
8046
- if (!skillMd.license) warnings.push({
8047
- field: 'license',
8048
- message: 'No license specified',
8049
- suggestion: 'Add license in SKILL.md frontmatter'
8160
+ message: `Invalid version format: "${version}". Must follow semver (x.y.z)`,
8161
+ suggestion: 'Use format like "1.0.0" or "1.0.0-beta.1"'
8050
8162
  });
8051
8163
  return {
8052
8164
  valid: 0 === errors.length,
8053
8165
  errors,
8054
- warnings
8166
+ warnings: []
8055
8167
  };
8056
8168
  }
8057
8169
  /**
8058
- * Generate integrity hash for files
8059
- */ generateIntegrity(skillPath, files) {
8060
- const hash = __WEBPACK_EXTERNAL_MODULE_node_crypto__.createHash('sha256');
8061
- // Sort files for consistent ordering
8062
- const sortedFiles = [
8063
- ...files
8064
- ].sort();
8065
- for (const file of sortedFiles){
8066
- const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
8067
- if (external_node_fs_.existsSync(filePath)) {
8068
- hash.update(file);
8069
- const content = external_node_fs_.readFileSync(filePath);
8070
- hash.update(content);
8071
- }
8072
- }
8073
- return `sha256-${hash.digest('hex')}`;
8074
- }
8075
- }
8076
- /**
8077
- * ContentScanner - Detect malicious patterns in SKILL.md content
8078
- *
8079
- * Features:
8080
- * - Context-aware: skips safe zones (frontmatter, code blocks, quotes, blockquotes)
8081
- * - 6 built-in detection rules across 3 risk levels
8082
- * - Configurable: override levels, disable rules, add custom rules
8083
- * - Pure string operations in scan() — no fs dependency, suitable for server use
8084
- * - scanFile() convenience method for CLI use
8085
- */ // ============================================================================
8086
- // Safe Zone Masking
8087
- // ============================================================================
8088
- /**
8089
- * Mask safe zones in Markdown content with spaces, preserving line structure.
8090
- *
8091
- * Safe zones (content replaced with spaces):
8092
- * - YAML frontmatter (`---` ... `---` at file start)
8093
- * - Fenced code blocks (``` or ~~~)
8094
- * - Indented code blocks (4 spaces / tab after blank line)
8095
- * - Blockquotes (`> ` prefix)
8096
- * - Inline code (`` `...` ``)
8097
- * - Double-quoted text (`"..."`, min 3 chars between quotes)
8098
- *
8099
- * Line breaks are preserved so line numbers remain correct.
8100
- */ function maskSafeZones(content) {
8101
- const lines = content.split('\n');
8102
- const result = [];
8103
- let inFrontmatter = false;
8104
- let inFencedCode = false;
8105
- let fenceChar = '';
8106
- let fenceLength = 0;
8107
- let prevLineBlank = false;
8108
- let prevLineIndentedCode = false;
8109
- for(let i = 0; i < lines.length; i++){
8110
- const line = lines[i];
8111
- // --- YAML Frontmatter (only at file start) ---
8112
- if (0 === i && '---' === line.trim()) {
8113
- inFrontmatter = true;
8114
- result.push(maskLine(line));
8115
- continue;
8116
- }
8117
- if (inFrontmatter) {
8118
- result.push(maskLine(line));
8119
- if ('---' === line.trim()) inFrontmatter = false;
8120
- continue;
8121
- }
8122
- // --- Fenced code blocks (``` or ~~~) ---
8123
- const fenceMatch = line.match(/^(`{3,}|~{3,})/);
8124
- if (!inFencedCode && fenceMatch) {
8125
- inFencedCode = true;
8126
- fenceChar = fenceMatch[1][0];
8127
- fenceLength = fenceMatch[1].length;
8128
- result.push(maskLine(line));
8129
- prevLineBlank = false;
8130
- prevLineIndentedCode = false;
8131
- continue;
8132
- }
8133
- if (inFencedCode) {
8134
- result.push(maskLine(line));
8135
- const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/);
8136
- if (closeMatch && closeMatch[1][0] === fenceChar && closeMatch[1].length >= fenceLength) inFencedCode = false;
8137
- prevLineBlank = false;
8138
- prevLineIndentedCode = false;
8139
- continue;
8140
- }
8141
- // --- Blockquote ---
8142
- if (/^>\s?/.test(line)) {
8143
- result.push(maskLine(line));
8144
- prevLineBlank = false;
8145
- prevLineIndentedCode = false;
8146
- continue;
8147
- }
8148
- // --- Indented code block (4 spaces or tab, after blank line) ---
8149
- if (/^(?: |\t)/.test(line) && (prevLineBlank || prevLineIndentedCode)) {
8150
- result.push(maskLine(line));
8151
- prevLineBlank = false;
8152
- prevLineIndentedCode = true;
8153
- continue;
8170
+ * Validate description
8171
+ *
8172
+ * Following agentskills.io specification:
8173
+ * - Max 1024 characters
8174
+ * - Non-empty
8175
+ */ validateDescription(description) {
8176
+ const errors = [];
8177
+ if (!description) {
8178
+ errors.push({
8179
+ field: 'description',
8180
+ message: 'Description is required',
8181
+ suggestion: 'Add "description" field to SKILL.md frontmatter'
8182
+ });
8183
+ return {
8184
+ valid: false,
8185
+ errors,
8186
+ warnings: []
8187
+ };
8154
8188
  }
8155
- // --- Normal line: mask inline code and double-quoted text ---
8156
- result.push(maskInline(line));
8157
- prevLineBlank = '' === line.trim();
8158
- prevLineIndentedCode = false;
8159
- }
8160
- return result.join('\n');
8161
- }
8162
- /** Replace all characters in a line with spaces (preserving length) */ function maskLine(line) {
8163
- return ' '.repeat(line.length);
8164
- }
8165
- /**
8166
- * Mask inline code (`` `...` ``) and double-quoted text (`"..."`) within a line.
8167
- * Uses regex replacement for efficiency (avoids char-by-char concatenation on long lines).
8168
- * Single quotes are NOT masked to avoid false matches with apostrophes.
8169
- */ function maskInline(line) {
8170
- let result = line;
8171
- // Inline code: `...`
8172
- result = result.replace(/`[^`]+`/g, (m)=>' '.repeat(m.length));
8173
- // Double-quoted text: "..." (min 3 chars between quotes)
8174
- result = result.replace(/"[^"]{3,}"/g, (m)=>' '.repeat(m.length));
8175
- return result;
8176
- }
8177
- // ============================================================================
8178
- // Rule Helpers
8179
- // ============================================================================
8180
- /** Find lines matching any of the given patterns, return one match per line */ function findLineMatches(content, patterns) {
8181
- const lines = content.split('\n');
8182
- const matches = [];
8183
- for(let i = 0; i < lines.length; i++)for (const pattern of patterns)if (pattern.test(lines[i])) {
8184
- matches.push({
8185
- line: i + 1
8189
+ if (description.length > MAX_DESCRIPTION_LENGTH) errors.push({
8190
+ field: 'description',
8191
+ message: `Description must be at most ${MAX_DESCRIPTION_LENGTH} characters`
8186
8192
  });
8187
- break;
8193
+ // Note: angle brackets are allowed per agentskills.io spec
8194
+ return {
8195
+ valid: 0 === errors.length,
8196
+ errors,
8197
+ warnings: []
8198
+ };
8188
8199
  }
8189
- return matches;
8190
- }
8191
- // ============================================================================
8192
- // Default Rules
8193
- // ============================================================================
8194
- const SNIPPET_MAX_LENGTH = 120;
8195
- /** Built-in detection rules */ const DEFAULT_RULES = [
8196
- // Rule 1: Prompt Injection (high)
8197
- {
8198
- id: 'prompt-injection',
8199
- level: 'high',
8200
- message: 'Detected prompt injection attempt',
8201
- skipSafeZones: true,
8202
- check: (content)=>findLineMatches(content, [
8203
- // English patterns
8204
- /ignore\s+(all\s+)?previous\s+instructions/i,
8205
- /disregard\s+(all\s+)?(prior|previous|above)\s+(instructions|rules|context)/i,
8206
- /you\s+are\s+now\s+(?:(?:a|an)\s+)?(?:(?:\w+\s+){0,3}(?:agent|ai|assistant|bot|model|character|persona|entity|system)|DAN\b|jailbr\w*|unrestricted|unfiltered|free\s+from)/i,
8207
- /from\s+now\s+on[,\s]+you\s+are/i,
8208
- /new\s+system\s+prompt/i,
8209
- /override\s+(your|the)\s+(system|safety|security)\s+(prompt|rules|instructions)/i,
8210
- /forget\s+(?:all\s+)?(?:your\s+)?(?:previous\s+|prior\s+)?(?:instructions|rules|constraints)/i,
8211
- /(?:you\s+are|you're)\s+(?:now\s+)?entering\s+(?:a\s+)?new\s+(?:mode|context|session)/i,
8212
- // Chinese patterns (中文提示词注入)
8213
- /[忽无][略视]\s*(所有\s*)?(之前的?|先前的?|以前的?)?\s*(指令|指示|规则|约束|限制)/,
8214
- /你现在是/,
8215
- /从现在开始.{0,10}你是/,
8216
- /新的系统提示词/,
8217
- /[覆改]写?\s*(你的|系统)\s*(提示词|规则|指令|安全)/,
8218
- /忘记\s*(所有\s*)?(之前的?|先前的?)?\s*(指令|指示|规则|约束)/,
8219
- /进入.{0,5}新的?\s*(模式|上下文|会话)/,
8220
- /不要遵守.{0,10}(安全|限制|规则|约束)/,
8221
- /解除.{0,5}(限制|约束|安全)/,
8222
- /无限制模式/,
8223
- /安全模式已关闭/
8224
- ])
8225
- },
8226
- // Rule 2: Data Exfiltration (high)
8227
- {
8228
- id: 'data-exfiltration',
8229
- level: 'high',
8230
- message: 'Detected potential data exfiltration command',
8231
- skipSafeZones: true,
8232
- check: (content)=>{
8233
- const lines = content.split('\n');
8234
- const matches = [];
8235
- const commandPattern = /\b(curl|wget|fetch|http\.post|requests\.post|nc\b|ncat|netcat)\b/i;
8236
- const sensitivePattern = /(\$[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)[A-Z_]*|\$ENV\b|\$\{[^}]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[^}]*\})/i;
8237
- for(let i = 0; i < lines.length; i++)if (commandPattern.test(lines[i]) && sensitivePattern.test(lines[i])) matches.push({
8238
- line: i + 1
8239
- });
8240
- return matches;
8200
+ /**
8201
+ * Load skill information from directory
8202
+ *
8203
+ * Following agentskills.io specification:
8204
+ * - SKILL.md is the SOLE source of metadata
8205
+ * - skillJson is synthesized from SKILL.md for backward compatibility with publish API
8206
+ */ loadSkill(skillPath) {
8207
+ const result = {
8208
+ path: skillPath,
8209
+ skillJson: null,
8210
+ skillMd: null,
8211
+ readme: null,
8212
+ files: []
8213
+ };
8214
+ // Load SKILL.md (sole source of metadata)
8215
+ const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'SKILL.md');
8216
+ if (external_node_fs_.existsSync(skillMdPath)) try {
8217
+ result.skillMd = parseSkillMdFile(skillMdPath);
8218
+ // Always synthesize skillJson from SKILL.md for backward compatibility
8219
+ if (result.skillMd) result.skillJson = this.synthesizeSkillJson(result.skillMd);
8220
+ } catch {
8221
+ // Will be caught in validation
8241
8222
  }
8242
- },
8243
- // Rule 3: Content Obfuscation (high) — scans ALL content including safe zones
8244
- {
8245
- id: 'obfuscation',
8246
- level: 'high',
8247
- message: 'Detected content obfuscation',
8248
- skipSafeZones: false,
8249
- check: (content)=>{
8250
- const matches = [];
8251
- const lines = content.split('\n');
8252
- // Zero-width characters (suspicious in any context)
8253
- const zeroWidthPattern = /[\u200B\u200C\u200D\uFEFF\u2060\u180E]/;
8254
- for(let i = 0; i < lines.length; i++)if (zeroWidthPattern.test(lines[i])) matches.push({
8255
- line: i + 1,
8256
- snippet: 'Zero-width Unicode characters detected'
8257
- });
8258
- // Long base64-like strings (>200 continuous chars)
8259
- const base64Pattern = /[A-Za-z0-9+/=]{200,}/;
8260
- for(let i = 0; i < lines.length; i++)if (base64Pattern.test(lines[i])) matches.push({
8261
- line: i + 1,
8262
- snippet: 'Suspicious base64-encoded block detected'
8263
- });
8264
- // Large HTML comments (>200 chars of content)
8265
- const commentRegex = /<!--([\s\S]{200,}?)-->/g;
8266
- let match;
8267
- // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
8268
- while(null !== (match = commentRegex.exec(content))){
8269
- const lineNum = content.slice(0, match.index).split('\n').length;
8270
- matches.push({
8271
- line: lineNum,
8272
- snippet: `Large HTML comment block (${match[1].length} chars)`
8273
- });
8274
- }
8275
- return matches;
8223
+ // Load README.md
8224
+ const readmePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'README.md');
8225
+ if (external_node_fs_.existsSync(readmePath)) {
8226
+ const content = external_node_fs_.readFileSync(readmePath, 'utf-8');
8227
+ // Only keep first 500 chars as preview
8228
+ result.readme = content.slice(0, 500);
8276
8229
  }
8277
- },
8278
- // Rule 4: Sensitive File Access (medium)
8279
- {
8280
- id: 'sensitive-file-access',
8281
- level: 'medium',
8282
- message: 'References sensitive file path',
8283
- skipSafeZones: true,
8284
- check: (content)=>findLineMatches(content, [
8285
- /~\/\.ssh\b/,
8286
- /~\/\.aws\b/,
8287
- /~\/\.gnupg\b/,
8288
- /~\/\.config\/gcloud\b/,
8289
- /\bid_rsa\b/i,
8290
- /\bid_ed25519\b/i,
8291
- /\/etc\/passwd\b/,
8292
- /\/etc\/shadow\b/,
8293
- /\.env\b(?!\.\w)/
8294
- ])
8295
- },
8296
- // Rule 5: Stealth Instructions (medium) — phrase + action verb matching
8297
- {
8298
- id: 'stealth-instructions',
8299
- level: 'medium',
8300
- message: 'Detected instruction to hide actions from user',
8301
- skipSafeZones: true,
8302
- check: (content)=>{
8303
- const actionVerbs = 'execute|delete|remove|send|transmit|modify|overwrite|install|download|upload|run|write|create|destroy|drop';
8304
- const patterns = [
8305
- // English patterns
8306
- new RegExp(`silently\\s+(?:${actionVerbs})`, 'i'),
8307
- new RegExp(`without\\s+telling\\s+the\\s+user.{0,30}(?:${actionVerbs})`, 'i'),
8308
- new RegExp("(?:do\\s+not|don'?t)\\s+show\\s+.{0,40}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
8309
- new RegExp("hide\\s+(?:this|the|these|all)\\s+.{0,30}(?:from\\s+the\\s+user|from\\s+user)", 'i'),
8310
- new RegExp("(?:do\\s+not|don'?t)\\s+mention\\s+.{0,30}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
8311
- new RegExp("keep\\s+(?:this|it)\\s+(?:a\\s+)?secret\\s+from\\s+(?:the\\s+)?user", 'i'),
8312
- // Chinese patterns (中文隐蔽指令)
8313
- /悄悄地?\s*(?:执行|删除|移除|发送|传输|修改|覆盖|安装|下载|上传|运行|写入|创建|销毁|丢弃)/,
8314
- /不要告诉用户/,
8315
- /不要让用户知道/,
8316
- /对用户隐藏/,
8317
- /在用户不知情的情况下/,
8318
- /瞒着用户/
8319
- ];
8320
- // Safe patterns to exclude (common in legitimate DevOps/automation skills)
8321
- const safePatterns = [
8322
- /silently\s+(?:ignore|skip|fail|discard|suppress|continue|pass|drop|swallow)/i,
8323
- // Chinese safe patterns (中文合法自动化用语)
8324
- /悄悄地?\s*(?:忽略|跳过|丢弃|抑制|继续|静默)/
8325
- ];
8326
- const lines = content.split('\n');
8327
- const matches = [];
8328
- for(let i = 0; i < lines.length; i++){
8329
- const line = lines[i];
8330
- if (!safePatterns.some((p)=>p.test(line))) {
8331
- for (const pattern of patterns)if (pattern.test(line)) {
8332
- matches.push({
8333
- line: i + 1
8334
- });
8335
- break;
8336
- }
8230
+ // Scan files (use files array from SKILL.md metadata if available)
8231
+ const filesPattern = result.skillMd?.metadata?.files;
8232
+ result.files = this.scanFiles(skillPath, filesPattern);
8233
+ return result;
8234
+ }
8235
+ /**
8236
+ * Synthesize a SkillJson object from SKILL.md frontmatter
8237
+ *
8238
+ * This creates a SkillJson representation from SKILL.md for backward compatibility
8239
+ * with the publish API. All metadata comes from SKILL.md.
8240
+ */ synthesizeSkillJson(skillMd) {
8241
+ // Extract version: first from top-level frontmatter, then from metadata, then default
8242
+ const version = skillMd.version || skillMd.metadata?.version || DEFAULT_VERSION;
8243
+ // Only include keywords if it's a valid array
8244
+ const keywords = Array.isArray(skillMd.metadata?.keywords) ? skillMd.metadata.keywords : void 0;
8245
+ return {
8246
+ name: skillMd.name,
8247
+ version,
8248
+ description: skillMd.description,
8249
+ license: skillMd.license,
8250
+ keywords,
8251
+ entry: 'SKILL.md'
8252
+ };
8253
+ }
8254
+ /**
8255
+ * Scan files to include in publish
8256
+ *
8257
+ * If includePatterns is specified, only include those files/directories.
8258
+ * Otherwise, scan all files in the directory (excluding ignored patterns).
8259
+ */ scanFiles(skillPath, includePatterns) {
8260
+ const files = [];
8261
+ const seen = new Set();
8262
+ // If includePatterns specified, use selective scanning
8263
+ if (includePatterns && includePatterns.length > 0) {
8264
+ // Add default files first
8265
+ for (const file of DEFAULT_FILES){
8266
+ const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
8267
+ if (external_node_fs_.existsSync(filePath) && !seen.has(file)) {
8268
+ files.push(file);
8269
+ seen.add(file);
8337
8270
  }
8338
8271
  }
8339
- return matches;
8340
- }
8341
- },
8342
- // Rule 6: Oversized Content (low) — scans ALL content
8343
- {
8344
- id: 'oversized-content',
8345
- level: 'low',
8346
- message: 'Content exceeds recommended size limit',
8347
- skipSafeZones: false,
8348
- check: (content)=>{
8349
- const MAX_SIZE_BYTES = 51200;
8350
- const sizeBytes = Buffer.byteLength(content, 'utf-8');
8351
- if (sizeBytes > MAX_SIZE_BYTES) return [
8352
- {
8353
- snippet: `Content size: ${(sizeBytes / 1024).toFixed(1)}KB (limit: 50KB)`
8272
+ // Add files from SKILL.md metadata.files array
8273
+ for (const pattern of includePatterns){
8274
+ const targetPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, pattern);
8275
+ if (external_node_fs_.existsSync(targetPath)) {
8276
+ const stat = external_node_fs_.statSync(targetPath);
8277
+ if (stat.isDirectory()) // Recursively add all files in directory
8278
+ this.addFilesFromDir(skillPath, pattern, files, seen);
8279
+ else if (!seen.has(pattern)) {
8280
+ files.push(pattern);
8281
+ seen.add(pattern);
8282
+ }
8354
8283
  }
8355
- ];
8356
- return [];
8357
- }
8358
- }
8359
- ];
8360
- // ============================================================================
8361
- // ContentScanner
8362
- // ============================================================================
8363
- /** Build the effective rule set from defaults + options */ function buildRuleSet(options) {
8364
- let rules = DEFAULT_RULES.map((r)=>({
8365
- ...r
8366
- }));
8367
- if (options?.disabledRules?.length) {
8368
- const disabled = new Set(options.disabledRules);
8369
- rules = rules.filter((r)=>!disabled.has(r.id));
8284
+ }
8285
+ } else // No includePatterns: scan entire directory (default behavior)
8286
+ this.addFilesFromDir(skillPath, '', files, seen);
8287
+ return files;
8370
8288
  }
8371
- if (options?.overrides) for (const rule of rules){
8372
- const override = options.overrides[rule.id];
8373
- if (override) rule.level = override;
8289
+ /**
8290
+ * Patterns to ignore when scanning directories
8291
+ */ static IGNORE_PATTERNS = [
8292
+ '.git',
8293
+ '.svn',
8294
+ '.hg',
8295
+ 'node_modules',
8296
+ '.DS_Store',
8297
+ 'Thumbs.db',
8298
+ '.idea',
8299
+ '.vscode',
8300
+ '*.log',
8301
+ '*.tmp',
8302
+ '*.swp',
8303
+ '*.bak'
8304
+ ];
8305
+ /**
8306
+ * Check if a file/directory should be ignored
8307
+ */ shouldIgnore(name) {
8308
+ for (const pattern of SkillValidator.IGNORE_PATTERNS)if (pattern.startsWith('*')) {
8309
+ // Wildcard pattern (e.g., *.log)
8310
+ const ext = pattern.slice(1);
8311
+ if (name.endsWith(ext)) return true;
8312
+ } else if (name === pattern) return true;
8313
+ return false;
8374
8314
  }
8375
- if (options?.customRules?.length) rules.push(...options.customRules);
8376
- return rules;
8377
- }
8378
- /**
8379
- * Content scanner for SKILL.md files.
8380
- *
8381
- * Detects prompt injection, data exfiltration, obfuscation, sensitive file
8382
- * access, stealth instructions, and oversized content.
8383
- *
8384
- * @example
8385
- * ```typescript
8386
- * // Default usage (CLI)
8387
- * const scanner = new ContentScanner();
8388
- * const result = scanner.scan(content);
8389
- *
8390
- * // Custom usage (private registry server)
8391
- * const scanner = new ContentScanner({
8392
- * overrides: { 'prompt-injection': 'medium' },
8393
- * disabledRules: ['stealth-instructions'],
8394
- * });
8395
- * ```
8396
- */ class ContentScanner {
8397
- rules;
8398
- constructor(options){
8399
- this.rules = buildRuleSet(options);
8315
+ /**
8316
+ * Recursively add files from directory
8317
+ */ addFilesFromDir(basePath, dirPath, files, seen) {
8318
+ const fullPath = dirPath ? __WEBPACK_EXTERNAL_MODULE_node_path__.join(basePath, dirPath) : basePath;
8319
+ const entries = external_node_fs_.readdirSync(fullPath, {
8320
+ withFileTypes: true
8321
+ });
8322
+ for (const entry of entries){
8323
+ // Skip ignored files/directories
8324
+ if (this.shouldIgnore(entry.name)) continue;
8325
+ const relativePath = dirPath ? __WEBPACK_EXTERNAL_MODULE_node_path__.join(dirPath, entry.name) : entry.name;
8326
+ if (entry.isDirectory()) this.addFilesFromDir(basePath, relativePath, files, seen);
8327
+ else if (!seen.has(relativePath)) {
8328
+ files.push(relativePath);
8329
+ seen.add(relativePath);
8330
+ }
8331
+ }
8400
8332
  }
8401
8333
  /**
8402
- * Scan content string for malicious patterns.
8403
- * Pure string operation — no file system access.
8404
- */ scan(content) {
8405
- const originalLines = content.split('\n');
8406
- const maskedContent = maskSafeZones(content);
8407
- const findings = [];
8408
- for (const rule of this.rules){
8409
- const targetContent = rule.skipSafeZones ? maskedContent : content;
8410
- const matches = rule.check(targetContent);
8411
- for (const match of matches){
8412
- // Use custom snippet if provided, otherwise generate from original content
8413
- const snippet = match.snippet ?? (null != match.line ? originalLines[match.line - 1]?.trim().slice(0, SNIPPET_MAX_LENGTH) : void 0);
8414
- findings.push({
8415
- rule: rule.id,
8416
- level: rule.level,
8417
- message: rule.message,
8418
- line: match.line,
8419
- snippet
8334
+ * Validate a skill directory for publishing
8335
+ *
8336
+ * Following agentskills.io specification:
8337
+ * - SKILL.md is the SOLE source of metadata
8338
+ * - name and description are REQUIRED in frontmatter
8339
+ */ validate(skillPath) {
8340
+ const errors = [];
8341
+ const warnings = [];
8342
+ // Check SKILL.md exists (REQUIRED per spec)
8343
+ const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'SKILL.md');
8344
+ if (!external_node_fs_.existsSync(skillMdPath)) {
8345
+ errors.push({
8346
+ field: 'SKILL.md',
8347
+ message: 'SKILL.md not found. This file is required for publishing.',
8348
+ suggestion: 'Create a SKILL.md file with name and description in YAML frontmatter'
8349
+ });
8350
+ return {
8351
+ valid: false,
8352
+ errors,
8353
+ warnings
8354
+ };
8355
+ }
8356
+ // Parse SKILL.md
8357
+ let skillMd;
8358
+ try {
8359
+ skillMd = parseSkillMdFile(skillMdPath);
8360
+ if (!skillMd) {
8361
+ errors.push({
8362
+ field: 'SKILL.md',
8363
+ message: 'SKILL.md must have valid YAML frontmatter with name and description',
8364
+ suggestion: 'Add frontmatter: ---\\nname: your-skill\\ndescription: Your description\\n---'
8420
8365
  });
8366
+ return {
8367
+ valid: false,
8368
+ errors,
8369
+ warnings
8370
+ };
8421
8371
  }
8372
+ } catch (error) {
8373
+ errors.push({
8374
+ field: 'SKILL.md',
8375
+ message: `Failed to parse SKILL.md: ${error.message}`,
8376
+ suggestion: 'Check the YAML frontmatter syntax is valid'
8377
+ });
8378
+ return {
8379
+ valid: false,
8380
+ errors,
8381
+ warnings
8382
+ };
8422
8383
  }
8423
- const hasHighRisk = findings.some((f)=>'high' === f.level);
8384
+ // Validate name from SKILL.md
8385
+ const nameResult = this.validateName(skillMd.name);
8386
+ errors.push(...nameResult.errors);
8387
+ // Validate description from SKILL.md
8388
+ const descResult = this.validateDescription(skillMd.description);
8389
+ errors.push(...descResult.errors);
8390
+ // Check version in SKILL.md
8391
+ const skillMdVersion = skillMd.version || skillMd.metadata?.version;
8392
+ if (skillMdVersion) {
8393
+ // Validate the version from SKILL.md
8394
+ const versionResult = this.validateVersion(skillMdVersion);
8395
+ errors.push(...versionResult.errors);
8396
+ } else warnings.push({
8397
+ field: 'version',
8398
+ message: `No version specified, defaulting to "${DEFAULT_VERSION}"`,
8399
+ suggestion: 'Add version in SKILL.md frontmatter'
8400
+ });
8401
+ // Check keywords count (only if metadata.keywords is a valid array)
8402
+ const keywords = skillMd.metadata?.keywords;
8403
+ if (Array.isArray(keywords) && keywords.length > MAX_KEYWORDS) warnings.push({
8404
+ field: 'keywords',
8405
+ message: `Too many keywords (${keywords.length}). Recommended max: ${MAX_KEYWORDS}`
8406
+ });
8407
+ // Check license
8408
+ if (!skillMd.license) warnings.push({
8409
+ field: 'license',
8410
+ message: 'No license specified',
8411
+ suggestion: 'Add license in SKILL.md frontmatter'
8412
+ });
8424
8413
  return {
8425
- passed: !hasHighRisk,
8426
- findings
8414
+ valid: 0 === errors.length,
8415
+ errors,
8416
+ warnings
8427
8417
  };
8428
8418
  }
8429
8419
  /**
8430
- * Scan a file for malicious patterns.
8431
- * Convenience wrapper that reads the file then calls scan().
8432
- */ scanFile(filePath) {
8433
- const content = external_node_fs_.readFileSync(filePath, 'utf-8');
8434
- return this.scan(content);
8420
+ * Generate integrity hash for files
8421
+ */ generateIntegrity(skillPath, files) {
8422
+ const hash = __WEBPACK_EXTERNAL_MODULE_node_crypto__.createHash('sha256');
8423
+ // Sort files for consistent ordering
8424
+ const sortedFiles = [
8425
+ ...files
8426
+ ].sort();
8427
+ for (const file of sortedFiles){
8428
+ const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
8429
+ if (external_node_fs_.existsSync(filePath)) {
8430
+ hash.update(file);
8431
+ const content = external_node_fs_.readFileSync(filePath);
8432
+ hash.update(content);
8433
+ }
8434
+ }
8435
+ return `sha256-${hash.digest('hex')}`;
8435
8436
  }
8436
8437
  }
8437
8438
  /**
@@ -8796,7 +8797,7 @@ async function publishAction(skillPath, options) {
8796
8797
  const validation = validator.validate(absolutePath);
8797
8798
  // 3.5. Content security scan
8798
8799
  const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(absolutePath, 'SKILL.md');
8799
- if (external_node_fs_.existsSync(skillMdPath)) {
8800
+ if (exists(skillMdPath)) {
8800
8801
  const scanner = new ContentScanner();
8801
8802
  const scanResult = scanner.scanFile(skillMdPath);
8802
8803
  displayScanFindings(scanResult);