reskill 1.14.0 → 1.16.0-beta.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
@@ -5237,6 +5237,133 @@ class RegistryResolver {
5237
5237
  }
5238
5238
  return false;
5239
5239
  }
5240
+ /**
5241
+ * AuthManager - Handle authentication token management
5242
+ *
5243
+ * Manages tokens for registry authentication.
5244
+ * Tokens are stored in ~/.reskillrc or via RESKILL_TOKEN environment variable.
5245
+ */ // ============================================================================
5246
+ // Constants
5247
+ // ============================================================================
5248
+ const CONFIG_FILE_NAME = '.reskillrc';
5249
+ // ============================================================================
5250
+ // AuthManager Class
5251
+ // ============================================================================
5252
+ class AuthManager {
5253
+ configPath;
5254
+ constructor(){
5255
+ const home = process.env.HOME || process.env.USERPROFILE || '';
5256
+ this.configPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(home, CONFIG_FILE_NAME);
5257
+ }
5258
+ /**
5259
+ * Get the default registry URL from environment variable
5260
+ *
5261
+ * Returns undefined if no registry is configured - there is no hardcoded default
5262
+ * to prevent accidental publishing to unintended registries.
5263
+ */ getDefaultRegistry() {
5264
+ return process.env.RESKILL_REGISTRY;
5265
+ }
5266
+ /**
5267
+ * Get path to config file
5268
+ */ getConfigPath() {
5269
+ return this.configPath;
5270
+ }
5271
+ /**
5272
+ * Get token for a registry
5273
+ *
5274
+ * Priority:
5275
+ * 1. RESKILL_TOKEN environment variable
5276
+ * 2. Token from ~/.reskillrc for the specified registry
5277
+ */ getToken(registry) {
5278
+ // Check environment variable first
5279
+ const envToken = process.env.RESKILL_TOKEN;
5280
+ if (envToken) return envToken;
5281
+ // Read from config file
5282
+ const config = this.readConfig();
5283
+ if (!config?.registries) return;
5284
+ const targetRegistry = registry || this.getDefaultRegistry();
5285
+ if (!targetRegistry) return;
5286
+ const auth = config.registries[targetRegistry];
5287
+ return auth?.token;
5288
+ }
5289
+ /**
5290
+ * Check if token exists for a registry
5291
+ */ hasToken(registry) {
5292
+ return void 0 !== this.getToken(registry);
5293
+ }
5294
+ /**
5295
+ * Get email for a registry
5296
+ */ getEmail(registry) {
5297
+ const config = this.readConfig();
5298
+ if (!config?.registries) return;
5299
+ const targetRegistry = registry || this.getDefaultRegistry();
5300
+ if (!targetRegistry) return;
5301
+ const auth = config.registries[targetRegistry];
5302
+ return auth?.email;
5303
+ }
5304
+ /**
5305
+ * Get handle for a registry
5306
+ */ getHandle(registry) {
5307
+ const config = this.readConfig();
5308
+ if (!config?.registries) return;
5309
+ const targetRegistry = registry || this.getDefaultRegistry();
5310
+ if (!targetRegistry) return;
5311
+ const auth = config.registries[targetRegistry];
5312
+ return auth?.handle;
5313
+ }
5314
+ /**
5315
+ * Set token for a registry
5316
+ *
5317
+ * Note: When no registry is specified and RESKILL_REGISTRY env var is not set,
5318
+ * this method will throw an error. The calling code should ensure a registry
5319
+ * is always provided (either explicitly or via environment variable).
5320
+ */ setToken(token, registry, email, handle) {
5321
+ const config = this.readConfig() || {};
5322
+ const targetRegistry = registry || this.getDefaultRegistry();
5323
+ if (!targetRegistry) throw new Error('No registry specified. Set RESKILL_REGISTRY environment variable or provide registry explicitly.');
5324
+ if (!config.registries) config.registries = {};
5325
+ config.registries[targetRegistry] = {
5326
+ token,
5327
+ ...email && {
5328
+ email
5329
+ },
5330
+ ...handle && {
5331
+ handle
5332
+ }
5333
+ };
5334
+ this.writeConfig(config);
5335
+ }
5336
+ /**
5337
+ * Remove token for a registry
5338
+ */ removeToken(registry) {
5339
+ const config = this.readConfig();
5340
+ if (!config?.registries) return;
5341
+ const targetRegistry = registry || this.getDefaultRegistry();
5342
+ if (!targetRegistry) return;
5343
+ delete config.registries[targetRegistry];
5344
+ this.writeConfig(config);
5345
+ }
5346
+ /**
5347
+ * Read config file
5348
+ */ readConfig() {
5349
+ try {
5350
+ if (!external_node_fs_.existsSync(this.configPath)) return null;
5351
+ const content = external_node_fs_.readFileSync(this.configPath, 'utf-8');
5352
+ if (!content.trim()) return null;
5353
+ return JSON.parse(content);
5354
+ } catch {
5355
+ return null;
5356
+ }
5357
+ }
5358
+ /**
5359
+ * Write config file
5360
+ */ writeConfig(config) {
5361
+ const content = JSON.stringify(config, null, 2);
5362
+ external_node_fs_.writeFileSync(this.configPath, content, {
5363
+ mode: 384
5364
+ });
5365
+ }
5366
+ }
5240
5367
  /**
5241
5368
  * Get status icon
5242
5369
  */ function getStatusIcon(status) {
@@ -5444,6 +5571,73 @@ class RegistryResolver {
5444
5571
  hint: 'For private repos, add SSH key: ssh-keygen -t ed25519'
5445
5572
  };
5446
5573
  }
5574
+ /**
5575
+ * Valid install modes
5576
+ *
5577
+ * Must stay in sync with InstallMode type from installer.ts ('symlink' | 'copy').
5578
+ * TypeScript literal union types are erased at runtime, so we maintain this list manually.
5579
+ */ const VALID_INSTALL_MODES = [
5580
+ 'symlink',
5581
+ 'copy'
5582
+ ];
5583
+ /**
5584
+ * Check registry authentication status
5585
+ *
5586
+ * Verifies actual token availability, not just file existence.
5587
+ * Uses AuthManager.getToken() which checks RESKILL_TOKEN env first,
5588
+ * then reads ~/.reskillrc for the configured registry.
5589
+ */ function checkAuthStatus() {
5590
+ const hasEnvToken = !!process.env.RESKILL_TOKEN;
5591
+ if (hasEnvToken) return {
5592
+ name: 'Registry auth',
5593
+ status: 'ok',
5594
+ message: 'RESKILL_TOKEN set'
5595
+ };
5596
+ // Check if ~/.reskillrc has any valid tokens
5597
+ const authManager = new AuthManager();
5598
+ const configPath = authManager.getConfigPath();
5599
+ if (!(0, external_node_fs_.existsSync)(configPath)) return {
5600
+ name: 'Registry auth',
5601
+ status: 'warn',
5602
+ message: 'no token configured',
5603
+ hint: 'Run: reskill login (needed for publish and private skills)'
5604
+ };
5605
+ // File exists — check if it actually contains a token for any registry
5606
+ const hasToken = authManager.hasToken();
5607
+ if (hasToken) return {
5608
+ name: 'Registry auth',
5609
+ status: 'ok',
5610
+ message: 'token configured via ~/.reskillrc'
5611
+ };
5612
+ return {
5613
+ name: 'Registry auth',
5614
+ status: 'warn',
5615
+ message: '~/.reskillrc exists but no token found for current registry',
5616
+ hint: 'Run: reskill login (needed for publish and private skills)'
5617
+ };
5618
+ }
5619
+ /**
5620
+ * Check environment variables used by reskill
5621
+ *
5622
+ * Security: Only report variable names, never values.
5623
+ * RESKILL_TOKEN contains a secret and must not be displayed.
5624
+ */ function checkEnvVars() {
5625
+ // Only collect names, never values (RESKILL_TOKEN is a secret)
5626
+ const vars = [];
5627
+ if (process.env.RESKILL_TOKEN) vars.push('RESKILL_TOKEN');
5628
+ if (process.env.RESKILL_REGISTRY) vars.push('RESKILL_REGISTRY');
5629
+ if (process.env.RESKILL_CACHE_DIR) vars.push('RESKILL_CACHE_DIR');
5630
+ if (0 === vars.length) return {
5631
+ name: 'Environment vars',
5632
+ status: 'ok',
5633
+ message: 'none set'
5634
+ };
5635
+ return {
5636
+ name: 'Environment vars',
5637
+ status: 'ok',
5638
+ message: `${vars.join(', ')} set`
5639
+ };
5640
+ }
5447
5641
  /**
5448
5642
  * Check cache directory
5449
5643
  */ function checkCacheDir() {
@@ -5535,12 +5729,21 @@ class RegistryResolver {
5535
5729
  try {
5536
5730
  const config = configLoader.load();
5537
5731
  const registries = config.registries || {};
5538
- for (const name of Object.keys(registries))if (RESERVED_REGISTRIES.includes(name.toLowerCase())) results.push({
5539
- name: 'Registry conflict',
5540
- status: 'warn',
5541
- message: `"${name}" overrides built-in registry`,
5542
- hint: 'Consider using a different name for custom registries'
5543
- });
5732
+ for (const [name, url] of Object.entries(registries)){
5733
+ if (RESERVED_REGISTRIES.includes(name.toLowerCase())) results.push({
5734
+ name: 'Registry conflict',
5735
+ status: 'warn',
5736
+ message: `"${name}" overrides built-in registry`,
5737
+ hint: 'Consider using a different name for custom registries'
5738
+ });
5739
+ // Validate URL format
5740
+ if ('string' == typeof url && !/^https?:\/\//.test(url)) results.push({
5741
+ name: 'Invalid registry URL',
5742
+ status: 'error',
5743
+ message: `"${name}": "${url}" is not a valid URL`,
5744
+ hint: 'Registry URLs must start with http:// or https://'
5745
+ });
5746
+ }
5544
5747
  } catch {
5545
5748
  // Ignore parse errors, handled by checkSkillsJson
5546
5749
  }
@@ -5575,6 +5778,46 @@ class RegistryResolver {
5575
5778
  }
5576
5779
  return null;
5577
5780
  }
5781
+ /**
5782
+ * Check for invalid installMode configuration
5783
+ */ function checkInstallMode(cwd) {
5784
+ const configLoader = new ConfigLoader(cwd);
5785
+ if (!configLoader.exists()) return null;
5786
+ try {
5787
+ const defaults = configLoader.getDefaults();
5788
+ const installMode = defaults.installMode;
5789
+ if (!installMode) return null;
5790
+ if (!VALID_INSTALL_MODES.includes(installMode)) return {
5791
+ name: 'Invalid installMode',
5792
+ status: 'error',
5793
+ message: `"${installMode}" is not a valid install mode`,
5794
+ hint: 'Valid values: symlink, copy'
5795
+ };
5796
+ } catch {
5797
+ // Ignore parse errors
5798
+ }
5799
+ return null;
5800
+ }
5801
+ /**
5802
+ * Check for invalid publishRegistry configuration
5803
+ */ function checkPublishRegistry(cwd) {
5804
+ const configLoader = new ConfigLoader(cwd);
5805
+ if (!configLoader.exists()) return null;
5806
+ try {
5807
+ const defaults = configLoader.getDefaults();
5808
+ const publishRegistry = defaults.publishRegistry;
5809
+ if (!publishRegistry) return null;
5810
+ if (!/^https?:\/\//.test(publishRegistry)) return {
5811
+ name: 'Invalid publishRegistry',
5812
+ status: 'warn',
5813
+ message: `"${publishRegistry}" is not a valid URL`,
5814
+ hint: 'publishRegistry must start with http:// or https://'
5815
+ };
5816
+ } catch {
5817
+ // Ignore parse errors
5818
+ }
5819
+ return null;
5820
+ }
5578
5821
  /**
5579
5822
  * Check for invalid targetAgents configuration
5580
5823
  */ function checkTargetAgents(cwd) {
@@ -5711,6 +5954,15 @@ class RegistryResolver {
5711
5954
  hint: 'Run: reskill install'
5712
5955
  };
5713
5956
  }
5957
+ // Check lockfile version compatibility before sync check
5958
+ // If version is unsupported, sync results would be meaningless
5959
+ const lockData = lockManager.load();
5960
+ if (lockData.lockfileVersion !== LOCKFILE_VERSION) return {
5961
+ name: 'skills.lock',
5962
+ status: 'warn',
5963
+ message: `unsupported lockfile version: ${lockData.lockfileVersion}`,
5964
+ hint: 'Run: reskill install to regenerate'
5965
+ };
5714
5966
  // Check if lock is in sync with config
5715
5967
  const configSkills = configLoader.getSkills();
5716
5968
  const lockedSkills = lockManager.getAll();
@@ -5821,6 +6073,66 @@ class RegistryResolver {
5821
6073
  }
5822
6074
  return results;
5823
6075
  }
6076
+ /**
6077
+ * Check detected agents
6078
+ */ async function checkDetectedAgents() {
6079
+ try {
6080
+ const installed = await detectInstalledAgents();
6081
+ if (0 === installed.length) return {
6082
+ name: 'Detected agents',
6083
+ status: 'ok',
6084
+ message: 'none detected'
6085
+ };
6086
+ return {
6087
+ name: 'Detected agents',
6088
+ status: 'ok',
6089
+ message: `${installed.length} detected: ${installed.join(', ')}`
6090
+ };
6091
+ } catch {
6092
+ return {
6093
+ name: 'Detected agents',
6094
+ status: 'warn',
6095
+ message: 'detection failed'
6096
+ };
6097
+ }
6098
+ }
6099
+ /**
6100
+ * Normalize a URL to its origin for comparison.
6101
+ * Handles trailing slashes and case differences (e.g., GITHUB.COM → github.com).
6102
+ */ function normalizeUrlOrigin(url) {
6103
+ try {
6104
+ return new URL(url).origin;
6105
+ } catch {
6106
+ return url;
6107
+ }
6108
+ }
6109
+ /**
6110
+ * Get custom registry URLs for network checks
6111
+ *
6112
+ * Reads registries from skills.json and publishRegistry,
6113
+ * excluding built-in github.com/gitlab.com and deduplicating.
6114
+ */ function getCustomRegistryUrls(cwd) {
6115
+ const urls = new Set();
6116
+ const builtinOrigins = new Set(Object.values(DEFAULT_REGISTRIES).map((u)=>normalizeUrlOrigin(u)));
6117
+ try {
6118
+ const configLoader = new ConfigLoader(cwd);
6119
+ if (!configLoader.exists()) return [];
6120
+ // Collect custom registry URLs
6121
+ const config = configLoader.load();
6122
+ const registries = config.registries || {};
6123
+ for (const url of Object.values(registries))if ('string' == typeof url && /^https?:\/\//.test(url) && !builtinOrigins.has(normalizeUrlOrigin(url))) urls.add(url);
6124
+ // Collect publishRegistry
6125
+ const defaults = configLoader.getDefaults();
6126
+ if (defaults.publishRegistry && /^https?:\/\//.test(defaults.publishRegistry)) {
6127
+ if (!builtinOrigins.has(normalizeUrlOrigin(defaults.publishRegistry))) urls.add(defaults.publishRegistry);
6128
+ }
6129
+ } catch {
6130
+ // Ignore errors
6131
+ }
6132
+ return [
6133
+ ...urls
6134
+ ];
6135
+ }
5824
6136
  /**
5825
6137
  * Check network connectivity
5826
6138
  */ async function checkNetwork(host) {
@@ -5867,23 +6179,32 @@ class RegistryResolver {
5867
6179
  */ async function runDoctorChecks(options) {
5868
6180
  const { cwd, packageName, packageVersion, skipNetwork, skipConfigChecks } = options;
5869
6181
  const results = [];
5870
- // Version checks
6182
+ // Environment checks
5871
6183
  results.push(await checkReskillVersion(packageVersion, packageName));
5872
6184
  results.push(checkNodeVersion());
5873
6185
  results.push(checkGitVersion());
5874
6186
  results.push(checkGitAuth());
6187
+ results.push(checkAuthStatus());
6188
+ results.push(checkEnvVars());
5875
6189
  // Directory checks
5876
6190
  results.push(checkCacheDir());
5877
6191
  results.push(checkSkillsJson(cwd));
5878
6192
  results.push(checkSkillsLock(cwd));
5879
6193
  results.push(...checkInstalledSkills(cwd));
6194
+ results.push(await checkDetectedAgents());
5880
6195
  // Deep config checks (can be skipped for faster checks)
5881
6196
  if (!skipConfigChecks) {
5882
- // Registry conflicts
6197
+ // Registry conflicts + URL validation
5883
6198
  results.push(...checkRegistryConflicts(cwd));
5884
6199
  // installDir validation
5885
6200
  const installDirCheck = checkInstallDir(cwd);
5886
6201
  if (installDirCheck) results.push(installDirCheck);
6202
+ // installMode validation
6203
+ const installModeCheck = checkInstallMode(cwd);
6204
+ if (installModeCheck) results.push(installModeCheck);
6205
+ // publishRegistry validation
6206
+ const publishRegistryCheck = checkPublishRegistry(cwd);
6207
+ if (publishRegistryCheck) results.push(publishRegistryCheck);
5887
6208
  // targetAgents validation
5888
6209
  results.push(...checkTargetAgents(cwd));
5889
6210
  // Skill reference format validation
@@ -5895,6 +6216,9 @@ class RegistryResolver {
5895
6216
  if (!skipNetwork) {
5896
6217
  results.push(await checkNetwork('https://github.com'));
5897
6218
  results.push(await checkNetwork('https://gitlab.com'));
6219
+ // Custom registry connectivity
6220
+ const customUrls = getCustomRegistryUrls(cwd);
6221
+ for (const url of customUrls)results.push(await checkNetwork(url));
5898
6222
  }
5899
6223
  return results;
5900
6224
  }
@@ -6191,133 +6515,6 @@ const DEFAULT_INSTALL_DIR = '.skills';
6191
6515
  // Display summary (use options.installDir directly since we just set it)
6192
6516
  displayConfigSummary(options.installDir);
6193
6517
  });
6194
- /**
6195
- * AuthManager - Handle authentication token management
6196
- *
6197
- * Manages tokens for registry authentication.
6198
- * Tokens are stored in ~/.reskillrc or via RESKILL_TOKEN environment variable.
6199
- */ // ============================================================================
6200
- // Constants
6201
- // ============================================================================
6202
- const CONFIG_FILE_NAME = '.reskillrc';
6203
- // ============================================================================
6204
- // AuthManager Class
6205
- // ============================================================================
6206
- class AuthManager {
6207
- configPath;
6208
- constructor(){
6209
- const home = process.env.HOME || process.env.USERPROFILE || '';
6210
- this.configPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(home, CONFIG_FILE_NAME);
6211
- }
6212
- /**
6213
- * Get the default registry URL from environment variable
6214
- *
6215
- * Returns undefined if no registry is configured - there is no hardcoded default
6216
- * to prevent accidental publishing to unintended registries.
6217
- */ getDefaultRegistry() {
6218
- return process.env.RESKILL_REGISTRY;
6219
- }
6220
- /**
6221
- * Get path to config file
6222
- */ getConfigPath() {
6223
- return this.configPath;
6224
- }
6225
- /**
6226
- * Get token for a registry
6227
- *
6228
- * Priority:
6229
- * 1. RESKILL_TOKEN environment variable
6230
- * 2. Token from ~/.reskillrc for the specified registry
6231
- */ getToken(registry) {
6232
- // Check environment variable first
6233
- const envToken = process.env.RESKILL_TOKEN;
6234
- if (envToken) return envToken;
6235
- // Read from config file
6236
- const config = this.readConfig();
6237
- if (!config?.registries) return;
6238
- const targetRegistry = registry || this.getDefaultRegistry();
6239
- if (!targetRegistry) return;
6240
- const auth = config.registries[targetRegistry];
6241
- return auth?.token;
6242
- }
6243
- /**
6244
- * Check if token exists for a registry
6245
- */ hasToken(registry) {
6246
- return void 0 !== this.getToken(registry);
6247
- }
6248
- /**
6249
- * Get email for a registry
6250
- */ getEmail(registry) {
6251
- const config = this.readConfig();
6252
- if (!config?.registries) return;
6253
- const targetRegistry = registry || this.getDefaultRegistry();
6254
- if (!targetRegistry) return;
6255
- const auth = config.registries[targetRegistry];
6256
- return auth?.email;
6257
- }
6258
- /**
6259
- * Get handle for a registry
6260
- */ getHandle(registry) {
6261
- const config = this.readConfig();
6262
- if (!config?.registries) return;
6263
- const targetRegistry = registry || this.getDefaultRegistry();
6264
- if (!targetRegistry) return;
6265
- const auth = config.registries[targetRegistry];
6266
- return auth?.handle;
6267
- }
6268
- /**
6269
- * Set token for a registry
6270
- *
6271
- * Note: When no registry is specified and RESKILL_REGISTRY env var is not set,
6272
- * this method will throw an error. The calling code should ensure a registry
6273
- * is always provided (either explicitly or via environment variable).
6274
- */ setToken(token, registry, email, handle) {
6275
- const config = this.readConfig() || {};
6276
- const targetRegistry = registry || this.getDefaultRegistry();
6277
- if (!targetRegistry) throw new Error('No registry specified. Set RESKILL_REGISTRY environment variable or provide registry explicitly.');
6278
- if (!config.registries) config.registries = {};
6279
- config.registries[targetRegistry] = {
6280
- token,
6281
- ...email && {
6282
- email
6283
- },
6284
- ...handle && {
6285
- handle
6286
- }
6287
- };
6288
- this.writeConfig(config);
6289
- }
6290
- /**
6291
- * Remove token for a registry
6292
- */ removeToken(registry) {
6293
- const config = this.readConfig();
6294
- if (!config?.registries) return;
6295
- const targetRegistry = registry || this.getDefaultRegistry();
6296
- if (!targetRegistry) return;
6297
- delete config.registries[targetRegistry];
6298
- this.writeConfig(config);
6299
- }
6300
- /**
6301
- * Read config file
6302
- */ readConfig() {
6303
- try {
6304
- if (!external_node_fs_.existsSync(this.configPath)) return null;
6305
- const content = external_node_fs_.readFileSync(this.configPath, 'utf-8');
6306
- if (!content.trim()) return null;
6307
- return JSON.parse(content);
6308
- } catch {
6309
- return null;
6310
- }
6311
- }
6312
- /**
6313
- * Write config file
6314
- */ writeConfig(config) {
6315
- const content = JSON.stringify(config, null, 2);
6316
- external_node_fs_.writeFileSync(this.configPath, content, {
6317
- mode: 384
6318
- });
6319
- }
6320
- }
6321
6518
  // ============================================================================
6322
6519
  // Utility Functions
6323
6520
  // ============================================================================
@@ -7876,6 +8073,344 @@ class SkillValidator {
7876
8073
  return `sha256-${hash.digest('hex')}`;
7877
8074
  }
7878
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;
8154
+ }
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
8186
+ });
8187
+ break;
8188
+ }
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
8226
+ });
8227
+ return matches;
8228
+ }
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'
8244
+ });
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'
8250
+ });
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;
8263
+ }
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
+ }
8314
+ }
8315
+ }
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)`
8331
+ }
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));
8347
+ }
8348
+ if (options?.overrides) for (const rule of rules){
8349
+ const override = options.overrides[rule.id];
8350
+ if (override) rule.level = override;
8351
+ }
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);
8377
+ }
8378
+ /**
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
8397
+ });
8398
+ }
8399
+ }
8400
+ const hasHighRisk = findings.some((f)=>'high' === f.level);
8401
+ return {
8402
+ passed: !hasHighRisk,
8403
+ findings
8404
+ };
8405
+ }
8406
+ /**
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);
8412
+ }
8413
+ }
7879
8414
  /**
7880
8415
  * publish command - Publish a skill to the registry
7881
8416
  *
@@ -8175,6 +8710,25 @@ class SkillValidator {
8175
8710
  logger_logger.newline();
8176
8711
  logger_logger.log('No changes made (--dry-run)');
8177
8712
  }
8713
+ /**
8714
+ * Display content scan findings
8715
+ */ function displayScanFindings(scanResult) {
8716
+ if (0 === scanResult.findings.length) {
8717
+ logger_logger.log(' ✓ Content security scan passed');
8718
+ return;
8719
+ }
8720
+ logger_logger.newline();
8721
+ logger_logger.log('⚠ Content Security Scan:');
8722
+ logger_logger.newline();
8723
+ for (const finding of scanResult.findings){
8724
+ const levelTag = finding.level.toUpperCase().padEnd(6);
8725
+ const lineInfo = finding.line ? ` (line ${finding.line})` : '';
8726
+ logger_logger.log(` ${levelTag} ${finding.rule}${lineInfo}`);
8727
+ logger_logger.log(` ${finding.message}`);
8728
+ if (finding.snippet) logger_logger.log(` > ${finding.snippet}`);
8729
+ logger_logger.newline();
8730
+ }
8731
+ }
8178
8732
  // ============================================================================
8179
8733
  // Main Action
8180
8734
  // ============================================================================
@@ -8217,6 +8771,18 @@ async function publishAction(skillPath, options) {
8217
8771
  }
8218
8772
  // 3. Validate
8219
8773
  const validation = validator.validate(absolutePath);
8774
+ // 3.5. Content security scan
8775
+ const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(absolutePath, 'SKILL.md');
8776
+ if (external_node_fs_.existsSync(skillMdPath)) {
8777
+ const scanner = new ContentScanner();
8778
+ const scanResult = scanner.scanFile(skillMdPath);
8779
+ displayScanFindings(scanResult);
8780
+ if (!scanResult.passed) {
8781
+ logger_logger.newline();
8782
+ logger_logger.error('Content security scan failed. Fix the issues above before publishing.');
8783
+ process.exit(1);
8784
+ }
8785
+ }
8220
8786
  // 4. Get git info
8221
8787
  let gitInfo;
8222
8788
  try {