From 75d670f860fe3c9510322e37e80dba95e013f692 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:36:10 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=E5=BC=95=E5=85=A5CapabilityToken?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8E=E6=9D=83=E9=99=90=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/security/capability.go | 645 ++++++++++++++++++++++++++ internal/security/capability_check.go | 48 ++ internal/security/capability_test.go | 302 ++++++++++++ internal/security/gateway.go | 4 + internal/security/gateway_test.go | 46 ++ internal/security/policy.go | 4 + internal/security/policy_test.go | 47 ++ internal/security/types.go | 4 + internal/security/workspace.go | 4 + 9 files changed, 1104 insertions(+) create mode 100644 internal/security/capability.go create mode 100644 internal/security/capability_check.go create mode 100644 internal/security/capability_test.go diff --git a/internal/security/capability.go b/internal/security/capability.go new file mode 100644 index 00000000..663491d9 --- /dev/null +++ b/internal/security/capability.go @@ -0,0 +1,645 @@ +package security + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "path/filepath" + "sort" + "strings" + "time" +) + +// WritePermissionLevel 描述 token 对写操作的授权等级。 +type WritePermissionLevel string + +const ( + // WritePermissionNone 表示禁止写操作。 + WritePermissionNone WritePermissionLevel = "none" + // WritePermissionWorkspace 表示仅允许在 allowlist 路径范围内写。 + WritePermissionWorkspace WritePermissionLevel = "workspace" + // WritePermissionAny 表示允许写操作(仍受路径与沙箱约束)。 + WritePermissionAny WritePermissionLevel = "any" +) + +// Validate 校验写权限等级是否合法。 +func (l WritePermissionLevel) Validate() error { + switch l { + case WritePermissionNone, WritePermissionWorkspace, WritePermissionAny: + return nil + default: + return fmt.Errorf("security: invalid write permission %q", l) + } +} + +// rank 返回写权限等级的比较顺序值。 +func (l WritePermissionLevel) rank() int { + switch l { + case WritePermissionNone: + return 0 + case WritePermissionWorkspace: + return 1 + case WritePermissionAny: + return 2 + default: + return -1 + } +} + +// NetworkPermissionMode 描述 token 对网络访问的授权模式。 +type NetworkPermissionMode string + +const ( + // NetworkPermissionDenyAll 表示禁止网络访问。 + NetworkPermissionDenyAll NetworkPermissionMode = "deny_all" + // NetworkPermissionAllowHosts 表示仅允许访问白名单 host。 + NetworkPermissionAllowHosts NetworkPermissionMode = "allow_hosts" + // NetworkPermissionAllowAll 表示允许访问任意 host。 + NetworkPermissionAllowAll NetworkPermissionMode = "allow_all" +) + +// Validate 校验网络授权模式是否合法。 +func (m NetworkPermissionMode) Validate() error { + switch m { + case NetworkPermissionDenyAll, NetworkPermissionAllowHosts, NetworkPermissionAllowAll: + return nil + default: + return fmt.Errorf("security: invalid network permission %q", m) + } +} + +// NetworkPolicy 描述 token 的网络访问策略。 +type NetworkPolicy struct { + Mode NetworkPermissionMode `json:"mode"` + AllowedHosts []string `json:"allowed_hosts,omitempty"` +} + +// normalize 归一化网络策略并应用默认值。 +func (p NetworkPolicy) normalize() NetworkPolicy { + out := NetworkPolicy{ + Mode: p.Mode, + AllowedHosts: normalizeLowerDistinctList(p.AllowedHosts), + } + if out.Mode == "" { + out.Mode = NetworkPermissionDenyAll + } + return out +} + +// Validate 校验网络策略结构是否合法。 +func (p NetworkPolicy) Validate() error { + if err := p.Mode.Validate(); err != nil { + return err + } + if p.Mode == NetworkPermissionAllowHosts && len(p.AllowedHosts) == 0 { + return errors.New("security: network allow_hosts requires at least one host") + } + return nil +} + +// CapabilityToken 是子代理工具调用所需的能力令牌。 +type CapabilityToken struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentID string `json:"agent_id"` + ParentAgentID string `json:"parent_agent_id,omitempty"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + AllowedTools []string `json:"allowed_tools"` + AllowedPaths []string `json:"allowed_paths,omitempty"` + NetworkPolicy NetworkPolicy `json:"network_policy"` + WritePermission WritePermissionLevel `json:"write_permission"` + Signature string `json:"signature"` +} + +const ( + // CapabilityRuleID 是 capability 决策在权限结果中的统一规则标识。 + CapabilityRuleID = "capability-token" +) + +// Normalize 返回去重、清洗后的 token 副本。 +func (t CapabilityToken) Normalize() CapabilityToken { + out := t + out.ID = strings.TrimSpace(out.ID) + out.TaskID = strings.TrimSpace(out.TaskID) + out.AgentID = strings.TrimSpace(out.AgentID) + out.ParentAgentID = strings.TrimSpace(out.ParentAgentID) + if !out.IssuedAt.IsZero() { + out.IssuedAt = out.IssuedAt.UTC() + } + if !out.ExpiresAt.IsZero() { + out.ExpiresAt = out.ExpiresAt.UTC() + } + out.AllowedTools = normalizeLowerDistinctList(out.AllowedTools) + out.AllowedPaths = normalizePathDistinctList(out.AllowedPaths) + out.NetworkPolicy = out.NetworkPolicy.normalize() + if out.WritePermission == "" { + out.WritePermission = WritePermissionNone + } + out.Signature = strings.TrimSpace(out.Signature) + return out +} + +// ValidateShape 校验 token 结构合法性(不校验时钟窗口)。 +func (t CapabilityToken) ValidateShape() error { + normalized := t.Normalize() + if normalized.ID == "" { + return errors.New("security: capability token id is empty") + } + if normalized.TaskID == "" { + return errors.New("security: capability token task_id is empty") + } + if normalized.AgentID == "" { + return errors.New("security: capability token agent_id is empty") + } + if normalized.IssuedAt.IsZero() || normalized.ExpiresAt.IsZero() { + return errors.New("security: capability token issued_at/expires_at is required") + } + if !normalized.ExpiresAt.After(normalized.IssuedAt) { + return errors.New("security: capability token expires_at must be after issued_at") + } + if len(normalized.AllowedTools) == 0 { + return errors.New("security: capability token allowed_tools is empty") + } + if err := normalized.NetworkPolicy.Validate(); err != nil { + return err + } + if err := normalized.WritePermission.Validate(); err != nil { + return err + } + return nil +} + +// ValidateAt 校验 token 在给定时间点是否有效。 +func (t CapabilityToken) ValidateAt(now time.Time) error { + normalized := t.Normalize() + if err := normalized.ValidateShape(); err != nil { + return err + } + if now.IsZero() { + now = time.Now().UTC() + } + now = now.UTC() + if now.Before(normalized.IssuedAt) { + return errors.New("security: capability token not active yet") + } + if !now.Before(normalized.ExpiresAt) { + return errors.New("security: capability token expired") + } + return nil +} + +type capabilitySigningPayload struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentID string `json:"agent_id"` + ParentAgentID string `json:"parent_agent_id,omitempty"` + IssuedAtUnix int64 `json:"issued_at_unix"` + ExpiresAtUnix int64 `json:"expires_at_unix"` + AllowedTools []string `json:"allowed_tools"` + AllowedPaths []string `json:"allowed_paths,omitempty"` + NetworkPolicy NetworkPolicy `json:"network_policy"` + WritePermission WritePermissionLevel `json:"write_permission"` +} + +// payloadForSigning 返回 token 的稳定签名载荷。 +func (t CapabilityToken) payloadForSigning() capabilitySigningPayload { + normalized := t.Normalize() + return capabilitySigningPayload{ + ID: normalized.ID, + TaskID: normalized.TaskID, + AgentID: normalized.AgentID, + ParentAgentID: normalized.ParentAgentID, + IssuedAtUnix: normalized.IssuedAt.Unix(), + ExpiresAtUnix: normalized.ExpiresAt.Unix(), + AllowedTools: append([]string(nil), normalized.AllowedTools...), + AllowedPaths: append([]string(nil), normalized.AllowedPaths...), + NetworkPolicy: normalized.NetworkPolicy, + WritePermission: normalized.WritePermission, + } +} + +// CapabilitySigner 负责 capability token 的签名与验签。 +type CapabilitySigner struct { + secret []byte +} + +// NewCapabilitySigner 用固定密钥构造签名器。 +func NewCapabilitySigner(secret []byte) (*CapabilitySigner, error) { + if len(secret) < 16 { + return nil, errors.New("security: capability signer secret is too short") + } + cloned := append([]byte(nil), secret...) + return &CapabilitySigner{secret: cloned}, nil +} + +// NewEphemeralCapabilitySigner 创建进程内临时签名器。 +func NewEphemeralCapabilitySigner() (*CapabilitySigner, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("security: generate capability signer key: %w", err) + } + return NewCapabilitySigner(key) +} + +// Sign 对 token 执行签名并返回带 signature 的副本。 +func (s *CapabilitySigner) Sign(token CapabilityToken) (CapabilityToken, error) { + if s == nil { + return CapabilityToken{}, errors.New("security: capability signer is nil") + } + normalized := token.Normalize() + normalized.Signature = "" + if err := normalized.ValidateShape(); err != nil { + return CapabilityToken{}, err + } + + signature, err := s.signature(normalized.payloadForSigning()) + if err != nil { + return CapabilityToken{}, err + } + normalized.Signature = signature + return normalized, nil +} + +// Verify 校验 token 的完整性签名。 +func (s *CapabilitySigner) Verify(token CapabilityToken) error { + if s == nil { + return errors.New("security: capability signer is nil") + } + normalized := token.Normalize() + if strings.TrimSpace(normalized.Signature) == "" { + return errors.New("security: capability token signature is empty") + } + provided := normalized.Signature + normalized.Signature = "" + if err := normalized.ValidateShape(); err != nil { + return err + } + expected, err := s.signature(normalized.payloadForSigning()) + if err != nil { + return err + } + if !hmac.Equal([]byte(provided), []byte(expected)) { + return errors.New("security: capability token signature mismatch") + } + return nil +} + +// signature 计算稳定 payload 的 HMAC-SHA256 签名。 +func (s *CapabilitySigner) signature(payload capabilitySigningPayload) (string, error) { + raw, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("security: marshal capability signing payload: %w", err) + } + mac := hmac.New(sha256.New, s.secret) + if _, err := mac.Write(raw); err != nil { + return "", fmt.Errorf("security: sign capability payload: %w", err) + } + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +// EnsureCapabilitySubset 校验 child token 是否为 parent token 的严格子集。 +func EnsureCapabilitySubset(parent CapabilityToken, child CapabilityToken) error { + parent = parent.Normalize() + child = child.Normalize() + if err := parent.ValidateShape(); err != nil { + return fmt.Errorf("security: invalid parent capability token: %w", err) + } + if err := child.ValidateShape(); err != nil { + return fmt.Errorf("security: invalid child capability token: %w", err) + } + if child.ExpiresAt.After(parent.ExpiresAt) { + return errors.New("security: child capability expires_at exceeds parent") + } + if child.WritePermission.rank() > parent.WritePermission.rank() { + return errors.New("security: child write permission exceeds parent") + } + if !isSubsetExact(parent.AllowedTools, child.AllowedTools) { + return errors.New("security: child allowed_tools exceeds parent") + } + if !isPathSubset(parent.AllowedPaths, child.AllowedPaths) { + return errors.New("security: child allowed_paths exceeds parent") + } + if err := ensureNetworkSubset(parent.NetworkPolicy, child.NetworkPolicy); err != nil { + return err + } + return nil +} + +// EvaluateCapabilityAction 判断 token 是否允许当前 action。 +func EvaluateCapabilityAction(token CapabilityToken, action Action, now time.Time) (bool, string) { + token = token.Normalize() + if err := token.ValidateAt(now); err != nil { + return false, err.Error() + } + if err := action.Validate(); err != nil { + return false, err.Error() + } + + toolName := strings.ToLower(strings.TrimSpace(action.Payload.ToolName)) + resource := strings.ToLower(strings.TrimSpace(action.Payload.Resource)) + if !matchesCapabilityTool(token.AllowedTools, toolName, resource) { + return false, "capability token tool not allowed" + } + + if action.Type == ActionTypeWrite && token.WritePermission == WritePermissionNone { + return false, "capability token write permission denied" + } + + if host, ok := extractActionNetworkHost(action); ok { + if allowed, reason := allowNetworkHost(token.NetworkPolicy, host); !allowed { + return false, reason + } + } + + if targetPath, ok, traversal := extractActionPath(action); ok { + if traversal { + return false, "capability token blocked path traversal target" + } + if !allowPathByList(token.AllowedPaths, targetPath) { + return false, "capability token path not allowed" + } + } + + return true, "" +} + +// ValidateCapabilityForWorkspace 在沙箱阶段复核 token 的路径约束。 +func ValidateCapabilityForWorkspace(action Action) error { + token := action.Payload.CapabilityToken + if token == nil { + return nil + } + + targetPath, ok, traversal := extractActionPath(action) + if !ok { + return nil + } + if traversal { + return errors.New("security: capability token blocked path traversal target") + } + if !allowPathByList(token.Normalize().AllowedPaths, targetPath) { + return errors.New("security: capability token path not allowed") + } + return nil +} + +// extractActionPath 返回 action 中用于路径授权判定的目标路径。 +func extractActionPath(action Action) (string, bool, bool) { + targetType := action.Payload.SandboxTargetType + if targetType == "" { + targetType = action.Payload.TargetType + } + + switch targetType { + case TargetTypePath, TargetTypeDirectory: + default: + return "", false, false + } + + raw := strings.TrimSpace(action.Payload.SandboxTarget) + if raw == "" { + raw = strings.TrimSpace(action.Payload.Target) + } + if raw == "" { + return "", false, false + } + return resolveActionPath(raw, action.Payload.Workdir), true, hasTraversal(raw) +} + +// extractActionNetworkHost 返回 action 中的网络目标 host。 +func extractActionNetworkHost(action Action) (string, bool) { + targetType := action.Payload.TargetType + resource := strings.ToLower(strings.TrimSpace(action.Payload.Resource)) + if targetType != TargetTypeURL && resource != "webfetch" { + return "", false + } + + raw := strings.TrimSpace(action.Payload.Target) + if raw == "" { + return "", false + } + parsed, err := url.Parse(raw) + if err != nil || parsed == nil { + return "", false + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + if host == "" { + return "", false + } + return host, true +} + +// allowNetworkHost 依据 token 网络策略判断 host 是否可访问。 +func allowNetworkHost(policy NetworkPolicy, host string) (bool, string) { + policy = policy.normalize() + host = strings.ToLower(strings.TrimSpace(host)) + switch policy.Mode { + case NetworkPermissionAllowAll: + return true, "" + case NetworkPermissionDenyAll: + return false, "capability token network policy denies all" + case NetworkPermissionAllowHosts: + if matchesCapabilityHost(policy.AllowedHosts, host) { + return true, "" + } + return false, "capability token host not allowed" + default: + return false, "capability token network policy is invalid" + } +} + +// matchesCapabilityTool 判断工具名或资源是否命中 token allowlist。 +func matchesCapabilityTool(allowlist []string, toolName string, resource string) bool { + if len(allowlist) == 0 { + return false + } + for _, pattern := range allowlist { + if pattern == toolName || pattern == resource { + return true + } + matchedTool, errTool := filepath.Match(pattern, toolName) + if errTool == nil && matchedTool { + return true + } + matchedResource, errResource := filepath.Match(pattern, resource) + if errResource == nil && matchedResource { + return true + } + } + return false +} + +// matchesCapabilityHost 支持 host 精确与 *.domain 通配匹配。 +func matchesCapabilityHost(allowHosts []string, host string) bool { + for _, allowed := range allowHosts { + if allowed == host { + return true + } + if strings.HasPrefix(allowed, "*.") && strings.HasSuffix(host, allowed[1:]) { + return true + } + } + return false +} + +// allowPathByList 判断目标路径是否落在 allowlist 前缀范围内。 +func allowPathByList(allowlist []string, target string) bool { + if len(allowlist) == 0 { + return false + } + target = normalizePathKey(target) + for _, allowed := range allowlist { + base := normalizePathKey(allowed) + if base == "" { + continue + } + if target == base || strings.HasPrefix(target, base+"/") { + return true + } + } + return false +} + +// normalizePathDistinctList 对路径列表做清洗、去重并排序,保证签名稳定。 +func normalizePathDistinctList(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized := normalizePathKey(value) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + sort.Strings(out) + return out +} + +// normalizeLowerDistinctList 对字符串列表做 lower/trim/去重并排序。 +func normalizeLowerDistinctList(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + sort.Strings(out) + return out +} + +// normalizePathKey 生成统一比较用路径键,屏蔽大小写与分隔符差异。 +func normalizePathKey(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "" + } + return filepath.ToSlash(strings.ToLower(filepath.Clean(trimmed))) +} + +// resolveActionPath 基于 workdir 解析 action 的路径目标,确保相对路径能与 allowlist 比较。 +func resolveActionPath(target string, workdir string) string { + resolved := strings.TrimSpace(target) + if resolved == "" { + return "" + } + if !filepath.IsAbs(resolved) { + base := strings.TrimSpace(workdir) + if base != "" { + resolved = filepath.Join(base, resolved) + } + } + return normalizePathKey(resolved) +} + +// hasTraversal 判断原始路径文本是否包含明显 traversal 段。 +func hasTraversal(path string) bool { + normalized := filepath.ToSlash(strings.TrimSpace(path)) + if normalized == "" { + return false + } + return normalized == ".." || + strings.HasPrefix(normalized, "../") || + strings.Contains(normalized, "/../") +} + +// isSubsetExact 判断 child 是否是 parent 的集合子集。 +func isSubsetExact(parent []string, child []string) bool { + parentSet := make(map[string]struct{}, len(parent)) + for _, value := range parent { + parentSet[value] = struct{}{} + } + for _, value := range child { + if _, ok := parentSet[value]; !ok { + return false + } + } + return true +} + +// isPathSubset 判断 child 路径是否全部落在 parent 路径前缀范围内。 +func isPathSubset(parent []string, child []string) bool { + if len(child) == 0 { + return true + } + if len(parent) == 0 { + return false + } + for _, candidate := range child { + matched := false + for _, base := range parent { + if candidate == base || strings.HasPrefix(candidate, base+"/") { + matched = true + break + } + } + if !matched { + return false + } + } + return true +} + +// ensureNetworkSubset 判断 child 网络策略是否是 parent 的子集。 +func ensureNetworkSubset(parent NetworkPolicy, child NetworkPolicy) error { + parent = parent.normalize() + child = child.normalize() + switch parent.Mode { + case NetworkPermissionAllowAll: + return nil + case NetworkPermissionDenyAll: + if child.Mode != NetworkPermissionDenyAll { + return errors.New("security: child network policy exceeds parent") + } + return nil + case NetworkPermissionAllowHosts: + if child.Mode == NetworkPermissionAllowAll { + return errors.New("security: child network policy exceeds parent") + } + if child.Mode == NetworkPermissionDenyAll { + return nil + } + if !isSubsetExact(parent.AllowedHosts, child.AllowedHosts) { + return errors.New("security: child allowed_hosts exceeds parent") + } + return nil + default: + return errors.New("security: invalid parent network policy") + } +} diff --git a/internal/security/capability_check.go b/internal/security/capability_check.go new file mode 100644 index 00000000..6ac379a0 --- /dev/null +++ b/internal/security/capability_check.go @@ -0,0 +1,48 @@ +package security + +import ( + "strings" + "time" +) + +// EvaluateCapabilityForEngine 在权限引擎入口执行 capability 判定。 +// 当 action 未携带 capability token 时,第二个返回值为 false。 +func EvaluateCapabilityForEngine(action Action, now time.Time) (CheckResult, bool) { + token := action.Payload.CapabilityToken + if token == nil { + return CheckResult{}, false + } + + allowed, reason := EvaluateCapabilityAction(*token, action, now) + if allowed { + return CheckResult{}, false + } + return capabilityDeniedResult(action, reason), true +} + +// IsCapabilityDeniedResult 判断权限结果是否由 capability 拒绝产生。 +func IsCapabilityDeniedResult(result CheckResult) bool { + if result.Rule == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(result.Rule.ID), CapabilityRuleID) +} + +func capabilityDeniedResult(action Action, reason string) CheckResult { + trimmedReason := strings.TrimSpace(reason) + if trimmedReason == "" { + trimmedReason = "capability token denied" + } + return CheckResult{ + Decision: DecisionDeny, + Action: action, + Rule: &Rule{ + ID: CapabilityRuleID, + Type: action.Type, + Resource: action.Payload.Resource, + Decision: DecisionDeny, + Reason: trimmedReason, + }, + Reason: trimmedReason, + } +} diff --git a/internal/security/capability_test.go b/internal/security/capability_test.go new file mode 100644 index 00000000..e24d2763 --- /dev/null +++ b/internal/security/capability_test.go @@ -0,0 +1,302 @@ +package security + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCapabilitySignerRoundTripAndTamper(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + token := CapabilityToken{ + ID: "token-1", + TaskID: "task-1", + AgentID: "agent-1", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionWorkspace, + } + + signer, err := NewCapabilitySigner([]byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatalf("new signer: %v", err) + } + signed, err := signer.Sign(token) + if err != nil { + t.Fatalf("sign token: %v", err) + } + if signed.Signature == "" { + t.Fatalf("expected non-empty signature") + } + if err := signer.Verify(signed); err != nil { + t.Fatalf("verify signed token: %v", err) + } + + tampered := signed + tampered.AllowedTools = []string{"filesystem_write_file"} + if err := signer.Verify(tampered); err == nil || !strings.Contains(err.Error(), "signature mismatch") { + t.Fatalf("expected signature mismatch for tampered token, got %v", err) + } +} + +func TestEvaluateCapabilityAction(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + workdir := t.TempDir() + allowedRoot := filepath.Join(workdir, "allowed") + token := CapabilityToken{ + ID: "token-2", + TaskID: "task-2", + AgentID: "agent-2", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file", "webfetch"}, + AllowedPaths: []string{allowedRoot}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"example.com"}}, + WritePermission: WritePermissionNone, + } + + tests := []struct { + name string + action Action + wantAllow bool + wantInErr string + }{ + { + name: "allow read in allowed path", + action: Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + Workdir: workdir, + TargetType: TargetTypePath, + Target: "allowed/readme.md", + SandboxTargetType: TargetTypePath, + SandboxTarget: "allowed/readme.md", + }, + }, + wantAllow: true, + }, + { + name: "deny traversal path", + action: Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + Workdir: workdir, + TargetType: TargetTypePath, + Target: "../outside.txt", + SandboxTargetType: TargetTypePath, + SandboxTarget: "../outside.txt", + }, + }, + wantAllow: false, + wantInErr: "traversal", + }, + { + name: "deny tool miss", + action: Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_glob", + Resource: "filesystem_glob", + }, + }, + wantAllow: false, + wantInErr: "tool not allowed", + }, + { + name: "deny network host miss", + action: Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypeURL, + Target: "https://not-example.com/path", + }, + }, + wantAllow: false, + wantInErr: "host not allowed", + }, + { + name: "deny write by write permission", + action: Action{ + Type: ActionTypeWrite, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + Workdir: workdir, + TargetType: TargetTypePath, + Target: "allowed/readme.md", + SandboxTargetType: TargetTypePath, + SandboxTarget: "allowed/readme.md", + }, + }, + wantAllow: false, + wantInErr: "write permission denied", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + allowed, reason := EvaluateCapabilityAction(token, tt.action, now) + if allowed != tt.wantAllow { + t.Fatalf("allow=%v, want %v, reason=%q", allowed, tt.wantAllow, reason) + } + if tt.wantInErr != "" && !strings.Contains(strings.ToLower(reason), strings.ToLower(tt.wantInErr)) { + t.Fatalf("expected reason to contain %q, got %q", tt.wantInErr, reason) + } + }) + } +} + +func TestEnsureCapabilitySubset(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + parent := CapabilityToken{ + ID: "parent", + TaskID: "task", + AgentID: "agent-parent", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(2 * time.Hour), + AllowedTools: []string{"filesystem_read_file", "webfetch"}, + AllowedPaths: []string{"/workspace", "/workspace/sub"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"example.com", "*.github.com"}}, + WritePermission: WritePermissionWorkspace, + } + + tests := []struct { + name string + child CapabilityToken + wantError string + }{ + { + name: "subset allowed", + child: CapabilityToken{ + ID: "child-ok", + TaskID: "task", + AgentID: "agent-child", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace/sub"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"example.com"}}, + WritePermission: WritePermissionNone, + }, + }, + { + name: "deny broader tool", + child: CapabilityToken{ + ID: "child-tool", + TaskID: "task", + AgentID: "agent-child", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file", "filesystem_write_file"}, + AllowedPaths: []string{"/workspace/sub"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"example.com"}}, + WritePermission: WritePermissionNone, + }, + wantError: "allowed_tools exceeds parent", + }, + { + name: "deny longer ttl", + child: CapabilityToken{ + ID: "child-ttl", + TaskID: "task", + AgentID: "agent-child", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(3 * time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace/sub"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"example.com"}}, + WritePermission: WritePermissionNone, + }, + wantError: "expires_at exceeds parent", + }, + { + name: "deny broader network", + child: CapabilityToken{ + ID: "child-net", + TaskID: "task", + AgentID: "agent-child", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace/sub"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionAllowAll}, + WritePermission: WritePermissionNone, + }, + wantError: "network policy exceeds parent", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := EnsureCapabilitySubset(parent, tt.child) + if tt.wantError == "" && err != nil { + t.Fatalf("expected no error, got %v", err) + } + if tt.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("expected error containing %q, got %v", tt.wantError, err) + } + } + }) + } +} + +func TestEvaluateCapabilityForEngine(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + token := CapabilityToken{ + ID: "token-3", + TaskID: "task-3", + AgentID: "agent-3", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionWorkspace, + } + action := Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_glob", + Resource: "filesystem_glob", + CapabilityToken: &token, + }, + } + + result, denied := EvaluateCapabilityForEngine(action, now) + if !denied { + t.Fatalf("expected denied result") + } + if result.Decision != DecisionDeny { + t.Fatalf("expected deny decision, got %q", result.Decision) + } + if result.Rule == nil || result.Rule.ID != CapabilityRuleID { + t.Fatalf("expected capability rule id, got %+v", result.Rule) + } + if !IsCapabilityDeniedResult(result) { + t.Fatalf("expected IsCapabilityDeniedResult to be true") + } +} diff --git a/internal/security/gateway.go b/internal/security/gateway.go index d4a698c1..933106c3 100644 --- a/internal/security/gateway.go +++ b/internal/security/gateway.go @@ -3,6 +3,7 @@ package security import ( "context" "strings" + "time" ) // PermissionEngine evaluates actions deterministically. @@ -47,6 +48,9 @@ func (g *StaticGateway) Check(ctx context.Context, action Action) (CheckResult, if err := action.Validate(); err != nil { return CheckResult{}, err } + if capResult, denied := EvaluateCapabilityForEngine(action, time.Now().UTC()); denied { + return capResult, nil + } for idx := range g.rules { rule := g.rules[idx] diff --git a/internal/security/gateway_test.go b/internal/security/gateway_test.go index c53c4b21..014e2c12 100644 --- a/internal/security/gateway_test.go +++ b/internal/security/gateway_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" ) func TestNewStaticGateway(t *testing.T) { @@ -240,3 +241,48 @@ func TestStaticGatewayCheck(t *testing.T) { func containsText(err error, text string) bool { return err != nil && strings.Contains(err.Error(), text) } + +func TestStaticGatewayCheckCapabilityTokenDeny(t *testing.T) { + t.Parallel() + + gateway, err := NewStaticGateway(DecisionAllow, nil) + if err != nil { + t.Fatalf("new gateway: %v", err) + } + + now := time.Now().UTC() + token := CapabilityToken{ + ID: "token-gateway", + TaskID: "task-gateway", + AgentID: "agent-gateway", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionWorkspace, + } + + result, err := gateway.Check(context.Background(), Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypeURL, + Target: "https://example.com", + CapabilityToken: &token, + }, + }) + if err != nil { + t.Fatalf("gateway check: %v", err) + } + if result.Decision != DecisionDeny { + t.Fatalf("expected deny decision, got %q", result.Decision) + } + if result.Rule == nil || result.Rule.ID != CapabilityRuleID { + t.Fatalf("expected capability rule id, got %+v", result.Rule) + } + if !strings.Contains(strings.ToLower(result.Reason), "tool not allowed") { + t.Fatalf("expected tool-not-allowed reason, got %q", result.Reason) + } +} diff --git a/internal/security/policy.go b/internal/security/policy.go index 1bf51e85..c2410dfa 100644 --- a/internal/security/policy.go +++ b/internal/security/policy.go @@ -7,6 +7,7 @@ import ( "path/filepath" "sort" "strings" + "time" ) // PolicyRule 描述一条可组合的权限策略规则。 @@ -112,6 +113,9 @@ func (e *PolicyEngine) Check(ctx context.Context, action Action) (CheckResult, e if err := action.Validate(); err != nil { return CheckResult{}, err } + if capResult, denied := EvaluateCapabilityForEngine(action, time.Now().UTC()); denied { + return capResult, nil + } view := newActionView(action) for _, rule := range e.rules { diff --git a/internal/security/policy_test.go b/internal/security/policy_test.go index d9ee2124..b8f37ed0 100644 --- a/internal/security/policy_test.go +++ b/internal/security/policy_test.go @@ -2,7 +2,9 @@ package security import ( "context" + "strings" "testing" + "time" ) func TestPolicyEngineRecommendedRules(t *testing.T) { @@ -606,3 +608,48 @@ func TestDeriveToolCategoryMCP(t *testing.T) { }) } } + +func TestPolicyEngineCheckCapabilityTokenDeny(t *testing.T) { + t.Parallel() + + engine, err := NewPolicyEngine(DecisionAllow, nil) + if err != nil { + t.Fatalf("new policy engine: %v", err) + } + + now := time.Now().UTC() + token := CapabilityToken{ + ID: "token-policy", + TaskID: "task-policy", + AgentID: "agent-policy", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionWorkspace, + } + + result, err := engine.Check(context.Background(), Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypeURL, + Target: "https://example.com", + CapabilityToken: &token, + }, + }) + if err != nil { + t.Fatalf("policy check: %v", err) + } + if result.Decision != DecisionDeny { + t.Fatalf("expected deny decision, got %q", result.Decision) + } + if result.Rule == nil || result.Rule.ID != CapabilityRuleID { + t.Fatalf("expected capability rule id, got %+v", result.Rule) + } + if !strings.Contains(strings.ToLower(result.Reason), "tool not allowed") { + t.Fatalf("expected tool-not-allowed reason, got %q", result.Reason) + } +} diff --git a/internal/security/types.go b/internal/security/types.go index 50da521b..6b38a4fe 100644 --- a/internal/security/types.go +++ b/internal/security/types.go @@ -62,6 +62,8 @@ type ActionPayload struct { Resource string Operation string SessionID string + TaskID string + AgentID string Workdir string TargetType TargetType Target string @@ -73,6 +75,8 @@ type ActionPayload struct { // validation. It falls back to Target when unset. For example, bash validates // its requested workdir here while Target still contains the shell command. SandboxTarget string + // CapabilityToken 是子代理调用工具时附带的能力令牌;为空表示主代理路径。 + CapabilityToken *CapabilityToken } // Action is the unified security input for one tool execution request. diff --git a/internal/security/workspace.go b/internal/security/workspace.go index 7af09964..b36f7582 100644 --- a/internal/security/workspace.go +++ b/internal/security/workspace.go @@ -30,6 +30,10 @@ func (s *WorkspaceSandbox) Check(ctx context.Context, action Action) (*Workspace if err := action.Validate(); err != nil { return nil, err } + // 对携带 capability token 的子代理动作,先执行路径 allowlist 复核。 + if err := ValidateCapabilityForWorkspace(action); err != nil { + return nil, err + } plan, ok, err := buildWorkspacePlan(action) if err != nil { From 52ecf3e0de985996d0944ff361f2c36a7b3c7053 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:36:36 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat:=E5=B7=A5=E5=85=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=99=A8=E6=8E=A5=E5=85=A5capability=E9=AA=8C=E7=AD=BE?= =?UTF-8?q?=E4=B8=8E=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/manager.go | 116 ++++++++- internal/tools/manager_test.go | 361 ++++++++++++++++++++++++++++ internal/tools/permission_mapper.go | 16 +- internal/tools/types.go | 3 + 4 files changed, 490 insertions(+), 6 deletions(-) diff --git a/internal/tools/manager.go b/internal/tools/manager.go index 51ae622f..52cbc957 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "log" "strings" + "time" providertypes "neo-code/internal/provider/types" "neo-code/internal/security" @@ -21,7 +23,6 @@ type SpecListInput struct { type Manager interface { ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) MicroCompactPolicy(name string) MicroCompactPolicy - // Execute 必须支持并发调用;runtime 可能在同一轮中并行调度多个工具调用。 Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) RememberSessionDecision(sessionID string, action security.Action, scope SessionPermissionScope) error } @@ -136,6 +137,7 @@ type DefaultManager struct { engine security.PermissionEngine sandbox WorkspaceSandbox sessionDecisions *sessionPermissionMemory + capabilitySigner *security.CapabilitySigner } // NewManager creates a manager that wraps an executor with security checks. @@ -153,15 +155,40 @@ func NewManager(executor Executor, engine security.PermissionEngine, sandbox Wor if sandbox == nil { sandbox = NoopWorkspaceSandbox{} } + capabilitySigner, err := security.NewEphemeralCapabilitySigner() + if err != nil { + return nil, err + } return &DefaultManager{ executor: executor, engine: engine, sandbox: sandbox, sessionDecisions: newSessionPermissionMemory(), + capabilitySigner: capabilitySigner, }, nil } +// SetCapabilitySigner 设置用于 capability token 验签的签名器。 +func (m *DefaultManager) SetCapabilitySigner(signer *security.CapabilitySigner) error { + if m == nil { + return errors.New("tools: manager is nil") + } + if signer == nil { + return errors.New("tools: capability signer is nil") + } + m.capabilitySigner = signer + return nil +} + +// CapabilitySigner 返回当前 manager 使用的 capability 签名器。 +func (m *DefaultManager) CapabilitySigner() *security.CapabilitySigner { + if m == nil { + return nil + } + return m.capabilitySigner +} + // ListAvailableSpecs returns the currently visible tool specs from the executor. func (m *DefaultManager) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) { if m == nil || m.executor == nil { @@ -198,6 +225,12 @@ func (m *DefaultManager) Execute(ctx context.Context, input ToolCallInput) (Tool result.ToolCallID = input.ID return result, err } + if err := m.verifyCapabilityToken(action); err != nil { + decision := capabilityDenyDecision(action, err.Error()) + m.auditCapabilityDecision(action, string(decision.Decision), decision.Reason) + result := blockedToolResult(input, decision) + return result, permissionErrorFromDecision(decision) + } decision, err := m.engine.Check(ctx, action) if err != nil { @@ -207,6 +240,9 @@ func (m *DefaultManager) Execute(ctx context.Context, input ToolCallInput) (Tool } // deny 规则始终优先,避免 session 记忆覆盖硬性安全策略。 if decision.Decision == security.DecisionDeny { + if security.IsCapabilityDeniedResult(decision) { + m.auditCapabilityDecision(action, string(decision.Decision), decision.Reason) + } result := blockedToolResult(input, decision) return result, permissionErrorFromDecision(decision) } @@ -238,6 +274,7 @@ func (m *DefaultManager) Execute(ctx context.Context, input ToolCallInput) (Tool result.ToolCallID = input.ID return result, err } + m.auditCapabilityDecision(action, string(security.DecisionAllow), "") if plan != nil { input.WorkspacePlan = plan @@ -246,6 +283,77 @@ func (m *DefaultManager) Execute(ctx context.Context, input ToolCallInput) (Tool return m.executor.Execute(ctx, input) } +// verifyCapabilityToken 校验 capability token 的签名、绑定关系与时效性。 +func (m *DefaultManager) verifyCapabilityToken(action security.Action) error { + token := action.Payload.CapabilityToken + if token == nil { + return nil + } + if m == nil || m.capabilitySigner == nil { + return errors.New("capability signer is unavailable") + } + if err := m.capabilitySigner.Verify(*token); err != nil { + return fmt.Errorf("invalid capability token signature: %w", err) + } + + normalized := token.Normalize() + taskID := strings.TrimSpace(action.Payload.TaskID) + if taskID != "" && normalized.TaskID != taskID { + return errors.New("capability token task_id does not match action") + } + agentID := strings.TrimSpace(action.Payload.AgentID) + if agentID != "" && normalized.AgentID != agentID { + return errors.New("capability token agent_id does not match action") + } + if err := normalized.ValidateAt(time.Now().UTC()); err != nil { + return fmt.Errorf("invalid capability token: %w", err) + } + return nil +} + +// capabilityDenyDecision 构造 capability 拒绝时统一的权限结果结构。 +func capabilityDenyDecision(action security.Action, reason string) security.CheckResult { + trimmedReason := strings.TrimSpace(reason) + if trimmedReason == "" { + trimmedReason = "capability token denied" + } + return security.CheckResult{ + Decision: security.DecisionDeny, + Action: action, + Rule: &security.Rule{ + ID: security.CapabilityRuleID, + Type: action.Type, + Resource: action.Payload.Resource, + Decision: security.DecisionDeny, + Reason: trimmedReason, + }, + Reason: trimmedReason, + } +} + +// auditCapabilityDecision 记录 capability 的 allow/deny 决策日志,便于追踪任务权限收敛。 +func (m *DefaultManager) auditCapabilityDecision(action security.Action, decision string, reason string) { + if action.Payload.CapabilityToken == nil { + return + } + taskID := strings.TrimSpace(action.Payload.TaskID) + if taskID == "" { + taskID = strings.TrimSpace(action.Payload.CapabilityToken.TaskID) + } + agentID := strings.TrimSpace(action.Payload.AgentID) + if agentID == "" { + agentID = strings.TrimSpace(action.Payload.CapabilityToken.AgentID) + } + log.Printf( + "tools capability audit: decision=%s task_id=%s agent_id=%s tool=%s reason=%s", + strings.TrimSpace(decision), + taskID, + agentID, + strings.TrimSpace(action.Payload.ToolName), + strings.TrimSpace(reason), + ) +} + // RememberSessionDecision 记录会话内权限记忆,用于后续同类 action 快速决策。 func (m *DefaultManager) RememberSessionDecision(sessionID string, action security.Action, scope SessionPermissionScope) error { if m == nil { @@ -345,6 +453,12 @@ func actionMetadata(action security.Action) map[string]any { if action.Payload.SandboxTarget != "" { metadata["permission_sandbox_target"] = action.Payload.SandboxTarget } + if strings.TrimSpace(action.Payload.TaskID) != "" { + metadata["permission_task_id"] = strings.TrimSpace(action.Payload.TaskID) + } + if strings.TrimSpace(action.Payload.AgentID) != "" { + metadata["permission_agent_id"] = strings.TrimSpace(action.Payload.AgentID) + } return metadata } diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index 3a63e9f1..002a23b3 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" providertypes "neo-code/internal/provider/types" "neo-code/internal/security" @@ -875,6 +876,17 @@ func TestBuildPermissionAction(t *testing.T) { wantTarget: "echo hi", wantSandbox: "scripts", }, + { + name: "bash defaults sandbox workdir to dot", + input: ToolCallInput{ + Name: "bash", + Arguments: []byte(`{"command":"echo hi"}`), + }, + wantType: security.ActionTypeBash, + wantResource: "bash", + wantTarget: "echo hi", + wantSandbox: ".", + }, { name: "read file maps to read action", input: ToolCallInput{ @@ -1334,3 +1346,352 @@ func TestNoopWorkspaceSandbox(t *testing.T) { t.Fatalf("expected context canceled, got %v", err) } } + +func TestDefaultManagerExecuteCapabilityTokenValidation(t *testing.T) { + t.Parallel() + + makeManager := func(t *testing.T) (*DefaultManager, *managerStubTool) { + t.Helper() + registry := NewRegistry() + readTool := &managerStubTool{name: "filesystem_read_file", content: "ok"} + registry.Register(readTool) + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + manager, err := NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + return manager, readTool + } + + now := time.Now().UTC() + workdir := t.TempDir() + baseToken := security.CapabilityToken{ + ID: "token-validation", + TaskID: "task-validation", + AgentID: "agent-validation", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{workdir}, + NetworkPolicy: security.NetworkPolicy{Mode: security.NetworkPermissionDenyAll}, + WritePermission: security.WritePermissionWorkspace, + } + + testCases := []struct { + name string + buildInput func(t *testing.T, manager *DefaultManager) ToolCallInput + expectErr string + expectCalls int + }{ + { + name: "allow signed token", + buildInput: func(t *testing.T, manager *DefaultManager) ToolCallInput { + t.Helper() + signed, err := manager.CapabilitySigner().Sign(baseToken) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return ToolCallInput{ + ID: "call-allow", + Name: "filesystem_read_file", + Arguments: []byte(`{"path":"README.md"}`), + Workdir: workdir, + TaskID: baseToken.TaskID, + AgentID: baseToken.AgentID, + CapabilityToken: &signed, + } + }, + expectCalls: 1, + }, + { + name: "deny tampered signature", + buildInput: func(t *testing.T, manager *DefaultManager) ToolCallInput { + t.Helper() + signed, err := manager.CapabilitySigner().Sign(baseToken) + if err != nil { + t.Fatalf("sign token: %v", err) + } + signed.AllowedTools = []string{"filesystem_write_file"} + return ToolCallInput{ + ID: "call-tampered", + Name: "filesystem_read_file", + Arguments: []byte(`{"path":"README.md"}`), + Workdir: workdir, + TaskID: baseToken.TaskID, + AgentID: baseToken.AgentID, + CapabilityToken: &signed, + } + }, + expectErr: "invalid capability token signature", + }, + { + name: "deny expired token", + buildInput: func(t *testing.T, manager *DefaultManager) ToolCallInput { + t.Helper() + expired := baseToken + expired.ID = "token-expired" + expired.IssuedAt = now.Add(-2 * time.Hour) + expired.ExpiresAt = now.Add(-time.Minute) + signed, err := manager.CapabilitySigner().Sign(expired) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return ToolCallInput{ + ID: "call-expired", + Name: "filesystem_read_file", + Arguments: []byte(`{"path":"README.md"}`), + Workdir: workdir, + TaskID: expired.TaskID, + AgentID: expired.AgentID, + CapabilityToken: &signed, + } + }, + expectErr: "capability token expired", + }, + { + name: "deny task mismatch", + buildInput: func(t *testing.T, manager *DefaultManager) ToolCallInput { + t.Helper() + signed, err := manager.CapabilitySigner().Sign(baseToken) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return ToolCallInput{ + ID: "call-mismatch", + Name: "filesystem_read_file", + Arguments: []byte(`{"path":"README.md"}`), + Workdir: workdir, + TaskID: "task-other", + AgentID: baseToken.AgentID, + CapabilityToken: &signed, + } + }, + expectErr: "task_id does not match action", + }, + } + + for _, tt := range testCases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + manager, readTool := makeManager(t) + input := tt.buildInput(t, manager) + + _, execErr := manager.Execute(context.Background(), input) + if tt.expectErr == "" { + if execErr != nil { + t.Fatalf("expected nil error, got %v", execErr) + } + } else { + var permissionErr *PermissionDecisionError + if !errors.As(execErr, &permissionErr) { + t.Fatalf("expected permission decision error, got %v", execErr) + } + if permissionErr.Decision() != string(security.DecisionDeny) { + t.Fatalf("expected deny decision, got %q", permissionErr.Decision()) + } + if permissionErr.RuleID() != security.CapabilityRuleID { + t.Fatalf("expected capability rule id, got %q", permissionErr.RuleID()) + } + if !strings.Contains(strings.ToLower(permissionErr.Reason()), strings.ToLower(tt.expectErr)) { + t.Fatalf("expected reason containing %q, got %q", tt.expectErr, permissionErr.Reason()) + } + } + if readTool.callCount != tt.expectCalls { + t.Fatalf("expected tool call count %d, got %d", tt.expectCalls, readTool.callCount) + } + }) + } +} + +func TestDefaultManagerExecuteCapabilityTokenPolicyDeny(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + webTool := &managerStubTool{name: "webfetch", content: "ok"} + readTool := &managerStubTool{name: "filesystem_read_file", content: "ok"} + registry.Register(webTool) + registry.Register(readTool) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + manager, err := NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + now := time.Now().UTC() + workdir := t.TempDir() + token := security.CapabilityToken{ + ID: "token-engine-deny", + TaskID: "task-engine-deny", + AgentID: "agent-engine-deny", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{filepath.Join(workdir, "safe")}, + NetworkPolicy: security.NetworkPolicy{Mode: security.NetworkPermissionDenyAll}, + WritePermission: security.WritePermissionWorkspace, + } + signed, err := manager.CapabilitySigner().Sign(token) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + cases := []struct { + name string + input ToolCallInput + wantInErr string + }{ + { + name: "tool allowlist miss", + input: ToolCallInput{ + ID: "call-tool-miss", + Name: "webfetch", + Arguments: []byte(`{"url":"https://example.com"}`), + Workdir: workdir, + TaskID: token.TaskID, + AgentID: token.AgentID, + CapabilityToken: &signed, + }, + wantInErr: "tool not allowed", + }, + { + name: "path traversal blocked", + input: ToolCallInput{ + ID: "call-path-traversal", + Name: "filesystem_read_file", + Arguments: []byte(`{"path":"../secret.txt"}`), + Workdir: workdir, + TaskID: token.TaskID, + AgentID: token.AgentID, + CapabilityToken: &signed, + }, + wantInErr: "traversal", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, execErr := manager.Execute(context.Background(), tc.input) + var permissionErr *PermissionDecisionError + if !errors.As(execErr, &permissionErr) { + t.Fatalf("expected permission decision error, got %v", execErr) + } + if permissionErr.Decision() != string(security.DecisionDeny) { + t.Fatalf("expected deny decision, got %q", permissionErr.Decision()) + } + if permissionErr.RuleID() != security.CapabilityRuleID { + t.Fatalf("expected capability rule id, got %q", permissionErr.RuleID()) + } + if !strings.Contains(strings.ToLower(permissionErr.Reason()), strings.ToLower(tc.wantInErr)) { + t.Fatalf("expected reason containing %q, got %q", tc.wantInErr, permissionErr.Reason()) + } + }) + } + + if webTool.callCount != 0 || readTool.callCount != 0 { + t.Fatalf("expected denied calls not to execute tools, got web=%d read=%d", webTool.callCount, readTool.callCount) + } +} + +func TestDefaultManagerCapabilitySignerHelpers(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(&managerStubTool{name: "filesystem_read_file", content: "ok"}) + + manager, err := NewManager(registry, nil, nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if manager.CapabilitySigner() == nil { + t.Fatalf("expected default capability signer") + } + + if err := manager.SetCapabilitySigner(nil); err == nil || !strings.Contains(err.Error(), "capability signer is nil") { + t.Fatalf("expected nil signer error, got %v", err) + } + customSigner, err := security.NewCapabilitySigner([]byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatalf("new custom signer: %v", err) + } + if err := manager.SetCapabilitySigner(customSigner); err != nil { + t.Fatalf("set custom signer: %v", err) + } + if manager.CapabilitySigner() != customSigner { + t.Fatalf("expected custom signer to be installed") + } + + var nilManager *DefaultManager + if err := nilManager.SetCapabilitySigner(customSigner); err == nil || !strings.Contains(err.Error(), "manager is nil") { + t.Fatalf("expected nil manager error, got %v", err) + } +} + +func TestDefaultManagerExecuteCapabilityTokenWithoutSigner(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + readTool := &managerStubTool{name: "filesystem_read_file", content: "ok"} + registry.Register(readTool) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + manager, err := NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + now := time.Now().UTC() + token := security.CapabilityToken{ + ID: "token-no-signer", + TaskID: "task-no-signer", + AgentID: "agent-no-signer", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{t.TempDir()}, + NetworkPolicy: security.NetworkPolicy{Mode: security.NetworkPermissionDenyAll}, + WritePermission: security.WritePermissionWorkspace, + } + signed, err := manager.CapabilitySigner().Sign(token) + if err != nil { + t.Fatalf("sign token: %v", err) + } + manager.capabilitySigner = nil + + _, execErr := manager.Execute(context.Background(), ToolCallInput{ + ID: "call-no-signer", + Name: "filesystem_read_file", + Arguments: []byte(`{"path":"README.md"}`), + Workdir: t.TempDir(), + TaskID: token.TaskID, + AgentID: token.AgentID, + CapabilityToken: &signed, + }) + var permissionErr *PermissionDecisionError + if !errors.As(execErr, &permissionErr) { + t.Fatalf("expected permission decision error, got %v", execErr) + } + if permissionErr.Decision() != string(security.DecisionDeny) || permissionErr.RuleID() != security.CapabilityRuleID { + t.Fatalf("unexpected decision/rule: decision=%q rule=%q", permissionErr.Decision(), permissionErr.RuleID()) + } + if !strings.Contains(strings.ToLower(permissionErr.Reason()), "signer is unavailable") { + t.Fatalf("expected signer unavailable reason, got %q", permissionErr.Reason()) + } + if readTool.callCount != 0 { + t.Fatalf("expected denied call not to execute tool, got %d", readTool.callCount) + } +} diff --git a/internal/tools/permission_mapper.go b/internal/tools/permission_mapper.go index d00ddabc..3fa544d3 100644 --- a/internal/tools/permission_mapper.go +++ b/internal/tools/permission_mapper.go @@ -16,11 +16,14 @@ func buildPermissionAction(input ToolCallInput) (security.Action, error) { action := security.Action{ Payload: security.ActionPayload{ - ToolName: toolName, - Resource: toolName, - Operation: toolName, - SessionID: input.SessionID, - Workdir: input.Workdir, + ToolName: toolName, + Resource: toolName, + Operation: toolName, + SessionID: input.SessionID, + TaskID: input.TaskID, + AgentID: input.AgentID, + Workdir: input.Workdir, + CapabilityToken: input.CapabilityToken, }, } @@ -32,6 +35,9 @@ func buildPermissionAction(input ToolCallInput) (security.Action, error) { action.Payload.Target = extractStringArgument(input.Arguments, "command") action.Payload.SandboxTargetType = security.TargetTypeDirectory action.Payload.SandboxTarget = extractStringArgument(input.Arguments, "workdir") + if action.Payload.SandboxTarget == "" { + action.Payload.SandboxTarget = "." + } case "filesystem_read_file": action.Type = security.ActionTypeRead action.Payload.Operation = "read_file" diff --git a/internal/tools/types.go b/internal/tools/types.go index 6bfe643e..9a8f5a6d 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -40,7 +40,10 @@ type ToolCallInput struct { Name string Arguments []byte SessionID string + TaskID string + AgentID string Workdir string + CapabilityToken *security.CapabilityToken WorkspacePlan *security.WorkspaceExecutionPlan // SessionMutator 仅对需要会话级写入的工具开放(例如 todo_write)。 SessionMutator SessionMutator From 80f8acd89287d144bb26d9bf674545e4999966cb Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:37:05 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:runtime=E9=80=8F=E4=BC=A0=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86capability=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=88=B0=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/permission.go | 16 ++- internal/runtime/permission_test.go | 158 ++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 5 deletions(-) diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index 549dfe82..18072162 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -45,6 +45,9 @@ const ( type permissionExecutionInput struct { RunID string SessionID string + TaskID string + AgentID string + Capability *security.CapabilityToken State *runState Call providertypes.ToolCall Workdir string @@ -72,11 +75,14 @@ func (s *Service) ResolvePermission(ctx context.Context, input PermissionResolut // executeToolCallWithPermission 执行工具调用,并在 ask/deny 路径上统一发出权限事件。 func (s *Service) executeToolCallWithPermission(ctx context.Context, input permissionExecutionInput) (tools.ToolResult, error) { callInput := tools.ToolCallInput{ - ID: input.Call.ID, - Name: input.Call.Name, - Arguments: []byte(input.Call.Arguments), - Workdir: input.Workdir, - SessionID: input.SessionID, + ID: input.Call.ID, + Name: input.Call.Name, + Arguments: []byte(input.Call.Arguments), + Workdir: input.Workdir, + SessionID: input.SessionID, + TaskID: input.TaskID, + AgentID: input.AgentID, + CapabilityToken: input.Capability, EmitChunk: func(chunk []byte) error { if err := ctx.Err(); err != nil { return err diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index beef78cb..b54cede1 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -3,6 +3,7 @@ package runtime import ( "context" "errors" + "strings" "sync" "testing" "time" @@ -1070,3 +1071,160 @@ func TestExecuteToolCallWithPermissionReturnsContextCanceledWhenChunkNotDelivere t.Fatalf("expected context.Canceled, got %v", execErr) } } + +func TestExecuteToolCallWithPermissionCapabilityDenyEmitsResolvedEvent(t *testing.T) { + t.Parallel() + + registry := tools.NewRegistry() + readTool := &stubTool{name: "filesystem_read_file", content: "ok"} + registry.Register(readTool) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + toolManager, err := tools.NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new tool manager: %v", err) + } + + now := time.Now().UTC() + workdir := t.TempDir() + token := security.CapabilityToken{ + ID: "token-runtime-deny", + TaskID: "task-runtime-deny", + AgentID: "agent-runtime-deny", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{workdir}, + NetworkPolicy: security.NetworkPolicy{Mode: security.NetworkPermissionDenyAll}, + WritePermission: security.WritePermissionWorkspace, + } + signed, err := toolManager.CapabilitySigner().Sign(token) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + + _, execErr := service.executeToolCallWithPermission(context.Background(), permissionExecutionInput{ + RunID: "run-capability-deny", + SessionID: "session-capability-deny", + TaskID: token.TaskID, + AgentID: token.AgentID, + Capability: &signed, + Call: providertypes.ToolCall{ + ID: "call-capability-deny", + Name: "filesystem_read_file", + Arguments: `{"path":"../outside.txt"}`, + }, + Workdir: workdir, + ToolTimeout: time.Second, + }) + var permissionErr *tools.PermissionDecisionError + if !errors.As(execErr, &permissionErr) { + t.Fatalf("expected permission decision error, got %v", execErr) + } + if permissionErr.Decision() != string(security.DecisionDeny) { + t.Fatalf("expected deny decision, got %q", permissionErr.Decision()) + } + if permissionErr.RuleID() != security.CapabilityRuleID { + t.Fatalf("expected capability rule id, got %q", permissionErr.RuleID()) + } + if !strings.Contains(strings.ToLower(permissionErr.Reason()), "traversal") { + t.Fatalf("expected traversal reason, got %q", permissionErr.Reason()) + } + if readTool.callCount != 0 { + t.Fatalf("expected denied tool not to execute, got %d calls", readTool.callCount) + } + + select { + case event := <-service.Events(): + if event.Type != EventPermissionResolved { + t.Fatalf("expected permission resolved event, got %q", event.Type) + } + payload, ok := event.Payload.(PermissionResolvedPayload) + if !ok { + t.Fatalf("expected PermissionResolvedPayload, got %#v", event.Payload) + } + if payload.Decision != "deny" || payload.ResolvedAs != "denied" { + t.Fatalf("unexpected resolved payload decision: %+v", payload) + } + if payload.RuleID != security.CapabilityRuleID { + t.Fatalf("expected rule id %q, got %q", security.CapabilityRuleID, payload.RuleID) + } + if !strings.Contains(strings.ToLower(payload.Reason), "traversal") { + t.Fatalf("expected traversal reason in payload, got %q", payload.Reason) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting permission resolved event") + } +} + +func TestExecuteToolCallWithPermissionForwardsCapabilityContext(t *testing.T) { + t.Parallel() + + manager := &stubToolManager{ + result: tools.ToolResult{ + Name: "filesystem_read_file", + Content: "ok", + }, + } + service := NewWithFactory( + newRuntimeConfigManager(t), + manager, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + + token := security.CapabilityToken{ + ID: "token-forward", + TaskID: "task-forward", + AgentID: "agent-forward", + IssuedAt: time.Now().UTC().Add(-time.Minute), + ExpiresAt: time.Now().UTC().Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{t.TempDir()}, + NetworkPolicy: security.NetworkPolicy{Mode: security.NetworkPermissionDenyAll}, + WritePermission: security.WritePermissionWorkspace, + } + + result, execErr := service.executeToolCallWithPermission(context.Background(), permissionExecutionInput{ + RunID: "run-forward", + SessionID: "session-forward", + TaskID: token.TaskID, + AgentID: token.AgentID, + Capability: &token, + Call: providertypes.ToolCall{ + ID: "call-forward", + Name: "filesystem_read_file", + Arguments: `{"path":"README.md"}`, + }, + Workdir: t.TempDir(), + ToolTimeout: time.Second, + }) + if execErr != nil { + t.Fatalf("executeToolCallWithPermission() error = %v", execErr) + } + if result.Content != "ok" { + t.Fatalf("expected successful tool result, got %+v", result) + } + + if manager.lastInput.TaskID != token.TaskID { + t.Fatalf("expected task id %q, got %q", token.TaskID, manager.lastInput.TaskID) + } + if manager.lastInput.AgentID != token.AgentID { + t.Fatalf("expected agent id %q, got %q", token.AgentID, manager.lastInput.AgentID) + } + if manager.lastInput.CapabilityToken == nil || manager.lastInput.CapabilityToken.ID != token.ID { + t.Fatalf("expected capability token forwarded, got %+v", manager.lastInput.CapabilityToken) + } +} From 0f1702267318c201e34e71954f4eb3aac7408f32 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:37:38 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat:=E5=AD=90=E4=BB=A3=E7=90=86=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=B7=AF=E5=BE=84=E8=83=BD=E5=8A=9B=E5=B9=B6=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=BB=91=E5=AE=9A=E4=BB=BB=E5=8A=A1=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/subagent/worker.go | 10 +++++----- internal/subagent/worker_test.go | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/internal/subagent/worker.go b/internal/subagent/worker.go index 18e9cf17..5002887c 100644 --- a/internal/subagent/worker.go +++ b/internal/subagent/worker.go @@ -72,7 +72,11 @@ func (w *worker) Start(task Task, budget Budget, capability Capability) error { w.task = task w.budget = budget.normalize(w.policy.DefaultBudget) - effectiveCapability, err := bindCapabilityToPolicy(capability.normalize(), w.policy) + capabilityInput := capability.normalize() + if len(capabilityInput.AllowedPaths) == 0 && strings.TrimSpace(task.Workspace) != "" { + capabilityInput.AllowedPaths = []string{strings.TrimSpace(task.Workspace)} + } + effectiveCapability, err := bindCapabilityToPolicy(capabilityInput, w.policy) if err != nil { return err } @@ -174,10 +178,6 @@ func (w *worker) Step(ctx context.Context) (StepResult, error) { // bindCapabilityToPolicy 将 capability 约束在角色策略允许的工具集合内。 func bindCapabilityToPolicy(capability Capability, policy RolePolicy) (Capability, error) { - if len(capability.AllowedPaths) > 0 { - return Capability{}, errorsf("capability allowed paths is not supported yet") - } - allowedPolicyTools := dedupeAndTrim(policy.AllowedTools) allowedSet := make(map[string]struct{}, len(allowedPolicyTools)) for _, tool := range allowedPolicyTools { diff --git a/internal/subagent/worker_test.go b/internal/subagent/worker_test.go index 532e4c76..3c8dd78d 100644 --- a/internal/subagent/worker_test.go +++ b/internal/subagent/worker_test.go @@ -282,8 +282,18 @@ func TestWorkerStartCapabilityPolicyGuard(t *testing.T) { } if err := wPath.Start(Task{ID: "t-cap-path", Goal: "goal"}, Budget{}, Capability{ AllowedPaths: []string{"/tmp/workspace"}, - }); err == nil { - t.Fatalf("expected unsupported allowed paths to fail") + }); err != nil { + t.Fatalf("expected allowed paths to pass, got %v", err) + } + if err := wPath.Stop(StopReasonCanceled); err != nil { + t.Fatalf("Stop() error = %v", err) + } + resultWithPath, err := wPath.Result() + if err != nil { + t.Fatalf("Result() error = %v", err) + } + if len(resultWithPath.Capability.AllowedPaths) != 1 || resultWithPath.Capability.AllowedPaths[0] != "/tmp/workspace" { + t.Fatalf("capability paths = %v, want [/tmp/workspace]", resultWithPath.Capability.AllowedPaths) } w2, err := NewWorker(RoleReviewer, policy, nil) @@ -303,6 +313,24 @@ func TestWorkerStartCapabilityPolicyGuard(t *testing.T) { if len(result.Capability.AllowedTools) != len(policy.AllowedTools) { t.Fatalf("capability tools = %v, want policy tools %v", result.Capability.AllowedTools, policy.AllowedTools) } + + w3, err := NewWorker(RoleReviewer, policy, nil) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + if err := w3.Start(Task{ID: "t-cap-workspace-default", Goal: "goal", Workspace: "/tmp/sub-task"}, Budget{}, Capability{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + if err := w3.Stop(StopReasonCanceled); err != nil { + t.Fatalf("Stop() error = %v", err) + } + resultWithWorkspace, err := w3.Result() + if err != nil { + t.Fatalf("Result() error = %v", err) + } + if len(resultWithWorkspace.Capability.AllowedPaths) != 1 || resultWithWorkspace.Capability.AllowedPaths[0] != "/tmp/sub-task" { + t.Fatalf("workspace capability path = %v, want [/tmp/sub-task]", resultWithWorkspace.Capability.AllowedPaths) + } } func TestWorkerTraceWindow(t *testing.T) { From 6a9ddb5d5b9b87cae6d3a5e95bdd35f1533ae942 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 16 Apr 2026 13:52:19 +0000 Subject: [PATCH 5/8] fix(capability): close review gaps and add coverage - make capability path normalization platform-aware - require non-empty task/agent binding when capability token is present - forward capability context through runtime tool execution path - add regression tests for security/tools/runtime Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/runtime/run.go | 6 +++++ internal/runtime/runtime.go | 12 ++++++--- internal/runtime/runtime_test.go | 29 +++++++++++++++++++- internal/runtime/state.go | 4 +++ internal/runtime/toolexec.go | 3 +++ internal/security/capability.go | 9 +++++-- internal/security/capability_test.go | 24 +++++++++++++++++ internal/tools/manager.go | 10 +++++-- internal/tools/manager_test.go | 40 ++++++++++++++++++++++++++++ 9 files changed, 128 insertions(+), 9 deletions(-) diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 442a76d1..748cf323 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -93,6 +93,12 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { } state := newRunState(input.RunID, session) + state.taskID = strings.TrimSpace(input.TaskID) + state.agentID = strings.TrimSpace(input.AgentID) + if input.CapabilityToken != nil { + token := input.CapabilityToken.Normalize() + state.capabilityToken = &token + } statePtr = &state if err := s.appendUserMessageAndSave(ctx, &state, input.Content); err != nil { return s.handleRunError(ctx, state.runID, state.session.ID, err) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index ce5546a4..0c866c71 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -11,6 +11,7 @@ import ( "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" "neo-code/internal/runtime/approval" + "neo-code/internal/security" agentsession "neo-code/internal/session" "neo-code/internal/skills" "neo-code/internal/tools" @@ -41,10 +42,13 @@ type Runtime interface { // UserInput 描述一次用户输入请求的最小运行参数。 type UserInput struct { - SessionID string - RunID string - Content string - Workdir string + SessionID string + RunID string + Content string + Workdir string + TaskID string + AgentID string + CapabilityToken *security.CapabilityToken } // ProviderFactory 负责基于运行期配置创建 provider 实例。 diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 623c2de5..370d6b74 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1342,6 +1342,18 @@ func TestServiceRunUsesToolManager(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() + now := time.Now().UTC() + capability := &security.CapabilityToken{ + ID: "token-run-tool-manager", + TaskID: "task-run-tool-manager", + AgentID: "agent-run-tool-manager", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_edit"}, + AllowedPaths: []string{t.TempDir()}, + NetworkPolicy: security.NetworkPolicy{Mode: security.NetworkPermissionDenyAll}, + WritePermission: security.WritePermissionWorkspace, + } toolManager := &stubToolManager{ specs: []providertypes.ToolSpec{ {Name: "filesystem_edit", Description: "stub", Schema: map[string]any{"type": "object"}}, @@ -1366,7 +1378,13 @@ func TestServiceRunUsesToolManager(t *testing.T) { } service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) - if err := service.Run(context.Background(), UserInput{RunID: "run-tool-manager", Content: "edit file"}); err != nil { + if err := service.Run(context.Background(), UserInput{ + RunID: "run-tool-manager", + Content: "edit file", + TaskID: capability.TaskID, + AgentID: capability.AgentID, + CapabilityToken: capability, + }); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1379,6 +1397,15 @@ func TestServiceRunUsesToolManager(t *testing.T) { if toolManager.lastInput.ID != "call-manager" { t.Fatalf("expected forwarded tool call id, got %q", toolManager.lastInput.ID) } + if toolManager.lastInput.TaskID != capability.TaskID { + t.Fatalf("expected forwarded task id %q, got %q", capability.TaskID, toolManager.lastInput.TaskID) + } + if toolManager.lastInput.AgentID != capability.AgentID { + t.Fatalf("expected forwarded agent id %q, got %q", capability.AgentID, toolManager.lastInput.AgentID) + } + if toolManager.lastInput.CapabilityToken == nil || toolManager.lastInput.CapabilityToken.ID != capability.ID { + t.Fatalf("expected forwarded capability token id %q, got %+v", capability.ID, toolManager.lastInput.CapabilityToken) + } if len(scripted.requests) == 0 || len(scripted.requests[0].Tools) != 1 || scripted.requests[0].Tools[0].Name != "filesystem_edit" { t.Fatalf("expected tool specs from tool manager, got %+v", scripted.requests) } diff --git a/internal/runtime/state.go b/internal/runtime/state.go index 2c0974fb..92f321d4 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -8,6 +8,7 @@ import ( "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" "neo-code/internal/runtime/controlplane" + "neo-code/internal/security" agentsession "neo-code/internal/session" ) @@ -23,6 +24,9 @@ type runState struct { reactiveCompactAttempts int autoCompactCache autoCompactThresholdCache rememberedThisRun bool + taskID string + agentID string + capabilityToken *security.CapabilityToken turn int phase controlplane.Phase stopEmitted bool diff --git a/internal/runtime/toolexec.go b/internal/runtime/toolexec.go index 01440389..90e8eafc 100644 --- a/internal/runtime/toolexec.go +++ b/internal/runtime/toolexec.go @@ -90,6 +90,9 @@ func (s *Service) executeOneToolCall( result, execErr := s.executeToolCallWithPermission(ctx, permissionExecutionInput{ RunID: state.runID, SessionID: state.session.ID, + TaskID: state.taskID, + AgentID: state.agentID, + Capability: state.capabilityToken, State: state, Call: call, Workdir: snapshot.workdir, diff --git a/internal/security/capability.go b/internal/security/capability.go index 663491d9..3a489096 100644 --- a/internal/security/capability.go +++ b/internal/security/capability.go @@ -10,6 +10,7 @@ import ( "fmt" "net/url" "path/filepath" + "runtime" "sort" "strings" "time" @@ -544,13 +545,17 @@ func normalizeLowerDistinctList(values []string) []string { return out } -// normalizePathKey 生成统一比较用路径键,屏蔽大小写与分隔符差异。 +// normalizePathKey 生成统一比较用路径键:始终清理路径与分隔符,仅在 Windows 下忽略大小写。 func normalizePathKey(path string) string { trimmed := strings.TrimSpace(path) if trimmed == "" { return "" } - return filepath.ToSlash(strings.ToLower(filepath.Clean(trimmed))) + normalized := filepath.ToSlash(filepath.Clean(trimmed)) + if runtime.GOOS == "windows" { + return strings.ToLower(normalized) + } + return normalized } // resolveActionPath 基于 workdir 解析 action 的路径目标,确保相对路径能与 allowlist 比较。 diff --git a/internal/security/capability_test.go b/internal/security/capability_test.go index e24d2763..50640611 100644 --- a/internal/security/capability_test.go +++ b/internal/security/capability_test.go @@ -2,6 +2,7 @@ package security import ( "path/filepath" + "runtime" "strings" "testing" "time" @@ -300,3 +301,26 @@ func TestEvaluateCapabilityForEngine(t *testing.T) { t.Fatalf("expected IsCapabilityDeniedResult to be true") } } + +func TestNormalizePathKeyPlatformSemantics(t *testing.T) { + t.Parallel() + + raw := filepath.Join("Workspace", "Sub", "..", "File.txt") + got := normalizePathKey(raw) + if got == "" { + t.Fatalf("expected normalized path key, got empty") + } + + upper := normalizePathKey(filepath.Join("Workspace", "File.txt")) + lower := normalizePathKey(filepath.Join("workspace", "file.txt")) + + if runtime.GOOS == "windows" { + if upper != lower { + t.Fatalf("windows path key should ignore case: %q vs %q", upper, lower) + } + return + } + if upper == lower { + t.Fatalf("non-windows path key should keep case sensitivity: %q vs %q", upper, lower) + } +} diff --git a/internal/tools/manager.go b/internal/tools/manager.go index 52cbc957..84c8697b 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -298,11 +298,17 @@ func (m *DefaultManager) verifyCapabilityToken(action security.Action) error { normalized := token.Normalize() taskID := strings.TrimSpace(action.Payload.TaskID) - if taskID != "" && normalized.TaskID != taskID { + if taskID == "" { + return errors.New("capability token requires non-empty action task_id") + } + if normalized.TaskID != taskID { return errors.New("capability token task_id does not match action") } agentID := strings.TrimSpace(action.Payload.AgentID) - if agentID != "" && normalized.AgentID != agentID { + if agentID == "" { + return errors.New("capability token requires non-empty action agent_id") + } + if normalized.AgentID != agentID { return errors.New("capability token agent_id does not match action") } if err := normalized.ValidateAt(time.Now().UTC()); err != nil { diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index 002a23b3..760cb01d 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -1471,6 +1471,46 @@ func TestDefaultManagerExecuteCapabilityTokenValidation(t *testing.T) { }, expectErr: "task_id does not match action", }, + { + name: "deny missing task id binding", + buildInput: func(t *testing.T, manager *DefaultManager) ToolCallInput { + t.Helper() + signed, err := manager.CapabilitySigner().Sign(baseToken) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return ToolCallInput{ + ID: "call-missing-task", + Name: "filesystem_read_file", + Arguments: []byte(`{"path":"README.md"}`), + Workdir: workdir, + TaskID: "", + AgentID: baseToken.AgentID, + CapabilityToken: &signed, + } + }, + expectErr: "requires non-empty action task_id", + }, + { + name: "deny missing agent id binding", + buildInput: func(t *testing.T, manager *DefaultManager) ToolCallInput { + t.Helper() + signed, err := manager.CapabilitySigner().Sign(baseToken) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return ToolCallInput{ + ID: "call-missing-agent", + Name: "filesystem_read_file", + Arguments: []byte(`{"path":"README.md"}`), + Workdir: workdir, + TaskID: baseToken.TaskID, + AgentID: "", + CapabilityToken: &signed, + } + }, + expectErr: "requires non-empty action agent_id", + }, } for _, tt := range testCases { From 741abd6e5e467f2919ff01a3ee7fc3d7cc589c23 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 16 Apr 2026 15:27:01 +0000 Subject: [PATCH 6/8] chore: fix gofmt issues causing CI conflict Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/subagent/factory.go | 1 - internal/tools/types.go | 16 ++++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/subagent/factory.go b/internal/subagent/factory.go index 3b4b19ba..77f7bf05 100644 --- a/internal/subagent/factory.go +++ b/internal/subagent/factory.go @@ -33,4 +33,3 @@ func (f *WorkerFactory) Create(role Role) (WorkerRuntime, error) { } return NewWorker(role, policy, engine) } - diff --git a/internal/tools/types.go b/internal/tools/types.go index 9a8f5a6d..24571fb7 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -36,15 +36,15 @@ type SessionMutator interface { // ToolCallInput 承载一次工具调用所需的运行时上下文。 type ToolCallInput struct { - ID string - Name string - Arguments []byte - SessionID string - TaskID string - AgentID string - Workdir string + ID string + Name string + Arguments []byte + SessionID string + TaskID string + AgentID string + Workdir string CapabilityToken *security.CapabilityToken - WorkspacePlan *security.WorkspaceExecutionPlan + WorkspacePlan *security.WorkspaceExecutionPlan // SessionMutator 仅对需要会话级写入的工具开放(例如 todo_write)。 SessionMutator SessionMutator // EmitChunk 用于工具执行期间的流式输出回调。 From c0fb92e6136891baad95d7a0eb8063c16229a1d6 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 02:55:55 +0000 Subject: [PATCH 7/8] test: boost coverage for capability and permission helpers Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/security/capability_test.go | 807 +++++++++++++++++++++++++++ internal/subagent/worker_test.go | 251 +++++++++ internal/tools/manager_test.go | 165 ++++++ 3 files changed, 1223 insertions(+) diff --git a/internal/security/capability_test.go b/internal/security/capability_test.go index 50640611..0c3c64f5 100644 --- a/internal/security/capability_test.go +++ b/internal/security/capability_test.go @@ -324,3 +324,810 @@ func TestNormalizePathKeyPlatformSemantics(t *testing.T) { t.Fatalf("non-windows path key should keep case sensitivity: %q vs %q", upper, lower) } } + +func TestCapabilitySignerAndTokenBoundaries(t *testing.T) { + t.Parallel() + + if _, err := NewCapabilitySigner([]byte("short-secret")); err == nil || !strings.Contains(err.Error(), "too short") { + t.Fatalf("expected short secret error, got %v", err) + } + signer, err := NewEphemeralCapabilitySigner() + if err != nil { + t.Fatalf("new ephemeral signer: %v", err) + } + if signer == nil { + t.Fatalf("expected non-nil signer") + } + + now := time.Now().UTC() + valid := CapabilityToken{ + ID: "ephemeral", + TaskID: "task-e", + AgentID: "agent-e", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionNone, + } + signed, err := signer.Sign(valid) + if err != nil { + t.Fatalf("sign token: %v", err) + } + if err := signer.Verify(signed); err != nil { + t.Fatalf("verify signed token: %v", err) + } + + var nilSigner *CapabilitySigner + if _, err := nilSigner.Sign(valid); err == nil || !strings.Contains(err.Error(), "signer is nil") { + t.Fatalf("expected nil signer sign error, got %v", err) + } + if err := nilSigner.Verify(signed); err == nil || !strings.Contains(err.Error(), "signer is nil") { + t.Fatalf("expected nil signer verify error, got %v", err) + } + + noSignature := valid + if err := signer.Verify(noSignature); err == nil || !strings.Contains(err.Error(), "signature is empty") { + t.Fatalf("expected empty signature error, got %v", err) + } +} + +func TestCapabilityTokenValidateShapeAndAt(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + token := CapabilityToken{ + ID: "token-shape", + TaskID: "task-shape", + AgentID: "agent-shape", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{" /workspace ", "/workspace"}, + NetworkPolicy: NetworkPolicy{}, + WritePermission: "", + } + normalized := token.Normalize() + if normalized.NetworkPolicy.Mode != NetworkPermissionDenyAll { + t.Fatalf("expected default network mode deny_all, got %q", normalized.NetworkPolicy.Mode) + } + if normalized.WritePermission != WritePermissionNone { + t.Fatalf("expected default write permission none, got %q", normalized.WritePermission) + } + if len(normalized.AllowedPaths) != 1 { + t.Fatalf("expected deduplicated allowed paths, got %+v", normalized.AllowedPaths) + } + if err := token.ValidateShape(); err != nil { + t.Fatalf("expected valid shape, got %v", err) + } + if err := token.ValidateAt(now); err != nil { + t.Fatalf("expected active token, got %v", err) + } + + tests := []struct { + name string + mutate func(CapabilityToken) CapabilityToken + at time.Time + wantInErr string + }{ + { + name: "missing id", + mutate: func(in CapabilityToken) CapabilityToken { + in.ID = " " + return in + }, + at: now, + wantInErr: "id is empty", + }, + { + name: "missing task", + mutate: func(in CapabilityToken) CapabilityToken { + in.TaskID = "" + return in + }, + at: now, + wantInErr: "task_id is empty", + }, + { + name: "missing agent", + mutate: func(in CapabilityToken) CapabilityToken { + in.AgentID = "" + return in + }, + at: now, + wantInErr: "agent_id is empty", + }, + { + name: "invalid ttl window", + mutate: func(in CapabilityToken) CapabilityToken { + in.ExpiresAt = in.IssuedAt + return in + }, + at: now, + wantInErr: "must be after", + }, + { + name: "empty allowed tools", + mutate: func(in CapabilityToken) CapabilityToken { + in.AllowedTools = nil + return in + }, + at: now, + wantInErr: "allowed_tools is empty", + }, + { + name: "invalid network mode", + mutate: func(in CapabilityToken) CapabilityToken { + in.NetworkPolicy = NetworkPolicy{Mode: NetworkPermissionMode("bad-mode")} + return in + }, + at: now, + wantInErr: "invalid network permission", + }, + { + name: "allow_hosts requires hosts", + mutate: func(in CapabilityToken) CapabilityToken { + in.NetworkPolicy = NetworkPolicy{Mode: NetworkPermissionAllowHosts} + return in + }, + at: now, + wantInErr: "requires at least one host", + }, + { + name: "invalid write permission", + mutate: func(in CapabilityToken) CapabilityToken { + in.WritePermission = WritePermissionLevel("bad") + return in + }, + at: now, + wantInErr: "invalid write permission", + }, + { + name: "not active yet", + mutate: func(in CapabilityToken) CapabilityToken { + in.IssuedAt = now.Add(time.Minute) + in.ExpiresAt = now.Add(2 * time.Hour) + return in + }, + at: now, + wantInErr: "not active yet", + }, + { + name: "expired", + mutate: func(in CapabilityToken) CapabilityToken { + in.IssuedAt = now.Add(-2 * time.Hour) + in.ExpiresAt = now.Add(-time.Hour) + return in + }, + at: now, + wantInErr: "expired", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.mutate(token).ValidateAt(tt.at) + if err == nil || !strings.Contains(err.Error(), tt.wantInErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantInErr, err) + } + }) + } +} + +func TestCapabilityHelperFunctions(t *testing.T) { + t.Parallel() + + if got := WritePermissionNone.rank(); got != 0 { + t.Fatalf("rank none=%d", got) + } + if got := WritePermissionWorkspace.rank(); got != 1 { + t.Fatalf("rank workspace=%d", got) + } + if got := WritePermissionAny.rank(); got != 2 { + t.Fatalf("rank any=%d", got) + } + if got := WritePermissionLevel("invalid").rank(); got != -1 { + t.Fatalf("rank invalid=%d", got) + } + if err := WritePermissionLevel("invalid").Validate(); err == nil { + t.Fatalf("expected invalid write permission error") + } + if err := NetworkPermissionMode("invalid").Validate(); err == nil { + t.Fatalf("expected invalid network mode error") + } + + paths := normalizePathDistinctList([]string{" /a ", "/a", "/a/./b", "", " "}) + if len(paths) < 2 { + t.Fatalf("expected normalized path list to retain distinct values, got %+v", paths) + } + hosts := normalizeLowerDistinctList([]string{" Example.com ", "example.com", "*.GitHub.com"}) + if len(hosts) != 2 || hosts[0] != "*.github.com" || hosts[1] != "example.com" { + t.Fatalf("unexpected normalized host list: %+v", hosts) + } + if !isSubsetExact([]string{"a", "b"}, []string{"a"}) { + t.Fatalf("expected subset exact true") + } + if isSubsetExact([]string{"a"}, []string{"a", "b"}) { + t.Fatalf("expected subset exact false") + } + if !isPathSubset([]string{"/repo"}, []string{"/repo/a"}) { + t.Fatalf("expected path subset true") + } + if isPathSubset([]string{"/repo/a"}, []string{"/repo"}) { + t.Fatalf("expected path subset false") + } + if !allowPathByList([]string{"/repo"}, "/repo/a.txt") { + t.Fatalf("expected allowPathByList to allow nested path") + } + if allowPathByList([]string{"/repo"}, "/tmp/x.txt") { + t.Fatalf("expected allowPathByList to deny outside path") + } + if !matchesCapabilityTool([]string{"filesystem_*"}, "filesystem_read_file", "") { + t.Fatalf("expected wildcard tool match") + } + if matchesCapabilityTool([]string{"["}, "filesystem_read_file", "") { + t.Fatalf("expected invalid wildcard pattern not to match") + } + if !matchesCapabilityHost([]string{"*.example.com"}, "api.example.com") { + t.Fatalf("expected wildcard host match") + } + if matchesCapabilityHost([]string{"example.com"}, "api.example.com") { + t.Fatalf("expected exact host mismatch") + } + if !hasTraversal("../etc/passwd") { + t.Fatalf("expected traversal detection") + } + if hasTraversal("safe/path") { + t.Fatalf("expected non-traversal path") + } +} + +func TestCapabilityNetworkSubsetAndActionHelpers(t *testing.T) { + t.Parallel() + + if err := ensureNetworkSubset( + NetworkPolicy{Mode: NetworkPermissionAllowAll}, + NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"a.com"}}, + ); err != nil { + t.Fatalf("allow_all parent should allow child, got %v", err) + } + if err := ensureNetworkSubset( + NetworkPolicy{Mode: NetworkPermissionDenyAll}, + NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"a.com"}}, + ); err == nil || !strings.Contains(err.Error(), "exceeds parent") { + t.Fatalf("expected deny_all parent rejection, got %v", err) + } + if err := ensureNetworkSubset( + NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"a.com"}}, + NetworkPolicy{Mode: NetworkPermissionAllowAll}, + ); err == nil || !strings.Contains(err.Error(), "exceeds parent") { + t.Fatalf("expected child allow_all rejection, got %v", err) + } + if err := ensureNetworkSubset( + NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"a.com"}}, + NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"b.com"}}, + ); err == nil || !strings.Contains(err.Error(), "allowed_hosts exceeds parent") { + t.Fatalf("expected child hosts subset rejection, got %v", err) + } + if err := ensureNetworkSubset( + NetworkPolicy{Mode: NetworkPermissionMode("bad-parent")}, + NetworkPolicy{Mode: NetworkPermissionDenyAll}, + ); err == nil || !strings.Contains(err.Error(), "invalid parent network policy") { + t.Fatalf("expected invalid parent mode error, got %v", err) + } + + if allowed, reason := allowNetworkHost(NetworkPolicy{Mode: NetworkPermissionAllowAll}, "Example.com"); !allowed || reason != "" { + t.Fatalf("allow_all should allow, got allowed=%v reason=%q", allowed, reason) + } + if allowed, reason := allowNetworkHost(NetworkPolicy{Mode: NetworkPermissionDenyAll}, "example.com"); allowed || !strings.Contains(reason, "denies all") { + t.Fatalf("deny_all should deny, got allowed=%v reason=%q", allowed, reason) + } + if allowed, reason := allowNetworkHost(NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"example.com"}}, "other.com"); allowed || !strings.Contains(reason, "host not allowed") { + t.Fatalf("allow_hosts miss should deny, got allowed=%v reason=%q", allowed, reason) + } + if allowed, reason := allowNetworkHost(NetworkPolicy{Mode: NetworkPermissionMode("invalid")}, "example.com"); allowed || !strings.Contains(reason, "is invalid") { + t.Fatalf("invalid mode should deny, got allowed=%v reason=%q", allowed, reason) + } + + if got := resolveActionPath("notes/a.txt", "/workspace"); !strings.HasSuffix(got, "/workspace/notes/a.txt") { + t.Fatalf("expected resolve relative path under workdir, got %q", got) + } + if got := resolveActionPath("", "/workspace"); got != "" { + t.Fatalf("expected empty resolved path, got %q", got) + } + + pathAction := Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + TargetType: TargetTypePath, + Target: "a/b.txt", + Workdir: "/workspace", + }, + } + target, ok, traversal := extractActionPath(pathAction) + if !ok || traversal || !strings.HasSuffix(target, "/workspace/a/b.txt") { + t.Fatalf("unexpected extracted path target=%q ok=%v traversal=%v", target, ok, traversal) + } + noneTarget, noneOK, noneTraversal := extractActionPath(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypeURL, + Target: "https://example.com", + }, + }) + if noneOK || noneTraversal || noneTarget != "" { + t.Fatalf("expected no path extraction for non-path target, got target=%q ok=%v traversal=%v", noneTarget, noneOK, noneTraversal) + } + + host, ok := extractActionNetworkHost(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypeURL, + Target: "https://API.EXAMPLE.com/x", + }, + }) + if !ok || host != "api.example.com" { + t.Fatalf("expected extracted lowercase host, got host=%q ok=%v", host, ok) + } + if host, ok := extractActionNetworkHost(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + TargetType: TargetTypePath, + Target: "/tmp/a.txt", + }, + }); ok || host != "" { + t.Fatalf("expected no host for path target, got host=%q ok=%v", host, ok) + } +} + +func TestCapabilityWorkspaceAndEngineDeniedHelpers(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + token := CapabilityToken{ + ID: "workspace-token", + TaskID: "task-workspace", + AgentID: "agent-workspace", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace/allowed"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionWorkspace, + } + + allowedAction := Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + TargetType: TargetTypePath, + Target: "/workspace/allowed/a.txt", + SandboxTargetType: TargetTypePath, + SandboxTarget: "/workspace/allowed/a.txt", + CapabilityToken: &token, + }, + } + if err := ValidateCapabilityForWorkspace(allowedAction); err != nil { + t.Fatalf("expected workspace capability allow, got %v", err) + } + + deniedAction := allowedAction + deniedAction.Payload.SandboxTarget = "/workspace/blocked/a.txt" + if err := ValidateCapabilityForWorkspace(deniedAction); err == nil || !strings.Contains(err.Error(), "path not allowed") { + t.Fatalf("expected workspace capability path denial, got %v", err) + } + + traversalAction := allowedAction + traversalAction.Payload.SandboxTarget = "../blocked.txt" + if err := ValidateCapabilityForWorkspace(traversalAction); err == nil || !strings.Contains(err.Error(), "traversal") { + t.Fatalf("expected workspace traversal denial, got %v", err) + } + + noPathAction := Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypeURL, + Target: "https://example.com", + CapabilityToken: &token, + }, + } + if err := ValidateCapabilityForWorkspace(noPathAction); err != nil { + t.Fatalf("expected non-path action to skip workspace check, got %v", err) + } + if err := ValidateCapabilityForWorkspace(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + }, + }); err != nil { + t.Fatalf("expected action without token to pass, got %v", err) + } + + if allowed, reason := EvaluateCapabilityAction(token, Action{ + Type: ActionType("invalid"), + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + }, + }, now); allowed || !strings.Contains(reason, "invalid action type") { + t.Fatalf("expected invalid action denial, got allowed=%v reason=%q", allowed, reason) + } + if allowed, reason := EvaluateCapabilityAction(token, allowedAction, now.Add(2*time.Hour)); allowed || !strings.Contains(reason, "expired") { + t.Fatalf("expected expired denial, got allowed=%v reason=%q", allowed, reason) + } + + emptyReasonResult := capabilityDeniedResult(allowedAction, " ") + if emptyReasonResult.Reason != "capability token denied" { + t.Fatalf("expected default deny reason, got %q", emptyReasonResult.Reason) + } + if IsCapabilityDeniedResult(CheckResult{}) { + t.Fatalf("expected no capability deny for empty result") + } + if IsCapabilityDeniedResult(CheckResult{ + Rule: &Rule{ID: "another-rule"}, + }) { + t.Fatalf("expected non-capability rule id to return false") + } +} + +func TestCapabilitySubsetAndEngineBranches(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + parent := CapabilityToken{ + ID: "parent-branch", + TaskID: "task-branch", + AgentID: "agent-parent-branch", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(2 * time.Hour), + AllowedTools: []string{"filesystem_read_file", "webfetch"}, + AllowedPaths: []string{"/workspace"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"example.com", "api.example.com"}}, + WritePermission: WritePermissionWorkspace, + } + + tests := []struct { + name string + child CapabilityToken + wantInErr string + }{ + { + name: "child deny_all network is subset", + child: CapabilityToken{ + ID: "child-deny-all", + TaskID: "task-branch", + AgentID: "agent-child-branch", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace/sub"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionNone, + }, + }, + { + name: "write permission exceeds parent", + child: CapabilityToken{ + ID: "child-write", + TaskID: "task-branch", + AgentID: "agent-child-branch", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace/sub"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"example.com"}}, + WritePermission: WritePermissionAny, + }, + wantInErr: "write permission exceeds parent", + }, + { + name: "path exceeds parent", + child: CapabilityToken{ + ID: "child-path", + TaskID: "task-branch", + AgentID: "agent-child-branch", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/outside"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionAllowHosts, AllowedHosts: []string{"example.com"}}, + WritePermission: WritePermissionNone, + }, + wantInErr: "allowed_paths exceeds parent", + }, + { + name: "invalid parent shape", + child: CapabilityToken{ + ID: "child-ok", + TaskID: "task-branch", + AgentID: "agent-child-branch", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace/sub"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionNone, + }, + wantInErr: "invalid parent capability token", + }, + { + name: "invalid child shape", + child: CapabilityToken{ + ID: " ", + TaskID: "task-branch", + AgentID: "agent-child-branch", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace/sub"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionNone, + }, + wantInErr: "invalid child capability token", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + parentToken := parent + if tt.name == "invalid parent shape" { + parentToken.ID = "" + } + err := EnsureCapabilitySubset(parentToken, tt.child) + if tt.wantInErr == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantInErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantInErr, err) + } + }) + } + + action := Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + TargetType: TargetTypePath, + Target: "/workspace/notes.txt", + }, + } + + allowToken := parent + allowToken.ID = "allow-token" + allowToken.AgentID = "agent-allow" + allowToken.AllowedPaths = []string{"/workspace"} + allowToken.NetworkPolicy = NetworkPolicy{Mode: NetworkPermissionDenyAll} + action.Payload.CapabilityToken = &allowToken + + result, denied := EvaluateCapabilityForEngine(action, now) + if denied || result.Decision != "" { + t.Fatalf("expected capability allow to bypass deny result, got denied=%v result=%+v", denied, result) + } + + result, denied = EvaluateCapabilityForEngine(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + }, + }, now) + if denied || result.Decision != "" { + t.Fatalf("expected no token to bypass capability check, got denied=%v result=%+v", denied, result) + } +} + +func TestCapabilitySignerShapeFailuresAndNetworkHostParsing(t *testing.T) { + t.Parallel() + + signer, err := NewCapabilitySigner([]byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatalf("new signer: %v", err) + } + now := time.Now().UTC() + valid := CapabilityToken{ + ID: "sign-shape", + TaskID: "task-sign-shape", + AgentID: "agent-sign-shape", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionNone, + } + + invalidForSign := valid + invalidForSign.AllowedTools = nil + if _, err := signer.Sign(invalidForSign); err == nil || !strings.Contains(err.Error(), "allowed_tools is empty") { + t.Fatalf("expected sign shape validation failure, got %v", err) + } + + signed, err := signer.Sign(valid) + if err != nil { + t.Fatalf("sign valid token: %v", err) + } + invalidForVerify := signed + invalidForVerify.TaskID = "" + if err := signer.Verify(invalidForVerify); err == nil || !strings.Contains(err.Error(), "task_id is empty") { + t.Fatalf("expected verify shape validation failure, got %v", err) + } + + if host, ok := extractActionNetworkHost(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypeURL, + Target: "://bad-url", + }, + }); ok || host != "" { + t.Fatalf("expected invalid URL parsing to fail, got host=%q ok=%v", host, ok) + } + if host, ok := extractActionNetworkHost(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypeURL, + Target: "https://", + }, + }); ok || host != "" { + t.Fatalf("expected empty host URL to fail, got host=%q ok=%v", host, ok) + } + if host, ok := extractActionNetworkHost(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypePath, + Target: "https://example.com", + }, + }); !ok || host != "example.com" { + t.Fatalf("expected webfetch resource fallback to parse host, got host=%q ok=%v", host, ok) + } +} + +func TestCapabilityLowLevelBranchCoverage(t *testing.T) { + t.Parallel() + + if hasTraversal("") { + t.Fatalf("empty path should not be traversal") + } + if !hasTraversal("a/../b") { + t.Fatalf("path containing '/../' should be traversal") + } + if !allowPathByList([]string{"/repo"}, "/repo") { + t.Fatalf("expected exact path allow") + } + if allowPathByList(nil, "/repo") { + t.Fatalf("expected empty allowlist to deny") + } + if !allowPathByList([]string{"", "/repo"}, "/repo/a") { + t.Fatalf("expected empty allowlist entry to be skipped") + } + if !matchesCapabilityTool([]string{"webfetch"}, "ignored", "webfetch") { + t.Fatalf("expected resource exact match") + } + if matchesCapabilityTool(nil, "filesystem_read_file", "filesystem_read_file") { + t.Fatalf("expected empty allowlist to return false") + } + if !matchesCapabilityTool([]string{"web*"}, "ignored", "webfetch") { + t.Fatalf("expected resource wildcard match") + } + if !matchesCapabilityHost([]string{"example.com"}, "example.com") { + t.Fatalf("expected exact host match") + } + if normalizePathKey(" ") != "" { + t.Fatalf("expected empty normalized path for blank input") + } + if !isPathSubset([]string{"/a"}, nil) { + t.Fatalf("empty child should always be subset") + } + if isPathSubset(nil, []string{"/a"}) { + t.Fatalf("empty parent should not contain non-empty child") + } + if allowed, reason := allowNetworkHost(NetworkPolicy{ + Mode: NetworkPermissionAllowHosts, + AllowedHosts: []string{"example.com"}, + }, "example.com"); !allowed || reason != "" { + t.Fatalf("allow_hosts exact hit should allow, got allowed=%v reason=%q", allowed, reason) + } + if values := normalizeLowerDistinctList([]string{"", " ", "Example.com"}); len(values) != 1 || values[0] != "example.com" { + t.Fatalf("expected blank values to be ignored, got %+v", values) + } + if err := ensureNetworkSubset( + NetworkPolicy{Mode: NetworkPermissionDenyAll}, + NetworkPolicy{Mode: NetworkPermissionDenyAll}, + ); err != nil { + t.Fatalf("expected deny_all child under deny_all parent, got %v", err) + } + + now := time.Now().UTC() + token := CapabilityToken{ + ID: "validate-at-zero-now", + TaskID: "task-validate-at-zero-now", + AgentID: "agent-validate-at-zero-now", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionNone, + } + if err := token.ValidateAt(time.Time{}); err != nil { + t.Fatalf("zero now should fallback to current time, got %v", err) + } + token.IssuedAt = time.Time{} + if err := token.ValidateShape(); err == nil || !strings.Contains(err.Error(), "issued_at/expires_at is required") { + t.Fatalf("expected required issued/expires validation error, got %v", err) + } + + denyPathToken := CapabilityToken{ + ID: "deny-path-token", + TaskID: "task-deny-path", + AgentID: "agent-deny-path", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + AllowedTools: []string{"filesystem_read_file"}, + AllowedPaths: []string{"/workspace/allowed"}, + NetworkPolicy: NetworkPolicy{Mode: NetworkPermissionDenyAll}, + WritePermission: WritePermissionWorkspace, + } + if allowed, reason := EvaluateCapabilityAction(denyPathToken, Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + TargetType: TargetTypePath, + Target: "/workspace/blocked/a.txt", + SandboxTargetType: TargetTypePath, + SandboxTarget: "/workspace/blocked/a.txt", + }, + }, now); allowed || !strings.Contains(reason, "path not allowed") { + t.Fatalf("expected EvaluateCapabilityAction path denial, got allowed=%v reason=%q", allowed, reason) + } + + if target, ok, traversal := extractActionPath(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + TargetType: TargetTypePath, + }, + }); ok || traversal || target != "" { + t.Fatalf("expected empty path target extraction, got target=%q ok=%v traversal=%v", target, ok, traversal) + } + if host, ok := extractActionNetworkHost(Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + TargetType: TargetTypeURL, + Target: "", + }, + }); ok || host != "" { + t.Fatalf("expected empty URL target to skip host extraction, got host=%q ok=%v", host, ok) + } +} diff --git a/internal/subagent/worker_test.go b/internal/subagent/worker_test.go index 3c8dd78d..28443130 100644 --- a/internal/subagent/worker_test.go +++ b/internal/subagent/worker_test.go @@ -463,3 +463,254 @@ func TestWorkerNilAndValidationBranches(t *testing.T) { t.Fatalf("nil worker policy should be empty, got %+v", nilPolicy) } } + +func TestWorkerStopReasonBranches(t *testing.T) { + t.Parallel() + + policy, err := DefaultRolePolicy(RoleCoder) + if err != nil { + t.Fatalf("DefaultRolePolicy() error = %v", err) + } + + newRunningWorker := func(t *testing.T) *worker { + t.Helper() + wr, err := NewWorker(RoleCoder, policy, EngineFunc(func(ctx context.Context, input StepInput) (StepOutput, error) { + return StepOutput{Done: false}, nil + })) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + impl, ok := wr.(*worker) + if !ok { + t.Fatalf("expected *worker implementation") + } + if err := impl.Start(Task{ID: "t-stop", Goal: "goal"}, Budget{MaxSteps: 3}, Capability{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + return impl + } + + t.Run("completed requires valid output", func(t *testing.T) { + t.Parallel() + w := newRunningWorker(t) + w.output = Output{Summary: " "} + if err := w.Stop(StopReasonCompleted); err == nil { + t.Fatalf("expected invalid completed output error") + } + }) + + for _, reason := range []StopReason{StopReasonTimeout, StopReasonMaxSteps, StopReasonError} { + reason := reason + t.Run("failed reason "+string(reason), func(t *testing.T) { + t.Parallel() + w := newRunningWorker(t) + if err := w.Stop(reason); err != nil { + t.Fatalf("Stop(%s) error = %v", reason, err) + } + if w.State() != StateFailed { + t.Fatalf("state=%q, want %q", w.State(), StateFailed) + } + }) + } + + t.Run("unsupported reason", func(t *testing.T) { + t.Parallel() + w := newRunningWorker(t) + if err := w.Stop(StopReason("invalid-reason")); err == nil { + t.Fatalf("expected unsupported reason error") + } + }) + + t.Run("idle worker stop fails", func(t *testing.T) { + t.Parallel() + wr, err := NewWorker(RoleCoder, policy, nil) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + if err := wr.Stop(StopReasonCanceled); err == nil { + t.Fatalf("expected non-running stop error") + } + }) +} + +func TestWorkerStepCancellationBranches(t *testing.T) { + t.Parallel() + + policy, err := DefaultRolePolicy(RoleResearcher) + if err != nil { + t.Fatalf("DefaultRolePolicy() error = %v", err) + } + + t.Run("step with canceled context before run", func(t *testing.T) { + t.Parallel() + wr, err := NewWorker(RoleResearcher, policy, nil) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + if err := wr.Start(Task{ID: "t-step-cancel-1", Goal: "goal"}, Budget{MaxSteps: 3}, Capability{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := wr.Step(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } + }) + + t.Run("engine cancellation transitions canceled state", func(t *testing.T) { + t.Parallel() + wr, err := NewWorker(RoleResearcher, policy, EngineFunc(func(ctx context.Context, input StepInput) (StepOutput, error) { + return StepOutput{}, context.Canceled + })) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + if err := wr.Start(Task{ID: "t-step-cancel-2", Goal: "goal"}, Budget{MaxSteps: 3}, Capability{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + if _, err := wr.Step(context.Background()); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } + if wr.State() != StateCanceled { + t.Fatalf("state=%q, want %q", wr.State(), StateCanceled) + } + }) +} + +func TestWorkerAdditionalUncoveredBranches(t *testing.T) { + t.Parallel() + + t.Run("new worker fills empty policy role", func(t *testing.T) { + t.Parallel() + policy := RolePolicy{ + SystemPrompt: "review", + AllowedTools: []string{"bash"}, + RequiredSections: []string{"summary"}, + } + wr, err := NewWorker(RoleReviewer, policy, nil) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + if wr.Policy().Role != RoleReviewer { + t.Fatalf("policy role=%q, want %q", wr.Policy().Role, RoleReviewer) + } + }) + + t.Run("new worker policy validate error", func(t *testing.T) { + t.Parallel() + _, err := NewWorker(RoleReviewer, RolePolicy{ + Role: RoleReviewer, + SystemPrompt: "", + AllowedTools: []string{"bash"}, + RequiredSections: []string{"summary"}, + }, nil) + if err == nil { + t.Fatalf("expected policy validate error") + } + }) + + t.Run("start invalid task", func(t *testing.T) { + t.Parallel() + policy, err := DefaultRolePolicy(RoleCoder) + if err != nil { + t.Fatalf("DefaultRolePolicy() error = %v", err) + } + wr, err := NewWorker(RoleCoder, policy, nil) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + if err := wr.Start(Task{}, Budget{}, Capability{}); err == nil { + t.Fatalf("expected task validate error") + } + }) + + t.Run("step hits pre-run max-steps guard", func(t *testing.T) { + t.Parallel() + policy, err := DefaultRolePolicy(RoleResearcher) + if err != nil { + t.Fatalf("DefaultRolePolicy() error = %v", err) + } + wr, err := NewWorker(RoleResearcher, policy, EngineFunc(func(ctx context.Context, input StepInput) (StepOutput, error) { + return StepOutput{Done: false}, nil + })) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + if err := wr.Start(Task{ID: "t-max-pre", Goal: "goal"}, Budget{MaxSteps: 2}, Capability{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + impl := wr.(*worker) + impl.mu.Lock() + impl.stepCount = impl.budget.MaxSteps + impl.mu.Unlock() + step, err := wr.Step(context.Background()) + if err != nil { + t.Fatalf("Step() error = %v", err) + } + if !step.Done || step.State != StateFailed { + t.Fatalf("expected max-steps failure, got %+v", step) + } + }) + + t.Run("step detects state change after engine returns", func(t *testing.T) { + t.Parallel() + policy, err := DefaultRolePolicy(RoleResearcher) + if err != nil { + t.Fatalf("DefaultRolePolicy() error = %v", err) + } + var impl *worker + wr, err := NewWorker(RoleResearcher, policy, EngineFunc(func(ctx context.Context, input StepInput) (StepOutput, error) { + impl.mu.Lock() + impl.state = StateCanceled + impl.mu.Unlock() + return StepOutput{Done: false}, nil + })) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + var ok bool + impl, ok = wr.(*worker) + if !ok { + t.Fatalf("expected *worker implementation") + } + if err := wr.Start(Task{ID: "t-state-change", Goal: "goal"}, Budget{MaxSteps: 3}, Capability{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + if _, err := wr.Step(context.Background()); err == nil { + t.Fatalf("expected state changed error") + } + }) + + t.Run("stop completed success and empty reason error", func(t *testing.T) { + t.Parallel() + policy, err := DefaultRolePolicy(RoleCoder) + if err != nil { + t.Fatalf("DefaultRolePolicy() error = %v", err) + } + wr, err := NewWorker(RoleCoder, policy, nil) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + impl := wr.(*worker) + if err := impl.Start(Task{ID: "t-stop-complete", Goal: "goal"}, Budget{MaxSteps: 2}, Capability{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + if err := impl.Stop(StopReason(" ")); err == nil { + t.Fatalf("expected empty stop reason error") + } + impl.output = Output{ + Summary: "done", + Findings: []string{"f1"}, + Patches: []string{"p1"}, + Risks: []string{"r1"}, + NextActions: []string{"n1"}, + Artifacts: []string{"a1"}, + } + if err := impl.Stop(StopReasonCompleted); err != nil { + t.Fatalf("Stop(Completed) error = %v", err) + } + if impl.State() != StateSucceeded { + t.Fatalf("state=%q, want %q", impl.State(), StateSucceeded) + } + }) +} diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index 760cb01d..e65328b0 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -520,6 +520,171 @@ func TestNewManagerRejectsNilExecutor(t *testing.T) { } } +func TestManagerPermissionHelperBranches(t *testing.T) { + t.Parallel() + + decision := security.CheckResult{ + Decision: security.DecisionAsk, + Action: security.Action{ + Type: security.ActionTypeRead, + Payload: security.ActionPayload{ + ToolName: "webfetch", + Resource: "webfetch", + Operation: "fetch", + TargetType: security.TargetTypeURL, + Target: "https://example.com", + SandboxTargetType: security.TargetTypePath, + SandboxTarget: "/workspace/tmp.txt", + TaskID: "task-1", + AgentID: "agent-1", + }, + }, + Rule: &security.Rule{ + ID: "session-memory:" + string(SessionPermissionScopeAlways), + }, + Reason: "need approval", + } + + if scope := extractRememberScope(security.CheckResult{}); scope != "" { + t.Fatalf("expected empty scope for nil rule, got %q", scope) + } + if scope := extractRememberScope(decision); scope != SessionPermissionScopeAlways { + t.Fatalf("expected always session scope, got %q", scope) + } + if scope := extractRememberScope(security.CheckResult{Rule: &security.Rule{ID: "session-memory:" + string(SessionPermissionScopeOnce)}}); scope != SessionPermissionScopeOnce { + t.Fatalf("expected once scope, got %q", scope) + } + if scope := extractRememberScope(security.CheckResult{Rule: &security.Rule{ID: "session-memory:" + string(SessionPermissionScopeReject)}}); scope != SessionPermissionScopeReject { + t.Fatalf("expected reject scope, got %q", scope) + } + if scope := extractRememberScope(security.CheckResult{Rule: &security.Rule{ID: "unknown"}}); scope != "" { + t.Fatalf("expected unknown scope to map empty, got %q", scope) + } + + if got := sessionDecisionReason(""); got != "session permission remembered" { + t.Fatalf("unexpected default session decision reason: %q", got) + } + if got := sessionDecisionReason(SessionPermissionScopeOnce); !strings.Contains(got, "once") { + t.Fatalf("unexpected once reason: %q", got) + } + if got := sessionDecisionReason(SessionPermissionScopeAlways); !strings.Contains(got, "always") { + t.Fatalf("unexpected always reason: %q", got) + } + if got := sessionDecisionReason(SessionPermissionScopeReject); !strings.Contains(got, "reject") { + t.Fatalf("unexpected reject reason: %q", got) + } + + metadata := permissionMetadata(decision) + for _, key := range []string{ + "permission_decision", + "permission_rule_id", + "permission_action_type", + "permission_resource", + "permission_operation", + "permission_target_type", + "permission_target", + "permission_sandbox_target_type", + "permission_sandbox_target", + "permission_task_id", + "permission_agent_id", + } { + if _, ok := metadata[key]; !ok { + t.Fatalf("expected metadata key %q, got %+v", key, metadata) + } + } + + withoutRule := permissionMetadata(security.CheckResult{ + Decision: security.DecisionDeny, + Action: security.Action{ + Type: security.ActionTypeRead, + Payload: security.ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + }, + }, + }) + if _, ok := withoutRule["permission_rule_id"]; ok { + t.Fatalf("did not expect permission_rule_id for empty rule") + } + if _, ok := withoutRule["permission_target_type"]; ok { + t.Fatalf("did not expect target metadata when target is empty") + } + if _, ok := withoutRule["permission_task_id"]; ok { + t.Fatalf("did not expect task metadata when task id is empty") + } + + details := permissionDetails(decision) + for _, fragment := range []string{"type: read", "resource: webfetch", "operation: fetch", "url: https://example.com", "policy: need approval"} { + if !strings.Contains(details, fragment) { + t.Fatalf("expected details containing %q, got %q", fragment, details) + } + } + minimalDetails := permissionDetails(security.CheckResult{ + Action: security.Action{Type: security.ActionTypeBash}, + }) + if strings.TrimSpace(minimalDetails) != "type: bash" { + t.Fatalf("expected minimal details for bash, got %q", minimalDetails) + } +} + +func TestManagerCapabilityDecisionHelpers(t *testing.T) { + t.Parallel() + + action := security.Action{ + Type: security.ActionTypeRead, + Payload: security.ActionPayload{ + ToolName: "filesystem_read_file", + Resource: "filesystem_read_file", + }, + } + deny := capabilityDenyDecision(action, "") + if deny.Decision != security.DecisionDeny { + t.Fatalf("expected deny decision, got %q", deny.Decision) + } + if deny.Rule == nil || deny.Rule.ID != security.CapabilityRuleID { + t.Fatalf("expected capability rule id, got %+v", deny.Rule) + } + if deny.Reason != "capability token denied" { + t.Fatalf("expected default deny reason, got %q", deny.Reason) + } + + resultAsk := blockedToolResult(ToolCallInput{ + ID: "call-ask", + Name: "webfetch", + }, security.CheckResult{ + Decision: security.DecisionAsk, + Action: action, + }) + if !strings.Contains(resultAsk.Content, "permission approval required") { + t.Fatalf("expected ask reason in blocked result, got %q", resultAsk.Content) + } + + resultDeny := blockedToolResult(ToolCallInput{ + ID: "call-deny", + Name: "webfetch", + }, security.CheckResult{ + Decision: security.DecisionDeny, + Action: action, + Reason: "explicit deny", + }) + if !strings.Contains(resultDeny.Content, "explicit deny") { + t.Fatalf("expected explicit deny reason in blocked result, got %q", resultDeny.Content) + } + + var nilManager *DefaultManager + if err := nilManager.RememberSessionDecision("session-1", action, SessionPermissionScopeAlways); err == nil || !strings.Contains(err.Error(), "manager is nil") { + t.Fatalf("expected nil manager remember error, got %v", err) + } + + manager := &DefaultManager{} + if err := manager.RememberSessionDecision("session-2", action, SessionPermissionScopeAlways); err != nil { + t.Fatalf("expected remember session decision success, got %v", err) + } + if manager.sessionDecisions == nil { + t.Fatalf("expected session memory to be initialized") + } +} + func TestDefaultManagerSessionPermissionMemory(t *testing.T) { t.Parallel() From 62453cb33f74848bd19d7c4ed42537364a1e9958 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 04:02:24 +0000 Subject: [PATCH 8/8] fix(security): harden capability isolation checks Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/subagent/worker.go | 26 +++++++++++++++- internal/subagent/worker_test.go | 21 +++++++++++++ internal/tools/manager.go | 46 ++++++++++++++++++++++++++-- internal/tools/manager_test.go | 22 +++++++++++++ internal/tools/webfetch/tool.go | 23 ++++++++++++-- internal/tools/webfetch/tool_test.go | 16 ++++++++++ 6 files changed, 148 insertions(+), 6 deletions(-) diff --git a/internal/subagent/worker.go b/internal/subagent/worker.go index 5002887c..d1efb3cf 100644 --- a/internal/subagent/worker.go +++ b/internal/subagent/worker.go @@ -3,6 +3,7 @@ package subagent import ( "context" "errors" + "path/filepath" "strings" "sync" "time" @@ -74,7 +75,11 @@ func (w *worker) Start(task Task, budget Budget, capability Capability) error { w.budget = budget.normalize(w.policy.DefaultBudget) capabilityInput := capability.normalize() if len(capabilityInput.AllowedPaths) == 0 && strings.TrimSpace(task.Workspace) != "" { - capabilityInput.AllowedPaths = []string{strings.TrimSpace(task.Workspace)} + workspace := strings.TrimSpace(task.Workspace) + if err := validateDefaultWorkspacePath(workspace); err != nil { + return err + } + capabilityInput.AllowedPaths = []string{workspace} } effectiveCapability, err := bindCapabilityToPolicy(capabilityInput, w.policy) if err != nil { @@ -206,6 +211,25 @@ func bindCapabilityToPolicy(capability Capability, policy RolePolicy) (Capabilit return capability, nil } +// validateDefaultWorkspacePath 校验默认注入 capability 的工作区路径,阻断危险根路径绑定。 +func validateDefaultWorkspacePath(workspace string) error { + cleaned := filepath.Clean(strings.TrimSpace(workspace)) + if cleaned == "." { + return errorsf("task workspace must not be current directory") + } + if cleaned == string(filepath.Separator) { + return errorsf("task workspace must not be filesystem root") + } + volume := filepath.VolumeName(cleaned) + if volume != "" { + suffix := strings.TrimPrefix(cleaned, volume) + if suffix == "" || suffix == string(filepath.Separator) { + return errorsf("task workspace must not be filesystem root") + } + } + return nil +} + // appendTraceBounded 将新增 trace 追加到切片尾部,并保证内部存储长度不超过上限。 func appendTraceBounded(trace []string, delta string, limit int) []string { trace = append(trace, delta) diff --git a/internal/subagent/worker_test.go b/internal/subagent/worker_test.go index 28443130..895649c2 100644 --- a/internal/subagent/worker_test.go +++ b/internal/subagent/worker_test.go @@ -3,6 +3,8 @@ package subagent import ( "context" "errors" + "path/filepath" + "strings" "testing" "time" ) @@ -331,6 +333,25 @@ func TestWorkerStartCapabilityPolicyGuard(t *testing.T) { if len(resultWithWorkspace.Capability.AllowedPaths) != 1 || resultWithWorkspace.Capability.AllowedPaths[0] != "/tmp/sub-task" { t.Fatalf("workspace capability path = %v, want [/tmp/sub-task]", resultWithWorkspace.Capability.AllowedPaths) } + + w4, err := NewWorker(RoleReviewer, policy, nil) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + if err := w4.Start(Task{ID: "t-cap-workspace-dot", Goal: "goal", Workspace: "."}, Budget{}, Capability{}); err == nil || + !strings.Contains(err.Error(), "must not be current directory") { + t.Fatalf("expected workspace dot rejection, got %v", err) + } + + w5, err := NewWorker(RoleReviewer, policy, nil) + if err != nil { + t.Fatalf("NewWorker() error = %v", err) + } + rootWorkspace := string(filepath.Separator) + if err := w5.Start(Task{ID: "t-cap-workspace-root", Goal: "goal", Workspace: rootWorkspace}, Budget{}, Capability{}); err == nil || + !strings.Contains(err.Error(), "must not be filesystem root") { + t.Fatalf("expected workspace root rejection, got %v", err) + } } func TestWorkerTraceWindow(t *testing.T) { diff --git a/internal/tools/manager.go b/internal/tools/manager.go index 84c8697b..424e68be 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "strings" + "sync" "time" providertypes "neo-code/internal/provider/types" @@ -52,6 +53,15 @@ func (NoopWorkspaceSandbox) Check(ctx context.Context, action security.Action) ( return nil, ctx.Err() } +var ( + // ErrPermissionDenied 标记工具请求被权限系统拒绝。 + ErrPermissionDenied = errors.New("tools: permission denied") + // ErrPermissionApprovalRequired 标记工具请求需要用户审批。 + ErrPermissionApprovalRequired = errors.New("tools: permission approval required") + // ErrCapabilityDenied 标记拒绝由 capability token 触发。 + ErrCapabilityDenied = errors.New("tools: capability denied") +) + // PermissionDecisionError reports a non-allow permission decision. type PermissionDecisionError struct { decision security.Decision @@ -82,6 +92,22 @@ func (e *PermissionDecisionError) Error() string { return "tools: " + reason } +// Unwrap 返回可用于 errors.Is 判定的哨兵错误集合。 +func (e *PermissionDecisionError) Unwrap() []error { + if e == nil { + return nil + } + switch e.decision { + case security.DecisionAsk: + return []error{ErrPermissionApprovalRequired} + default: + if strings.EqualFold(strings.TrimSpace(e.ruleID), security.CapabilityRuleID) { + return []error{ErrPermissionDenied, ErrCapabilityDenied} + } + return []error{ErrPermissionDenied} + } +} + // Decision returns the blocking engine decision. func (e *PermissionDecisionError) Decision() string { if e == nil { @@ -137,6 +163,7 @@ type DefaultManager struct { engine security.PermissionEngine sandbox WorkspaceSandbox sessionDecisions *sessionPermissionMemory + capabilityMu sync.RWMutex capabilitySigner *security.CapabilitySigner } @@ -177,6 +204,8 @@ func (m *DefaultManager) SetCapabilitySigner(signer *security.CapabilitySigner) if signer == nil { return errors.New("tools: capability signer is nil") } + m.capabilityMu.Lock() + defer m.capabilityMu.Unlock() m.capabilitySigner = signer return nil } @@ -186,6 +215,18 @@ func (m *DefaultManager) CapabilitySigner() *security.CapabilitySigner { if m == nil { return nil } + m.capabilityMu.RLock() + defer m.capabilityMu.RUnlock() + return m.capabilitySigner +} + +// capabilitySignerSnapshot 返回当前 capability signer 的并发安全快照。 +func (m *DefaultManager) capabilitySignerSnapshot() *security.CapabilitySigner { + if m == nil { + return nil + } + m.capabilityMu.RLock() + defer m.capabilityMu.RUnlock() return m.capabilitySigner } @@ -289,10 +330,11 @@ func (m *DefaultManager) verifyCapabilityToken(action security.Action) error { if token == nil { return nil } - if m == nil || m.capabilitySigner == nil { + signer := m.capabilitySignerSnapshot() + if signer == nil { return errors.New("capability signer is unavailable") } - if err := m.capabilitySigner.Verify(*token); err != nil { + if err := signer.Verify(*token); err != nil { return fmt.Errorf("invalid capability token signature: %w", err) } diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index e65328b0..1645d83c 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -482,6 +482,9 @@ func TestPermissionDecisionError(t *testing.T) { if errors.Is(err, context.Canceled) { t.Fatalf("permission error should not match unrelated errors") } + if !errors.Is(err, ErrPermissionApprovalRequired) { + t.Fatalf("ask decision should match ErrPermissionApprovalRequired") + } denyErr := &PermissionDecisionError{} if !strings.Contains(denyErr.Error(), "permission denied") { @@ -496,6 +499,9 @@ func TestPermissionDecisionError(t *testing.T) { if denyErr.RememberScope() != "" { t.Fatalf("expected empty remember scope, got %q", denyErr.RememberScope()) } + if !errors.Is(denyErr, ErrPermissionDenied) { + t.Fatalf("default deny should match ErrPermissionDenied") + } var nilErr *PermissionDecisionError if nilErr.Error() != "" || nilErr.Decision() != "" || nilErr.ToolName() != "" || nilErr.RememberScope() != "" { @@ -509,6 +515,17 @@ func TestPermissionDecisionError(t *testing.T) { if !strings.Contains(defaultAsk.Error(), "permission approval required") { t.Fatalf("expected default ask message, got %q", defaultAsk.Error()) } + + capabilityErr := &PermissionDecisionError{ + decision: security.DecisionDeny, + ruleID: security.CapabilityRuleID, + } + if !errors.Is(capabilityErr, ErrCapabilityDenied) { + t.Fatalf("capability deny should match ErrCapabilityDenied") + } + if !errors.Is(capabilityErr, ErrPermissionDenied) { + t.Fatalf("capability deny should also match ErrPermissionDenied") + } } func TestNewManagerRejectsNilExecutor(t *testing.T) { @@ -1875,7 +1892,9 @@ func TestDefaultManagerExecuteCapabilityTokenWithoutSigner(t *testing.T) { if err != nil { t.Fatalf("sign token: %v", err) } + manager.capabilityMu.Lock() manager.capabilitySigner = nil + manager.capabilityMu.Unlock() _, execErr := manager.Execute(context.Background(), ToolCallInput{ ID: "call-no-signer", @@ -1896,6 +1915,9 @@ func TestDefaultManagerExecuteCapabilityTokenWithoutSigner(t *testing.T) { if !strings.Contains(strings.ToLower(permissionErr.Reason()), "signer is unavailable") { t.Fatalf("expected signer unavailable reason, got %q", permissionErr.Reason()) } + if !errors.Is(execErr, ErrCapabilityDenied) { + t.Fatalf("expected capability denied sentinel, got %v", execErr) + } if readTool.callCount != 0 { t.Fatalf("expected denied call not to execute tool, got %d", readTool.callCount) } diff --git a/internal/tools/webfetch/tool.go b/internal/tools/webfetch/tool.go index 83daa3f5..b468b666 100644 --- a/internal/tools/webfetch/tool.go +++ b/internal/tools/webfetch/tool.go @@ -25,6 +25,7 @@ const ( reasonReadResponseFailed = "read response failed" reasonUnsupportedType = "unsupported content type" reasonEmptyContent = "content is empty after extraction" + reasonRedirectNotAllowed = "redirect is not allowed" errorMessageUnexpectedHTTP = "unexpected HTTP status %s" ) @@ -58,14 +59,22 @@ type responseData struct { func New(cfg Config) *Tool { normalized := normalizeConfig(cfg) return &Tool{ - client: &http.Client{ - Timeout: normalized.Timeout, - }, + client: newHTTPClient(normalized.Timeout), cfg: normalized, supportedText: newContentTypeSet(normalized.SupportedContentTypes), } } +// newHTTPClient 创建禁止自动重定向的客户端,避免跨域重定向绕过上层网络权限校验。 +func newHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + func (t *Tool) Name() string { return toolName } @@ -172,6 +181,14 @@ func (t *Tool) handleResponse(targetURL string, resp *http.Response) (tools.Tool ContentType: detectContentType(resp.Header.Get("Content-Type"), body), Truncated: truncated, } + if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest { + location := strings.TrimSpace(resp.Header.Get("Location")) + details := location + if details == "" { + details = resp.Status + } + return t.newErrorResult(data, reasonRedirectNotAllowed, details), fmt.Errorf("webfetch: redirect blocked: %s", details) + } content, title, err := t.extractContent(data.ContentType, body) if err != nil { diff --git a/internal/tools/webfetch/tool_test.go b/internal/tools/webfetch/tool_test.go index f49da6a3..c4670ab7 100644 --- a/internal/tools/webfetch/tool_test.go +++ b/internal/tools/webfetch/tool_test.go @@ -59,6 +59,8 @@ func TestToolExecute(t *testing.T) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("upstream failed")) + case "/redirect": + http.Redirect(w, r, "https://example.com/blocked", http.StatusFound) default: w.WriteHeader(http.StatusNotFound) } @@ -210,6 +212,20 @@ func TestToolExecute(t *testing.T) { expectType: "text/plain", expectIsError: true, }, + { + name: "blocks redirect responses", + args: map[string]string{"url": server.URL + "/redirect"}, + toolConfig: defaultConfig, + expectErr: "redirect blocked", + expectContent: []string{ + "tool error", + "tool: webfetch", + "reason: " + reasonRedirectNotAllowed, + "https://example.com/blocked", + }, + expectStatus: "302 Found", + expectIsError: true, + }, { name: "rejects invalid scheme", args: map[string]string{"url": "ftp://example.com"},