-
Notifications
You must be signed in to change notification settings - Fork 679
feat(controller): allow L2 humans to update worker skills within their teams #1212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import ( | |
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "strings" | ||
| "time" | ||
|
|
||
| v1beta1 "github.com/agentscope-ai/AgentTeams/agentteams-controller/api/v1beta1" | ||
|
|
@@ -213,6 +214,12 @@ func (h *ResourceHandler) UpdateWorker(w http.ResponseWriter, r *http.Request) { | |
| } | ||
|
|
||
| ctx := r.Context() | ||
| if caller := authpkg.CallerFromContext(ctx); caller != nil && caller.Role == authpkg.RoleHuman { | ||
| if status, msg := h.checkHumanWorkerUpdate(ctx, caller, name, &req); status != 0 { | ||
| httputil.WriteError(w, status, msg) | ||
| return | ||
| } | ||
| } | ||
| for attempt := 0; attempt < k8sUpdateMaxRetries; attempt++ { | ||
| var worker v1beta1.Worker | ||
| if err := h.client.Get(ctx, client.ObjectKey{Name: name, Namespace: h.namespace}, &worker); err != nil { | ||
|
|
@@ -247,6 +254,9 @@ func (h *ResourceHandler) UpdateWorker(w http.ResponseWriter, r *http.Request) { | |
| if req.Skills != nil { | ||
| worker.Spec.Skills = req.Skills | ||
| } | ||
| if req.RemoteSkills != nil { | ||
| worker.Spec.RemoteSkills = req.RemoteSkills | ||
| } | ||
| if req.McpServers != nil { | ||
| worker.Spec.McpServers = req.McpServers | ||
| } | ||
|
|
@@ -876,6 +886,95 @@ func (h *ResourceHandler) findTeamForMember(ctx context.Context, name string) (s | |
| return team.Name, true, nil | ||
| } | ||
|
|
||
| // checkHumanWorkerUpdate enforces the L2 human boundary on worker updates. | ||
| // The worker must be a member of one of the caller's accessibleTeams — | ||
| // standalone workers are hidden from L2 readers (ListWorkers), so they are | ||
| // hidden here as well (404 keeps the endpoint probe-resistant). The request | ||
| // may only touch the public-catalog skill assignment (skills). remoteSkills | ||
| // (arbitrary external registries with credential-bearing source URIs) and | ||
| // mcpServers (the gateway consumer key is injected into every entry, so an | ||
| // L2-controlled URL is a credential-exfiltration path) require an elevated | ||
| // capability pending the L2 permission design; everything else (model, | ||
| // image, identity, resources, ...) is the team owner's domain. | ||
| // TestL2WorkerUpdateFieldPolicyCoversAllRequestFields pins the policy so no | ||
| // field of UpdateWorkerRequest becomes L2-writable by omission. | ||
| // Returns (0, "") when the update is allowed. | ||
| func (h *ResourceHandler) checkHumanWorkerUpdate(ctx context.Context, caller *authpkg.CallerIdentity, name string, req *UpdateWorkerRequest) (int, string) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The field policy here is fail-open: it enumerates the forbidden fields, so any field added to |
||
| team, _, ok, err := findTeamMember(ctx, h.client, h.namespace, name) | ||
| if err != nil { | ||
| return http.StatusInternalServerError, "lookup worker team: " + err.Error() | ||
| } | ||
| if !ok { | ||
| return http.StatusNotFound, "worker: not found" | ||
| } | ||
| // Out-of-scope workers are hidden from L2 readers on the read path | ||
| // (GET → 404, LIST → filtered). The update path must not reopen that | ||
| // probe surface: a 403 here would let a scoped human enumerate workers | ||
| // it cannot see and learn which team owns them (W8). | ||
| if !caller.TeamMatches(team.Name) { | ||
| return http.StatusNotFound, "worker: not found" | ||
| } | ||
| var forbidden []string | ||
| if req.WorkerName != "" { | ||
| forbidden = append(forbidden, "workerName") | ||
| } | ||
| if req.Model != "" { | ||
| forbidden = append(forbidden, "model") | ||
| } | ||
| if req.ModelProvider != "" { | ||
| forbidden = append(forbidden, "modelProvider") | ||
| } | ||
| if req.Runtime != "" { | ||
| forbidden = append(forbidden, "runtime") | ||
| } | ||
| if req.Image != "" { | ||
| forbidden = append(forbidden, "image") | ||
| } | ||
| if req.Identity != "" { | ||
| forbidden = append(forbidden, "identity") | ||
| } | ||
| if req.Soul != "" { | ||
| forbidden = append(forbidden, "soul") | ||
| } | ||
| if req.Agents != "" { | ||
| forbidden = append(forbidden, "agents") | ||
| } | ||
| // Credential-bearing surfaces: remoteSkills (registry source URIs may | ||
| // embed tokens) and mcpServers (GenerateMcporterConfig injects the | ||
| // gateway bearer key into every entry, URL used verbatim — an | ||
| // attacker-controlled URL exfiltrates it). Elevated capability pending | ||
| // the L2 permission design. | ||
| if req.RemoteSkills != nil { | ||
| forbidden = append(forbidden, "remoteSkills") | ||
| } | ||
| if req.McpServers != nil { | ||
| forbidden = append(forbidden, "mcpServers") | ||
| } | ||
| if req.Package != "" { | ||
| forbidden = append(forbidden, "package") | ||
| } | ||
| if req.Expose != nil { | ||
| forbidden = append(forbidden, "expose") | ||
| } | ||
| if req.ChannelPolicy != nil { | ||
| forbidden = append(forbidden, "channelPolicy") | ||
| } | ||
| if req.Resources != nil { | ||
| forbidden = append(forbidden, "resources") | ||
| } | ||
| if req.ContainerManaged != nil { | ||
| forbidden = append(forbidden, "containerManaged") | ||
| } | ||
| if req.State != nil { | ||
| forbidden = append(forbidden, "state") | ||
| } | ||
| if len(forbidden) > 0 { | ||
| return http.StatusBadRequest, | ||
| "L2 humans may only update the skills field (public-catalog assignment); remoteSkills and mcpServers require an elevated capability; not allowed: " + strings.Join(forbidden, ", ") | ||
| } | ||
| return 0, "" | ||
| } | ||
|
|
||
| func (h *ResourceHandler) validateTeamWorkerMembers(ctx context.Context, teamName string, members []v1beta1.TeamWorkerRef) error { | ||
| seen := make(map[string]struct{}, len(members)) | ||
| leaders := 0 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
L2-controlled
remoteSkills/mcpServersreach the worker verbatim. Note thatagentconfig.GenerateMcporterConfig(internal/agentconfig/mcporter.go) uses each MCP server URL as-is and unconditionally injectsAuthorization: Bearer <gatewayKey>— the same consumer key used for LLM access. With this PR an L2 human can register an MCP entry pointing at an arbitrary external URL, so the worker would deliver the gateway consumer key to an attacker-controlled endpoint (credential exfiltration). This matches the maintainer's review: bearer attachment must be restricted to trusted gateway endpoints or moved to an explicitcredentialRef, and arbitrary remote skill registries should require an elevated (FullAccess-like) permission.