skillvault 0.11.2 → 0.13.1
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 +57 -13
- package/dist/safe-dir.js +46 -0
- package/dist/skill-name.js +78 -0
- package/package.json +5 -1
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.
|
|
29
|
+
const VERSION = '0.13.1';
|
|
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
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1995
|
-
|
|
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)}¤t_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 (!
|
|
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 (!
|
|
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);
|
package/dist/safe-dir.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.13.1",
|
|
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",
|