skillvault 0.11.1 → 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.1';
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);
@@ -12,10 +12,63 @@
12
12
  * <roots.configDir>/project.json (per install, project- or global-scoped)
13
13
  * Active publishers in this specific install plus install metadata.
14
14
  */
15
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
15
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, openSync, closeSync, unlinkSync, } from 'node:fs';
16
16
  import { join, dirname } from 'node:path';
17
17
  import { homedir } from 'node:os';
18
18
  import { credentialsPath } from './scope.js';
19
+ /**
20
+ * Run `fn` while holding an exclusive advisory lock on `<targetPath>.lock`.
21
+ * Same primitive as projects-registry.ts withFileLock — duplicated here so
22
+ * credentials.ts can be locked without crossing module boundaries. See that
23
+ * file for the design notes (POSIX O_EXCL semantics, busy-wait backoff,
24
+ * 1s stale-lock reclaim).
25
+ */
26
+ function withFileLock(targetPath, fn) {
27
+ const lockPath = targetPath + '.lock';
28
+ mkdirSync(dirname(lockPath), { recursive: true });
29
+ const start = Date.now();
30
+ const TIMEOUT_MS = 1000;
31
+ let fd = null;
32
+ while (fd === null) {
33
+ try {
34
+ fd = openSync(lockPath, 'wx', 0o600);
35
+ }
36
+ catch (err) {
37
+ const code = err.code;
38
+ if (code !== 'EEXIST')
39
+ throw err;
40
+ if (Date.now() - start > TIMEOUT_MS) {
41
+ try {
42
+ unlinkSync(lockPath);
43
+ }
44
+ catch { }
45
+ try {
46
+ fd = openSync(lockPath, 'wx', 0o600);
47
+ }
48
+ catch {
49
+ throw new Error(`Could not acquire lock at ${lockPath} after ${TIMEOUT_MS}ms`);
50
+ }
51
+ break;
52
+ }
53
+ const buf = new SharedArrayBuffer(4);
54
+ const view = new Int32Array(buf);
55
+ Atomics.wait(view, 0, 0, 5 + Math.floor(Math.random() * 10));
56
+ }
57
+ }
58
+ try {
59
+ return fn();
60
+ }
61
+ finally {
62
+ try {
63
+ closeSync(fd);
64
+ }
65
+ catch { }
66
+ try {
67
+ unlinkSync(lockPath);
68
+ }
69
+ catch { }
70
+ }
71
+ }
19
72
  /**
20
73
  * Load customer credentials from ~/.skillvault/credentials.json.
21
74
  *
@@ -75,11 +128,20 @@ export function loadCredentials() {
75
128
  }
76
129
  /**
77
130
  * Persist customer credentials to ~/.skillvault/credentials.json with mode 0600.
131
+ *
132
+ * Wrapped in withFileLock so two concurrent invites with different invite
133
+ * codes can't race and silently drop one of each other's publishers from
134
+ * the publishers[] array. The caller is expected to read-modify-write the
135
+ * full credentials object themselves; this function only serializes the
136
+ * write step. For full read-modify-write atomicity, callers should hold
137
+ * the lock across both load + save (see redeemInvite() in cli.ts).
78
138
  */
79
139
  export function saveCredentials(creds) {
80
140
  const path = credentialsPath();
81
- mkdirSync(dirname(path), { recursive: true });
82
- writeFileSync(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
141
+ withFileLock(path, () => {
142
+ mkdirSync(dirname(path), { recursive: true });
143
+ writeFileSync(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
144
+ });
83
145
  }
84
146
  function projectConfigPath(roots) {
85
147
  return join(roots.configDir, 'project.json');
@@ -6,10 +6,80 @@
6
6
  * `getCurrentProject()` to figure out whether the current CWD already has a
7
7
  * registered SkillVault install.
8
8
  */
9
- import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
9
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync, openSync, closeSync, unlinkSync, } from 'node:fs';
10
10
  import { dirname, resolve } from 'node:path';
11
11
  import { projectsRegistryPath } from './scope.js';
12
12
  const REGISTRY_VERSION = 1;
13
+ /**
14
+ * Run `fn` while holding an exclusive advisory lock on `<targetPath>.lock`.
15
+ *
16
+ * Uses `openSync(..., 'wx')` to atomically create the lockfile (POSIX
17
+ * O_EXCL semantics — exactly one process succeeds, the rest get EEXIST).
18
+ * On contention, busy-waits with a small backoff up to ~1s before giving
19
+ * up. The lockfile is unlinked on completion (success or thrown error)
20
+ * via try/finally so a crash mid-operation can't permanently wedge
21
+ * subsequent invocations — though if a process is SIGKILL'd between the
22
+ * openSync and the closeSync, the lockfile will linger and the next
23
+ * caller will time out and either inherit the stale lock (forced) or
24
+ * throw. For our use case (a single-user CLI doing read-modify-write on
25
+ * a small JSON file), 1s is plenty.
26
+ *
27
+ * Used by registerProject + unregisterProject to fix the lost-update race
28
+ * when two `--invite` commands run concurrently against the same
29
+ * ~/.skillvault/projects.json (audit scenario 12-concurrent-install).
30
+ */
31
+ function withFileLock(targetPath, fn) {
32
+ const lockPath = targetPath + '.lock';
33
+ mkdirSync(dirname(lockPath), { recursive: true });
34
+ const start = Date.now();
35
+ const TIMEOUT_MS = 1000;
36
+ let fd = null;
37
+ while (fd === null) {
38
+ try {
39
+ fd = openSync(lockPath, 'wx', 0o600);
40
+ }
41
+ catch (err) {
42
+ const code = err.code;
43
+ if (code !== 'EEXIST')
44
+ throw err;
45
+ if (Date.now() - start > TIMEOUT_MS) {
46
+ // Stale lock — assume the previous holder crashed and reclaim it.
47
+ try {
48
+ unlinkSync(lockPath);
49
+ }
50
+ catch { }
51
+ // Loop one more time, but if it still fails, throw.
52
+ try {
53
+ fd = openSync(lockPath, 'wx', 0o600);
54
+ }
55
+ catch {
56
+ throw new Error(`Could not acquire lock at ${lockPath} after ${TIMEOUT_MS}ms`);
57
+ }
58
+ break;
59
+ }
60
+ // Busy-wait briefly. The Node.js event loop is single-threaded so
61
+ // we can't yield to a peer process via Promise without making the
62
+ // function async — and registerProject is sync. Atomics.wait on a
63
+ // SharedArrayBuffer is the cleanest sync sleep:
64
+ const buf = new SharedArrayBuffer(4);
65
+ const view = new Int32Array(buf);
66
+ Atomics.wait(view, 0, 0, 5 + Math.floor(Math.random() * 10));
67
+ }
68
+ }
69
+ try {
70
+ return fn();
71
+ }
72
+ finally {
73
+ try {
74
+ closeSync(fd);
75
+ }
76
+ catch { }
77
+ try {
78
+ unlinkSync(lockPath);
79
+ }
80
+ catch { }
81
+ }
82
+ }
13
83
  function readRegistry() {
14
84
  const path = projectsRegistryPath();
15
85
  if (!existsSync(path))
@@ -75,29 +145,39 @@ export function listProjects() {
75
145
  /**
76
146
  * Insert or update a project entry. Matching is by absolute path; if an entry
77
147
  * with the same path already exists, it is replaced.
148
+ *
149
+ * The read-modify-write is wrapped in withFileLock so two concurrent
150
+ * `--invite` calls can't race and lose one of each other's updates.
78
151
  */
79
152
  export function registerProject(entry) {
80
- const reg = readRegistry();
81
- const normalized = { ...entry, path: resolve(entry.path) };
82
- const idx = reg.projects.findIndex((p) => resolve(p.path) === normalized.path);
83
- if (idx >= 0) {
84
- reg.projects[idx] = normalized;
85
- }
86
- else {
87
- reg.projects.push(normalized);
88
- }
89
- writeRegistry(reg);
153
+ withFileLock(projectsRegistryPath(), () => {
154
+ const reg = readRegistry();
155
+ const normalized = { ...entry, path: resolve(entry.path) };
156
+ const idx = reg.projects.findIndex((p) => resolve(p.path) === normalized.path);
157
+ if (idx >= 0) {
158
+ reg.projects[idx] = normalized;
159
+ }
160
+ else {
161
+ reg.projects.push(normalized);
162
+ }
163
+ writeRegistry(reg);
164
+ });
90
165
  }
91
166
  /**
92
167
  * Remove the project entry whose path matches. No-op if not present.
168
+ *
169
+ * Wrapped in withFileLock for the same reason as registerProject — a
170
+ * concurrent uninstall + register would otherwise race.
93
171
  */
94
172
  export function unregisterProject(path) {
95
- const reg = readRegistry();
96
- const target = resolve(path);
97
- const before = reg.projects.length;
98
- reg.projects = reg.projects.filter((p) => resolve(p.path) !== target);
99
- if (reg.projects.length !== before)
100
- writeRegistry(reg);
173
+ withFileLock(projectsRegistryPath(), () => {
174
+ const reg = readRegistry();
175
+ const target = resolve(path);
176
+ const before = reg.projects.length;
177
+ reg.projects = reg.projects.filter((p) => resolve(p.path) !== target);
178
+ if (reg.projects.length !== before)
179
+ writeRegistry(reg);
180
+ });
101
181
  }
102
182
  /**
103
183
  * Look up the registry entry that matches the current working directory (or
@@ -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.1",
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",