fix: bound schema and evidence projections

This commit is contained in:
lda
2026-08-11 22:01:36 +07:00 Verified
parent e017cc0a97
commit fe49d1a11c
5 changed files with 274 additions and 15 deletions
@@ -121,6 +121,60 @@ describe("evidence policy", () => {
expect(jsonByteLength(record.response)).toBeLessThanOrEqual(32 * 1024);
});
it("replaces retained CLI metadata when the raw request has an exact sensitive key", () => {
const record = makeRecord(
"secret-cli",
{
jsonrpc: "2.0",
method: "workflow.capabilities.call",
params: {
qualified_name: "local.example.login",
payload: { password: "correct horse battery staple" },
},
id: 7,
},
);
const originalCli =
"uv run wf cap call local.example.login --input '{\"password\":\"correct horse battery staple\"}'";
const recordWithCli = { ...record, equivalentCli: originalCli };
const sanitized = sanitizeEvidenceRecord(recordWithCli);
expect(sanitized.equivalentCli).toBe("[redacted: sensitive request]");
expect(sanitized.equivalentCli).not.toContain("correct horse");
expect(recordWithCli.equivalentCli).toBe(originalCli);
});
it("does not redact CLI metadata for near-match request keys", () => {
const record = makeRecord("safe-cli", {
params: {
payload: {
password_hint: "first pet",
tokenCount: 3,
},
},
});
expect(sanitizeEvidenceRecord(record).equivalentCli).toBe(
"uv run wf cap list",
);
});
it("deterministically bounds a near-256 KiB retained CLI string", () => {
const record = {
...makeRecord("large-cli", { params: { payload: { value: "safe" } } }),
equivalentCli: `uv run wf cap call local.example.echo --input '${"x".repeat(255 * 1024)}'`,
};
const first = sanitizeEvidenceRecord(record).equivalentCli;
const second = sanitizeEvidenceRecord(record).equivalentCli;
expect(new TextEncoder().encode(first).length).toBeLessThanOrEqual(4096);
expect(first).toBe(second);
expect(first).toMatch(/\[truncated: evidence limit\]$/);
expect(record.equivalentCli.length).toBeGreaterThan(250 * 1024);
});
it("preserves ordinary scalar data", () => {
expect(sanitizeEvidenceValue({ ok: true, count: 3, empty: null, text: "hello" })).toEqual({
ok: true,
@@ -2,6 +2,7 @@ import type { EvidenceRecord } from "../../app/state.js";
export const EVIDENCE_MAX_BYTES = 32 * 1024;
export const EVIDENCE_MAX_RECORDS = 100;
export const EVIDENCE_MAX_CLI_BYTES = 4 * 1024;
const MAX_DEPTH = 8;
const MAX_STRING_LENGTH = 4096;
@@ -12,6 +13,8 @@ const EVIDENCE_LIMIT_MARKER = "[truncated: evidence limit]";
const CIRCULAR_MARKER = "[truncated: circular reference]";
const UNSUPPORTED_MARKER = "[unsupported: value]";
const TRUNCATION_KEY = EVIDENCE_LIMIT_MARKER;
const SENSITIVE_REQUEST_CLI_MARKER = "[redacted: sensitive request]";
const MAX_REQUEST_SCAN_OBJECTS = 100_000;
const sensitiveKeys = new Set([
"authorization",
@@ -28,6 +31,9 @@ const sensitiveKeys = new Set([
const isSensitiveKey = (key: string): boolean => sensitiveKeys.has(key.toLowerCase());
const utf8ByteLength = (value: string): number =>
new TextEncoder().encode(value).length;
const byteLength = (value: unknown): number => {
const serialized = JSON.stringify(value);
return new TextEncoder().encode(serialized).length;
@@ -56,6 +62,59 @@ const readProperty = (value: object, key: string): unknown => {
}
};
/**
* Scan raw request keys before sanitization so a redacted projection cannot
* hide the fact that equivalent CLI metadata contains the same credential.
*/
const requestContainsSensitiveKey = (value: unknown): boolean => {
const pending: object[] = [];
const seen = new WeakSet<object>();
if (value !== null && typeof value === "object") pending.push(value);
let scannedObjects = 0;
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined || seen.has(current)) continue;
seen.add(current);
scannedObjects += 1;
// Ambiguous/pathological requests redact rather than risk retaining a secret.
if (scannedObjects > MAX_REQUEST_SCAN_OBJECTS) return true;
let keys: string[];
try {
keys = Object.keys(current);
} catch {
return true;
}
for (const key of keys) {
if (isSensitiveKey(key)) return true;
const child = readProperty(current, key);
if (child !== null && typeof child === "object") pending.push(child);
}
}
return false;
};
const fitCliString = (value: string): string => {
if (utf8ByteLength(value) <= EVIDENCE_MAX_CLI_BYTES) return value;
const characters = Array.from(value);
let low = 0;
let high = characters.length;
let best = EVIDENCE_LIMIT_MARKER;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const candidate = `${characters.slice(0, middle).join("")}${EVIDENCE_LIMIT_MARKER}`;
if (utf8ByteLength(candidate) <= EVIDENCE_MAX_CLI_BYTES) {
best = candidate;
low = middle + 1;
} else {
high = middle - 1;
}
}
return best;
};
const projectValue = (
value: unknown,
depth: number,
@@ -245,6 +304,9 @@ export const sanitizeEvidenceValue = (value: unknown): unknown =>
export const sanitizeEvidenceRecord = (record: EvidenceRecord): EvidenceRecord => ({
...record,
equivalentCli: requestContainsSensitiveKey(record.request)
? SENSITIVE_REQUEST_CLI_MARKER
: fitCliString(record.equivalentCli),
// Keep request context visible even when a response is independently oversized.
request: sanitizeEvidenceValue(record.request),
response: sanitizeEvidenceValue(record.response),
@@ -173,6 +173,78 @@ describe("normalizeSchema", () => {
});
});
it("falls back deterministically for an object reference cycle through a property", () => {
const field = normalizeSchema({
$defs: {
Node: {
type: "object",
properties: {
label: { type: "string" },
child: { $ref: "#/$defs/Node" },
},
},
},
type: "object",
properties: {
root: { $ref: "#/$defs/Node" },
},
});
const root = field.children.find((child) => child.key === "root");
const child = root?.children.find((candidate) => candidate.key === "child");
expect(child).toMatchObject({
kind: "json",
fallbackReason: "Local schema reference cycle detected.",
});
});
it("falls back deterministically for an array reference cycle through its items", () => {
const field = normalizeSchema({
$defs: {
RecursiveList: {
type: "array",
items: { $ref: "#/$defs/RecursiveList" },
},
},
type: "object",
properties: {
values: { $ref: "#/$defs/RecursiveList" },
},
});
const values = field.children.find((child) => child.key === "values");
expect(values).toMatchObject({ kind: "array" });
expect(values?.item).toMatchObject({
kind: "json",
fallbackReason: "Local schema reference cycle detected.",
});
});
it("allows independent sibling fields to reuse the same referenced schema", () => {
const field = normalizeSchema({
$defs: {
Shared: {
type: "object",
properties: { value: { type: "string" } },
},
},
type: "object",
properties: {
first: { $ref: "#/$defs/Shared" },
second: { $ref: "#/$defs/Shared" },
},
});
expect(field.children.map((child) => child.kind)).toEqual([
"object",
"object",
]);
expect(field.children.map((child) => child.children[0]?.kind)).toEqual([
"string",
"string",
]);
});
it("recursively rebases nested object and array item paths", () => {
const field = normalizeSchema({
type: "object",
@@ -1,4 +1,4 @@
import { resolveLocalSchemaNode } from "./schema-reference.js";
import { resolveLocalSchemaNodeWithAncestry } from "./schema-reference.js";
export type SchemaField = {
readonly path: ReadonlyArray<string | number>;
@@ -37,6 +37,8 @@ export const rebaseSchemaField = (
type SchemaRecord = Record<string, unknown>;
type EnumValue = string | number | boolean | null;
const MAX_NORMALIZATION_DEPTH = 64;
const isRecord = (value: unknown): value is SchemaRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -109,8 +111,25 @@ const normalizeField = (
key: string,
required: boolean,
defaultTitle: string,
referenceAncestry: ReadonlySet<string>,
depth: number,
): SchemaField => {
const resolution = resolveLocalSchemaNode(rootSchema, schema);
if (depth >= MAX_NORMALIZATION_DEPTH) {
return fallback(
schema,
path,
key,
required,
defaultTitle,
"Schema normalization depth limit exceeded.",
);
}
const resolution = resolveLocalSchemaNodeWithAncestry(
rootSchema,
schema,
referenceAncestry,
);
if (!resolution.ok) {
const title = isRecord(schema) ? stringValue(schema.title) ?? defaultTitle : defaultTitle;
return fallback(schema, path, key, required, title, resolution.reason);
@@ -164,6 +183,8 @@ const normalizeField = (
propertyKey,
requiredNames.has(propertyKey),
stringValue(propertySchema && isRecord(propertySchema) ? propertySchema.title : null) ?? propertyKey,
resolution.referenceAncestry,
depth + 1,
),
)
: [];
@@ -188,7 +209,16 @@ const normalizeField = (
if (itemSchema === undefined) {
return fallback(resolvedSchema, path, key, required, title, "The array has no item schema; edit JSON directly.");
}
const item = normalizeField(rootSchema, itemSchema, [...path, 0], "item", true, `${title} item`);
const item = normalizeField(
rootSchema,
itemSchema,
[...path, 0],
"item",
true,
`${title} item`,
resolution.referenceAncestry,
depth + 1,
);
return {
path,
key,
@@ -230,7 +260,17 @@ export const normalizeSchemaField = (
path: ReadonlyArray<string | number> = [],
key = "root",
required = true,
): SchemaField => normalizeField(schema, schema, path, key, required, key === "root" ? "Value" : key);
): SchemaField =>
normalizeField(
schema,
schema,
path,
key,
required,
key === "root" ? "Value" : key,
new Set(),
0,
);
export const normalizeSchema = (schema: unknown): SchemaField =>
normalizeSchemaField(schema);
@@ -4,6 +4,14 @@ export type SchemaReferenceResolution =
| { readonly ok: true; readonly schema: unknown }
| { readonly ok: false; readonly reason: string };
export type SchemaReferenceTraversalResolution =
| {
readonly ok: true;
readonly schema: unknown;
readonly referenceAncestry: ReadonlySet<string>;
}
| { readonly ok: false; readonly reason: string };
const ANNOTATION_KEYS = ["title", "description", "default"] as const;
const isRecord = (value: unknown): value is SchemaRecord =>
@@ -12,7 +20,9 @@ const isRecord = (value: unknown): value is SchemaRecord =>
const hasOwn = (value: SchemaRecord, key: string): boolean =>
Object.prototype.hasOwnProperty.call(value, key);
const failure = (reason: string): SchemaReferenceResolution => ({
const failure = (
reason: string,
): { readonly ok: false; readonly reason: string } => ({
ok: false,
reason,
});
@@ -93,10 +103,10 @@ const mergeAnnotations = (
const resolveNode = (
rootSchema: unknown,
schemaNode: unknown,
activeReferences: Set<string>,
): SchemaReferenceResolution => {
referenceAncestry: ReadonlySet<string>,
): SchemaReferenceTraversalResolution => {
if (!isRecord(schemaNode) || !hasOwn(schemaNode, "$ref")) {
return { ok: true, schema: schemaNode };
return { ok: true, schema: schemaNode, referenceAncestry };
}
const ref = schemaNode.$ref;
@@ -114,7 +124,7 @@ const resolveNode = (
if (hasStructuralSiblings(schemaNode)) {
return failure("Structural siblings beside $ref are not supported.");
}
if (activeReferences.has(ref))
if (referenceAncestry.has(ref))
return failure("Local schema reference cycle detected.");
const target = pointerTarget(rootSchema, ref);
@@ -126,15 +136,36 @@ const resolveNode = (
);
}
// Track only the current reference path so repeated sibling refs remain valid.
activeReferences.add(ref);
const resolved = resolveNode(rootSchema, target.value, activeReferences);
activeReferences.delete(ref);
// Keep references active for structural child descent. Callers pass the
// resulting immutable ancestry to each child, so sibling reuse stays valid.
const nextAncestry = new Set(referenceAncestry);
nextAncestry.add(ref);
const resolved = resolveNode(rootSchema, target.value, nextAncestry);
if (!resolved.ok) return resolved;
return { ok: true, schema: mergeAnnotations(resolved.schema, schemaNode) };
return {
ok: true,
schema: mergeAnnotations(resolved.schema, schemaNode),
referenceAncestry: resolved.referenceAncestry,
};
};
export const resolveLocalSchemaNodeWithAncestry = (
rootSchema: unknown,
schemaNode: unknown,
referenceAncestry: ReadonlySet<string>,
): SchemaReferenceTraversalResolution =>
resolveNode(rootSchema, schemaNode, referenceAncestry);
export const resolveLocalSchemaNode = (
rootSchema: unknown,
schemaNode: unknown,
): SchemaReferenceResolution => resolveNode(rootSchema, schemaNode, new Set());
): SchemaReferenceResolution => {
const resolution = resolveLocalSchemaNodeWithAncestry(
rootSchema,
schemaNode,
new Set(),
);
return resolution.ok
? { ok: true, schema: resolution.schema }
: resolution;
};