feat: prevent raw tool call JSON leakage for unknown or rejected tool calls and consolidate container publishing to GHCR.

This commit is contained in:
CJACK
2026-02-23 00:27:46 +08:00
parent 35e89230fd
commit 0d3d535c08
9 changed files with 84 additions and 17 deletions

View File

@@ -375,7 +375,7 @@ func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing.
}
}
func TestHandleStreamUnknownToolNotIntercepted(t *testing.T) {
func TestHandleStreamUnknownToolDoesNotLeakRawPayload(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
@@ -393,8 +393,34 @@ func TestHandleStreamUnknownToolNotIntercepted(t *testing.T) {
if streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for unknown schema name, body=%s", rec.Body.String())
}
if !streamHasRawToolJSONContent(frames) {
t.Fatalf("expected raw tool_calls json to remain in content for unknown schema name: %s", rec.Body.String())
if streamHasRawToolJSONContent(frames) {
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name: %s", rec.Body.String())
}
if streamFinishReason(frames) != "stop" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
}
}
func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\"}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid5b", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name (no args): %s", rec.Body.String())
}
if streamFinishReason(frames) != "stop" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())

View File

@@ -200,9 +200,14 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
if insideCodeFence(state.recentTextTail + prefixPart) {
return captured, nil, "", true
}
parsed := util.ParseStandaloneToolCalls(obj, toolNames)
if len(parsed) == 0 {
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
if len(parsed.Calls) == 0 {
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
// Parsed as tool-call payload but rejected by schema/policy:
// consume it to avoid leaking raw tool_calls JSON to user content.
return prefixPart, nil, suffixPart, true
}
return captured, nil, "", true
}
return prefixPart, parsed, suffixPart, true
return prefixPart, parsed.Calls, suffixPart, true
}

View File

@@ -205,8 +205,17 @@ function consumeToolCapture(state, toolNames) {
suffix: '',
};
}
const rawParsed = parseStandaloneToolCalls(captured.slice(start, obj.end), []);
const parsed = parseStandaloneToolCalls(captured.slice(start, obj.end), toolNames);
if (parsed.length === 0) {
if (rawParsed.length > 0 && Array.isArray(toolNames) && toolNames.length > 0) {
return {
ready: true,
prefix: prefixPart,
calls: [],
suffix: suffixPart,
};
}
if (state.toolNameSent) {
return {
ready: true,