From ca430c56ca0fade381d111b1955781a7720510cc Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 2 May 2026 00:31:24 +0900 Subject: [PATCH] feat: harden operator-controlled agent commands MHS-18 adds server-side policy enforcement for operator-controlled agents, daemon prompt alignment, and regression tests for issue create/update, batch update, agent mentions, fallback comments, and actor resolution. --- server/internal/agentpolicy/policy.go | 82 ++++++++ server/internal/agentpolicy/policy_test.go | 71 +++++++ server/internal/daemon/daemon.go | 3 + server/internal/daemon/execenv/execenv.go | 1 + .../operator_controlled_policy_test.go | 75 +++++++ .../internal/daemon/execenv/runtime_config.go | 22 ++- server/internal/daemon/types.go | 17 +- server/internal/handler/agent.go | 17 +- .../internal/handler/agent_command_policy.go | 75 +++++++ .../handler/agent_command_policy_test.go | 187 ++++++++++++++++++ server/internal/handler/comment.go | 3 + server/internal/handler/daemon.go | 17 +- server/internal/handler/handler.go | 2 +- server/internal/handler/issue.go | 42 +++- server/internal/service/task.go | 24 ++- 15 files changed, 602 insertions(+), 36 deletions(-) create mode 100644 server/internal/agentpolicy/policy.go create mode 100644 server/internal/agentpolicy/policy_test.go create mode 100644 server/internal/daemon/execenv/operator_controlled_policy_test.go create mode 100644 server/internal/handler/agent_command_policy.go create mode 100644 server/internal/handler/agent_command_policy_test.go diff --git a/server/internal/agentpolicy/policy.go b/server/internal/agentpolicy/policy.go new file mode 100644 index 0000000000..37df8107bb --- /dev/null +++ b/server/internal/agentpolicy/policy.go @@ -0,0 +1,82 @@ +package agentpolicy + +import "encoding/json" + +const ( + ModeOperatorControlled = "operator_controlled" + + CommandIssueCreate = "issue.create" + CommandIssueUpdateStatus = "issue.update.status" + CommandIssueStatus = "issue.status" + CommandIssueUpdateAssignee = "issue.update.assignee" + CommandIssueAssign = "issue.assign" +) + +// Policy is the Multica command policy embedded in agent.runtime_config. +type Policy struct { + Mode string `json:"mode"` + DenyCommands []string `json:"deny_commands"` + DenyAgentMentions *bool `json:"deny_agent_mentions"` + AllowPlainComment *bool `json:"allow_comment_without_agent_mentions"` +} + +type runtimeConfig struct { + MulticaPolicy *Policy `json:"multica_policy"` +} + +// FromRuntimeConfig extracts the Multica command policy from agent.runtime_config. +// Invalid or empty JSON is treated as no policy so legacy agents keep working. +func FromRuntimeConfig(raw []byte) Policy { + if len(raw) == 0 { + return Policy{} + } + var cfg runtimeConfig + if err := json.Unmarshal(raw, &cfg); err != nil || cfg.MulticaPolicy == nil { + return Policy{} + } + return *cfg.MulticaPolicy +} + +func (p Policy) IsOperatorControlled() bool { + return p.Mode == ModeOperatorControlled +} + +func (p Policy) DeniesCommand(command string) bool { + for _, denied := range p.DenyCommands { + if denied == command { + return true + } + } + if !p.IsOperatorControlled() { + return false + } + switch command { + case CommandIssueCreate, + CommandIssueUpdateStatus, + CommandIssueStatus, + CommandIssueUpdateAssignee, + CommandIssueAssign: + return true + default: + return false + } +} + +func (p Policy) DeniesAnyCommand(commands ...string) bool { + for _, command := range commands { + if p.DeniesCommand(command) { + return true + } + } + return false +} + +func (p Policy) DeniesAgentMentionsByDefault() bool { + if p.IsOperatorControlled() { + return true + } + if p.DenyAgentMentions != nil { + return *p.DenyAgentMentions + } + return false +} diff --git a/server/internal/agentpolicy/policy_test.go b/server/internal/agentpolicy/policy_test.go new file mode 100644 index 0000000000..35cc8c4e7f --- /dev/null +++ b/server/internal/agentpolicy/policy_test.go @@ -0,0 +1,71 @@ +package agentpolicy + +import "testing" + +func TestOperatorControlledPolicyDeniesBaselineCommands(t *testing.T) { + policy := FromRuntimeConfig([]byte(`{ + "multica_policy": { + "mode": "operator_controlled" + } + }`)) + + for _, command := range []string{ + CommandIssueCreate, + CommandIssueUpdateStatus, + CommandIssueStatus, + CommandIssueUpdateAssignee, + CommandIssueAssign, + } { + if !policy.DeniesCommand(command) { + t.Fatalf("expected operator-controlled policy to deny %s", command) + } + } + + if !policy.DeniesAgentMentionsByDefault() { + t.Fatalf("expected operator-controlled policy to deny agent mentions by default") + } +} + +func TestDenyCommandsWorkWithoutOperatorControlledMode(t *testing.T) { + policy := FromRuntimeConfig([]byte(`{ + "multica_policy": { + "deny_commands": ["issue.create"], + "deny_agent_mentions": true + } + }`)) + + if !policy.DeniesCommand(CommandIssueCreate) { + t.Fatalf("expected explicit deny_commands to deny issue.create") + } + if policy.DeniesCommand(CommandIssueStatus) { + t.Fatalf("did not expect status to be denied without operator_controlled mode or explicit deny") + } + if !policy.DeniesAgentMentionsByDefault() { + t.Fatalf("expected explicit deny_agent_mentions to be honored") + } +} + +func TestEmptyOrInvalidRuntimeConfigHasNoPolicy(t *testing.T) { + for _, raw := range [][]byte{nil, []byte(`{}`), []byte(`not json`)} { + policy := FromRuntimeConfig(raw) + if policy.DeniesCommand(CommandIssueCreate) { + t.Fatalf("expected no command denial for raw=%q", string(raw)) + } + if policy.DeniesAgentMentionsByDefault() { + t.Fatalf("expected no agent mention denial for raw=%q", string(raw)) + } + } +} + +func TestOperatorControlledAlwaysDeniesAgentMentions(t *testing.T) { + policy := FromRuntimeConfig([]byte(`{ + "multica_policy": { + "mode": "operator_controlled", + "deny_agent_mentions": false + } + }`)) + + if !policy.DeniesAgentMentionsByDefault() { + t.Fatalf("expected operator-controlled policy to deny agent mentions even when deny_agent_mentions is false") + } +} diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 6228773a6b..394406596d 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -1224,11 +1224,13 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i var agentID string var skills []SkillData var instructions string + var runtimeConfig json.RawMessage if task.Agent != nil { agentID = task.Agent.ID agentName = task.Agent.Name skills = task.Agent.Skills instructions = task.Agent.Instructions + runtimeConfig = task.Agent.RuntimeConfig } // Prepare isolated execution environment. @@ -1240,6 +1242,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i AgentID: agentID, AgentName: agentName, AgentInstructions: instructions, + AgentRuntimeConfig: runtimeConfig, AgentSkills: convertSkillsForEnv(skills), Repos: convertReposForEnv(task.Repos), ProjectID: task.ProjectID, diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 4fb0e36080..c61ae63f34 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -47,6 +47,7 @@ type TaskContextForEnv struct { AgentID string // unique ID of the dispatched agent AgentName string AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md + AgentRuntimeConfig json.RawMessage AgentSkills []SkillContextForEnv Repos []RepoContextForEnv // workspace repos available for checkout ProjectID string // issue's project, when present diff --git a/server/internal/daemon/execenv/operator_controlled_policy_test.go b/server/internal/daemon/execenv/operator_controlled_policy_test.go new file mode 100644 index 0000000000..ab899dd152 --- /dev/null +++ b/server/internal/daemon/execenv/operator_controlled_policy_test.go @@ -0,0 +1,75 @@ +package execenv + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInjectRuntimeConfigOperatorControlledAssignmentOmitsStatusCommands(t *testing.T) { + t.Parallel() + dir := t.TempDir() + ctx := TaskContextForEnv{ + IssueID: "policy-issue-id", + AgentRuntimeConfig: []byte(`{ + "multica_policy": { + "mode": "operator_controlled", + "deny_agent_mentions": true + } + }`), + } + + if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil { + t.Fatalf("InjectRuntimeConfig: %v", err) + } + content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) + if err != nil { + t.Fatalf("read CLAUDE.md: %v", err) + } + s := string(content) + if !strings.Contains(s, "operator-controlled assignment") { + t.Fatalf("expected operator-controlled assignment instructions, got:\n%s", s) + } + for _, forbidden := range []string{ + "multica issue status policy-issue-id in_progress", + "multica issue status policy-issue-id in_review", + "multica issue status policy-issue-id blocked", + } { + if strings.Contains(s, forbidden) { + t.Fatalf("operator-controlled runtime config should not contain %q", forbidden) + } + } + if !strings.Contains(s, "Do not include any `mention://agent/...` links") { + t.Fatalf("expected agent mention guardrail in operator-controlled instructions") + } +} + +func TestInjectRuntimeConfigOperatorControlledCommentTriggerOmitsLifecycleInstructions(t *testing.T) { + t.Parallel() + dir := t.TempDir() + ctx := TaskContextForEnv{ + IssueID: "policy-issue-id", + TriggerCommentID: "trigger-comment-id", + AgentRuntimeConfig: []byte(`{ + "multica_policy": { + "mode": "operator_controlled" + } + }`), + } + + if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil { + t.Fatalf("InjectRuntimeConfig: %v", err) + } + content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) + if err != nil { + t.Fatalf("read CLAUDE.md: %v", err) + } + s := string(content) + if strings.Contains(s, "unless the comment explicitly asks for it") { + t.Fatalf("operator-controlled comment trigger should not allow lifecycle changes on request") + } + if !strings.Contains(s, "Do NOT change issue status, change assignee, create issues, or mention another agent") { + t.Fatalf("expected operator-controlled lifecycle and handoff guardrail in comment-triggered instructions") + } +} diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 4132ae7300..8243676fed 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/multica-ai/multica/server/internal/agentpolicy" ) // formatProjectResource renders a single resource as a human-readable bullet. @@ -76,6 +78,7 @@ func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error // about the Multica runtime environment and available CLI tools. func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { var b strings.Builder + agentPolicy := agentpolicy.FromRuntimeConfig(ctx.AgentRuntimeConfig) b.WriteString("# Multica Agent Runtime\n\n") b.WriteString("You are a coding agent in the Multica platform. Use the `multica` CLI to interact with the platform.\n\n") @@ -249,9 +252,24 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked — do NOT confuse it with previous comments\n", ctx.TriggerCommentID) b.WriteString("4. **Decide whether a reply is warranted.** If you produced actual work this turn (investigated, fixed, answered a real question), post the result via step 6 — that is a normal reply, not a noise comment. If the triggering comment was a pure acknowledgment / thanks / sign-off from another agent AND you produced no work this turn, do NOT post a reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is a valid and preferred way to end agent-to-agent conversations.\n") b.WriteString("5. If a reply IS warranted: do any requested work first, then **decide whether to include any `@mention` link.** The default is NO mention. Only mention when you are escalating to a human owner who is not yet involved, delegating a concrete new sub-task to another agent for the first time, or the user explicitly asked you to loop someone in. Never @mention the agent you are replying to as a thank-you or sign-off.\n") - b.WriteString("6. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered to the user. ") + b.WriteString("6. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered. ") b.WriteString(BuildCommentReplyInstructions(ctx.IssueID, ctx.TriggerCommentID)) - b.WriteString("7. Do NOT change the issue status unless the comment explicitly asks for it\n\n") + if agentPolicy.IsOperatorControlled() { + b.WriteString("7. Do NOT change issue status, change assignee, create issues, or mention another agent. A human operator owns lifecycle and handoff changes.\n\n") + } else { + b.WriteString("7. Do NOT change the issue status unless the comment explicitly asks for it\n\n") + } + } else if agentPolicy.IsOperatorControlled() { + // Operator-controlled assignment: the server enforces write policy for + // status/assignee/issue-create/agent-mention commands, so do not instruct + // the agent to run commands that will be rejected. + b.WriteString("**This is an operator-controlled assignment.** A human operator owns issue lifecycle changes.\n\n") + fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand your task\n", ctx.IssueID) + fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the full comment history — this is mandatory, not optional. Earlier comments often carry context the issue body lacks.\n", ctx.IssueID) + b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since ` to fetch only recent ones\n") + b.WriteString("3. Complete the requested work without changing issue status, changing assignee, creating issues, or mentioning another agent.\n") + fmt.Fprintf(&b, "4. **Post your final results as a plain comment — this step is mandatory**: `multica issue comment add %s --content \"...\"`. Do not include any `mention://agent/...` links.\n", ctx.IssueID) + b.WriteString("5. If blocked, post a plain comment explaining the blocker and wait for the human operator to change status or assignment.\n\n") } else { // Assignment-triggered: defer to agent Skills for workflow specifics. b.WriteString("You are responsible for managing the issue status throughout your work.\n\n") diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index 12b9160e40..5935ff7a16 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -62,14 +62,15 @@ type Task struct { // AgentData holds agent details returned by the claim endpoint. type AgentData struct { - ID string `json:"id"` - Name string `json:"name"` - Instructions string `json:"instructions"` - Skills []SkillData `json:"skills"` - CustomEnv map[string]string `json:"custom_env,omitempty"` - CustomArgs []string `json:"custom_args,omitempty"` - McpConfig json.RawMessage `json:"mcp_config,omitempty"` - Model string `json:"model,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Instructions string `json:"instructions"` + Skills []SkillData `json:"skills"` + RuntimeConfig json.RawMessage `json:"runtime_config,omitempty"` + CustomEnv map[string]string `json:"custom_env,omitempty"` + CustomArgs []string `json:"custom_args,omitempty"` + McpConfig json.RawMessage `json:"mcp_config,omitempty"` + Model string `json:"model,omitempty"` } // SkillData represents a structured skill for task execution. diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index b59dc8a628..7cd409c2ed 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -178,14 +178,15 @@ type AgentTaskResponse struct { // TaskAgentData holds agent info included in claim responses so the daemon // can set up the execution environment (branch naming, skill files, instructions). type TaskAgentData struct { - ID string `json:"id"` - Name string `json:"name"` - Instructions string `json:"instructions"` - Skills []service.AgentSkillData `json:"skills,omitempty"` - CustomEnv map[string]string `json:"custom_env,omitempty"` - CustomArgs []string `json:"custom_args,omitempty"` - McpConfig json.RawMessage `json:"mcp_config,omitempty"` - Model string `json:"model,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Instructions string `json:"instructions"` + Skills []service.AgentSkillData `json:"skills,omitempty"` + RuntimeConfig json.RawMessage `json:"runtime_config,omitempty"` + CustomEnv map[string]string `json:"custom_env,omitempty"` + CustomArgs []string `json:"custom_args,omitempty"` + McpConfig json.RawMessage `json:"mcp_config,omitempty"` + Model string `json:"model,omitempty"` } func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse { diff --git a/server/internal/handler/agent_command_policy.go b/server/internal/handler/agent_command_policy.go new file mode 100644 index 0000000000..370894025b --- /dev/null +++ b/server/internal/handler/agent_command_policy.go @@ -0,0 +1,75 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/multica-ai/multica/server/internal/agentpolicy" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +const ( + agentCommandIssueCreate = agentpolicy.CommandIssueCreate + agentCommandIssueUpdateStatus = agentpolicy.CommandIssueUpdateStatus + agentCommandIssueStatus = agentpolicy.CommandIssueStatus + agentCommandIssueUpdateAssignee = agentpolicy.CommandIssueUpdateAssignee + agentCommandIssueAssign = agentpolicy.CommandIssueAssign +) + +func (h *Handler) agentPolicyForActor(ctx context.Context, actorType, actorID, workspaceID string) (agentpolicy.Policy, error) { + if actorType != "agent" { + return agentpolicy.Policy{}, nil + } + agentUUID, err := util.ParseUUID(actorID) + if err != nil { + return agentpolicy.Policy{}, nil + } + wsUUID, err := util.ParseUUID(workspaceID) + if err != nil { + return agentpolicy.Policy{}, err + } + agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{ + ID: agentUUID, + WorkspaceID: wsUUID, + }) + if err != nil { + return agentpolicy.Policy{}, err + } + return agentpolicy.FromRuntimeConfig(agent.RuntimeConfig), nil +} + +func (h *Handler) denyAgentCommandsIfNeeded(w http.ResponseWriter, r *http.Request, workspaceID, actorType, actorID string, commands ...string) bool { + policy, err := h.agentPolicyForActor(r.Context(), actorType, actorID, workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate agent policy") + return true + } + if policy.DeniesAnyCommand(commands...) { + writeError(w, http.StatusForbidden, "agent policy forbids this command") + return true + } + return false +} + +func (h *Handler) denyAgentMentionIfNeeded(w http.ResponseWriter, r *http.Request, workspaceID, actorType, actorID, content string) bool { + policy, err := h.agentPolicyForActor(r.Context(), actorType, actorID, workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to evaluate agent policy") + return true + } + if policy.DeniesAgentMentionsByDefault() && containsAgentMention(content) { + writeError(w, http.StatusForbidden, "agent policy forbids mentioning agents") + return true + } + return false +} + +func containsAgentMention(content string) bool { + for _, mention := range util.ParseMentions(content) { + if mention.Type == "agent" { + return true + } + } + return false +} diff --git a/server/internal/handler/agent_command_policy_test.go b/server/internal/handler/agent_command_policy_test.go new file mode 100644 index 0000000000..8b3b23b33d --- /dev/null +++ b/server/internal/handler/agent_command_policy_test.go @@ -0,0 +1,187 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +const operatorControlledRuntimeConfig = `{ + "multica_policy": { + "mode": "operator_controlled", + "deny_commands": [ + "issue.create", + "issue.update.status", + "issue.status", + "issue.update.assignee", + "issue.assign" + ], + "deny_agent_mentions": true, + "allow_comment_without_agent_mentions": true + } +}` + +func createHandlerTestAgentWithRuntimeConfig(t *testing.T, name, runtimeConfig string) string { + t.Helper() + + var agentID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO agent ( + workspace_id, name, description, runtime_mode, runtime_config, + runtime_id, visibility, max_concurrent_tasks, owner_id, + instructions, custom_env, custom_args, mcp_config + ) + VALUES ($1, $2, '', 'cloud', $3::jsonb, $4, 'private', 1, $5, '', '{}'::jsonb, '[]'::jsonb, '{}'::jsonb) + RETURNING id + `, testWorkspaceID, name, runtimeConfig, handlerTestRuntimeID(t), testUserID).Scan(&agentID); err != nil { + t.Fatalf("failed to create policy test agent: %v", err) + } + + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID) + }) + return agentID +} + +func createAgentPolicyTestIssue(t *testing.T, title string) IssueResponse { + t.Helper() + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": title, + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var created IssueResponse + if err := json.NewDecoder(w.Body).Decode(&created); err != nil { + t.Fatalf("decode created issue: %v", err) + } + + t.Cleanup(func() { + cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil) + cleanupReq = withURLParam(cleanupReq, "id", created.ID) + testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq) + }) + return created +} + +func TestAgentCommandPolicyDeniesIssueCreate(t *testing.T) { + agentID := createHandlerTestAgentWithRuntimeConfig(t, "Policy Create Agent", operatorControlledRuntimeConfig) + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "agent policy should deny create", + }) + req.Header.Set("X-Agent-ID", agentID) + testHandler.CreateIssue(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("CreateIssue: expected 403, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAgentCommandPolicyDeniesStatusAndAssigneeUpdates(t *testing.T) { + agentID := createHandlerTestAgentWithRuntimeConfig(t, "Policy Update Agent", operatorControlledRuntimeConfig) + created := createAgentPolicyTestIssue(t, "agent policy update target") + + w := httptest.NewRecorder() + req := newRequest("PUT", "/api/issues/"+created.ID, map[string]any{ + "status": "done", + }) + req = withURLParam(req, "id", created.ID) + req.Header.Set("X-Agent-ID", agentID) + testHandler.UpdateIssue(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("UpdateIssue status: expected 403, got %d: %s", w.Code, w.Body.String()) + } + + var status string + if err := testPool.QueryRow(context.Background(), `SELECT status FROM issue WHERE id = $1`, created.ID).Scan(&status); err != nil { + t.Fatalf("query status: %v", err) + } + if status != "todo" { + t.Fatalf("expected issue status to remain todo, got %q", status) + } + + w = httptest.NewRecorder() + req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{ + "assignee_type": "agent", + "assignee_id": agentID, + }) + req = withURLParam(req, "id", created.ID) + req.Header.Set("X-Agent-ID", agentID) + testHandler.UpdateIssue(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("UpdateIssue assignee: expected 403, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAgentCommandPolicyDeniesAgentMentionsButAllowsPlainComment(t *testing.T) { + agentID := createHandlerTestAgentWithRuntimeConfig(t, "Policy Comment Agent", operatorControlledRuntimeConfig) + created := createAgentPolicyTestIssue(t, "agent policy comment target") + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues/"+created.ID+"/comments", map[string]any{ + "content": "Loop [@Policy Comment Agent](mention://agent/" + agentID + ")", + }) + req = withURLParam(req, "id", created.ID) + req.Header.Set("X-Agent-ID", agentID) + testHandler.CreateComment(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("CreateComment with agent mention: expected 403, got %d: %s", w.Code, w.Body.String()) + } + + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/"+created.ID+"/comments", map[string]any{ + "content": "Plain implementation result with no agent mention.", + }) + req = withURLParam(req, "id", created.ID) + req.Header.Set("X-Agent-ID", agentID) + testHandler.CreateComment(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateComment plain: expected 201, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAgentCommandPolicyDeniesBatchStatusAndAssigneeUpdates(t *testing.T) { + agentID := createHandlerTestAgentWithRuntimeConfig(t, "Policy Batch Agent", operatorControlledRuntimeConfig) + created := createAgentPolicyTestIssue(t, "agent policy batch target") + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues/batch-update", map[string]any{ + "issue_ids": []string{created.ID}, + "updates": map[string]any{"status": "done"}, + }) + req.Header.Set("X-Agent-ID", agentID) + testHandler.BatchUpdateIssues(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("BatchUpdateIssues status: expected 403, got %d: %s", w.Code, w.Body.String()) + } + + var status string + if err := testPool.QueryRow(context.Background(), `SELECT status FROM issue WHERE id = $1`, created.ID).Scan(&status); err != nil { + t.Fatalf("query status: %v", err) + } + if status != "todo" { + t.Fatalf("expected issue status to remain todo, got %q", status) + } + + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/batch-update", map[string]any{ + "issue_ids": []string{created.ID}, + "updates": map[string]any{ + "assignee_type": "agent", + "assignee_id": agentID, + }, + }) + req.Header.Set("X-Agent-ID", agentID) + testHandler.BatchUpdateIssues(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("BatchUpdateIssues assignee: expected 403, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index a77b21a4f9..bf76b06725 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -224,6 +224,9 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { // Determine author identity: agent (via X-Agent-ID header) or member. authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID)) + if h.denyAgentMentionIfNeeded(w, r, uuidToString(issue.WorkspaceID), authorType, authorID, req.Content) { + return + } // Defense against resumed-session drift: when an agent posts from inside a // comment-triggered task AND the comment is being posted on that same diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 4f38601e2e..adaf91f60d 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -858,14 +858,15 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { mcpConfig = json.RawMessage(agent.McpConfig) } resp.Agent = &TaskAgentData{ - ID: uuidToString(agent.ID), - Name: agent.Name, - Instructions: agent.Instructions, - Skills: skills, - CustomEnv: customEnv, - CustomArgs: customArgs, - McpConfig: mcpConfig, - Model: agent.Model.String, + ID: uuidToString(agent.ID), + Name: agent.Name, + Instructions: agent.Instructions, + Skills: skills, + RuntimeConfig: json.RawMessage(agent.RuntimeConfig), + CustomEnv: customEnv, + CustomArgs: customArgs, + McpConfig: mcpConfig, + Model: agent.Model.String, } } diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index 178beb1760..3740be7167 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -250,7 +250,7 @@ func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (act return "member", userID } task, err := h.Queries.GetAgentTask(r.Context(), taskUUID) - if err != nil || uuidToString(task.AgentID) != agentID { + if err != nil || task.AgentID != agentUUID { slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID) return "member", userID } diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 7cfc412fe5..9c604655b4 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -1084,6 +1084,12 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { if !ok { return } + // Determine creator identity before any write-side effects so agent command + // policy can fail closed without incrementing issue counters. + creatorType, actualCreatorID := h.resolveActor(r, creatorID, workspaceID) + if h.denyAgentCommandsIfNeeded(w, r, workspaceID, creatorType, actualCreatorID, agentCommandIssueCreate) { + return + } status := req.Status if status == "" { @@ -1173,8 +1179,8 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { return } - // Determine creator identity: agent (via X-Agent-ID header) or member. - creatorType, actualCreatorID := h.resolveActor(r, creatorID, workspaceID) + // Creator identity was resolved before opening the transaction so policy + // checks cannot leave behind write-side effects. // Optional origin stamping (quick-create / autopilot). Only the // allowed origin types are accepted; anything else is rejected so a @@ -1325,6 +1331,19 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { var rawFields map[string]json.RawMessage json.Unmarshal(bodyBytes, &rawFields) + // Determine actor identity before mutation so agent command policy can block + // operator-controlled agents before status/assignee side effects happen. + actorType, actorID := h.resolveActor(r, userID, workspaceID) + _, touchesStatus := rawFields["status"] + _, touchesAssigneeType := rawFields["assignee_type"] + _, touchesAssigneeID := rawFields["assignee_id"] + if touchesStatus && h.denyAgentCommandsIfNeeded(w, r, workspaceID, actorType, actorID, agentCommandIssueUpdateStatus, agentCommandIssueStatus) { + return + } + if (touchesAssigneeType || touchesAssigneeID) && h.denyAgentCommandsIfNeeded(w, r, workspaceID, actorType, actorID, agentCommandIssueUpdateAssignee, agentCommandIssueAssign) { + return + } + // Pre-fill nullable fields (bare sqlc.narg) with current values params := db.UpdateIssueParams{ ID: prevIssue.ID, @@ -1436,9 +1455,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { // Validate the resulting (assignee_type, assignee_id) pair when the caller // touches either field. Existing data on the issue is left alone if the // caller is not changing it. - _, touchedType := rawFields["assignee_type"] - _, touchedID := rawFields["assignee_id"] - if touchedType || touchedID { + if touchesAssigneeType || touchesAssigneeID { if status, msg := h.validateAssigneePair(r.Context(), r, workspaceID, params.AssigneeType, params.AssigneeID); status != 0 { writeError(w, status, msg) return @@ -1466,8 +1483,8 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { dueDateChanged := prevDueDate != resp.DueDate && (prevDueDate == nil) != (resp.DueDate == nil) || (prevDueDate != nil && resp.DueDate != nil && *prevDueDate != *resp.DueDate) - // Determine actor identity: agent (via X-Agent-ID header) or member. - actorType, actorID := h.resolveActor(r, userID, workspaceID) + // Actor identity was resolved before mutation so policy and publish metadata + // use the same request actor. h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, map[string]any{ "issue": resp, @@ -1724,6 +1741,16 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { if !ok { return } + actorType, actorID := h.resolveActor(r, userID, workspaceID) + _, touchesStatus := rawUpdates["status"] + _, touchesAssigneeType := rawUpdates["assignee_type"] + _, touchesAssigneeID := rawUpdates["assignee_id"] + if touchesStatus && h.denyAgentCommandsIfNeeded(w, r, workspaceID, actorType, actorID, agentCommandIssueUpdateStatus, agentCommandIssueStatus) { + return + } + if (touchesAssigneeType || touchesAssigneeID) && h.denyAgentCommandsIfNeeded(w, r, workspaceID, actorType, actorID, agentCommandIssueUpdateAssignee, agentCommandIssueAssign) { + return + } updated := 0 for _, issueID := range req.IssueIDs { issueUUID, err := util.ParseUUID(issueID) @@ -1861,7 +1888,6 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) resp := issueToResponse(issue, prefix) - actorType, actorID := h.resolveActor(r, userID, workspaceID) assigneeChanged := (req.Updates.AssigneeType != nil || req.Updates.AssigneeID != nil) && (prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID)) diff --git a/server/internal/service/task.go b/server/internal/service/task.go index ff95b8b02e..848086d8ca 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -13,6 +13,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/agentpolicy" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/mention" "github.com/multica-ai/multica/server/internal/realtime" @@ -1358,11 +1359,23 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p if content == "" { return } - // Look up issue to get workspace ID for mention expansion and broadcasting. + // Look up issue to get workspace ID for policy evaluation, mention expansion, and broadcasting. issue, err := s.Queries.GetIssue(ctx, issueID) if err != nil { return } + agent, err := s.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{ + ID: agentID, + WorkspaceID: issue.WorkspaceID, + }) + if err != nil { + slog.Warn("failed to evaluate agent comment policy", "agent_id", util.UUIDToString(agentID), "issue_id", util.UUIDToString(issueID), "error", err) + return + } + if agentpolicy.FromRuntimeConfig(agent.RuntimeConfig).DeniesAgentMentionsByDefault() && containsAgentMention(content) { + slog.Warn("agent policy blocked synthesized agent mention comment", "agent_id", util.UUIDToString(agentID), "issue_id", util.UUIDToString(issueID)) + return + } // Resolve thread root: if parentID points to a reply (has its own parent), // use that parent instead so the comment lands in the top-level thread. if parentID.Valid { @@ -1406,6 +1419,15 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p }) } +func containsAgentMention(content string) bool { + for _, mention := range util.ParseMentions(content) { + if mention.Type == "agent" { + return true + } + } + return false +} + func issueToMap(issue db.Issue, issuePrefix string) map[string]any { return map[string]any{ "id": util.UUIDToString(issue.ID),