Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions packages/core/src/utils/safeJsonStringify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,115 @@ describe('safeJsonStringify', () => {
expect(safeJsonStringify(42)).toBe('42');
expect(safeJsonStringify(true)).toBe('true');
});

it('should preserve duplicate sibling references as full copies', () => {
// The same object referenced from two sibling properties is not a cycle:
// both branches must serialize in full, matching native JSON.stringify.
const shared = { name: 'shared', n: 1 };
const obj = { a: shared, b: shared };

const result = safeJsonStringify(obj);
expect(result).toBe(
'{"a":{"name":"shared","n":1},"b":{"name":"shared","n":1}}',
);
expect(result).not.toContain('[Circular]');
});

it('should preserve duplicate references repeated in an array', () => {
const shared = { id: 1 };
const arr = [shared, shared, shared];

const result = safeJsonStringify(arr);
expect(result).toBe('[{"id":1},{"id":1},{"id":1}]');
expect(result).not.toContain('[Circular]');
});

it('should preserve a shared leaf appearing on multiple branches', () => {
const leaf = { kind: 'leaf' };
const tree = { left: { sub: leaf }, right: { sub: leaf } };

const result = safeJsonStringify(tree);
expect(result).toBe(
'{"left":{"sub":{"kind":"leaf"}},"right":{"sub":{"kind":"leaf"}}}',
);
expect(result).not.toContain('[Circular]');
});

it('should detect indirect cycles via an intermediate object', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parent: any = { name: 'parent' };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const child: any = { name: 'child' };
parent.child = child;
child.parent = parent;

const result = safeJsonStringify(parent);
expect(result).toBe(
'{"name":"parent","child":{"name":"child","parent":"[Circular]"}}',
);
});

it('should preserve a shared subtree alongside a real cycle', () => {
const shared = { tag: 'shared' };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const root: any = { a: shared, b: shared };
root.self = root;

const result = safeJsonStringify(root);
expect(result).toBe(
'{"a":{"tag":"shared"},"b":{"tag":"shared"},"self":"[Circular]"}',
);
});

it('should preserve a shared leaf reached through deep ancestor chains', () => {
// Forces the unwind loop to pop five frames between the deep branch and
// the sibling branch. Without the pop, the second occurrence of `shared`
// would still see `shared` on the stack and emit [Circular].
const shared = { tag: 'shared' };
const root = {
l1: { l2: { l3: { l4: { l5: { leaf: shared } } } } },
sibling: { leaf: shared },
};

const result = safeJsonStringify(root);
expect(result).toBe(
'{"l1":{"l2":{"l3":{"l4":{"l5":{"leaf":{"tag":"shared"}}}}}},"sibling":{"leaf":{"tag":"shared"}}}',
);
expect(result).not.toContain('[Circular]');
});

it('should detect a real cycle through deep ancestor chains', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const root: any = { l1: { l2: { l3: { l4: { l5: { back: null } } } } } };
root.l1.l2.l3.l4.l5.back = root;

const result = safeJsonStringify(root);
expect(result).toBe(
'{"l1":{"l2":{"l3":{"l4":{"l5":{"back":"[Circular]"}}}}}}',
);
});

it('should preserve a shared object returned by toJSON from sibling positions', () => {
// JSON.stringify calls toJSON() before invoking the replacer, so the
// replacer sees the post-toJSON value. Two siblings whose toJSON returns
// the same object are duplicate refs, not a cycle.
const shared = { tag: 'shared' };
const root = {
a: { toJSON: () => shared },
b: { toJSON: () => shared },
};

const result = safeJsonStringify(root);
expect(result).toBe('{"a":{"tag":"shared"},"b":{"tag":"shared"}}');
expect(result).not.toContain('[Circular]');
});

it('should detect a cycle when toJSON returns an ancestor', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const root: any = { name: 'root' };
root.child = { toJSON: () => root };

const result = safeJsonStringify(root);
expect(result).toBe('{"name":"root","child":"[Circular]"}');
});
});
Comment thread
ihubanov marked this conversation as resolved.
26 changes: 19 additions & 7 deletions packages/core/src/utils/safeJsonStringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
/**
* Safely stringifies an object to JSON, handling circular references by replacing them with [Circular].
*
* Only true cycles (an object reachable from itself along the current ancestor
* path) are replaced. Duplicate references (the same object appearing in
* multiple sibling positions) are preserved as full copies, matching the
* behavior of `JSON.stringify` on acyclic graphs.
*
* @param obj - The object to stringify
* @param space - Optional space parameter for formatting (defaults to no formatting)
* @returns JSON string with circular references replaced by [Circular]
Expand All @@ -15,16 +20,23 @@ export function safeJsonStringify(
obj: unknown,
space?: string | number,
): string {
const seen = new WeakSet();
const ancestors: object[] = [];
return JSON.stringify(
obj,
(key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
function (this: unknown, _key, value) {
if (typeof value !== 'object' || value === null) {
return value;
}
// `this` is the parent of `value`. As JSON.stringify's DFS walk unwinds
// back up the tree, pop any ancestors that are no longer on the path
// to `this` so the stack reflects only the current chain of ancestors.
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
Comment thread
ihubanov marked this conversation as resolved.
ancestors.pop();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] DAG with shared refs can produce exponentially large output. The old WeakSet collapsed all revisited objects to [Circular] (O(n) output). The new algorithm fully expands every duplicate reference — correct per JSON.stringify semantics, but it removes the implicit output-size guard. A DAG with 50 levels where each node has 2 children referencing the same leaf produces ~2⁵⁰ copies of the leaf in the output, blocking the event loop until OOM.

This matters because safeJsonStringify is called on MCP tool params (mcp-tool.ts:452) and tool registry params (tool-registry.ts:48), which originate from external MCP servers (attacker-controlled input).

Consider adding a node-count cap as a safety valve:

let nodeCount = 0;
const MAX_NODES = 10_000;
// in replacer, before ancestors.push:
if (++nodeCount > MAX_NODES) return '[Truncated]';

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

@ihubanov ihubanov May 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair concern, but I think this is the cost of correctness over the old behavior.

Native JSON.stringify already produces the 2^N output on the same DAG shape, so what the helper does now matches what callers would get if they removed the safeJsonStringify wrapper. The old WeakSet doubled as a hidden size guard, but it did so by silently corrupting the output for any acyclic graph with shared refs.

I looked at the actual callers (loggers.ts, file-exporters.ts, qwen-logger.ts, message-bus.ts, mcp-tool.ts, tool-registry.ts). They all stringify request/response payloads, tool args, error messages. Flat-ish JSON from the model or typed message structs. None of them currently fan out into a DAG shape that would blow up.

If size-guarded serialization for arbitrary user input becomes a real concern, I think that's a separate enhancement (a max output size or max duplicate count knob) rather than reintroducing the false [Circular]s here. Can revisit as a follow-up if telemetry ever surfaces a caller that needs it.

if (ancestors.includes(value as object)) {
return '[Circular]';
}
ancestors.push(value as object);
return value;
},
space,
Expand Down
Loading