From 0491350f1b09be1037c79a220a8abad9e3b000d1 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Mon, 30 Mar 2026 22:22:04 +0800 Subject: [PATCH 1/2] 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 --- apps/web/app/(dashboard)/agents/page.tsx | 41 ++++++ .../components/pickers/assignee-picker.tsx | 60 +++++--- .../components/pickers/property-picker.tsx | 5 +- server/internal/handler/agent.go | 2 +- server/internal/handler/issue.go | 51 +++++++ server/internal/service/task.go | 5 +- server/pkg/agent/claude_test.go | 1 + server/pkg/redact/redact.go | 71 +++++++++ server/pkg/redact/redact_test.go | 139 ++++++++++++++++++ 9 files changed, 351 insertions(+), 24 deletions(-) create mode 100644 server/pkg/redact/redact.go create mode 100644 server/pkg/redact/redact_test.go diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 7fb11efd..902cbcf4 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -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("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({ /> +
+ +
+ + +
+
+
diff --git a/apps/web/features/issues/components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx index a41ba9cc..5ffd0d2c 100644 --- a/apps/web/features/issues/components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -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 && ( - {filteredAgents.map((a) => ( - { - onUpdate({ - assignee_type: "agent", - assignee_id: a.id, - }); - setOpen(false); - }} - > -
- -
- {a.name} -
- ))} + {filteredAgents.map((a) => { + const allowed = canAssignAgent(a, user?.id, memberRole); + return ( + { + if (!allowed) return; + onUpdate({ + assignee_type: "agent", + assignee_id: a.id, + }); + setOpen(false); + }} + > +
+ +
+ {a.name} + {a.visibility === "private" && ( + + )} +
+ ); + })}
)} diff --git a/apps/web/features/issues/components/pickers/property-picker.tsx b/apps/web/features/issues/components/pickers/property-picker.tsx index 2aa2048d..1329fa4d 100644 --- a/apps/web/features/issues/components/pickers/property-picker.tsx +++ b/apps/web/features/issues/components/pickers/property-picker.tsx @@ -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 (