diff --git a/adapters/models/openai_advisory.go b/adapters/models/openai_advisory.go new file mode 100644 index 0000000..6ca1ed9 --- /dev/null +++ b/adapters/models/openai_advisory.go @@ -0,0 +1,269 @@ +package models + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + "unicode/utf8" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/cognitive" +) + +const ( + OpenAIAdvisoryBindingID capability.CapabilityBindingID = "openai-responses-gpt-5.4-mini-2026-03-17" + OpenAIAdvisoryBindingRevision int64 = 1 + openAIResponsesURL = "https://api.openai.com/v1/responses" + openAIModel = "gpt-5.4-mini-2026-03-17" + openAIMaxOutputTokens = 4096 + openAIResponseLimitBytes = 1 << 20 + openAITimeout = 30 * time.Second + openAIInstructions = "Provide non-canonical advisory text only. State uncertainty when appropriate. Never claim that an action occurred, never request or expose secrets, and never emit tool or executable instructions." +) + +type OpenAIAdvisoryAdapter struct { + apiKey []byte + client *http.Client +} + +func NewOpenAIAdvisoryAdapter(apiKey []byte, transport http.RoundTripper) (*OpenAIAdvisoryAdapter, error) { + if len(apiKey) == 0 || bytes.IndexAny(apiKey, "\r\n") >= 0 { + return nil, errors.New("invalid OpenAI credential") + } + if transport == nil { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, errors.New("default HTTP transport is unavailable") + } + cloned := base.Clone() + cloned.Proxy = nil + transport = cloned + } + return &OpenAIAdvisoryAdapter{ + apiKey: append([]byte(nil), apiKey...), + client: &http.Client{ + Transport: transport, + Timeout: openAITimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + }, nil +} + +type openAIRequest struct { + Model string `json:"model"` + Instructions string `json:"instructions"` + Input string `json:"input"` + Store bool `json:"store"` + Reasoning openAIReasoning `json:"reasoning"` + MaxOutputTokens int `json:"max_output_tokens"` +} + +type openAIReasoning struct { + Effort string `json:"effort"` +} + +type openAIResponse struct { + Status string `json:"status"` + Model string `json:"model"` + Store *bool `json:"store"` + Output []json.RawMessage `json:"output"` + Usage *openAIUsage `json:"usage"` + IncompleteDetails *openAIIncompleteDetails `json:"incomplete_details"` +} + +type openAIIncompleteDetails struct { + Reason string `json:"reason"` +} + +type openAIUsage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` +} + +type openAIOutputHeader struct { + Type string `json:"type"` +} + +type openAIMessage struct { + Type string `json:"type"` + Role string `json:"role"` + Status string `json:"status"` + Content []json.RawMessage `json:"content"` +} + +type openAIContentHeader struct { + Type string `json:"type"` +} + +type openAIOutputText struct { + Type string `json:"type"` + Text string `json:"text"` + Annotations []json.RawMessage `json:"annotations"` +} + +type openAIRefusal struct { + Type string `json:"type"` + Refusal string `json:"refusal"` +} + +type openAIReasoningItem struct { + Type string `json:"type"` + Summary []json.RawMessage `json:"summary"` +} + +func (a *OpenAIAdvisoryAdapter) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) { + if err := ValidateAdvisoryRequest(request); err != nil { + return cognitive.AdvisoryResponse{}, err + } + if request.ProviderBindingID != OpenAIAdvisoryBindingID || request.ProviderBindingRevision != OpenAIAdvisoryBindingRevision || len(request.Context) != 0 { + return cognitive.AdvisoryResponse{}, fmt.Errorf("%w: OpenAI Mode A binding", ErrInvalidAdvisory) + } + payload, err := json.Marshal(openAIRequest{ + Model: openAIModel, + Instructions: openAIInstructions, + Input: request.Message, + Store: false, + Reasoning: openAIReasoning{Effort: "low"}, + MaxOutputTokens: openAIMaxOutputTokens, + }) + if err != nil { + return cognitive.AdvisoryResponse{}, err + } + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, openAIResponsesURL, bytes.NewReader(payload)) + if err != nil { + return cognitive.AdvisoryResponse{}, err + } + httpRequest.Header.Set("Authorization", "Bearer "+string(a.apiKey)) + httpRequest.Header.Set("Content-Type", "application/json") + httpRequest.Header.Set("Accept", "application/json") + + httpResponse, err := a.client.Do(httpRequest) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return advisoryFailure(request, cognitive.FinishTimeout), nil + } + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + return advisoryFailure(request, cognitive.FinishTimeout), nil + } + return advisoryFailure(request, cognitive.FinishProviderUnavailable), nil + } + defer httpResponse.Body.Close() + if httpResponse.StatusCode != http.StatusOK { + return advisoryFailure(request, cognitive.FinishProviderUnavailable), nil + } + if contentType := httpResponse.Header.Get("Content-Type"); contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "application/json") { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + body, err := io.ReadAll(io.LimitReader(httpResponse.Body, openAIResponseLimitBytes+1)) + if err != nil || len(body) > openAIResponseLimitBytes { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + var providerResponse openAIResponse + if err := json.Unmarshal(body, &providerResponse); err != nil || providerResponse.Model != openAIModel || (providerResponse.Store != nil && *providerResponse.Store) || providerResponse.Usage == nil || providerResponse.Usage.InputTokens < 0 || providerResponse.Usage.OutputTokens < 0 { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + if providerResponse.Status == "incomplete" && providerResponse.IncompleteDetails != nil && providerResponse.IncompleteDetails.Reason == "max_output_tokens" { + return advisoryFailure(request, cognitive.FinishLengthLimit), nil + } + if providerResponse.Status != "completed" || providerResponse.IncompleteDetails != nil { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + + text, refused, ok := extractOpenAIText(providerResponse.Output) + if !ok { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + if refused { + return advisoryFailure(request, cognitive.FinishContentRejected), nil + } + response := cognitive.AdvisoryResponse{ + Version: cognitive.CreatorAdvisoryV1, + RequestID: request.RequestID, + ProviderBindingID: request.ProviderBindingID, + ProviderBindingRevision: request.ProviderBindingRevision, + AdvisoryText: text, + FinishReason: cognitive.FinishCompleted, + Usage: &cognitive.ProviderUsage{ + InputUnits: providerResponse.Usage.InputTokens, + OutputUnits: providerResponse.Usage.OutputTokens, + }, + } + if err := ValidateAdvisoryResponse(request, response); err != nil { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + return response, nil +} + +func extractOpenAIText(output []json.RawMessage) (string, bool, bool) { + var text string + refused := false + messages := 0 + for _, raw := range output { + var header openAIOutputHeader + if json.Unmarshal(raw, &header) != nil { + return "", false, false + } + switch header.Type { + case "reasoning": + var item openAIReasoningItem + if json.Unmarshal(raw, &item) != nil || len(item.Summary) != 0 { + return "", false, false + } + case "message": + messages++ + if messages != 1 { + return "", false, false + } + var message openAIMessage + if json.Unmarshal(raw, &message) != nil || message.Role != "assistant" || message.Status != "completed" || len(message.Content) != 1 { + return "", false, false + } + var contentHeader openAIContentHeader + if json.Unmarshal(message.Content[0], &contentHeader) != nil { + return "", false, false + } + switch contentHeader.Type { + case "output_text": + var content openAIOutputText + if json.Unmarshal(message.Content[0], &content) != nil || content.Text == "" || !utf8.ValidString(content.Text) || len(content.Annotations) != 0 { + return "", false, false + } + text = content.Text + case "refusal": + var content openAIRefusal + if json.Unmarshal(message.Content[0], &content) != nil || content.Refusal == "" { + return "", false, false + } + refused = true + default: + return "", false, false + } + default: + return "", false, false + } + } + if messages != 1 || (refused && text != "") || (!refused && text == "") { + return "", false, false + } + return text, refused, true +} + +func advisoryFailure(request cognitive.AdvisoryRequest, reason cognitive.FinishReason) cognitive.AdvisoryResponse { + return cognitive.AdvisoryResponse{ + Version: cognitive.CreatorAdvisoryV1, + RequestID: request.RequestID, + ProviderBindingID: request.ProviderBindingID, + ProviderBindingRevision: request.ProviderBindingRevision, + FinishReason: reason, + } +} diff --git a/adapters/models/openai_advisory_test.go b/adapters/models/openai_advisory_test.go new file mode 100644 index 0000000..0cf5543 --- /dev/null +++ b/adapters/models/openai_advisory_test.go @@ -0,0 +1,200 @@ +package models + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/cognitive" + "git.cin.su/m/Assistant/contracts/identity" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func validOpenAIAdvisoryRequest() cognitive.AdvisoryRequest { + return cognitive.AdvisoryRequest{ + Version: cognitive.CreatorAdvisoryV1, + RequestID: "request-1", + ActorID: identity.ActorID("actor-private"), + PrincipalID: identity.PrincipalID("principal-private"), + ScopeID: authority.ScopeID("scope-private"), + Message: "What should I verify?", + Locale: "en", + MaxOutputBytes: MaxAdvisoryResponseBytes, + ProviderBindingID: OpenAIAdvisoryBindingID, + ProviderBindingRevision: OpenAIAdvisoryBindingRevision, + } +} + +func providerHTTPResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func validOpenAIResponse(text string) string { + body, _ := json.Marshal(map[string]any{ + "status": "completed", + "model": openAIModel, + "store": false, + "output": []any{ + map[string]any{"type": "reasoning", "summary": []any{}}, + map[string]any{"type": "message", "role": "assistant", "status": "completed", "content": []any{ + map[string]any{"type": "output_text", "text": text, "annotations": []any{}}, + }}, + }, + "usage": map[string]any{"input_tokens": 12, "output_tokens": 7}, + }) + return string(body) +} + +func TestOpenAIAdvisorySendsOnlyModeAPayload(t *testing.T) { + calls := 0 + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + if request.Method != http.MethodPost || request.URL.String() != openAIResponsesURL || request.Host != "api.openai.com" { + t.Fatalf("unexpected target: %s %s host=%s", request.Method, request.URL, request.Host) + } + if request.Header.Get("Authorization") != "Bearer test-key" || request.Header.Get("Content-Type") != "application/json" { + t.Fatal("provider authentication or content type is missing") + } + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + if len(payload) != 6 || payload["model"] != openAIModel || payload["input"] != "What should I verify?" || payload["store"] != false || payload["instructions"] != openAIInstructions || payload["max_output_tokens"] != float64(openAIMaxOutputTokens) { + t.Fatalf("unexpected provider payload keys/values: %v", payload) + } + reasoning, ok := payload["reasoning"].(map[string]any) + if !ok || len(reasoning) != 1 || reasoning["effort"] != "low" { + t.Fatalf("unexpected reasoning payload: %v", payload["reasoning"]) + } + serialized := string(body) + for _, forbidden := range []string{"actor-private", "principal-private", "scope-private", "request-1", "tools", "previous_response_id", "conversation", "background"} { + if strings.Contains(serialized, forbidden) { + t.Fatalf("provider payload leaked forbidden field %q", forbidden) + } + } + return providerHTTPResponse(http.StatusOK, validOpenAIResponse("Check the current evidence.")), nil + }) + adapter, err := NewOpenAIAdvisoryAdapter([]byte("test-key"), transport) + if err != nil { + t.Fatal(err) + } + response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest()) + if err != nil { + t.Fatal(err) + } + if calls != 1 || response.FinishReason != cognitive.FinishCompleted || response.AdvisoryText != "Check the current evidence." || response.Usage == nil || response.Usage.InputUnits != 12 || response.Usage.OutputUnits != 7 { + t.Fatalf("unexpected response: calls=%d response=%+v", calls, response) + } +} + +func TestOpenAIAdvisoryRejectsContextBeforeTransport(t *testing.T) { + called := false + adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) { + called = true + return nil, errors.New("must not call") + })) + request := validOpenAIAdvisoryRequest() + request.Context = []cognitive.ContextExcerpt{{ID: "excerpt", SourceKind: "task", SourceID: "task", Revision: 1, Sensitivity: cognitive.SensitivityPublic, Text: "public"}} + if _, err := adapter.Advise(context.Background(), request); err == nil || called { + t.Fatalf("error=%v called=%t", err, called) + } +} + +func TestOpenAIAdvisoryDoesNotFollowRedirectOrRetry(t *testing.T) { + calls := 0 + adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + response := providerHTTPResponse(http.StatusFound, "{}") + response.Header.Set("Location", "https://attacker.invalid/steal") + return response, nil + })) + response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest()) + if err != nil || calls != 1 || response.FinishReason != cognitive.FinishProviderUnavailable { + t.Fatalf("error=%v calls=%d response=%+v", err, calls, response) + } +} + +type timeoutError struct{} + +func (timeoutError) Error() string { return "timeout" } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } + +func TestOpenAIAdvisoryMapsTimeoutAndDoesNotRetry(t *testing.T) { + calls := 0 + adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + return nil, timeoutError{} + })) + response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest()) + if err != nil || calls != 1 || response.FinishReason != cognitive.FinishTimeout { + t.Fatalf("error=%v calls=%d response=%+v", err, calls, response) + } +} + +func TestOpenAIAdvisoryMapsRefusal(t *testing.T) { + body := `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"refusal","refusal":"cannot comply"}]}],"usage":{"input_tokens":3,"output_tokens":0}}` + adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) { + return providerHTTPResponse(http.StatusOK, body), nil + })) + response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest()) + if err != nil || response.FinishReason != cognitive.FinishContentRejected || response.AdvisoryText != "" { + t.Fatalf("error=%v response=%+v", err, response) + } +} + +func TestOpenAIAdvisoryMapsOutputLimit(t *testing.T) { + body := `{"status":"incomplete","model":"gpt-5.4-mini-2026-03-17","store":false,"incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":3,"output_tokens":4096}}` + adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) { + return providerHTTPResponse(http.StatusOK, body), nil + })) + response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest()) + if err != nil || response.FinishReason != cognitive.FinishLengthLimit || response.AdvisoryText != "" { + t.Fatalf("error=%v response=%+v", err, response) + } +} + +func TestOpenAIAdvisoryRejectsExecutableOrUntrustedOutput(t *testing.T) { + tests := map[string]string{ + "tool call": `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"function_call","name":"mutate"}],"usage":{"input_tokens":1,"output_tokens":1}}`, + "citation": `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"answer","annotations":[{"type":"url_citation"}]}]}],"usage":{"input_tokens":1,"output_tokens":1}}`, + "wrong model": strings.Replace(validOpenAIResponse("answer"), openAIModel, "moving-alias", 1), + "oversize": validOpenAIResponse(strings.Repeat("a", MaxAdvisoryResponseBytes+1)), + "malformed": `{`, + } + for name, body := range tests { + t.Run(name, func(t *testing.T) { + adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) { + return providerHTTPResponse(http.StatusOK, body), nil + })) + response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest()) + if err != nil || response.FinishReason != cognitive.FinishInvalidProviderResponse || response.AdvisoryText != "" { + t.Fatalf("error=%v response=%+v", err, response) + } + }) + } +} + +func TestOpenAIAdvisoryRejectsInvalidCredential(t *testing.T) { + for _, key := range [][]byte{nil, []byte("line\nbreak")} { + if _, err := NewOpenAIAdvisoryAdapter(key, nil); err == nil { + t.Fatalf("credential %q was accepted", key) + } + } +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index cafbdaa..b17a98c 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/ARCH-000010-accept-openai-advisory-binding.json", - "task_id": "ARCH-000010", + "task_file": "dev/tasks/DEV-000050-openai-advisory-adapter.json", + "task_id": "DEV-000050", "task_revision": 1 } diff --git a/dev/tasks/DEV-000050-openai-advisory-adapter.json b/dev/tasks/DEV-000050-openai-advisory-adapter.json new file mode 100644 index 0000000..e5242a3 --- /dev/null +++ b/dev/tasks/DEV-000050-openai-advisory-adapter.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000050", + "task_revision": 1, + "architecture_revision": "ADR-0007", + "base_repo_revision": "00965f20e0c9a0e15a9444c72c4208f328834e08", + "risk_tier": "E", + "intent": "Implement the fixed OpenAI Responses API Creator advisory adapter with standard-library HTTP and adversarial fake-transport tests, without wiring runtime configuration, adding a real credential, enabling external egress or changing the provider-neutral contract.", + "requirement_ids": ["ADR-0006", "ADR-0007", "INV-EPI-001"], + "allowed_change_scope": {"modules": [], "paths": ["adapters/models/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000050-openai-advisory-adapter.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "cmd/**", "database/**", "deploy/**", "security/**", "tests/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0}, + "contracts": {"consume": ["creator-advisory-v1", "ADR-0007"], "may_change": []}, + "semantic_owner": "none-provider-adapter", + "canonical_state": [], + "state_transitions": [], + "side_effects_and_privileges": ["adapter can construct one fixed-origin HTTPS request when invoked by a later authorized runtime task", "this task invokes only local fake transports and grants no runtime/provider credential or production egress"], + "persistence_and_migrations": [], + "failure_retry_restart": ["one call and no retry", "deadline maps to TIMEOUT", "transport/status failure maps to PROVIDER_UNAVAILABLE", "refusal maps to CONTENT_REJECTED", "invalid or executable provider response maps to INVALID_PROVIDER_RESPONSE", "no request or response is persisted"], + "assumptions": ["Mode A permits message-only payload and forbids all context excerpts", "fixed binding is openai-responses-gpt-5.4-mini-2026-03-17 revision 1", "docker-test remains prohibited"], + "acceptance_scenarios": ["request uses only fixed origin/path/model/instructions/message/store=false/reasoning low and output limit", "authority/routing fields and context are absent from provider JSON", "redirects and ambient proxies are disabled", "unknown/tool output, citation annotations, wrong model and oversize/malformed responses fail closed", "refusal, timeout and unavailable mappings are explicit", "tests prove no automatic retry and no body logging", "no SDK or third-party dependency is added", "runtime remains deterministic fake only"], + "required_tests": ["unit", "adversarial fake transport", "go test ./...", "go vet ./...", "architecture preflight", "exact diff scope", "CI"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 0a8e2ee..d86e8c2 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -30,4 +30,6 @@ ADR-0006 and DEV-000046 establish the provider-neutral, non-canonical Creator ad DEV-000049 adds and executes a repeatable production audit for that boundary. It proves that an authenticated 16 KiB message reaches only the deterministic unavailable binding, unauthenticated and browser-supplied authority fields fail closed, the request sentinel is absent from cognitive-worker logs, and the cognitive worker has no database, Commit/Executor, Creator or provider credential environment/mount. +ADR-0007 accepts OpenAI Responses API with immutable snapshot `gpt-5.4-mini-2026-03-17` and Mode A per-request retention disclosure as the first real provider binding class. DEV-000050 implements its standard-library adapter and adversarial fake-transport tests: fixed origin/payload, message-only egress, no tools or provider memory, redirect/proxy denial, no retry, strict output mapping and fail-closed timeout/refusal/unavailable/invalid states. The adapter is not wired into the runtime; production remains on `deterministic-fake` with no provider credential or egress. + These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository.