diff --git a/config.example.yaml b/config.example.yaml index 5c1f31d4..ce2d3cca 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -157,17 +157,19 @@ hitl: default_reviewer: human # 全局默认审批等待时限(秒):300=5分钟,0=不限时;新建会话无独立配置时沿用 default_timeout_seconds: 300 - # 审计 Agent 专用模型;字段留空则复用上方 openai 配置。建议 model 填小模型,用于降低审批成本。 + # 审计 Agent 后端二选一:openai=兼容协议聊天模型(提示词 JSON);typesafe=TypeSafe Jev 结构化放通/拦截。 + audit_backend: openai + # 审计 Agent 专用模型。openai 后端字段留空则复用主模型;typesafe 后端 api_key 必填且不继承主模型密钥。 audit_model: - provider: "" # openai / claude;留空跟随 openai.provider - base_url: "" # 留空跟随 openai.base_url - api_key: "" # 留空跟随 openai.api_key - model: "" # 留空跟随 openai.model,例如可填 gpt-4o-mini / qwen-turbo / deepseek-chat + provider: "" # openai / claude;仅 openai 后端生效,留空跟随 openai.provider + base_url: "" # openai 后端留空跟随主模型;typesafe 后端留空使用 https://api.typesafe.ai + api_key: "" # openai 后端留空跟随主模型;typesafe 后端填写 TypeSafe API Key + model: "" # openai 后端建议填小模型;typesafe 后端留空使用 jev-latest # 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理) retention_days: 90 # 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 [] tool_whitelist: [read_file, ls, list_dir, glob, grep, tool_search, upsert_project_fact, get_project_fact, list_project_facts, search_project_facts, list_vulnerabilities, get_vulnerability, get_asset, query_assets, list_knowledge_risk_types, get_tool_execution, wait_tool_execution, batch_task_list, batch_task_get, manage_webshell_list, c2_event, c2_file] - # audit_agent_prompt: | # 审批模式;留空使用内置默认,可在「人机协同」页编辑 + # audit_agent_prompt: | # 审批模式;留空使用内置默认,可在「人机协同」页编辑。openai 后端作聊天提示词;typesafe 后端作为 Jev 组织策略 # audit_agent_prompt_review_edit: | # 审查编辑模式;留空使用内置默认 audit_agent_prompt: |- diff --git a/docs/en-US/configuration.md b/docs/en-US/configuration.md index f49d0129..cfedeb20 100644 --- a/docs/en-US/configuration.md +++ b/docs/en-US/configuration.md @@ -103,7 +103,9 @@ Common Web UI operations: ## Fallback Relationships - `vision.api_key/base_url/provider` can inherit from the resolved default AI channel. -- `hitl.audit_model` can inherit from the resolved default AI channel. +- `hitl.audit_backend` chooses `openai` (default) or `typesafe` (TypeSafe Jev). +- `hitl.audit_model` can inherit from the resolved default AI channel when `audit_backend` is `openai`. TypeSafe keys are never inherited. +- `hitl.audit_agent_prompt` is a chat system prompt on `openai`, and a Jev `operatorPolicy` overlay on `typesafe`. The built-in default prompt is not copied into Jev state. - `knowledge.embedding.base_url/api_key` can inherit from model settings. - rerank config can inherit from embedding/openai. - `database.knowledge_db_path` can be separate or reuse the main DB. diff --git a/docs/en-US/hitl-best-practices.md b/docs/en-US/hitl-best-practices.md index e507c243..6608fdce 100644 --- a/docs/en-US/hitl-best-practices.md +++ b/docs/en-US/hitl-best-practices.md @@ -9,6 +9,7 @@ HITL reviews tool calls before an Agent executes them. Use it to control high-ri Open **System Settings → Human-in-the-loop** in the web UI. You can configure: - Global default reviewer: `human` or `audit_agent` +- Approval engine: `hitl.audit_backend` (`openai` or `typesafe`) - Dedicated Audit Agent model: `hitl.audit_model` - Resolved audit log retention days - No-approval tool allowlist: `hitl.tool_whitelist` @@ -19,6 +20,7 @@ Example `config.yaml`: ```yaml hitl: default_reviewer: human + audit_backend: openai audit_model: provider: "" base_url: "" @@ -28,7 +30,9 @@ hitl: tool_whitelist: [read_file, ls, glob, grep, tool_search, get_project_fact, list_project_facts, search_project_facts, list_vulnerabilities, get_vulnerability, get_asset, query_assets, list_knowledge_risk_types, get_tool_execution, wait_tool_execution, batch_task_list, batch_task_get, manage_webshell_list, c2_event, c2_file] ``` -`audit_model` supports partial configuration. Empty fields inherit from the resolved default AI channel, so the common setup is to fill only `model` and run approvals on a cheaper small model. +`audit_backend` is a choice of `openai` (default, chat-completions JSON from the prompt) or `typesafe` (TypeSafe Jev). Custom audit-strategy text is evaluated as structured `operatorPolicy` questions; built-in destructive rules remain a hard floor. Jev cannot rewrite arguments, including in review-edit mode. The built-in default prompt is already encoded as Jev questions and is not copied into state. + +`audit_model` supports partial configuration on the OpenAI backend. Empty fields inherit from the resolved default AI channel. On the TypeSafe backend, `api_key` is required and is **not** inherited from the main model; blank `base_url` uses `https://api.typesafe.ai`, and blank `model` uses `jev-latest`. ## Recommended Approval Strategy @@ -86,6 +90,8 @@ Reject actions outside the user-authorized target scope. In review-edit mode, you may narrow paths, targets, or command arguments before approving, but must not expand the attack surface. ``` +On the OpenAI backend this text is a chat system prompt. On TypeSafe Jev it becomes an `operatorPolicy` overlay evaluated as structured questions; built-in destructive rules remain a hard floor, and Jev will not rewrite arguments. If the text is empty or identical to the built-in default, Jev uses the built-in questions only and does not copy the long prompt into state. + ### 4. Keep The Allowlist Conservative Allowlisted tools skip approval, so keep the list stable and low-risk. Recommended examples: diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 0db87d4a..0b0e0d08 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -107,6 +107,7 @@ agent: ```yaml hitl: default_reviewer: audit_agent + audit_backend: openai retention_days: 90 tool_whitelist: [read_file, list_dir, glob, grep, tool_search] audit_model: @@ -117,9 +118,10 @@ hitl: ``` - `default_reviewer`:`human` 或 `audit_agent`。 +- `audit_backend`:`openai`(默认,兼容协议聊天模型)或 `typesafe`(TypeSafe Jev)。 - `tool_whitelist`:全局免审批工具列表,会与会话白名单合并。 -- `audit_model`:审计 Agent 独立模型;留空复用主模型。 -- `audit_agent_prompt` / `audit_agent_prompt_review_edit`:可覆盖默认审批策略。 +- `audit_model`:openai 后端留空复用主模型;typesafe 后端需填写 TypeSafe API Key,不继承主模型密钥。 +- `audit_agent_prompt` / `audit_agent_prompt_review_edit`:openai 后端作为聊天提示词;typesafe 后端作为 Jev 的组织策略(`operatorPolicy`)。内置默认提示词与 Jev 问题重复,不会再复制进 state。 更多策略见 [人机协同最佳实践](hitl-best-practices.md)。 @@ -264,7 +266,7 @@ project: 几个字段有“留空复用”的关系: - `vision.api_key/base_url/provider` 留空时复用 `openai`。 -- `hitl.audit_model` 留空时复用默认 AI 通道解析后的 `openai`。 +- `hitl.audit_model` 在 `audit_backend=openai` 时留空复用默认 AI 通道;typesafe 后端不继承主模型密钥。 - `knowledge.embedding.base_url/api_key` 留空时复用主模型或 embedding 默认配置。 - `knowledge.retrieval.rerank.base_url/api_key` 留空时复用 embedding/openai。 - `database.knowledge_db_path` 留空时可以复用主会话数据库,但独立文件更利于备份。 diff --git a/docs/zh-CN/hitl-best-practices.md b/docs/zh-CN/hitl-best-practices.md index 111cad1a..1e01f0ed 100644 --- a/docs/zh-CN/hitl-best-practices.md +++ b/docs/zh-CN/hitl-best-practices.md @@ -9,6 +9,7 @@ Web 端进入 **系统设置 → 人机协同**,可配置: - 全局默认审批方:`human` 或 `audit_agent` +- 审批引擎:`hitl.audit_backend`(`openai` 或 `typesafe`) - 审计 Agent 专用模型:`hitl.audit_model` - 已决策审计日志保留天数 - 免审批工具白名单:`hitl.tool_whitelist` @@ -19,6 +20,7 @@ Web 端进入 **系统设置 → 人机协同**,可配置: ```yaml hitl: default_reviewer: human + audit_backend: openai audit_model: provider: "" base_url: "" @@ -28,7 +30,9 @@ hitl: tool_whitelist: [read_file, ls, glob, grep, tool_search, get_project_fact, list_project_facts, search_project_facts, list_vulnerabilities, get_vulnerability, get_asset, query_assets, list_knowledge_risk_types, get_tool_execution, wait_tool_execution, batch_task_list, batch_task_get, manage_webshell_list, c2_event, c2_file] ``` -`audit_model` 的字段可以只填一部分。空字段会自动继承默认 AI 通道解析后的模型配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。 +`audit_backend` 为二选一:`openai`(默认)走兼容协议聊天模型,用提示词输出 JSON;`typesafe` 走 TypeSafe Jev。自定义审批策略会作为 `operatorPolicy` 编进结构化问题,内置破坏性规则仍是硬底线。Jev 不能改参,审查编辑模式下也只返回通过/拒绝。内置默认提示词与 Jev 问题重复,不会再复制进 state。 + +`audit_model` 在 openai 后端可以只填一部分,空字段继承默认 AI 通道。typesafe 后端的 `api_key` 必填且**不会**复用主模型密钥;`base_url` 留空为 `https://api.typesafe.ai`,`model` 留空为 `jev-latest`。 ## 推荐审批策略 @@ -86,6 +90,8 @@ hitl: 审查编辑模式下,可将路径、目标、命令参数收窄后 approve,但不得扩大攻击面。 ``` +OpenAI 协议后端把这段文字当聊天提示词。TypeSafe Jev 把它当作 `operatorPolicy` 编进结构化问题;内置破坏性规则仍是硬底线,Jev 不会改参。留空或等于内置默认时,Jev 只用内置问题,不再把长提示词复制进 state。 + ### 4. 白名单只放稳定低风险工具 白名单工具会跳过审批,因此要保守维护。推荐放: diff --git a/internal/app/app.go b/internal/app/app.go index 1200c94d..07891717 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1073,6 +1073,7 @@ func setupRoutes( protected.PUT("/config", configHandler.UpdateConfig) protected.POST("/config/apply", configHandler.ApplyConfig) protected.POST("/config/test-openai", configHandler.TestOpenAI) + protected.POST("/config/test-typesafe", configHandler.TestTypeSafe) protected.POST("/config/test-vision", configHandler.TestVision) protected.POST("/config/list-models", configHandler.ListModels) diff --git a/internal/config/config.go b/internal/config/config.go index 283ad029..3b229176 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1114,7 +1114,9 @@ type AgentConfig struct { // tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效。 // audit_agent_prompt / audit_agent_prompt_review_edit 可在人机协同页编辑并立即生效;空则使用内置默认。 type HitlConfig struct { - // AuditModel 审计 Agent 专用模型;字段留空时继承 OpenAI 主配置,便于用小模型做审批。 + // AuditBackend 审计 Agent 后端:openai(兼容协议聊天模型)或 typesafe(Jev 结构化裁决)。空值视为 openai。 + AuditBackend string `yaml:"audit_backend,omitempty" json:"audit_backend,omitempty"` + // AuditModel 审计 Agent 专用模型。openai 后端空字段继承主模型;typesafe 后端 api_key 必填,不继承主模型密钥。 AuditModel OpenAIConfig `yaml:"audit_model,omitempty" json:"audit_model,omitempty"` // ToolWhitelist 全局免审批工具名(与白名单内工具不触发 HITL 审批)。 ToolWhitelist []string `yaml:"tool_whitelist,omitempty" json:"tool_whitelist,omitempty"` @@ -1176,6 +1178,37 @@ func (h HitlConfig) RetentionDaysEffective() int { return *h.RetentionDays } +const ( + HitlAuditBackendOpenAI = "openai" + HitlAuditBackendTypeSafe = "typesafe" + TypeSafeDefaultBaseURL = "https://api.typesafe.ai" + TypeSafeDefaultModel = "jev-latest" +) + +// EffectiveAuditBackend returns openai or typesafe. Omitted or unknown values default to openai. +func (h HitlConfig) EffectiveAuditBackend() string { + switch strings.ToLower(strings.TrimSpace(h.AuditBackend)) { + case HitlAuditBackendTypeSafe, "jev", "type-safe", "typesafe-ai": + return HitlAuditBackendTypeSafe + default: + return HitlAuditBackendOpenAI + } +} + +// TypeSafeConfigEffective returns TypeSafe endpoint settings. Empty base_url/model use defaults; API key is never inherited from the main OpenAI channel. +func (h HitlConfig) TypeSafeConfigEffective() (baseURL, apiKey, model string) { + baseURL = strings.TrimSpace(h.AuditModel.BaseURL) + if baseURL == "" { + baseURL = TypeSafeDefaultBaseURL + } + apiKey = strings.TrimSpace(h.AuditModel.APIKey) + model = strings.TrimSpace(h.AuditModel.Model) + if model == "" { + model = TypeSafeDefaultModel + } + return strings.TrimSuffix(baseURL, "/"), apiKey, model +} + // AuditModelEffective returns the audit-agent model config with empty fields inherited from the main model config. func (h HitlConfig) AuditModelEffective(main OpenAIConfig) OpenAIConfig { out := main @@ -1291,6 +1324,22 @@ func (c HitlConfig) EffectiveAuditAgentPromptForMode(mode string) string { return DefaultHitlAuditAgentPrompt() } +// JevOperatorPolicy returns a custom audit-strategy prompt for TypeSafe Jev. +// Built-in default prompts stay encoded as Jev questions and are not copied into state. +func (c HitlConfig) JevOperatorPolicy(mode string) string { + effective := strings.TrimSpace(c.EffectiveAuditAgentPromptForMode(mode)) + var def string + if normalizeHitlModeForPrompt(mode) == "review_edit" { + def = strings.TrimSpace(DefaultHitlAuditAgentPromptReviewEdit()) + } else { + def = strings.TrimSpace(DefaultHitlAuditAgentPrompt()) + } + if effective == "" || effective == def { + return "" + } + return effective +} + func normalizeHitlModeForPrompt(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case "review_edit": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f2d51e56..007ae1d3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -75,6 +75,34 @@ func TestLoadIgnoresLegacyAuthPasswordField(t *testing.T) { } } +func TestHitlEffectiveAuditBackend(t *testing.T) { + if got := (HitlConfig{}).EffectiveAuditBackend(); got != HitlAuditBackendOpenAI { + t.Fatalf("empty backend = %q, want openai", got) + } + if got := (HitlConfig{AuditBackend: "Jev"}).EffectiveAuditBackend(); got != HitlAuditBackendTypeSafe { + t.Fatalf("jev alias = %q, want typesafe", got) + } + if got := (HitlConfig{AuditBackend: "claude"}).EffectiveAuditBackend(); got != HitlAuditBackendOpenAI { + t.Fatalf("unknown backend = %q, want openai", got) + } +} + +func TestHitlTypeSafeConfigEffectiveDoesNotInheritMainKey(t *testing.T) { + gotURL, gotKey, gotModel := (HitlConfig{ + AuditBackend: "typesafe", + AuditModel: OpenAIConfig{APIKey: "ts-key"}, + }).TypeSafeConfigEffective() + if gotURL != TypeSafeDefaultBaseURL { + t.Fatalf("base url = %q, want default", gotURL) + } + if gotKey != "ts-key" { + t.Fatalf("api key = %q, want ts-key", gotKey) + } + if gotModel != TypeSafeDefaultModel { + t.Fatalf("model = %q, want default", gotModel) + } +} + func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) { main := OpenAIConfig{ Provider: "openai", diff --git a/internal/config/hitl_prompt_test.go b/internal/config/hitl_prompt_test.go index d2fbdcb8..d584ed4e 100644 --- a/internal/config/hitl_prompt_test.go +++ b/internal/config/hitl_prompt_test.go @@ -29,3 +29,15 @@ func TestDefaultHitlAuditAgentPromptReviewEditKeepsEditedArguments(t *testing.T) t.Fatal("review-edit prompt must require a matched rule") } } + +func TestJevOperatorPolicySkipsDefaultPrompt(t *testing.T) { + if got := (HitlConfig{}).JevOperatorPolicy("approval"); got != "" { + t.Fatalf("empty config should not send default prompt to Jev, got %q", got) + } + if got := (HitlConfig{AuditAgentPrompt: DefaultHitlAuditAgentPrompt()}).JevOperatorPolicy("approval"); got != "" { + t.Fatalf("default prompt should not be sent to Jev, got %q", got) + } + if got := (HitlConfig{AuditAgentPrompt: "拦截所有命令执行"}).JevOperatorPolicy("approval"); got != "拦截所有命令执行" { + t.Fatalf("custom prompt=%q", got) + } +} diff --git a/internal/handler/config.go b/internal/handler/config.go index 61daa929..2bcebd61 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -24,6 +24,7 @@ import ( "cyberstrike-ai/internal/openai" "cyberstrike-ai/internal/security" "cyberstrike-ai/internal/toolguard" + "cyberstrike-ai/internal/typesafe" "github.com/cloudwego/eino/schema" "github.com/gin-gonic/gin" @@ -891,6 +892,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { } if req.Hitl != nil { + h.config.Hitl.AuditBackend = req.Hitl.EffectiveAuditBackend() h.config.Hitl.AuditModel = req.Hitl.AuditModel h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, req.Hitl.ToolWhitelist) if strings.TrimSpace(req.Hitl.DefaultMode) != "" { @@ -911,6 +913,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { h.config.Hitl.RetentionDays = &v } h.logger.Info("更新HITL配置", + zap.String("audit_backend", h.config.Hitl.AuditBackend), zap.String("default_reviewer", h.config.Hitl.DefaultReviewer), zap.Int("tool_whitelist", len(h.config.Hitl.ToolWhitelist)), ) @@ -1317,6 +1320,61 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) { }) } +// TestTypeSafeRequest 测试 TypeSafe / Jev 连接。 +type TestTypeSafeRequest struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` +} + +// TestTypeSafe 用一条最小 Noul 验证 TypeSafe System One 是否可用。 +func (h *ConfigHandler) TestTypeSafe(c *gin.Context) { + var req TestTypeSafeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) + return + } + if strings.TrimSpace(req.APIKey) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "TypeSafe API Key 不能为空"}) + return + } + + client := typesafe.NewClient(req.BaseURL, req.APIKey, req.Model, nil) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + start := time.Now() + result, err := client.SystemOne(ctx, "connectivity ping", map[string]typesafe.Question{ + "ok": typesafe.Noul("Is this a connectivity test ping?", "Yes, this is only a ping.", "No."), + }) + if err != nil { + if apiErr, ok := err.(*typesafe.APIError); ok { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", apiErr.StatusCode, apiErr.Body), + "status_code": apiErr.StatusCode, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": false, + "error": "连接失败: " + err.Error(), + }) + return + } + model := strings.TrimSpace(req.Model) + if result != nil && strings.TrimSpace(result.Model) != "" { + model = result.Model + } + if model == "" { + model = config.TypeSafeDefaultModel + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "model": model, + "latency_ms": time.Since(start).Milliseconds(), + }) +} + // ListModelsRequest 获取模型列表请求(OpenAI 兼容 GET /models)。 type ListModelsRequest struct { Provider string `json:"provider"` @@ -2149,6 +2207,7 @@ func (h *ConfigHandler) MergeHitlToolWhitelistIntoConfig(add []string) error { func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) { root := doc.Content[0] hitlNode := ensureMap(root, "hitl") + setStringInMap(hitlNode, "audit_backend", cfg.EffectiveAuditBackend()) auditModelNode := ensureMap(hitlNode, "audit_model") setStringInMap(auditModelNode, "provider", cfg.AuditModel.Provider) setStringInMap(auditModelNode, "base_url", cfg.AuditModel.BaseURL) diff --git a/internal/handler/hitl.go b/internal/handler/hitl.go index 616ac1c7..c136499d 100644 --- a/internal/handler/hitl.go +++ b/internal/handler/hitl.go @@ -659,10 +659,13 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex expiresAt := approvalStartedAt.Add(cfg.Timeout) approvalExpiresAt = &expiresAt } + auditBackend, auditModel := h.hitlAuditEngineInfo() payload["hitlApproval"] = map[string]interface{}{ "createdAt": approvalStartedAt, "timeoutSeconds": timeoutSeconds, "expiresAt": approvalExpiresAt, + "auditBackend": auditBackend, + "auditModel": auditModel, } payloadRaw, _ := json.Marshal(payload) p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw), cfg.Reviewer) @@ -1072,11 +1075,14 @@ type setHitlDefaultConfigReq struct { } func (h *AgentHandler) hitlDefaultConfigResponse() gin.H { + backend, model := h.hitlAuditEngineInfo() return gin.H{ "defaultMode": h.hitlEffectiveDefaultMode(), "defaultReviewer": h.hitlEffectiveDefaultReviewer(), "defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(), "hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(), + "auditBackend": backend, + "auditModel": model, } } diff --git a/internal/handler/hitl_audit_agent.go b/internal/handler/hitl_audit_agent.go index 8bbd37b9..4da67c75 100644 --- a/internal/handler/hitl_audit_agent.go +++ b/internal/handler/hitl_audit_agent.go @@ -10,7 +10,9 @@ import ( "time" "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/hitl" "cyberstrike-ai/internal/openai" + "cyberstrike-ai/internal/typesafe" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -23,6 +25,9 @@ func (h *AgentHandler) auditAgentReview(ctx context.Context, hitlMode, toolName return hitlDecision{Decision: "reject", Comment: "audit agent: handler unavailable"} } mode := normalizeHitlMode(hitlMode) + if h.config != nil && h.config.Hitl.EffectiveAuditBackend() == config.HitlAuditBackendTypeSafe { + return h.auditAgentReviewTypeSafe(ctx, mode, toolName, payload) + } prompt := config.DefaultHitlAuditAgentPrompt() if h.config != nil { prompt = h.config.Hitl.EffectiveAuditAgentPromptForMode(mode) @@ -109,6 +114,34 @@ func (h *AgentHandler) auditLLMConfig() config.OpenAIConfig { return config.OpenAIConfig{} } +func (h *AgentHandler) auditAgentReviewTypeSafe(ctx context.Context, hitlMode, toolName string, payload map[string]interface{}) hitlDecision { + if h == nil || h.config == nil { + return hitlDecision{Decision: "reject", Comment: "audit agent: TypeSafe 未配置"} + } + baseURL, apiKey, model := h.config.Hitl.TypeSafeConfigEffective() + if apiKey == "" { + return hitlDecision{Decision: "reject", Comment: "audit agent: TypeSafe API Key 未配置"} + } + if ctx == nil { + ctx = context.Background() + } + callCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + client := typesafe.NewClient(baseURL, apiKey, model, nil) + policy := h.config.Hitl.JevOperatorPolicy(hitlMode) + result, err := client.SystemOne(callCtx, hitl.BuildJevState(hitlMode, toolName, payload, policy), hitl.JevAuditQuestions(policy)) + if err != nil { + h.logger.Warn("审计 Agent TypeSafe 调用失败", zap.Error(err), zap.String("tool", toolName)) + return hitlDecision{Decision: "reject", Comment: "audit agent: TypeSafe 调用失败,保守拒绝"} + } + decision, comment := hitl.DecideJev(result) + if comment == "" { + comment = "audit agent: " + decision + } + return hitlDecision{Decision: decision, Comment: comment} +} + func buildAuditAgentReviewInput(hitlMode, toolName string, payload map[string]interface{}) string { review := map[string]interface{}{ "hitlMode": normalizeHitlMode(hitlMode), diff --git a/internal/handler/hitl_audit_agent_test.go b/internal/handler/hitl_audit_agent_test.go index 8a7d9a4c..48a71784 100644 --- a/internal/handler/hitl_audit_agent_test.go +++ b/internal/handler/hitl_audit_agent_test.go @@ -1,8 +1,11 @@ package handler import ( + "context" "strings" "testing" + + "cyberstrike-ai/internal/config" ) func TestParseAuditAgentLLMContentApprove(t *testing.T) { @@ -65,6 +68,17 @@ func TestParseAuditAgentLLMContentWithEditedArguments(t *testing.T) { } } +func TestAuditAgentReviewTypeSafeMissingAPIKey(t *testing.T) { + h := &AgentHandler{config: &config.Config{Hitl: config.HitlConfig{AuditBackend: "typesafe"}}} + d := h.auditAgentReview(context.Background(), "approval", "exec", nil) + if d.Decision != "reject" { + t.Fatalf("decision=%s", d.Decision) + } + if !strings.Contains(d.Comment, "TypeSafe API Key") { + t.Fatalf("comment=%s", d.Comment) + } +} + func TestBuildAuditAgentReviewInputIncludesMode(t *testing.T) { s := buildAuditAgentReviewInput("review_edit", "execute", map[string]interface{}{ "arguments": `{"command":"pwd"}`, diff --git a/internal/handler/hitl_audit_backend.go b/internal/handler/hitl_audit_backend.go new file mode 100644 index 00000000..b5acd0b7 --- /dev/null +++ b/internal/handler/hitl_audit_backend.go @@ -0,0 +1,74 @@ +package handler + +import ( + "encoding/json" + "strings" + + "cyberstrike-ai/internal/config" +) + +func (h *AgentHandler) hitlAuditEngineInfo() (backend, model string) { + backend = config.HitlAuditBackendOpenAI + if h == nil || h.config == nil { + return backend, "" + } + backend = h.config.Hitl.EffectiveAuditBackend() + if backend == config.HitlAuditBackendTypeSafe { + _, _, model = h.config.Hitl.TypeSafeConfigEffective() + return backend, model + } + return backend, strings.TrimSpace(h.config.Hitl.AuditModelEffective(h.config.OpenAI).Model) +} + +func stringifyHitlJSON(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + b, err := json.Marshal(v) + if err != nil { + return "" + } + var s string + if json.Unmarshal(b, &s) == nil { + return strings.TrimSpace(s) + } + return strings.TrimSpace(string(b)) +} + +func inferHitlAuditBackendFromComment(comment string) string { + c := strings.ToLower(comment) + if strings.Contains(comment, "TypeSafe") || strings.Contains(comment, "破坏分") || + strings.Contains(c, "choice=") || strings.Contains(comment, "Jev") { + return config.HitlAuditBackendTypeSafe + } + if strings.TrimSpace(comment) == "" { + return "" + } + return config.HitlAuditBackendOpenAI +} + +func hitlAuditBackendFromRecord(decidedBy, comment, payloadJSON string) (backend, model string) { + if normalizeHitlDecidedBy(decidedBy) != "audit_agent" { + return "", "" + } + var root map[string]any + if err := json.Unmarshal([]byte(payloadJSON), &root); err == nil { + if appr, ok := root["hitlApproval"].(map[string]any); ok { + raw := stringifyHitlJSON(appr["auditBackend"]) + if raw != "" { + backend = (config.HitlConfig{AuditBackend: raw}).EffectiveAuditBackend() + } + model = stringifyHitlJSON(appr["auditModel"]) + } + } + if backend == "" { + backend = inferHitlAuditBackendFromComment(comment) + } + if backend == "" { + backend = config.HitlAuditBackendOpenAI + } + return backend, model +} diff --git a/internal/handler/hitl_audit_backend_test.go b/internal/handler/hitl_audit_backend_test.go new file mode 100644 index 00000000..a634a251 --- /dev/null +++ b/internal/handler/hitl_audit_backend_test.go @@ -0,0 +1,69 @@ +package handler + +import ( + "testing" + + "cyberstrike-ai/internal/config" +) + +func TestHitlAuditEngineInfoTypeSafe(t *testing.T) { + h := &AgentHandler{config: &config.Config{ + OpenAI: config.OpenAIConfig{Model: "gpt-4o"}, + Hitl: config.HitlConfig{AuditBackend: "typesafe"}, + }} + backend, model := h.hitlAuditEngineInfo() + if backend != config.HitlAuditBackendTypeSafe { + t.Fatalf("backend=%q", backend) + } + if model != config.TypeSafeDefaultModel { + t.Fatalf("model=%q, want %s", model, config.TypeSafeDefaultModel) + } +} + +func TestHitlAuditEngineInfoOpenAIInheritsMainModel(t *testing.T) { + h := &AgentHandler{config: &config.Config{ + OpenAI: config.OpenAIConfig{Model: "gpt-4o-mini"}, + Hitl: config.HitlConfig{AuditBackend: "openai"}, + }} + backend, model := h.hitlAuditEngineInfo() + if backend != config.HitlAuditBackendOpenAI { + t.Fatalf("backend=%q", backend) + } + if model != "gpt-4o-mini" { + t.Fatalf("model=%q", model) + } +} + +func TestHitlAuditBackendFromRecordPrefersPayload(t *testing.T) { + backend, model := hitlAuditBackendFromRecord("audit_agent", "audit agent: 实际操作:探测", `{ + "hitlApproval": {"auditBackend": "typesafe", "auditModel": "jev-latest"} + }`) + if backend != config.HitlAuditBackendTypeSafe || model != "jev-latest" { + t.Fatalf("backend=%q model=%q", backend, model) + } +} + +func TestHitlAuditBackendFromRecordInfersJevComment(t *testing.T) { + backend, _ := hitlAuditBackendFromRecord("audit_agent", + "audit agent: 未命中破坏性规则,默认放行;最高破坏分=破坏业务可用性 0.12;choice=approve(0.90)", + `{}`) + if backend != config.HitlAuditBackendTypeSafe { + t.Fatalf("backend=%q", backend) + } +} + +func TestHitlAuditBackendFromRecordInfersOpenAIComment(t *testing.T) { + backend, _ := hitlAuditBackendFromRecord("audit_agent", + "audit agent: 实际操作:读取 /etc/passwd;命中规则:A3", + `{}`) + if backend != config.HitlAuditBackendOpenAI { + t.Fatalf("backend=%q", backend) + } +} + +func TestHitlAuditBackendFromRecordIgnoresHuman(t *testing.T) { + backend, model := hitlAuditBackendFromRecord("human", "人工通过", `{"hitlApproval":{"auditBackend":"typesafe"}}`) + if backend != "" || model != "" { + t.Fatalf("backend=%q model=%q", backend, model) + } +} diff --git a/internal/handler/hitl_logs.go b/internal/handler/hitl_logs.go index a6d787e0..773e35dc 100644 --- a/internal/handler/hitl_logs.go +++ b/internal/handler/hitl_logs.go @@ -56,6 +56,7 @@ func hitlInterruptRowToMap( if messageID.Valid { msgID = messageID.String } + auditBackend, auditModel := hitlAuditBackendFromRecord(decidedBy, comment.String, payload) return map[string]interface{}{ "id": id, "conversationId": cid, @@ -69,6 +70,8 @@ func hitlInterruptRowToMap( "decision": decision.String, "comment": comment.String, "decidedBy": decidedBy, + "auditBackend": auditBackend, + "auditModel": auditModel, "createdAt": createdAt, "decidedAt": func() interface{} { if decidedAt.Valid { diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index e5d03f71..02a041ee 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -4913,6 +4913,50 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, + "/api/config/test-typesafe": map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []string{"配置管理"}, + "summary": "测试 TypeSafe Jev 连接", + "description": "发送一条最小 Noul 请求,验证 TypeSafe System One API Key 是否可用。", + "operationId": "testTypeSafe", + "requestBody": map[string]interface{}{ + "required": true, + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "required": []string{"api_key"}, + "properties": map[string]interface{}{ + "base_url": map[string]interface{}{"type": "string", "description": "可选,默认 https://api.typesafe.ai"}, + "api_key": map[string]interface{}{"type": "string", "description": "TypeSafe API Key"}, + "model": map[string]interface{}{"type": "string", "description": "可选,默认 jev-latest", "example": "jev-latest"}, + }, + }, + }, + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "测试结果", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "success": map[string]interface{}{"type": "boolean"}, + "error": map[string]interface{}{"type": "string"}, + "model": map[string]interface{}{"type": "string"}, + "latency_ms": map[string]interface{}{"type": "number"}, + }, + }, + }, + }, + }, + "400": map[string]interface{}{"description": "参数错误"}, + "401": map[string]interface{}{"description": "未授权"}, + }, + }, + }, "/api/config/list-models": map[string]interface{}{ "post": map[string]interface{}{ "tags": []string{"配置管理"}, diff --git a/internal/hitl/jev.go b/internal/hitl/jev.go new file mode 100644 index 00000000..f228f4d3 --- /dev/null +++ b/internal/hitl/jev.go @@ -0,0 +1,195 @@ +package hitl + +import ( + "encoding/json" + "fmt" + "strings" + + "cyberstrike-ai/internal/typesafe" +) + +const ( + jevRejectThreshold = 0.55 + + jevQAvailability = "destroys_availability" + jevQDataLoss = "irreversible_data_loss" + jevQCredentials = "credential_or_acl_change" + jevQConfig = "config_tamper" + jevQPentest = "authorized_pentest_payload" + jevQOperatorPolicy = "violates_operator_policy" + jevQDecision = "decision" + + jevDefaultPolicy = "Approve authorized pentest operations by default. Reject only when the call would clearly destroy live business availability, irreversibly destroy real data, change production credentials/ACLs, or tamper with live system configuration. If both a pentest payload and a destructive action are present, reject." +) + +const jevStateMaxFieldRunes = 8000 + +// JevAuditQuestions is the built-in CyberStrikeAI approval policy, asked in English for Jev accuracy. +// A non-empty operatorPolicy adds a Noul/Choice overlay so the organization prompt is evaluated as state, not as a chat system prompt. +func JevAuditQuestions(operatorPolicy string) map[string]typesafe.Question { + qs := map[string]typesafe.Question{ + jevQAvailability: typesafe.Noul( + "Would executing this tool call stop, disable, reboot, or overwhelm a live business service?", + "The call would stop/kill/reboot a service, or generate DoS-level load on a production system.", + "The call would not take the business offline. Recon, scans, and authorized exploit payloads count as no.", + ), + jevQDataLoss: typesafe.Noul( + "Would executing this tool call cause irreversible loss or mass destruction of real business data or files?", + "DROP/TRUNCATE/DELETE of real tables, rm of important files, wiping a database, or similar irreversible destruction.", + "No irreversible data destruction. Authorized pentest writes, webshell drops, and read/query operations count as no.", + ), + jevQCredentials: typesafe.Noul( + "Would executing this tool call change real user passwords, force users offline, or modify accounts/roles/permissions?", + "Password reset, account/role/ACL changes, or forcing real users offline.", + "No account-system tampering. Exploit payloads that do not change production identity systems count as no.", + ), + jevQConfig: typesafe.Noul( + "Would executing this tool call change server, service, firewall, startup, or scheduled-task configuration of a live system?", + "Modifying system/Web/database/firewall/startup/cron configuration on a real host.", + "No live configuration change. Writing a test file or exploit artifact is not configuration tampering.", + ), + jevQPentest: typesafe.Noul( + "Is this an authorized penetration-testing action such as recon, scanning, exploit payload, webshell upload, reverse shell, or credential spraying at a non-DoS rate?", + "Typical authorized pentest activity that should be allowed even if it looks aggressive or performs writes.", + "Not a pentest payload, or the action is clearly aimed at destroying business availability or data.", + ), + jevQDecision: typesafe.Choice( + "Should CyberStrikeAI approve this tool call? Approve authorized pentest activity. Reject only actions that would clearly destroy live business availability, irreversibly destroy real data, change production credentials/ACLs, or tamper with live system configuration.", + map[string]string{ + "approve": "Safe to execute in an authorized pentest. Includes exploit payloads, webshells, reverse shells, scans, and recon.", + "reject": "Would clearly damage live business availability, destroy real data, change production accounts, or alter live system configuration.", + }, + ), + } + if strings.TrimSpace(operatorPolicy) == "" { + return qs + } + qs[jevQOperatorPolicy] = typesafe.Noul( + "Would executing this tool call be forbidden by the operatorPolicy text in the state? Read operatorPolicy even if it is written in Chinese. Treat exec, execute, shell, bash, zsh, and any command argument as command execution.", + "operatorPolicy forbids this action, for example by blocking all command execution or this specific tool or command.", + "operatorPolicy does not forbid this action.", + ) + qs[jevQDecision] = typesafe.Choice( + "Should this tool call be approved? Follow operatorPolicy in the state first; it is the organization's written approval policy and takes precedence over default pentest-allow rules. If operatorPolicy forbids this action, reject. Still reject live-system destruction, irreversible data loss, credential/ACL changes, and live configuration tampering.", + map[string]string{ + "approve": "operatorPolicy allows this action or is silent, and it would not destroy a live system.", + "reject": "operatorPolicy forbids this action, or it would destroy live business availability, data, credentials, or configuration.", + }, + ) + return qs +} + +// BuildJevState keeps only the fields Jev needs. Large cognition blobs are truncated to avoid context rot. +func BuildJevState(hitlMode, toolName string, payload map[string]interface{}, operatorPolicy string) map[string]interface{} { + policy := jevDefaultPolicy + if strings.TrimSpace(operatorPolicy) != "" { + policy = "Follow operatorPolicy first. It is the organization's written approval policy and may be in Chinese. If it forbids this action, reject. The built-in floor still rejects live-system destruction." + } + state := map[string]interface{}{ + "hitlMode": strings.TrimSpace(hitlMode), + "toolName": strings.TrimSpace(toolName), + "policy": policy, + } + if s := strings.TrimSpace(operatorPolicy); s != "" { + state["operatorPolicy"] = truncateRunes(s, jevStateMaxFieldRunes) + } + if payload == nil { + return state + } + for _, k := range []string{"arguments", "argumentsObj", "command", "userMessage"} { + if v, ok := payload[k]; ok && v != nil && fmt.Sprint(v) != "" { + state[k] = truncateJevValue(v) + } + } + return state +} + +func truncateJevValue(v interface{}) interface{} { + switch t := v.(type) { + case string: + return truncateRunes(t, jevStateMaxFieldRunes) + case map[string]interface{}, []interface{}: + b, err := json.Marshal(t) + if err != nil { + return truncateRunes(fmt.Sprint(t), jevStateMaxFieldRunes) + } + s := string(b) + if len([]rune(s)) <= jevStateMaxFieldRunes { + return t + } + return truncateRunes(s, jevStateMaxFieldRunes) + default: + return truncateRunes(fmt.Sprint(t), jevStateMaxFieldRunes) + } +} + +func truncateRunes(s string, max int) string { + rs := []rune(s) + if max <= 0 || len(rs) <= max { + return s + } + return string(rs[:max]) + "…" +} + +// DecideJev maps TypeSafe answers onto approve/reject using code-owned policy. +// Destructive Nouls and a custom operator-policy Noul win over pentest Nouls. +// Uncertain cases follow the product default: approve, unless a custom policy Choice rejects with high confidence. +func DecideJev(result *typesafe.Result) (decision, comment string) { + if result == nil { + return "reject", "audit agent: TypeSafe 无有效响应,保守拒绝" + } + availability := result.Noul(jevQAvailability) + dataLoss := result.Noul(jevQDataLoss) + credentials := result.Noul(jevQCredentials) + configTamper := result.Noul(jevQConfig) + pentest := result.Noul(jevQPentest) + policyNoul := result.Noul(jevQOperatorPolicy) + choice, choiceConf := result.Choice(jevQDecision) + _, hasOperatorPolicy := result.Answers[jevQOperatorPolicy] + + type hit struct { + id string + label string + noul float64 + } + hits := []hit{ + {jevQAvailability, "破坏业务可用性", availability}, + {jevQDataLoss, "不可逆数据破坏", dataLoss}, + {jevQCredentials, "账号权限篡改", credentials}, + {jevQConfig, "改系统配置", configTamper}, + {jevQOperatorPolicy, "组织审批策略", policyNoul}, + } + var fired []string + maxReject := 0.0 + topLabel := "" + for _, h := range hits { + if h.noul > maxReject { + maxReject = h.noul + topLabel = h.label + } + if h.noul >= jevRejectThreshold { + fired = append(fired, fmt.Sprintf("%s=%.2f", h.label, h.noul)) + } + } + + decision = "approve" + reason := "未命中破坏性规则,默认放行" + if maxReject >= jevRejectThreshold { + decision = "reject" + reason = "命中拒绝规则:" + strings.Join(fired, ";") + } else if strings.EqualFold(choice, "reject") && choiceConf >= 0.85 && (hasOperatorPolicy || (maxReject < 0.35 && pentest < 0.5)) { + decision = "reject" + if hasOperatorPolicy { + reason = fmt.Sprintf("Jev 按组织策略拒绝(choice=%.2f,策略分=%.2f)", choiceConf, policyNoul) + } else { + reason = fmt.Sprintf("Jev 高置信拒绝(choice=%.2f,最高破坏分=%.2f)", choiceConf, maxReject) + } + } + + if decision == "approve" && topLabel != "" { + reason = fmt.Sprintf("%s;最高破坏分=%s %.2f;渗透payload=%.2f", reason, topLabel, maxReject, pentest) + } + + comment = fmt.Sprintf("audit agent: %s;choice=%s(%.2f)", reason, choice, choiceConf) + return decision, comment +} diff --git a/internal/hitl/jev_test.go b/internal/hitl/jev_test.go new file mode 100644 index 00000000..2b54bd18 --- /dev/null +++ b/internal/hitl/jev_test.go @@ -0,0 +1,154 @@ +package hitl + +import ( + "strings" + "testing" + + "cyberstrike-ai/internal/typesafe" +) + +func TestDecideJevRejectsDestructive(t *testing.T) { + dec, comment := DecideJev(&typesafe.Result{Answers: map[string]map[string]any{ + jevQAvailability: {"noul": 0.92}, + jevQDataLoss: {"noul": 0.1}, + jevQCredentials: {"noul": 0.05}, + jevQConfig: {"noul": 0.04}, + jevQPentest: {"noul": 0.8}, + jevQDecision: {"choice": "approve", "confidence": 0.4}, + }}) + if dec != "reject" { + t.Fatalf("decision=%s comment=%s", dec, comment) + } + if !strings.Contains(comment, "破坏业务可用性") { + t.Fatalf("comment=%s", comment) + } +} + +func TestDecideJevApprovesPentestPayload(t *testing.T) { + dec, _ := DecideJev(&typesafe.Result{Answers: map[string]map[string]any{ + jevQAvailability: {"noul": 0.08}, + jevQDataLoss: {"noul": 0.06}, + jevQCredentials: {"noul": 0.04}, + jevQConfig: {"noul": 0.05}, + jevQPentest: {"noul": 0.97}, + jevQDecision: {"choice": "approve", "confidence": 0.9}, + }}) + if dec != "approve" { + t.Fatalf("decision=%s", dec) + } +} + +func TestDecideJevDestructiveWinsOverPentest(t *testing.T) { + dec, _ := DecideJev(&typesafe.Result{Answers: map[string]map[string]any{ + jevQAvailability: {"noul": 0.12}, + jevQDataLoss: {"noul": 0.88}, + jevQCredentials: {"noul": 0.1}, + jevQConfig: {"noul": 0.1}, + jevQPentest: {"noul": 0.95}, + jevQDecision: {"choice": "approve", "confidence": 0.7}, + }}) + if dec != "reject" { + t.Fatalf("decision=%s", dec) + } +} + +func TestDecideJevUncertainApproves(t *testing.T) { + dec, _ := DecideJev(&typesafe.Result{Answers: map[string]map[string]any{ + jevQAvailability: {"noul": 0.4}, + jevQDataLoss: {"noul": 0.2}, + jevQCredentials: {"noul": 0.1}, + jevQConfig: {"noul": 0.1}, + jevQPentest: {"noul": 0.3}, + jevQDecision: {"choice": "reject", "confidence": 0.5}, + }}) + if dec != "approve" { + t.Fatalf("decision=%s", dec) + } +} + +func TestBuildJevStateOmitsCognitionBlobs(t *testing.T) { + state := BuildJevState("approval", "exec", map[string]interface{}{ + "arguments": `{"command":"id"}`, + "userMessage": "whoami", + "thinking": "long chain", + "reasoningChain": "should not appear", + }, "") + if state["toolName"] != "exec" { + t.Fatalf("toolName=%v", state["toolName"]) + } + if _, ok := state["thinking"]; ok { + t.Fatal("thinking should be omitted") + } + if _, ok := state["reasoningChain"]; ok { + t.Fatal("reasoningChain should be omitted") + } + if state["arguments"] != `{"command":"id"}` { + t.Fatalf("arguments=%v", state["arguments"]) + } +} + +func TestJevAuditQuestionsCoverPolicyAxes(t *testing.T) { + qs := JevAuditQuestions("") + for _, id := range []string{jevQAvailability, jevQDataLoss, jevQCredentials, jevQConfig, jevQPentest, jevQDecision} { + if _, ok := qs[id]; !ok { + t.Fatalf("missing question %s", id) + } + } + if _, ok := qs[jevQOperatorPolicy]; ok { + t.Fatal("default questions should not include operator policy overlay") + } +} + +func TestBuildJevStateIncludesOperatorPolicy(t *testing.T) { + state := BuildJevState("approval", "exec", map[string]interface{}{"command": "id"}, "拦截所有命令执行") + if state["operatorPolicy"] != "拦截所有命令执行" { + t.Fatalf("operatorPolicy=%v", state["operatorPolicy"]) + } + policy, _ := state["policy"].(string) + if !strings.Contains(policy, "operatorPolicy") { + t.Fatalf("policy=%v", state["policy"]) + } +} + +func TestJevAuditQuestionsAddsPolicyOverlay(t *testing.T) { + qs := JevAuditQuestions("拦截所有命令执行") + if _, ok := qs[jevQOperatorPolicy]; !ok { + t.Fatal("missing operator policy noul") + } +} + +func TestDecideJevRejectsOperatorPolicy(t *testing.T) { + dec, comment := DecideJev(&typesafe.Result{Answers: map[string]map[string]any{ + jevQAvailability: {"noul": 0.08}, + jevQDataLoss: {"noul": 0.06}, + jevQCredentials: {"noul": 0.04}, + jevQConfig: {"noul": 0.05}, + jevQPentest: {"noul": 0.01}, + jevQOperatorPolicy: {"noul": 0.91}, + jevQDecision: {"choice": "approve", "confidence": 0.2}, + }}) + if dec != "reject" { + t.Fatalf("decision=%s comment=%s", dec, comment) + } + if !strings.Contains(comment, "组织审批策略") { + t.Fatalf("comment=%s", comment) + } +} + +func TestDecideJevPolicyChoiceRejectsEvenIfPentest(t *testing.T) { + dec, comment := DecideJev(&typesafe.Result{Answers: map[string]map[string]any{ + jevQAvailability: {"noul": 0.1}, + jevQDataLoss: {"noul": 0.1}, + jevQCredentials: {"noul": 0.1}, + jevQConfig: {"noul": 0.1}, + jevQPentest: {"noul": 0.9}, + jevQOperatorPolicy: {"noul": 0.4}, + jevQDecision: {"choice": "reject", "confidence": 0.92}, + }}) + if dec != "reject" { + t.Fatalf("decision=%s comment=%s", dec, comment) + } + if !strings.Contains(comment, "组织策略") { + t.Fatalf("comment=%s", comment) + } +} diff --git a/internal/typesafe/client.go b/internal/typesafe/client.go new file mode 100644 index 00000000..f2b2bf64 --- /dev/null +++ b/internal/typesafe/client.go @@ -0,0 +1,196 @@ +package typesafe + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + DefaultBaseURL = "https://api.typesafe.ai" + DefaultModel = "jev-latest" +) + +// Client calls TypeSafe System One (Jev). +type Client struct { + httpClient *http.Client + baseURL string + apiKey string + model string +} + +// APIError is a non-2xx TypeSafe HTTP response. +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("typesafe api error: status=%d body=%s", e.StatusCode, e.Body) +} + +// NewClient builds a System One client. Empty baseURL/model use TypeSafe defaults. +func NewClient(baseURL, apiKey, model string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 90 * time.Second} + } + baseURL = strings.TrimSuffix(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + baseURL = DefaultBaseURL + } + model = strings.TrimSpace(model) + if model == "" { + model = DefaultModel + } + return &Client{ + httpClient: httpClient, + baseURL: baseURL, + apiKey: strings.TrimSpace(apiKey), + model: model, + } +} + +// Question is a typed System One question (noul / choice / score). +type Question map[string]any + +// Noul builds a yes/no question. +func Noul(instructions string, trueMean, falseMean string) Question { + q := Question{ + "type": "noul", + "instructions": instructions, + } + if strings.TrimSpace(trueMean) != "" || strings.TrimSpace(falseMean) != "" { + q["criteria"] = map[string]string{ + "true": trueMean, + "false": falseMean, + } + } + return q +} + +// Choice builds a closed-set question. +func Choice(instructions string, criteria map[string]string) Question { + return Question{ + "type": "choice", + "instructions": instructions, + "criteria": criteria, + } +} + +// Result is a System One evaluation response. +type Result struct { + Model string `json:"model"` + Answers map[string]map[string]any `json:"answers"` + Usage Usage `json:"usage"` +} + +// Usage reports token counts. +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// Noul returns the probability that question id is yes. +func (r *Result) Noul(id string) float64 { + if r == nil { + return 0 + } + ans, ok := r.Answers[id] + if !ok || ans == nil { + return 0 + } + switch v := ans["noul"].(type) { + case float64: + return v + case json.Number: + f, _ := v.Float64() + return f + default: + return 0 + } +} + +// Choice returns the selected option and confidence. +func (r *Result) Choice(id string) (choice string, confidence float64) { + if r == nil { + return "", 0 + } + ans, ok := r.Answers[id] + if !ok || ans == nil { + return "", 0 + } + choice, _ = ans["choice"].(string) + switch v := ans["confidence"].(type) { + case float64: + confidence = v + case json.Number: + confidence, _ = v.Float64() + } + return strings.TrimSpace(choice), confidence +} + +type systemOneRequest struct { + State any `json:"state"` + Model string `json:"model"` + Questions map[string]Question `json:"questions"` +} + +// SystemOne evaluates state against questions. +func (c *Client) SystemOne(ctx context.Context, state any, questions map[string]Question) (*Result, error) { + if c == nil { + return nil, fmt.Errorf("typesafe client is not initialized") + } + if strings.TrimSpace(c.apiKey) == "" { + return nil, fmt.Errorf("typesafe api key is empty") + } + if len(questions) == 0 { + return nil, fmt.Errorf("typesafe questions are empty") + } + if ctx == nil { + ctx = context.Background() + } + + body, err := json.Marshal(systemOneRequest{ + State: state, + Model: c.model, + Questions: questions, + }) + if err != nil { + return nil, fmt.Errorf("marshal typesafe payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/systemone", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build typesafe request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("call typesafe api: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read typesafe response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, &APIError{StatusCode: resp.StatusCode, Body: string(respBody)} + } + + var out Result + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, fmt.Errorf("decode typesafe response: %w", err) + } + if out.Answers == nil { + out.Answers = map[string]map[string]any{} + } + return &out, nil +} diff --git a/internal/typesafe/client_test.go b/internal/typesafe/client_test.go new file mode 100644 index 00000000..052d02d0 --- /dev/null +++ b/internal/typesafe/client_test.go @@ -0,0 +1,61 @@ +package typesafe + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSystemOneParsesNoulAndChoice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/systemone" { + t.Fatalf("path = %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer ts-key" { + t.Fatalf("auth = %q", got) + } + var req systemOneRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + if req.Model != "jev-latest" { + t.Fatalf("model = %q", req.Model) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "model": "jev-1.13.0", + "answers": map[string]any{ + "ok": map[string]any{"type": "noul", "noul": 0.91}, + "act": map[string]any{ + "type": "choice", + "choice": "approve", + "confidence": 0.8, + }, + }, + }) + })) + defer srv.Close() + + client := NewClient(srv.URL, "ts-key", "", srv.Client()) + got, err := client.SystemOne(context.Background(), "ping", map[string]Question{ + "ok": Noul("Is this a ping?", "yes", "no"), + }) + if err != nil { + t.Fatal(err) + } + if got.Noul("ok") != 0.91 { + t.Fatalf("noul = %v", got.Noul("ok")) + } + choice, conf := got.Choice("act") + if choice != "approve" || conf != 0.8 { + t.Fatalf("choice=%q conf=%v", choice, conf) + } +} + +func TestSystemOneEmptyAPIKey(t *testing.T) { + client := NewClient("", "", "", nil) + if _, err := client.SystemOne(context.Background(), "ping", map[string]Question{"q": Noul("x", "", "")}); err == nil { + t.Fatal("expected error") + } +} diff --git a/web/static/css/style.css b/web/static/css/style.css index c69ad79d..58c07acb 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -2479,6 +2479,19 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover { color: #0f172a; } +.hitl-page-audit-engine { + margin: 10px 0 0; + font-size: 13px; + line-height: 1.6; + color: #0f172a; +} + +.hitl-log-engine { + margin-top: 4px; + font-size: 12px; + color: #64748b; +} + .hitl-page-reviewer-hint { margin: 10px 0 0; font-size: 12px; @@ -36978,6 +36991,7 @@ html[data-theme="dark"] .info-collect-cell-modal-title { -webkit-text-fill-color: var(--text-primary) !important; } +html[data-theme="dark"] .hitl-page-audit-engine, html[data-theme="dark"] .hitl-page-reviewer-hint, html[data-theme="dark"] .hitl-page-whitelist-hint, html[data-theme="dark"] .hitl-page-strategy-hint, diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 52b1228e..21f9988c 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -917,6 +917,10 @@ "hitl": { "pageTitle": "HITL approvals", "pageReviewerLabel": "Current reviewer", + "auditEngineLabel": "Approval engine", + "auditEngineOpenAI": "OpenAI protocol", + "auditEngineJev": "TypeSafe Jev", + "colAuditEngine": "Approval engine", "pageReviewerHint": "Applies to the selected conversation. Without a conversation, saved to config.yaml as the global default for new chats. Takes effect immediately.", "pageReviewerSaved": "Reviewer saved.", "whitelistLabel": "Tool whitelist (no approval)", @@ -928,6 +932,7 @@ "strategyTabApproval": "Approval mode", "strategyTabReviewEdit": "Review & edit mode", "strategyHintApproval": "Whitelisted tools skip approval. In approval mode the Audit Agent only approves or rejects.", + "strategyHintJev": "TypeSafe Jev is active: custom strategy text below is evaluated as structured questions. Built-in destructive rules remain a hard floor. Jev cannot rewrite arguments.", "strategyHintReviewEdit": "In review & edit mode the Audit Agent may narrow parameters via editedArguments before approve; reject if parameters cannot be safely adjusted.", "strategyReset": "Reset to default", "strategySaved": "Audit strategy saved.", @@ -1790,13 +1795,22 @@ "defaultReviewer": "Global default reviewer", "defaultReviewerHint": "Used when no conversation is selected and for new conversations; the chat sidebar can still override it.", "auditModelTitle": "Audit Agent model", + "auditBackend": "Approval engine", + "auditBackendHint": "Choose one: an OpenAI-compatible chat model returns JSON from the prompt, or TypeSafe Jev makes a structured allow/block decision (no argument editing).", + "auditBackendOpenAI": "OpenAI-compatible model", + "auditBackendTypeSafe": "TypeSafe Jev", "auditModelReuseMain": "Follow main model config", "auditModelBaseUrlPlaceholder": "Leave blank to reuse the main Base URL", "auditModelApiKeyPlaceholder": "Leave blank to reuse the main API Key", "auditModelName": "Approval model", "auditModelNamePlaceholder": "Leave blank to reuse the main model; a small model is recommended", "auditModelHint": "Used only for Audit Agent approvals; manual approval does not call a model.", + "auditModelTypeSafeHint": "Jev uses TypeSafe System One, not the OpenAI protocol. The API key is required and is not reused from the main model. Leave the model blank to use jev-latest. In review-edit mode Jev only allows or blocks; it will not rewrite arguments.", + "auditModelTypeSafeBaseUrlPlaceholder": "Leave blank to use https://api.typesafe.ai", + "auditModelTypeSafeApiKeyPlaceholder": "TypeSafe API key (required; not reused from the main model)", + "auditModelTypeSafeNamePlaceholder": "Leave blank to use jev-latest", "testAuditModel": "Test audit model", + "testTypeSafeFillRequired": "Enter a TypeSafe API key first", "retentionDays": "Resolved audit log retention days", "retentionDaysHint": "0 keeps logs forever; blank uses the 90-day default.", "toolWhitelist": "No-approval tool whitelist", @@ -1805,6 +1819,7 @@ "auditAgentTitle": "Audit Agent strategy", "auditPromptApproval": "Approval-mode prompt", "auditPromptHint": "Leave blank to use the backend default strategy.", + "auditPromptTypeSafeHint": "The current engine is TypeSafe Jev: custom strategy text is evaluated as structured questions. Built-in destructive rules remain a hard floor. Jev cannot rewrite arguments.", "auditPromptReviewEdit": "Review-edit-mode prompt", "auditPromptReviewEditHint": "Review-edit mode can approve with narrowed editedArguments." }, diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 311a89e5..34f805fc 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -906,6 +906,10 @@ "pageTitle": "人机协同审批", "pageReviewerLabel": "当前审批方", "pageReviewerHint": "作用于当前选中会话;未选会话时写入 config.yaml 作为全局默认,新建会话时沿用。切换后立即生效。", + "auditEngineLabel": "审批引擎", + "auditEngineOpenAI": "OpenAI 协议", + "auditEngineJev": "TypeSafe Jev", + "colAuditEngine": "审批引擎", "pageReviewerSaved": "审批方已保存。", "whitelistLabel": "免审批工具白名单", "whitelistHint": "每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。", @@ -916,6 +920,7 @@ "strategyTabApproval": "审批模式", "strategyTabReviewEdit": "审查编辑模式", "strategyHintApproval": "白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。", + "strategyHintJev": "当前是 TypeSafe Jev:会读取下面的自定义策略并编成结构化问题;破坏业务可用性等内置规则仍是硬底线。Jev 不能改参。", "strategyHintReviewEdit": "审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。", "strategyReset": "恢复默认", "strategySaved": "审计策略已保存。", @@ -1778,13 +1783,22 @@ "defaultReviewer": "全局默认审批方", "defaultReviewerHint": "未选会话和新建会话默认使用该审批方;会话侧栏仍可临时覆盖。", "auditModelTitle": "审计 Agent 模型", + "auditBackend": "审批引擎", + "auditBackendHint": "二选一:OpenAI 兼容聊天模型按提示词输出 JSON;Jev 用结构化问题做放通/拦截,不能改参。", + "auditBackendOpenAI": "OpenAI 协议模型", + "auditBackendTypeSafe": "TypeSafe Jev", "auditModelReuseMain": "跟随主模型配置", "auditModelBaseUrlPlaceholder": "留空则复用主模型 Base URL", "auditModelApiKeyPlaceholder": "留空则复用主模型 API Key", "auditModelName": "审批模型", "auditModelNamePlaceholder": "留空则复用主模型;建议填写小模型", "auditModelHint": "仅审计 Agent 审批时使用;人工审批不消耗模型。", + "auditModelTypeSafeHint": "Jev 走 TypeSafe System One,不是 OpenAI 协议。API Key 必填,不复用主模型密钥;模型留空使用 jev-latest。审查编辑模式下 Jev 只做放通/拦截,不会改参。", + "auditModelTypeSafeBaseUrlPlaceholder": "留空使用 https://api.typesafe.ai", + "auditModelTypeSafeApiKeyPlaceholder": "TypeSafe API Key(必填,不复用主模型)", + "auditModelTypeSafeNamePlaceholder": "留空使用 jev-latest", "testAuditModel": "测试审计模型", + "testTypeSafeFillRequired": "请先填写 TypeSafe API Key", "retentionDays": "已决策审计日志保留天数", "retentionDaysHint": "0 表示不自动清理;留空使用默认 90 天。", "toolWhitelist": "免审批工具白名单", @@ -1793,6 +1807,7 @@ "auditAgentTitle": "审计 Agent 策略", "auditPromptApproval": "审批模式提示词", "auditPromptHint": "留空时使用后端内置默认策略。", + "auditPromptTypeSafeHint": "当前审批引擎是 TypeSafe Jev:会读取本页/人机协同页的自定义策略并编成结构化问题;内置破坏性规则仍是硬底线。Jev 不能改参。", "auditPromptReviewEdit": "审查编辑模式提示词", "auditPromptReviewEditHint": "审查编辑模式可通过 editedArguments 收窄参数后放行。" }, diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 2faa282f..ce7744fb 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -143,6 +143,7 @@ let chatAIChannels = {}; let chatDefaultAIChannel = ''; let chatAIChannelIdByNormalizedId = {}; let chatHitlAuditModelName = ''; +let chatHitlAuditBackend = ''; let chatSystemModelRequestSeq = 0; let chatSystemModelSaving = false; let chatSystemModelCloseTimer = null; @@ -1070,10 +1071,26 @@ function currentSystemModelLabel() { return model || (ch && (ch.name || chatDefaultAIChannel)) || currentChatModelLabel(); } +function currentHitlAuditBackend() { + const b = String(chatHitlAuditBackend || (typeof window !== 'undefined' && window.csaiHitlAuditBackend) || '').trim().toLowerCase(); + return (b === 'typesafe' || b === 'jev' || b === 'type-safe') ? 'typesafe' : 'openai'; +} + function currentHitlAuditModelLabel() { + if (currentHitlAuditBackend() === 'typesafe') { + return chatHitlAuditModelName || 'jev-latest'; + } return chatHitlAuditModelName || currentSystemModelLabel(); } +function currentHitlAuditEngineLabel() { + const engine = currentHitlAuditBackend() === 'typesafe' + ? chatTranslate('settings.hitl.auditBackendTypeSafe', 'TypeSafe Jev') + : chatTranslate('settings.hitl.auditBackendOpenAI', 'OpenAI 协议模型'); + const model = currentHitlAuditModelLabel(); + return engine + (model ? ' · ' + model : ''); +} + function resolveChatPickerChannelId() { return selectedChatAIChannelId() || chatDefaultAIChannel; } @@ -1709,7 +1726,7 @@ function updateChatComposerSessionShortcuts(summary) { ? chatTranslate('chat.sessionShortcutAuditAgent', 'Agent 审查') : chatTranslate('chat.sessionShortcutHuman', '人工审批'); const modeLabel = data.hitl || getHitlModeLabel(cfg.mode); - const approvalModel = auditAgent ? currentHitlAuditModelLabel() : ''; + const approvalModel = auditAgent ? currentHitlAuditEngineLabel() : ''; const label = prefix + ':' + modeLabel + (approvalModel ? ' · ' + approvalModel : ''); hitlEl.textContent = label; hitlEl.title = label; @@ -2070,9 +2087,22 @@ async function initChatAgentModeFromConfig() { multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled); populateChatAIChannelSelect(cfg.ai || {}); const hitlAuditModel = cfg.hitl && cfg.hitl.audit_model; + chatHitlAuditBackend = cfg.hitl && typeof cfg.hitl.audit_backend === 'string' + ? cfg.hitl.audit_backend.trim().toLowerCase() + : ''; chatHitlAuditModelName = hitlAuditModel && typeof hitlAuditModel.model === 'string' ? hitlAuditModel.model.trim() : ''; + if (typeof window !== 'undefined') { + window.csaiHitlAuditBackend = chatHitlAuditBackend; + window.csaiHitlAuditModel = chatHitlAuditModelName; + if (typeof window.renderHitlPageAuditEngine === 'function') { + window.renderHitlPageAuditEngine(); + } + if (typeof window.renderHitlStrategyJevHint === 'function') { + window.renderHitlStrategyJevHint(); + } + } updateChatReasoningSummary(); if (typeof window !== 'undefined') { window.__csaiMultiAgentPublic = cfg.multi_agent || null; diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index 4021db0d..cacb953c 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -57,7 +57,7 @@ test('输入框可按会话通道获取模型并双向同步会话推理且审 assert.match(chat, /function currentHitlAuditModelLabel\(\)/); assert.match(chat, /const label = currentChatModelLabel\(\)/); assert.doesNotMatch(chat, /const label = data\.model \|\| currentChatModelLabel\(\)/); - assert.match(chat, /const approvalModel = auditAgent \? currentHitlAuditModelLabel\(\) : ''/); + assert.match(chat, /const approvalModel = auditAgent \? currentHitlAuditEngineLabel\(\) : ''/); assert.match(chat, /hitlAuditModel\.model\.trim\(\)/); assert.match(template, /id="chat-model-shortcut"[^>]+onclick="openChatSystemModelPicker\(event\)"/); assert.match(template, /id="chat-system-model-menu"[^>]+hidden/); @@ -361,6 +361,24 @@ test('旧会话首次升级到五分钟默认审批时限,仍允许用户之 assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /markLegacyHitlTimeoutMigrated/); }); +test('人机协同页和日志展示 Jev / OpenAI 审批引擎', () => { + const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8'); + assert.match(template, /id="hitl-page-audit-engine"/); + assert.match(template, /id="hitl-log-detail-engine"/); + assert.match(hitlPage, /function hitlAuditEngineFromItem/); + assert.match(hitlPage, /function renderHitlPageAuditEngine/); + assert.match(hitlPage, /function renderHitlStrategyJevHint/); + assert.match(template, /id="hitl-strategy-hint-jev"/); + assert.equal(zh.hitl.strategyHintJev.includes('Jev'), true); + assert.equal(en.hitl.strategyHintJev.includes('Jev'), true); + assert.match(handler, /auditBackend/); + assert.match(chat, /function currentHitlAuditEngineLabel\(\)/); + assert.equal(zh.hitl.auditEngineJev, 'TypeSafe Jev'); + assert.equal(en.hitl.auditEngineJev, 'TypeSafe Jev'); + assert.equal(zh.hitl.auditEngineOpenAI, 'OpenAI 协议'); + assert.equal(en.hitl.auditEngineOpenAI, 'OpenAI protocol'); +}); + test('审批体验文案具有完整中英文资源', () => { const hitlKeys = [ 'waitingApprovalShort', diff --git a/web/static/js/hitl.js b/web/static/js/hitl.js index 04a2c120..81d92c67 100644 --- a/web/static/js/hitl.js +++ b/web/static/js/hitl.js @@ -272,9 +272,15 @@ function applyHitlDefaultConfigFromServer(data) { reviewer: reviewer, timeoutSeconds: timeoutSeconds }; + const backend = hitlNormalizeAuditBackend(src.auditBackend || src.audit_backend); + const model = String(src.auditModel || src.audit_model || '').trim(); + if (backend) out.auditBackend = backend; + if (model) out.auditModel = model; if (typeof window !== 'undefined') { window.csaiHitlDefaultConfig = out; window.csaiHitlDefaultReviewer = reviewer; + if (backend) window.csaiHitlAuditBackend = backend; + if (model || backend) window.csaiHitlAuditModel = model; if (Array.isArray(src.hitlGlobalToolWhitelist)) { window.csaiHitlGlobalToolWhitelist = src.hitlGlobalToolWhitelist; } @@ -1088,6 +1094,8 @@ function refreshHitlPageReviewerBar() { if (typeof window.bindHitlReviewerToggleListeners === 'function') { window.bindHitlReviewerToggleListeners(); } + renderHitlPageAuditEngine(); + renderHitlStrategyJevHint(); } let hitlDefaultAuditPrompt = ''; @@ -1114,6 +1122,7 @@ function switchHitlStrategyMode(mode) { if (reviewTa) reviewTa.hidden = hitlStrategyMode !== 'review_edit'; if (hintApproval) hintApproval.hidden = hitlStrategyMode !== 'approval'; if (hintReview) hintReview.hidden = hitlStrategyMode !== 'review_edit'; + renderHitlStrategyJevHint(); } function showHitlStrategyFeedback(text, isError) { @@ -1151,6 +1160,23 @@ async function refreshHitlAuditStrategy() { } } +function renderHitlStrategyJevHint() { + let el = document.getElementById('hitl-strategy-hint-jev'); + const bar = document.querySelector('.hitl-page-strategy-bar') || document.getElementById('hitl-page-strategy-bar'); + if (!el && bar) { + el = document.createElement('p'); + el.className = 'hitl-page-strategy-hint'; + el.id = 'hitl-strategy-hint-jev'; + const reviewHint = document.getElementById('hitl-strategy-hint-review-edit'); + if (reviewHint && reviewHint.parentNode) reviewHint.parentNode.insertBefore(el, reviewHint.nextSibling); + else bar.appendChild(el); + } + if (!el) return; + const ts = hitlCurrentAuditEngine().backend === 'typesafe'; + el.hidden = !ts; + if (ts) el.textContent = hitlT('strategyHintJev', 'TypeSafe Jev evaluates the custom strategy as structured questions. Built-in destructive rules remain a hard floor.'); +} + async function saveHitlAuditStrategy() { const approvalTa = document.getElementById('hitl-audit-agent-prompt'); const reviewTa = document.getElementById('hitl-audit-agent-prompt-review-edit'); @@ -1205,7 +1231,6 @@ function refreshHitlActivePanel() { } function hitlDecidedByLabel(v) { - const key = 'reviewer' + String(v || 'human').replace(/_([a-z])/g, function (_, c) { return c.toUpperCase(); }).replace(/^./, function (c) { return c.toUpperCase(); }); const map = { human: hitlT('reviewerHuman', 'Human'), audit_agent: hitlT('reviewerAgent', 'Audit Agent'), @@ -1215,6 +1240,83 @@ function hitlDecidedByLabel(v) { return map[v] || v || '-'; } +function hitlNormalizeAuditBackend(v) { + const s = String(v || '').trim().toLowerCase(); + if (s === 'typesafe' || s === 'jev' || s === 'type-safe' || s === 'typesafe-ai') return 'typesafe'; + if (s === 'openai' || s === 'openai_compatible' || s === 'llm') return 'openai'; + return ''; +} + +function hitlCurrentAuditEngine() { + const cfg = (typeof window !== 'undefined' && window.csaiHitlDefaultConfig) || {}; + const backend = hitlNormalizeAuditBackend(cfg.auditBackend || (typeof window !== 'undefined' && window.csaiHitlAuditBackend)); + let model = String(cfg.auditModel || (typeof window !== 'undefined' && window.csaiHitlAuditModel) || '').trim(); + if (backend === 'typesafe' && !model) model = 'jev-latest'; + return { backend: backend || 'openai', model: model }; +} + +function hitlAuditEngineLabel(backend, model) { + const b = hitlNormalizeAuditBackend(backend); + if (!b) return ''; + const name = b === 'typesafe' + ? hitlT('auditEngineJev', 'TypeSafe Jev') + : hitlT('auditEngineOpenAI', 'OpenAI protocol'); + const m = String(model || '').trim(); + return m ? (name + ' · ' + m) : name; +} + +function hitlAuditEngineFromItem(item) { + const data = item && typeof item === 'object' ? item : {}; + let backend = hitlNormalizeAuditBackend(data.auditBackend || data.audit_backend); + let model = String(data.auditModel || data.audit_model || '').trim(); + if (!backend) { + const payload = typeof window.hitlParsePayloadObject === 'function' + ? hitlParsePayloadObject(data.payload || '') + : {}; + const approval = payload && payload.hitlApproval && typeof payload.hitlApproval === 'object' + ? payload.hitlApproval + : {}; + backend = hitlNormalizeAuditBackend(approval.auditBackend || approval.audit_backend); + if (!model) model = String(approval.auditModel || approval.audit_model || '').trim(); + } + if (!backend) { + const comment = String(data.comment || ''); + if (/TypeSafe|破坏分|choice=|Jev/i.test(comment)) backend = 'typesafe'; + else if (hitlReviewerNormalize(data.decidedBy || data.decided_by) === 'audit_agent') backend = 'openai'; + } + if (backend === 'typesafe' && !model) model = 'jev-latest'; + return { backend: backend, model: model }; +} + +function ensureHitlPageAuditEngineEl() { + let el = document.getElementById('hitl-page-audit-engine'); + if (el) return el; + const bar = document.getElementById('hitl-page-reviewer-bar'); + if (!bar) return null; + el = document.createElement('p'); + el.className = 'hitl-page-audit-engine'; + el.id = 'hitl-page-audit-engine'; + el.hidden = true; + const hint = bar.querySelector('.hitl-page-reviewer-hint'); + if (hint) bar.insertBefore(el, hint); + else bar.appendChild(el); + return el; +} + +function renderHitlPageAuditEngine() { + const el = ensureHitlPageAuditEngineEl(); + if (!el) return; + const info = hitlCurrentAuditEngine(); + const engine = hitlAuditEngineLabel(info.backend, info.model); + if (!engine) { + el.hidden = true; + el.textContent = ''; + return; + } + el.hidden = false; + el.textContent = hitlT('auditEngineLabel', 'Approval engine') + ':' + engine; +} + function hitlFormatTime(v) { if (!v) return '-'; try { @@ -1594,7 +1696,11 @@ function renderHitlLogsTable(items) { '
作用于当前选中会话;未选会话时保存到本机,新建会话时沿用。切换后立即生效。
白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。
审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。
+当前是 TypeSafe Jev:会读取下面的自定义策略并编成结构化问题;破坏业务可用性等内置规则仍是硬底线。Jev 不能改参。
@@ -1603,6 +1605,10 @@