@skill-harness/adapters 0.9.0 → 0.10.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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A deliberately tiny JSON Schema evaluator, used to validate a native record
3
+ * against a *pinned producer schema document* before any semantic normalization.
4
+ *
5
+ * Why not a general validator: the only schema this has to evaluate is pi-daddy's
6
+ * canonical `ledgerVersion: 2` contract, which uses a small, closed subset of draft
7
+ * 2020-12. Interpreting the producer's own bytes is what makes "unknown top-level
8
+ * field", "bad enum member", "wrong nullability" and "requiredness drift" fail
9
+ * closed *by construction* rather than by a hand-transcribed rule that can drift.
10
+ *
11
+ * The one thing a subset evaluator must never do is silently ignore a keyword it
12
+ * does not implement — that turns a tightened contract into an unvalidated one.
13
+ * `assertSupportedSchema` therefore walks the whole document and refuses any
14
+ * keyword outside `SUPPORTED_KEYWORDS`, so a future contract construct is a loud
15
+ * failure instead of a quiet hole.
16
+ *
17
+ * Violation messages carry the instance *path* and the *schema's* expectation, and
18
+ * never the instance value: a ledger is untrusted input and an error string ends up
19
+ * in persisted diagnostics. The one name that can come from the instance is an
20
+ * undeclared property's, and that is withheld unless the contract declares it
21
+ * somewhere (see `knownFieldNames`).
22
+ */
23
+ export interface SchemaViolation {
24
+ /** Instance path, e.g. `correlation.event_seq` or `approvalSources["tool:read"]`. */
25
+ path: string;
26
+ /** What the pinned schema required. Never contains an unbounded instance value. */
27
+ message: string;
28
+ }
29
+ type Schema = Record<string, unknown>;
30
+ /**
31
+ * Refuse a schema document containing constructs this evaluator cannot enforce.
32
+ * Called once per document (memoized by the caller) so a contract bump that adds
33
+ * a keyword fails loudly rather than validating less than it claims.
34
+ */
35
+ export declare function assertSupportedSchema(schema: unknown, label: string, path?: string): void;
36
+ /**
37
+ * Validate `value` against `schema`. Returns every violation found; an empty
38
+ * array means the instance satisfies the pinned contract.
39
+ *
40
+ * `knownFieldNames` is the set of property names the caller is willing to echo in
41
+ * an "unknown field" message. A name outside it is redacted: an attacker-supplied
42
+ * key is untrusted text, but naming a field the contract *does* know elsewhere is
43
+ * what makes the message actionable.
44
+ */
45
+ export declare function validateClosedSchema(schema: Schema, value: unknown, options?: {
46
+ knownFieldNames?: ReadonlySet<string>;
47
+ }): SchemaViolation[];
48
+ /** Every property name declared anywhere in the document — safe to echo. */
49
+ export declare function declaredPropertyNames(schema: unknown): Set<string>;
50
+ export {};
@@ -0,0 +1,353 @@
1
+ /**
2
+ * A deliberately tiny JSON Schema evaluator, used to validate a native record
3
+ * against a *pinned producer schema document* before any semantic normalization.
4
+ *
5
+ * Why not a general validator: the only schema this has to evaluate is pi-daddy's
6
+ * canonical `ledgerVersion: 2` contract, which uses a small, closed subset of draft
7
+ * 2020-12. Interpreting the producer's own bytes is what makes "unknown top-level
8
+ * field", "bad enum member", "wrong nullability" and "requiredness drift" fail
9
+ * closed *by construction* rather than by a hand-transcribed rule that can drift.
10
+ *
11
+ * The one thing a subset evaluator must never do is silently ignore a keyword it
12
+ * does not implement — that turns a tightened contract into an unvalidated one.
13
+ * `assertSupportedSchema` therefore walks the whole document and refuses any
14
+ * keyword outside `SUPPORTED_KEYWORDS`, so a future contract construct is a loud
15
+ * failure instead of a quiet hole.
16
+ *
17
+ * Violation messages carry the instance *path* and the *schema's* expectation, and
18
+ * never the instance value: a ledger is untrusted input and an error string ends up
19
+ * in persisted diagnostics. The one name that can come from the instance is an
20
+ * undeclared property's, and that is withheld unless the contract declares it
21
+ * somewhere (see `knownFieldNames`).
22
+ */
23
+ import { redactText } from "@skill-harness/core";
24
+ /** Keywords carried for humans and ignored for validation. */
25
+ const ANNOTATION_KEYWORDS = new Set(["$schema", "$id", "title", "description", "$defs"]);
26
+ /** Keywords this evaluator implements. Anything else is refused, not ignored. */
27
+ const SUPPORTED_KEYWORDS = new Set([
28
+ ...ANNOTATION_KEYWORDS,
29
+ // structure
30
+ "$ref", "oneOf", "type", "properties", "required", "additionalProperties", "items",
31
+ // value constraints
32
+ "enum", "const", "minLength", "maxLength", "minimum", "pattern", "format",
33
+ ]);
34
+ /**
35
+ * The shape each supported keyword must have. A keyword whose *name* is known but
36
+ * whose *value* has an unexpected shape is the same hole as an unknown keyword:
37
+ * `required: "a"` would be skipped by the `Array.isArray` guard downstream and
38
+ * requiredness would go unenforced with nothing said.
39
+ */
40
+ const KEYWORD_SHAPES = {
41
+ $ref: { check: (value) => typeof value === "string", expected: "a string" },
42
+ oneOf: { check: (value) => Array.isArray(value) && value.length > 0, expected: "a non-empty array" },
43
+ type: { check: (value) => typeof value === "string" || (Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === "string")), expected: "a string or array of strings" },
44
+ properties: { check: (value) => isSchemaObject(value), expected: "an object" },
45
+ required: { check: (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string"), expected: "an array of strings" },
46
+ additionalProperties: { check: (value) => typeof value === "boolean" || isSchemaObject(value), expected: "a boolean or a schema object" },
47
+ items: { check: (value) => isSchemaObject(value), expected: "a schema object" },
48
+ enum: { check: (value) => Array.isArray(value) && value.length > 0, expected: "a non-empty array" },
49
+ minLength: { check: (value) => typeof value === "number", expected: "a number" },
50
+ maxLength: { check: (value) => typeof value === "number", expected: "a number" },
51
+ minimum: { check: (value) => typeof value === "number", expected: "a number" },
52
+ pattern: { check: (value) => typeof value === "string", expected: "a string" },
53
+ format: { check: (value) => typeof value === "string", expected: "a string" },
54
+ // `const` may legitimately be any JSON value, including null.
55
+ };
56
+ const SUPPORTED_FORMATS = new Set(["date-time"]);
57
+ const SUPPORTED_TYPES = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]);
58
+ /**
59
+ * Refuse a schema document containing constructs this evaluator cannot enforce.
60
+ * Called once per document (memoized by the caller) so a contract bump that adds
61
+ * a keyword fails loudly rather than validating less than it claims.
62
+ */
63
+ export function assertSupportedSchema(schema, label, path = "#") {
64
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
65
+ throw new Error(`${label} is not a JSON Schema object at ${path}`);
66
+ }
67
+ const node = schema;
68
+ for (const keyword of Object.keys(node)) {
69
+ if (!SUPPORTED_KEYWORDS.has(keyword)) {
70
+ throw new Error(`${label} uses unsupported JSON Schema keyword \`${keyword}\` at ${path}; the closed-contract evaluator refuses to validate less than the schema declares`);
71
+ }
72
+ const shape = KEYWORD_SHAPES[keyword];
73
+ if (shape && !shape.check(node[keyword])) {
74
+ throw new Error(`${label} declares \`${keyword}\` at ${path} as something other than ${shape.expected}; the closed-contract evaluator refuses to skip a keyword it cannot read`);
75
+ }
76
+ }
77
+ if (node.format !== undefined && !SUPPORTED_FORMATS.has(String(node.format))) {
78
+ throw new Error(`${label} uses unsupported format \`${String(node.format)}\` at ${path}`);
79
+ }
80
+ for (const type of typeList(node)) {
81
+ if (!SUPPORTED_TYPES.has(type))
82
+ throw new Error(`${label} uses unsupported type \`${type}\` at ${path}`);
83
+ }
84
+ if (node.$ref !== undefined) {
85
+ if (!/^#\/\$defs\/[A-Za-z0-9_]+$/.test(String(node.$ref))) {
86
+ throw new Error(`${label} uses unsupported $ref \`${String(node.$ref)}\` at ${path}; only #/$defs/<name> is resolvable`);
87
+ }
88
+ // `validate` follows a `$ref` and evaluates the target *instead of* this node, so
89
+ // a sibling constraint would be silently dropped — the precise hole this file
90
+ // exists to prevent. Refuse the combination rather than half-enforce it.
91
+ const siblings = Object.keys(node).filter((keyword) => keyword !== "$ref" && !ANNOTATION_KEYWORDS.has(keyword));
92
+ if (siblings.length > 0) {
93
+ throw new Error(`${label} combines $ref with ${siblings.map((keyword) => `\`${keyword}\``).join(", ")} at ${path}; the closed-contract evaluator would drop the sibling constraint, so it refuses the schema instead`);
94
+ }
95
+ }
96
+ for (const [name, entry] of Object.entries(object(node.$defs) ?? {}))
97
+ assertSupportedSchema(entry, label, `${path}/$defs/${name}`);
98
+ for (const [index, entry] of (Array.isArray(node.oneOf) ? node.oneOf : []).entries())
99
+ assertSupportedSchema(entry, label, `${path}/oneOf/${index}`);
100
+ for (const [name, entry] of Object.entries(object(node.properties) ?? {}))
101
+ assertSupportedSchema(entry, label, `${path}/properties/${name}`);
102
+ if (node.items !== undefined)
103
+ assertSupportedSchema(node.items, label, `${path}/items`);
104
+ if (node.additionalProperties !== undefined && node.additionalProperties !== false && node.additionalProperties !== true) {
105
+ assertSupportedSchema(node.additionalProperties, label, `${path}/additionalProperties`);
106
+ }
107
+ }
108
+ /**
109
+ * Validate `value` against `schema`. Returns every violation found; an empty
110
+ * array means the instance satisfies the pinned contract.
111
+ *
112
+ * `knownFieldNames` is the set of property names the caller is willing to echo in
113
+ * an "unknown field" message. A name outside it is redacted: an attacker-supplied
114
+ * key is untrusted text, but naming a field the contract *does* know elsewhere is
115
+ * what makes the message actionable.
116
+ */
117
+ export function validateClosedSchema(schema, value, options = {}) {
118
+ return validate(schema, schema, value, "", options.knownFieldNames ?? new Set());
119
+ }
120
+ /** Every property name declared anywhere in the document — safe to echo. */
121
+ export function declaredPropertyNames(schema) {
122
+ const names = new Set();
123
+ const walk = (node) => {
124
+ const current = object(node);
125
+ if (!current)
126
+ return;
127
+ for (const [name, entry] of Object.entries(object(current.properties) ?? {})) {
128
+ names.add(name);
129
+ walk(entry);
130
+ }
131
+ for (const entry of Object.values(object(current.$defs) ?? {}))
132
+ walk(entry);
133
+ for (const entry of Array.isArray(current.oneOf) ? current.oneOf : [])
134
+ walk(entry);
135
+ if (current.items !== undefined)
136
+ walk(current.items);
137
+ if (current.additionalProperties && typeof current.additionalProperties === "object")
138
+ walk(current.additionalProperties);
139
+ };
140
+ walk(schema);
141
+ return names;
142
+ }
143
+ function validate(root, schema, value, path, known) {
144
+ if (schema.$ref !== undefined) {
145
+ const resolved = resolveRef(root, String(schema.$ref));
146
+ return validate(root, resolved, value, path, known);
147
+ }
148
+ const violations = [];
149
+ const types = typeList(schema);
150
+ if (types.length && !types.some((type) => matchesType(type, value))) {
151
+ return [{ path, message: `must be ${describeTypes(types)}` }];
152
+ }
153
+ if (schema.const !== undefined && !sameJson(schema.const, value)) {
154
+ return [{ path, message: `must be ${JSON.stringify(schema.const)}` }];
155
+ }
156
+ if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => sameJson(allowed, value))) {
157
+ return [{ path, message: `must be one of ${schema.enum.map((allowed) => stringifyAllowed(allowed)).join(", ")}` }];
158
+ }
159
+ if (Array.isArray(schema.oneOf)) {
160
+ const branches = schema.oneOf.map((branch) => validate(root, branch, value, path, known));
161
+ const matched = branches.filter((branch) => branch.length === 0).length;
162
+ if (matched === 0)
163
+ return bestBranch(root, schema.oneOf, branches, value, path);
164
+ // A closed union that matches twice is ambiguous; refuse rather than pick.
165
+ if (matched > 1)
166
+ return [{ path, message: `matches ${matched} of the ${branches.length} allowed shapes and is therefore ambiguous` }];
167
+ }
168
+ if (typeof value === "string")
169
+ violations.push(...validateString(schema, value, path));
170
+ if (typeof value === "number")
171
+ violations.push(...validateNumber(schema, value, path));
172
+ if (Array.isArray(value) && schema.items !== undefined) {
173
+ value.forEach((entry, index) => violations.push(...validate(root, schema.items, entry, `${path}[${index}]`, known)));
174
+ }
175
+ const record = object(value);
176
+ if (record)
177
+ violations.push(...validateObject(root, schema, record, path, known));
178
+ return violations;
179
+ }
180
+ function validateObject(root, schema, record, path, known) {
181
+ const violations = [];
182
+ const properties = object(schema.properties) ?? {};
183
+ for (const name of (Array.isArray(schema.required) ? schema.required : [])) {
184
+ if (!Object.hasOwn(record, name))
185
+ violations.push({ path: child(path, name), message: "is required" });
186
+ }
187
+ for (const [name, entry] of Object.entries(record)) {
188
+ if (entry === undefined)
189
+ continue;
190
+ const propertySchema = Object.hasOwn(properties, name) ? object(properties[name]) : undefined;
191
+ if (propertySchema) {
192
+ violations.push(...validate(root, propertySchema, entry, child(path, name), known));
193
+ continue;
194
+ }
195
+ if (schema.additionalProperties === false) {
196
+ violations.push({
197
+ path: path || "(top level)",
198
+ message: `carries undeclared field ${known.has(name) ? name : "[REDACTED field name]"}, which the closed contract does not allow`,
199
+ });
200
+ continue;
201
+ }
202
+ const extra = typeof schema.additionalProperties === "object" && schema.additionalProperties !== null
203
+ ? schema.additionalProperties
204
+ : undefined;
205
+ if (extra)
206
+ violations.push(...validate(root, extra, entry, child(path, name), known));
207
+ }
208
+ return violations;
209
+ }
210
+ function validateString(schema, value, path) {
211
+ const violations = [];
212
+ if (typeof schema.minLength === "number" && value.length < schema.minLength) {
213
+ violations.push({ path, message: schema.minLength === 1 ? "must not be empty" : `must be at least ${schema.minLength} characters` });
214
+ }
215
+ if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
216
+ violations.push({ path, message: `must be at most ${schema.maxLength} characters` });
217
+ }
218
+ if (typeof schema.pattern === "string" && !new RegExp(schema.pattern).test(value)) {
219
+ violations.push({ path, message: `must match ${schema.pattern}` });
220
+ }
221
+ if (schema.format === "date-time" && !isRfc3339(value)) {
222
+ violations.push({ path, message: "must be an RFC 3339 date-time" });
223
+ }
224
+ return violations;
225
+ }
226
+ function validateNumber(schema, value, path) {
227
+ if (typeof schema.minimum === "number" && value < schema.minimum) {
228
+ return [{ path, message: `must be >= ${schema.minimum}` }];
229
+ }
230
+ return [];
231
+ }
232
+ /**
233
+ * Report the failed union against the branch the instance was *aiming at*.
234
+ *
235
+ * pi-daddy's union is discriminated by `event`, so the branch whose `const`
236
+ * properties all match is the intended one, however many other violations it has.
237
+ * Picking the branch with the fewest violations instead would report "event must be
238
+ * `child_lifecycle`" about a `capability_decision` that is simply missing several
239
+ * required fields — fail-closed but actively misleading. Only when no branch's
240
+ * discriminator matches does violation count decide.
241
+ */
242
+ function bestBranch(root, schemas, branches, value, path) {
243
+ const discriminated = schemas
244
+ .map((schema, index) => ({ schema, violations: branches[index] }))
245
+ .filter(({ schema }) => matchesDiscriminator(root, schema, value));
246
+ const candidates = discriminated.length === 1 ? [discriminated[0].violations] : branches;
247
+ let best = candidates[0] ?? [];
248
+ for (const branch of candidates)
249
+ if (branch.length < best.length)
250
+ best = branch;
251
+ return best.length ? best : [{ path, message: "does not match any allowed shape" }];
252
+ }
253
+ /**
254
+ * True when every *required* `const`-valued property of a branch matches the
255
+ * instance. Requiredness is what separates a discriminator from an optional flag:
256
+ * pi-daddy's variants also use `const: true` for omit-or-true markers like
257
+ * `humanDenied` and `aborted`, and treating those as discriminating would make no
258
+ * branch match a record that simply left them out.
259
+ */
260
+ function matchesDiscriminator(root, schema, value) {
261
+ const resolved = schema.$ref !== undefined ? resolveRef(root, String(schema.$ref)) : schema;
262
+ const record = object(value);
263
+ const properties = object(resolved.properties);
264
+ if (!record || !properties)
265
+ return false;
266
+ const required = new Set((Array.isArray(resolved.required) ? resolved.required : []));
267
+ const consts = Object.entries(properties)
268
+ .filter(([name]) => required.has(name))
269
+ .map(([name, entry]) => [name, object(entry)?.const])
270
+ .filter(([, constant]) => constant !== undefined);
271
+ return consts.length > 0 && consts.every(([name, constant]) => sameJson(constant, record[name]));
272
+ }
273
+ function resolveRef(root, ref) {
274
+ const name = ref.replace("#/$defs/", "");
275
+ const resolved = object((object(root.$defs) ?? {})[name]);
276
+ if (!resolved)
277
+ throw new Error(`unresolvable $ref ${ref} in pinned schema`);
278
+ return resolved;
279
+ }
280
+ function typeList(schema) {
281
+ if (typeof schema.type === "string")
282
+ return [schema.type];
283
+ if (Array.isArray(schema.type))
284
+ return schema.type.map(String);
285
+ return [];
286
+ }
287
+ function matchesType(type, value) {
288
+ switch (type) {
289
+ case "object": return object(value) !== undefined;
290
+ case "array": return Array.isArray(value);
291
+ case "string": return typeof value === "string";
292
+ case "boolean": return typeof value === "boolean";
293
+ case "null": return value === null;
294
+ case "integer": return typeof value === "number" && Number.isInteger(value);
295
+ case "number": return typeof value === "number" && Number.isFinite(value);
296
+ default: return false;
297
+ }
298
+ }
299
+ function describeTypes(types) {
300
+ const article = (type) => (["object", "array", "integer"].includes(type) ? `an ${type}` : `a ${type}`);
301
+ if (types.length === 1)
302
+ return types[0] === "null" ? "null" : article(types[0]);
303
+ return types.map((type) => (type === "null" ? "null" : article(type))).join(" or ");
304
+ }
305
+ function stringifyAllowed(value) {
306
+ return typeof value === "string" ? value : JSON.stringify(value);
307
+ }
308
+ /**
309
+ * Build the instance path for a property. Under a map-valued `additionalProperties`
310
+ * the key comes from the ledger, so it is bounded and redaction-checked before it
311
+ * reaches an error string: a path is a diagnostic, not a place to relay input.
312
+ */
313
+ function child(path, name) {
314
+ const safe = /^[A-Za-z0-9_.:-]{1,64}$/.test(name) && redactText(name) === name ? name : "[REDACTED key]";
315
+ if (safe === "[REDACTED key]")
316
+ return path ? `${path}[REDACTED key]` : "[REDACTED key]";
317
+ if (!path)
318
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(safe) ? safe : `[${JSON.stringify(safe)}]`;
319
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(safe) ? `${path}.${safe}` : `${path}[${JSON.stringify(safe)}]`;
320
+ }
321
+ function sameJson(left, right) {
322
+ return left === right || JSON.stringify(left) === JSON.stringify(right);
323
+ }
324
+ /**
325
+ * RFC 3339 §5.6, as written — not as the harness happens to emit.
326
+ *
327
+ * That means lowercase `t`/`z` separators and a leap `:60` second are valid here,
328
+ * even though pi-daddy's builders use `toISOString()` and never produce them. This
329
+ * function implements a *contract* constraint (`"format": "date-time"`), so being
330
+ * stricter than the contract would reject a conforming producer line and report it
331
+ * as a contract violation. The harness's own narrower `validTime` still applies
332
+ * afterwards, where it is correctly labelled a harness requirement.
333
+ */
334
+ function isRfc3339(value) {
335
+ const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/.exec(value);
336
+ if (!match)
337
+ return false;
338
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
339
+ if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 60)
340
+ return false;
341
+ // `time-numoffset` is bounded by the same `time-hour`/`time-minute` rules, so
342
+ // `+25:70` is not an RFC 3339 date-time however parseable it looks.
343
+ if (match[8] !== undefined && (Number(match[8]) > 23 || Number(match[9]) > 59))
344
+ return false;
345
+ return day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
346
+ }
347
+ function object(value) {
348
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
349
+ }
350
+ function isSchemaObject(value) {
351
+ return object(value) !== undefined;
352
+ }
353
+ //# sourceMappingURL=closed-schema.js.map