@kontextmind/kxm 0.6.0 → 0.7.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/agents/coordinator.yaml +9 -0
  3. package/.kxm/agents/critic-arch.yaml +13 -0
  4. package/.kxm/agents/critic-cli.yaml +13 -0
  5. package/.kxm/agents/implementer.yaml +13 -0
  6. package/.kxm/gates.yaml +8 -0
  7. package/.kxm/producers.yaml +22 -0
  8. package/.kxm/project.yaml +15 -0
  9. package/.kxm/roles/writer.yaml +5 -0
  10. package/.kxm/workflows/default.yaml +47 -0
  11. package/CHANGELOG.md +39 -7
  12. package/README.md +1 -0
  13. package/docs/README.md +2 -0
  14. package/docs/agent-skills.md +118 -0
  15. package/docs/architecture.md +1 -1
  16. package/docs/assignment-runner.md +21 -8
  17. package/docs/configuration.md +2 -2
  18. package/docs/kxm-handbook.md +3 -3
  19. package/docs/operator-pi-packages.md +63 -0
  20. package/docs/skills/repo-work-delivery.md +102 -0
  21. package/docs/test-matrix.md +4 -3
  22. package/docs/troubleshooting.md +19 -0
  23. package/docs/vnext/validation.md +9 -0
  24. package/docs/webhook-workflows.md +2 -2
  25. package/examples/README.md +1 -1
  26. package/package.json +16 -17
  27. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  28. package/plugins/kxm/README.md +1 -1
  29. package/plugins/kxm/dist/cli.js +6875 -3672
  30. package/plugins/kxm/dist/core.js +214 -34
  31. package/plugins/kxm/dist/extension.js +7721 -85
  32. package/plugins/kxm/dist/mcp-server.js +75 -21
  33. package/plugins/kxm/dist/runtime.js +5403 -1014
  34. package/plugins/kxm/dist/server.js +3008 -2268
  35. package/plugins/kxm/dist/vnext-runtime-supervisor.js +5751 -631
  36. package/plugins/kxm/package.json +1 -1
  37. package/plugins/kxm/skills/SUITE.md +5 -0
  38. package/plugins/kxm/skills/hints.json +73 -0
  39. package/plugins/kxm/skills/kxm/SKILL.md +30 -83
  40. package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
  41. package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
  42. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
  43. package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
  44. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +34 -0
  45. package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
  46. package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
  47. package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
  48. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +35 -0
  49. package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
  50. package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
  51. package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
  52. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
  53. package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
  54. package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
  55. package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
  56. package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
  57. package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
  58. package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
  59. package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
  60. package/plugins/kxm/src/autocomplete.ts +9 -3
  61. package/plugins/kxm/src/cli.ts +1077 -11
  62. package/plugins/kxm/src/commands.ts +150 -8
  63. package/plugins/kxm/src/config.ts +7 -4
  64. package/plugins/kxm/src/context-packet.ts +172 -0
  65. package/plugins/kxm/src/extension.ts +36 -1
  66. package/plugins/kxm/src/external-effects.ts +356 -7
  67. package/plugins/kxm/src/hub.ts +2 -4
  68. package/plugins/kxm/src/improve.ts +72 -0
  69. package/plugins/kxm/src/mcp-server.ts +1 -1
  70. package/plugins/kxm/src/model-inventory.ts +127 -0
  71. package/plugins/kxm/src/policy-draft.d.mts +55 -0
  72. package/plugins/kxm/src/policy-draft.mjs +565 -0
  73. package/plugins/kxm/src/price-calc.ts +17 -18
  74. package/plugins/kxm/src/prices.ts +32 -16
  75. package/plugins/kxm/src/producers.ts +71 -0
  76. package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
  77. package/plugins/kxm/src/restricted-yaml.mjs +145 -0
  78. package/plugins/kxm/src/role.ts +375 -0
  79. package/plugins/kxm/src/routing.ts +99 -1
  80. package/plugins/kxm/src/session-work.ts +9 -2
  81. package/plugins/kxm/src/studio-layout.ts +660 -17
  82. package/plugins/kxm/src/suggest.ts +7 -13
  83. package/plugins/kxm/src/telemetry.ts +82 -0
  84. package/plugins/kxm/src/tui.ts +140 -0
  85. package/plugins/kxm/src/vnext-config.ts +15 -110
  86. package/plugins/kxm/src/vnext-engine.ts +198 -62
  87. package/plugins/kxm/src/vnext-harness.ts +263 -81
  88. package/plugins/kxm/src/vnext-oneshot-evidence.ts +85 -0
  89. package/plugins/kxm/src/vnext-oneshot-process.ts +149 -0
  90. package/plugins/kxm/src/vnext-oneshot-producer.ts +170 -233
  91. package/plugins/kxm/src/vnext-runtime-store.ts +35 -1
  92. package/plugins/kxm/src/vnext-runtime-supervisor.ts +120 -5
  93. package/plugins/kxm/src/vnext-runtime.ts +14 -0
  94. package/plugins/kxm/src/workflow-manager.ts +392 -0
  95. package/plugins/kxm/src/workflow-tui.ts +255 -0
  96. package/schemas/policy-draft/README.md +17 -0
  97. package/schemas/policy-draft/model.v2.schema.json +140 -0
  98. package/schemas/policy-draft/role.v2.schema.json +91 -0
  99. package/schemas/vnext/role.schema.json +76 -0
  100. package/schemas/vnext/run-event.schema.json +1 -0
  101. package/scripts/assignment-run.d.mts +1 -1
  102. package/scripts/assignment-run.mjs +44 -35
  103. package/scripts/check-generated.mjs +33 -9
  104. package/scripts/emit-codex-artifacts.mjs +255 -11
  105. package/scripts/harness-run.d.mts +12 -4
  106. package/scripts/harness-run.mjs +65 -17
  107. package/scripts/kxm-hub.mjs +6 -0
  108. package/scripts/native-critic.d.mts +5 -0
  109. package/scripts/native-critic.mjs +60 -0
  110. package/.kxm/config/README.md +0 -5
  111. package/.kxm/config/agents.json +0 -43
  112. package/.kxm/config/env.example +0 -56
  113. package/.kxm/config/update.example.yaml +0 -9
  114. package/.kxm/config/workflows/fix.json +0 -160
  115. package/.kxm/config/workflows/jira-development.json +0 -116
  116. package/.kxm/config/workflows/provenance-quorum.json +0 -150
  117. package/.kxm/config/workflows/v04-dogfood.json +0 -72
@@ -29,10 +29,7 @@ export function findModelPrice(catalog: PriceCatalog, model: string, provider?:
29
29
  const normalizedProvider = provider?.trim().toLowerCase();
30
30
 
31
31
  for (const entry of catalog.models) {
32
- if (normalizedProvider && entry.provider.toLowerCase() !== normalizedProvider) {
33
- const matchesAlias = entry.aliases?.some((a) => a.toLowerCase() === normalizedModel);
34
- if (!matchesAlias) continue;
35
- }
32
+ if (normalizedProvider && entry.provider.toLowerCase() !== normalizedProvider) continue;
36
33
  if (entry.id.toLowerCase() === normalizedModel || entry.model.toLowerCase() === normalizedModel) {
37
34
  return entry;
38
35
  }
@@ -58,20 +55,21 @@ export function calculateModelCost(
58
55
  const row = findModelPrice(catalog, params.model, params.provider);
59
56
  if (!row || row.tiers.length === 0) return undefined;
60
57
 
61
- const context = params.contextTokens ?? params.tokensIn ?? 0;
62
- let selectedTier = row.tiers[0]!;
63
- for (const tier of row.tiers) {
64
- if (tier.upToContextTokens !== undefined && tier.upToContextTokens !== null && context > tier.upToContextTokens) {
65
- continue;
66
- }
67
- selectedTier = tier;
68
- break;
69
- }
70
-
71
- const tokensIn = params.tokensIn ?? 0;
72
- const tokensOut = params.tokensOut ?? 0;
73
- const cacheRead = params.cacheReadTokens ?? 0;
74
- const cacheWrite = params.cacheWriteTokens ?? 0;
58
+ if (catalog.currency !== undefined && catalog.currency !== "USD") return undefined;
59
+ const validCount = (n: unknown): n is number => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
60
+ const tokensIn = params.tokensIn;
61
+ const tokensOut = params.tokensOut;
62
+ const cacheRead = params.cacheReadTokens;
63
+ const cacheWrite = params.cacheWriteTokens;
64
+ if (!validCount(tokensIn) || !validCount(tokensOut) || !validCount(cacheRead) || !validCount(cacheWrite)) return undefined;
65
+ // Cumulative usage is not context occupancy. Only an unbounded flat tier can
66
+ // be selected when the caller has no context measurement.
67
+ const context = params.contextTokens;
68
+ if (context != null && !validCount(context)) return undefined;
69
+ const selectedTier = context == null
70
+ ? (row.tiers.length === 1 && row.tiers[0]!.upToContextTokens == null ? row.tiers[0] : undefined)
71
+ : row.tiers.find((tier) => tier.upToContextTokens == null || context <= tier.upToContextTokens);
72
+ if (!selectedTier || (cacheRead > 0 && selectedTier.cacheReadPerMillion == null) || (cacheWrite > 0 && selectedTier.cacheWritePerMillion == null)) return undefined;
75
73
 
76
74
  const cost =
77
75
  (tokensIn / 1_000_000) * selectedTier.inputPerMillion +
@@ -79,6 +77,7 @@ export function calculateModelCost(
79
77
  (cacheRead / 1_000_000) * (selectedTier.cacheReadPerMillion ?? 0) +
80
78
  (cacheWrite / 1_000_000) * (selectedTier.cacheWritePerMillion ?? 0);
81
79
 
80
+ if (!Number.isFinite(cost) || cost < 0) return undefined;
82
81
  const priceRef = `${catalog.date}#${row.id}`;
83
82
  return {
84
83
  costUsd: Math.round(cost * 1_000_000) / 1_000_000,
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parse } from "yaml";
5
5
 
@@ -40,6 +40,10 @@ export function parsePriceCatalog(text: string): PriceCatalog {
40
40
  if (typeof parsed.date !== "string" || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(parsed.date)) {
41
41
  throw new Error("price catalog date must be YYYY-MM-DD");
42
42
  }
43
+ if (!Number.isFinite(Date.parse(parsed.date)) || new Date(parsed.date).toISOString().slice(0, 10) !== parsed.date) {
44
+ throw new Error("price catalog date must be a valid calendar date");
45
+ }
46
+ if (parsed.currency !== undefined && parsed.currency !== "USD") throw new Error("price catalog currency must be USD");
43
47
  if (typeof parsed.sha256 !== "string" || !/^(?:sha256:)?[a-f0-9]{64}$/.test(parsed.sha256)) {
44
48
  throw new Error("price catalog sha256 must be a 64-character hex digest");
45
49
  }
@@ -66,25 +70,34 @@ export function parsePriceCatalog(text: string): PriceCatalog {
66
70
  if (!Array.isArray(m.tiers) || m.tiers.length === 0) {
67
71
  throw new Error(`price catalog model ${m.id} must have at least one tier`);
68
72
  }
69
- const tiers: PriceTier[] = m.tiers.map((tItem, tIndex) => {
73
+ const rawTiers = m.tiers;
74
+ let previousBound = 0;
75
+ const tiers: PriceTier[] = rawTiers.map((tItem, tIndex) => {
70
76
  if (!tItem || typeof tItem !== "object" || Array.isArray(tItem)) {
71
77
  throw new Error(`tier at index ${tIndex} for model ${m.id} must be an object`);
72
78
  }
73
79
  const t = tItem as Record<string, unknown>;
74
- const inputRate = typeof t.inputPerMillion === "number" ? t.inputPerMillion : (typeof t.input === "number" ? t.input : undefined);
75
- const outputRate = typeof t.outputPerMillion === "number" ? t.outputPerMillion : (typeof t.output === "number" ? t.output : undefined);
76
- if (inputRate === undefined || inputRate < 0) {
77
- throw new Error(`tier at index ${tIndex} for model ${m.id} must have a non-negative inputPerMillion`);
78
- }
79
- if (outputRate === undefined || outputRate < 0) {
80
- throw new Error(`tier at index ${tIndex} for model ${m.id} must have a non-negative outputPerMillion`);
80
+ const rate = (field: string, short: string, required = false): number | null => {
81
+ const value = t[field] !== undefined ? t[field] : t[short];
82
+ if (value == null && !required) return null;
83
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
84
+ throw new Error(`tier ${tIndex} for model ${m.id} requires finite non-negative ${field}`);
85
+ }
86
+ return value;
87
+ };
88
+ const bound = t.upToContextTokens ?? null;
89
+ if (bound === null) {
90
+ if (tIndex !== rawTiers.length - 1) throw new Error("unbounded price tier must be last");
91
+ } else {
92
+ if (typeof bound !== "number" || !Number.isSafeInteger(bound) || bound <= previousBound) throw new Error("price tier bounds must be positive and strictly increasing");
93
+ previousBound = bound;
81
94
  }
82
95
  return {
83
- upToContextTokens: typeof t.upToContextTokens === "number" ? t.upToContextTokens : null,
84
- inputPerMillion: inputRate,
85
- outputPerMillion: outputRate,
86
- cacheReadPerMillion: typeof t.cacheReadPerMillion === "number" ? t.cacheReadPerMillion : (typeof t.cacheRead === "number" ? t.cacheRead : null),
87
- cacheWritePerMillion: typeof t.cacheWritePerMillion === "number" ? t.cacheWritePerMillion : (typeof t.cacheWrite === "number" ? t.cacheWrite : null),
96
+ upToContextTokens: bound,
97
+ inputPerMillion: rate("inputPerMillion", "input", true)!,
98
+ outputPerMillion: rate("outputPerMillion", "output", true)!,
99
+ cacheReadPerMillion: rate("cacheReadPerMillion", "cacheRead"),
100
+ cacheWritePerMillion: rate("cacheWritePerMillion", "cacheWrite"),
88
101
  };
89
102
  });
90
103
  return {
@@ -96,13 +109,16 @@ export function parsePriceCatalog(text: string): PriceCatalog {
96
109
  };
97
110
  });
98
111
 
99
- return {
112
+ const catalog: PriceCatalog = {
100
113
  schema: PRICES_SCHEMA,
101
114
  date: parsed.date,
102
115
  sha256: parsed.sha256.replace(/^sha256:/, ""),
103
116
  currency: typeof parsed.currency === "string" ? parsed.currency : "USD",
104
117
  models,
105
118
  };
119
+ if (new Set(models.map((row) => row.id)).size !== models.length) throw new Error("duplicate price catalog model id");
120
+ if (hashPriceCatalog(catalog) !== catalog.sha256) throw new Error("price catalog hash mismatch");
121
+ return catalog;
106
122
  }
107
123
 
108
124
  export function loadPriceCatalog(rootOrPath: string): PriceCatalog | undefined {
@@ -110,7 +126,7 @@ export function loadPriceCatalog(rootOrPath: string): PriceCatalog | undefined {
110
126
  ? join(rootOrPath, ".kxm", "prices.yaml")
111
127
  : (existsSync(join(rootOrPath, "prices.yaml"))
112
128
  ? join(rootOrPath, "prices.yaml")
113
- : (existsSync(rootOrPath) && !rootOrPath.endsWith("/") ? rootOrPath : undefined));
129
+ : (existsSync(rootOrPath) && statSync(rootOrPath).isFile() ? rootOrPath : undefined));
114
130
 
115
131
  if (!candidatePath || !existsSync(candidatePath)) {
116
132
  return undefined;
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse, stringify } from "yaml";
4
+
5
+ export interface ProducerPolicy { schema: "kxm.producers.v1"; updatedAt: string; promoted: string[]; demoted: string[]; enabled: string[]; disabled: string[]; roles: Record<string, string[]>; }
6
+ const empty = (): ProducerPolicy => ({ schema: "kxm.producers.v1", updatedAt: new Date().toISOString(), promoted: [], demoted: [], enabled: [], disabled: [], roles: {} });
7
+ export function loadProducerPolicy(root: string): ProducerPolicy {
8
+ const path = join(root, ".kxm", "producers.yaml");
9
+ if (!existsSync(path)) return empty();
10
+ const value = parse(readFileSync(path, "utf8")) as Partial<ProducerPolicy>;
11
+ if (value?.schema !== "kxm.producers.v1" || !Array.isArray(value.promoted) || !Array.isArray(value.demoted)) throw new Error("invalid .kxm/producers.yaml");
12
+ return { schema: "kxm.producers.v1", updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : new Date().toISOString(), promoted: value.promoted.filter((x): x is string => typeof x === "string"), demoted: value.demoted.filter((x): x is string => typeof x === "string"), enabled: Array.isArray(value.enabled) ? value.enabled.filter((x): x is string => typeof x === "string") : [], disabled: Array.isArray(value.disabled) ? value.disabled.filter((x): x is string => typeof x === "string") : [], roles: value.roles && typeof value.roles === "object" ? Object.fromEntries(Object.entries(value.roles).filter(([, v]) => Array.isArray(v)).map(([k, v]) => [k, (v as unknown[]).filter((x): x is string => typeof x === "string")])) : {} };
13
+ }
14
+ export function updateProducer(root: string, model: string, status: "promoted" | "demoted"): ProducerPolicy {
15
+ const policy = loadProducerPolicy(root);
16
+ policy.promoted = policy.promoted.filter((x) => x !== model);
17
+ policy.demoted = policy.demoted.filter((x) => x !== model);
18
+ policy[status].push(model);
19
+ policy.updatedAt = new Date().toISOString();
20
+ mkdirSync(join(root, ".kxm"), { recursive: true });
21
+ writeFileSync(join(root, ".kxm", "producers.yaml"), stringify(policy), "utf8");
22
+ return policy;
23
+ }
24
+ export function setModelState(root: string, model: string, state: "enabled" | "disabled", role?: string, removeRole = false): ProducerPolicy {
25
+ const policy = loadProducerPolicy(root);
26
+ policy.enabled = policy.enabled.filter((x) => x !== model);
27
+ policy.disabled = policy.disabled.filter((x) => x !== model);
28
+ policy[state].push(model);
29
+ if (role) {
30
+ if (!/^[a-z][a-z0-9_-]{0,63}$/i.test(role)) throw new Error("invalid role id");
31
+ const rolePath = join(root, ".kxm", "roles", `${role}.yaml`);
32
+ let roleFile: Record<string, unknown> = {};
33
+ if (existsSync(rolePath)) {
34
+ const parsed = parse(readFileSync(rolePath, "utf8"));
35
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) roleFile = parsed as Record<string, unknown>;
36
+ }
37
+ const roster = Array.isArray(roleFile.roster) ? roleFile.roster.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
38
+ const existing = roster.findIndex((entry) => entry.model === model);
39
+ if (removeRole) { if (existing >= 0) roster.splice(existing, 1); }
40
+ else if (existing < 0) roster.push({ model, enabled: true });
41
+ roleFile.schema ??= "kxm.role.v1"; roleFile.id ??= role; roleFile.roster = roster;
42
+ mkdirSync(join(root, ".kxm", "roles"), { recursive: true });
43
+ writeFileSync(rolePath, stringify(roleFile), "utf8");
44
+ }
45
+ policy.updatedAt = new Date().toISOString();
46
+ mkdirSync(join(root, ".kxm"), { recursive: true });
47
+ writeFileSync(join(root, ".kxm", "producers.yaml"), stringify(policy), "utf8");
48
+ return policy;
49
+ }
50
+
51
+ export function listRoleBindings(root: string): Record<string, string[]> {
52
+ const dir = join(root, ".kxm", "roles"); const result: Record<string, string[]> = {};
53
+ if (!existsSync(dir)) return result;
54
+ for (const file of readdirSync(dir).filter((name) => name.endsWith(".yaml"))) {
55
+ const role = file.slice(0, -5); const value = parse(readFileSync(join(dir, file), "utf8")) as { roster?: Array<{ model?: unknown }> };
56
+ result[role] = (value.roster ?? []).map((entry) => entry.model).filter((model): model is string => typeof model === "string");
57
+ }
58
+ return result;
59
+ }
60
+
61
+ export function isProducerAdmitted(root: string, modelId: string): boolean {
62
+ const policy = loadProducerPolicy(root);
63
+ return policy.enabled.includes(modelId) && policy.promoted.includes(modelId) && !policy.disabled.includes(modelId) && !policy.demoted.includes(modelId);
64
+ }
65
+
66
+ export function listInventoryModels(root: string): string[] {
67
+ const path = join(root, ".kxm", "models", "inventory.yaml");
68
+ if (!existsSync(path)) return [];
69
+ const value = parse(readFileSync(path, "utf8")) as { models?: Array<{ id?: unknown }> };
70
+ return (value.models ?? []).map((m) => m.id).filter((id): id is string => typeof id === "string");
71
+ }
@@ -0,0 +1,31 @@
1
+ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
2
+ export type JsonObject = { [key: string]: JsonValue };
3
+
4
+ export interface VnextYamlLimits {
5
+ maxDocumentBytes: number;
6
+ maxDepth: number;
7
+ maxScalarBytes: number;
8
+ maxCollectionItems: number;
9
+ maxTotalNodes: number;
10
+ maxKeys: number;
11
+ }
12
+
13
+ export const VNEXT_YAML_LIMITS: Readonly<VnextYamlLimits>;
14
+
15
+ export interface RestrictedYamlIssue {
16
+ phase: "parse";
17
+ code: string;
18
+ file: string;
19
+ message: string;
20
+ }
21
+
22
+ export class RestrictedYamlError extends Error {
23
+ readonly issues: readonly RestrictedYamlIssue[];
24
+ constructor(issues: readonly RestrictedYamlIssue[]);
25
+ }
26
+
27
+ export function parseRestrictedYaml(
28
+ input: string | Uint8Array,
29
+ label?: string,
30
+ limits?: Readonly<VnextYamlLimits>,
31
+ ): JsonObject;
@@ -0,0 +1,145 @@
1
+ import { isAlias, isCollection, isMap, isScalar, parseDocument, visit } from "yaml";
2
+
3
+ /** Restricted JSON-compatible YAML limits used by vNext configuration. */
4
+ export const VNEXT_YAML_LIMITS = Object.freeze({
5
+ maxDocumentBytes: 256 * 1024,
6
+ maxDepth: 32,
7
+ maxScalarBytes: 64 * 1024,
8
+ maxCollectionItems: 4096,
9
+ maxTotalNodes: 16_384,
10
+ maxKeys: 8192,
11
+ });
12
+
13
+ const ALLOWED_YAML_TAGS = new Set([
14
+ "tag:yaml.org,2002:map",
15
+ "tag:yaml.org,2002:seq",
16
+ "tag:yaml.org,2002:str",
17
+ "tag:yaml.org,2002:null",
18
+ "tag:yaml.org,2002:bool",
19
+ "tag:yaml.org,2002:int",
20
+ "tag:yaml.org,2002:float",
21
+ ]);
22
+
23
+ function compareCodeUnits(left, right) {
24
+ return left < right ? -1 : left > right ? 1 : 0;
25
+ }
26
+
27
+ function sortIssues(issues) {
28
+ return [...issues].sort((left, right) => compareCodeUnits(left.file, right.file)
29
+ || compareCodeUnits(left.phase, right.phase)
30
+ || compareCodeUnits(left.code, right.code)
31
+ || compareCodeUnits(left.message, right.message));
32
+ }
33
+
34
+ function issue(phase, code, file, message) {
35
+ return { phase, code, file, message };
36
+ }
37
+
38
+ export class RestrictedYamlError extends Error {
39
+ /**
40
+ * @param {readonly { phase: string, code: string, file: string, message: string }[]} issues
41
+ */
42
+ constructor(issues) {
43
+ const sorted = sortIssues(issues);
44
+ super(sorted.map((entry) => `${entry.file}: ${entry.code}: ${entry.message}`).join("\n"));
45
+ this.name = "RestrictedYamlError";
46
+ this.issues = sorted;
47
+ }
48
+ }
49
+
50
+ function fail(phase, code, file, message) {
51
+ throw new RestrictedYamlError([issue(phase, code, file, message)]);
52
+ }
53
+
54
+ function decodeUtf8(input, label) {
55
+ if (typeof input === "string") return input;
56
+ try {
57
+ return new TextDecoder("utf-8", { fatal: true }).decode(input);
58
+ } catch {
59
+ fail("parse", "invalid_utf8", label, "document is not valid UTF-8");
60
+ }
61
+ }
62
+
63
+ function isJsonObject(value) {
64
+ return value !== null && typeof value === "object" && !Array.isArray(value);
65
+ }
66
+
67
+ function assertJsonValue(value, label, path = "$", seen = new Set()) {
68
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
69
+ if (typeof value === "number") {
70
+ if (!Number.isFinite(value)) fail("parse", "non_json_number", label, `${path} is not a finite JSON number`);
71
+ return;
72
+ }
73
+ if (!value || typeof value !== "object") fail("parse", "non_json_value", label, `${path} is not JSON-compatible`);
74
+ if (seen.has(value)) fail("parse", "cyclic_value", label, `${path} is cyclic`);
75
+ seen.add(value);
76
+ if (Array.isArray(value)) {
77
+ for (const [index, candidate] of value.entries()) assertJsonValue(candidate, label, `${path}[${index}]`, seen);
78
+ } else {
79
+ const prototype = Object.getPrototypeOf(value);
80
+ if (prototype !== Object.prototype && prototype !== null) {
81
+ fail("parse", "constructed_object", label, `${path} has a forbidden constructed type`);
82
+ }
83
+ for (const [key, candidate] of Object.entries(value)) assertJsonValue(candidate, label, `${path}.${key}`, seen);
84
+ }
85
+ seen.delete(value);
86
+ }
87
+
88
+ /** Parse the restricted, JSON-compatible YAML profile used by all vNext configuration. */
89
+ export function parseRestrictedYaml(
90
+ input,
91
+ label = "<yaml>",
92
+ limits = VNEXT_YAML_LIMITS,
93
+ ) {
94
+ const byteLength = typeof input === "string" ? Buffer.byteLength(input, "utf8") : input.byteLength;
95
+ if (byteLength > limits.maxDocumentBytes) {
96
+ fail("parse", "document_too_large", label, `document exceeds ${limits.maxDocumentBytes} bytes`);
97
+ }
98
+ const text = decodeUtf8(input, label);
99
+ const document = parseDocument(text, {
100
+ customTags: [],
101
+ strict: true,
102
+ uniqueKeys: true,
103
+ });
104
+ if (document.errors.length > 0) {
105
+ fail("parse", "invalid_yaml", label, document.errors.map((error) => error.message).join("; "));
106
+ }
107
+ if (document.warnings.length > 0) {
108
+ fail("parse", "yaml_warning", label, document.warnings.map((warning) => warning.message).join("; "));
109
+ }
110
+
111
+ let nodes = 0;
112
+ let keys = 0;
113
+ visit(document, (_key, node, path) => {
114
+ nodes += 1;
115
+ if (nodes > limits.maxTotalNodes) fail("parse", "node_limit", label, `document exceeds ${limits.maxTotalNodes} nodes`);
116
+ if (path.length > limits.maxDepth) fail("parse", "depth_limit", label, `document exceeds nesting depth ${limits.maxDepth}`);
117
+ if (isAlias(node)) fail("parse", "alias_forbidden", label, "aliases are forbidden");
118
+ if (node && typeof node === "object" && "anchor" in node && typeof node.anchor === "string") {
119
+ fail("parse", "anchor_forbidden", label, "anchors are forbidden");
120
+ }
121
+ if (isCollection(node) && node.items.length > limits.maxCollectionItems) {
122
+ fail("parse", "collection_limit", label, `collection exceeds ${limits.maxCollectionItems} items`);
123
+ }
124
+ if (isMap(node)) {
125
+ keys += node.items.length;
126
+ if (keys > limits.maxKeys) fail("parse", "key_limit", label, `document exceeds ${limits.maxKeys} mapping keys`);
127
+ for (const pair of node.items) {
128
+ if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
129
+ fail("parse", "non_string_key", label, "mapping keys must be strings");
130
+ }
131
+ }
132
+ }
133
+ if (isScalar(node) && typeof node.value === "string" && Buffer.byteLength(node.value, "utf8") > limits.maxScalarBytes) {
134
+ fail("parse", "scalar_limit", label, `scalar exceeds ${limits.maxScalarBytes} bytes`);
135
+ }
136
+ if (node && typeof node === "object" && "tag" in node && typeof node.tag === "string" && !ALLOWED_YAML_TAGS.has(node.tag)) {
137
+ fail("parse", "tag_forbidden", label, `tag ${node.tag} is forbidden`);
138
+ }
139
+ });
140
+
141
+ const value = document.toJS({ maxAliasCount: 0 });
142
+ assertJsonValue(value, label);
143
+ if (!isJsonObject(value)) fail("parse", "root_not_object", label, "resource root must be a mapping");
144
+ return value;
145
+ }