reskill 1.16.0-beta.0 → 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,932 +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;
7872
7767
  }
7873
- /**
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;
7883
- return {
7884
- name: skillMd.name,
7885
- version,
7886
- description: skillMd.description,
7887
- license: skillMd.license,
7888
- keywords,
7889
- entry: 'SKILL.md'
7890
- };
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));
7891
7779
  }
7892
- /**
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;
7780
+ if (options?.overrides) for (const rule of rules){
7781
+ const override = options.overrides[rule.id];
7782
+ if (override) rule.level = override;
7926
7783
  }
7927
- /**
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
- ];
7943
- /**
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;
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);
7952
7809
  }
7953
7810
  /**
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);
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
+ });
7968
7830
  }
7969
7831
  }
7832
+ const hasHighRisk = findings.some((f)=>'high' === f.level);
7833
+ return {
7834
+ passed: !hasHighRisk,
7835
+ findings
7836
+ };
7970
7837
  }
7971
7838
  /**
7972
- * Validate a skill directory for publishing
7973
- *
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) {
7978
- 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)) {
7983
- 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'
7987
- });
7988
- return {
7989
- valid: false,
7990
- errors,
7991
- warnings
7992
- };
7993
- }
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) {
8011
- errors.push({
8012
- field: 'SKILL.md',
8013
- message: `Failed to parse SKILL.md: ${error.message}`,
8014
- suggestion: 'Check the YAML frontmatter syntax is valid'
8015
- });
8016
- return {
8017
- valid: false,
8018
- errors,
8019
- warnings
8020
- };
8021
- }
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({
8035
- 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'
8050
- });
8051
- return {
8052
- valid: 0 === errors.length,
8053
- errors,
8054
- warnings
8055
- };
8056
- }
8057
- /**
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')}`;
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);
8074
7845
  }
8075
7846
  }
8076
7847
  /**
8077
- * ContentScanner - Detect malicious patterns in SKILL.md content
7848
+ * Publisher - Handle Git information and publish payload building
8078
7849
  *
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
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';
7855
+ }
7856
+ }
8087
7857
  // ============================================================================
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;
7858
+ // Publisher Class
7859
+ // ============================================================================
7860
+ class Publisher {
7861
+ /**
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;
8116
7884
  }
8117
- if (inFrontmatter) {
8118
- result.push(maskLine(line));
8119
- if ('---' === line.trim()) inFrontmatter = false;
8120
- continue;
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
8121
7895
  }
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;
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
8132
7904
  }
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;
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
8140
7918
  }
8141
- // --- Blockquote ---
8142
- if (/^>\s?/.test(line)) {
8143
- result.push(maskLine(line));
8144
- prevLineBlank = false;
8145
- prevLineIndentedCode = false;
8146
- continue;
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`);
8147
7929
  }
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;
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
8154
7940
  }
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;
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
+ }
7953
+ /**
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;
7971
+ }
7972
+ /**
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;
8030
+ }
8031
+ }
8032
+ return total;
8033
+ }
8176
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
8177
8046
  // ============================================================================
8178
- // Rule Helpers
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
+ ];
8179
8058
  // ============================================================================
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
8059
+ // SkillValidator Class
8060
+ // ============================================================================
8061
+ class SkillValidator {
8062
+ /**
8063
+ * Validate skill name format
8064
+ *
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) {
8071
+ const errors = [];
8072
+ if (!name) {
8073
+ errors.push({
8074
+ field: 'name',
8075
+ message: 'Skill name is required',
8076
+ suggestion: 'Add "name" field to SKILL.md frontmatter'
8077
+ });
8078
+ return {
8079
+ valid: false,
8080
+ errors,
8081
+ warnings: []
8082
+ };
8083
+ }
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`
8186
8088
  });
8187
- break;
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
+ };
8188
8128
  }
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
- /ignore\s+(all\s+)?previous\s+instructions/i,
8204
- /disregard\s+(all\s+)?(prior|previous|above)\s+(instructions|rules|context)/i,
8205
- /you\s+are\s+now\s+/i,
8206
- /from\s+now\s+on[,\s]+you\s+are/i,
8207
- /new\s+system\s+prompt/i,
8208
- /override\s+(your|the)\s+(system|safety|security)\s+(prompt|rules|instructions)/i,
8209
- /forget\s+(?:all\s+)?(?:your\s+)?(?:previous\s+|prior\s+)?(?:instructions|rules|constraints)/i,
8210
- /entering\s+(a\s+)?new\s+(mode|context|session)/i
8211
- ])
8212
- },
8213
- // Rule 2: Data Exfiltration (high)
8214
- {
8215
- id: 'data-exfiltration',
8216
- level: 'high',
8217
- message: 'Detected potential data exfiltration command',
8218
- skipSafeZones: true,
8219
- check: (content)=>{
8220
- const lines = content.split('\n');
8221
- const matches = [];
8222
- const commandPattern = /\b(curl|wget|fetch|http\.post|requests\.post|nc\b|ncat|netcat)\b/i;
8223
- const sensitivePattern = /(\$[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)[A-Z_]*|\$ENV\b|\$\{[^}]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[^}]*\})/i;
8224
- for(let i = 0; i < lines.length; i++)if (commandPattern.test(lines[i]) && sensitivePattern.test(lines[i])) matches.push({
8225
- line: i + 1
8129
+ /**
8130
+ * Validate version format (semver)
8131
+ */ validateVersion(version) {
8132
+ const errors = [];
8133
+ if (!version) {
8134
+ errors.push({
8135
+ field: 'version',
8136
+ message: 'Version is required',
8137
+ suggestion: 'Add "version" field to SKILL.md frontmatter (e.g., "1.0.0")'
8226
8138
  });
8227
- return matches;
8139
+ return {
8140
+ valid: false,
8141
+ errors,
8142
+ warnings: []
8143
+ };
8228
8144
  }
8229
- },
8230
- // Rule 3: Content Obfuscation (high) — scans ALL content including safe zones
8231
- {
8232
- id: 'obfuscation',
8233
- level: 'high',
8234
- message: 'Detected content obfuscation',
8235
- skipSafeZones: false,
8236
- check: (content)=>{
8237
- const matches = [];
8238
- const lines = content.split('\n');
8239
- // Zero-width characters (suspicious in any context)
8240
- const zeroWidthPattern = /[\u200B\u200C\u200D\uFEFF\u2060\u180E]/;
8241
- for(let i = 0; i < lines.length; i++)if (zeroWidthPattern.test(lines[i])) matches.push({
8242
- line: i + 1,
8243
- snippet: 'Zero-width Unicode characters detected'
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)}"`
8244
8151
  });
8245
- // Long base64-like strings (>200 continuous chars)
8246
- const base64Pattern = /[A-Za-z0-9+/=]{200,}/;
8247
- for(let i = 0; i < lines.length; i++)if (base64Pattern.test(lines[i])) matches.push({
8248
- line: i + 1,
8249
- snippet: 'Suspicious base64-encoded block detected'
8152
+ return {
8153
+ valid: false,
8154
+ errors,
8155
+ warnings: []
8156
+ };
8157
+ }
8158
+ if (!__WEBPACK_EXTERNAL_MODULE_semver__.valid(version)) errors.push({
8159
+ field: 'version',
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"'
8162
+ });
8163
+ return {
8164
+ valid: 0 === errors.length,
8165
+ errors,
8166
+ warnings: []
8167
+ };
8168
+ }
8169
+ /**
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'
8250
8182
  });
8251
- // Large HTML comments (>200 chars of content)
8252
- const commentRegex = /<!--([\s\S]{200,}?)-->/g;
8253
- let match;
8254
- // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
8255
- while(null !== (match = commentRegex.exec(content))){
8256
- const lineNum = content.slice(0, match.index).split('\n').length;
8257
- matches.push({
8258
- line: lineNum,
8259
- snippet: `Large HTML comment block (${match[1].length} chars)`
8260
- });
8261
- }
8262
- return matches;
8183
+ return {
8184
+ valid: false,
8185
+ errors,
8186
+ warnings: []
8187
+ };
8263
8188
  }
8264
- },
8265
- // Rule 4: Sensitive File Access (medium)
8266
- {
8267
- id: 'sensitive-file-access',
8268
- level: 'medium',
8269
- message: 'References sensitive file path',
8270
- skipSafeZones: true,
8271
- check: (content)=>findLineMatches(content, [
8272
- /~\/\.ssh\b/,
8273
- /~\/\.aws\b/,
8274
- /~\/\.gnupg\b/,
8275
- /~\/\.config\/gcloud\b/,
8276
- /\bid_rsa\b/i,
8277
- /\bid_ed25519\b/i,
8278
- /\/etc\/passwd\b/,
8279
- /\/etc\/shadow\b/,
8280
- /\.env\b(?!\.\w)/
8281
- ])
8282
- },
8283
- // Rule 5: Stealth Instructions (medium) — phrase + action verb matching
8284
- {
8285
- id: 'stealth-instructions',
8286
- level: 'medium',
8287
- message: 'Detected instruction to hide actions from user',
8288
- skipSafeZones: true,
8289
- check: (content)=>{
8290
- const actionVerbs = 'execute|delete|remove|send|transmit|modify|overwrite|install|download|upload|run|write|create|destroy|drop';
8291
- const patterns = [
8292
- new RegExp(`silently\\s+(?:${actionVerbs})`, 'i'),
8293
- new RegExp(`without\\s+telling\\s+the\\s+user.{0,30}(?:${actionVerbs})`, 'i'),
8294
- new RegExp("(?:do\\s+not|don'?t)\\s+show\\s+.{0,40}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
8295
- new RegExp("hide\\s+(?:this|the|these|all)\\s+.{0,30}(?:from\\s+the\\s+user|from\\s+user)", 'i'),
8296
- new RegExp("(?:do\\s+not|don'?t)\\s+mention\\s+.{0,30}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
8297
- new RegExp("keep\\s+(?:this|it)\\s+(?:a\\s+)?secret\\s+from\\s+(?:the\\s+)?user", 'i')
8298
- ];
8299
- // Safe patterns to exclude (common in legitimate DevOps/automation skills)
8300
- const safePatterns = [
8301
- /silently\s+(?:ignore|skip|fail|discard|suppress|continue|pass|drop|swallow)/i
8302
- ];
8303
- const lines = content.split('\n');
8304
- const matches = [];
8305
- for(let i = 0; i < lines.length; i++){
8306
- const line = lines[i];
8307
- if (!safePatterns.some((p)=>p.test(line))) {
8308
- for (const pattern of patterns)if (pattern.test(line)) {
8309
- matches.push({
8310
- line: i + 1
8311
- });
8312
- break;
8313
- }
8189
+ if (description.length > MAX_DESCRIPTION_LENGTH) errors.push({
8190
+ field: 'description',
8191
+ message: `Description must be at most ${MAX_DESCRIPTION_LENGTH} characters`
8192
+ });
8193
+ // Note: angle brackets are allowed per agentskills.io spec
8194
+ return {
8195
+ valid: 0 === errors.length,
8196
+ errors,
8197
+ warnings: []
8198
+ };
8199
+ }
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
8222
+ }
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);
8229
+ }
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);
8314
8270
  }
8315
8271
  }
8316
- return matches;
8317
- }
8318
- },
8319
- // Rule 6: Oversized Content (low) — scans ALL content
8320
- {
8321
- id: 'oversized-content',
8322
- level: 'low',
8323
- message: 'Content exceeds recommended size limit',
8324
- skipSafeZones: false,
8325
- check: (content)=>{
8326
- const MAX_SIZE_BYTES = 51200;
8327
- const sizeBytes = Buffer.byteLength(content, 'utf-8');
8328
- if (sizeBytes > MAX_SIZE_BYTES) return [
8329
- {
8330
- 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
+ }
8331
8283
  }
8332
- ];
8333
- return [];
8334
- }
8335
- }
8336
- ];
8337
- // ============================================================================
8338
- // ContentScanner
8339
- // ============================================================================
8340
- /** Build the effective rule set from defaults + options */ function buildRuleSet(options) {
8341
- let rules = DEFAULT_RULES.map((r)=>({
8342
- ...r
8343
- }));
8344
- if (options?.disabledRules?.length) {
8345
- const disabled = new Set(options.disabledRules);
8346
- 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;
8347
8288
  }
8348
- if (options?.overrides) for (const rule of rules){
8349
- const override = options.overrides[rule.id];
8350
- 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;
8351
8314
  }
8352
- if (options?.customRules?.length) rules.push(...options.customRules);
8353
- return rules;
8354
- }
8355
- /**
8356
- * Content scanner for SKILL.md files.
8357
- *
8358
- * Detects prompt injection, data exfiltration, obfuscation, sensitive file
8359
- * access, stealth instructions, and oversized content.
8360
- *
8361
- * @example
8362
- * ```typescript
8363
- * // Default usage (CLI)
8364
- * const scanner = new ContentScanner();
8365
- * const result = scanner.scan(content);
8366
- *
8367
- * // Custom usage (private registry server)
8368
- * const scanner = new ContentScanner({
8369
- * overrides: { 'prompt-injection': 'medium' },
8370
- * disabledRules: ['stealth-instructions'],
8371
- * });
8372
- * ```
8373
- */ class ContentScanner {
8374
- rules;
8375
- constructor(options){
8376
- 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
+ }
8377
8332
  }
8378
8333
  /**
8379
- * Scan content string for malicious patterns.
8380
- * Pure string operation — no file system access.
8381
- */ scan(content) {
8382
- const originalLines = content.split('\n');
8383
- const maskedContent = maskSafeZones(content);
8384
- const findings = [];
8385
- for (const rule of this.rules){
8386
- const targetContent = rule.skipSafeZones ? maskedContent : content;
8387
- const matches = rule.check(targetContent);
8388
- for (const match of matches){
8389
- // Use custom snippet if provided, otherwise generate from original content
8390
- const snippet = match.snippet ?? (null != match.line ? originalLines[match.line - 1]?.trim().slice(0, SNIPPET_MAX_LENGTH) : void 0);
8391
- findings.push({
8392
- rule: rule.id,
8393
- level: rule.level,
8394
- message: rule.message,
8395
- line: match.line,
8396
- 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---'
8397
8365
  });
8366
+ return {
8367
+ valid: false,
8368
+ errors,
8369
+ warnings
8370
+ };
8398
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
+ };
8399
8383
  }
8400
- 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
+ });
8401
8413
  return {
8402
- passed: !hasHighRisk,
8403
- findings
8414
+ valid: 0 === errors.length,
8415
+ errors,
8416
+ warnings
8404
8417
  };
8405
8418
  }
8406
8419
  /**
8407
- * Scan a file for malicious patterns.
8408
- * Convenience wrapper that reads the file then calls scan().
8409
- */ scanFile(filePath) {
8410
- const content = external_node_fs_.readFileSync(filePath, 'utf-8');
8411
- 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')}`;
8412
8436
  }
8413
8437
  }
8414
8438
  /**
@@ -8773,7 +8797,7 @@ async function publishAction(skillPath, options) {
8773
8797
  const validation = validator.validate(absolutePath);
8774
8798
  // 3.5. Content security scan
8775
8799
  const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(absolutePath, 'SKILL.md');
8776
- if (external_node_fs_.existsSync(skillMdPath)) {
8800
+ if (exists(skillMdPath)) {
8777
8801
  const scanner = new ContentScanner();
8778
8802
  const scanResult = scanner.scanFile(skillMdPath);
8779
8803
  displayScanFindings(scanResult);