agentic-workflow-manager 9.20.2 → 9.20.3

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.
@@ -24,11 +24,71 @@ exports.matchFrontmatterBlock = matchFrontmatterBlock;
24
24
  exports.isBlockScalarHeader = isBlockScalarHeader;
25
25
  exports.findFrontmatterDescription = findFrontmatterDescription;
26
26
  exports.readFrontmatterDescription = readFrontmatterDescription;
27
+ /** Tope del contenido interno (entre `---` y el cerrador) antes de abandonar el
28
+ * escaneo (FH-3). Misma cifra que `FRONTMATTER_MAX` en process/model.ts. */
29
+ const FRONTMATTER_INNER_MAX = 65536;
30
+ /** ¿La linea es exactamente el cerrador `---` con espacios/tabs finales opcionales?
31
+ * `---x`, `---#comment` y similares NO cierran. */
32
+ function isFrontmatterCloserLine(line) {
33
+ if (!line.startsWith('---'))
34
+ return false;
35
+ for (let i = 3; i < line.length; i++) {
36
+ const c = line[i];
37
+ if (c !== ' ' && c !== '\t')
38
+ return false;
39
+ }
40
+ return true;
41
+ }
27
42
  /** Texto crudo del frontmatter (entre los delimitadores `---`), o null si el
28
- * bloque falta o esta mal formado. Tolera CRLF. */
43
+ * bloque falta o esta mal formado. Tolera CRLF. Cierra solo en una linea que
44
+ * sea exactamente `---` (con espacios/tabs finales opcionales). Si el contenido
45
+ * interno excederia `FRONTMATTER_INNER_MAX` antes de un cerrador valido, retorna
46
+ * null sin seguir acumulando lineas (FH-3). */
29
47
  function matchFrontmatterBlock(raw) {
30
- const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
31
- return fmMatch ? fmMatch[1] : null;
48
+ if (!raw.startsWith('---'))
49
+ return null;
50
+ const afterOpen = raw[3] === '\r' && raw[4] === '\n' ? 5
51
+ : raw[3] === '\n' ? 4
52
+ : -1;
53
+ if (afterOpen < 0)
54
+ return null;
55
+ const body = raw.slice(afterOpen);
56
+ const lines = [];
57
+ let innerLen = 0;
58
+ let i = 0;
59
+ while (i <= body.length) {
60
+ let end = i;
61
+ while (end < body.length && body[end] !== '\n' && body[end] !== '\r')
62
+ end++;
63
+ const line = body.slice(i, end);
64
+ if (isFrontmatterCloserLine(line)) {
65
+ return lines.join('\n');
66
+ }
67
+ // join('\n') inserta un salto entre lineas ya acumuladas.
68
+ const nextLen = lines.length === 0 ? line.length : innerLen + 1 + line.length;
69
+ if (nextLen > FRONTMATTER_INNER_MAX)
70
+ return null;
71
+ lines.push(line);
72
+ innerLen = nextLen;
73
+ if (end >= body.length)
74
+ break;
75
+ if (body[end] === '\r' && body[end + 1] === '\n')
76
+ i = end + 2;
77
+ else
78
+ i = end + 1;
79
+ }
80
+ return null;
81
+ }
82
+ /** Quita un comentario YAML de escalar plano: solo `#` precedido de espacio o
83
+ * tab (no NBSP ni otros whitespace Unicode). Lineal en la longitud del valor. */
84
+ function stripPlainScalarComment(value) {
85
+ for (let i = 0; i < value.length - 1; i++) {
86
+ const c = value[i];
87
+ if ((c === ' ' || c === '\t') && value[i + 1] === '#') {
88
+ return value.slice(0, i).trim();
89
+ }
90
+ }
91
+ return value.trim();
32
92
  }
33
93
  /** Indicador de block scalar en la linea de la clave: estilo (`>` folded /
34
94
  * `|` literal) + chomping opcional (`-`/`+`) + indentacion explicita opcional
@@ -212,10 +272,11 @@ function findFrontmatterDescription(frontmatter) {
212
272
  }
213
273
  }
214
274
  }
215
- // Escalar plano: un `#` PRECEDIDO DE ESPACIO abre un comentario y no forma
216
- // parte del valor (un `#` pegado a texto, como en `C#`, si). Dentro de
275
+ // Escalar plano: un `#` PRECEDIDO DE ESPACIO O TAB abre un comentario y no
276
+ // forma parte del valor (un `#` pegado a texto, como en `C#`, si; NBSP
277
+ // antes de `#` tampoco abre comentario — alineado con js-yaml). Dentro de
217
278
  // comillas el `#` es literal, y esas ramas ya retornaron.
218
- return { value: value.replace(/\s+#.*$/, '').trim(), startLine, endLine };
279
+ return { value: stripPlainScalarComment(value), startLine, endLine };
219
280
  }
220
281
  /** Azucar para el caso comun: solo el valor resuelto de `description`. */
221
282
  function readFrontmatterDescription(frontmatter) {
@@ -24,8 +24,16 @@ const ALLOWED_FIELDS = ['awm', 'schema', 'name', 'status', 'entry_point', 'termi
24
24
  const DESCRIPTION_MAX = 1024;
25
25
  /** Tope del bloque de frontmatter antes de entregarlo al parser YAML: acota el
26
26
  * costo de parsear contenido no confiable de un registry. Por encima no se
27
- * parsea (para `description`, se rechaza con el mensaje del límite de 1024). */
27
+ * parsea (para `description`, se rechaza con el mensaje del límite de 1024).
28
+ * Para un bloque con discriminador `awm: process-model`, también corta
29
+ * `readPairs` (FH-5). */
28
30
  const FRONTMATTER_MAX = 65536;
31
+ /** Tope de problemas acumulados en un solo diagnóstico de proceso (FH-6). */
32
+ const MAX_PROCESS_PROBLEMS = 20;
33
+ /** Tope de caracteres de una clave desconocida/duplicada embebida en el diagnóstico (FH-6). */
34
+ const UNKNOWN_FIELD_KEY_MAX = 64;
35
+ /** Tope del valor crudo de `name`/`terminates_to` antes de `yaml.load` (FH-4). */
36
+ const YAML_SCALAR_RAW_MAX = 128;
29
37
  /** Lo que un runtime mostraría mal o usaría para engañar: controles C0 (salvo
30
38
  * tab), DEL, C1, separadores U+2028/U+2029, marcas LRM/RLM/ALM, overrides e
31
39
  * isolates bidi, y surrogates sueltos (regex en vez de `isWellFormed`: el lib
@@ -34,7 +42,50 @@ const FORBIDDEN_CHAR = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\u061C\u200E\u20
34
42
  /** Discriminador indentado: un modelo cuyas claves quedaron con sangría se
35
43
  * perdería en silencio como "no es un modelo". */
36
44
  const INDENTED_DISCRIMINATOR = /^awm[ \t]*:[ \t]*process-model$/;
45
+ /** Discriminador a nivel de línea (sin sangría) para el gate de tamaño FH-5. */
46
+ const PROCESS_MODEL_DISCRIMINATOR_LINE = /^awm[ \t]*:[ \t]*process-model[ \t]*$/m;
47
+ /** Discriminador en prefijo (columna 0 o indentado) cuando matchFrontmatterBlock
48
+ * devolvió null por exceso de tamaño (FH-5 QA amend). */
49
+ const PROCESS_MODEL_DISCRIMINATOR_PREFIX = /^[ \t]*awm[ \t]*:[ \t]*process-model[ \t]*$/m;
50
+ /** Token de ancla/alias YAML con forma `&name` / `*name` (FH-4). */
51
+ const YAML_ALIAS_TOKEN = /[&*][A-Za-z0-9_-]+/;
37
52
  const STATUSES = ['draft', 'active'];
53
+ /** Sentinel cuando `loadYamlScalar` no obtiene un mapeo plano con clave `v`. */
54
+ const YAML_SCALAR_MISS = Symbol('yaml-scalar-miss');
55
+ /** Quita un comentario de escalar plano (` #…` / `\t#…`) solo para el guard FH-4. */
56
+ function stripTrailingPlainCommentForGuard(raw) {
57
+ for (let i = 0; i < raw.length - 1; i++) {
58
+ const c = raw[i];
59
+ if ((c === ' ' || c === '\t') && raw[i + 1] === '#')
60
+ return raw.slice(0, i);
61
+ }
62
+ return raw;
63
+ }
64
+ /** Carga un valor suelto de `readPairs` como escalar YAML (`v: <raw>`). NUNCA lanza.
65
+ * Rechaza sin parsear si el raw (sin comentario final) es demasiado largo o trae
66
+ * un token de ancla/alias con forma `&name` / `*name`. */
67
+ function loadYamlScalar(raw) {
68
+ const forGuard = stripTrailingPlainCommentForGuard(raw);
69
+ if (forGuard.length > YAML_SCALAR_RAW_MAX || YAML_ALIAS_TOKEN.test(forGuard))
70
+ return YAML_SCALAR_MISS;
71
+ try {
72
+ const doc = js_yaml_1.default.load(`v: ${raw}`);
73
+ if (isPlainObject(doc) && Object.prototype.hasOwnProperty.call(doc, 'v'))
74
+ return doc.v;
75
+ }
76
+ catch { /* invalid YAML → miss */ }
77
+ return YAML_SCALAR_MISS;
78
+ }
79
+ /** Si `raw` es un escalar string YAML, lo devuelve; si no (null/bool/number/…), `null`. */
80
+ function yamlStringSlug(raw) {
81
+ const value = loadYamlScalar(raw);
82
+ return typeof value === 'string' ? value : null;
83
+ }
84
+ /** Clave truncada para diagnósticos `unknown field` / `duplicate field` (FH-6). */
85
+ function formatFieldKey(key) {
86
+ const shown = key.length > UNKNOWN_FIELD_KEY_MAX ? `${key.slice(0, UNKNOWN_FIELD_KEY_MAX)}…` : key;
87
+ return JSON.stringify(shown);
88
+ }
38
89
  /** El slug es lo único del modelo que puede viajar dentro de un id del Dashboard
39
90
  * (ver sanitize.ts). Por eso se valida acá y no en la frontera de render: si
40
91
  * admitiera rutas, markup o `=`, el id dejaría de ser seguro por construcción. */
@@ -42,13 +93,21 @@ exports.PROCESS_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
42
93
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
43
94
  /** Caracteres de ancho cero: un valor hecho solo de ellos se ve vacío. */
44
95
  const ZERO_WIDTH = /[\u200B-\u200D\u2060\uFEFF]/g;
96
+ /** Empuja a `problems` mientras haya cupo (FH-6); cuenta el resto en `omitted`. */
97
+ function noteProblem(problems, omitted, msg) {
98
+ if (problems.length < MAX_PROCESS_PROBLEMS)
99
+ problems.push(msg);
100
+ else
101
+ omitted.n++;
102
+ }
45
103
  /** Lector de pares `clave: valor` de un frontmatter plano. El contrato no admite
46
104
  * anidamiento ni block scalars: cualquier línea que no sea `clave: valor` es un
47
105
  * rechazo, no una interpretación. (El VALOR de `description` sí pasa por el
48
- * parser YAML, más abajo.) */
106
+ * parser YAML, más abajo.) Deja de registrar problemas tras 20 (FH-6). */
49
107
  function readPairs(block) {
50
108
  const pairs = new Map();
51
109
  const problems = [];
110
+ const omitted = { n: 0 };
52
111
  const continuations = [];
53
112
  // Mismo criterio que YAML, no que JS: se parte en TODO salto de línea YAML
54
113
  // (un CR suelto también; si no, `a\rclave: x` sería un par para acá y dos
@@ -60,8 +119,9 @@ function readPairs(block) {
60
119
  // NEL/LS/PS son saltos de línea para YAML 1.1 (PyYAML) y no para js-yaml:
61
120
  // un comentario podría esconder una clave que solo un runtime ve. Se
62
121
  // reporta en CUALQUIER línea, comentarios incluidos, y solo por posición.
63
- if (/[\u0085\u2028\u2029]/.test(raw))
64
- problems.push(`frontmatter line ${index + 1} contains a YAML 1.1 line break (NEL, LS or PS)`);
122
+ if (/[\u0085\u2028\u2029]/.test(raw)) {
123
+ noteProblem(problems, omitted, `frontmatter line ${index + 1} contains a YAML 1.1 line break (NEL, LS or PS)`);
124
+ }
65
125
  if (line === '' || line.startsWith('#'))
66
126
  continue;
67
127
  // El contrato es un mapa PLANO: una línea que empieza con espacio o tab
@@ -78,23 +138,26 @@ function readPairs(block) {
78
138
  // contenido: una línea suelta suele ser la continuación de un valor
79
139
  // multilínea, y los diagnósticos no transportan valores de campos.
80
140
  if (!m) {
81
- problems.push(`frontmatter line ${index + 1} is not a "key: value" pair (multi-line values are not supported)`);
141
+ noteProblem(problems, omitted, `frontmatter line ${index + 1} is not a "key: value" pair (multi-line values are not supported)`);
82
142
  continue;
83
143
  }
84
144
  if (pairs.has(m[1])) {
85
- problems.push(`duplicate field ${JSON.stringify(m[1])}`);
145
+ noteProblem(problems, omitted, `duplicate field ${formatFieldKey(m[1])}`);
86
146
  continue;
87
147
  }
88
148
  pairs.set(m[1], m[2]);
89
149
  }
90
- return { pairs, problems, continuations };
150
+ return { pairs, problems, continuations, omittedProblems: omitted.n };
91
151
  }
92
152
  /** Carga el bloque de frontmatter COMPLETO con js-yaml (schema por defecto),
93
153
  * exactamente como lo lee un runtime. Por encima del tope no se parsea. NUNCA
94
- * lanza: un error de js-yaml es `invalid`. */
154
+ * lanza: un error de js-yaml es `invalid`. Rechaza sin `yaml.load` si el bloque
155
+ * contiene un token de ancla/alias con forma `&name` / `*name` (FH-4). */
95
156
  function loadBlock(block) {
96
157
  if (block.length > FRONTMATTER_MAX)
97
158
  return { kind: 'too-long' };
159
+ if (YAML_ALIAS_TOKEN.test(block))
160
+ return { kind: 'invalid' };
98
161
  try {
99
162
  return { kind: 'loaded', doc: js_yaml_1.default.load(block) };
100
163
  }
@@ -168,9 +231,28 @@ function parseProcessFrontmatter(source, file) {
168
231
  catch {
169
232
  return { diagnostics: [] };
170
233
  }
171
- if (block === null)
234
+ const sizeDiag = { diagnostics: [`${file}: invalid process model — frontmatter exceeds ${FRONTMATTER_MAX} characters`] };
235
+ if (block === null) {
236
+ // FH-5: match devolvió null (sin cerrador a tiempo / tope interno). Si el
237
+ // prefijo declara process-model (columna 0 o indentado) → tamaño; si no → silencio.
238
+ if (source.startsWith('---')) {
239
+ const prefix = source.slice(0, FRONTMATTER_MAX + 128);
240
+ if (PROCESS_MODEL_DISCRIMINATOR_PREFIX.test(prefix))
241
+ return sizeDiag;
242
+ }
172
243
  return { diagnostics: [] };
173
- const { pairs, problems, continuations } = readPairs(block);
244
+ }
245
+ // FH-5 defensa: bloque devuelto que aun así exceda el tope (no debería pasar
246
+ // tras FH-3). Con discriminador → tamaño; sin él → silencio.
247
+ if (block.length > FRONTMATTER_MAX) {
248
+ if (PROCESS_MODEL_DISCRIMINATOR_LINE.test(block) || PROCESS_MODEL_DISCRIMINATOR_PREFIX.test(block)) {
249
+ return sizeDiag;
250
+ }
251
+ return { diagnostics: [] };
252
+ }
253
+ const { pairs, problems, continuations, omittedProblems } = readPairs(block);
254
+ const omitted = { n: omittedProblems };
255
+ const note = (msg) => noteProblem(problems, omitted, msg);
174
256
  // Un modelo con las claves indentadas no tiene `awm` de nivel superior y se
175
257
  // perdería en silencio como "otro documento". Se nombra solo la línea.
176
258
  // Dispara con el prefiltro barato y, además, si el bloque NO carga como YAML
@@ -193,13 +275,13 @@ function parseProcessFrontmatter(source, file) {
193
275
  return { diagnostics: [] };
194
276
  for (const key of pairs.keys()) {
195
277
  if (!ALLOWED_FIELDS.includes(key)) {
196
- problems.push(`unknown field ${JSON.stringify(key)} — the contract admits only ${ALLOWED_FIELDS.join(', ')}`);
278
+ note(`unknown field ${formatFieldKey(key)} — the contract admits only ${ALLOWED_FIELDS.join(', ')}`);
197
279
  }
198
280
  }
199
281
  const rawSchema = pairs.get('schema');
200
282
  const schema = rawSchema !== undefined && /^\d+$/.test(rawSchema) ? Number(rawSchema) : Number.NaN;
201
283
  if (!Number.isInteger(schema) || schema < 1) {
202
- problems.push('"schema" must be a positive integer');
284
+ note('"schema" must be a positive integer');
203
285
  }
204
286
  else if (schema > types_1.KNOWN_PROCESS_SCHEMA) {
205
287
  // R1.4: detenerse e informar. Interpretarlo como el contrato anterior es
@@ -209,37 +291,58 @@ function parseProcessFrontmatter(source, file) {
209
291
  for (const field of ['name', 'status', 'entry_point', 'terminates_to', 'created', 'updated']) {
210
292
  const value = pairs.get(field);
211
293
  if (value === undefined || value === '')
212
- problems.push(`"${field}" is required`);
294
+ note(`"${field}" is required`);
295
+ }
296
+ const YAML_STRING_DIAG = 'must be a YAML string scalar (quote values YAML reads as null, boolean, or number)';
297
+ let name = '';
298
+ const rawName = pairs.get('name') ?? '';
299
+ if (rawName !== '') {
300
+ const resolvedName = yamlStringSlug(rawName);
301
+ if (resolvedName === null)
302
+ note(`"name" ${YAML_STRING_DIAG}`);
303
+ else if (!exports.PROCESS_NAME.test(resolvedName))
304
+ note('"name" must be a lowercase slug (a-z, 0-9, hyphen)');
305
+ else
306
+ name = resolvedName;
213
307
  }
214
- const name = pairs.get('name') ?? '';
215
- if (name !== '' && !exports.PROCESS_NAME.test(name))
216
- problems.push('"name" must be a lowercase slug (a-z, 0-9, hyphen)');
217
308
  const status = pairs.get('status') ?? '';
218
309
  if (status !== '' && !STATUSES.includes(status))
219
- problems.push(`"status" must be one of ${STATUSES.join(', ')}`);
310
+ note(`"status" must be one of ${STATUSES.join(', ')}`);
220
311
  const entryPointRaw = pairs.get('entry_point') ?? '';
221
312
  if (entryPointRaw !== '' && entryPointRaw !== 'true' && entryPointRaw !== 'false')
222
- problems.push('"entry_point" must be true or false');
223
- const terminatesTo = pairs.get('terminates_to') ?? '';
224
- if (terminatesTo !== '' && terminatesTo !== 'none' && !exports.PROCESS_NAME.test(terminatesTo)) {
225
- problems.push('"terminates_to" must be a lowercase slug or "none"');
313
+ note('"entry_point" must be true or false');
314
+ let terminatesTo = '';
315
+ const rawTerminatesTo = pairs.get('terminates_to') ?? '';
316
+ if (rawTerminatesTo !== '') {
317
+ const resolvedTerminatesTo = yamlStringSlug(rawTerminatesTo);
318
+ if (resolvedTerminatesTo === null)
319
+ note(`"terminates_to" ${YAML_STRING_DIAG}`);
320
+ else if (resolvedTerminatesTo !== 'none' && !exports.PROCESS_NAME.test(resolvedTerminatesTo)) {
321
+ note('"terminates_to" must be a lowercase slug or "none"');
322
+ }
323
+ else {
324
+ terminatesTo = resolvedTerminatesTo;
325
+ }
226
326
  }
227
327
  for (const field of ['created', 'updated']) {
228
328
  const value = pairs.get(field) ?? '';
229
329
  if (value !== '' && !DATE.test(value))
230
- problems.push(`"${field}" must be YYYY-MM-DD`);
330
+ note(`"${field}" must be YYYY-MM-DD`);
231
331
  }
232
332
  let description;
233
333
  const rawDescription = pairs.get('description');
234
334
  if (rawDescription !== undefined) {
235
335
  const checked = checkDescription(rawDescription, block, new Set(pairs.keys()));
236
336
  if ('problem' in checked)
237
- problems.push(checked.problem);
337
+ note(checked.problem);
238
338
  else
239
339
  description = checked.value;
240
340
  }
241
- if (problems.length > 0) {
242
- return { diagnostics: [`${file}: invalid process model — ${problems.join('; ')}`] };
341
+ if (problems.length > 0 || omitted.n > 0) {
342
+ const capped = omitted.n > 0
343
+ ? problems.concat([`${omitted.n} more problems`])
344
+ : problems;
345
+ return { diagnostics: [`${file}: invalid process model — ${capped.join('; ')}`] };
243
346
  }
244
347
  return {
245
348
  model: {
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // FH-1 / FH-2 / FH-3: linear plain-scalar comment strip, YAML-aligned
7
+ // space/tab-before-# vs NBSP, and exact `---` closer for matchFrontmatterBlock.
8
+ const js_yaml_1 = __importDefault(require("js-yaml"));
9
+ const frontmatter_1 = require("../../src/core/frontmatter");
10
+ const helpers_1 = require("./changes/helpers");
11
+ describe('frontmatter hardening (FH-1, FH-2, FH-3)', () => {
12
+ describe('FH-3: matchFrontmatterBlock closes only on exact ---', () => {
13
+ it('keeps keys after a ---x line inside the block', () => {
14
+ const raw = '---\nname: a\n---x: 1\nstatus: b\n---\n\nbody\n';
15
+ const block = (0, frontmatter_1.matchFrontmatterBlock)(raw);
16
+ expect(block).not.toBeNull();
17
+ expect(block).toContain('---x: 1');
18
+ expect(block).toContain('status: b');
19
+ });
20
+ it('closes on --- with trailing spaces', () => {
21
+ const block = (0, frontmatter_1.matchFrontmatterBlock)('---\nname: a\n--- \n\nbody\n');
22
+ expect(block).toBe('name: a');
23
+ });
24
+ it('closes on --- with trailing tabs and CRLF', () => {
25
+ const block = (0, frontmatter_1.matchFrontmatterBlock)('---\r\nname: a\r\n---\t\t\r\n\r\nbody\r\n');
26
+ expect(block).toBe('name: a');
27
+ });
28
+ it('does not close on ---#comment', () => {
29
+ const raw = '---\nname: a\n---#comment\nstatus: b\n---\n\nbody\n';
30
+ const block = (0, frontmatter_1.matchFrontmatterBlock)(raw);
31
+ expect(block).not.toBeNull();
32
+ expect(block).toContain('---#comment');
33
+ expect(block).toContain('status: b');
34
+ });
35
+ it('yields empty string for empty frontmatter', () => {
36
+ expect((0, frontmatter_1.matchFrontmatterBlock)('---\n---\n')).toBe('');
37
+ });
38
+ it('does not close on --- followed by NBSP', () => {
39
+ const raw = '---\nname: a\n---\u00A0\nstatus: b\n---\n\nbody\n';
40
+ const block = (0, frontmatter_1.matchFrontmatterBlock)(raw);
41
+ expect(block).not.toBeNull();
42
+ expect(block).toContain('---\u00A0');
43
+ expect(block).toContain('status: b');
44
+ });
45
+ it('returns null quickly when open frontmatter would exceed 65536 without a closer', () => {
46
+ const huge = `---\n${'x'.repeat(70_000)}`;
47
+ const started = Date.now();
48
+ expect((0, frontmatter_1.matchFrontmatterBlock)(huge)).toBeNull();
49
+ expect(Date.now() - started).toBeLessThan(500);
50
+ });
51
+ it('returns null when a closer exists only after inner content would exceed 65536', () => {
52
+ // Without the allocation bound, this would return a 65537-char block.
53
+ const raw = `---\n${'x'.repeat(65_537)}\n---\n`;
54
+ const started = Date.now();
55
+ expect((0, frontmatter_1.matchFrontmatterBlock)(raw)).toBeNull();
56
+ expect(Date.now() - started).toBeLessThan(500);
57
+ });
58
+ });
59
+ describe('FH-2: comment strip is space/tab only (not NBSP)', () => {
60
+ it('drops trailing comment after space before #', () => {
61
+ expect((0, frontmatter_1.findFrontmatterDescription)('description: hello # drop').value).toBe('hello');
62
+ });
63
+ it('drops trailing comment after tab before #', () => {
64
+ expect((0, frontmatter_1.findFrontmatterDescription)('description: hello\t# drop').value).toBe('hello');
65
+ });
66
+ it('keeps NBSP before # (matches js-yaml)', () => {
67
+ const fm = 'description: hello\u00A0# keep';
68
+ const ours = (0, frontmatter_1.findFrontmatterDescription)(fm).value;
69
+ const doc = js_yaml_1.default.load(fm);
70
+ expect(ours).toBe(doc.description);
71
+ expect(ours).toContain('\u00A0');
72
+ expect(ours).toContain('# keep');
73
+ });
74
+ it('keeps # with no whitespace before it (C#)', () => {
75
+ expect((0, frontmatter_1.findFrontmatterDescription)('description: usar C# y F#').value).toBe('usar C# y F#');
76
+ });
77
+ });
78
+ describe('FH-1: plain description comment strip is linear-time', () => {
79
+ it('scales linearly with long runs of spaces and no #', () => {
80
+ (0, helpers_1.expectLinearScaling)((n) => {
81
+ (0, frontmatter_1.findFrontmatterDescription)('description: a' + ' '.repeat(n) + 'x');
82
+ }, 20_000);
83
+ });
84
+ it('scales linearly with long runs of tabs and no #', () => {
85
+ (0, helpers_1.expectLinearScaling)((n) => {
86
+ (0, frontmatter_1.findFrontmatterDescription)('description: a' + '\t'.repeat(n) + 'x');
87
+ }, 20_000);
88
+ });
89
+ });
90
+ });
@@ -58,6 +58,159 @@ describe('parseProcessFrontmatter', () => {
58
58
  const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', 'name: ../../etc/passwd'), 'p');
59
59
  expect(r.model).toBeUndefined();
60
60
  });
61
+ describe('YAML string slugs (FH-4)', () => {
62
+ const YAML_STRING = /YAML string scalar|quote values YAML reads as null, boolean, or number/i;
63
+ it.each(['null', 'true', '0x1f', '1e5', '01'])('rejects bare YAML non-string name: %s', (raw) => {
64
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ${raw}`), 'p');
65
+ expect(r.model).toBeUndefined();
66
+ expect(r.diagnostics.join(' ')).toMatch(YAML_STRING);
67
+ expect(r.diagnostics.join(' ')).toMatch(/"name"/);
68
+ });
69
+ it.each([
70
+ ['"null"', 'null'],
71
+ ['"demo-ok"', 'demo-ok'],
72
+ ])('accepts quoted YAML string name %s', (raw, expected) => {
73
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ${raw}`), 'p');
74
+ expect(r.diagnostics).toEqual([]);
75
+ expect(r.model?.name).toBe(expected);
76
+ });
77
+ it('rejects terminates_to loaded as a non-string', () => {
78
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('terminates_to: none', 'terminates_to: 0x1f'), 'p');
79
+ expect(r.model).toBeUndefined();
80
+ expect(r.diagnostics.join(' ')).toMatch(YAML_STRING);
81
+ expect(r.diagnostics.join(' ')).toMatch(/"terminates_to"/);
82
+ });
83
+ it.each(['none', '"none"'])('accepts terminates_to %s as none', (raw) => {
84
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('terminates_to: none', `terminates_to: ${raw}`), 'p');
85
+ expect(r.diagnostics).toEqual([]);
86
+ expect(r.model?.terminatesTo).toBe('none');
87
+ });
88
+ it('rejects a nested-alias bomb in name without calling yaml.load on it', () => {
89
+ // Classic self-referential alias expansion; the pre-load guard must
90
+ // refuse &/* (and never hang) before js-yaml sees the value.
91
+ const bomb = '&a [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]';
92
+ const load = jest.spyOn(js_yaml_1.default, 'load');
93
+ try {
94
+ const started = Date.now();
95
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ${bomb}`), 'p');
96
+ expect(Date.now() - started).toBeLessThan(500);
97
+ expect(r.model).toBeUndefined();
98
+ expect(r.diagnostics.join(' ')).toMatch(YAML_STRING);
99
+ expect(r.diagnostics.join(' ')).toMatch(/"name"/);
100
+ expect(load.mock.calls.some(([arg]) => String(arg).includes(bomb))).toBe(false);
101
+ expect(load.mock.calls.some(([arg]) => /[&*]/.test(String(arg)))).toBe(false);
102
+ }
103
+ finally {
104
+ load.mockRestore();
105
+ }
106
+ });
107
+ it('rejects a name raw longer than 128 characters without calling yaml.load on it', () => {
108
+ const raw = `"${'a'.repeat(129)}"`;
109
+ expect(raw.length).toBeGreaterThan(128);
110
+ const load = jest.spyOn(js_yaml_1.default, 'load');
111
+ try {
112
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ${raw}`), 'p');
113
+ expect(r.model).toBeUndefined();
114
+ expect(r.diagnostics.join(' ')).toMatch(YAML_STRING);
115
+ expect(load.mock.calls.some(([arg]) => String(arg).includes(raw))).toBe(false);
116
+ }
117
+ finally {
118
+ load.mockRestore();
119
+ }
120
+ });
121
+ it('accepts name with a trailing plain comment that contains * (guard strips comment)', () => {
122
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', 'name: my-proc # tag*'), 'p');
123
+ expect(r.diagnostics).toEqual([]);
124
+ expect(r.model?.name).toBe('my-proc');
125
+ });
126
+ it('rejects a description block with an alias-shaped token without calling yaml.load on the block', () => {
127
+ // Alias token lives in a comment so readPairs stays clean; loadBlock must
128
+ // refuse before yaml.load (same alias class as FH-4 slug guard). Slug
129
+ // loads (`v: …`) may still run; the full block must not.
130
+ const withDesc = valid.replace('updated: 2026-08-23', 'updated: 2026-08-23\ndescription: "ok"\n# &hostile_alias');
131
+ const load = jest.spyOn(js_yaml_1.default, 'load');
132
+ try {
133
+ const r = (0, model_1.parseProcessFrontmatter)(withDesc, 'p');
134
+ expect(r.model).toBeUndefined();
135
+ expect(r.diagnostics.join(' ')).toMatch(/"description" cannot be read: the frontmatter is not valid YAML/);
136
+ expect(load.mock.calls.some(([arg]) => String(arg).includes('&hostile_alias'))).toBe(false);
137
+ expect(load.mock.calls.some(([arg]) => String(arg).includes('description:'))).toBe(false);
138
+ }
139
+ finally {
140
+ load.mockRestore();
141
+ }
142
+ });
143
+ it('accepts a quoted description with bare & that is not an alias token', () => {
144
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('updated: 2026-08-23', 'updated: 2026-08-23\ndescription: "foo & bar"'), 'p');
145
+ expect(r.diagnostics).toEqual([]);
146
+ expect(r.model?.description).toBe('foo & bar');
147
+ });
148
+ });
149
+ describe('bounded frontmatter diagnostics (FH-5, FH-6)', () => {
150
+ const FRONTMATTER_MAX = 65536;
151
+ const blockOf = (source) => source.slice(4, source.indexOf('\n---', 4));
152
+ const withBlockLength = (source, target) => {
153
+ const pad = target - blockOf(source).length - 3;
154
+ const next = source.replace('\ncreated:', `\n# ${'x'.repeat(pad)}\ncreated:`);
155
+ expect(blockOf(next)).toHaveLength(target);
156
+ return next;
157
+ };
158
+ it('rejects an oversized process-model block with a size diagnostic and no model', () => {
159
+ const source = withBlockLength(valid, FRONTMATTER_MAX + 1);
160
+ const load = jest.spyOn(js_yaml_1.default, 'load');
161
+ try {
162
+ const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
163
+ expect(r.model).toBeUndefined();
164
+ expect(r.diagnostics.join(' ')).toMatch(/frontmatter exceeds 65536 characters/);
165
+ expect(load).not.toHaveBeenCalled();
166
+ }
167
+ finally {
168
+ load.mockRestore();
169
+ }
170
+ });
171
+ it('stays silent for an oversized block without the process-model discriminator and never calls yaml.load', () => {
172
+ const source = withBlockLength(valid.replace('awm: process-model\n', ''), FRONTMATTER_MAX + 1);
173
+ const load = jest.spyOn(js_yaml_1.default, 'load');
174
+ try {
175
+ const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
176
+ expect(r.model).toBeUndefined();
177
+ expect(r.diagnostics).toEqual([]);
178
+ expect(load).not.toHaveBeenCalled();
179
+ }
180
+ finally {
181
+ load.mockRestore();
182
+ }
183
+ });
184
+ it('caps process-model problems at 20 and reports the remainder count', () => {
185
+ const extras = Array.from({ length: 25 }, (_, i) => `unknown_${i}: x`).join('\n');
186
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('created:', `${extras}\ncreated:`), 'p');
187
+ expect(r.model).toBeUndefined();
188
+ const joined = r.diagnostics.join(' ');
189
+ const unknownMentions = joined.match(/unknown field/g) ?? [];
190
+ expect(unknownMentions.length).toBe(20);
191
+ expect(joined).toContain('5 more problems');
192
+ });
193
+ it('truncates unknown field keys longer than 64 characters with an ellipsis', () => {
194
+ const longKey = `k${'a'.repeat(199)}`;
195
+ expect(longKey).toHaveLength(200);
196
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('created:', `${longKey}: x\ncreated:`), 'p');
197
+ expect(r.model).toBeUndefined();
198
+ const joined = r.diagnostics.join(' ');
199
+ expect(joined).toMatch(/unknown field/);
200
+ expect(joined).toContain(`${'a'.repeat(63)}…`);
201
+ expect(joined).not.toContain(longKey);
202
+ });
203
+ it('truncates duplicate field keys longer than 64 characters with an ellipsis', () => {
204
+ const longKey = `d${'b'.repeat(199)}`;
205
+ expect(longKey).toHaveLength(200);
206
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('created:', `${longKey}: a\n${longKey}: b\ncreated:`), 'p');
207
+ expect(r.model).toBeUndefined();
208
+ const joined = r.diagnostics.join(' ');
209
+ expect(joined).toMatch(/duplicate field/);
210
+ expect(joined).toContain(`${'b'.repeat(63)}…`);
211
+ expect(joined).not.toContain(longKey);
212
+ });
213
+ });
61
214
  it('nunca lanza ante entrada basura', () => {
62
215
  for (const junk of ['', '---\n', '---\n\x00\n---\n', 'no frontmatter']) {
63
216
  expect(() => (0, model_1.parseProcessFrontmatter)(junk, 'p')).not.toThrow();
@@ -208,13 +361,15 @@ describe('parseProcessFrontmatter', () => {
208
361
  expect(r.diagnostics).toEqual([]);
209
362
  expect(r.model?.description).toBe('ok');
210
363
  });
211
- it('rejects a frontmatter block over 65536 characters with the 1024 message without parsing it', () => {
364
+ it('rejects a frontmatter block over 65536 characters at the size gate without parsing it', () => {
365
+ // FH-5: process-model discriminator + oversized block → size diagnostic
366
+ // before readPairs / description YAML load (no longer the 1024 path).
212
367
  const source = withBlockLength(65537);
213
368
  const load = jest.spyOn(js_yaml_1.default, 'load');
214
369
  try {
215
370
  const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
216
371
  expect(r.model).toBeUndefined();
217
- expect(r.diagnostics.join(' ')).toMatch(/"description" must be at most 1024 characters/);
372
+ expect(r.diagnostics.join(' ')).toMatch(/frontmatter exceeds 65536 characters/);
218
373
  expect(load).not.toHaveBeenCalled();
219
374
  }
220
375
  finally {
@@ -404,13 +559,15 @@ describe('parseProcessFrontmatter', () => {
404
559
  expect(r.model).toBeUndefined();
405
560
  expect(r.diagnostics.join(' ')).not.toMatch(/must not be indented/);
406
561
  });
407
- it('reports an indented discriminator in a block over 65536 characters without loading it', () => {
562
+ it('rejects an oversized indented process-model with a size diagnostic and never calls yaml.load', () => {
563
+ // FH-5 (QA amend): indented awm: process-model in an oversized/unclosed
564
+ // prefix must surface the size diagnostic, not silence.
408
565
  const source = indentAll(valid.replace('\ncreated:', `\n# ${'x'.repeat(65536)}\ncreated:`));
409
566
  const load = jest.spyOn(js_yaml_1.default, 'load');
410
567
  try {
411
- expect((0, model_1.parseProcessFrontmatter)(source, 'p')).toEqual({
412
- diagnostics: ['p: invalid process model — frontmatter keys must not be indented (frontmatter line 1)'],
413
- });
568
+ const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
569
+ expect(r.model).toBeUndefined();
570
+ expect(r.diagnostics.join(' ')).toMatch(/frontmatter exceeds 65536 characters/);
414
571
  expect(load).not.toHaveBeenCalled();
415
572
  }
416
573
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.20.2",
3
+ "version": "9.20.3",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
Binary file
Binary file