@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.kxm/agents/coordinator.yaml +9 -0
- package/.kxm/agents/critic-arch.yaml +13 -0
- package/.kxm/agents/critic-cli.yaml +13 -0
- package/.kxm/agents/implementer.yaml +13 -0
- package/.kxm/gates.yaml +8 -0
- package/.kxm/producers.yaml +22 -0
- package/.kxm/project.yaml +15 -0
- package/.kxm/roles/writer.yaml +5 -0
- package/.kxm/workflows/default.yaml +47 -0
- package/CHANGELOG.md +39 -7
- package/README.md +1 -0
- package/docs/README.md +2 -0
- package/docs/agent-skills.md +118 -0
- package/docs/architecture.md +1 -1
- package/docs/assignment-runner.md +21 -8
- package/docs/configuration.md +2 -2
- package/docs/kxm-handbook.md +3 -3
- package/docs/operator-pi-packages.md +63 -0
- package/docs/skills/repo-work-delivery.md +102 -0
- package/docs/test-matrix.md +4 -3
- package/docs/troubleshooting.md +19 -0
- package/docs/vnext/validation.md +9 -0
- package/docs/webhook-workflows.md +2 -2
- package/examples/README.md +1 -1
- package/package.json +16 -17
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/README.md +1 -1
- package/plugins/kxm/dist/cli.js +6875 -3672
- package/plugins/kxm/dist/core.js +214 -34
- package/plugins/kxm/dist/extension.js +7721 -85
- package/plugins/kxm/dist/mcp-server.js +75 -21
- package/plugins/kxm/dist/runtime.js +5403 -1014
- package/plugins/kxm/dist/server.js +3008 -2268
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +5751 -631
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/SUITE.md +5 -0
- package/plugins/kxm/skills/hints.json +73 -0
- package/plugins/kxm/skills/kxm/SKILL.md +30 -83
- package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
- package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
- package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
- package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +34 -0
- package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
- package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
- package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +35 -0
- package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
- package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
- package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
- package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
- package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
- package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
- package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
- package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
- package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
- package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
- package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
- package/plugins/kxm/src/autocomplete.ts +9 -3
- package/plugins/kxm/src/cli.ts +1077 -11
- package/plugins/kxm/src/commands.ts +150 -8
- package/plugins/kxm/src/config.ts +7 -4
- package/plugins/kxm/src/context-packet.ts +172 -0
- package/plugins/kxm/src/extension.ts +36 -1
- package/plugins/kxm/src/external-effects.ts +356 -7
- package/plugins/kxm/src/hub.ts +2 -4
- package/plugins/kxm/src/improve.ts +72 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/model-inventory.ts +127 -0
- package/plugins/kxm/src/policy-draft.d.mts +55 -0
- package/plugins/kxm/src/policy-draft.mjs +565 -0
- package/plugins/kxm/src/price-calc.ts +17 -18
- package/plugins/kxm/src/prices.ts +32 -16
- package/plugins/kxm/src/producers.ts +71 -0
- package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
- package/plugins/kxm/src/restricted-yaml.mjs +145 -0
- package/plugins/kxm/src/role.ts +375 -0
- package/plugins/kxm/src/routing.ts +99 -1
- package/plugins/kxm/src/session-work.ts +9 -2
- package/plugins/kxm/src/studio-layout.ts +660 -17
- package/plugins/kxm/src/suggest.ts +7 -13
- package/plugins/kxm/src/telemetry.ts +82 -0
- package/plugins/kxm/src/tui.ts +140 -0
- package/plugins/kxm/src/vnext-config.ts +15 -110
- package/plugins/kxm/src/vnext-engine.ts +198 -62
- package/plugins/kxm/src/vnext-harness.ts +263 -81
- package/plugins/kxm/src/vnext-oneshot-evidence.ts +85 -0
- package/plugins/kxm/src/vnext-oneshot-process.ts +149 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +170 -233
- package/plugins/kxm/src/vnext-runtime-store.ts +35 -1
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +120 -5
- package/plugins/kxm/src/vnext-runtime.ts +14 -0
- package/plugins/kxm/src/workflow-manager.ts +392 -0
- package/plugins/kxm/src/workflow-tui.ts +255 -0
- package/schemas/policy-draft/README.md +17 -0
- package/schemas/policy-draft/model.v2.schema.json +140 -0
- package/schemas/policy-draft/role.v2.schema.json +91 -0
- package/schemas/vnext/role.schema.json +76 -0
- package/schemas/vnext/run-event.schema.json +1 -0
- package/scripts/assignment-run.d.mts +1 -1
- package/scripts/assignment-run.mjs +44 -35
- package/scripts/check-generated.mjs +33 -9
- package/scripts/emit-codex-artifacts.mjs +255 -11
- package/scripts/harness-run.d.mts +12 -4
- package/scripts/harness-run.mjs +65 -17
- package/scripts/kxm-hub.mjs +6 -0
- package/scripts/native-critic.d.mts +5 -0
- package/scripts/native-critic.mjs +60 -0
- package/.kxm/config/README.md +0 -5
- package/.kxm/config/agents.json +0 -43
- package/.kxm/config/env.example +0 -56
- package/.kxm/config/update.example.yaml +0 -9
- package/.kxm/config/workflows/fix.json +0 -160
- package/.kxm/config/workflows/jira-development.json +0 -116
- package/.kxm/config/workflows/provenance-quorum.json +0 -150
- 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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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:
|
|
84
|
-
inputPerMillion:
|
|
85
|
-
outputPerMillion:
|
|
86
|
-
cacheReadPerMillion:
|
|
87
|
-
cacheWritePerMillion:
|
|
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
|
-
|
|
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) &&
|
|
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
|
+
}
|