Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
94afafee45 fix(agent): show masked env keys to non-authorized users instead of hiding tab
Instead of completely hiding the Environment tab for non-owner/non-admin
users, show the variable keys with masked values (****) in a read-only
view. This lets members see which variables are configured without
exposing the actual values.

- Backend: mask values with "****" instead of nullifying custom_env
- Added custom_env_redacted boolean to API response
- Frontend: EnvTab supports readOnly mode with lock icon and muted styling
2026-04-15 13:01:11 +08:00
Jiang Bohan
6daa409127 fix(agent): restrict custom_env visibility to agent owner and workspace admin
Agent environment variables (custom_env) were visible to all workspace
members, exposing sensitive tokens. Now only the agent owner and
workspace owner/admin can view them — regular members receive the field
omitted (null) from API responses, and the frontend hides the
Environment tab accordingly.

Closes #1018
2026-04-15 12:54:22 +08:00
5 changed files with 84 additions and 1 deletions

View File

@@ -55,6 +55,7 @@ export const mockAgents: Agent[] = [
runtime_mode: "cloud",
runtime_config: {},
custom_env: {},
custom_env_redacted: false,
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,

View File

@@ -48,6 +48,7 @@ export interface Agent {
runtime_mode: AgentRuntimeMode;
runtime_config: Record<string, unknown>;
custom_env: Record<string, string>;
custom_env_redacted: boolean;
visibility: AgentVisibility;
status: AgentStatus;
max_concurrent_tasks: number;

View File

@@ -168,6 +168,7 @@ export function AgentDetail({
{activeTab === "env" && (
<EnvTab
agent={agent}
readOnly={agent.custom_env_redacted}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}

View File

@@ -8,6 +8,7 @@ import {
Trash2,
Eye,
EyeOff,
Lock,
} from "lucide-react";
import type { Agent } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
@@ -46,9 +47,11 @@ function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
export function EnvTab({
agent,
readOnly = false,
onSave,
}: {
agent: Agent;
readOnly?: boolean;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
@@ -111,6 +114,45 @@ export function EnvTab({
}
};
if (readOnly) {
return (
<div className="max-w-lg space-y-4">
<div>
<Label className="text-xs text-muted-foreground">
Environment Variables
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Injected into the agent process at launch. Values are hidden only the agent owner or workspace admin can view and edit them.
</p>
</div>
{envEntries.length > 0 ? (
<div className="space-y-2">
{envEntries.map((entry) => (
<div key={entry.id} className="flex items-center gap-2">
<Input
value={entry.key}
readOnly
className="w-[40%] font-mono text-xs bg-muted"
/>
<div className="relative flex-1">
<Input
type="password"
value="****"
readOnly
className="pr-8 font-mono text-xs bg-muted"
/>
<Lock className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">No environment variables configured.</p>
)}
</div>
);
}
return (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">

View File

@@ -24,6 +24,7 @@ type AgentResponse struct {
RuntimeMode string `json:"runtime_mode"`
RuntimeConfig any `json:"runtime_config"`
CustomEnv map[string]string `json:"custom_env"`
CustomEnvRedacted bool `json:"custom_env_redacted"`
Visibility string `json:"visibility"`
Status string `json:"status"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
@@ -142,9 +143,11 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
userID := requestUserID(r)
var agents []db.Agent
var err error
@@ -181,6 +184,10 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
if skills, ok := skillMap[resp.ID]; ok {
resp.Skills = skills
}
// Redact custom_env for users who are not the agent owner or workspace owner/admin.
if !canViewAgentEnv(a, userID, member.Role) {
redactEnv(&resp)
}
visible = append(visible, resp)
}
@@ -205,6 +212,15 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
resp.Skills[i] = skillToResponse(s)
}
}
// Redact custom_env for users who are not the agent owner or workspace owner/admin.
userID := requestUserID(r)
if member, ok := ctxMember(r.Context()); ok {
if !canViewAgentEnv(agent, userID, member.Role) {
redactEnv(&resp)
}
}
writeJSON(w, http.StatusOK, resp)
}
@@ -315,6 +331,28 @@ type UpdateAgentRequest struct {
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
}
// canViewAgentEnv checks whether the requesting user is allowed to see the
// agent's custom environment variables. Only the agent owner or workspace
// owner/admin may view them; for everyone else the field is redacted.
func canViewAgentEnv(agent db.Agent, userID string, memberRole string) bool {
if roleAllowed(memberRole, "owner", "admin") {
return true
}
return uuidToString(agent.OwnerID) == userID
}
// redactEnv masks custom_env values in the response when the caller is not
// authorised to view them. Keys are preserved so members can see which
// variables are configured; values are replaced with "****".
func redactEnv(resp *AgentResponse) {
masked := make(map[string]string, len(resp.CustomEnv))
for k := range resp.CustomEnv {
masked[k] = "****"
}
resp.CustomEnv = masked
resp.CustomEnvRedacted = true
}
// canManageAgent checks whether the current user can update or archive an agent.
// Only the agent owner or workspace owner/admin can manage any agent,
// regardless of whether it is public or private.