diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 8f4d69f7..594e8f5e 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -13,6 +13,33 @@ type RuntimeEvent struct { Payload any } +// PermissionRequestPayload 描述一次需要审批的权限请求上下文。 +type PermissionRequestPayload struct { + ToolName string + ActionType string + Operation string + TargetType string + Target string + Decision string + Reason string + RuleID string + RememberScope string +} + +// PermissionResolvedPayload 描述权限请求被运行时处理后的最终状态。 +type PermissionResolvedPayload struct { + ToolName string + ActionType string + Operation string + TargetType string + Target string + Decision string + Reason string + RuleID string + RememberScope string + ResolvedAs string +} + const ( // EventUserMessage is emitted after the user input has been accepted and saved. EventUserMessage EventType = "user_message" @@ -36,6 +63,10 @@ const ( // EventProviderRetry is emitted when runtime retries a provider call due to // a retryable error (e.g. 429, 5xx). Payload is a human-readable message. EventProviderRetry EventType = "provider_retry" + // EventPermissionRequest is emitted when a tool call hits an ask decision. + EventPermissionRequest EventType = "permission_request" + // EventPermissionResolved is emitted when runtime resolves a permission request or denial. + EventPermissionResolved EventType = "permission_resolved" // EventCompactStart is emitted when a compact cycle starts. EventCompactStart EventType = "compact_start" // EventCompactDone is emitted when a compact cycle completes. diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index ae3b380a..c95bbcd3 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -242,6 +242,12 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { if execErr != nil && strings.TrimSpace(result.Content) == "" { result.Content = execErr.Error() } + if permissionEvent, ok := permissionEventFromError(execErr); ok { + if permissionEvent.decision == "ask" { + s.emit(ctx, EventPermissionRequest, input.RunID, session.ID, permissionEvent.toRequestPayload()) + } + s.emit(ctx, EventPermissionResolved, input.RunID, session.ID, permissionEvent.toResolvedPayload()) + } toolMessage := provider.Message{ Role: provider.RoleTool, @@ -563,6 +569,81 @@ func (s *Service) isRunCanceled(err error) bool { return errors.Is(err, context.Canceled) } +type permissionEventView struct { + toolName string + actionType string + operation string + targetType string + target string + decision string + reason string + ruleID string + resolvedAs string +} + +// permissionEventFromError 从工具权限错误中提取可用于 runtime 事件的结构化信息。 +func permissionEventFromError(err error) (permissionEventView, bool) { + var permissionErr *tools.PermissionDecisionError + if !errors.As(err, &permissionErr) { + return permissionEventView{}, false + } + + action := permissionErr.Action() + decision := strings.TrimSpace(permissionErr.Decision()) + reason := strings.TrimSpace(permissionErr.Reason()) + if reason == "" { + reason = strings.TrimSpace(err.Error()) + } + + resolvedAs := "denied" + if strings.EqualFold(decision, "ask") { + resolvedAs = "rejected" + } + + return permissionEventView{ + toolName: action.Payload.ToolName, + actionType: string(action.Type), + operation: action.Payload.Operation, + targetType: string(action.Payload.TargetType), + target: action.Payload.Target, + decision: decision, + reason: reason, + ruleID: strings.TrimSpace(permissionErr.RuleID()), + resolvedAs: resolvedAs, + }, true +} + +// toRequestPayload 将权限事件视图转换为 permission_request 载荷。 +func (v permissionEventView) toRequestPayload() PermissionRequestPayload { + return PermissionRequestPayload{ + ToolName: v.toolName, + ActionType: v.actionType, + Operation: v.operation, + TargetType: v.targetType, + Target: v.target, + Decision: v.decision, + Reason: v.reason, + RuleID: v.ruleID, + RememberScope: "", + } +} + +// toResolvedPayload 将权限事件视图转换为 permission_resolved 载荷。 +func (v permissionEventView) toResolvedPayload() PermissionResolvedPayload { + return PermissionResolvedPayload{ + ToolName: v.toolName, + ActionType: v.actionType, + Operation: v.operation, + TargetType: v.targetType, + Target: v.target, + Decision: v.decision, + Reason: v.reason, + RuleID: v.ruleID, + RememberScope: "", + ResolvedAs: v.resolvedAs, + } +} + func effectiveSessionWorkdir(sessionWorkdir string, defaultWorkdir string) string { workdir := strings.TrimSpace(sessionWorkdir) if workdir != "" { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index e67038d4..67e4cb49 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -14,6 +14,7 @@ import ( agentcontext "neo-code/internal/context" contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" + "neo-code/internal/security" "neo-code/internal/tools" ) @@ -641,6 +642,182 @@ func TestServiceRunUsesToolManager(t *testing.T) { } } +func TestServiceRunEmitsPermissionRequestAndResolvedForAsk(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + tool := &stubTool{name: "webfetch", content: "should-not-run"} + registry.Register(tool) + + engine, err := security.NewStaticGateway(security.DecisionAllow, []security.Rule{ + { + ID: "ask-webfetch", + Type: security.ActionTypeRead, + Resource: "webfetch", + Decision: security.DecisionAsk, + Reason: "requires approval", + }, + }) + 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) + } + + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + {ID: "call-ask", Name: "webfetch", Arguments: `{"url":"https://example.com/private"}`}, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: provider.Message{Role: "assistant", Content: "done"}, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) + if err := service.Run(context.Background(), UserInput{RunID: "run-permission-ask", Content: "fetch private"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + if tool.callCount != 0 { + t.Fatalf("expected blocked tool not to execute, got %d", tool.callCount) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventPermissionRequest, + EventPermissionResolved, + EventToolResult, + EventAgentDone, + }) + assertNoEventType(t, events, EventError) + + var ( + requestPayload PermissionRequestPayload + resolvedPayload PermissionResolvedPayload + ) + for _, event := range events { + switch event.Type { + case EventPermissionRequest: + payload, ok := event.Payload.(PermissionRequestPayload) + if !ok { + t.Fatalf("expected PermissionRequestPayload, got %#v", event.Payload) + } + requestPayload = payload + case EventPermissionResolved: + payload, ok := event.Payload.(PermissionResolvedPayload) + if !ok { + t.Fatalf("expected PermissionResolvedPayload, got %#v", event.Payload) + } + resolvedPayload = payload + } + } + + if requestPayload.ToolName != "webfetch" || requestPayload.Decision != "ask" { + t.Fatalf("unexpected permission request payload: %+v", requestPayload) + } + if requestPayload.RuleID != "ask-webfetch" { + t.Fatalf("expected rule id ask-webfetch, got %+v", requestPayload) + } + if resolvedPayload.ToolName != "webfetch" || resolvedPayload.Decision != "ask" { + t.Fatalf("unexpected permission resolved payload: %+v", resolvedPayload) + } + if resolvedPayload.ResolvedAs != "rejected" { + t.Fatalf("expected resolved_as rejected, got %+v", resolvedPayload) + } +} + +func TestServiceRunEmitsPermissionResolvedForDeny(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + tool := &stubTool{name: "bash", content: "should-not-run"} + registry.Register(tool) + + engine, err := security.NewStaticGateway(security.DecisionAllow, []security.Rule{ + { + ID: "deny-bash", + Type: security.ActionTypeBash, + Resource: "bash", + Decision: security.DecisionDeny, + Reason: "bash denied", + }, + }) + 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) + } + + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + {ID: "call-deny", Name: "bash", Arguments: `{"command":"echo hi"}`}, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: provider.Message{Role: "assistant", Content: "done"}, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) + if err := service.Run(context.Background(), UserInput{RunID: "run-permission-deny", Content: "run bash"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + if tool.callCount != 0 { + t.Fatalf("expected blocked tool not to execute, got %d", tool.callCount) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventPermissionResolved, + EventToolResult, + EventAgentDone, + }) + assertNoEventType(t, events, EventPermissionRequest) + assertNoEventType(t, events, EventError) + + for _, event := range events { + if event.Type != EventPermissionResolved { + continue + } + payload, ok := event.Payload.(PermissionResolvedPayload) + if !ok { + t.Fatalf("expected PermissionResolvedPayload, got %#v", event.Payload) + } + if payload.ToolName != "bash" || payload.Decision != "deny" || payload.ResolvedAs != "denied" { + t.Fatalf("unexpected permission resolved payload: %+v", payload) + } + if payload.RuleID != "deny-bash" { + t.Fatalf("expected deny-bash rule id, got %+v", payload) + } + return + } + t.Fatalf("expected permission resolved event payload") +} + func TestServiceRunHandlesToolManagerSpecError(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() diff --git a/internal/tools/bash/executor.go b/internal/tools/bash/executor.go new file mode 100644 index 00000000..e8dc7247 --- /dev/null +++ b/internal/tools/bash/executor.go @@ -0,0 +1,153 @@ +package bash + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "neo-code/internal/security" + "neo-code/internal/tools" +) + +// SecurityExecutor is the bash execution boundary that enforces workspace and +// runtime safety checks before running a shell command. +type SecurityExecutor interface { + Execute(ctx context.Context, call tools.ToolCallInput, command string, requestedWorkdir string) (tools.ToolResult, error) +} + +type commandRunner interface { + CombinedOutput(ctx context.Context, binary string, args []string, workdir string) ([]byte, error) +} + +type execCommandRunner struct{} + +func (execCommandRunner) CombinedOutput( + ctx context.Context, + binary string, + args []string, + workdir string, +) ([]byte, error) { + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Dir = workdir + return cmd.CombinedOutput() +} + +type defaultSecurityExecutor struct { + root string + shell string + timeout time.Duration + runner commandRunner +} + +// NewDefaultSecurityExecutor returns the default secure bash executor. +func NewDefaultSecurityExecutor(root string, shell string, timeout time.Duration) SecurityExecutor { + return &defaultSecurityExecutor{ + root: root, + shell: shell, + timeout: timeout, + runner: execCommandRunner{}, + } +} + +func (e *defaultSecurityExecutor) Execute( + ctx context.Context, + call tools.ToolCallInput, + command string, + requestedWorkdir string, +) (tools.ToolResult, error) { + if strings.TrimSpace(command) == "" { + err := errors.New("bash: command is empty") + return tools.NewErrorResult("bash", tools.NormalizeErrorReason("bash", err), "", nil), err + } + + base := strings.TrimSpace(call.Workdir) + if base == "" { + base = e.root + } + _, workdir, err := tools.ResolveWorkspaceTarget( + call, + security.TargetTypeDirectory, + base, + requestedWorkdir, + resolveWorkdir, + ) + if err != nil { + return tools.NewErrorResult("bash", tools.NormalizeErrorReason("bash", err), "", nil), err + } + + runCtx, cancel := context.WithTimeout(ctx, e.timeout) + defer cancel() + + binary, args := shellCommand(e.shell, command) + output, runErr := e.runner.CombinedOutput(runCtx, binary, args, workdir) + content := string(output) + if runErr != nil { + result := tools.NewErrorResult( + "bash", + tools.NormalizeErrorReason("bash", runErr), + content, + map[string]any{"workdir": workdir}, + ) + result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes) + return result, runErr + } + + result := tools.ToolResult{ + Name: "bash", + Content: content, + Metadata: map[string]any{ + "workdir": workdir, + }, + } + result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes) + return result, nil +} + +func shellCommand(shell string, command string) (string, []string) { + switch strings.ToLower(strings.TrimSpace(shell)) { + case "powershell", "pwsh": + return "powershell", []string{"-NoProfile", "-Command", command} + case "bash": + return "bash", []string{"-lc", command} + case "sh": + return "sh", []string{"-lc", command} + } + + if runtime.GOOS == "windows" { + return "powershell", []string{"-NoProfile", "-Command", command} + } + return "sh", []string{"-lc", command} +} + +func resolveWorkdir(root string, requested string) (string, error) { + if strings.ContainsRune(root, '\x00') || strings.ContainsRune(requested, '\x00') { + return "", errors.New("bash: invalid path contains NUL") + } + base, err := filepath.Abs(root) + if err != nil { + return "", err + } + target := requested + if strings.TrimSpace(target) == "" { + target = base + } else if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + target, err = filepath.Abs(target) + if err != nil { + return "", err + } + rel, err := filepath.Rel(base, target) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errors.New("bash: workdir escapes workspace root") + } + return target, nil +} diff --git a/internal/tools/bash/executor_test.go b/internal/tools/bash/executor_test.go new file mode 100644 index 00000000..45071e3a --- /dev/null +++ b/internal/tools/bash/executor_test.go @@ -0,0 +1,203 @@ +package bash + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "neo-code/internal/tools" +) + +type stubRunner struct { + run func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error) +} + +func (r stubRunner) CombinedOutput( + ctx context.Context, + binary string, + args []string, + workdir string, +) ([]byte, error) { + if r.run != nil { + return r.run(ctx, binary, args, workdir) + } + return []byte("ok"), nil +} + +func TestDefaultSecurityExecutorExecute(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + executor := &defaultSecurityExecutor{ + root: workspace, + shell: defaultShell(), + timeout: 20 * time.Millisecond, + runner: stubRunner{}, + } + + tests := []struct { + name string + callWorkdir string + command string + requestedDir string + overrideRun func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error) + expectErr string + expectResult []string + expectMeta string + }{ + { + name: "rejects empty command", + command: "", + callWorkdir: workspace, + expectErr: "command is empty", + }, + { + name: "rejects escaped workdir", + command: "echo hi", + callWorkdir: workspace, + requestedDir: "..", + expectErr: "workdir escapes workspace root", + }, + { + name: "handles timeout from runner context", + command: "slow", + callWorkdir: workspace, + overrideRun: func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error) { + <-ctx.Done() + return nil, ctx.Err() + }, + expectErr: context.DeadlineExceeded.Error(), + expectResult: []string{"tool error", "tool: bash"}, + expectMeta: workspace, + }, + { + name: "applies output truncation on error details", + command: "boom", + callWorkdir: workspace, + overrideRun: func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error) { + return []byte(strings.Repeat("x", tools.DefaultOutputLimitBytes+100)), errors.New("exit status 1") + }, + expectErr: "exit status 1", + expectResult: []string{"...[truncated]"}, + expectMeta: workspace, + }, + { + name: "success returns output and metadata", + command: "ok", + callWorkdir: workspace, + overrideRun: func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error) { + return []byte("hello"), nil + }, + expectResult: []string{"hello"}, + expectMeta: workspace, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + runner := stubRunner{} + if tt.overrideRun != nil { + runner.run = tt.overrideRun + } + executor.runner = runner + + result, err := executor.Execute( + context.Background(), + tools.ToolCallInput{Workdir: tt.callWorkdir}, + tt.command, + tt.requestedDir, + ) + + if tt.expectErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, fragment := range tt.expectResult { + if !strings.Contains(result.Content, fragment) { + t.Fatalf("expected content containing %q, got %q", fragment, result.Content) + } + } + if tt.expectMeta != "" { + got, _ := result.Metadata["workdir"].(string) + if !strings.Contains(got, tt.expectMeta) { + t.Fatalf("expected workdir metadata containing %q, got %q", tt.expectMeta, got) + } + } + }) + } +} + +func TestShellCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shell string + binary string + }{ + {name: "powershell", shell: "powershell", binary: "powershell"}, + {name: "pwsh", shell: "pwsh", binary: "powershell"}, + {name: "bash", shell: "bash", binary: "bash"}, + {name: "sh", shell: "sh", binary: "sh"}, + {name: "fallback", shell: "unknown", binary: defaultShell()}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + binary, args := shellCommand(tt.shell, "echo hi") + if binary != tt.binary { + t.Fatalf("shellCommand(%q) binary=%q, want %q", tt.shell, binary, tt.binary) + } + if len(args) == 0 { + t.Fatalf("expected non-empty args") + } + }) + } +} + +func TestResolveWorkdir(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + tests := []struct { + name string + root string + requested string + expectErr string + }{ + {name: "uses root for empty requested", root: workspace, requested: ""}, + {name: "resolves relative path", root: workspace, requested: "sub"}, + {name: "rejects traversal", root: workspace, requested: "..", expectErr: "escapes workspace root"}, + {name: "rejects invalid root", root: string([]byte{0}), requested: "", expectErr: "invalid"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + target, err := resolveWorkdir(tt.root, tt.requested) + if tt.expectErr != "" { + if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.expectErr)) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.requested != "" && !filepath.IsAbs(target) { + t.Fatalf("expected absolute target, got %q", target) + } + }) + } +} diff --git a/internal/tools/bash/helpers_test.go b/internal/tools/bash/helpers_test.go index af6773ed..89b8be75 100644 --- a/internal/tools/bash/helpers_test.go +++ b/internal/tools/bash/helpers_test.go @@ -44,7 +44,8 @@ func TestToolHelpers(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - got := New(t.TempDir(), tt.shell, time.Second).shellArgs("echo hi") + binary, args := shellCommand(tt.shell, "echo hi") + got := append([]string{binary}, args...) if len(got) < len(tt.want) { t.Fatalf("expected shell args prefix %v, got %v", tt.want, got) } @@ -57,7 +58,8 @@ func TestToolHelpers(t *testing.T) { } t.Run("default shell args", func(t *testing.T) { - got := New(t.TempDir(), "unknown", time.Second).shellArgs("echo hi") + binary, args := shellCommand("unknown", "echo hi") + got := append([]string{binary}, args...) if goruntime.GOOS == "windows" { if got[0] != "powershell" { t.Fatalf("expected windows fallback to powershell, got %v", got) diff --git a/internal/tools/bash/security_integration_test.go b/internal/tools/bash/security_integration_test.go new file mode 100644 index 00000000..9b44f080 --- /dev/null +++ b/internal/tools/bash/security_integration_test.go @@ -0,0 +1,131 @@ +package bash + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "neo-code/internal/security" + "neo-code/internal/tools" +) + +type stubSecurityExecutor struct { + calls int + err error +} + +func (e *stubSecurityExecutor) Execute( + ctx context.Context, + call tools.ToolCallInput, + command string, + requestedWorkdir string, +) (tools.ToolResult, error) { + e.calls++ + if e.err != nil { + return tools.NewErrorResult("bash", tools.NormalizeErrorReason("bash", e.err), "", nil), e.err + } + return tools.ToolResult{ + Name: "bash", + Content: "executed", + Metadata: map[string]any{ + "command": command, + }, + }, nil +} + +func TestBashToolManagerPermissionDecisions(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + args, err := json.Marshal(map[string]string{ + "command": "echo hi", + }) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + + tests := []struct { + name string + rules []security.Rule + executorErr error + expectErr string + expectCalls int + expectBody string + }{ + { + name: "allow runs executor", + expectCalls: 1, + expectBody: "executed", + }, + { + name: "deny blocks before executor", + rules: []security.Rule{ + {ID: "deny-bash", Type: security.ActionTypeBash, Resource: "bash", Decision: security.DecisionDeny, Reason: "bash denied"}, + }, + expectErr: "bash denied", + expectCalls: 0, + expectBody: "reason: bash denied", + }, + { + name: "ask blocks before executor", + rules: []security.Rule{ + {ID: "ask-bash", Type: security.ActionTypeBash, Resource: "bash", Decision: security.DecisionAsk, Reason: "need approval"}, + }, + expectErr: "need approval", + expectCalls: 0, + expectBody: "reason: need approval", + }, + { + name: "executor error is returned", + executorErr: errors.New("bash: failed"), + expectErr: "failed", + expectCalls: 1, + expectBody: "reason: failed", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + executor := &stubSecurityExecutor{err: tt.executorErr} + tool := NewWithExecutor(workspace, defaultShell(), 2*time.Second, executor) + registry := tools.NewRegistry() + registry.Register(tool) + + engine, err := security.NewStaticGateway(security.DecisionAllow, tt.rules) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + manager, err := tools.NewManager(registry, engine, security.NewWorkspaceSandbox()) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + result, execErr := manager.Execute(context.Background(), tools.ToolCallInput{ + Name: "bash", + Arguments: args, + Workdir: workspace, + }) + + if tt.expectErr != "" { + if execErr == nil || !strings.Contains(execErr.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, execErr) + } + } else if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + + if executor.calls != tt.expectCalls { + t.Fatalf("expected executor calls %d, got %d", tt.expectCalls, executor.calls) + } + if tt.expectBody != "" && !strings.Contains(result.Content, tt.expectBody) { + t.Fatalf("expected result content containing %q, got %q", tt.expectBody, result.Content) + } + }) + } +} diff --git a/internal/tools/bash/tool.go b/internal/tools/bash/tool.go index 9facbb88..f2559d97 100644 --- a/internal/tools/bash/tool.go +++ b/internal/tools/bash/tool.go @@ -4,21 +4,16 @@ import ( "context" "encoding/json" "errors" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" "time" - "neo-code/internal/security" "neo-code/internal/tools" ) type Tool struct { - root string - shell string - timeout time.Duration + root string + shell string + timeout time.Duration + executor SecurityExecutor } type input struct { @@ -27,10 +22,25 @@ type input struct { } func New(root string, shell string, timeout time.Duration) *Tool { + executor := NewDefaultSecurityExecutor(root, shell, timeout) return &Tool{ - root: root, - shell: shell, - timeout: timeout, + root: root, + shell: shell, + timeout: timeout, + executor: executor, + } +} + +// NewWithExecutor creates a bash tool using an injected security executor. +func NewWithExecutor(root string, shell string, timeout time.Duration, executor SecurityExecutor) *Tool { + if executor == nil { + executor = NewDefaultSecurityExecutor(root, shell, timeout) + } + return &Tool{ + root: root, + shell: shell, + timeout: timeout, + executor: executor, } } @@ -64,94 +74,10 @@ func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.Too if err := json.Unmarshal(call.Arguments, &in); err != nil { return tools.NewErrorResult(t.Name(), "invalid arguments", err.Error(), nil), err } - if strings.TrimSpace(in.Command) == "" { - err := errors.New("bash: command is empty") + if t.executor == nil { + err := errors.New("bash: security executor is nil") return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } - base := strings.TrimSpace(call.Workdir) - if base == "" { - base = t.root - } - _, workdir, err := tools.ResolveWorkspaceTarget( - call, - security.TargetTypeDirectory, - base, - in.Workdir, - resolveWorkdir, - ) - if err != nil { - return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err - } - - runCtx, cancel := context.WithTimeout(ctx, t.timeout) - defer cancel() - - args := t.shellArgs(in.Command) - cmd := exec.CommandContext(runCtx, args[0], args[1:]...) - cmd.Dir = workdir - output, err := cmd.CombinedOutput() - - content := string(output) - if err != nil { - result := tools.NewErrorResult( - t.Name(), - tools.NormalizeErrorReason(t.Name(), err), - content, - map[string]any{"workdir": workdir}, - ) - result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes) - return result, err - } - - result := tools.ToolResult{ - Name: t.Name(), - Content: content, - Metadata: map[string]any{ - "workdir": workdir, - }, - } - result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes) - return result, nil -} - -func (t *Tool) shellArgs(command string) []string { - shell := strings.ToLower(strings.TrimSpace(t.shell)) - switch shell { - case "powershell", "pwsh": - return []string{"powershell", "-NoProfile", "-Command", command} - case "bash": - return []string{"bash", "-lc", command} - case "sh": - return []string{"sh", "-lc", command} - } - if runtime.GOOS == "windows" { - return []string{"powershell", "-NoProfile", "-Command", command} - } - return []string{"sh", "-lc", command} -} - -func resolveWorkdir(root string, requested string) (string, error) { - base, err := filepath.Abs(root) - if err != nil { - return "", err - } - target := requested - if strings.TrimSpace(target) == "" { - target = base - } else if !filepath.IsAbs(target) { - target = filepath.Join(base, target) - } - target, err = filepath.Abs(target) - if err != nil { - return "", err - } - rel, err := filepath.Rel(base, target) - if err != nil { - return "", err - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { - return "", errors.New("bash: workdir escapes workspace root") - } - return target, nil + return t.executor.Execute(ctx, call, in.Command, in.Workdir) } diff --git a/internal/tools/filesystem/glob.go b/internal/tools/filesystem/glob.go index 03574e80..fb5c1c74 100644 --- a/internal/tools/filesystem/glob.go +++ b/internal/tools/filesystem/glob.go @@ -72,6 +72,10 @@ func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool if err != nil { return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } + filter, err := newResultPathFilter(root) + if err != nil { + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err + } matcher, err := buildGlobMatcher(pattern) if err != nil { @@ -79,6 +83,9 @@ func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool } matches := make([]string, 0, 32) + matchedCount := 0 + filteredCount := 0 + filteredReasons := map[string]int{} err = filepath.WalkDir(searchRoot, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -98,7 +105,14 @@ func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool return nil } if matcher.MatchString(normalizeSlashPath(relativeToSearch)) { - matches = append(matches, normalizeSlashPath(toRelativePath(root, path))) + matchedCount++ + relativePath, reason, allowed := filter.evaluate(path) + if !allowed { + filteredCount++ + filteredReasons[reason]++ + return nil + } + matches = append(matches, relativePath) } return nil }) @@ -112,8 +126,12 @@ func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool Name: t.Name(), Content: "no matches", Metadata: map[string]any{ - "root": searchRoot, - "count": 0, + "root": searchRoot, + "count": 0, + "matched_count": matchedCount, + "filtered_count": filteredCount, + "returned_count": 0, + "filtered_reasons": filteredReasons, }, }, nil } @@ -122,8 +140,12 @@ func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool Name: t.Name(), Content: strings.Join(matches, "\n"), Metadata: map[string]any{ - "root": searchRoot, - "count": len(matches), + "root": searchRoot, + "count": len(matches), + "matched_count": matchedCount, + "filtered_count": filteredCount, + "returned_count": len(matches), + "filtered_reasons": filteredReasons, }, } result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes) diff --git a/internal/tools/filesystem/glob_test.go b/internal/tools/filesystem/glob_test.go index 5ac8b30d..aa23ddf1 100644 --- a/internal/tools/filesystem/glob_test.go +++ b/internal/tools/filesystem/glob_test.go @@ -3,6 +3,7 @@ package filesystem import ( "context" "encoding/json" + "os" "path/filepath" "strconv" "strings" @@ -181,3 +182,59 @@ func TestGlobToolErrorFormattingAndTruncation(t *testing.T) { }) } } + +func TestGlobToolFiltersSensitiveAndSymlinkEscapes(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + outside := t.TempDir() + mustWriteFile(t, filepath.Join(workspace, "safe.txt"), "ok") + mustWriteFile(t, filepath.Join(workspace, ".env"), "SECRET=1") + mustWriteFile(t, filepath.Join(workspace, ".git", "config"), "[core]\n") + mustWriteFile(t, filepath.Join(workspace, "cert.pem"), "pem") + mustWriteFile(t, filepath.Join(outside, "secret.txt"), "outside") + linkPath := filepath.Join(workspace, "linked.txt") + if err := os.Symlink(filepath.Join(outside, "secret.txt"), linkPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + tool := NewGlob(workspace) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: mustMarshalFSArgs(t, map[string]string{"pattern": "**/*"}), + Workdir: workspace, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + content := normalizeSlashPath(result.Content) + if !strings.Contains(content, "safe.txt") { + t.Fatalf("expected safe result to be returned, got %q", result.Content) + } + for _, blocked := range []string{".env", ".git/config", "cert.pem", "linked.txt"} { + if strings.Contains(content, blocked) { + t.Fatalf("expected %q to be filtered, got %q", blocked, result.Content) + } + } + + if got, ok := result.Metadata["matched_count"].(int); !ok || got < 4 { + t.Fatalf("expected matched_count >= 4, got %#v", result.Metadata["matched_count"]) + } + if got, ok := result.Metadata["filtered_count"].(int); !ok || got < 3 { + t.Fatalf("expected filtered_count >= 3, got %#v", result.Metadata["filtered_count"]) + } + if got, ok := result.Metadata["returned_count"].(int); !ok || got < 1 { + t.Fatalf("expected returned_count >= 1, got %#v", result.Metadata["returned_count"]) + } + reasons, ok := result.Metadata["filtered_reasons"].(map[string]int) + if !ok { + t.Fatalf("expected filtered_reasons map, got %#v", result.Metadata["filtered_reasons"]) + } + if reasons[filterReasonSensitivePath] < 2 { + t.Fatalf("expected sensitive_path reason count, got %#v", reasons) + } + if reasons[filterReasonSymlinkEscape] < 1 { + t.Fatalf("expected symlink_escape reason count, got %#v", reasons) + } +} diff --git a/internal/tools/filesystem/grep.go b/internal/tools/filesystem/grep.go index cd54adec..3c993016 100644 --- a/internal/tools/filesystem/grep.go +++ b/internal/tools/filesystem/grep.go @@ -80,6 +80,10 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool if err != nil { return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } + filter, err := newResultPathFilter(root) + if err != nil { + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err + } matcher, err := buildGrepMatcher(pattern, args.UseRegex) if err != nil { @@ -87,8 +91,11 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool } var ( - results []string - matchedFiles int + results []string + matchedFiles int + matchedCount int + filteredCount int + filteredReasons = map[string]int{} ) err = filepath.WalkDir(searchRoot, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { @@ -103,6 +110,12 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool if entry.IsDir() { return nil } + relativePath, reason, allowed := filter.evaluate(path) + if !allowed { + filteredCount++ + filteredReasons[reason]++ + return nil + } data, err := os.ReadFile(path) if err != nil { @@ -116,7 +129,8 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool continue } fileMatched = true - results = append(results, fmt.Sprintf("%s:%d: %s", toRelativePath(root, path), idx+1, strings.TrimRight(line, "\r"))) + matchedCount++ + results = append(results, fmt.Sprintf("%s:%d: %s", relativePath, idx+1, strings.TrimRight(line, "\r"))) if len(results) >= defaultGrepResultLimit { return errGrepResultLimitReached } @@ -135,9 +149,13 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool Name: t.Name(), Content: "no matches", Metadata: map[string]any{ - "root": searchRoot, - "matched_files": 0, - "matched_lines": 0, + "root": searchRoot, + "matched_files": 0, + "matched_lines": 0, + "matched_count": matchedCount, + "filtered_count": filteredCount, + "returned_count": 0, + "filtered_reasons": filteredReasons, }, }, nil } @@ -146,9 +164,13 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool Name: t.Name(), Content: strings.Join(results, "\n"), Metadata: map[string]any{ - "root": searchRoot, - "matched_files": matchedFiles, - "matched_lines": len(results), + "root": searchRoot, + "matched_files": matchedFiles, + "matched_lines": len(results), + "matched_count": matchedCount, + "filtered_count": filteredCount, + "returned_count": len(results), + "filtered_reasons": filteredReasons, }, } result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes) diff --git a/internal/tools/filesystem/grep_test.go b/internal/tools/filesystem/grep_test.go index 3eec737f..61397c48 100644 --- a/internal/tools/filesystem/grep_test.go +++ b/internal/tools/filesystem/grep_test.go @@ -3,6 +3,7 @@ package filesystem import ( "context" "encoding/json" + "os" "path/filepath" "strconv" "strings" @@ -180,3 +181,58 @@ func TestGrepToolErrorFormattingAndTruncation(t *testing.T) { }) } } + +func TestGrepToolFiltersSensitiveAndSymlinkEscapes(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + outside := t.TempDir() + mustWriteFile(t, filepath.Join(workspace, "safe.txt"), "needle\n") + mustWriteFile(t, filepath.Join(workspace, ".env"), "needle\n") + mustWriteFile(t, filepath.Join(workspace, ".git", "config"), "needle\n") + mustWriteFile(t, filepath.Join(workspace, "private.pem"), "needle\n") + mustWriteFile(t, filepath.Join(outside, "secret.txt"), "needle\n") + linkPath := filepath.Join(workspace, "linked.txt") + if err := os.Symlink(filepath.Join(outside, "secret.txt"), linkPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + tool := NewGrep(workspace) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: mustMarshalFSArgs(t, map[string]any{"pattern": "needle"}), + Workdir: workspace, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(normalizeSlashPath(result.Content), "safe.txt:1: needle") { + t.Fatalf("expected safe file match, got %q", result.Content) + } + for _, blocked := range []string{".env", ".git/config", "private.pem", "linked.txt"} { + if strings.Contains(result.Content, blocked) { + t.Fatalf("expected %q to be filtered, got %q", blocked, result.Content) + } + } + + if got, ok := result.Metadata["matched_count"].(int); !ok || got < 1 { + t.Fatalf("expected matched_count >= 1, got %#v", result.Metadata["matched_count"]) + } + if got, ok := result.Metadata["filtered_count"].(int); !ok || got < 3 { + t.Fatalf("expected filtered_count >= 3, got %#v", result.Metadata["filtered_count"]) + } + if got, ok := result.Metadata["returned_count"].(int); !ok || got < 1 { + t.Fatalf("expected returned_count >= 1, got %#v", result.Metadata["returned_count"]) + } + reasons, ok := result.Metadata["filtered_reasons"].(map[string]int) + if !ok { + t.Fatalf("expected filtered_reasons map, got %#v", result.Metadata["filtered_reasons"]) + } + if reasons[filterReasonSensitivePath] < 2 { + t.Fatalf("expected sensitive_path reason count, got %#v", reasons) + } + if reasons[filterReasonSymlinkEscape] < 1 { + t.Fatalf("expected symlink_escape reason count, got %#v", reasons) + } +} diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go index e27706e5..030b0b4b 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -69,6 +69,15 @@ func (t *ReadFileTool) Execute(ctx context.Context, input tools.ToolCallInput) ( if err != nil { return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } + filter, err := newResultPathFilter(base) + if err != nil { + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err + } + _, reason, allowed := filter.evaluate(target) + if !allowed { + err := errors.New(readFileToolName + ": blocked by security policy (" + reason + ")") + return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err + } data, err := os.ReadFile(target) if err != nil { diff --git a/internal/tools/filesystem/read_file_test.go b/internal/tools/filesystem/read_file_test.go index a124272a..739a5ebd 100644 --- a/internal/tools/filesystem/read_file_test.go +++ b/internal/tools/filesystem/read_file_test.go @@ -20,6 +20,9 @@ func TestReadFileToolExecute(t *testing.T) { if err := os.WriteFile(filepath.Join(workspace, "small.txt"), []byte("hello world"), 0o644); err != nil { t.Fatalf("write small file: %v", err) } + if err := os.WriteFile(filepath.Join(workspace, ".env"), []byte("API_KEY=test"), 0o644); err != nil { + t.Fatalf("write env file: %v", err) + } if err := os.MkdirAll(filepath.Join(workspace, "nested"), 0o755); err != nil { t.Fatalf("mkdir nested: %v", err) } @@ -56,6 +59,11 @@ func TestReadFileToolExecute(t *testing.T) { path: filepath.Join("..", "outside.txt"), expectErr: "path escapes workspace root", }, + { + name: "reject sensitive file", + path: ".env", + expectErr: "blocked by security policy (sensitive_path)", + }, } tool := New(workspace) diff --git a/internal/tools/filesystem/result_filter.go b/internal/tools/filesystem/result_filter.go new file mode 100644 index 00000000..9ebc84d7 --- /dev/null +++ b/internal/tools/filesystem/result_filter.go @@ -0,0 +1,84 @@ +package filesystem + +import ( + "path/filepath" + "strings" +) + +const ( + filterReasonOutsideWorkspace = "outside_workspace" + filterReasonSensitivePath = "sensitive_path" + filterReasonSymlinkEscape = "symlink_escape" +) + +// resultPathFilter 统一对文件路径做工作区边界、敏感路径与符号链接逃逸检查。 +type resultPathFilter struct { + root string +} + +// newResultPathFilter 基于工作区根目录创建结果过滤器。 +func newResultPathFilter(root string) (*resultPathFilter, error) { + absoluteRoot, err := filepath.Abs(strings.TrimSpace(root)) + if err != nil { + return nil, err + } + return &resultPathFilter{root: filepath.Clean(absoluteRoot)}, nil +} + +// evaluate 对候选路径执行统一安全检查,返回相对路径、拒绝原因与是否允许。 +func (f *resultPathFilter) evaluate(path string) (relative string, reason string, allowed bool) { + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", filterReasonOutsideWorkspace, false + } + absolutePath = filepath.Clean(absolutePath) + if !isPathWithinRoot(f.root, absolutePath) { + return "", filterReasonOutsideWorkspace, false + } + + relative = normalizeSlashPath(toRelativePath(f.root, absolutePath)) + if isSensitivePath(relative) { + return "", filterReasonSensitivePath, false + } + + resolvedPath, err := filepath.EvalSymlinks(absolutePath) + if err == nil { + resolvedPath, absErr := filepath.Abs(resolvedPath) + if absErr != nil || !isPathWithinRoot(f.root, resolvedPath) { + return "", filterReasonSymlinkEscape, false + } + } + + return relative, "", true +} + +// isPathWithinRoot 判断目标路径是否仍在工作区根目录之内。 +func isPathWithinRoot(root string, candidate string) bool { + rel, err := filepath.Rel(root, candidate) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +// isSensitivePath 判断相对路径是否命中默认敏感路径策略。 +func isSensitivePath(relative string) bool { + normalized := strings.TrimSpace(strings.ToLower(normalizeSlashPath(relative))) + if normalized == "" || normalized == "." { + return false + } + + if normalized == ".git" || strings.HasPrefix(normalized, ".git/") { + return true + } + + base := strings.ToLower(filepath.Base(normalized)) + if strings.HasPrefix(base, ".env") { + return true + } + if strings.HasSuffix(base, ".pem") { + return true + } + + return false +} diff --git a/internal/tools/filesystem/result_filter_test.go b/internal/tools/filesystem/result_filter_test.go new file mode 100644 index 00000000..7f16c4fb --- /dev/null +++ b/internal/tools/filesystem/result_filter_test.go @@ -0,0 +1,46 @@ +package filesystem + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResultPathFilterEvaluateReasons(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + outside := t.TempDir() + mustWriteFile(t, filepath.Join(workspace, "safe.txt"), "ok") + mustWriteFile(t, filepath.Join(workspace, ".env"), "secret") + mustWriteFile(t, filepath.Join(outside, "target.txt"), "outside") + linkPath := filepath.Join(workspace, "escape.txt") + if err := os.Symlink(filepath.Join(outside, "target.txt"), linkPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + filter, err := newResultPathFilter(workspace) + if err != nil { + t.Fatalf("create filter: %v", err) + } + + relative, reason, allowed := filter.evaluate(filepath.Join(workspace, "safe.txt")) + if !allowed || reason != "" || relative != "safe.txt" { + t.Fatalf("expected safe path allowed, got relative=%q reason=%q allowed=%v", relative, reason, allowed) + } + + _, reason, allowed = filter.evaluate(filepath.Join(workspace, ".env")) + if allowed || reason != filterReasonSensitivePath { + t.Fatalf("expected sensitive path rejection, got reason=%q allowed=%v", reason, allowed) + } + + _, reason, allowed = filter.evaluate(filepath.Join(outside, "target.txt")) + if allowed || reason != filterReasonOutsideWorkspace { + t.Fatalf("expected outside workspace rejection, got reason=%q allowed=%v", reason, allowed) + } + + _, reason, allowed = filter.evaluate(linkPath) + if allowed || reason != filterReasonSymlinkEscape { + t.Fatalf("expected symlink escape rejection, got reason=%q allowed=%v", reason, allowed) + } +} diff --git a/internal/tools/manager.go b/internal/tools/manager.go index a0f88b36..4c099c4c 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -49,6 +49,7 @@ type PermissionDecisionError struct { toolName string action security.Action reason string + ruleID string } // Error returns a stable error message for the blocked tool call. @@ -87,6 +88,30 @@ func (e *PermissionDecisionError) ToolName() string { return e.toolName } +// Action 返回触发权限决策时的结构化动作上下文。 +func (e *PermissionDecisionError) Action() security.Action { + if e == nil { + return security.Action{} + } + return e.action +} + +// Reason 返回权限网关给出的拒绝或审批原因。 +func (e *PermissionDecisionError) Reason() string { + if e == nil { + return "" + } + return strings.TrimSpace(e.reason) +} + +// RuleID 返回命中规则的标识,未命中时为空字符串。 +func (e *PermissionDecisionError) RuleID() string { + if e == nil { + return "" + } + return strings.TrimSpace(e.ruleID) +} + // DefaultManager routes tool calls through the permission engine, workspace // sandbox, and executor. type DefaultManager struct { @@ -184,11 +209,16 @@ func blockedToolResult(input ToolCallInput, decision security.CheckResult) ToolR } func permissionErrorFromDecision(decision security.CheckResult) error { + ruleID := "" + if decision.Rule != nil { + ruleID = decision.Rule.ID + } return &PermissionDecisionError{ decision: decision.Decision, toolName: decision.Action.Payload.ToolName, action: decision.Action, reason: decision.Reason, + ruleID: ruleID, } } diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index c33f405d..a4a2fe98 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -357,6 +357,7 @@ func TestPermissionDecisionError(t *testing.T) { }, }, reason: "approval required", + ruleID: "rule-ask-webfetch", } if !strings.Contains(err.Error(), "approval required") { t.Fatalf("expected reason in error, got %q", err.Error()) @@ -367,6 +368,15 @@ func TestPermissionDecisionError(t *testing.T) { if err.ToolName() != "webfetch" { t.Fatalf("expected tool name webfetch, got %q", err.ToolName()) } + if err.Reason() != "approval required" { + t.Fatalf("expected approval reason, got %q", err.Reason()) + } + if err.RuleID() != "rule-ask-webfetch" { + t.Fatalf("expected rule id rule-ask-webfetch, got %q", err.RuleID()) + } + if err.Action().Type != security.ActionTypeRead { + t.Fatalf("expected action type read, got %q", err.Action().Type) + } if errors.Is(err, context.Canceled) { t.Fatalf("permission error should not match unrelated errors") }