feat: add TypeSafe Jev as HITL audit backend

Let audit_agent approve or reject with one System One call instead of chat JSON, while keeping the OpenAI-compatible backend as an option.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ed1s0nZandCursor committed 2026-09-22 21:15:53 +08:00
1 parent 3aa9274675
commit 38b96ec67a
30 files changed
+1372 -21

No files matched your search

+8 -6
View File
@@ -157,17 +157,19 @@ hitl:
default_reviewer: human default_reviewer: human
# 全局默认审批等待时限(秒):300=5分钟,0=不限时;新建会话无独立配置时沿用 # 全局默认审批等待时限(秒):300=5分钟,0=不限时;新建会话无独立配置时沿用
default_timeout_seconds: 300 default_timeout_seconds: 300
# 审计 Agent 专用模型;字段留空则复用上方 openai 配置。建议 model 填小模型,用于降低审批成本。 # 审计 Agent 后端二选一:openai=兼容协议聊天模型(提示词 JSON);typesafe=TypeSafe Jev 结构化放通/拦截。
audit_backend: openai
# 审计 Agent 专用模型。openai 后端字段留空则复用主模型;typesafe 后端 api_key 必填且不继承主模型密钥。
audit_model: audit_model:
provider: "" # openai / claude;留空跟随 openai.provider provider: "" # openai / claude;仅 openai 后端生效,留空跟随 openai.provider
base_url: "" # 留空跟随 openai.base_url base_url: "" # openai 后端留空跟随主模型;typesafe 后端留空使用 https://api.typesafe.ai
api_key: "" # 留空跟随 openai.api_key api_key: "" # openai 后端留空跟随主模型;typesafe 后端填写 TypeSafe API Key
model: "" # 留空跟随 openai.model,例如可填 gpt-4o-mini / qwen-turbo / deepseek-chat model: "" # openai 后端建议填小模型;typesafe 后端留空使用 jev-latest
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理) # 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
retention_days: 90 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] 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_review_edit: | # 审查编辑模式;留空使用内置默认
audit_agent_prompt: |- audit_agent_prompt: |-
+3 -1
View File
@@ -103,7 +103,9 @@ Common Web UI operations:
## Fallback Relationships ## Fallback Relationships
- `vision.api_key/base_url/provider` can inherit from the resolved default AI channel. - `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. - `knowledge.embedding.base_url/api_key` can inherit from model settings.
- rerank config can inherit from embedding/openai. - rerank config can inherit from embedding/openai.
- `database.knowledge_db_path` can be separate or reuse the main DB. - `database.knowledge_db_path` can be separate or reuse the main DB.
+7 -1
View File
@@ -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: Open **System Settings → Human-in-the-loop** in the web UI. You can configure:
- Global default reviewer: `human` or `audit_agent` - Global default reviewer: `human` or `audit_agent`
- Approval engine: `hitl.audit_backend` (`openai` or `typesafe`)
- Dedicated Audit Agent model: `hitl.audit_model` - Dedicated Audit Agent model: `hitl.audit_model`
- Resolved audit log retention days - Resolved audit log retention days
- No-approval tool allowlist: `hitl.tool_whitelist` - No-approval tool allowlist: `hitl.tool_whitelist`
@@ -19,6 +20,7 @@ Example `config.yaml`:
```yaml ```yaml
hitl: hitl:
default_reviewer: human default_reviewer: human
audit_backend: openai
audit_model: audit_model:
provider: "" provider: ""
base_url: "" 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] 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 ## 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. 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 ### 4. Keep The Allowlist Conservative
Allowlisted tools skip approval, so keep the list stable and low-risk. Recommended examples: Allowlisted tools skip approval, so keep the list stable and low-risk. Recommended examples:
+5 -3
View File
@@ -107,6 +107,7 @@ agent:
```yaml ```yaml
hitl: hitl:
default_reviewer: audit_agent default_reviewer: audit_agent
audit_backend: openai
retention_days: 90 retention_days: 90
tool_whitelist: [read_file, list_dir, glob, grep, tool_search] tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
audit_model: audit_model:
@@ -117,9 +118,10 @@ hitl:
``` ```
- `default_reviewer`:`human` 或 `audit_agent`。 - `default_reviewer`:`human` 或 `audit_agent`。
- `audit_backend`:`openai`(默认,兼容协议聊天模型)或 `typesafe`(TypeSafe Jev)。
- `tool_whitelist`:全局免审批工具列表,会与会话白名单合并。 - `tool_whitelist`:全局免审批工具列表,会与会话白名单合并。
- `audit_model`:审计 Agent 独立模型;留空复用主模型。 - `audit_model`:openai 后端留空复用主模型;typesafe 后端需填写 TypeSafe API Key,不继承主模型密钥。
- `audit_agent_prompt` / `audit_agent_prompt_review_edit`:可覆盖默认审批策略。 - `audit_agent_prompt` / `audit_agent_prompt_review_edit`:openai 后端作为聊天提示词;typesafe 后端作为 Jev 的组织策略(`operatorPolicy`)。内置默认提示词与 Jev 问题重复,不会再复制进 state。
更多策略见 [人机协同最佳实践](hitl-best-practices.md)。 更多策略见 [人机协同最佳实践](hitl-best-practices.md)。
@@ -264,7 +266,7 @@ project:
几个字段有“留空复用”的关系: 几个字段有“留空复用”的关系:
- `vision.api_key/base_url/provider` 留空时复用 `openai`。 - `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.embedding.base_url/api_key` 留空时复用主模型或 embedding 默认配置。
- `knowledge.retrieval.rerank.base_url/api_key` 留空时复用 embedding/openai。 - `knowledge.retrieval.rerank.base_url/api_key` 留空时复用 embedding/openai。
- `database.knowledge_db_path` 留空时可以复用主会话数据库,但独立文件更利于备份。 - `database.knowledge_db_path` 留空时可以复用主会话数据库,但独立文件更利于备份。
+7 -1
View File
@@ -9,6 +9,7 @@
Web 端进入 **系统设置 → 人机协同**,可配置: Web 端进入 **系统设置 → 人机协同**,可配置:
- 全局默认审批方:`human` 或 `audit_agent` - 全局默认审批方:`human` 或 `audit_agent`
- 审批引擎:`hitl.audit_backend`(`openai` 或 `typesafe`)
- 审计 Agent 专用模型:`hitl.audit_model` - 审计 Agent 专用模型:`hitl.audit_model`
- 已决策审计日志保留天数 - 已决策审计日志保留天数
- 免审批工具白名单:`hitl.tool_whitelist` - 免审批工具白名单:`hitl.tool_whitelist`
@@ -19,6 +20,7 @@ Web 端进入 **系统设置 → 人机协同**,可配置:
```yaml ```yaml
hitl: hitl:
default_reviewer: human default_reviewer: human
audit_backend: openai
audit_model: audit_model:
provider: "" provider: ""
base_url: "" 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] 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,但不得扩大攻击面。 审查编辑模式下,可将路径、目标、命令参数收窄后 approve,但不得扩大攻击面。
``` ```
OpenAI 协议后端把这段文字当聊天提示词。TypeSafe Jev 把它当作 `operatorPolicy` 编进结构化问题;内置破坏性规则仍是硬底线,Jev 不会改参。留空或等于内置默认时,Jev 只用内置问题,不再把长提示词复制进 state。
### 4. 白名单只放稳定低风险工具 ### 4. 白名单只放稳定低风险工具
白名单工具会跳过审批,因此要保守维护。推荐放: 白名单工具会跳过审批,因此要保守维护。推荐放:
+1
View File
@@ -1073,6 +1073,7 @@ func setupRoutes(
protected.PUT("/config", configHandler.UpdateConfig) protected.PUT("/config", configHandler.UpdateConfig)
protected.POST("/config/apply", configHandler.ApplyConfig) protected.POST("/config/apply", configHandler.ApplyConfig)
protected.POST("/config/test-openai", configHandler.TestOpenAI) protected.POST("/config/test-openai", configHandler.TestOpenAI)
protected.POST("/config/test-typesafe", configHandler.TestTypeSafe)
protected.POST("/config/test-vision", configHandler.TestVision) protected.POST("/config/test-vision", configHandler.TestVision)
protected.POST("/config/list-models", configHandler.ListModels) protected.POST("/config/list-models", configHandler.ListModels)
+50 -1
View File
@@ -1114,7 +1114,9 @@ type AgentConfig struct {
// tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效。 // tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效。
// audit_agent_prompt / audit_agent_prompt_review_edit 可在人机协同页编辑并立即生效;空则使用内置默认。 // audit_agent_prompt / audit_agent_prompt_review_edit 可在人机协同页编辑并立即生效;空则使用内置默认。
type HitlConfig struct { 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"` AuditModel OpenAIConfig `yaml:"audit_model,omitempty" json:"audit_model,omitempty"`
// ToolWhitelist 全局免审批工具名(与白名单内工具不触发 HITL 审批)。 // ToolWhitelist 全局免审批工具名(与白名单内工具不触发 HITL 审批)。
ToolWhitelist []string `yaml:"tool_whitelist,omitempty" json:"tool_whitelist,omitempty"` ToolWhitelist []string `yaml:"tool_whitelist,omitempty" json:"tool_whitelist,omitempty"`
@@ -1176,6 +1178,37 @@ func (h HitlConfig) RetentionDaysEffective() int {
return *h.RetentionDays 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. // AuditModelEffective returns the audit-agent model config with empty fields inherited from the main model config.
func (h HitlConfig) AuditModelEffective(main OpenAIConfig) OpenAIConfig { func (h HitlConfig) AuditModelEffective(main OpenAIConfig) OpenAIConfig {
out := main out := main
@@ -1291,6 +1324,22 @@ func (c HitlConfig) EffectiveAuditAgentPromptForMode(mode string) string {
return DefaultHitlAuditAgentPrompt() 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 { func normalizeHitlModeForPrompt(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) { switch strings.ToLower(strings.TrimSpace(mode)) {
case "review_edit": case "review_edit":
+28
View File
@@ -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) { func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
main := OpenAIConfig{ main := OpenAIConfig{
Provider: "openai", Provider: "openai",
+12
View File
@@ -29,3 +29,15 @@ func TestDefaultHitlAuditAgentPromptReviewEditKeepsEditedArguments(t *testing.T)
t.Fatal("review-edit prompt must require a matched rule") 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)
}
}
+59
View File
@@ -24,6 +24,7 @@ import (
"cyberstrike-ai/internal/openai" "cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/security" "cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/toolguard" "cyberstrike-ai/internal/toolguard"
"cyberstrike-ai/internal/typesafe"
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -891,6 +892,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
} }
if req.Hitl != nil { if req.Hitl != nil {
h.config.Hitl.AuditBackend = req.Hitl.EffectiveAuditBackend()
h.config.Hitl.AuditModel = req.Hitl.AuditModel h.config.Hitl.AuditModel = req.Hitl.AuditModel
h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, req.Hitl.ToolWhitelist) h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, req.Hitl.ToolWhitelist)
if strings.TrimSpace(req.Hitl.DefaultMode) != "" { if strings.TrimSpace(req.Hitl.DefaultMode) != "" {
@@ -911,6 +913,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
h.config.Hitl.RetentionDays = &v h.config.Hitl.RetentionDays = &v
} }
h.logger.Info("更新HITL配置", h.logger.Info("更新HITL配置",
zap.String("audit_backend", h.config.Hitl.AuditBackend),
zap.String("default_reviewer", h.config.Hitl.DefaultReviewer), zap.String("default_reviewer", h.config.Hitl.DefaultReviewer),
zap.Int("tool_whitelist", len(h.config.Hitl.ToolWhitelist)), 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)。 // ListModelsRequest 获取模型列表请求(OpenAI 兼容 GET /models)。
type ListModelsRequest struct { type ListModelsRequest struct {
Provider string `json:"provider"` Provider string `json:"provider"`
@@ -2149,6 +2207,7 @@ func (h *ConfigHandler) MergeHitlToolWhitelistIntoConfig(add []string) error {
func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) { func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
root := doc.Content[0] root := doc.Content[0]
hitlNode := ensureMap(root, "hitl") hitlNode := ensureMap(root, "hitl")
setStringInMap(hitlNode, "audit_backend", cfg.EffectiveAuditBackend())
auditModelNode := ensureMap(hitlNode, "audit_model") auditModelNode := ensureMap(hitlNode, "audit_model")
setStringInMap(auditModelNode, "provider", cfg.AuditModel.Provider) setStringInMap(auditModelNode, "provider", cfg.AuditModel.Provider)
setStringInMap(auditModelNode, "base_url", cfg.AuditModel.BaseURL) setStringInMap(auditModelNode, "base_url", cfg.AuditModel.BaseURL)
+6
View File
@@ -659,10 +659,13 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
expiresAt := approvalStartedAt.Add(cfg.Timeout) expiresAt := approvalStartedAt.Add(cfg.Timeout)
approvalExpiresAt = &expiresAt approvalExpiresAt = &expiresAt
} }
auditBackend, auditModel := h.hitlAuditEngineInfo()
payload["hitlApproval"] = map[string]interface{}{ payload["hitlApproval"] = map[string]interface{}{
"createdAt": approvalStartedAt, "createdAt": approvalStartedAt,
"timeoutSeconds": timeoutSeconds, "timeoutSeconds": timeoutSeconds,
"expiresAt": approvalExpiresAt, "expiresAt": approvalExpiresAt,
"auditBackend": auditBackend,
"auditModel": auditModel,
} }
payloadRaw, _ := json.Marshal(payload) payloadRaw, _ := json.Marshal(payload)
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw), cfg.Reviewer) 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 { func (h *AgentHandler) hitlDefaultConfigResponse() gin.H {
backend, model := h.hitlAuditEngineInfo()
return gin.H{ return gin.H{
"defaultMode": h.hitlEffectiveDefaultMode(), "defaultMode": h.hitlEffectiveDefaultMode(),
"defaultReviewer": h.hitlEffectiveDefaultReviewer(), "defaultReviewer": h.hitlEffectiveDefaultReviewer(),
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(), "defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(), "hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
"auditBackend": backend,
"auditModel": model,
} }
} }
+33
View File
@@ -10,7 +10,9 @@ import (
"time" "time"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/hitl"
"cyberstrike-ai/internal/openai" "cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/typesafe"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "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"} return hitlDecision{Decision: "reject", Comment: "audit agent: handler unavailable"}
} }
mode := normalizeHitlMode(hitlMode) mode := normalizeHitlMode(hitlMode)
if h.config != nil && h.config.Hitl.EffectiveAuditBackend() == config.HitlAuditBackendTypeSafe {
return h.auditAgentReviewTypeSafe(ctx, mode, toolName, payload)
}
prompt := config.DefaultHitlAuditAgentPrompt() prompt := config.DefaultHitlAuditAgentPrompt()
if h.config != nil { if h.config != nil {
prompt = h.config.Hitl.EffectiveAuditAgentPromptForMode(mode) prompt = h.config.Hitl.EffectiveAuditAgentPromptForMode(mode)
@@ -109,6 +114,34 @@ func (h *AgentHandler) auditLLMConfig() config.OpenAIConfig {
return 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 { func buildAuditAgentReviewInput(hitlMode, toolName string, payload map[string]interface{}) string {
review := map[string]interface{}{ review := map[string]interface{}{
"hitlMode": normalizeHitlMode(hitlMode), "hitlMode": normalizeHitlMode(hitlMode),
+14
View File
@@ -1,8 +1,11 @@
package handler package handler
import ( import (
"context"
"strings" "strings"
"testing" "testing"
"cyberstrike-ai/internal/config"
) )
func TestParseAuditAgentLLMContentApprove(t *testing.T) { 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) { func TestBuildAuditAgentReviewInputIncludesMode(t *testing.T) {
s := buildAuditAgentReviewInput("review_edit", "execute", map[string]interface{}{ s := buildAuditAgentReviewInput("review_edit", "execute", map[string]interface{}{
"arguments": `{"command":"pwd"}`, "arguments": `{"command":"pwd"}`,
+74
View File
@@ -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
}
@@ -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)
}
}
+3
View File
@@ -56,6 +56,7 @@ func hitlInterruptRowToMap(
if messageID.Valid { if messageID.Valid {
msgID = messageID.String msgID = messageID.String
} }
auditBackend, auditModel := hitlAuditBackendFromRecord(decidedBy, comment.String, payload)
return map[string]interface{}{ return map[string]interface{}{
"id": id, "id": id,
"conversationId": cid, "conversationId": cid,
@@ -69,6 +70,8 @@ func hitlInterruptRowToMap(
"decision": decision.String, "decision": decision.String,
"comment": comment.String, "comment": comment.String,
"decidedBy": decidedBy, "decidedBy": decidedBy,
"auditBackend": auditBackend,
"auditModel": auditModel,
"createdAt": createdAt, "createdAt": createdAt,
"decidedAt": func() interface{} { "decidedAt": func() interface{} {
if decidedAt.Valid { if decidedAt.Valid {
+44
View File
@@ -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{}{ "/api/config/list-models": map[string]interface{}{
"post": map[string]interface{}{ "post": map[string]interface{}{
"tags": []string{"配置管理"}, "tags": []string{"配置管理"},
+195
View File
@@ -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
}
+154
View File
@@ -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)
}
}
+196
View File
@@ -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
}
+61
View File
@@ -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")
}
}
+14
View File
@@ -2479,6 +2479,19 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
color: #0f172a; 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 { .hitl-page-reviewer-hint {
margin: 10px 0 0; margin: 10px 0 0;
font-size: 12px; font-size: 12px;
@@ -36978,6 +36991,7 @@ html[data-theme="dark"] .info-collect-cell-modal-title {
-webkit-text-fill-color: var(--text-primary) !important; -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-reviewer-hint,
html[data-theme="dark"] .hitl-page-whitelist-hint, html[data-theme="dark"] .hitl-page-whitelist-hint,
html[data-theme="dark"] .hitl-page-strategy-hint, html[data-theme="dark"] .hitl-page-strategy-hint,
+15
View File
@@ -917,6 +917,10 @@
"hitl": { "hitl": {
"pageTitle": "HITL approvals", "pageTitle": "HITL approvals",
"pageReviewerLabel": "Current reviewer", "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.", "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.", "pageReviewerSaved": "Reviewer saved.",
"whitelistLabel": "Tool whitelist (no approval)", "whitelistLabel": "Tool whitelist (no approval)",
@@ -928,6 +932,7 @@
"strategyTabApproval": "Approval mode", "strategyTabApproval": "Approval mode",
"strategyTabReviewEdit": "Review & edit mode", "strategyTabReviewEdit": "Review & edit mode",
"strategyHintApproval": "Whitelisted tools skip approval. In approval mode the Audit Agent only approves or rejects.", "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.", "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", "strategyReset": "Reset to default",
"strategySaved": "Audit strategy saved.", "strategySaved": "Audit strategy saved.",
@@ -1790,13 +1795,22 @@
"defaultReviewer": "Global default reviewer", "defaultReviewer": "Global default reviewer",
"defaultReviewerHint": "Used when no conversation is selected and for new conversations; the chat sidebar can still override it.", "defaultReviewerHint": "Used when no conversation is selected and for new conversations; the chat sidebar can still override it.",
"auditModelTitle": "Audit Agent model", "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", "auditModelReuseMain": "Follow main model config",
"auditModelBaseUrlPlaceholder": "Leave blank to reuse the main Base URL", "auditModelBaseUrlPlaceholder": "Leave blank to reuse the main Base URL",
"auditModelApiKeyPlaceholder": "Leave blank to reuse the main API Key", "auditModelApiKeyPlaceholder": "Leave blank to reuse the main API Key",
"auditModelName": "Approval model", "auditModelName": "Approval model",
"auditModelNamePlaceholder": "Leave blank to reuse the main model; a small model is recommended", "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.", "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", "testAuditModel": "Test audit model",
"testTypeSafeFillRequired": "Enter a TypeSafe API key first",
"retentionDays": "Resolved audit log retention days", "retentionDays": "Resolved audit log retention days",
"retentionDaysHint": "0 keeps logs forever; blank uses the 90-day default.", "retentionDaysHint": "0 keeps logs forever; blank uses the 90-day default.",
"toolWhitelist": "No-approval tool whitelist", "toolWhitelist": "No-approval tool whitelist",
@@ -1805,6 +1819,7 @@
"auditAgentTitle": "Audit Agent strategy", "auditAgentTitle": "Audit Agent strategy",
"auditPromptApproval": "Approval-mode prompt", "auditPromptApproval": "Approval-mode prompt",
"auditPromptHint": "Leave blank to use the backend default strategy.", "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", "auditPromptReviewEdit": "Review-edit-mode prompt",
"auditPromptReviewEditHint": "Review-edit mode can approve with narrowed editedArguments." "auditPromptReviewEditHint": "Review-edit mode can approve with narrowed editedArguments."
}, },
+15
View File
@@ -906,6 +906,10 @@
"pageTitle": "人机协同审批", "pageTitle": "人机协同审批",
"pageReviewerLabel": "当前审批方", "pageReviewerLabel": "当前审批方",
"pageReviewerHint": "作用于当前选中会话;未选会话时写入 config.yaml 作为全局默认,新建会话时沿用。切换后立即生效。", "pageReviewerHint": "作用于当前选中会话;未选会话时写入 config.yaml 作为全局默认,新建会话时沿用。切换后立即生效。",
"auditEngineLabel": "审批引擎",
"auditEngineOpenAI": "OpenAI 协议",
"auditEngineJev": "TypeSafe Jev",
"colAuditEngine": "审批引擎",
"pageReviewerSaved": "审批方已保存。", "pageReviewerSaved": "审批方已保存。",
"whitelistLabel": "免审批工具白名单", "whitelistLabel": "免审批工具白名单",
"whitelistHint": "每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。", "whitelistHint": "每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。",
@@ -916,6 +920,7 @@
"strategyTabApproval": "审批模式", "strategyTabApproval": "审批模式",
"strategyTabReviewEdit": "审查编辑模式", "strategyTabReviewEdit": "审查编辑模式",
"strategyHintApproval": "白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。", "strategyHintApproval": "白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。",
"strategyHintJev": "当前是 TypeSafe Jev:会读取下面的自定义策略并编成结构化问题;破坏业务可用性等内置规则仍是硬底线。Jev 不能改参。",
"strategyHintReviewEdit": "审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。", "strategyHintReviewEdit": "审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。",
"strategyReset": "恢复默认", "strategyReset": "恢复默认",
"strategySaved": "审计策略已保存。", "strategySaved": "审计策略已保存。",
@@ -1778,13 +1783,22 @@
"defaultReviewer": "全局默认审批方", "defaultReviewer": "全局默认审批方",
"defaultReviewerHint": "未选会话和新建会话默认使用该审批方;会话侧栏仍可临时覆盖。", "defaultReviewerHint": "未选会话和新建会话默认使用该审批方;会话侧栏仍可临时覆盖。",
"auditModelTitle": "审计 Agent 模型", "auditModelTitle": "审计 Agent 模型",
"auditBackend": "审批引擎",
"auditBackendHint": "二选一:OpenAI 兼容聊天模型按提示词输出 JSON;Jev 用结构化问题做放通/拦截,不能改参。",
"auditBackendOpenAI": "OpenAI 协议模型",
"auditBackendTypeSafe": "TypeSafe Jev",
"auditModelReuseMain": "跟随主模型配置", "auditModelReuseMain": "跟随主模型配置",
"auditModelBaseUrlPlaceholder": "留空则复用主模型 Base URL", "auditModelBaseUrlPlaceholder": "留空则复用主模型 Base URL",
"auditModelApiKeyPlaceholder": "留空则复用主模型 API Key", "auditModelApiKeyPlaceholder": "留空则复用主模型 API Key",
"auditModelName": "审批模型", "auditModelName": "审批模型",
"auditModelNamePlaceholder": "留空则复用主模型;建议填写小模型", "auditModelNamePlaceholder": "留空则复用主模型;建议填写小模型",
"auditModelHint": "仅审计 Agent 审批时使用;人工审批不消耗模型。", "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": "测试审计模型", "testAuditModel": "测试审计模型",
"testTypeSafeFillRequired": "请先填写 TypeSafe API Key",
"retentionDays": "已决策审计日志保留天数", "retentionDays": "已决策审计日志保留天数",
"retentionDaysHint": "0 表示不自动清理;留空使用默认 90 天。", "retentionDaysHint": "0 表示不自动清理;留空使用默认 90 天。",
"toolWhitelist": "免审批工具白名单", "toolWhitelist": "免审批工具白名单",
@@ -1793,6 +1807,7 @@
"auditAgentTitle": "审计 Agent 策略", "auditAgentTitle": "审计 Agent 策略",
"auditPromptApproval": "审批模式提示词", "auditPromptApproval": "审批模式提示词",
"auditPromptHint": "留空时使用后端内置默认策略。", "auditPromptHint": "留空时使用后端内置默认策略。",
"auditPromptTypeSafeHint": "当前审批引擎是 TypeSafe Jev:会读取本页/人机协同页的自定义策略并编成结构化问题;内置破坏性规则仍是硬底线。Jev 不能改参。",
"auditPromptReviewEdit": "审查编辑模式提示词", "auditPromptReviewEdit": "审查编辑模式提示词",
"auditPromptReviewEditHint": "审查编辑模式可通过 editedArguments 收窄参数后放行。" "auditPromptReviewEditHint": "审查编辑模式可通过 editedArguments 收窄参数后放行。"
}, },
+31 -1
View File
@@ -143,6 +143,7 @@ let chatAIChannels = {};
let chatDefaultAIChannel = ''; let chatDefaultAIChannel = '';
let chatAIChannelIdByNormalizedId = {}; let chatAIChannelIdByNormalizedId = {};
let chatHitlAuditModelName = ''; let chatHitlAuditModelName = '';
let chatHitlAuditBackend = '';
let chatSystemModelRequestSeq = 0; let chatSystemModelRequestSeq = 0;
let chatSystemModelSaving = false; let chatSystemModelSaving = false;
let chatSystemModelCloseTimer = null; let chatSystemModelCloseTimer = null;
@@ -1070,10 +1071,26 @@ function currentSystemModelLabel() {
return model || (ch && (ch.name || chatDefaultAIChannel)) || currentChatModelLabel(); 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() { function currentHitlAuditModelLabel() {
if (currentHitlAuditBackend() === 'typesafe') {
return chatHitlAuditModelName || 'jev-latest';
}
return chatHitlAuditModelName || currentSystemModelLabel(); 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() { function resolveChatPickerChannelId() {
return selectedChatAIChannelId() || chatDefaultAIChannel; return selectedChatAIChannelId() || chatDefaultAIChannel;
} }
@@ -1709,7 +1726,7 @@ function updateChatComposerSessionShortcuts(summary) {
? chatTranslate('chat.sessionShortcutAuditAgent', 'Agent 审查') ? chatTranslate('chat.sessionShortcutAuditAgent', 'Agent 审查')
: chatTranslate('chat.sessionShortcutHuman', '人工审批'); : chatTranslate('chat.sessionShortcutHuman', '人工审批');
const modeLabel = data.hitl || getHitlModeLabel(cfg.mode); const modeLabel = data.hitl || getHitlModeLabel(cfg.mode);
const approvalModel = auditAgent ? currentHitlAuditModelLabel() : ''; const approvalModel = auditAgent ? currentHitlAuditEngineLabel() : '';
const label = prefix + ':' + modeLabel + (approvalModel ? ' · ' + approvalModel : ''); const label = prefix + ':' + modeLabel + (approvalModel ? ' · ' + approvalModel : '');
hitlEl.textContent = label; hitlEl.textContent = label;
hitlEl.title = label; hitlEl.title = label;
@@ -2070,9 +2087,22 @@ async function initChatAgentModeFromConfig() {
multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled); multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled);
populateChatAIChannelSelect(cfg.ai || {}); populateChatAIChannelSelect(cfg.ai || {});
const hitlAuditModel = cfg.hitl && cfg.hitl.audit_model; 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' chatHitlAuditModelName = hitlAuditModel && typeof hitlAuditModel.model === 'string'
? hitlAuditModel.model.trim() ? 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(); updateChatReasoningSummary();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.__csaiMultiAgentPublic = cfg.multi_agent || null; window.__csaiMultiAgentPublic = cfg.multi_agent || null;
+19 -1
View File
@@ -57,7 +57,7 @@ test('输入框可按会话通道获取模型并双向同步会话推理且审
assert.match(chat, /function currentHitlAuditModelLabel\(\)/); assert.match(chat, /function currentHitlAuditModelLabel\(\)/);
assert.match(chat, /const label = currentChatModelLabel\(\)/); assert.match(chat, /const label = currentChatModelLabel\(\)/);
assert.doesNotMatch(chat, /const label = data\.model \|\| 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(chat, /hitlAuditModel\.model\.trim\(\)/);
assert.match(template, /id="chat-model-shortcut"[^>]+onclick="openChatSystemModelPicker\(event\)"/); assert.match(template, /id="chat-model-shortcut"[^>]+onclick="openChatSystemModelPicker\(event\)"/);
assert.match(template, /id="chat-system-model-menu"[^>]+hidden/); 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/); 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('审批体验文案具有完整中英文资源', () => { test('审批体验文案具有完整中英文资源', () => {
const hitlKeys = [ const hitlKeys = [
'waitingApprovalShort', 'waitingApprovalShort',
+139 -2
View File
@@ -272,9 +272,15 @@ function applyHitlDefaultConfigFromServer(data) {
reviewer: reviewer, reviewer: reviewer,
timeoutSeconds: timeoutSeconds 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') { if (typeof window !== 'undefined') {
window.csaiHitlDefaultConfig = out; window.csaiHitlDefaultConfig = out;
window.csaiHitlDefaultReviewer = reviewer; window.csaiHitlDefaultReviewer = reviewer;
if (backend) window.csaiHitlAuditBackend = backend;
if (model || backend) window.csaiHitlAuditModel = model;
if (Array.isArray(src.hitlGlobalToolWhitelist)) { if (Array.isArray(src.hitlGlobalToolWhitelist)) {
window.csaiHitlGlobalToolWhitelist = src.hitlGlobalToolWhitelist; window.csaiHitlGlobalToolWhitelist = src.hitlGlobalToolWhitelist;
} }
@@ -1088,6 +1094,8 @@ function refreshHitlPageReviewerBar() {
if (typeof window.bindHitlReviewerToggleListeners === 'function') { if (typeof window.bindHitlReviewerToggleListeners === 'function') {
window.bindHitlReviewerToggleListeners(); window.bindHitlReviewerToggleListeners();
} }
renderHitlPageAuditEngine();
renderHitlStrategyJevHint();
} }
let hitlDefaultAuditPrompt = ''; let hitlDefaultAuditPrompt = '';
@@ -1114,6 +1122,7 @@ function switchHitlStrategyMode(mode) {
if (reviewTa) reviewTa.hidden = hitlStrategyMode !== 'review_edit'; if (reviewTa) reviewTa.hidden = hitlStrategyMode !== 'review_edit';
if (hintApproval) hintApproval.hidden = hitlStrategyMode !== 'approval'; if (hintApproval) hintApproval.hidden = hitlStrategyMode !== 'approval';
if (hintReview) hintReview.hidden = hitlStrategyMode !== 'review_edit'; if (hintReview) hintReview.hidden = hitlStrategyMode !== 'review_edit';
renderHitlStrategyJevHint();
} }
function showHitlStrategyFeedback(text, isError) { 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() { async function saveHitlAuditStrategy() {
const approvalTa = document.getElementById('hitl-audit-agent-prompt'); const approvalTa = document.getElementById('hitl-audit-agent-prompt');
const reviewTa = document.getElementById('hitl-audit-agent-prompt-review-edit'); const reviewTa = document.getElementById('hitl-audit-agent-prompt-review-edit');
@@ -1205,7 +1231,6 @@ function refreshHitlActivePanel() {
} }
function hitlDecidedByLabel(v) { 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 = { const map = {
human: hitlT('reviewerHuman', 'Human'), human: hitlT('reviewerHuman', 'Human'),
audit_agent: hitlT('reviewerAgent', 'Audit Agent'), audit_agent: hitlT('reviewerAgent', 'Audit Agent'),
@@ -1215,6 +1240,83 @@ function hitlDecidedByLabel(v) {
return map[v] || 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) { function hitlFormatTime(v) {
if (!v) return '-'; if (!v) return '-';
try { try {
@@ -1594,7 +1696,11 @@ function renderHitlLogsTable(items) {
'<td>' + escapeHtml(String(item.toolName || '-')) + '</td>' + '<td>' + escapeHtml(String(item.toolName || '-')) + '</td>' +
'<td class="hitl-logs-cell-mono">' + escapeHtml(String(item.conversationId || '-')) + '</td>' + '<td class="hitl-logs-cell-mono">' + escapeHtml(String(item.conversationId || '-')) + '</td>' +
'<td><span class="hitl-decision-tag ' + decisionCls + '">' + escapeHtml(hitlDecisionLabel(decision)) + '</span></td>' + '<td><span class="hitl-decision-tag ' + decisionCls + '">' + escapeHtml(hitlDecisionLabel(decision)) + '</span></td>' +
'<td>' + escapeHtml(hitlDecidedByLabel(item.decidedBy)) + '</td>' + '<td>' + escapeHtml(hitlDecidedByLabel(item.decidedBy)) + (function () {
const engine = hitlAuditEngineFromItem(item);
const label = hitlAuditEngineLabel(engine.backend, engine.model);
return label ? '<div class="hitl-log-engine">' + escapeHtml(label) + '</div>' : '';
}()) + '</td>' +
'<td class="hitl-logs-summary">' + escapeHtml(summary) + '</td>' + '<td class="hitl-logs-summary">' + escapeHtml(summary) + '</td>' +
'<td>' + escapeHtml(hitlFormatTime(item.decidedAt || item.createdAt)) + '</td>' + '<td>' + escapeHtml(hitlFormatTime(item.decidedAt || item.createdAt)) + '</td>' +
'<td class="hitl-logs-actions">' + '<td class="hitl-logs-actions">' +
@@ -1676,6 +1782,8 @@ function refreshHitlI18n() {
syncAllHitlLogFilterSelects(); syncAllHitlLogFilterSelects();
renderHitlLogsPagination(); renderHitlLogsPagination();
renderHitlPendingPagination(); renderHitlPendingPagination();
renderHitlPageAuditEngine();
renderHitlStrategyJevHint();
} }
function renderHitlLogsPagination() { function renderHitlLogsPagination() {
@@ -1788,6 +1896,33 @@ async function openHitlLogModal(idOpt) {
decisionEl.innerHTML = '<span class="hitl-decision-tag ' + cls + '">' + escapeHtml(hitlDecisionLabel(decision)) + '</span>'; decisionEl.innerHTML = '<span class="hitl-decision-tag ' + cls + '">' + escapeHtml(hitlDecisionLabel(decision)) + '</span>';
} }
if (decidedByEl) decidedByEl.textContent = hitlDecidedByLabel(item.decidedBy); if (decidedByEl) decidedByEl.textContent = hitlDecidedByLabel(item.decidedBy);
let engineRow = document.getElementById('hitl-log-detail-engine-row');
let engineEl = document.getElementById('hitl-log-detail-engine');
if (!engineRow || !engineEl) {
const decidedRow = decidedByEl && decidedByEl.closest('.hitl-log-detail-row');
const dl = decidedRow && decidedRow.parentElement;
if (dl && decidedRow) {
engineRow = document.createElement('div');
engineRow.className = 'hitl-log-detail-row';
engineRow.id = 'hitl-log-detail-engine-row';
engineRow.hidden = true;
engineRow.innerHTML = '<dt>' + escapeHtml(hitlT('colAuditEngine', 'Approval engine')) + '</dt><dd id="hitl-log-detail-engine">—</dd>';
if (decidedRow.nextSibling) dl.insertBefore(engineRow, decidedRow.nextSibling);
else dl.appendChild(engineRow);
engineEl = document.getElementById('hitl-log-detail-engine');
}
}
if (engineRow && engineEl) {
const engine = hitlAuditEngineFromItem(item);
const label = hitlAuditEngineLabel(engine.backend, engine.model);
if (label) {
engineEl.textContent = label;
engineRow.hidden = false;
} else {
engineEl.textContent = '';
engineRow.hidden = true;
}
}
if (timeEl) timeEl.textContent = hitlFormatTime(item.decidedAt || item.createdAt); if (timeEl) timeEl.textContent = hitlFormatTime(item.decidedAt || item.createdAt);
const comment = String(item.comment || '').trim(); const comment = String(item.comment || '').trim();
if (commentRow && commentEl) { if (commentRow && commentEl) {
@@ -1823,6 +1958,8 @@ window.refreshHitlPageWhitelist = refreshHitlPageWhitelist;
window.refreshHitlPending = refreshHitlPending; window.refreshHitlPending = refreshHitlPending;
window.refreshHitlLogs = refreshHitlLogs; window.refreshHitlLogs = refreshHitlLogs;
window.refreshHitlActivePanel = refreshHitlActivePanel; window.refreshHitlActivePanel = refreshHitlActivePanel;
window.renderHitlPageAuditEngine = renderHitlPageAuditEngine;
window.renderHitlStrategyJevHint = renderHitlStrategyJevHint;
window.switchHitlPageTab = switchHitlPageTab; window.switchHitlPageTab = switchHitlPageTab;
window.switchHitlStrategyMode = switchHitlStrategyMode; window.switchHitlStrategyMode = switchHitlStrategyMode;
window.resetHitlAuditStrategy = resetHitlAuditStrategy; window.resetHitlAuditStrategy = resetHitlAuditStrategy;
+6
View File
@@ -110,6 +110,12 @@
} catch (e) { } catch (e) {
// ignore // ignore
} }
try {
if (typeof window.syncHitlAuditBackendUI === 'function') {
window.syncHitlAuditBackendUI();
}
} catch (e) { /* ignore */ }
} }
function updateLangLabel() { function updateLangLabel() {
+87 -3
View File
@@ -790,6 +790,11 @@ async function loadConfig(loadTools = true, options = {}) {
hitlReviewerEl.value = reviewer === 'audit_agent' ? 'audit_agent' : 'human'; hitlReviewerEl.value = reviewer === 'audit_agent' ? 'audit_agent' : 'human';
} }
const hitlAuditModel = hitl.audit_model || {}; const hitlAuditModel = hitl.audit_model || {};
const hitlAuditBackendEl = document.getElementById('hitl-audit-backend');
if (hitlAuditBackendEl) {
const backend = String(hitl.audit_backend || '').trim().toLowerCase();
hitlAuditBackendEl.value = (backend === 'typesafe' || backend === 'jev') ? 'typesafe' : 'openai';
}
const hitlAuditProviderEl = document.getElementById('hitl-audit-model-provider'); const hitlAuditProviderEl = document.getElementById('hitl-audit-model-provider');
if (hitlAuditProviderEl) { if (hitlAuditProviderEl) {
const provider = String(hitlAuditModel.provider || '').trim().toLowerCase(); const provider = String(hitlAuditModel.provider || '').trim().toLowerCase();
@@ -817,6 +822,9 @@ async function loadConfig(loadTools = true, options = {}) {
if (hitlReviewEditPromptEl) { if (hitlReviewEditPromptEl) {
hitlReviewEditPromptEl.value = hitl.audit_agent_prompt_review_edit || ''; hitlReviewEditPromptEl.value = hitl.audit_agent_prompt_review_edit || '';
} }
if (typeof window.syncHitlAuditBackendUI === 'function') {
window.syncHitlAuditBackendUI();
}
// 填充Agent配置 // 填充Agent配置
document.getElementById('agent-max-iterations').value = currentConfig.agent.max_iterations || 30; document.getElementById('agent-max-iterations').value = currentConfig.agent.max_iterations || 30;
@@ -2020,6 +2028,7 @@ async function applySettings() {
}, },
hitl: { hitl: {
...prevHitl, ...prevHitl,
audit_backend: document.getElementById('hitl-audit-backend')?.value === 'typesafe' ? 'typesafe' : 'openai',
audit_model: { audit_model: {
...(prevHitl.audit_model || {}), ...(prevHitl.audit_model || {}),
provider: document.getElementById('hitl-audit-model-provider')?.value || '', provider: document.getElementById('hitl-audit-model-provider')?.value || '',
@@ -3287,6 +3296,15 @@ function initModelListControls() {
hitlAuditProv.dataset.modelListBound = '1'; hitlAuditProv.dataset.modelListBound = '1';
hitlAuditProv.addEventListener('change', syncModelListFetchButtons); hitlAuditProv.addEventListener('change', syncModelListFetchButtons);
} }
const hitlAuditBackend = document.getElementById('hitl-audit-backend');
if (hitlAuditBackend && !hitlAuditBackend.dataset.backendBound) {
hitlAuditBackend.dataset.backendBound = '1';
hitlAuditBackend.addEventListener('change', function () {
syncHitlAuditBackendUI();
syncModelListFetchButtons();
});
syncHitlAuditBackendUI();
}
const knowledgeEmbeddingProv = document.getElementById('knowledge-embedding-provider'); const knowledgeEmbeddingProv = document.getElementById('knowledge-embedding-provider');
if (knowledgeEmbeddingProv && !knowledgeEmbeddingProv.dataset.modelListBound) { if (knowledgeEmbeddingProv && !knowledgeEmbeddingProv.dataset.modelListBound) {
knowledgeEmbeddingProv.dataset.modelListBound = '1'; knowledgeEmbeddingProv.dataset.modelListBound = '1';
@@ -3638,6 +3656,48 @@ async function testVisionConnection() {
} }
} }
function isHitlAuditTypeSafe() {
const v = (document.getElementById('hitl-audit-backend')?.value || '').trim().toLowerCase();
return v === 'typesafe' || v === 'jev';
}
function syncHitlAuditBackendUI() {
const ts = isHitlAuditTypeSafe();
const providerGroup = document.getElementById('hitl-audit-openai-provider-group');
if (providerGroup) providerGroup.style.display = ts ? 'none' : '';
const fetchBtn = document.getElementById('fetch-hitl-audit-models-btn');
if (fetchBtn) fetchBtn.style.display = ts ? 'none' : '';
const openaiHint = document.getElementById('hitl-audit-model-openai-hint');
const tsHint = document.getElementById('hitl-audit-model-typesafe-hint');
if (openaiHint) openaiHint.hidden = ts;
if (tsHint) tsHint.hidden = !ts;
const promptHint = document.getElementById('hitl-audit-prompt-typesafe-hint');
if (promptHint) promptHint.hidden = !ts;
const tFn = function (key, fallback) {
return typeof settingsT === 'function' ? settingsT(key, fallback) : (fallback || key);
};
const baseUrlEl = document.getElementById('hitl-audit-model-base-url');
const apiKeyEl = document.getElementById('hitl-audit-model-api-key');
const modelEl = document.getElementById('hitl-audit-model-name');
if (baseUrlEl) {
baseUrlEl.placeholder = ts
? tFn('settings.hitl.auditModelTypeSafeBaseUrlPlaceholder', '留空使用 https://api.typesafe.ai')
: tFn('settings.hitl.auditModelBaseUrlPlaceholder', '留空则复用主模型 Base URL');
}
if (apiKeyEl) {
apiKeyEl.placeholder = ts
? tFn('settings.hitl.auditModelTypeSafeApiKeyPlaceholder', 'TypeSafe API Key(必填,不复用主模型)')
: tFn('settings.hitl.auditModelApiKeyPlaceholder', '留空则复用主模型 API Key');
}
if (modelEl) {
modelEl.placeholder = ts
? tFn('settings.hitl.auditModelTypeSafeNamePlaceholder', '留空使用 jev-latest')
: tFn('settings.hitl.auditModelNamePlaceholder', '留空则复用主模型;建议填写小模型');
}
}
window.syncHitlAuditBackendUI = syncHitlAuditBackendUI;
function collectHitlAuditModelEffectiveConfig() { function collectHitlAuditModelEffectiveConfig() {
const main = { const main = {
provider: document.getElementById('openai-provider')?.value || 'openai', provider: document.getElementById('openai-provider')?.value || 'openai',
@@ -3656,9 +3716,29 @@ function collectHitlAuditModelEffectiveConfig() {
async function testHitlAuditModelConnection() { async function testHitlAuditModelConnection() {
const btn = document.getElementById('test-hitl-audit-model-btn'); const btn = document.getElementById('test-hitl-audit-model-btn');
const resultEl = document.getElementById('test-hitl-audit-model-result'); const resultEl = document.getElementById('test-hitl-audit-model-result');
const typeSafe = isHitlAuditTypeSafe();
const cfg = collectHitlAuditModelEffectiveConfig(); const cfg = collectHitlAuditModelEffectiveConfig();
const apiKey = typeSafe
? (document.getElementById('hitl-audit-model-api-key')?.value.trim() || '')
: cfg.api_key;
const baseUrl = typeSafe
? (document.getElementById('hitl-audit-model-base-url')?.value.trim() || '')
: cfg.base_url;
const model = typeSafe
? (document.getElementById('hitl-audit-model-name')?.value.trim() || 'jev-latest')
: cfg.model;
if (!cfg.base_url || !cfg.api_key || !cfg.model) { if (typeSafe) {
if (!apiKey) {
if (resultEl) {
resultEl.style.color = 'var(--danger-color, #e53e3e)';
resultEl.textContent = typeof settingsT === 'function'
? settingsT('settings.hitl.testTypeSafeFillRequired', '请先填写 TypeSafe API Key')
: '请先填写 TypeSafe API Key';
}
return;
}
} else if (!cfg.base_url || !cfg.api_key || !cfg.model) {
if (resultEl) { if (resultEl) {
resultEl.style.color = 'var(--danger-color, #e53e3e)'; resultEl.style.color = 'var(--danger-color, #e53e3e)';
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 Base URL、API Key 和模型'; resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 Base URL、API Key 和模型';
@@ -3676,10 +3756,14 @@ async function testHitlAuditModelConnection() {
} }
try { try {
const response = await apiFetch('/api/config/test-openai', { const endpoint = typeSafe ? '/api/config/test-typesafe' : '/api/config/test-openai';
const payload = typeSafe
? { base_url: baseUrl, api_key: apiKey, model: model }
: cfg;
const response = await apiFetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg) body: JSON.stringify(payload)
}); });
const result = await response.json(); const result = await response.json();
+17 -1
View File
@@ -1464,6 +1464,7 @@
</button> </button>
</div> </div>
</div> </div>
<p class="hitl-page-audit-engine" id="hitl-page-audit-engine" hidden></p>
<p class="hitl-page-reviewer-hint" data-i18n="hitl.pageReviewerHint">作用于当前选中会话;未选会话时保存到本机,新建会话时沿用。切换后立即生效。</p> <p class="hitl-page-reviewer-hint" data-i18n="hitl.pageReviewerHint">作用于当前选中会话;未选会话时保存到本机,新建会话时沿用。切换后立即生效。</p>
</div> </div>
<div class="hitl-page-tabs" role="tablist"> <div class="hitl-page-tabs" role="tablist">
@@ -1553,6 +1554,7 @@
</div> </div>
<p class="hitl-page-strategy-hint" id="hitl-strategy-hint-approval" data-i18n="hitl.strategyHintApproval">白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。</p> <p class="hitl-page-strategy-hint" id="hitl-strategy-hint-approval" data-i18n="hitl.strategyHintApproval">白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。</p>
<p class="hitl-page-strategy-hint" id="hitl-strategy-hint-review-edit" hidden data-i18n="hitl.strategyHintReviewEdit">审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。</p> <p class="hitl-page-strategy-hint" id="hitl-strategy-hint-review-edit" hidden data-i18n="hitl.strategyHintReviewEdit">审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。</p>
<p class="hitl-page-strategy-hint" id="hitl-strategy-hint-jev" hidden data-i18n="hitl.strategyHintJev">当前是 TypeSafe Jev:会读取下面的自定义策略并编成结构化问题;破坏业务可用性等内置规则仍是硬底线。Jev 不能改参。</p>
<textarea id="hitl-audit-agent-prompt" class="hitl-strategy-textarea" rows="14" spellcheck="false" autocomplete="off"></textarea> <textarea id="hitl-audit-agent-prompt" class="hitl-strategy-textarea" rows="14" spellcheck="false" autocomplete="off"></textarea>
<textarea id="hitl-audit-agent-prompt-review-edit" class="hitl-strategy-textarea" rows="14" spellcheck="false" autocomplete="off" hidden></textarea> <textarea id="hitl-audit-agent-prompt-review-edit" class="hitl-strategy-textarea" rows="14" spellcheck="false" autocomplete="off" hidden></textarea>
<div id="hitl-strategy-feedback" class="hitl-apply-feedback" role="status" aria-live="polite" hidden></div> <div id="hitl-strategy-feedback" class="hitl-apply-feedback" role="status" aria-live="polite" hidden></div>
@@ -1603,6 +1605,10 @@
<dt data-i18n="hitl.colDecidedBy">审批方</dt> <dt data-i18n="hitl.colDecidedBy">审批方</dt>
<dd id="hitl-log-detail-decided-by">—</dd> <dd id="hitl-log-detail-decided-by">—</dd>
</div> </div>
<div class="hitl-log-detail-row" id="hitl-log-detail-engine-row" hidden>
<dt data-i18n="hitl.colAuditEngine">审批引擎</dt>
<dd id="hitl-log-detail-engine">—</dd>
</div>
<div class="hitl-log-detail-row"> <div class="hitl-log-detail-row">
<dt data-i18n="hitl.colTime">时间</dt> <dt data-i18n="hitl.colTime">时间</dt>
<dd id="hitl-log-detail-time">—</dd> <dd id="hitl-log-detail-time">—</dd>
@@ -3983,6 +3989,14 @@
<h5 data-i18n="settings.hitl.auditModelTitle">审计 Agent 模型</h5> <h5 data-i18n="settings.hitl.auditModelTitle">审计 Agent 模型</h5>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="hitl-audit-backend" data-i18n="settings.hitl.auditBackend">审批引擎</label>
<select id="hitl-audit-backend" class="form-select">
<option value="openai" data-i18n="settings.hitl.auditBackendOpenAI">OpenAI 协议模型</option>
<option value="typesafe" data-i18n="settings.hitl.auditBackendTypeSafe">TypeSafe Jev</option>
</select>
<small class="form-hint" data-i18n="settings.hitl.auditBackendHint">二选一:OpenAI 兼容聊天模型按提示词输出 JSON;Jev 用结构化问题做放通/拦截,不能改参。</small>
</div>
<div class="form-group" id="hitl-audit-openai-provider-group">
<label for="hitl-audit-model-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label> <label for="hitl-audit-model-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
<select id="hitl-audit-model-provider" class="form-select"> <select id="hitl-audit-model-provider" class="form-select">
<option value="" data-i18n="settings.hitl.auditModelReuseMain">跟随主模型配置</option> <option value="" data-i18n="settings.hitl.auditModelReuseMain">跟随主模型配置</option>
@@ -4009,7 +4023,8 @@
</div> </div>
<small id="fetch-hitl-audit-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small> <small id="fetch-hitl-audit-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
<span id="fetch-hitl-audit-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span> <span id="fetch-hitl-audit-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
<small class="form-hint" data-i18n="settings.hitl.auditModelHint">仅审计 Agent 审批时使用;人工审批不消耗模型。</small> <small id="hitl-audit-model-openai-hint" class="form-hint" data-i18n="settings.hitl.auditModelHint">仅审计 Agent 审批时使用;人工审批不消耗模型。</small>
<small id="hitl-audit-model-typesafe-hint" class="form-hint" data-i18n="settings.hitl.auditModelTypeSafeHint" hidden>Jev 走 TypeSafe System One,不是 OpenAI 协议。API Key 必填,不复用主模型密钥;模型留空使用 jev-latest。审查编辑模式下 Jev 只做放通/拦截,不会改参。</small>
</div> </div>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;"> <div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
<a href="javascript:void(0)" id="test-hitl-audit-model-btn" onclick="testHitlAuditModelConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settings.hitl.testAuditModel">测试审计模型</a> <a href="javascript:void(0)" id="test-hitl-audit-model-btn" onclick="testHitlAuditModelConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settings.hitl.testAuditModel">测试审计模型</a>
@@ -4035,6 +4050,7 @@
<label for="hitl-audit-agent-prompt-settings" data-i18n="settings.hitl.auditPromptApproval">审批模式提示词</label> <label for="hitl-audit-agent-prompt-settings" data-i18n="settings.hitl.auditPromptApproval">审批模式提示词</label>
<textarea id="hitl-audit-agent-prompt-settings" rows="10" spellcheck="false" autocomplete="off"></textarea> <textarea id="hitl-audit-agent-prompt-settings" rows="10" spellcheck="false" autocomplete="off"></textarea>
<small class="form-hint" data-i18n="settings.hitl.auditPromptHint">留空时使用后端内置默认策略。</small> <small class="form-hint" data-i18n="settings.hitl.auditPromptHint">留空时使用后端内置默认策略。</small>
<small id="hitl-audit-prompt-typesafe-hint" class="form-hint" data-i18n="settings.hitl.auditPromptTypeSafeHint" hidden>当前审批引擎是 TypeSafe Jev:会读取本页/人机协同页的自定义策略并编成结构化问题;内置破坏性规则仍是硬底线。Jev 不能改参。</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="hitl-audit-agent-prompt-review-edit-settings" data-i18n="settings.hitl.auditPromptReviewEdit">审查编辑模式提示词</label> <label for="hitl-audit-agent-prompt-review-edit-settings" data-i18n="settings.hitl.auditPromptReviewEdit">审查编辑模式提示词</label>