| /** | |
| * @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); | |
| } | |