mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 16:15:50 +08:00
feat(antigravity): obfuscate sensitive words in system instructions
Closes: #4696 #4723 #4732
This commit is contained in:
@@ -254,6 +254,12 @@ codex:
|
||||
# username: "user"
|
||||
# credential: "secret"
|
||||
|
||||
# Antigravity provider behavior.
|
||||
# antigravity:
|
||||
# sensitive-words: # optional: words to obfuscate with zero-width characters in system instructions
|
||||
# - "API"
|
||||
# - "proxy"
|
||||
|
||||
# xAI provider behavior.
|
||||
xai:
|
||||
# When true, inject the native x_search tool when the request does not declare it.
|
||||
|
||||
@@ -15,6 +15,21 @@ func TestCloneForRuntimeNil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigBytes_AntigravitySensitiveWords(t *testing.T) {
|
||||
cfg, errParse := ParseConfigBytes([]byte(`antigravity:
|
||||
sensitive-words:
|
||||
- "API"
|
||||
- "proxy"
|
||||
`))
|
||||
if errParse != nil {
|
||||
t.Fatalf("ParseConfigBytes() error = %v", errParse)
|
||||
}
|
||||
want := []string{"API", "proxy"}
|
||||
if !reflect.DeepEqual(cfg.Antigravity.SensitiveWords, want) {
|
||||
t.Fatalf("Antigravity.SensitiveWords = %#v, want %#v", cfg.Antigravity.SensitiveWords, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneForRuntimeDeepCopiesConfig(t *testing.T) {
|
||||
cfg := sampleCloneRuntimeConfig()
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ type Config struct {
|
||||
|
||||
AntigravitySignatureBypassStrict *bool `yaml:"antigravity-signature-bypass-strict,omitempty" json:"antigravity-signature-bypass-strict,omitempty"`
|
||||
|
||||
// Antigravity configures provider-wide Antigravity request behavior.
|
||||
Antigravity AntigravityConfig `yaml:"antigravity" json:"antigravity"`
|
||||
|
||||
// GeminiKey defines Gemini API key configurations with optional routing overrides.
|
||||
GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
|
||||
|
||||
|
||||
@@ -125,6 +125,12 @@ type XAIConfig struct {
|
||||
InjectXSearch bool `yaml:"inject-x-search" json:"inject-x-search"`
|
||||
}
|
||||
|
||||
// AntigravityConfig configures provider-wide Antigravity request behavior.
|
||||
type AntigravityConfig struct {
|
||||
// SensitiveWords is a list of words to obfuscate with zero-width characters in system instructions.
|
||||
SensitiveWords []string `yaml:"sensitive-words,omitempty" json:"sensitive-words,omitempty"`
|
||||
}
|
||||
|
||||
// CodexConfig configures provider-wide Codex request behavior.
|
||||
type CodexConfig struct {
|
||||
IdentityConfuse bool `yaml:"identity-confuse" json:"identity-confuse"`
|
||||
|
||||
@@ -59,6 +59,14 @@ func NewAntigravityExecutor(cfg *config.Config) *AntigravityExecutor {
|
||||
return &AntigravityExecutor{cfg: cfg}
|
||||
}
|
||||
|
||||
func (e *AntigravityExecutor) obfuscateSensitiveWords(payload []byte) []byte {
|
||||
if e == nil || e.cfg == nil || len(e.cfg.Antigravity.SensitiveWords) == 0 {
|
||||
return payload
|
||||
}
|
||||
matcher := helps.BuildSensitiveWordMatcher(e.cfg.Antigravity.SensitiveWords)
|
||||
return helps.ObfuscateSensitiveWordsInSystemInstruction(payload, matcher)
|
||||
}
|
||||
|
||||
// antigravityTransport is a singleton HTTP/1.1 transport shared by all Antigravity requests.
|
||||
// It is initialized once via antigravityTransportOnce to avoid leaking a new connection pool
|
||||
// (and the goroutines managing it) on every request.
|
||||
|
||||
@@ -77,6 +77,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
||||
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
||||
requestPath := helps.PayloadRequestPath(opts)
|
||||
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
|
||||
translated = e.obfuscateSensitiveWords(translated)
|
||||
translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated)
|
||||
reporter.SetTranslatedReasoningEffort(translated, to.String())
|
||||
|
||||
@@ -300,6 +301,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
||||
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
||||
requestPath := helps.PayloadRequestPath(opts)
|
||||
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
|
||||
translated = e.obfuscateSensitiveWords(translated)
|
||||
translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated)
|
||||
reporter.SetTranslatedReasoningEffort(translated, to.String())
|
||||
|
||||
|
||||
@@ -145,6 +145,71 @@ func TestSanitizeAntigravityGeminiRequestSignaturesFinalizesParallelCalls(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravitySensitiveWordsObfuscatesSystemInstructionOnly(t *testing.T) {
|
||||
executor := NewAntigravityExecutor(&config.Config{
|
||||
Antigravity: config.AntigravityConfig{SensitiveWords: []string{"proxy"}},
|
||||
})
|
||||
payload := []byte(`{"request":{"systemInstruction":{"parts":[{"text":"Use proxy safely"}]},"contents":[{"role":"user","parts":[{"text":"proxy remains unchanged"}]}]}}`)
|
||||
|
||||
got := executor.obfuscateSensitiveWords(payload)
|
||||
if systemText := gjson.GetBytes(got, "request.systemInstruction.parts.0.text").String(); systemText != "Use p\u200Broxy safely" {
|
||||
t.Fatalf("system instruction = %q, want zero-width obfuscation", systemText)
|
||||
}
|
||||
if contentText := gjson.GetBytes(got, "request.contents.0.parts.0.text").String(); contentText != "proxy remains unchanged" {
|
||||
t.Fatalf("content text = %q, want unchanged", contentText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityStreamObfuscatesSensitiveSystemInstruction(t *testing.T) {
|
||||
captured := make(chan []byte, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, errRead := io.ReadAll(r.Body)
|
||||
if errRead != nil {
|
||||
t.Errorf("read request body: %v", errRead)
|
||||
return
|
||||
}
|
||||
captured <- body
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {}\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
executor := NewAntigravityExecutor(&config.Config{
|
||||
Antigravity: config.AntigravityConfig{SensitiveWords: []string{"Hermes", "Nous Research"}},
|
||||
RequestRetry: 1,
|
||||
})
|
||||
result, errExecute := executor.ExecuteStream(context.Background(), &cliproxyauth.Auth{
|
||||
Metadata: map[string]any{
|
||||
"access_token": "token-123",
|
||||
"expired": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
"project_id": "project-1",
|
||||
},
|
||||
Attributes: map[string]string{"base_url": server.URL},
|
||||
}, cliproxyexecutor.Request{
|
||||
Model: "gemini-3.6-flash-high",
|
||||
Payload: []byte(`{"model":"gemini-3.6-flash-high","instructions":"You are Hermes Agent, an intelligent AI assistant created by Nous Research.","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
||||
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
||||
Stream: true,
|
||||
})
|
||||
if errExecute != nil {
|
||||
t.Fatalf("ExecuteStream() error = %v", errExecute)
|
||||
}
|
||||
for chunk := range result.Chunks {
|
||||
if chunk.Err != nil {
|
||||
t.Fatalf("stream chunk error = %v", chunk.Err)
|
||||
}
|
||||
}
|
||||
|
||||
body := <-captured
|
||||
got := gjson.GetBytes(body, "request.systemInstruction.parts.0.text").String()
|
||||
want := "You are H\u200Bermes Agent, an intelligent AI assistant created by N\u200Bous Research."
|
||||
if got != want {
|
||||
t.Fatalf("system instruction = %q, want %q; body=%s", got, want, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityExecutorCountTokensSanitizesGeminiToolHistory(t *testing.T) {
|
||||
inner := protowire.AppendTag(nil, 1, protowire.BytesType)
|
||||
inner = protowire.AppendBytes(inner, []byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
|
||||
|
||||
@@ -72,6 +72,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
||||
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
||||
requestPath := helps.PayloadRequestPath(opts)
|
||||
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
|
||||
translated = e.obfuscateSensitiveWords(translated)
|
||||
translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated)
|
||||
translated, _ = sjson.DeleteBytes(translated, "request.stream")
|
||||
reporter.SetTranslatedReasoningEffort(translated, to.String())
|
||||
|
||||
@@ -55,6 +55,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
}
|
||||
payload = e.obfuscateSensitiveWords(payload)
|
||||
payload = sanitizeAntigravityGeminiRequestSignatures(baseModel, payload)
|
||||
preparedPayload, _, errReplay := prepareAntigravityGeminiReasoningReplayPayload(ctx, baseModel, req, opts, payload)
|
||||
if errReplay != nil {
|
||||
|
||||
@@ -97,6 +97,44 @@ func ObfuscateSensitiveWords(payload []byte, matcher *SensitiveWordMatcher) []by
|
||||
return payload
|
||||
}
|
||||
|
||||
// ObfuscateSensitiveWordsInSystemInstruction obfuscates sensitive words in an Antigravity system instruction.
|
||||
func ObfuscateSensitiveWordsInSystemInstruction(payload []byte, matcher *SensitiveWordMatcher) []byte {
|
||||
if matcher == nil || matcher.regex == nil {
|
||||
return payload
|
||||
}
|
||||
|
||||
for _, path := range []string{"request.systemInstruction", "request.system_instruction"} {
|
||||
instruction := gjson.GetBytes(payload, path)
|
||||
if !instruction.Exists() {
|
||||
continue
|
||||
}
|
||||
if instruction.Type == gjson.String {
|
||||
text := instruction.String()
|
||||
if obfuscated := matcher.obfuscateText(text); obfuscated != text {
|
||||
payload, _ = sjson.SetBytes(payload, path, obfuscated)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
parts := instruction.Get("parts")
|
||||
if !parts.IsArray() {
|
||||
continue
|
||||
}
|
||||
parts.ForEach(func(key, part gjson.Result) bool {
|
||||
if part.Get("text").Type != gjson.String {
|
||||
return true
|
||||
}
|
||||
text := part.Get("text").String()
|
||||
if obfuscated := matcher.obfuscateText(text); obfuscated != text {
|
||||
payload, _ = sjson.SetBytes(payload, path+".parts."+key.String()+".text", obfuscated)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
// obfuscateSystemBlocks obfuscates sensitive words in system blocks.
|
||||
func obfuscateSystemBlocks(payload []byte, matcher *SensitiveWordMatcher) []byte {
|
||||
system := gjson.GetBytes(payload, "system")
|
||||
|
||||
@@ -104,6 +104,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
||||
if oldCfg.QuotaExceeded.AntigravityCredits != newCfg.QuotaExceeded.AntigravityCredits {
|
||||
changes = append(changes, fmt.Sprintf("quota-exceeded.antigravity-credits: %t -> %t", oldCfg.QuotaExceeded.AntigravityCredits, newCfg.QuotaExceeded.AntigravityCredits))
|
||||
}
|
||||
if !reflect.DeepEqual(oldCfg.Antigravity.SensitiveWords, newCfg.Antigravity.SensitiveWords) {
|
||||
changes = append(changes, fmt.Sprintf("antigravity.sensitive-words: %d -> %d", len(oldCfg.Antigravity.SensitiveWords), len(newCfg.Antigravity.SensitiveWords)))
|
||||
}
|
||||
|
||||
if oldCfg.Codex.IdentityConfuse != newCfg.Codex.IdentityConfuse {
|
||||
changes = append(changes, fmt.Sprintf("codex.identity-confuse: %t -> %t", oldCfg.Codex.IdentityConfuse, newCfg.Codex.IdentityConfuse))
|
||||
|
||||
@@ -337,6 +337,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
|
||||
MaxRetryInterval: 1,
|
||||
WebsocketAuth: false,
|
||||
QuotaExceeded: config.QuotaExceeded{SwitchProject: false, SwitchPreviewModel: false, AntigravityCredits: false},
|
||||
Antigravity: config.AntigravityConfig{SensitiveWords: []string{"old-word"}},
|
||||
ClaudeKey: []config.ClaudeKey{{APIKey: "c1"}},
|
||||
CodexKey: []config.CodexKey{{APIKey: "x1"}},
|
||||
RemoteManagement: config.RemoteManagement{DisableControlPanel: false, PanelGitHubRepository: "old/repo", SecretKey: "keep"},
|
||||
@@ -362,6 +363,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
|
||||
MaxRetryInterval: 3,
|
||||
WebsocketAuth: true,
|
||||
QuotaExceeded: config.QuotaExceeded{SwitchProject: true, SwitchPreviewModel: true, AntigravityCredits: true},
|
||||
Antigravity: config.AntigravityConfig{SensitiveWords: []string{"new-word-1", "new-word-2"}},
|
||||
XAI: config.XAIConfig{InjectXSearch: true},
|
||||
ClaudeKey: []config.ClaudeKey{
|
||||
{APIKey: "c1", BaseURL: "http://new", ProxyURL: "http://p", Headers: map[string]string{"H": "1"}, ExcludedModels: []string{"a"}},
|
||||
@@ -410,6 +412,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
|
||||
expectContains(t, details, "quota-exceeded.switch-project: false -> true")
|
||||
expectContains(t, details, "quota-exceeded.switch-preview-model: false -> true")
|
||||
expectContains(t, details, "quota-exceeded.antigravity-credits: false -> true")
|
||||
expectContains(t, details, "antigravity.sensitive-words: 1 -> 2")
|
||||
expectContains(t, details, "xai.inject-x-search: false -> true")
|
||||
expectContains(t, details, "api-keys count: 1 -> 2")
|
||||
expectContains(t, details, "claude-api-key count: 1 -> 2")
|
||||
|
||||
Reference in New Issue
Block a user