@ahood/cli 0.12.0 → 0.12.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.
@@ -0,0 +1,59 @@
1
+ import { posix } from "node:path";
2
+ import * as tarStream from "tar-stream";
3
+ import { sanitizeForTerminal } from "./terminal-safe.js";
4
+ // AlexKay28/ahood#792 -- the one archive-entry path rule, mirroring the
5
+ // server's lib/publish/entry-path.ts. `SKILL.md` and `././SKILL.md` used to be
6
+ // two entries to the server (which reads the FIRST) but one file to
7
+ // extractTarGz (resolve() collapsed them and the LAST write won), so a hidden
8
+ // later duplicate could overwrite the reviewed file at install. Every
9
+ // extractor now refuses such an archive up front instead.
10
+ /** True when `path` is already a canonical relative POSIX path: non-empty,
11
+ * no backslash or NUL, not absolute, no empty / "." / ".." segment, and
12
+ * unchanged by posix.normalize. */
13
+ export function isCanonicalEntryPath(path) {
14
+ if (path === "" || path.includes("\\") || path.includes("\0"))
15
+ return false;
16
+ if (posix.isAbsolute(path) || posix.normalize(path) !== path)
17
+ return false;
18
+ return path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
19
+ }
20
+ /** A tar header name relative to the archive root. `tar -C dir .` names
21
+ * every entry `./x`, so exactly ONE leading "./" is dropped; whatever
22
+ * remains must already be canonical (`././x` is rejected, not collapsed). */
23
+ export function tarEntryPath(headerName) {
24
+ return headerName.startsWith("./") ? headerName.slice(2) : headerName;
25
+ }
26
+ /** Walks every header of an already-decompressed tar and throws unless each
27
+ * non-directory entry has a canonical, unique path. Runs before an extractor
28
+ * writes or returns anything, so a bad entry anywhere refuses the whole
29
+ * archive. Directory entries (`./`, `./scripts/`) are not files and are
30
+ * skipped. */
31
+ export async function assertCanonicalEntryPaths(tar) {
32
+ const seen = new Set();
33
+ const extract = tarStream.extract();
34
+ await new Promise((resolvePromise, reject) => {
35
+ extract.on("entry", (header, stream, next) => {
36
+ stream.resume();
37
+ if (header.type === "directory") {
38
+ next();
39
+ return;
40
+ }
41
+ const path = tarEntryPath(header.name);
42
+ const problem = !isCanonicalEntryPath(path)
43
+ ? `Refusing to extract unsafe archive entry: ${sanitizeForTerminal(header.name)} (not a canonical relative path)`
44
+ : seen.has(path)
45
+ ? `Refusing to extract archive: it has more than one entry for "${sanitizeForTerminal(path)}"`
46
+ : null;
47
+ if (problem) {
48
+ reject(new Error(problem));
49
+ extract.destroy();
50
+ return;
51
+ }
52
+ seen.add(path);
53
+ next();
54
+ });
55
+ extract.on("finish", () => resolvePromise());
56
+ extract.on("error", reject);
57
+ extract.end(tar);
58
+ });
59
+ }
@@ -8,6 +8,7 @@ import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, pinnedFileP
8
8
  import { readLockfile, withLock, writeFileAtomic, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
9
9
  import { promptSecret } from "../secret-prompt.js";
10
10
  import { sanitizeForTerminal } from "../terminal-safe.js";
11
+ import { assertCanonicalEntryPaths, tarEntryPath } from "../archive-entries.js";
11
12
  import { UsageError } from "../usage-error.js";
12
13
  import { assertKind, describeKind, isPinnedFileKind, KINDS, strictKind } from "../kinds.js";
13
14
  const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
@@ -92,6 +93,10 @@ export async function extractTarGz(buffer, destDir) {
92
93
  catch (error) {
93
94
  throw new Error(`Archive decompresses to more than ${MAX_EXTRACTED_BYTES / (1024 * 1024)} MB, or is not valid gzip -- refusing to extract.`, { cause: error });
94
95
  }
96
+ // Every entry path is canonical and unique before the first write
97
+ // (AlexKay28/ahood#792): otherwise `././SKILL.md` lands on SKILL.md and the
98
+ // last of the two wins, while the server validated the first.
99
+ await assertCanonicalEntryPaths(decompressed);
95
100
  const extract = tarStream.extract();
96
101
  const resolvedDest = resolve(destDir);
97
102
  let entryCount = 0;
@@ -112,7 +117,7 @@ export async function extractTarGz(buffer, destDir) {
112
117
  // segments (and, on Windows, drive-absolute and backslash-separated
113
118
  // names) so the comparison catches every escape shape, not just a
114
119
  // literal leading "../".
115
- const entryPath = header.name.replace(/^\.\//, "");
120
+ const entryPath = tarEntryPath(header.name);
116
121
  const fullPath = resolve(destDir, entryPath);
117
122
  if (fullPath !== resolvedDest && !fullPath.startsWith(resolvedDest + sep)) {
118
123
  stream.resume();
@@ -198,27 +203,20 @@ export async function extractSingleFileContent(buffer, entryName) {
198
203
  catch (error) {
199
204
  throw new Error(`Archive decompresses to more than ${MAX_EXTRACTED_BYTES / (1024 * 1024)} MB, or is not valid gzip -- refusing to extract.`, { cause: error });
200
205
  }
206
+ // A duplicate or non-canonical entry anywhere refuses the archive, rather
207
+ // than this reader and extractTarGz picking different copies of one file
208
+ // (AlexKay28/ahood#792; this used to take the first, extractTarGz the last).
209
+ await assertCanonicalEntryPaths(decompressed);
201
210
  const extract = tarStream.extract();
202
211
  let found = null;
203
212
  await new Promise((resolvePromise, reject) => {
204
213
  extract.on("entry", (header, stream, next) => {
205
- // Normalize a leading "./" the same way extractTarGz and the server's
206
- // extract-and-checksum.ts normalizeEntryPath do -- a plain `tar czf`
207
- // commonly prefixes entries with "./", which would otherwise pass
208
- // server-side validation/publish but then fail to install here with
209
- // "AGENT.md not found" (ahood-cli final review finding #3).
210
- const normalizedName = header.name.replace(/^\.\//, "");
211
- if (normalizedName !== entryName) {
212
- stream.resume();
213
- next();
214
- return;
215
- }
216
- // Only take the FIRST matching entry, mirroring the server side's
217
- // `files.find(...)` lookup -- otherwise a tar with two entries at the
218
- // same normalized name would resolve to whichever one happens to come
219
- // last here, diverging from what the server validated against
220
- // (ahood-cli final review finding #4).
221
- if (found !== null) {
214
+ // Drop tar's one leading "./" the same way extractTarGz and the server
215
+ // do -- a plain `tar czf` commonly prefixes entries with "./", which
216
+ // would otherwise pass server-side validation/publish but then fail to
217
+ // install here with "AGENT.md not found" (ahood-cli final review
218
+ // finding #3).
219
+ if (header.type === "directory" || tarEntryPath(header.name) !== entryName) {
222
220
  stream.resume();
223
221
  next();
224
222
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",