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
16 changes: 11 additions & 5 deletions internal/runtime/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const (
type permissionExecutionInput struct {
RunID string
SessionID string
TaskID string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

permissionExecutionInput now carries TaskID/AgentID/Capability, but runtime call paths still create this struct without populating those fields (for example the normal tool execution path in executeOneToolCall). As a result, capability binding checks are effectively skipped in production flow because verifyCapabilityToken only compares task/agent when those action fields are non-empty. This makes the claimed runtime context propagation incomplete and weakens task/agent isolation guarantees.

AgentID string
Capability *security.CapabilityToken
State *runState
Call providertypes.ToolCall
Workdir string
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TaskID / AgentID / CapabilityToken are now forwarded here, but the main runtime execution path (executeOneToolCall in internal/runtime/toolexec.go) still constructs permissionExecutionInput without populating these fields. In practice, capability binding/signature checks won’t run for normal tool execution flow. Please wire these values from runtime state/subagent context into that caller path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里开始透传 TaskID/AgentID/CapabilityToken,但当前主链路调用点 internal/runtime/toolexec.go 构造 permissionExecutionInput 时并未填充这些字段。结果是正常工具执行路径下 capability 绑定/验签不会真正生效。建议在调用 executeToolCallWithPermission 时补齐上下文并加一条端到端回归测试覆盖该链路。

AgentID: input.AgentID,
CapabilityToken: input.Capability,
EmitChunk: func(chunk []byte) error {
if err := ctx.Err(); err != nil {
return err
Expand Down
158 changes: 158 additions & 0 deletions internal/runtime/permission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runtime
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -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)
}
}
6 changes: 6 additions & 0 deletions internal/runtime/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.Parts); err != nil {
return s.handleRunError(ctx, state.runID, state.session.ID, err)
Expand Down
12 changes: 8 additions & 4 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -41,10 +42,13 @@ type Runtime interface {

// UserInput 描述一次用户输入请求的最小运行参数。
type UserInput struct {
SessionID string
RunID string
Parts []providertypes.ContentPart
Workdir string
SessionID string
RunID string
Parts []providertypes.ContentPart
Workdir string
TaskID string
AgentID string
CapabilityToken *security.CapabilityToken
}

// ProviderFactory 负责基于运行期配置创建 provider 实例。
Expand Down
29 changes: 28 additions & 1 deletion internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand All @@ -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", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit file")}}); err != nil {
if err := service.Run(context.Background(), UserInput{
RunID: "run-tool-manager",
Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit file")},
TaskID: capability.TaskID,
AgentID: capability.AgentID,
CapabilityToken: capability,
}); err != nil {
t.Fatalf("Run() error = %v", err)
}

Expand All @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/runtime/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/runtime/toolexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading