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 +57 -13
- package/dist/credentials.js +65 -3
- package/dist/projects-registry.js +97 -17
- 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.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
|
-
|
|
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/credentials.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
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.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",
|