feat(security): add agent output redaction and private agent assignment enforcement

- Add redact package to detect and mask secrets (AWS keys, private keys,
  API tokens, bearer tokens, credentials, home paths) in agent output
  before posting as comments in TaskService
- Enforce agent visibility on issue assignment: private agents can only
  be assigned by their owner or workspace admins
- Add visibility picker (workspace/private) to CreateAgentDialog,
  default to private
- Grey out unassignable private agents in the assignee picker with
  lock icon indicator
This commit is contained in:
Jiayuan 2026-03-30 22:22:04 +08:00
parent 9c3ff52363
commit 0491350f1b
9 changed files with 351 additions and 24 deletions

View file

@ -25,10 +25,13 @@ import {
MoreHorizontal,
Play,
ChevronDown,
Globe,
Lock,
} from "lucide-react";
import type {
Agent,
AgentStatus,
AgentVisibility,
AgentTool,
AgentTrigger,
AgentTriggerType,
@ -126,6 +129,7 @@ function CreateAgentDialog({
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedRuntimeId, setSelectedRuntimeId] = useState(runtimes[0]?.id ?? "");
const [visibility, setVisibility] = useState<AgentVisibility>("private");
const [creating, setCreating] = useState(false);
const [runtimeOpen, setRuntimeOpen] = useState(false);
@ -145,6 +149,7 @@ function CreateAgentDialog({
name: name.trim(),
description: description.trim(),
runtime_id: selectedRuntime.id,
visibility,
triggers: [{ id: generateId(), type: "on_assign", enabled: true, config: {} }],
});
onClose();
@ -189,6 +194,42 @@ function CreateAgentDialog({
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Visibility</Label>
<div className="mt-1.5 flex gap-2">
<button
type="button"
onClick={() => setVisibility("workspace")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "workspace"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">All members can assign</div>
</div>
</button>
<button
type="button"
onClick={() => setVisibility("private")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "private"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">Only you can assign</div>
</div>
</button>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Runtime</Label>
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>

View file

@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { Bot, UserMinus } from "lucide-react";
import type { IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
import { Bot, Lock, UserMinus } from "lucide-react";
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import {
PropertyPicker,
@ -11,6 +12,13 @@ import {
PickerEmpty,
} from "./property-picker";
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
if (agent.visibility !== "private") return true;
if (agent.owner_id === userId) return true;
if (memberRole === "owner" || memberRole === "admin") return true;
return false;
}
export function AssigneePicker({
assigneeType,
assigneeId,
@ -24,10 +32,14 @@ export function AssigneePicker({
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
const query = filter.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
@ -117,24 +129,32 @@ export function AssigneePicker({
{/* Agents */}
{filteredAgents.length > 0 && (
<PickerSection label="Agents">
{filteredAgents.map((a) => (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
onClick={() => {
onUpdate({
assignee_type: "agent",
assignee_id: a.id,
});
setOpen(false);
}}
>
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
<span>{a.name}</span>
</PickerItem>
))}
{filteredAgents.map((a) => {
const allowed = canAssignAgent(a, user?.id, memberRole);
return (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
disabled={!allowed}
onClick={() => {
if (!allowed) return;
onUpdate({
assignee_type: "agent",
assignee_id: a.id,
});
setOpen(false);
}}
>
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
<Bot className="size-2.5" />
</div>
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
{a.visibility === "private" && (
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
)}
</PickerItem>
);
})}
</PickerSection>
)}

View file

@ -79,11 +79,13 @@ export function PropertyPicker({
export function PickerItem({
selected,
disabled,
onClick,
hoverClassName,
children,
}: {
selected: boolean;
disabled?: boolean;
onClick: () => void;
hoverClassName?: string;
children: React.ReactNode;
@ -91,8 +93,9 @@ export function PickerItem({
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${hoverClassName ?? "hover:bg-accent"} transition-colors`}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${disabled ? "opacity-50 cursor-not-allowed" : hoverClassName ?? "hover:bg-accent"} transition-colors`}
>
<span className="flex flex-1 items-center gap-2">{children}</span>
{selected && <Check className="h-3.5 w-3.5 text-muted-foreground" />}

View file

@ -246,7 +246,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
return
}
if req.Visibility == "" {
req.Visibility = "workspace"
req.Visibility = "private"
}
if req.MaxConcurrentTasks == 0 {
req.MaxConcurrentTasks = 6

View file

@ -182,6 +182,14 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
assigneeID = parseUUID(*req.AssigneeID)
}
// Enforce agent visibility: private agents can only be assigned by owner/admin.
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
if ok, msg := h.canAssignAgent(r.Context(), r, *req.AssigneeID, workspaceID); !ok {
writeError(w, http.StatusForbidden, msg)
return
}
}
var parentIssueID pgtype.UUID
if req.ParentIssueID != nil {
parentIssueID = parseUUID(*req.ParentIssueID)
@ -347,6 +355,14 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
}
// Enforce agent visibility: private agents can only be assigned by owner/admin.
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
if ok, msg := h.canAssignAgent(r.Context(), r, *req.AssigneeID, workspaceID); !ok {
writeError(w, http.StatusForbidden, msg)
return
}
}
issue, err := h.Queries.UpdateIssue(r.Context(), params)
if err != nil {
slog.Warn("update issue failed", append(logger.RequestAttrs(r), "error", err, "issue_id", id, "workspace_id", workspaceID)...)
@ -403,6 +419,34 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// canAssignAgent checks whether the requesting user is allowed to assign issues
// to the given agent. Private agents can only be assigned by their owner or
// workspace admins/owners.
func (h *Handler) canAssignAgent(ctx context.Context, r *http.Request, agentID, workspaceID string) (bool, string) {
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: parseUUID(agentID),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
return false, "agent not found"
}
if agent.Visibility != "private" {
return true, ""
}
userID := requestUserID(r)
if uuidToString(agent.OwnerID) == userID {
return true, ""
}
member, err := h.getWorkspaceMember(ctx, userID, workspaceID)
if err != nil {
return false, "cannot assign to private agent"
}
if roleAllowed(member.Role, "owner", "admin") {
return true, ""
}
return false, "cannot assign to private agent"
}
func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bool {
if issue.Status != "todo" {
return false
@ -580,6 +624,13 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
}
// Enforce agent visibility for batch assignment.
if req.Updates.AssigneeType != nil && *req.Updates.AssigneeType == "agent" && req.Updates.AssigneeID != nil {
if ok, _ := h.canAssignAgent(r.Context(), r, *req.Updates.AssigneeID, workspaceID); !ok {
continue
}
}
issue, err := h.Queries.UpdateIssue(r.Context(), params)
if err != nil {
slog.Warn("batch update issue failed", "issue_id", issueID, "error", err)

View file

@ -16,6 +16,7 @@ import (
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
"github.com/multica-ai/multica/server/pkg/redact"
)
type TaskService struct {
@ -187,7 +188,7 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
var payload protocol.TaskCompletedPayload
if err := json.Unmarshal(result, &payload); err == nil {
if payload.Output != "" {
s.createAgentComment(ctx, task.IssueID, task.AgentID, payload.Output, "comment")
s.createAgentComment(ctx, task.IssueID, task.AgentID, redact.Text(payload.Output), "comment")
}
}
@ -227,7 +228,7 @@ func (s *TaskService) FailTask(ctx context.Context, taskID pgtype.UUID, errMsg s
slog.Warn("task failed", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID), "error", errMsg)
if errMsg != "" {
s.createAgentComment(ctx, task.IssueID, task.AgentID, errMsg, "system")
s.createAgentComment(ctx, task.IssueID, task.AgentID, redact.Text(errMsg), "system")
}
// Reconcile agent status
s.ReconcileAgentStatus(ctx, task.AgentID)

View file

@ -218,6 +218,7 @@ func TestBuildEnvNilExtras(t *testing.T) {
}
}
func mustMarshal(t *testing.T, v any) json.RawMessage {
t.Helper()
data, err := json.Marshal(v)

View file

@ -0,0 +1,71 @@
// Package redact provides functions for detecting and masking secrets
// in agent output before it reaches the database or WebSocket broadcast.
package redact
import (
"os"
"os/user"
"regexp"
"strings"
)
// secretPattern pairs a compiled regex with its replacement text.
type secretPattern struct {
re *regexp.Regexp
replacement string
}
// Patterns are checked in order; first match wins per position.
var patterns = []secretPattern{
// AWS access key IDs (always start with AKIA)
{regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), "[REDACTED AWS KEY]"},
// AWS secret access keys (40 char base64-ish, preceded by a common separator)
{regexp.MustCompile(`(?i)(?:aws_secret_access_key|secret_?access_?key)\s*[=:]\s*[A-Za-z0-9/+=]{40}`), "[REDACTED AWS SECRET]"},
// PEM private keys (multi-line)
{regexp.MustCompile(`(?s)-----BEGIN[A-Z\s]*PRIVATE KEY-----.*?-----END[A-Z\s]*PRIVATE KEY-----`), "[REDACTED PRIVATE KEY]"},
// GitHub tokens (classic PAT, fine-grained, OAuth, etc.)
{regexp.MustCompile(`\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b`), "[REDACTED GITHUB TOKEN]"},
// OpenAI / Anthropic API keys
{regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}\b`), "[REDACTED API KEY]"},
// Slack tokens
{regexp.MustCompile(`\bxox[bporas]-[A-Za-z0-9\-]{10,}\b`), "[REDACTED SLACK TOKEN]"},
// Generic "Bearer <token>" in output
{regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b`), "Bearer [REDACTED]"},
// Generic key=value patterns for common secret env var names
{regexp.MustCompile(`(?i)(?:API_KEY|API_SECRET|SECRET_KEY|ACCESS_TOKEN|AUTH_TOKEN|PRIVATE_KEY|DATABASE_URL|DB_PASSWORD|REDIS_URL)\s*[=:]\s*\S+`), "[REDACTED CREDENTIAL]"},
}
// homeDir is resolved once at init for path redaction.
var homeDir string
var username string
func init() {
homeDir, _ = os.UserHomeDir()
if u, err := user.Current(); err == nil {
username = u.Username
}
}
// Text scans the input string for known secret patterns and replaces
// matches with safe placeholders. It also masks the local user's home
// directory path to prevent leaking the username.
func Text(s string) string {
for _, p := range patterns {
s = p.re.ReplaceAllString(s, p.replacement)
}
// Redact home directory paths (e.g. /Users/john/ → /Users/****/).
if homeDir != "" && username != "" {
masked := strings.Replace(homeDir, username, "****", 1)
s = strings.ReplaceAll(s, homeDir, masked)
}
return s
}

View file

@ -0,0 +1,139 @@
package redact
import (
"strings"
"testing"
)
func TestRedactAWSAccessKey(t *testing.T) {
t.Parallel()
input := "Found key AKIAIOSFODNN7EXAMPLE in config"
got := Text(input)
if strings.Contains(got, "AKIAIOSFODNN7EXAMPLE") {
t.Fatalf("AWS key not redacted: %s", got)
}
if !strings.Contains(got, "[REDACTED AWS KEY]") {
t.Fatalf("expected [REDACTED AWS KEY] placeholder, got: %s", got)
}
}
func TestRedactAWSSecretKey(t *testing.T) {
t.Parallel()
input := "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
got := Text(input)
if strings.Contains(got, "wJalrXUtnFEMI") {
t.Fatalf("AWS secret not redacted: %s", got)
}
}
func TestRedactPrivateKey(t *testing.T) {
t.Parallel()
input := "Here is the key:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----\nDone."
got := Text(input)
if strings.Contains(got, "MIIEow") {
t.Fatalf("private key content not redacted: %s", got)
}
if !strings.Contains(got, "[REDACTED PRIVATE KEY]") {
t.Fatalf("expected [REDACTED PRIVATE KEY] placeholder, got: %s", got)
}
}
func TestRedactGitHubToken(t *testing.T) {
t.Parallel()
input := "export GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"
got := Text(input)
if strings.Contains(got, "ghp_") {
t.Fatalf("GitHub token not redacted: %s", got)
}
}
func TestRedactOpenAIKey(t *testing.T) {
t.Parallel()
input := "OPENAI_API_KEY=sk-proj-abc123def456ghi789jkl012mno345"
got := Text(input)
if strings.Contains(got, "sk-proj-abc123") {
t.Fatalf("OpenAI key not redacted: %s", got)
}
}
func TestRedactSlackToken(t *testing.T) {
t.Parallel()
input := "token: xoxb-123456789012-1234567890123-AbCdEfGhIjKl"
got := Text(input)
if strings.Contains(got, "xoxb-") {
t.Fatalf("Slack token not redacted: %s", got)
}
}
func TestRedactBearerToken(t *testing.T) {
t.Parallel()
input := "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123"
got := Text(input)
if strings.Contains(got, "eyJhbGci") {
t.Fatalf("Bearer token not redacted: %s", got)
}
}
func TestRedactGenericCredentials(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
}{
{"API_KEY", "API_KEY=mysupersecretkey123"},
{"DATABASE_URL", "DATABASE_URL=postgres://user:pass@host/db"},
{"DB_PASSWORD", "DB_PASSWORD: hunter2"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Text(tc.input)
if !strings.Contains(got, "[REDACTED CREDENTIAL]") {
t.Fatalf("expected credential redaction for %s, got: %s", tc.name, got)
}
})
}
}
func TestRedactHomeDirectory(t *testing.T) {
t.Parallel()
if homeDir == "" || username == "" {
t.Skip("cannot determine home dir or username")
}
input := "Reading file at " + homeDir + "/Documents/secret.txt"
got := Text(input)
if strings.Contains(got, username) {
t.Fatalf("home directory username not redacted: %s", got)
}
if !strings.Contains(got, "****") {
t.Fatalf("expected **** in path, got: %s", got)
}
}
func TestNoFalsePositivesOnNormalText(t *testing.T) {
t.Parallel()
inputs := []string{
"This is a normal commit message about fixing a bug",
"The function returns skip-navigation as the class name",
"Created PR #42 for the authentication feature",
"Running tests in /tmp/test-workspace/project",
"The API endpoint /api/issues/123 was updated",
}
for _, input := range inputs {
got := Text(input)
if got != input {
t.Fatalf("false positive redaction:\n input: %s\n output: %s", input, got)
}
}
}
func TestRedactMultipleSecrets(t *testing.T) {
t.Parallel()
input := "Keys: AKIAIOSFODNN7EXAMPLE and ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"
got := Text(input)
if strings.Contains(got, "AKIAIOSFODNN7EXAMPLE") {
t.Fatal("AWS key not redacted in multi-secret text")
}
if strings.Contains(got, "ghp_") {
t.Fatal("GitHub token not redacted in multi-secret text")
}
}