Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1979183
fix(core): pre-validate bridged tool_call arguments against the targe…
yiliang114 Sep 28, 2026
9d6a39d
fix(core): exempt media-policy targets from the bridge argument pre-c…
yiliang114 Sep 28, 2026
2c91d42
fix(core): order the bridge argument pre-check behind truncation and …
yiliang114 Sep 28, 2026
191879b
style(core): apply prettier formatting flagged by the lint gate
yiliang114 Sep 28, 2026
4c7ebf9
fix(core): close R3 review gaps in bridge pre-check policy, retry acc…
yiliang114 Sep 29, 2026
bad821c
fix(core): clone the pre-check schema and log the bridge policy looku…
yiliang114 Sep 29, 2026
888727d
test(core): use index-signature access for the mutated schema property
yiliang114 Sep 29, 2026
55783c4
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Sep 29, 2026
0fd2661
fix(core): report an in-flight subagent refresh rejection under its o…
yiliang114 Sep 29, 2026
4c101ec
fix(core): treat a target's direct and bridge retry channels as one f…
yiliang114 Sep 29, 2026
2059aea
fix(cli): let ACP's own L1 gate own a policy-denied bridge target
yiliang114 Sep 29, 2026
29d3080
fix(core): preserve target validation and contain bridge policy errors
yiliang114 Sep 29, 2026
6c9d701
test(cli): complete the pm stub in the ACP target-policy bridge tests
yiliang114 Sep 29, 2026
0ab0c14
Merge origin/main into fix/issue-12889-bridge-arg-validation
yiliang114 Sep 29, 2026
a674e3a
fix(core): isolate deferred argument validation
yiliang114 Sep 30, 2026
bacca2b
fix(core): relax nested additionalProperties in the bridge argument p…
yiliang114 Sep 30, 2026
21c07c5
fix(core): preserve composition branches in the bridge argument relax…
yiliang114 Sep 30, 2026
f92adcf
Merge origin/main into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 1, 2026
df8c325
fix(core): close the additionalProperties relaxation at the $ref boun…
yiliang114 Oct 1, 2026
bd31eac
fix(core): preserve bridge policy denials and media defaults
yiliang114 Oct 1, 2026
28a787d
Merge remote-tracking branch 'origin/main' into fix/issue-12889-bridg…
yiliang114 Oct 1, 2026
38fb20a
refactor(core): label the target's own validation error instead of pr…
yiliang114 Oct 1, 2026
403da27
fix(core): let a Responses model fill tool_call arguments; declare an…
yiliang114 Oct 1, 2026
d7465d1
test(core): use non-empty arguments that still fail validation
yiliang114 Oct 1, 2026
8b1b247
fix(core): keep deferred tool recovery in the primary session
yiliang114 Oct 1, 2026
6eb25b2
Merge remote-tracking branch 'origin/main' into codex/token-governanc…
yiliang114 Oct 1, 2026
9af1dee
fix(core): Preserve eager tool demotion during bridge recovery
yiliang114 Oct 2, 2026
d179954
Merge commit 'de2612434a3800735dcabef48bc67efcbb0077b4' into codex/to…
yiliang114 Oct 2, 2026
20930cd
fix(core): guard bridged empty-arg recovery against cancellation and …
yiliang114 Oct 2, 2026
b4f63dd
fix(acp): Keep truncated bridged calls hidden
yiliang114 Oct 2, 2026
a69f8d4
revert(core): drop the empty-argument auto-reveal fallback from the b…
yiliang114 Oct 2, 2026
af4d01f
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 2, 2026
15aac03
test(acp): Verify deferred-tool validation diagnostics
yiliang114 Oct 2, 2026
8bbced9
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 2, 2026
7682e70
Merge branch 'main' into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 3, 2026
28af8e4
Merge origin/main into fix/issue-12889-bridge-arg-validation
yiliang114 Oct 3, 2026
67ea7b6
test(core): preserve shared bridged validation retry budget
yiliang114 Oct 3, 2026
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
Prev Previous commit
Next Next commit
fix(core): close the additionalProperties relaxation at the $ref boun…
…dary

The name-keyed exemption made composition non-lexical: $defs and
definitions were walked by value, so a $ref-expressed oneOf had its
branches relaxed inside the shared definition — an argument matching
exactly one published branch then matched two in the pre-check's clone
and the bridge refused a call the target's own schema accepts. $defs
and definitions now stay byte-identical (they are reachable only
through $ref, and each use site is covered directly).

Two measured corrections ride along: allOf/anyOf/then/else come out of
the verbatim list (relaxing under them only widens acceptance — measured
0 newly-rejected against oneOf's 1 and not's 8), and draft-07
dependencies joins the name-keyed walk so a constraint literally named
additionalProperties keeps its own value instead of being inverted into
always-pass.

Three discriminating tests: the same tagged union written with
$defs/$ref resolves and the target's own build() accepts it; allOf
branches relax; a false-valued dependency named additionalProperties
still refuses. Each is red under its revert (re-walking $defs,
re-listing allOf, dropping dependencies from the name-keyed walk).

Co-authored-by: Qwen-Coder <[email protected]>
  • Loading branch information
yiliang114 committed Oct 1, 2026
commit df8c325776e2e9f3fd6e1d1085913e0d23431b6f
106 changes: 106 additions & 0 deletions packages/core/src/tools/tool-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,112 @@ describe('ToolCallTool', () => {
});
});

it('resolves the same tagged union written with $defs/$ref', async () => {
// The $ref form is what generated schemas actually use. Composition is
// non-lexical through it, so the relaxation must not descend into the
// shared definitions: flipping a branch's additionalProperties there
// makes {a,b} match both and oneOf (exactly one) fails. Mutation check:
// re-adding $defs to the name-keyed walk turns this red with "must match
// exactly one schema in oneOf".
const target = new MockTool({
name: 'mcp__srv__refunion',
shouldDefer: true,
params: {
type: 'object',
$defs: {
A: {
properties: { a: { type: 'string' } },
required: ['a'],
additionalProperties: false,
},
B: {
properties: { a: { type: 'string' }, b: { type: 'string' } },
required: ['a'],
additionalProperties: false,
},
},
oneOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/B' }],
},
});
const result = await resolveDeferredToolCall(
makeRegistry([target], new Set([target.name])),
{ name: target.name, arguments: { a: 'x', b: 'y' } },
);

expect(result).not.toHaveProperty('error');
expect(result).toMatchObject({ arguments: { a: 'x', b: 'y' } });
if ('tool' in result) {
// The target's own validator (Ajv on the unmodified schema) accepts.
expect(() => result.tool.build(result.arguments)).not.toThrow();
}
});

it('relaxes additionalProperties inside allOf branches, which only widens', async () => {
// allOf branches do not discriminate — both must hold — so a per-branch
// additionalProperties: false is not load-bearing the way oneOf's is.
// args {a,b} fail each strict branch (each forbids the other key) and
// pass both relaxed ones. Mutation check: re-listing allOf as verbatim
// turns this red with a refusal.
class LenientTool extends MockTool {
override validateToolParams(): string | null {
return null;
}
}
const target = new LenientTool({
name: 'allof_target',
shouldDefer: true,
params: {
type: 'object',
allOf: [
{
properties: { a: { type: 'string' } },
required: ['a'],
additionalProperties: false,
},
{
properties: { b: { type: 'string' } },
required: ['b'],
additionalProperties: false,
},
],
},
});
const result = await resolveDeferredToolCall(
makeRegistry([target], new Set([target.name])),
{ name: target.name, arguments: { a: 'x', b: 'y' } },
);

expect(result).not.toHaveProperty('error');
expect(result).toMatchObject({ arguments: { a: 'x', b: 'y' } });
});

it('never reads a constraint literally named additionalProperties as the keyword', async () => {
// `dependencies` maps a NAME to a constraint; a dependency named
// additionalProperties with a false schema forbids that property, and
// flipping the false to true would invert it into always-pass — the
// pre-check would then resolve a call the target's own validator
// refuses. The constraint must stay false after the walk.
const target = new MockTool({
name: 'dep_target',
shouldDefer: true,
params: {
type: 'object',
properties: { mode: { type: 'string' } },
dependencies: { additionalProperties: false },
},
});
const result = await resolveDeferredToolCall(
makeRegistry([target], new Set([target.name])),
{ name: target.name, arguments: { additionalProperties: 'x' } },
);

expect(result).toMatchObject({
errorType: ToolErrorType.INVALID_TOOL_PARAMS,
targetName: 'dep_target',
});
expect(result).not.toHaveProperty('tool');
});

it('still attributes wrong field types when surplus keys are present', async () => {
const target = makeWebFetchLike();
const result = await resolveDeferredToolCall(
Expand Down
29 changes: 16 additions & 13 deletions packages/core/src/tools/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,34 +89,37 @@ function bridgeRefusal(message: string): Error {

/**
* Schema keywords whose subtree the relaxation below must leave byte-identical.
* Composition branches (`oneOf`/`anyOf`/`allOf`/`if`/`then`/`else`/`not`) use a
* per-branch `additionalProperties: false` to tell the branches apart, so
* relaxing it there inverts the schema's meaning instead of widening acceptance.
* Annotation keywords hold data the schema compares against, not a subschema.
* `oneOf`/`not` discriminate: a per-branch `additionalProperties: false` tells
* branches apart, so relaxing it there inverts the schema's meaning instead of
* widening acceptance (`if` selects a branch by the same mechanism). Annotation
* keywords hold data the schema compares against, not a subschema. `$defs` and
* `definitions` are reached only through `$ref`: they are shared definitions,
* and relaxing inside one silently rewrites every branch that references it —
* each use site is already covered directly by the walk above.
* (`allOf`/`anyOf`/`then`/`else` are deliberately absent: relaxing under them
* only widens acceptance, so the walk descends.)
*/
const VERBATIM_SCHEMA_KEYS: ReadonlySet<string> = new Set([
'allOf',
'anyOf',
'oneOf',
'not',
'if',
'then',
'else',
'const',
'default',
'enum',
'example',
'examples',
'$defs',
'definitions',
]);

/**
* Schema keywords whose value maps an arbitrary NAME to a subschema. The names
* are data, so a property literally named `additionalProperties` keeps its own
* schema rather than being read as the keyword: these are walked by value only.
* Schema keywords whose value maps an arbitrary NAME to a subschema or
* constraint. The names are data, so a property literally named
* `additionalProperties` keeps its own schema rather than being read as the
* keyword: these are walked by value only.
*/
const NAME_TO_SCHEMA_KEYS: ReadonlySet<string> = new Set([
Comment thread
yiliang114 marked this conversation as resolved.
Outdated
'$defs',
'definitions',
'dependencies',
'dependentSchemas',
'patternProperties',
'properties',
Expand Down
Loading