sprag-cli 3.52.0 → 3.54.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.
Files changed (42) hide show
  1. package/README.ko.md +46 -3
  2. package/README.md +50 -4
  3. package/bin/cli.js +35 -4
  4. package/docs/CODEX.ko.md +172 -0
  5. package/docs/CODEX.md +485 -0
  6. package/package.json +11 -3
  7. package/presets/delegation/bounds.md +29 -0
  8. package/presets/ratchet-rules.json +4 -1
  9. package/src/agent.js +33 -0
  10. package/src/codex-brief.js +89 -0
  11. package/src/codex-budget.js +73 -0
  12. package/src/codex-cache-policy.js +203 -0
  13. package/src/codex-cache.js +46 -0
  14. package/src/codex-delegation.js +154 -0
  15. package/src/codex-doc2md-ledger.js +45 -0
  16. package/src/codex-doc2md.js +99 -0
  17. package/src/codex-doctor.js +53 -0
  18. package/src/codex-harness.js +200 -0
  19. package/src/codex-hooks.js +131 -0
  20. package/src/codex-installer.js +79 -0
  21. package/src/codex-ledger.js +143 -0
  22. package/src/codex-panel-launcher.js +48 -0
  23. package/src/codex-panel-runner.js +74 -0
  24. package/src/codex-panel-session.js +35 -0
  25. package/src/codex-panel-shell.js +51 -0
  26. package/src/codex-panel-state.js +40 -0
  27. package/src/codex-panel.js +448 -0
  28. package/src/codex-parser.js +397 -0
  29. package/src/codex-route-scan.js +171 -0
  30. package/src/commands/brief.js +8 -1
  31. package/src/commands/codex-delegate.js +59 -0
  32. package/src/commands/codex.js +557 -0
  33. package/src/commands/delegate.js +133 -0
  34. package/src/commands/seed.js +11 -10
  35. package/src/delegation-guard.js +278 -0
  36. package/src/doc2md.cjs +72 -28
  37. package/src/handoff.js +27 -7
  38. package/src/harness.js +26 -17
  39. package/src/installer.js +168 -0
  40. package/src/seed-rules.js +58 -34
  41. package/src/session-paths.js +242 -0
  42. package/src/update-check.js +37 -2
package/src/seed-rules.js CHANGED
@@ -46,23 +46,25 @@ import {
46
46
 
47
47
  const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
48
48
 
49
- export function seedStatePath() {
50
- return join(userDataDir(), 'seed-state.json');
49
+ // Codex answers are kept apart: saying no in one agent must not consume the
50
+ // other agent's offer, and the two agents write to different ratchet files.
51
+ export function seedStatePath({ agent = 'claude' } = {}) {
52
+ return join(userDataDir(), agent === 'codex' ? 'codex-seed-state.json' : 'seed-state.json');
51
53
  }
52
54
 
53
- export function loadSeedState() {
55
+ export function loadSeedState(opts = {}) {
54
56
  try {
55
- const data = JSON.parse(readFileSync(seedStatePath(), 'utf8'));
57
+ const data = JSON.parse(readFileSync(seedStatePath(opts), 'utf8'));
56
58
  return data && typeof data.decided === 'object' && data.decided ? data : { decided: {} };
57
59
  } catch {
58
60
  return { decided: {} };
59
61
  }
60
62
  }
61
63
 
62
- export function saveSeedState(state) {
64
+ export function saveSeedState(state, opts = {}) {
63
65
  const dir = userDataDir();
64
66
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
65
- writeFileSync(seedStatePath(), JSON.stringify(state, null, 2) + '\n');
67
+ writeFileSync(seedStatePath(opts), JSON.stringify(state, null, 2) + '\n');
66
68
  }
67
69
 
68
70
  /** Bundled tier-delegation presets (empty array when the file is unreadable). */
@@ -80,15 +82,19 @@ export function modelPresets() {
80
82
  * ids would renumber the moment a rule is inserted above them, silently moving
81
83
  * a user's "no" onto a different rule.
82
84
  */
83
- export function ratchetPresets(lang = userLanguage()) {
84
- return presetRuleEntries().map((r) => ({
85
- // Hashed from the Korean text in both languages: the id has to survive a
86
- // reworded translation, or a user who declined a rule would be asked again
87
- // the next time its English copy changes.
88
- id: `fix-${createHash('sha1').update(r.ko).digest('hex').slice(0, 6)}`,
89
- text: lang === 'ko' ? r.ko : r.en,
90
- ko: r.ko,
91
- }));
85
+ export function ratchetPresets(lang = userLanguage(), { agent = 'claude' } = {}) {
86
+ return presetRuleEntries()
87
+ // A preset without `agents` applies everywhere; one naming Claude model
88
+ // tiers would only mislead a Codex session.
89
+ .filter((r) => !r.agents || r.agents.includes(agent))
90
+ .map((r) => ({
91
+ // Hashed from the Korean text in both languages: the id has to survive a
92
+ // reworded translation, or a user who declined a rule would be asked again
93
+ // the next time its English copy changes.
94
+ id: `fix-${createHash('sha1').update(r.ko).digest('hex').slice(0, 6)}`,
95
+ text: lang === 'ko' ? r.ko : r.en,
96
+ ko: r.ko,
97
+ }));
92
98
  }
93
99
 
94
100
  const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/, '').trim();
@@ -102,9 +108,11 @@ const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/, '').
102
108
  * whose own route-scan already promoted that shape does not need ours, and
103
109
  * offering it anyway would read as the tool failing to notice its own state.
104
110
  */
105
- export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() } = {}) {
106
- const { decided } = loadSeedState();
107
- const registered = (() => {
111
+ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
112
+ const { decided } = loadSeedState({ agent });
113
+ // Codex model routing comes from its own measured route-scan candidates;
114
+ // Claude tier presets name models Codex does not run.
115
+ const registered = agent === 'codex' ? [] : (() => {
108
116
  try {
109
117
  return loadModelRules().rules;
110
118
  } catch {
@@ -116,12 +124,12 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() }
116
124
  const haveText = new Set();
117
125
  for (const scope of ['global', 'project']) {
118
126
  try {
119
- for (const r of harnessListRules({ root, scope }).rules) haveText.add(stripDate(r.text));
127
+ for (const r of harnessListRules({ root, scope, agent }).rules) haveText.add(stripDate(r.text));
120
128
  } catch { /* a missing ratchet file just means nothing is registered yet */ }
121
129
  }
122
130
 
123
131
  const out = [];
124
- for (const p of modelPresets()) {
132
+ for (const p of agent === 'codex' ? [] : modelPresets()) {
125
133
  if (decided[p.id]) continue;
126
134
  if (haveModel.has(`${p.tier}|${p.category}`)) continue;
127
135
  out.push({
@@ -134,7 +142,7 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() }
134
142
  preset: p,
135
143
  });
136
144
  }
137
- for (const p of ratchetPresets(lang)) {
145
+ for (const p of ratchetPresets(lang, { agent })) {
138
146
  if (decided[p.id]) continue;
139
147
  // Either language counts as already registered: `harness pull` may have
140
148
  // written the other one.
@@ -149,10 +157,10 @@ export function findSeed(id, opts = {}) {
149
157
  return pendingSeeds(opts).find((s) => s.id === id) || null;
150
158
  }
151
159
 
152
- function recordDecision(id, action, extra = {}) {
153
- const state = loadSeedState();
160
+ function recordDecision(id, action, extra = {}, { agent = 'claude' } = {}) {
161
+ const state = loadSeedState({ agent });
154
162
  state.decided[id] = { action, at: new Date().toISOString().slice(0, 10), ...extra };
155
- saveSeedState(state);
163
+ saveSeedState(state, { agent });
156
164
  }
157
165
 
158
166
  /**
@@ -162,13 +170,15 @@ function recordDecision(id, action, extra = {}) {
162
170
  *
163
171
  * Returns { id, kind, scope, path?, rule } or null when the id is not pending.
164
172
  */
165
- export async function acceptSeed(id, { scope = 'global', root = findProjectRoot(), lang = userLanguage() } = {}) {
166
- const seed = findSeed(id, { lang, root });
173
+ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = userLanguage(), agent = 'claude' } = {}) {
174
+ // No default: a rule in the wrong scope is noise everywhere or missing where it mattered.
175
+ if (scope !== 'global' && scope !== 'project') throw new Error('acceptSeed needs an explicit scope: global or project');
176
+ const seed = findSeed(id, { lang, root, agent });
167
177
  if (!seed) return null;
168
178
 
169
179
  if (seed.kind === 'ratchet') {
170
- const res = harnessPromote(seed.ruleText, { root, scope });
171
- recordDecision(id, 'accepted', { scope });
180
+ const res = harnessPromote(seed.ruleText, { root, scope, agent });
181
+ recordDecision(id, 'accepted', { scope }, { agent });
172
182
  return { id, kind: 'ratchet', scope, path: res.path, rule: seed.ruleText };
173
183
  }
174
184
 
@@ -216,15 +226,15 @@ export async function acceptSeed(id, { scope = 'global', root = findProjectRoot(
216
226
  export function skipSeed(id, opts = {}) {
217
227
  const seed = findSeed(id, opts);
218
228
  if (!seed) return null;
219
- recordDecision(id, 'skipped');
229
+ recordDecision(id, 'skipped', {}, { agent: opts.agent });
220
230
  return { id, kind: seed.kind, rule: seed.ruleText };
221
231
  }
222
232
 
223
233
  /** Forget every recorded answer — the full set becomes pending again. */
224
- export function resetSeeds() {
225
- const state = loadSeedState();
234
+ export function resetSeeds({ agent = 'claude' } = {}) {
235
+ const state = loadSeedState({ agent });
226
236
  const count = Object.keys(state.decided).length;
227
- saveSeedState({ decided: {} });
237
+ saveSeedState({ decided: {} }, { agent });
228
238
  return count;
229
239
  }
230
240
 
@@ -236,9 +246,23 @@ export function resetSeeds() {
236
246
  * asked for, and because a single "register all of these?" question invites a
237
247
  * yes to rules the user never read.
238
248
  */
239
- export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot() } = {}) {
240
- const pending = pendingSeeds({ lang, root });
249
+ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
250
+ const pending = pendingSeeds({ lang, root, agent });
241
251
  if (pending.length === 0) return null;
252
+ if (agent === 'codex') {
253
+ // Codex hook text stays English; the rule text itself follows the user's language.
254
+ const cmd = (s) => `${CLI_NAME} seed ${s} --agent codex`;
255
+ return [
256
+ `[Sprag seed] ${pending.length} recommended Codex ratchet rule(s) from the bundled presets are not registered yet.`,
257
+ 'Ask in your first reply, after answering any request the user opened with. Offer these choices in this order:',
258
+ ` 1. Register all, globally: ${cmd('accept all --global')}`,
259
+ ` 2. Register all, this project only: ${cmd('accept all --project')}`,
260
+ ' 3. Decide one at a time: ask about each rule below and run its command as soon as they answer',
261
+ ` 4. Register none: ${cmd('skip all')} (never offered again)`,
262
+ 'Always confirm the scope with the user before running an accept command.',
263
+ ...pending.map((s) => ` [${s.id}] ${s.ruleText}`),
264
+ ].join('\n');
265
+ }
242
266
  const models = pending.filter((s) => s.kind === 'model');
243
267
  const fixes = pending.filter((s) => s.kind === 'ratchet');
244
268
  const lines = [];
@@ -0,0 +1,242 @@
1
+ /**
2
+ * session-paths — pull the file paths this session has already looked at out
3
+ * of its transcript, so a fresh subagent can be pointed at them instead of
4
+ * re-discovering them by exploring from scratch.
5
+ *
6
+ * Only the transcript TAIL is read: this runs from a PreToolUse hook on every
7
+ * Task/Agent call, so the whole file must not be parsed each time. Older tool
8
+ * calls fall out of the window, which is acceptable — the point is "recently
9
+ * touched", not "ever touched".
10
+ *
11
+ * The window is sized by measurement, not by brief.js's 256KiB convention. A
12
+ * transcript's bytes are dominated by tool RESULTS (file contents, command
13
+ * output, base64 images) while the tool_use records this reads are a few
14
+ * hundred bytes each, so a byte window is far shallower than it looks: on a
15
+ * real 4.65MB session it held 28 lines, 1.9% of the session, and recovered 0
16
+ * of the 8 path-bearing calls that session had made. At 1MB the same
17
+ * transcript gave back 4; the read cost 3.3ms against this hook's 10s
18
+ * timeout. See TAIL_BYTES below for why the default sits higher still.
19
+ */
20
+
21
+ import { existsSync, statSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
22
+ import { isAbsolute, resolve, relative, sep } from 'node:path';
23
+
24
+ // Tools whose argument names a file directly, each with the key it uses. The
25
+ // key is spelled out per tool rather than assumed: NotebookEdit takes
26
+ // `notebook_path`, so reading `file_path` from every one of these silently
27
+ // collected nothing from notebook edits while the docs listed the tool as
28
+ // supported. MultiEdit belongs here for the same reason korean-lint's matcher
29
+ // (`Write|Edit|MultiEdit|Bash`) counts it as a writing tool.
30
+ //
31
+ // Bash is left out on purpose: its `command` string is free text, not a path
32
+ // field, and scraping it for paths would be a much noisier feature than this.
33
+ const FILE_PATH_ARG = {
34
+ Read: 'file_path',
35
+ Edit: 'file_path',
36
+ MultiEdit: 'file_path',
37
+ Write: 'file_path',
38
+ NotebookEdit: 'notebook_path',
39
+ };
40
+ const PATH_ARG_TOOLS = new Set(['Grep', 'Glob']);
41
+
42
+ /**
43
+ * How far back to read. 4MB costs 11.5ms on the measured session — nothing
44
+ * against the hook's 10s timeout — and buys margin that 1MB does not: a single
45
+ * transcript line can be 240KB when a session reads an image, so four such
46
+ * reads would exhaust a 1MB window inside one turn and empty this section
47
+ * again. The ceiling also bounds the buffer this allocates per delegation.
48
+ */
49
+ const TAIL_BYTES = 4 * 1024 * 1024;
50
+
51
+ /**
52
+ * A path that exists, is a file, and still resolves inside `root`.
53
+ *
54
+ * The containment test in the loop compares strings, and statSync follows
55
+ * symlinks, so a link that sits inside root while pointing outside it passed
56
+ * both. No new access is opened — every path here is one the parent session
57
+ * already read — but a function that goes as far as rejecting control
58
+ * characters in a filename should not be the loose one about links.
59
+ */
60
+ function isRegularFileInside(abs, realRoot) {
61
+ try {
62
+ if (!statSync(abs).isFile()) return false;
63
+ // Both sides resolved: comparing a resolved path against an unresolved root
64
+ // rejects everything whenever the root itself sits behind a link, which is
65
+ // the normal case for a macOS temp directory (/tmp -> /private/tmp) and so
66
+ // for this project's own tests.
67
+ const real = relative(realRoot, realpathSync(abs));
68
+ return real !== '' && real !== '..' && !real.startsWith('..' + sep) && !isAbsolute(real);
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Recently-touched file paths for this session, most-recent-first, restricted
76
+ * to `root` and returned relative to it.
77
+ *
78
+ * Every failure mode — missing transcript, unreadable file, malformed JSON —
79
+ * collapses to an empty array rather than throwing, because this feeds a
80
+ * PreToolUse hook: one bad transcript line must not block the delegation it
81
+ * was only trying to make cheaper.
82
+ */
83
+ export function recentToolPaths(transcriptPath, { root, limit = 15, tailBytes = TAIL_BYTES } = {}) {
84
+ try {
85
+ if (!transcriptPath || !existsSync(transcriptPath)) return [];
86
+ // Paths are reported relative to root, so without one there is nothing
87
+ // meaningful to return. resolve() would throw on undefined and the outer
88
+ // catch would hide that as an empty result, so say it here instead.
89
+ if (typeof root !== 'string' || root === '') return [];
90
+
91
+ const size = statSync(transcriptPath).size;
92
+ const start = Math.max(0, size - tailBytes);
93
+ // One byte of lookbehind, so the shift() below is right in both cases. When
94
+ // `start` happens to land just past a newline, the first line in the window
95
+ // is a whole record and dropping it lost one for nothing. Reading from the
96
+ // byte before means that case decodes with a leading '\n', whose split
97
+ // produces an empty first element — so the same unconditional shift()
98
+ // removes an empty string there and a genuine fragment otherwise.
99
+ const readFrom = start > 0 ? start - 1 : 0;
100
+ // allocUnsafe, not alloc: the read loop below overwrites what it uses and
101
+ // only buf.subarray(0, bytesRead) is ever decoded, so the untouched
102
+ // remainder never leaves this function. This saves zeroing 4MB per
103
+ // delegation, which is worth taking but is not where the cost of this
104
+ // function is — decoding the window to a string and splitting it allocates
105
+ // several times more. Cutting that means walking the buffer backwards by
106
+ // newline and decoding only the lines needed to reach `limit`, which is
107
+ // the change to make if the window is ever widened again.
108
+ const buf = Buffer.allocUnsafe(size - readFrom);
109
+ let fd;
110
+ let bytesRead = 0;
111
+ try {
112
+ fd = openSync(transcriptPath, 'r');
113
+ // One readSync is not promised to fill the whole request — a short read
114
+ // is common on some filesystems even mid-file — and the part it leaves
115
+ // unread here is the tail end of the buffer, which is the most recent
116
+ // slice of the transcript and exactly what this feature is trying to
117
+ // recover. Looping until the buffer is full (or the file runs out)
118
+ // keeps that slice from being silently dropped.
119
+ while (bytesRead < buf.length) {
120
+ const n = readSync(fd, buf, bytesRead, buf.length - bytesRead, readFrom + bytesRead);
121
+ if (n === 0) break;
122
+ bytesRead += n;
123
+ }
124
+ } finally {
125
+ if (fd !== undefined) closeSync(fd);
126
+ }
127
+
128
+ // Only what was actually read. A transcript is still being written while
129
+ // this runs, so between statSync and openSync the file may have been
130
+ // truncated or rotated, which ends the loop above early (n === 0) with
131
+ // bytesRead short of the buffer. Decoding the untouched remainder would
132
+ // turn the last record into NUL bytes and lose the newest path to a
133
+ // parse error.
134
+ const lines = buf.subarray(0, bytesRead).toString('utf8').split('\n');
135
+ // A tail that began mid-file leaves the first line either a truncated
136
+ // record or, thanks to the lookbehind byte above, an empty string. Either
137
+ // way it goes. When the read started at byte 0 the file was smaller than
138
+ // the window and that line is whole, so keeping it costs nothing and
139
+ // dropping it would lose the only record a short transcript has.
140
+ if (start > 0) lines.shift();
141
+
142
+ // Resolved once, not per path: only the stat'ed candidates need it, and the
143
+ // root does not change between them. Falls back to the given root when it
144
+ // cannot be resolved, which keeps a missing directory from emptying the
145
+ // whole list.
146
+ let realRoot = root;
147
+ try {
148
+ realRoot = realpathSync(root);
149
+ } catch {
150
+ // keep root as given
151
+ }
152
+
153
+ // Chronological order first (oldest touched path to newest); reversed
154
+ // and deduped below so the most recent occurrence of each path wins.
155
+ const touched = [];
156
+ for (const line of lines) {
157
+ // Cheap prefilter before the JSON.parse cost, same trick brief.js uses
158
+ // for `"usage"` — most lines in a transcript are not tool_use blocks.
159
+ if (!line.includes('"tool_use"')) continue;
160
+ let entry;
161
+ try {
162
+ entry = JSON.parse(line);
163
+ } catch {
164
+ continue;
165
+ }
166
+ const content = entry?.message?.content;
167
+ if (!Array.isArray(content)) continue;
168
+
169
+ for (const block of content) {
170
+ if (!block || block.type !== 'tool_use') continue;
171
+ const input = block.input;
172
+ if (!input || typeof input !== 'object') continue;
173
+
174
+ let rawPath = null;
175
+ // Grep and Glob are the two whose argument is a directory as often as
176
+ // a file, so what they contribute has to be confirmed before it is
177
+ // offered as something to read. Read/Edit/Write name a file by
178
+ // contract.
179
+ let mayBeDirectory = false;
180
+ const argKey = FILE_PATH_ARG[block.name];
181
+ if (argKey) rawPath = input[argKey];
182
+ else if (PATH_ARG_TOOLS.has(block.name)) {
183
+ rawPath = input.path;
184
+ mayBeDirectory = true;
185
+ }
186
+ if (typeof rawPath !== 'string' || rawPath === '') continue;
187
+ // A POSIX filename may hold any byte but `/` and NUL, and this list is
188
+ // rendered one path per line straight into the prompt of a subagent
189
+ // that has tool permissions. So a filename carrying a newline plus a
190
+ // sentence would leave the "already read these" section and arrive as
191
+ // its own instruction. Filenames are outside input whenever the
192
+ // session is working in a repository it did not write, which makes
193
+ // this a boundary rather than a curiosity.
194
+ //
195
+ // The class covers more than C0 and DEL, because more than those end a
196
+ // line: U+2028 and U+2029 are Unicode's own line and paragraph
197
+ // separators and U+0085 is NEL, and a renderer may break on any of
198
+ // them. The backtick is here for a different reason — the section wraps
199
+ // each path in one, so a name containing a backtick closes that span
200
+ // early and returns the rest of the line to prose. Both are cheaper to
201
+ // reject while collecting than to escape while rendering.
202
+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029`]/.test(rawPath)) continue;
203
+ // The free half of the directory test; the stat below covers the rest.
204
+ if (rawPath.endsWith('/') || rawPath.endsWith(sep)) continue;
205
+
206
+ const abs = isAbsolute(rawPath) ? rawPath : resolve(root, rawPath);
207
+ const rel = relative(root, abs);
208
+ // Outside root: relative() climbs out as ".." alone or ".." followed by
209
+ // a separator, or (on a different Windows drive) returns an
210
+ // already-absolute path. The separator has to be part of the test —
211
+ // without it a directory legitimately named `..fixtures` or `..cache`
212
+ // read as an escape and everything under it vanished from the list with
213
+ // no signal anywhere.
214
+ if (rel === '' || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) continue;
215
+
216
+ touched.push({ rel, mayBeDirectory });
217
+ }
218
+ }
219
+
220
+ const deduped = [];
221
+ const seen = new Set();
222
+ for (let i = touched.length - 1; i >= 0 && deduped.length < limit; i--) {
223
+ const { rel: p, mayBeDirectory } = touched[i];
224
+ if (seen.has(p)) continue;
225
+ seen.add(p);
226
+ // `Grep({ path: 'src' })` names a directory without a trailing
227
+ // separator, and the section this feeds says the session "already read
228
+ // these" — so a directory listed there invites a subagent to Read it and
229
+ // spend one of its capped tool calls on the failure. The same reasoning
230
+ // already guards the ratchet pointer in delegation-guard.js.
231
+ //
232
+ // This is the one place a syscall is worth it. It runs after dedupe and
233
+ // stops at `limit`, so at most 15 stats happen per delegation against an
234
+ // 11.5ms read, and only for the two tools that can name a directory.
235
+ if (mayBeDirectory && !isRegularFileInside(resolve(root, p), realRoot)) continue;
236
+ deduped.push(p);
237
+ }
238
+ return deduped;
239
+ } catch {
240
+ return [];
241
+ }
242
+ }
@@ -266,16 +266,51 @@ export function releaseHighlights(body, lang = 'en') {
266
266
  return out;
267
267
  }
268
268
 
269
+ /**
270
+ * The releases endpoint for one version, or a throw.
271
+ *
272
+ * The version that arrives here came out of a registry response rather than out
273
+ * of this process, so it is checked rather than trusted: a value carrying a
274
+ * slash or a scheme would otherwise have a say in which URL this function
275
+ * requests. Two checks are needed, because either one alone leaves a gap. The
276
+ * shape test rejects anything that is not a version, and the prefix test runs
277
+ * against the parsed URL rather than against the string it was built from,
278
+ * because `new URL` has collapsed `../` segments by that point and the same
279
+ * test on the raw string would pass a value that walks back out of the path.
280
+ * The origin is a constant either way, so no input can move the request to
281
+ * another host; this states that guarantee in code instead of leaving it
282
+ * implied.
283
+ */
284
+ function releaseTagUrl(version) {
285
+ const tag = String(version);
286
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(tag)) {
287
+ throw new Error(`refusing to request notes for ${JSON.stringify(tag)}: not a version`);
288
+ }
289
+ const url = new URL(`${RELEASES_API}/v${tag}`);
290
+ if (!url.href.startsWith(`${RELEASES_API}/`)) {
291
+ throw new Error(`refusing to request ${url.href}: outside ${RELEASES_API}`);
292
+ }
293
+ return url;
294
+ }
295
+
269
296
  /**
270
297
  * Fetch the highlights for one version. Returns [] on any failure: the notice
271
298
  * is worth showing with a bare version number, so a missing release, a rate
272
- * limit, or an offline machine must not cost the user the notice itself.
299
+ * limit, or an offline machine must not cost the user the notice itself. A
300
+ * version that fails the endpoint check above lands in that same place: no
301
+ * highlights, and the notice still goes out.
273
302
  */
274
303
  export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS, lang = 'en' } = {}) {
275
304
  const controller = new AbortController();
276
305
  const timer = setTimeout(() => controller.abort(), timeoutMs);
277
306
  try {
278
- const res = await fetch(`${RELEASES_API}/v${version}`, {
307
+ const endpoint = releaseTagUrl(version);
308
+ // The URL is this module's own constant plus a checked version. The
309
+ // scanner's node_ssrf heuristic reads any two-argument function as an HTTP
310
+ // handler and its first argument as request input, which is what it matched
311
+ // on here; this module serves nothing and has no request to read.
312
+ // nosemgrep: rules.lgpl.nodejs_scan.javascript-ssrf-rule-node_ssrf
313
+ const res = await fetch(endpoint, {
279
314
  signal: controller.signal,
280
315
  headers: { accept: 'application/vnd.github+json' },
281
316
  });