Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions internal/runtime/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down
81 changes: 81 additions & 0 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 != "" {
Expand Down
177 changes: 177 additions & 0 deletions internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading