@lifeaitools/rdc-skills 0.13.3 → 0.13.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -7,7 +7,7 @@ import { execSync, spawnSync } from 'node:child_process';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, copyFileSync } from 'node:fs';
9
9
  import { homedir, tmpdir } from 'node:os';
10
- import { dirname, basename, extname, join, resolve, relative } from 'node:path';
10
+ import { dirname, basename, extname, join, resolve, relative, sep } from 'node:path';
11
11
  import { pathToFileURL, fileURLToPath } from 'node:url';
12
12
  import { createRequire } from 'node:module';
13
13
 
@@ -142,15 +142,48 @@ function loadAdmZip() {
142
142
  try { return createRequire(join(cacheDir, 'package.json'))('adm-zip'); } catch { return null; }
143
143
  }
144
144
 
145
+ // Reject any archive member that resolves outside destDir (zip-slip / path
146
+ // traversal). Returns the validated absolute destination path. Throws on escape.
147
+ function safeJoinOrThrow(destDir, entryName) {
148
+ const root = resolve(destDir);
149
+ const dest = resolve(root, entryName);
150
+ if (dest !== root && !dest.startsWith(root + sep)) {
151
+ throw new Error(`zip-slip: entry "${entryName}" escapes destination directory`);
152
+ }
153
+ return dest;
154
+ }
155
+
145
156
  function extractZip(zipPath, destDir) {
146
157
  mkdirSync(destDir, { recursive: true });
158
+ const root = resolve(destDir);
147
159
  const AdmZip = loadAdmZip();
148
160
  if (AdmZip) {
149
- new AdmZip(zipPath).extractAllTo(destDir, /* overwrite */ true);
161
+ const zip = new AdmZip(zipPath);
162
+ const entries = zip.getEntries();
163
+ // First pass: validate every entry resolves inside destDir. A brochure
164
+ // source zip must never contain `..` or absolute members — reject the whole
165
+ // archive rather than silently skipping (fail-closed).
166
+ for (const entry of entries) {
167
+ safeJoinOrThrow(root, entry.entryName);
168
+ }
169
+ // Second pass: extract validated entries individually. Do NOT use the
170
+ // blanket extractAllTo — adm-zip does not sanitize entry paths.
171
+ for (const entry of entries) {
172
+ if (entry.isDirectory) {
173
+ mkdirSync(safeJoinOrThrow(root, entry.entryName), { recursive: true });
174
+ continue;
175
+ }
176
+ const dest = safeJoinOrThrow(root, entry.entryName);
177
+ mkdirSync(dirname(dest), { recursive: true });
178
+ zip.extractEntryTo(entry, root, /* maintainEntryPath */ true, /* overwrite */ true);
179
+ }
150
180
  return;
151
181
  }
152
- // Fallback: platform-native extractor.
182
+ // Fallback: platform-native extractor. Validate members via a listing pass
183
+ // BEFORE extracting, since the bulk extract commands cannot apply per-entry
184
+ // guards. Reject any `..` segment or absolute/leading-slash member.
153
185
  log('adm-zip unavailable — falling back to platform unzip');
186
+ assertNativeZipSafe(zipPath);
154
187
  let r;
155
188
  if (process.platform === 'win32') {
156
189
  r = spawnSync('powershell', ['-NoProfile', '-Command',
@@ -161,6 +194,37 @@ function extractZip(zipPath, destDir) {
161
194
  if (!r || r.status !== 0) throw new Error('unzip failed (no pure-Node lib and platform extractor failed)');
162
195
  }
163
196
 
197
+ // List archive members with a native tool and reject any traversal/absolute
198
+ // entry. Throws if a member is unsafe or the listing cannot be produced.
199
+ function assertNativeZipSafe(zipPath) {
200
+ let names = [];
201
+ if (process.platform === 'win32') {
202
+ const ps = spawnSync('powershell', ['-NoProfile', '-Command',
203
+ `Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
204
+ `[System.IO.Compression.ZipFile]::OpenRead('${zipPath}').Entries | ForEach-Object { $_.FullName }`],
205
+ { encoding: 'utf8' });
206
+ if (ps.status !== 0) throw new Error('cannot list zip members for traversal check (PowerShell)');
207
+ names = String(ps.stdout || '').split(/\r?\n/);
208
+ } else {
209
+ const zi = spawnSync('zipinfo', ['-1', zipPath], { encoding: 'utf8' });
210
+ if (zi.status === 0) {
211
+ names = String(zi.stdout || '').split(/\r?\n/);
212
+ } else {
213
+ const ul = spawnSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' });
214
+ if (ul.status !== 0) throw new Error('cannot list zip members for traversal check (unzip -Z1)');
215
+ names = String(ul.stdout || '').split(/\r?\n/);
216
+ }
217
+ }
218
+ for (const raw of names) {
219
+ const name = raw.trim();
220
+ if (!name) continue;
221
+ const norm = name.replace(/\\/g, '/');
222
+ if (norm.startsWith('/') || /^[A-Za-z]:/.test(norm) || norm.split('/').some((seg) => seg === '..')) {
223
+ throw new Error(`zip-slip: entry "${name}" escapes destination directory`);
224
+ }
225
+ }
226
+ }
227
+
164
228
  // --- stage input -----------------------------------------------------------
165
229
  function stageInput() {
166
230
  const stageDir = join(workRoot, 'src');