skillvault 0.11.2 → 0.12.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.js CHANGED
@@ -21,10 +21,12 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync
21
21
  import { dirname, join, resolve as pathResolve } from 'node:path';
22
22
  import { createDecipheriv, createHmac, createPublicKey, diffieHellman, hkdfSync, generateKeyPairSync, } from 'node:crypto';
23
23
  import { resolveScope, resolveRoots, } from './scope.js';
24
+ import { isValidSkillName, } from './skill-name.js';
25
+ import { ensureSafeDir } from './safe-dir.js';
24
26
  import { loadCredentials, saveCredentials, loadProjectConfig, saveProjectConfig, } from './credentials.js';
25
27
  import { registerProject, unregisterProject, getCurrentProject, listProjects, } from './projects-registry.js';
26
28
  import { confirmInstall } from './prompts.js';
27
- const VERSION = '0.11.2';
29
+ const VERSION = '0.12.0';
28
30
  const HOME = process.env.HOME || process.env.USERPROFILE || '~';
29
31
  const API_URL = process.env.SKILLVAULT_API_URL || 'https://api.getskillvault.com';
30
32
  const CONFIG_DIR = join(HOME, '.skillvault');
@@ -771,6 +773,14 @@ async function syncSkills(roots = getActiveRoots()) {
771
773
  const localVaults = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
772
774
  for (const vaultFile of localVaults) {
773
775
  const localSkillName = vaultFile.replace(/\.vault$/, '');
776
+ // Defense in depth: a poisoned local vault directory (from an
777
+ // older buggy CLI version, or a hand-crafted file) might contain
778
+ // a path-traversal name. Skip anything that doesn't pass the
779
+ // current validator instead of feeding it into rmSync below.
780
+ if (!isValidSkillName(localSkillName)) {
781
+ console.error(`[sync] Skipping invalid local vault filename: ${JSON.stringify(vaultFile)}`);
782
+ continue;
783
+ }
774
784
  if (!remoteSkillNames.has(localSkillName)) {
775
785
  console.error(`[sync] Grant revoked: "${localSkillName}" from ${pub.name}`);
776
786
  // Remove from this install's claude skill dir
@@ -807,6 +817,18 @@ async function syncSkills(roots = getActiveRoots()) {
807
817
  // Download missing or updated vaults
808
818
  let pubSynced = 0;
809
819
  for (const skill of skills) {
820
+ // Reject server-supplied skill names that would escape pubVaultDir
821
+ // or break the stub frontmatter. The server already validates at
822
+ // publish time (see packages/server/src/routes/skills.ts), but a
823
+ // compromised or downgraded server could still hand us a malicious
824
+ // name. Defense in depth — fail closed on the customer side.
825
+ // See packages/agent/src/skill-name.ts and
826
+ // docs/security-test-outstanding.md § 2.1.
827
+ if (!isValidSkillName(skill.skill_name)) {
828
+ errors.push(`${pub.name}: refusing skill with unsafe name ${JSON.stringify(skill.skill_name)}`);
829
+ console.error(`[sync] Refusing skill from ${pub.name} with unsafe name: ${JSON.stringify(skill.skill_name)}`);
830
+ continue;
831
+ }
810
832
  const vaultPath = join(pubVaultDir, `${skill.skill_name}.vault`);
811
833
  const vaultExists = existsSync(vaultPath);
812
834
  let needsDownload = !vaultExists;
@@ -901,6 +923,16 @@ async function installSkillStubs(roots = getActiveRoots()) {
901
923
  const vaultFiles = readdirSync(pubVaultDir).filter(f => f.endsWith('.vault'));
902
924
  for (const vaultFile of vaultFiles) {
903
925
  const skillName = vaultFile.replace(/\.vault$/, '');
926
+ // Defense in depth: validate before using as a path component or
927
+ // interpolating into stub frontmatter. Server already validates
928
+ // at publish, sync re-validates, but a buggy/older CLI may have
929
+ // left poisoned files in the vault directory. Skip them rather
930
+ // than letting them reach `join` and writeFileSync.
931
+ if (!isValidSkillName(skillName)) {
932
+ errors.push(`Skipping invalid local skill name: ${JSON.stringify(skillName)}`);
933
+ console.error(`[install] Skipping invalid local skill name: ${JSON.stringify(skillName)}`);
934
+ continue;
935
+ }
904
936
  const vaultPath = join(pubVaultDir, vaultFile);
905
937
  const installStubDir = join(installSkillsDir, skillName);
906
938
  const manifestPath = join(installStubDir, 'manifest.json');
@@ -1008,7 +1040,13 @@ ${multiFileSection}`;
1008
1040
  encrypted: true,
1009
1041
  }, null, 2);
1010
1042
  // Write the stub into this install's claude skills directory.
1011
- mkdirSync(installStubDir, { recursive: true });
1043
+ // ensureSafeDir refuses if the directory already exists as a
1044
+ // symlink (defense against pre-created symlinks pointing at
1045
+ // ~/.ssh, ~/.config, etc).
1046
+ if (!ensureSafeDir(installStubDir)) {
1047
+ errors.push(`Skipping ${skillName}: install dir is a symlink`);
1048
+ continue;
1049
+ }
1012
1050
  writeFileSync(join(installStubDir, 'SKILL.md'), stub, { mode: 0o600 });
1013
1051
  writeFileSync(join(installStubDir, 'manifest.json'), manifestData, { mode: 0o600 });
1014
1052
  if (vaultFileList.length > 0) {
@@ -1018,16 +1056,18 @@ ${multiFileSection}`;
1018
1056
  // and any other detected agent platforms (Cursor, Windsurf, Codex).
1019
1057
  if (isGlobal) {
1020
1058
  const agentSkillDir = join(AGENTS_SKILLS_DIR, skillName);
1021
- mkdirSync(agentSkillDir, { recursive: true });
1022
- writeFileSync(join(agentSkillDir, 'SKILL.md'), stub, { mode: 0o600 });
1023
- writeFileSync(join(agentSkillDir, 'manifest.json'), manifestData, { mode: 0o600 });
1024
- if (vaultFileList.length > 0) {
1025
- writeFileSync(join(agentSkillDir, 'files.json'), JSON.stringify(vaultFileList, null, 2), { mode: 0o600 });
1059
+ if (ensureSafeDir(agentSkillDir)) {
1060
+ writeFileSync(join(agentSkillDir, 'SKILL.md'), stub, { mode: 0o600 });
1061
+ writeFileSync(join(agentSkillDir, 'manifest.json'), manifestData, { mode: 0o600 });
1062
+ if (vaultFileList.length > 0) {
1063
+ writeFileSync(join(agentSkillDir, 'files.json'), JSON.stringify(vaultFileList, null, 2), { mode: 0o600 });
1064
+ }
1026
1065
  }
1027
1066
  for (const platform of detectedPlatforms) {
1028
1067
  const platformSkillDir = join(platform.dir, skillName);
1068
+ if (!ensureSafeDir(platformSkillDir))
1069
+ continue;
1029
1070
  try {
1030
- mkdirSync(platformSkillDir, { recursive: true });
1031
1071
  writeFileSync(join(platformSkillDir, 'SKILL.md'), stub, { mode: 0o600 });
1032
1072
  writeFileSync(join(platformSkillDir, 'manifest.json'), manifestData, { mode: 0o600 });
1033
1073
  if (vaultFileList.length > 0) {
@@ -1991,14 +2031,18 @@ function watermark(content, id, email, publisherName, skillName) {
1991
2031
  const { content: withHeartbeats, heartbeatPair } = watermarkLayer5(result, id, skillName); // stealth heartbeats
1992
2032
  return { content: withHeartbeats, heartbeatPair };
1993
2033
  }
1994
- function validateSkillName(name) {
1995
- return /^[a-zA-Z0-9_-]+$/.test(name) && name.length > 0 && name.length <= 128;
1996
- }
2034
+ // validateSkillName / isValidSkillName are imported from ./skill-name.js
2035
+ // ensureSafeDir is imported from ./safe-dir.js
1997
2036
  /**
1998
2037
  * Quick sync for a single skill — checks for vault update before decrypting.
1999
2038
  * Returns true if the vault was updated. Status goes to stderr.
2000
2039
  */
2001
2040
  async function syncSingleSkill(skillName, pub, config, roots = getActiveRoots()) {
2041
+ // Hard-fail this entry rather than letting an unsafe name reach the
2042
+ // path joins below. Callers already validate at CLI parse time, but
2043
+ // this is the second line of defense if any caller forgets.
2044
+ if (!isValidSkillName(skillName))
2045
+ return false;
2002
2046
  try {
2003
2047
  const capabilityName = `skill/${skillName.toLowerCase()}`;
2004
2048
  const res = await fetch(`${config.api_url}/skills/check-update?capability=${encodeURIComponent(capabilityName)}&current_version=0.0.0`, { signal: AbortSignal.timeout(5000) });
@@ -2054,7 +2098,7 @@ async function backgroundSyncAll(_config, roots = getActiveRoots()) {
2054
2098
  * List files in a skill vault without outputting content.
2055
2099
  */
2056
2100
  async function listSkillFiles(skillName) {
2057
- if (!validateSkillName(skillName)) {
2101
+ if (!isValidSkillName(skillName)) {
2058
2102
  console.error('Error: Invalid skill name.');
2059
2103
  process.exit(1);
2060
2104
  }
@@ -2112,7 +2156,7 @@ async function listSkillFiles(skillName) {
2112
2156
  * Status messages go to stderr so they don't pollute the skill content.
2113
2157
  */
2114
2158
  async function loadSkill(skillName) {
2115
- if (!validateSkillName(skillName)) {
2159
+ if (!isValidSkillName(skillName)) {
2116
2160
  console.error('Error: Invalid skill name. Skill names can only contain letters, numbers, hyphens, and underscores (max 128 chars).');
2117
2161
  console.error('Example: npx skillvault --load my-skill-name');
2118
2162
  process.exit(1);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Symlink-aware directory creation.
3
+ *
4
+ * The customer agent CLI writes skill stubs into well-known paths like
5
+ * `~/.claude/skills/<name>/` and `~/.agents/skills/<name>/`. A local
6
+ * attacker (or another process running as the customer) can pre-create
7
+ * one of those targets as a symbolic link pointing at a sensitive
8
+ * directory — `~/.ssh/`, `~/.config/`, the project root, etc. Without a
9
+ * check, `mkdirSync(target, { recursive: true })` is a no-op when the
10
+ * target already exists, and the subsequent `writeFileSync` follows the
11
+ * symlink and drops attacker-influenced content into the linked target.
12
+ *
13
+ * `ensureSafeDir` refuses that case explicitly:
14
+ *
15
+ * - If the path exists AND is a symlink, return false and log a clear
16
+ * diagnostic. The caller is expected to skip this skill rather than
17
+ * fail the whole sync.
18
+ * - If the path exists AND is a real directory, return true.
19
+ * - If the path does not exist, create it (recursive) and return true.
20
+ * - Any other failure (e.g. EACCES) returns false with a diagnostic.
21
+ *
22
+ * Regression coverage for docs/security-test-outstanding.md § 2.1.
23
+ */
24
+ import { lstatSync, mkdirSync } from 'node:fs';
25
+ const defaultLogger = { error: (m) => console.error(m) };
26
+ export function ensureSafeDir(dirPath, logger = defaultLogger) {
27
+ try {
28
+ const stat = lstatSync(dirPath);
29
+ if (stat.isSymbolicLink()) {
30
+ logger.error(`[install] Refusing to write into symlinked directory: ${dirPath}`);
31
+ logger.error(`[install] If this is intentional, remove the symlink and re-run --sync.`);
32
+ return false;
33
+ }
34
+ }
35
+ catch {
36
+ // Doesn't exist — fall through and create it.
37
+ }
38
+ try {
39
+ mkdirSync(dirPath, { recursive: true });
40
+ return true;
41
+ }
42
+ catch (err) {
43
+ logger.error(`[install] Failed to create ${dirPath}: ${err instanceof Error ? err.message : 'unknown error'}`);
44
+ return false;
45
+ }
46
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Customer-side skill-name validator (mirror of
3
+ * `packages/shared/src/skill-name.ts`).
4
+ *
5
+ * The agent CLI is published as a standalone npm package and does NOT
6
+ * import from `skillvault-shared`, so the validator is duplicated here.
7
+ * Both copies must agree — if you change one, change the other and rerun
8
+ * `packages/shared/test/skill-name.test.ts` plus the agent test below.
9
+ *
10
+ * Why duplicate at all? Defense in depth. The server already rejects
11
+ * malicious names at publish time (see `packages/server/src/routes/skills.ts`),
12
+ * but the customer agent must not assume the server is honest. A
13
+ * compromised server, a downgrade to an older server version, or a
14
+ * locally-poisoned vault directory could otherwise hand the agent a name
15
+ * that traverses out of the vault directory or breaks the stub
16
+ * frontmatter.
17
+ *
18
+ * Allowed: ASCII letters (any case), digits, dashes, underscores.
19
+ * 1–64 characters. Must start with a letter or digit.
20
+ * Rejects: path separators, parent traversal, null bytes, control chars,
21
+ * shell/YAML metacharacters, ANSI escape sequences, non-ASCII, oversized.
22
+ */
23
+ export const MAX_SKILL_NAME_LENGTH = 64;
24
+ const ALLOWED_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
25
+ export class InvalidSkillNameError extends Error {
26
+ reason;
27
+ skillName;
28
+ constructor(reason, skillName) {
29
+ super(`invalid skill name: ${reason}`);
30
+ this.reason = reason;
31
+ this.skillName = skillName;
32
+ this.name = 'InvalidSkillNameError';
33
+ }
34
+ }
35
+ /** Returns true when `name` is safe to use as a path component. */
36
+ export function isValidSkillName(name) {
37
+ if (typeof name !== 'string')
38
+ return false;
39
+ if (name.length === 0 || name.length > MAX_SKILL_NAME_LENGTH)
40
+ return false;
41
+ for (let i = 0; i < name.length; i++) {
42
+ const code = name.charCodeAt(i);
43
+ if (code < 0x20 || code === 0x7f)
44
+ return false;
45
+ }
46
+ if (name.includes('..'))
47
+ return false;
48
+ if (name.includes('/') || name.includes('\\'))
49
+ return false;
50
+ return ALLOWED_PATTERN.test(name);
51
+ }
52
+ /** Throws `InvalidSkillNameError` if `name` is unsafe. */
53
+ export function validateSkillName(name) {
54
+ if (typeof name !== 'string') {
55
+ throw new InvalidSkillNameError('must be a string', String(name));
56
+ }
57
+ if (name.length === 0) {
58
+ throw new InvalidSkillNameError('must not be empty', name);
59
+ }
60
+ if (name.length > MAX_SKILL_NAME_LENGTH) {
61
+ throw new InvalidSkillNameError(`exceeds ${MAX_SKILL_NAME_LENGTH} characters`, name);
62
+ }
63
+ for (let i = 0; i < name.length; i++) {
64
+ const code = name.charCodeAt(i);
65
+ if (code < 0x20 || code === 0x7f) {
66
+ throw new InvalidSkillNameError(`contains control character (0x${code.toString(16).padStart(2, '0')})`, name);
67
+ }
68
+ }
69
+ if (name.includes('..')) {
70
+ throw new InvalidSkillNameError('contains parent-directory segment ".."', name);
71
+ }
72
+ if (name.includes('/') || name.includes('\\')) {
73
+ throw new InvalidSkillNameError('contains a path separator', name);
74
+ }
75
+ if (!ALLOWED_PATTERN.test(name)) {
76
+ throw new InvalidSkillNameError(`must match ${ALLOWED_PATTERN.source}`, name);
77
+ }
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillvault",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "SkillVault — secure skill distribution for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,10 @@
13
13
  "files": [
14
14
  "dist/**/*.js"
15
15
  ],
16
+ "homepage": "https://getskillvault.com",
17
+ "bugs": {
18
+ "url": "https://getskillvault.com"
19
+ },
16
20
  "keywords": [
17
21
  "skillvault",
18
22
  "claude",