@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.
- package/dist/archive-entries.js +59 -0
- package/dist/commands/add.js +16 -18
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/commands/add.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
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