activate-agentmd 2.4.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/README.md +158 -0
- package/bin/agentmd.js +186 -0
- package/package.json +55 -0
- package/src/commands/agents.js +44 -0
- package/src/commands/analytics.js +128 -0
- package/src/commands/auth.js +144 -0
- package/src/commands/ci.js +296 -0
- package/src/commands/extract.js +254 -0
- package/src/commands/info.js +105 -0
- package/src/commands/init.js +166 -0
- package/src/commands/install.js +216 -0
- package/src/commands/link.js +206 -0
- package/src/commands/list.js +99 -0
- package/src/commands/outdated.js +92 -0
- package/src/commands/remove.js +72 -0
- package/src/commands/review.js +293 -0
- package/src/commands/search.js +110 -0
- package/src/commands/sync.js +245 -0
- package/src/commands/telemetry.js +82 -0
- package/src/commands/test.js +226 -0
- package/src/commands/update.js +115 -0
- package/src/commands/validate.js +125 -0
- package/src/config/license-public-keys.json +17 -0
- package/src/detect.json +452 -0
- package/src/lib/agents.js +301 -0
- package/src/lib/analytics.js +137 -0
- package/src/lib/coordinates.js +103 -0
- package/src/lib/credentials.js +115 -0
- package/src/lib/detect.js +352 -0
- package/src/lib/diff.js +142 -0
- package/src/lib/enterprise.js +140 -0
- package/src/lib/fetcher.js +174 -0
- package/src/lib/invocation.js +47 -0
- package/src/lib/license.js +234 -0
- package/src/lib/manifest.js +191 -0
- package/src/lib/patterns.js +135 -0
- package/src/lib/pro.js +149 -0
- package/src/lib/ranking.js +287 -0
- package/src/lib/registry.js +207 -0
- package/src/lib/star.js +105 -0
- package/src/lib/status.js +75 -0
- package/src/lib/telemetry.js +169 -0
- package/src/lib/versions.js +190 -0
- package/src/registry.json +33907 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetcher.js
|
|
3
|
+
*
|
|
4
|
+
* Fetches preset markdown from the registry over HTTPS.
|
|
5
|
+
* Uses Node's built-in https module — zero extra dependencies.
|
|
6
|
+
*
|
|
7
|
+
* This is a supply-chain-sensitive path: the bytes it returns end up in a
|
|
8
|
+
* file that a developer's coding agent will read as instructions. It is
|
|
9
|
+
* deliberately strict about what it will talk to and how much it will accept.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
const https = require("https");
|
|
15
|
+
|
|
16
|
+
const TIMEOUT_MS = Number(process.env.AGENTMD_TIMEOUT_MS) || 30000;
|
|
17
|
+
const MAX_REDIRECTS = 5;
|
|
18
|
+
const MAX_BYTES = 2 * 1024 * 1024; // presets are markdown; the largest today is ~40 KB
|
|
19
|
+
const RETRIES = 2;
|
|
20
|
+
/**
|
|
21
|
+
* A ceiling on the whole request, headers and body together.
|
|
22
|
+
* `req.setTimeout` only fires on socket inactivity, so a server that dribbles
|
|
23
|
+
* one byte at a time keeps resetting it and the CLI hangs indefinitely.
|
|
24
|
+
*/
|
|
25
|
+
const DEADLINE_MS = TIMEOUT_MS * 2;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shared keep-alive agent.
|
|
29
|
+
*
|
|
30
|
+
* `agentmd init` opens ~30 connections in a row. Without pooling, each one
|
|
31
|
+
* pays a fresh DNS + TLS handshake, and a burst of simultaneous cold
|
|
32
|
+
* handshakes was reliably timing out the first batch of `outdated`. Reusing a
|
|
33
|
+
* small pool of sockets fixes that and is kinder to the registry host.
|
|
34
|
+
*/
|
|
35
|
+
const agent = new https.Agent({ keepAlive: true, maxSockets: 6 });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fetch text over HTTPS.
|
|
39
|
+
*
|
|
40
|
+
* Refuses plaintext HTTP outright (including via redirect), so a hijacked
|
|
41
|
+
* redirect cannot downgrade the transport and inject preset content.
|
|
42
|
+
* Bounded by a timeout, a redirect limit and a response size cap so a hung or
|
|
43
|
+
* hostile server cannot wedge the CLI or exhaust memory.
|
|
44
|
+
*/
|
|
45
|
+
function fetchOnce(url, redirectsLeft = MAX_REDIRECTS) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = new URL(url);
|
|
50
|
+
} catch {
|
|
51
|
+
return reject(new Error(`Invalid registry URL: ${url}`));
|
|
52
|
+
}
|
|
53
|
+
if (parsed.protocol !== "https:") {
|
|
54
|
+
return reject(
|
|
55
|
+
new Error(`Refusing to fetch preset content over ${parsed.protocol}// — HTTPS is required (${url})`)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const req = https.get(parsed, { agent, headers: { "user-agent": "activate-agentmd" } }, (res) => {
|
|
60
|
+
const { statusCode, headers } = res;
|
|
61
|
+
|
|
62
|
+
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
|
63
|
+
res.resume(); // drain so the socket can be reused
|
|
64
|
+
if (redirectsLeft <= 0) {
|
|
65
|
+
return reject(new Error(`Too many redirects fetching ${url}`));
|
|
66
|
+
}
|
|
67
|
+
const next = new URL(headers.location, parsed).toString();
|
|
68
|
+
return fetchOnce(next, redirectsLeft - 1).then(resolve, reject);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (statusCode !== 200) {
|
|
72
|
+
res.resume();
|
|
73
|
+
return reject(new Error(`HTTP ${statusCode} fetching ${url}`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const chunks = [];
|
|
77
|
+
let bytes = 0;
|
|
78
|
+
res.setTimeout(TIMEOUT_MS, () => {
|
|
79
|
+
req.destroy(new Error(`Stalled after ${TIMEOUT_MS}ms while reading ${url}`));
|
|
80
|
+
});
|
|
81
|
+
res.on("data", (chunk) => {
|
|
82
|
+
bytes += chunk.length;
|
|
83
|
+
if (bytes > MAX_BYTES) {
|
|
84
|
+
req.destroy();
|
|
85
|
+
return reject(new Error(`Preset at ${url} exceeds the ${MAX_BYTES / 1024 / 1024} MB limit`));
|
|
86
|
+
}
|
|
87
|
+
chunks.push(chunk);
|
|
88
|
+
});
|
|
89
|
+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
90
|
+
res.on("error", reject);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
req.setTimeout(TIMEOUT_MS, () => {
|
|
94
|
+
req.destroy(new Error(`Timed out after ${TIMEOUT_MS}ms fetching ${url}`));
|
|
95
|
+
});
|
|
96
|
+
const deadline = setTimeout(() => {
|
|
97
|
+
req.destroy(new Error(`Timed out after ${DEADLINE_MS}ms fetching ${url} (response never completed)`));
|
|
98
|
+
}, DEADLINE_MS);
|
|
99
|
+
if (typeof deadline.unref === "function") deadline.unref();
|
|
100
|
+
const done = () => clearTimeout(deadline);
|
|
101
|
+
req.on("close", done);
|
|
102
|
+
req.on("error", (err) => { done(); reject(err); });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Fetch with retries.
|
|
108
|
+
*
|
|
109
|
+
* Retries only transient failures — a timeout or a dropped connection. An
|
|
110
|
+
* HTTP 404 means the package genuinely isn't there, and retrying it three
|
|
111
|
+
* times just makes the user wait longer for the same error.
|
|
112
|
+
*/
|
|
113
|
+
async function fetchText(url) {
|
|
114
|
+
try {
|
|
115
|
+
return await fetchWithRetries(url);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// registry.js is required lazily: it imports the package index, which the
|
|
118
|
+
// fetcher's unit tests never need.
|
|
119
|
+
const { RAW_BASE, RAW_FALLBACK } = require("./registry");
|
|
120
|
+
if (RAW_FALLBACK && /HTTP 404/.test(err.message) && url.startsWith(RAW_BASE)) {
|
|
121
|
+
if (!warnedFallback) {
|
|
122
|
+
warnedFallback = true;
|
|
123
|
+
process.stderr.write(` ! ${RAW_BASE} is missing a file; falling back to main. Update the CLI when the next release lands.\n`);
|
|
124
|
+
}
|
|
125
|
+
return fetchWithRetries(RAW_FALLBACK + url.slice(RAW_BASE.length));
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let warnedFallback = false;
|
|
132
|
+
|
|
133
|
+
async function fetchWithRetries(url) {
|
|
134
|
+
let lastError;
|
|
135
|
+
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
|
136
|
+
try {
|
|
137
|
+
return await fetchOnce(url);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
lastError = err;
|
|
140
|
+
const transient = /timed out|stalled|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|ENOTFOUND|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH/i.test(err.message);
|
|
141
|
+
if (!transient || attempt === RETRIES) throw explain(err, url);
|
|
142
|
+
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw explain(lastError, url);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Turn a Node network error into something a developer can act on. Raw
|
|
150
|
+
* `getaddrinfo EAI_AGAIN` tells a user nothing about which knob to turn, and
|
|
151
|
+
* the two knobs that exist (a proxy, and AGENTMD_TIMEOUT_MS) are not
|
|
152
|
+
* discoverable from the stack trace.
|
|
153
|
+
*/
|
|
154
|
+
function explain(err, url) {
|
|
155
|
+
const host = (() => { try { return new URL(url).host; } catch { return "the registry"; } })();
|
|
156
|
+
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
|
157
|
+
const hint = (text) => Object.assign(new Error(text), { cause: err, url });
|
|
158
|
+
|
|
159
|
+
if (/ENOTFOUND|EAI_AGAIN/i.test(err.message)) {
|
|
160
|
+
return hint(`Cannot resolve ${host}. Check your network or DNS${proxy ? "" : "; behind a proxy, set HTTPS_PROXY"}.`);
|
|
161
|
+
}
|
|
162
|
+
if (/ECONNREFUSED|EHOSTUNREACH|ENETUNREACH/i.test(err.message)) {
|
|
163
|
+
return hint(`Cannot reach ${host}. Check your network${proxy ? ` and HTTPS_PROXY (${proxy})` : " or firewall"}.`);
|
|
164
|
+
}
|
|
165
|
+
if (/timed out|stalled/i.test(err.message)) {
|
|
166
|
+
return hint(`${err.message.replace(/ fetching .*/, "")} fetching from ${host}. Raise AGENTMD_TIMEOUT_MS (currently ${TIMEOUT_MS}) on a slow link.`);
|
|
167
|
+
}
|
|
168
|
+
if (/certificate|self.signed|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(err.message)) {
|
|
169
|
+
return hint(`TLS verification failed for ${host}: ${err.message}. A corporate proxy needs NODE_EXTRA_CA_CERTS pointed at its root certificate.`);
|
|
170
|
+
}
|
|
171
|
+
return err;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { fetchText, fetchOnce, explain, TIMEOUT_MS, DEADLINE_MS, MAX_BYTES, MAX_REDIRECTS, RETRIES };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* invocation.js
|
|
3
|
+
*
|
|
4
|
+
* Works out how the user invoked this CLI, so the commands it suggests are
|
|
5
|
+
* ones they can actually paste back.
|
|
6
|
+
*
|
|
7
|
+
* This matters more than it sounds. `npx activate-agentmd` does not put `agentmd`
|
|
8
|
+
* on PATH — npm caches the package under ~/.npm/_npx/<hash>/ and runs it from
|
|
9
|
+
* there. So a first-time user who ran `npx activate-agentmd init`, then followed
|
|
10
|
+
* the printed "Next: agentmd link", got `command not found` immediately after
|
|
11
|
+
* their first success.
|
|
12
|
+
*
|
|
13
|
+
* Every hint in every command goes through here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* npm caches npx packages under a `_npx` directory; nothing else uses it.
|
|
20
|
+
*
|
|
21
|
+
* Both separators are normalized regardless of the host platform. Keying off
|
|
22
|
+
* path.sep would mean a Windows-style path is only recognised when running on
|
|
23
|
+
* Windows, which makes the behaviour untestable anywhere else and silently
|
|
24
|
+
* wrong for any mixed-separator path.
|
|
25
|
+
*/
|
|
26
|
+
function isNpx(scriptPath = process.argv[1] || "") {
|
|
27
|
+
const normalized = String(scriptPath).replace(/\\/g, "/");
|
|
28
|
+
return /\/_npx\//.test(normalized) || process.env.npm_command === "exec";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The prefix to put in front of a subcommand.
|
|
33
|
+
* "npx activate-agentmd" when run through npx, otherwise the plain binary name
|
|
34
|
+
* (a global install, a devDependency run via npm scripts, or ./node_modules/.bin).
|
|
35
|
+
*/
|
|
36
|
+
function invocation() {
|
|
37
|
+
if (process.env.AGENTMD_INVOCATION) return process.env.AGENTMD_INVOCATION;
|
|
38
|
+
return isNpx() ? "npx activate-agentmd" : "agentmd";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Format a runnable command string, e.g. cmd("link") -> "npx activate-agentmd link". */
|
|
42
|
+
function cmd(subcommand = "") {
|
|
43
|
+
const prefix = invocation();
|
|
44
|
+
return subcommand ? `${prefix} ${subcommand}` : prefix;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { invocation, cmd, isNpx };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* license.js
|
|
3
|
+
*
|
|
4
|
+
* Offline verification of Agent.md Pro license keys.
|
|
5
|
+
*
|
|
6
|
+
* A key is a signed claim, not a lookup token:
|
|
7
|
+
*
|
|
8
|
+
* agmd_<base64url(payload JSON)>.<base64url(Ed25519 signature)>
|
|
9
|
+
*
|
|
10
|
+
* The CLI ships a **keyset** of trusted public keys and verifies the signature
|
|
11
|
+
* locally, so `agentmd extract` works on a plane, in a CI runner with no
|
|
12
|
+
* egress, and behind a proxy that blocks everything but npm. No call home on
|
|
13
|
+
* the hot path, nothing to rate-limit, and no way to forge a key without a
|
|
14
|
+
* private half.
|
|
15
|
+
*
|
|
16
|
+
* Why a keyset rather than one key: a single embedded key cannot be rotated
|
|
17
|
+
* without breaking every license already issued, and cannot be revoked at all
|
|
18
|
+
* — a compromise would mean shipping a new CLI and asking every customer to
|
|
19
|
+
* upgrade before anything improved. With key ids:
|
|
20
|
+
*
|
|
21
|
+
* - each license names the key that signed it (`kid`)
|
|
22
|
+
* - adding a key invalidates nothing
|
|
23
|
+
* - retiring a key stops it signing while it keeps verifying (rotation grace)
|
|
24
|
+
* - revoking a key kills every license under it at the next CLI update
|
|
25
|
+
*
|
|
26
|
+
* Licenses issued before `kid` existed carry none, and verify against the
|
|
27
|
+
* keyset's `legacyKid`. They keep working.
|
|
28
|
+
*
|
|
29
|
+
* The trade offline verification makes is revocation latency: a revoked key
|
|
30
|
+
* stops being trusted when the user updates the CLI, not the instant we say
|
|
31
|
+
* so. Keys are therefore dated a year at most. An online revocation check is
|
|
32
|
+
* the obvious next step and is deliberately not here yet, rather than being
|
|
33
|
+
* half-built and unreliable.
|
|
34
|
+
*
|
|
35
|
+
* Ed25519 is in Node's crypto core, so this costs no dependency.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
"use strict";
|
|
39
|
+
|
|
40
|
+
const crypto = require("crypto");
|
|
41
|
+
const fs = require("fs");
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The trusted keyset, generated from config/license-public-keys.json by
|
|
45
|
+
* scripts/build-keyset.js. Public halves only.
|
|
46
|
+
*/
|
|
47
|
+
const BUNDLED = require("../config/license-public-keys.json");
|
|
48
|
+
|
|
49
|
+
const PREFIX = "agmd_";
|
|
50
|
+
const b64url = {
|
|
51
|
+
encode: (buf) => Buffer.from(buf).toString("base64url"),
|
|
52
|
+
decode: (str) => Buffer.from(str, "base64url"),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Plans that unlock the gated commands, cheapest first. */
|
|
56
|
+
const PAID_PLANS = ["pro", "team", "enterprise"];
|
|
57
|
+
|
|
58
|
+
/** Statuses that can still verify. `revoked` is the whole point of the field. */
|
|
59
|
+
const VERIFIES = new Set(["active", "retired"]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The keyset in force.
|
|
63
|
+
*
|
|
64
|
+
* Two overrides, both for self-hosting rather than convenience — an enterprise
|
|
65
|
+
* running its own registry signs its own licenses:
|
|
66
|
+
*
|
|
67
|
+
* AGENTMD_LICENSE_KEYSET path to a keyset JSON file, or inline JSON
|
|
68
|
+
* AGENTMD_LICENSE_PUBLIC_KEY one bare public key (kept from before keysets)
|
|
69
|
+
*
|
|
70
|
+
* A broken override falls back to the bundled keyset rather than failing open
|
|
71
|
+
* or closed silently: a typo in an env var must not hand someone Pro, and must
|
|
72
|
+
* not lock a paying customer out either.
|
|
73
|
+
*/
|
|
74
|
+
function keyset() {
|
|
75
|
+
const inline = process.env.AGENTMD_LICENSE_KEYSET;
|
|
76
|
+
if (inline) {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = inline.trim().startsWith("{") ? JSON.parse(inline) : JSON.parse(fs.readFileSync(inline, "utf-8"));
|
|
79
|
+
if (parsed && Array.isArray(parsed.keys) && parsed.keys.length) return parsed;
|
|
80
|
+
} catch {
|
|
81
|
+
// unreadable or malformed — fall through to the bundled keyset
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const single = process.env.AGENTMD_LICENSE_PUBLIC_KEY;
|
|
86
|
+
if (single) {
|
|
87
|
+
const kid = process.env.AGENTMD_LICENSE_KID || "env";
|
|
88
|
+
return { schema: 1, defaultKid: kid, legacyKid: kid, keys: [{ kid, alg: "ed25519", status: "active", publicKey: single }] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return BUNDLED;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The key record for a kid, or null. */
|
|
95
|
+
function findKey(kid, set = keyset()) {
|
|
96
|
+
return set.keys.find((k) => k.kid === kid) || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* May this kid be used to *sign* a new license?
|
|
101
|
+
*
|
|
102
|
+
* Verifying and signing are deliberately different questions. A `retired` key
|
|
103
|
+
* still verifies — licenses issued under it are in customers' hands — but
|
|
104
|
+
* signing anything new with it would extend the life of a key we have already
|
|
105
|
+
* decided to stop using, and the customer would have no way to tell. Only an
|
|
106
|
+
* `active` key signs.
|
|
107
|
+
*
|
|
108
|
+
* Returns `{ ok: true }` or `{ ok: false, reason }`, phrased for an operator
|
|
109
|
+
* at a terminal rather than for a user.
|
|
110
|
+
*/
|
|
111
|
+
function canSign(kid, set = keyset()) {
|
|
112
|
+
const record = findKey(kid, set);
|
|
113
|
+
if (!record) {
|
|
114
|
+
return { ok: false, reason: `\`${kid}\` is not in config/license-public-keys.json. Add it, run node scripts/build-keyset.js, and ship that CLI before issuing under it.` };
|
|
115
|
+
}
|
|
116
|
+
if (record.status === "revoked") {
|
|
117
|
+
return { ok: false, reason: `\`${kid}\` is revoked. Anything signed with it is void on sight — issuing under it would produce a dead license.` };
|
|
118
|
+
}
|
|
119
|
+
if (record.status !== "active") {
|
|
120
|
+
return { ok: false, reason: `\`${kid}\` is \`${record.status}\`, not \`active\`. A retired key still verifies the licenses already issued under it, but must not sign new ones.` };
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, record };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function publicKeyOf(record) {
|
|
126
|
+
return crypto.createPublicKey({ key: Buffer.from(record.publicKey, "base64"), format: "der", type: "spki" });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Verify a key and return its claims.
|
|
131
|
+
*
|
|
132
|
+
* Returns `{ valid: true, claims, kid }` or `{ valid: false, reason }`. Never
|
|
133
|
+
* throws on malformed input — a user pasting half a key should get a sentence,
|
|
134
|
+
* not a stack trace. Reasons read as a predicate, because they are printed
|
|
135
|
+
* after "That key …" and "The key from <source> …".
|
|
136
|
+
*/
|
|
137
|
+
function verify(key, set = keyset()) {
|
|
138
|
+
if (typeof key !== "string" || !key.startsWith(PREFIX)) {
|
|
139
|
+
return { valid: false, reason: "is not an Agent.md license key — one starts with `agmd_`" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const [payloadPart, signaturePart, ...rest] = key.slice(PREFIX.length).split(".");
|
|
143
|
+
if (!payloadPart || !signaturePart || rest.length) {
|
|
144
|
+
return { valid: false, reason: "is malformed — expected `agmd_<payload>.<signature>`" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let signature;
|
|
148
|
+
let claims;
|
|
149
|
+
try {
|
|
150
|
+
signature = b64url.decode(signaturePart);
|
|
151
|
+
claims = JSON.parse(b64url.decode(payloadPart).toString("utf-8"));
|
|
152
|
+
} catch {
|
|
153
|
+
return { valid: false, reason: "is malformed — the payload or signature is not valid base64url" };
|
|
154
|
+
}
|
|
155
|
+
if (!claims || typeof claims !== "object") {
|
|
156
|
+
return { valid: false, reason: "is malformed — the payload is not an object" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Which key signed this. A license from before kid support carries none and
|
|
160
|
+
// verifies against whichever key the keyset nominates as the legacy one.
|
|
161
|
+
const kid = typeof claims.kid === "string" && claims.kid ? claims.kid : set.legacyKid;
|
|
162
|
+
const record = findKey(kid, set);
|
|
163
|
+
|
|
164
|
+
if (!record) {
|
|
165
|
+
return {
|
|
166
|
+
valid: false,
|
|
167
|
+
kid,
|
|
168
|
+
reason: `names signing key \`${kid}\`, which this version of the CLI does not trust — update the CLI, or check the key was not mistyped`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (record.status === "revoked") {
|
|
172
|
+
return {
|
|
173
|
+
valid: false,
|
|
174
|
+
kid,
|
|
175
|
+
reason: `was signed with \`${kid}\`, a key that has been revoked — every license under it is void. Contact support for a replacement`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (!VERIFIES.has(record.status)) {
|
|
179
|
+
return { valid: false, kid, reason: `was signed with \`${kid}\`, which is in an unknown state (\`${record.status}\`)` };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let ok = false;
|
|
183
|
+
try {
|
|
184
|
+
ok = crypto.verify(null, Buffer.from(payloadPart, "utf-8"), publicKeyOf(record), signature);
|
|
185
|
+
} catch {
|
|
186
|
+
ok = false;
|
|
187
|
+
}
|
|
188
|
+
if (!ok) return { valid: false, kid, reason: "was not issued by Agent.md — the signature does not match" };
|
|
189
|
+
|
|
190
|
+
if (!PAID_PLANS.includes(claims.plan)) {
|
|
191
|
+
return { valid: false, kid, claims, reason: `is for the \`${claims.plan || "none"}\` plan, which does not include Pro features` };
|
|
192
|
+
}
|
|
193
|
+
if (typeof claims.exp === "number" && claims.exp * 1000 < Date.now()) {
|
|
194
|
+
return { valid: false, kid, claims, reason: `expired on ${new Date(claims.exp * 1000).toISOString().slice(0, 10)}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { valid: true, claims, kid };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Sign a payload. Only ever runs where a private key lives — the issuing
|
|
202
|
+
* script and its tests, never in a user's CLI.
|
|
203
|
+
*
|
|
204
|
+
* `kid` is stamped into the claims so verification knows which key to use.
|
|
205
|
+
* Omitting it produces a pre-kid license, which is only useful for proving
|
|
206
|
+
* that licenses issued before keysets still verify.
|
|
207
|
+
*/
|
|
208
|
+
function sign(claims, privateKeyB64, kid) {
|
|
209
|
+
const key = crypto.createPrivateKey({
|
|
210
|
+
key: Buffer.from(privateKeyB64, "base64"),
|
|
211
|
+
format: "der",
|
|
212
|
+
type: "pkcs8",
|
|
213
|
+
});
|
|
214
|
+
const body = kid ? { ...claims, kid } : { ...claims };
|
|
215
|
+
const payload = b64url.encode(JSON.stringify(body));
|
|
216
|
+
const signature = b64url.encode(crypto.sign(null, Buffer.from(payload, "utf-8"), key));
|
|
217
|
+
return `${PREFIX}${payload}.${signature}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Last 6 characters, for printing a key without printing the key. */
|
|
221
|
+
function fingerprint(key) {
|
|
222
|
+
return typeof key === "string" && key.length > 6 ? `…${key.slice(-6)}` : "…";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Days until expiry, or null when the key never expires. */
|
|
226
|
+
function daysLeft(claims) {
|
|
227
|
+
if (!claims || typeof claims.exp !== "number") return null;
|
|
228
|
+
return Math.ceil((claims.exp * 1000 - Date.now()) / 86400000);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
verify, sign, fingerprint, daysLeft, keyset, findKey, canSign,
|
|
233
|
+
PAID_PLANS, PREFIX, VERIFIES,
|
|
234
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manifest.js
|
|
3
|
+
*
|
|
4
|
+
* Reads and writes .agentmd/manifest.json in the user's project root.
|
|
5
|
+
* Tracks which presets are installed, their versions, checksums and paths.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const crypto = require("crypto");
|
|
13
|
+
|
|
14
|
+
const MANIFEST_DIR = ".agentmd";
|
|
15
|
+
const MANIFEST_FILE = "manifest.json";
|
|
16
|
+
const PRESETS_DIR = ".agentmd/presets";
|
|
17
|
+
|
|
18
|
+
function getManifestPath(cwd) {
|
|
19
|
+
return path.join(cwd, MANIFEST_DIR, MANIFEST_FILE);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getPresetsDir(cwd) {
|
|
23
|
+
return path.join(cwd, PRESETS_DIR);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function checksum(content) {
|
|
27
|
+
return "sha256-" + crypto.createHash("sha256").update(content, "utf-8").digest("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a preset filename to an absolute path inside .agentmd/presets/,
|
|
32
|
+
* refusing anything that would escape it.
|
|
33
|
+
*
|
|
34
|
+
* Filenames are derived from registry coordinates, so today they are already
|
|
35
|
+
* constrained — but this is the one place the CLI turns remote-influenced data
|
|
36
|
+
* into a filesystem write, and a registry index is exactly the kind of thing
|
|
37
|
+
* an attacker would target to get `../../.bashrc` written. Check it here so
|
|
38
|
+
* every caller is covered rather than trusting each one to remember.
|
|
39
|
+
*/
|
|
40
|
+
function resolvePresetPath(cwd, filename) {
|
|
41
|
+
if (typeof filename !== "string" || filename.length === 0) {
|
|
42
|
+
throw new Error("Invalid preset filename");
|
|
43
|
+
}
|
|
44
|
+
const dir = path.resolve(getPresetsDir(cwd));
|
|
45
|
+
const target = path.resolve(dir, filename);
|
|
46
|
+
if (target !== path.join(dir, path.basename(target)) || !target.startsWith(dir + path.sep)) {
|
|
47
|
+
throw new Error(`Refusing to write outside ${PRESETS_DIR}/: ${filename}`);
|
|
48
|
+
}
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a path the CLI is about to write inside the project, refusing
|
|
54
|
+
* anything that escapes it.
|
|
55
|
+
*
|
|
56
|
+
* `link` writes outside `.agentmd/` by design — CLAUDE.md, .cursor/rules/,
|
|
57
|
+
* .github/instructions/, and one file per detected workspace. Those paths are
|
|
58
|
+
* built from category and preset names and from a directory walk, so they are
|
|
59
|
+
* constrained today; this makes that a property of the code rather than of
|
|
60
|
+
* every caller remembering. Symlinks are resolved so a linked directory cannot
|
|
61
|
+
* be used to step outside the project either.
|
|
62
|
+
*/
|
|
63
|
+
function resolveInProject(cwd, relative) {
|
|
64
|
+
if (typeof relative !== "string" || relative.length === 0) {
|
|
65
|
+
throw new Error("Invalid path");
|
|
66
|
+
}
|
|
67
|
+
if (path.isAbsolute(relative)) {
|
|
68
|
+
throw new Error(`Refusing to write to an absolute path: ${relative}`);
|
|
69
|
+
}
|
|
70
|
+
const root = realpath(path.resolve(cwd));
|
|
71
|
+
const target = path.resolve(root, relative);
|
|
72
|
+
// The file itself may not exist yet; its nearest existing ancestor must
|
|
73
|
+
// still live inside the project once symlinks are followed.
|
|
74
|
+
let existing = target;
|
|
75
|
+
while (!fs.existsSync(existing) && path.dirname(existing) !== existing) existing = path.dirname(existing);
|
|
76
|
+
const real = realpath(existing);
|
|
77
|
+
if (real !== root && !real.startsWith(root + path.sep)) {
|
|
78
|
+
throw new Error(`Refusing to write outside the project: ${relative}`);
|
|
79
|
+
}
|
|
80
|
+
return target;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function realpath(p) {
|
|
84
|
+
try { return fs.realpathSync(p); } catch { return p; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Turn registry coordinates into a flat, filesystem-safe preset filename.
|
|
89
|
+
*
|
|
90
|
+
* The model is part of the name. Without it, claude/Security/owasp and
|
|
91
|
+
* grok/Security/owasp both resolved to Security-owasp.md — two manifest
|
|
92
|
+
* entries pointing at one file, so whichever installed last silently replaced
|
|
93
|
+
* the other while the manifest still claimed both were present.
|
|
94
|
+
*
|
|
95
|
+
* Runs of dots collapse to one, and leading dots are stripped, so no input can
|
|
96
|
+
* produce a `..` segment or a hidden file. Package names like `linear.app` and
|
|
97
|
+
* `x.ai` keep their single dot.
|
|
98
|
+
*/
|
|
99
|
+
function presetFilename(model, category, preset) {
|
|
100
|
+
const slug = (s) =>
|
|
101
|
+
String(s)
|
|
102
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
103
|
+
.replace(/\.{2,}/g, ".")
|
|
104
|
+
.replace(/^[.\-]+|[-]+$/g, "");
|
|
105
|
+
return `${slug(model)}-${slug(category)}-${slug(preset)}.md`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read the existing manifest, or return a fresh one. */
|
|
109
|
+
function readManifest(cwd) {
|
|
110
|
+
const manifestPath = getManifestPath(cwd);
|
|
111
|
+
if (fs.existsSync(manifestPath)) {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
114
|
+
if (!Array.isArray(parsed.presets)) parsed.presets = [];
|
|
115
|
+
return parsed;
|
|
116
|
+
} catch {
|
|
117
|
+
// Corrupt manifest — start fresh rather than crashing the install.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { version: "1", installedAt: new Date().toISOString(), presets: [] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Write the manifest, creating .agentmd/ if needed. */
|
|
124
|
+
function writeManifest(cwd, manifest) {
|
|
125
|
+
fs.mkdirSync(path.join(cwd, MANIFEST_DIR), { recursive: true });
|
|
126
|
+
manifest.presets.sort((a, b) => a.id.localeCompare(b.id));
|
|
127
|
+
fs.writeFileSync(getManifestPath(cwd), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function ensurePresetsDir(cwd) {
|
|
131
|
+
const dir = getPresetsDir(cwd);
|
|
132
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
133
|
+
return dir;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Add or replace a preset entry. */
|
|
137
|
+
function upsertPreset(manifest, entry) {
|
|
138
|
+
const record = {
|
|
139
|
+
id: entry.id,
|
|
140
|
+
model: entry.model,
|
|
141
|
+
category: entry.category,
|
|
142
|
+
preset: entry.preset,
|
|
143
|
+
version: entry.version,
|
|
144
|
+
checksum: entry.checksum,
|
|
145
|
+
source: entry.url,
|
|
146
|
+
installedAt: new Date().toISOString(),
|
|
147
|
+
file: entry.file,
|
|
148
|
+
};
|
|
149
|
+
const index = manifest.presets.findIndex((p) => p.id === record.id);
|
|
150
|
+
if (index >= 0) {
|
|
151
|
+
record.installedAt = manifest.presets[index].installedAt;
|
|
152
|
+
record.updatedAt = new Date().toISOString();
|
|
153
|
+
manifest.presets[index] = record;
|
|
154
|
+
} else {
|
|
155
|
+
manifest.presets.push(record);
|
|
156
|
+
}
|
|
157
|
+
return record;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function findPreset(manifest, id) {
|
|
161
|
+
return manifest.presets.find((p) => p.id.toLowerCase() === String(id).toLowerCase()) || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isInstalled(manifest, model, category, preset) {
|
|
165
|
+
return findPreset(manifest, `${model}/${category}/${preset}`) !== null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Drop an entry from the manifest. Returns the removed record, or null. */
|
|
169
|
+
function removePreset(manifest, id) {
|
|
170
|
+
const index = manifest.presets.findIndex((p) => p.id.toLowerCase() === String(id).toLowerCase());
|
|
171
|
+
if (index < 0) return null;
|
|
172
|
+
return manifest.presets.splice(index, 1)[0];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
readManifest,
|
|
177
|
+
writeManifest,
|
|
178
|
+
ensurePresetsDir,
|
|
179
|
+
upsertPreset,
|
|
180
|
+
findPreset,
|
|
181
|
+
removePreset,
|
|
182
|
+
isInstalled,
|
|
183
|
+
getManifestPath,
|
|
184
|
+
getPresetsDir,
|
|
185
|
+
resolvePresetPath,
|
|
186
|
+
resolveInProject,
|
|
187
|
+
presetFilename,
|
|
188
|
+
checksum,
|
|
189
|
+
MANIFEST_DIR,
|
|
190
|
+
PRESETS_DIR,
|
|
191
|
+
};
|