File size: 5,343 Bytes
7a1ad33 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | /**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Produces a stable, deterministic JSON string representation with sorted keys.
*
* This method is critical for security policy matching. It ensures that the same
* object always produces the same string representation, regardless of property
* insertion order, which could vary across different JavaScript engines or
* runtime conditions.
*
* Key behaviors:
* 1. **Sorted Keys**: Object properties are always serialized in alphabetical order,
* ensuring deterministic output for pattern matching.
*
* 2. **Circular Reference Protection**: Uses ancestor chain tracking (not just
* object identity) to detect true circular references while correctly handling
* repeated non-circular object references. Circular references are replaced
* with "[Circular]" to prevent stack overflow attacks.
*
* 3. **JSON Spec Compliance**:
* - undefined values: Omitted from objects, converted to null in arrays
* - Functions: Omitted from objects, converted to null in arrays
* - toJSON methods: Respected and called when present (per JSON.stringify spec)
*
* 4. **Security Considerations**:
* - Prevents DoS via circular references that would cause infinite recursion
* - Ensures consistent policy rule matching by normalizing property order
* - Respects toJSON for objects that sanitize their output
* - Handles toJSON methods that throw errors gracefully
*
* @param obj - The object to stringify (typically toolCall.args)
* @returns A deterministic JSON string representation
*
* @example
* // Different property orders produce the same output:
* stableStringify({b: 2, a: 1}) === stableStringify({a: 1, b: 2})
* // Returns: '{"a":1,"b":2}'
*
* @example
* // Circular references are handled safely:
* const obj = {a: 1};
* obj.self = obj;
* stableStringify(obj)
* // Returns: '{"a":1,"self":"[Circular]"}'
*
* @example
* // toJSON methods are respected:
* const obj = {
* sensitive: 'secret',
* toJSON: () => ({ safe: 'data' })
* };
* stableStringify(obj)
* // Returns: '{"safe":"data"}'
*/
export function stableStringify(obj: unknown): string {
const stringify = (
currentObj: unknown,
ancestors: Set<unknown>,
isTopLevel = false,
): string => {
// Handle primitives and null
if (currentObj === undefined || typeof currentObj === 'symbol') {
return 'null'; // undefined and symbols in arrays become null in JSON
}
if (currentObj === null) {
return 'null';
}
if (typeof currentObj === 'function') {
return 'null'; // functions in arrays become null in JSON
}
if (typeof currentObj !== 'object') {
return JSON.stringify(currentObj);
}
// Check for circular reference (object is in ancestor chain)
if (ancestors.has(currentObj)) {
return '"[Circular]"';
}
ancestors.add(currentObj);
try {
// Check for toJSON method and use it if present
const objWithToJSON = currentObj as { toJSON?: () => unknown };
if (typeof objWithToJSON.toJSON === 'function') {
try {
const jsonValue = objWithToJSON.toJSON();
// The result of toJSON needs to be stringified recursively
if (jsonValue === null) {
return 'null';
}
// The result of toJSON is effectively a new object graph, but it
// takes the place of the current node, so we preserve the top-level
// status of the current node.
return stringify(jsonValue, ancestors, isTopLevel);
} catch {
// If toJSON throws, treat as a regular object
}
}
if (Array.isArray(currentObj)) {
const items = currentObj.map((item) => {
// undefined, functions and symbols in arrays become null
if (
item === undefined ||
typeof item === 'function' ||
typeof item === 'symbol'
) {
return 'null';
}
return stringify(item, ancestors, false);
});
return '[' + items.join(',') + ']';
}
// Handle objects - sort keys and filter out undefined/function values
const sortedKeys = Object.keys(currentObj).sort();
const pairs: string[] = [];
for (const key of sortedKeys) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const value = (currentObj as Record<string, unknown>)[key];
// Skip undefined, function and symbol values in objects (per JSON spec)
if (
value !== undefined &&
typeof value !== 'function' &&
typeof value !== 'symbol'
) {
let pairStr =
JSON.stringify(key) + ':' + stringify(value, ancestors, false);
if (isTopLevel) {
// We use a null byte (\0) to denote structural boundaries.
// This is safe because any literal \0 in the user's data will
// be escaped by JSON.stringify into "\u0000" before reaching here.
pairStr = '\0' + pairStr + '\0';
}
pairs.push(pairStr);
}
}
return '{' + pairs.join(',') + '}';
} finally {
ancestors.delete(currentObj);
}
};
return stringify(obj, new Set(), true);
}
|