fix: bound schema and evidence projections
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user