From c015698619241b16da4d22b56eb135b6075a0342 Mon Sep 17 00:00:00 2001 From: LUOSENGWA Date: Tue, 1 Sep 2026 12:56:52 +0000 Subject: [PATCH] feat(controller): add team-scoped worker tool approval endpoints for L2 humans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxies a minimal read/write surface of each QwenPaw worker's /workspace/running-config API so L2 humans can manage the tool-execution security level (approval_level) of workers in their own teams: GET /api/v1/workers/{name}/approval -> {"approval_level": "AUTO"} PUT /api/v1/workers/{name}/approval <- {"approval_level": "STRICT"} - Levels: STRICT / SMART / AUTO (upstream default) / OFF — validated against the fixed four-token set before the worker is touched (the upstream model accepts any string; the proxy is the validation boundary). - Write scope: admin/manager any team; L2 human own teams only (W8: cross-team workers hide as 404); team leaders read-only (403 on PUT, same boundary as the knowledge base write API). - Safe write: the upstream PUT persists the full running-config object, so the proxy does GET -> change only approval_level -> PUT the whole object back; all other fields round trip verbatim. Upstream 409 passes through. - Embedded mode only (same addressing as the checkpoint proxy); kube mode 503; pre-2.x workers surface the upstream 404 (version gate). - Every successful change is audit-logged. Tests: 23 handler tests (scope/W8/leader-read-only/version-gate/ invalid-values/full-object round trip/409 passthrough/kube 502-503) + 3 authorizer cases pinning the W8 boundary. --- .../internal/auth/authorizer.go | 15 + .../internal/auth/authorizer_test.go | 31 + agentteams-controller/internal/server/http.go | 5 + .../internal/server/worker_approval.go | 403 ++++++++++++ .../internal/server/worker_approval_test.go | 573 ++++++++++++++++++ docs/usage/project-workflow-api.md | 48 ++ docs/zh-cn/usage/project-workflow-api.md | 32 + 7 files changed, 1107 insertions(+) create mode 100644 agentteams-controller/internal/server/worker_approval.go create mode 100644 agentteams-controller/internal/server/worker_approval_test.go diff --git a/agentteams-controller/internal/auth/authorizer.go b/agentteams-controller/internal/auth/authorizer.go index a124ff3bc..daed09bee 100644 --- a/agentteams-controller/internal/auth/authorizer.go +++ b/agentteams-controller/internal/auth/authorizer.go @@ -15,6 +15,7 @@ const ( ActionSleep Action = "sleep" ActionEnsureReady Action = "ensure-ready" ActionReady Action = "ready" + ActionWorkerApproval Action = "worker-approval" ActionSTS Action = "sts" ActionStatus Action = "status" ActionRefreshMatrixToken Action = "refresh-matrix-token" @@ -126,6 +127,20 @@ func (a *Authorizer) authorizeHuman(caller *CallerIdentity, req AuthzRequest) er // workspaceFileAccess flag and the knowledge base allowlist. return nil } + if req.Action == ActionWorkerApproval { + // L2 humans may change the tool-approval level of workers in + // their own teams. Like ActionGet this action is NOT rejected + // cross-team at the authorizer level: a 403 here would let a + // scoped caller probe which workers exist in other teams + // (W8 anti-probing). All real enforcement happens in the + // HANDLER, not in this authorizer/middleware: + // ApprovalHandler.approvalScope performs the worker→team + // resolution, hides cross-team and standalone workers as + // 404, and denies team leaders (read-only). The authorizer + // deliberately allows so the handler can hide with 404 + // instead of the authorizer answering 403. + return nil + } return deny(caller, req) default: diff --git a/agentteams-controller/internal/auth/authorizer_test.go b/agentteams-controller/internal/auth/authorizer_test.go index ca552defb..823a00342 100644 --- a/agentteams-controller/internal/auth/authorizer_test.go +++ b/agentteams-controller/internal/auth/authorizer_test.go @@ -299,3 +299,34 @@ func TestAuthorizer_WorkerProjectDenied(t *testing.T) { } } } + +func TestAuthorizer_WorkerApproval_W8Boundary(t *testing.T) { + az := NewAuthorizer() + human := &CallerIdentity{Role: RoleHuman, Username: "maizong", Teams: []string{"market-team"}} + + // W8: like ActionGet, the approval write is allowed at the authorizer + // even cross-team, so the handler can hide it as 404 — a 403 from the + // middleware would let a scoped caller probe which workers exist in + // other teams. The handler is the real boundary. + for _, req := range []AuthzRequest{ + {Action: ActionWorkerApproval, ResourceKind: "worker", ResourceName: "market-analyst", ResourceTeam: "market-team"}, + {Action: ActionWorkerApproval, ResourceKind: "worker", ResourceName: "biz-analyst", ResourceTeam: "biz-team"}, + } { + if err := az.Authorize(human, req); err != nil { + t.Errorf("L2 human approval write %s/%s should be allowed at the authorizer (handler hides cross-team as 404), got: %v", req.ResourceName, req.ResourceTeam, err) + } + } + + // The leader is read-only on the approval API: the write action is + // denied (their reads still go through ActionGet). + leader := &CallerIdentity{Role: RoleTeamLeader, Username: "market-analyst", Team: "market-team"} + if err := az.Authorize(leader, AuthzRequest{Action: ActionWorkerApproval, ResourceKind: "worker", ResourceName: "market-analyst", ResourceTeam: "market-team"}); err == nil { + t.Error("team-leader approval write should be denied (read-only)") + } + + // Workers never expose the approval action on their own resources. + worker := &CallerIdentity{Role: RoleWorker, Username: "market-analyst", WorkerName: "market-analyst"} + if err := az.Authorize(worker, AuthzRequest{Action: ActionWorkerApproval, ResourceKind: "worker", ResourceName: "market-analyst"}); err == nil { + t.Error("worker self approval write should be denied") + } +} diff --git a/agentteams-controller/internal/server/http.go b/agentteams-controller/internal/server/http.go index 6b5539b86..14076301f 100644 --- a/agentteams-controller/internal/server/http.go +++ b/agentteams-controller/internal/server/http.go @@ -132,6 +132,11 @@ func NewHTTPServer(addr string, deps ServerDeps) *HTTPServer { mux.Handle("GET /api/v1/workers/{name}/workspace-files/{sub}", mw.RequireAuthz(authpkg.ActionGet, "worker", nameFn)(http.HandlerFunc(wfh.proxyWorkspaceFiles))) mux.Handle("PUT /api/v1/workers/{name}/workspace-files/file-content", mw.RequireAuthz(authpkg.ActionWorkspaceFilesWrite, "worker", nameFn)(http.HandlerFunc(wfh.proxyWorkspaceFileWrite))) + // --- Worker tool approval (team-scoped; proxy to the worker's qwenpaw app) --- + ah := NewApprovalHandler(deps.Client, deps.Namespace, deps.KubeMode, deps.ContainerPrefix) + mux.Handle("GET /api/v1/workers/{name}/approval", mw.RequireAuthz(authpkg.ActionGet, "worker", nameFn)(http.HandlerFunc(ah.getWorkerApproval))) + mux.Handle("PUT /api/v1/workers/{name}/approval", mw.RequireAuthz(authpkg.ActionWorkerApproval, "worker", nameFn)(http.HandlerFunc(ah.updateWorkerApproval))) + // W-PR-2: human intervention + lifecycle (write endpoints). All writes go // through RequireAuthz ActionUpdate + "project" so the authorizer's // requireSameTeam (TeamLeader / L2) rejects cross-team writes at the code diff --git a/agentteams-controller/internal/server/worker_approval.go b/agentteams-controller/internal/server/worker_approval.go new file mode 100644 index 000000000..72af7ad2a --- /dev/null +++ b/agentteams-controller/internal/server/worker_approval.go @@ -0,0 +1,403 @@ +package server + +// Worker tool-approval control (GET/PUT /api/v1/workers/{name}/approval). +// +// Each QwenPaw worker's agent profile (agent.json) carries an +// `approval_level` — the tool-execution security level that decides which +// tool calls run automatically and which pause for a human approval: +// STRICT (every tool needs approval), SMART (low-risk tools auto-allowed), +// AUTO (only guarded tools — the upstream default), or OFF (guard +// disabled). The worker's qwenpaw app exposes it on +// GET/PUT /workspace/running-config: the field round-trips through the +// running-config object and is written back into the agent profile by the +// app itself. +// +// The Controller proxies a minimal surface so L2 humans can read and set +// the level of workers in their own teams (L1/admin/manager: any team; +// team leaders: read-only — consistent with the knowledge base write +// boundary): +// +// GET /api/v1/workers/{name}/approval -> {"approval_level": "AUTO"} +// PUT /api/v1/workers/{name}/approval <- {"approval_level": "STRICT"} +// +// Write safety: the upstream PUT expects the *full* running-config object +// (it persists whatever is sent, with a per-file path lock), so the proxy +// performs the safe-write pattern: GET the current object, change only +// `approval_level`, PUT the whole object back. Every other field round +// trips verbatim. The approval value is validated against the four known +// levels *before* the worker is touched (the upstream model accepts any +// string, so an unvalidated proxy would write garbage into agent.json). +// +// Embedded mode only (same addressing as the checkpoint and workspace-file +// proxies: effective container prefix + system-wins console port). Kube +// mode returns a uniform 503 before any worker lookup. Cross-team workers +// hide as 404 (W8 anti-probing, same as reads). A worker on a QwenPaw +// version without the running-config router surfaces the upstream 404 +// verbatim (version-gate contract). Every successful change is +// audit-logged. + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + v1beta1 "github.com/agentscope-ai/AgentTeams/agentteams-controller/api/v1beta1" + authpkg "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/auth" + "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/httputil" + "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/service" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + // approvalProxyTimeout bounds each upstream call. + approvalProxyTimeout = 5 * time.Second + // upstreamConfigMax caps the upstream running-config response. The + // safe-write pattern round-trips the FULL object, so a small hard cap + // would silently truncate agent.json on the way back; instead the cap + // sits well above any realistic running-config and exceeding it fails + // loudly (502) rather than truncating. + upstreamConfigMax = 1 << 20 // 1 MiB +) + +// fetchUpstreamConfig performs the context-aware GET of the worker's +// running-config and enforces upstreamConfigMax with a loud 502 instead of +// silent truncation. It writes its own error responses and returns +// (body, status, ok). +func (h *ApprovalHandler) fetchUpstreamConfig(w http.ResponseWriter, r *http.Request, baseURL string) ([]byte, int, bool) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, baseURL+"/workspace/running-config", nil) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, "build running-config request: "+err.Error()) + return nil, 0, false + } + resp, err := h.http.Do(req) + if err != nil { + httputil.WriteError(w, http.StatusBadGateway, "worker approval API unreachable: "+err.Error()) + return nil, 0, false + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamConfigMax+1)) + if err != nil { + httputil.WriteError(w, http.StatusBadGateway, "read running-config response: "+err.Error()) + return nil, 0, false + } + if len(body) > upstreamConfigMax { + httputil.WriteError(w, http.StatusBadGateway, "worker running-config exceeds the 1 MiB proxy cap") + return nil, 0, false + } + return body, resp.StatusCode, true +} + +// approvalLevels is the fixed set of tool-execution security levels the +// QwenPaw agent profile understands (see AgentProfileConfig.approval_level +// in the QwenPaw config module). The upstream running-config model does +// not validate the value, so the proxy is the validation boundary. +var approvalLevels = map[string]bool{ + "STRICT": true, // every tool call needs approval + "SMART": true, // low-risk tools auto-allowed + "AUTO": true, // only guarded tools (upstream default) + "OFF": true, // guard disabled +} + +// ApprovalHandler proxies the worker tool-approval endpoints. +type ApprovalHandler struct { + client client.Client + namespace string + kubeMode string + http *http.Client + containerPrefix string + // workerBaseURL resolves a worker name to its qwenpaw app base URL + // from the effective prefix and the worker's env. Injectable for + // tests. + workerBaseURL func(name string, env map[string]string) string +} + +// NewApprovalHandler creates the handler with the default embedded-mode +// worker address resolution (same chain as the checkpoint proxy). +func NewApprovalHandler(c client.Client, namespace, kubeMode, containerPrefix string) *ApprovalHandler { + h := &ApprovalHandler{ + client: c, + namespace: namespace, + kubeMode: kubeMode, + http: &http.Client{Timeout: approvalProxyTimeout}, + containerPrefix: containerPrefix, + } + h.workerBaseURL = h.defaultWorkerBaseURL + return h +} + +// defaultWorkerBaseURL resolves the worker's qwenpaw app base URL via the +// effective container prefix and the system-wins console port — identical +// to the checkpoint proxy, so the proxy always dials the port the +// container listens on. +func (h *ApprovalHandler) defaultWorkerBaseURL(name string, env map[string]string) string { + port := service.EffectiveWorkerConsolePort(env) + return fmt.Sprintf("http://%s%s:%s", h.containerPrefix, name, port) +} + +// approvalScope resolves the worker and its owning team, enforcing the +// W8 rule for scoped callers (cross-team workers hide as 404). It returns +// the team name and the resolved base URL for the upstream dial. +func (h *ApprovalHandler) approvalScope(w http.ResponseWriter, r *http.Request, name string) (string, bool) { + if h.kubeMode != "embedded" { + httputil.WriteError(w, http.StatusServiceUnavailable, "worker tool approval requires embedded mode") + return "", false + } + var worker v1beta1.Worker + if err := h.client.Get(r.Context(), client.ObjectKey{Name: name, Namespace: h.namespace}, &worker); err != nil { + if apierrors.IsNotFound(err) { + httputil.WriteError(w, http.StatusNotFound, "worker not found") + return "", false + } + writeK8sError(w, "get worker approval", err) + return "", false + } + // findTeamMember's second return value is the member (worker) name, + // not the team name — the scope check compares against the Team CR + // name (first return value), the same chain as GetWorker. + teamObj, _, _, err := findTeamMember(r.Context(), h.client, h.namespace, name) + if err != nil { + writeK8sError(w, "get worker approval", err) + return "", false + } + teamName := "" + if teamObj != nil { + teamName = teamObj.Name + } + if caller := authpkg.CallerFromContext(r.Context()); caller != nil && + (caller.Role == authpkg.RoleTeamLeader || caller.Role == authpkg.RoleHuman) && + !caller.TeamMatches(teamName) { + httputil.WriteError(w, http.StatusNotFound, "worker not found") + return "", false + } + return h.workerBaseURL(name, worker.Spec.Env), true +} + +// getWorkerApproval handles GET /api/v1/workers/{name}/approval. +func (h *ApprovalHandler) getWorkerApproval(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" || !workerNamePattern.MatchString(name) { + httputil.WriteError(w, http.StatusBadRequest, "worker name is required and must be a valid DNS label") + return + } + baseURL, ok := h.approvalScope(w, r, name) + if !ok { + return + } + body, status, ok := h.fetchUpstreamConfig(w, r, baseURL) + if !ok { + return + } + switch status { + case http.StatusOK: + var cfg map[string]any + if err := json.Unmarshal(body, &cfg); err != nil { + httputil.WriteError(w, http.StatusBadGateway, "worker returned an unparsable running config") + return + } + // The upstream GET always populates approval_level from the agent + // profile (defaulting to "AUTO" when the profile has none). A + // non-string value is a data-shape mismatch, not a silent fallback. + switch level := cfg["approval_level"].(type) { + case nil: + writeApprovalJSON(w, "AUTO") + case string: + if level == "" { + level = "AUTO" + } + writeApprovalJSON(w, level) + default: + httputil.WriteError(w, http.StatusBadGateway, "worker returned a non-string approval_level") + } + case http.StatusNotFound: + // Version gate: a QwenPaw version without the running-config + // router 404s here — passed through verbatim (status AND body) so + // clients can show "worker upgrade required" instead of "worker + // missing". + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + if len(body) > 0 { + _, _ = w.Write(body) + } + default: + httputil.WriteError(w, http.StatusBadGateway, fmt.Sprintf("worker approval API error (status %d): %s", status, string(body))) + } +} + +// updateWorkerApproval handles PUT /api/v1/workers/{name}/approval. +func (h *ApprovalHandler) updateWorkerApproval(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" || !workerNamePattern.MatchString(name) { + httputil.WriteError(w, http.StatusBadRequest, "worker name is required and must be a valid DNS label") + return + } + caller := authpkg.CallerFromContext(r.Context()) + if caller == nil { + httputil.WriteError(w, http.StatusForbidden, "caller identity required") + return + } + // Team leaders stay read-only on the approval API (their tool-policy + // surface is the chat/owner path, not REST) — same boundary as the + // knowledge base write API. + if caller.Role == authpkg.RoleTeamLeader { + httputil.WriteError(w, http.StatusForbidden, "team leaders can read tool approval but not change it") + return + } + baseURL, ok := h.approvalScope(w, r, name) + if !ok { + return + } + // Body: {"approval_level": ""} — the only accepted shape. + var payload struct { + ApprovalLevel string `json:"approval_level"` + } + raw, err := io.ReadAll(io.LimitReader(r.Body, 4096)) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, "read request body: "+err.Error()) + return + } + if err := json.Unmarshal(raw, &payload); err != nil { + httputil.WriteError(w, http.StatusBadRequest, "request body must be a JSON object with an approval_level string field") + return + } + if !approvalLevels[payload.ApprovalLevel] { + httputil.WriteError(w, http.StatusBadRequest, "approval_level must be one of STRICT, SMART, AUTO, or OFF") + return + } + // approval_level=OFF disables Tool Guard entirely (every tool call + // executes without approval) — that is a security-policy operation, + // not ordinary worker configuration. Default L2 humans may switch + // among the guarded levels (STRICT/SMART/AUTO) but cannot turn the + // guard off; OFF requires the elevated tool-approval capability that + // the L2 permission design (#1220) defines and admins grant + // explicitly. Admin/manager keep the full range (any-worker scope). + // TODO(#1220): when this hardcoded role check is replaced by the + // capability lookup, the capability design must EXPLICITLY + // enumerate which roles/capabilities may set OFF (an elevated + // tool-approval grant) — do not default OFF to "any non-human + // principal", which would silently let worker/leader roles + // disable Tool Guard. + if payload.ApprovalLevel == "OFF" && caller.Role == authpkg.RoleHuman { + httputil.WriteError(w, http.StatusForbidden, + "setting approval_level=OFF requires the elevated tool-approval capability (L2 permission design, #1220); use STRICT, SMART, or AUTO") + return + } + // Safe write: the upstream PUT persists the *full* running-config + // object, so fetch the current one first and change only this field. + // Known limitation (documented per review): the GET→modify→PUT + // sequence is not atomic. Two concurrent callers editing the same + // worker can interleave, and the later PUT wins (last-writer-wins). + // The upstream 409 covers path-lock/reindex contention only, not + // general concurrent modification, and the upstream API exposes no + // concurrency token, so optimistic locking is not available. + // Acceptable for this low-frequency configuration operation. + cfgBody, status, ok := h.fetchUpstreamConfig(w, r, baseURL) + if !ok { + return + } + switch status { + case http.StatusNotFound: + // Version gate: passed through verbatim (status AND body), same as + // the read path. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + if len(cfgBody) > 0 { + _, _ = w.Write(cfgBody) + } + return + case http.StatusOK: + // fall through + default: + httputil.WriteError(w, http.StatusBadGateway, fmt.Sprintf("worker approval API error (status %d): %s", status, string(cfgBody))) + return + } + var cfg map[string]any + if err := json.Unmarshal(cfgBody, &cfg); err != nil { + httputil.WriteError(w, http.StatusBadGateway, "worker returned an unparsable running config") + return + } + if cfg == nil { + // A JSON null body: writing the empty object back would wipe the + // worker's config — fail loudly instead of writing {}. + httputil.WriteError(w, http.StatusBadGateway, "worker returned an empty running-config object") + return + } + cfg["approval_level"] = payload.ApprovalLevel + body, err := json.Marshal(cfg) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, "marshal running-config update: "+err.Error()) + return + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodPut, baseURL+"/workspace/running-config", bytes.NewReader(body)) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, "build approval update request: "+err.Error()) + return + } + req.Header.Set("Content-Type", "application/json") + up, err := h.http.Do(req) + if err != nil { + httputil.WriteError(w, http.StatusBadGateway, "worker approval API unreachable: "+err.Error()) + return + } + defer up.Body.Close() + // Same cap discipline as fetchUpstreamConfig: read one byte beyond + // the limit so an oversized response is detected loudly (502) + // instead of silently truncated — a truncated upstream response + // would mask upstream anomalies and make the read/write paths + // inconsistent. + upBody, err := io.ReadAll(io.LimitReader(up.Body, upstreamConfigMax+1)) + if err != nil { + httputil.WriteError(w, http.StatusBadGateway, "read running-config update response: "+err.Error()) + return + } + if len(upBody) > upstreamConfigMax { + httputil.WriteError(w, http.StatusBadGateway, "worker running-config update response exceeds the 1 MiB proxy cap") + return + } + switch up.StatusCode { + case http.StatusOK: + var updated map[string]any + if json.Unmarshal(upBody, &updated) == nil { + if level, ok := updated["approval_level"].(string); ok && level != "" { + payload.ApprovalLevel = level + } + } + writeApprovalJSON(w, payload.ApprovalLevel) + if logger := log.FromContext(r.Context()).WithName("worker-approval"); logger.Enabled() { + logger.Info("worker tool approval level changed", + "worker", name, "approval_level", payload.ApprovalLevel, + "caller", caller.Username, "role", caller.Role) + } + case http.StatusConflict: + // Concurrent upstream config change (path lock / reindex in + // flight) — pass through (status AND body) so the client retries + // with a fresh GET. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write(upBody) + default: + httputil.WriteError(w, http.StatusBadGateway, fmt.Sprintf("worker approval API error (status %d): %s", up.StatusCode, string(upBody))) + } +} + +// writeApprovalJSON renders the minimal client-facing response: +// {"approval_level": ""}. +func writeApprovalJSON(w http.ResponseWriter, level string) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"approval_level":` + mustJSONString(level) + `}`)) +} + +// mustJSONString renders a string as a JSON string literal. The approval +// level is one of four fixed uppercase tokens, so escaping can never +// fail for the accepted inputs. +func mustJSONString(s string) string { + b, err := json.Marshal(s) + if err != nil { + return `"AUTO"` + } + return string(b) +} diff --git a/agentteams-controller/internal/server/worker_approval_test.go b/agentteams-controller/internal/server/worker_approval_test.go new file mode 100644 index 000000000..5f3abc9f7 --- /dev/null +++ b/agentteams-controller/internal/server/worker_approval_test.go @@ -0,0 +1,573 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/agentscope-ai/AgentTeams/agentteams-controller/api/v1beta1" + authpkg "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// newTestApprovalHandler builds an ApprovalHandler against a fake API and an +// optional upstream worker app (nil = the default dialer, which fails +// closed for the 502 test). +func newTestApprovalHandler(t *testing.T, kubeMode string, ts *httptest.Server, objs ...runtime.Object) *ApprovalHandler { + t.Helper() + k8s := fake.NewClientBuilder().WithScheme(newProjectTestScheme(t)).WithRuntimeObjects(objs...).Build() + h := NewApprovalHandler(k8s, "default", kubeMode, "agentteams-worker-") + if ts != nil { + h.workerBaseURL = func(string, map[string]string) string { return ts.URL } + } + return h +} + +// approvalTeam / approvalWorker / approvalTeamWithWorkers build the CR +// fixtures (same shape as the checkpoint test fixtures, distinct names to +// keep the two suites independent). +func approvalTeam(name string, workers ...string) *v1beta1.Team { + refs := make([]v1beta1.TeamWorkerRef, 0, len(workers)) + for _, w := range workers { + refs = append(refs, v1beta1.TeamWorkerRef{Name: w}) + } + return &v1beta1.Team{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: v1beta1.TeamSpec{TeamName: name, WorkerMembers: refs}, + } +} + +func approvalWorker(name string) *v1beta1.Worker { + return &v1beta1.Worker{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: v1beta1.WorkerSpec{WorkerName: name}, + } +} + +func approvalTeamWithWorkers(name string, workers ...string) []runtime.Object { + objs := make([]runtime.Object, 0, len(workers)+1) + objs = append(objs, approvalTeam(name, workers...)) + for _, w := range workers { + objs = append(objs, approvalWorker(w)) + } + return objs +} + +// approvalRequest builds a request with the {name} path value set. +func approvalRequest(method, name, body string) *http.Request { + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, "/api/v1/workers/"+name+"/approval", reader) + req.SetPathValue("name", name) + return req +} + +// approvalUpstream simulates the worker's /workspace/running-config: GET +// returns the full config, PUT validates the full-object round trip and +// echoes the updated config. +func approvalUpstream(t *testing.T, current string, gotPUT *[]byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/workspace/running-config") { + w.WriteHeader(http.StatusNotFound) + return + } + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"approval_level":` + jsonQuote(current) + `,"reme_light_memory_config":{"needs_reindex":false},"daily_memory_dir":"memory"}`)) + case http.MethodPut: + body, _ := io.ReadAll(r.Body) + *gotPUT = body + var cfg map[string]any + if err := json.Unmarshal(body, &cfg); err != nil || cfg["approval_level"] == nil { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"detail":"approval_level missing"}`)) + return + } + level, _ := cfg["approval_level"].(string) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"approval_level":` + jsonQuote(level) + `,"daily_memory_dir":"memory"}`)) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) +} + +func jsonQuote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +// --- GET --- + +func TestApprovalGet_InScopeL2Human(t *testing.T) { + up := approvalUpstream(t, "SMART", nil) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + req := withCaller(approvalRequest(http.MethodGet, "market-analyst", ""), + &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"market-team"}}) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s, want 200 for in-scope L2 human", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != `{"approval_level":"SMART"}` { + t.Fatalf("body=%s, want the minimal approval_level response", got) + } +} + +func TestApprovalGet_CrossTeamHidden(t *testing.T) { + up := approvalUpstream(t, "SMART", nil) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + req := withCaller(approvalRequest(http.MethodGet, "market-analyst", ""), + &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "sunzong", Teams: []string{"biz-team"}}) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d, want 404 for cross-team read (W8)", rec.Code) + } +} + +func TestApprovalGet_TeamLeaderInScope(t *testing.T) { + up := approvalUpstream(t, "STRICT", nil) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + req := withCaller(approvalRequest(http.MethodGet, "market-analyst", ""), + &authpkg.CallerIdentity{Role: authpkg.RoleTeamLeader, Username: "market-analyst", Team: "market-team"}) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200 for in-scope leader read", rec.Code) + } +} + +func TestApprovalGet_StandaloneHiddenFromScoped(t *testing.T) { + up := approvalUpstream(t, "AUTO", nil) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, approvalWorker("lone-worker")) + req := withCaller(approvalRequest(http.MethodGet, "lone-worker", ""), + &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"market-team"}}) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d, want 404 for standalone worker (scoped caller)", rec.Code) + } + // Admin still sees it. + rec2 := httptest.NewRecorder() + h.getWorkerApproval(rec2, adminCaller(approvalRequest(http.MethodGet, "lone-worker", ""))) + if rec2.Code != http.StatusOK { + t.Fatalf("admin status=%d, want 200", rec2.Code) + } +} + +func TestApprovalGet_DefaultLevelWhenMissing(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // No approval_level key at all in the config object. + _, _ = w.Write([]byte(`{"daily_memory_dir":"memory"}`)) + })) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + req := adminCaller(approvalRequest(http.MethodGet, "market-analyst", "")) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200", rec.Code) + } + if got := rec.Body.String(); got != `{"approval_level":"AUTO"}` { + t.Fatalf("body=%s, want the AUTO default", got) + } +} + +func TestApprovalGet_VersionGate404(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, adminCaller(approvalRequest(http.MethodGet, "market-analyst", ""))) + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d, want 404 passthrough (pre-2.x version gate)", rec.Code) + } +} + +func TestApprovalGet_Unreachable502(t *testing.T) { + h := newTestApprovalHandler(t, "embedded", nil, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, adminCaller(approvalRequest(http.MethodGet, "market-analyst", ""))) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status=%d, want 502 when the worker app is unreachable", rec.Code) + } +} + +func TestApprovalGet_KubeModeUnavailable(t *testing.T) { + h := newTestApprovalHandler(t, "kube", nil, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, adminCaller(approvalRequest(http.MethodGet, "market-analyst", ""))) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d, want 503 in kube mode", rec.Code) + } +} + +// --- PUT --- + +func TestApprovalPut_InScopeL2Human(t *testing.T) { + var putBody []byte + up := approvalUpstream(t, "AUTO", &putBody) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + req := withCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"STRICT"}`), + &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"market-team"}}) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s, want 200 for in-scope L2 human write", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != `{"approval_level":"STRICT"}` { + t.Fatalf("body=%s, want the updated level echoed", got) + } + // The upstream PUT must carry the FULL config (safe write) with only + // the approval level changed. + if len(putBody) == 0 { + t.Fatal("upstream PUT never called") + } + var cfg map[string]any + if err := json.Unmarshal(putBody, &cfg); err != nil { + t.Fatalf("upstream PUT body is not JSON: %v", err) + } + if cfg["approval_level"] != "STRICT" { + t.Fatalf("upstream approval_level=%v, want STRICT", cfg["approval_level"]) + } + if cfg["daily_memory_dir"] != "memory" { + t.Fatalf("unrelated field lost in the round trip: %v", cfg) + } +} + +func TestApprovalPut_LeaderDenied(t *testing.T) { + up := approvalUpstream(t, "AUTO", nil) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + req := withCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"OFF"}`), + &authpkg.CallerIdentity{Role: authpkg.RoleTeamLeader, Username: "market-analyst", Team: "market-team"}) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d, want 403 for in-scope leader (read-only)", rec.Code) + } +} + +func TestApprovalPut_CrossTeamHidden(t *testing.T) { + up := approvalUpstream(t, "AUTO", nil) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + req := withCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"OFF"}`), + &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "sunzong", Teams: []string{"biz-team"}}) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d, want 404 for cross-team write (W8)", rec.Code) + } +} + +func TestApprovalPut_AdminAllowed(t *testing.T) { + var putBody []byte + up := approvalUpstream(t, "AUTO", &putBody) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, adminCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"SMART"}`))) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200 for admin", rec.Code) + } + if len(putBody) == 0 { + t.Fatal("upstream PUT never called") + } +} + +func TestApprovalPut_ManagerAllowed(t *testing.T) { + // Managers keep the full level range (including OFF), same as + // admin — guards the role boundary documented in the design. + var putBody []byte + up := approvalUpstream(t, "AUTO", &putBody) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + req := withCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"OFF"}`), + &authpkg.CallerIdentity{Role: authpkg.RoleManager, Username: "manager"}) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200 for manager (full range incl. OFF)", rec.Code) + } + if len(putBody) == 0 { + t.Fatal("upstream PUT never called") + } +} + +func TestApprovalPut_InvalidLevelRejected(t *testing.T) { + up := approvalUpstream(t, "AUTO", nil) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + for _, level := range []string{"strict", "YOLO", "", "Auto"} { + req := withCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":`+jsonQuote(level)+`}`), + &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"market-team"}}) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("level=%q status=%d, want 400 (value not in the fixed set)", level, rec.Code) + } + } +} + +func TestApprovalPut_InvalidBody(t *testing.T) { + up := approvalUpstream(t, "AUTO", nil) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + for _, body := range []string{`[]`, `{"noLevel":1}`, `not-json`} { + req := adminCaller(approvalRequest(http.MethodPut, "market-analyst", body)) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("body=%q status=%d, want 400", body, rec.Code) + } + } +} + +func TestApprovalPut_ConflictPassthrough(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _, _ = w.Write([]byte(`{"approval_level":"AUTO"}`)) + default: + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"configuration changed concurrently"}`)) + } + })) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, adminCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"OFF"}`))) + if rec.Code != http.StatusConflict { + t.Fatalf("status=%d, want 409 passthrough", rec.Code) + } + if !strings.Contains(rec.Body.String(), "configuration changed concurrently") { + t.Fatalf("body=%s, want the upstream conflict detail", rec.Body.String()) + } +} + +func TestApprovalPut_VersionGate404(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, adminCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"OFF"}`))) + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d, want 404 passthrough (pre-2.x version gate)", rec.Code) + } +} + +func TestApprovalPut_KubeModeUnavailable(t *testing.T) { + h := newTestApprovalHandler(t, "kube", nil, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, adminCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"OFF"}`))) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d, want 503 in kube mode", rec.Code) + } +} + +// approval_level=OFF disables Tool Guard — a security-policy operation +// that default L2 humans must not perform (maintainer review 2026-09-03; +// elevated capability pending the #1220 design). Admin keeps the full +// range. +func TestApprovalPut_OffDeniedForL2Human(t *testing.T) { + var offPut []byte + up := approvalUpstream(t, "AUTO", &offPut) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + + req := withCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"OFF"}`), + &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"market-team"}}) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d, want 403 for L2 human setting OFF", rec.Code) + } + + // Admin (any-worker scope) may still set OFF. + var putBody []byte + up2 := approvalUpstream(t, "AUTO", &putBody) + defer up2.Close() + h2 := newTestApprovalHandler(t, "embedded", up2, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec2 := httptest.NewRecorder() + h2.updateWorkerApproval(rec2, adminCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"OFF"}`))) + if rec2.Code != http.StatusOK { + t.Fatalf("admin status=%d, want 200 (admin keeps the full level range)", rec2.Code) + } + if len(putBody) == 0 { + t.Fatal("upstream PUT never called for admin OFF") + } + + // Guarded levels remain L2-writable. + rec3 := httptest.NewRecorder() + h2.updateWorkerApproval(rec3, withCaller( + approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"STRICT"}`), + &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"market-team"}})) + if rec3.Code != http.StatusOK { + t.Fatalf("L2 guarded-level status=%d, want 200", rec3.Code) + } +} + +// --- upstream failure/shape coverage (bot review 2026-09-03) --- + +func TestApprovalGet_Upstream500(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"detail":"boom"}`)) + })) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, adminCaller(approvalRequest(http.MethodGet, "market-analyst", ""))) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status=%d, want 502 for upstream 500", rec.Code) + } +} + +func TestApprovalGet_NonStringLevel502(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"approval_level":3}`)) + } + })) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, adminCaller(approvalRequest(http.MethodGet, "market-analyst", ""))) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status=%d, want 502 for a non-string approval_level", rec.Code) + } +} + +func TestApprovalGet_VersionGateBodyPassthrough(t *testing.T) { + upBody := `{"detail":"running-config router not available on this QwenPaw version"}` + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(upBody)) + })) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.getWorkerApproval(rec, adminCaller(approvalRequest(http.MethodGet, "market-analyst", ""))) + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d, want 404 version gate", rec.Code) + } + if got := rec.Body.String(); got != upBody { + t.Fatalf("body=%s, want the upstream 404 body verbatim", got) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("content-type=%s, want application/json", ct) + } +} + +// A running-config larger than the old 4 KiB cap must round-trip the FULL +// object through the safe write (fields beyond the cutoff must survive). +func TestApprovalPut_RoundTripLargeConfig(t *testing.T) { + pad := strings.Repeat("x", 4500) // > old 4096 cap + cfg := `{"approval_level":"AUTO","large":"` + pad + `","daily_memory_dir":"memory"}` + var putBody []byte + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _, _ = w.Write([]byte(cfg)) + case http.MethodPut: + body, _ := io.ReadAll(r.Body) + putBody = body + var c map[string]any + if err := json.Unmarshal(body, &c); err != nil || c["approval_level"] == nil { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + _, _ = w.Write([]byte(`{"approval_level":"STRICT"}`)) + } + })) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + req := withCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"STRICT"}`), + &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"market-team"}}) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s, want 200", rec.Code, rec.Body.String()) + } + var c map[string]any + if err := json.Unmarshal(putBody, &c); err != nil { + t.Fatalf("upstream PUT body is not JSON: %v", err) + } + if got, _ := c["large"].(string); got != pad { + t.Fatalf("large field truncated or lost in the round trip (len=%d, want %d)", len(got), len(pad)) + } + if c["approval_level"] != "STRICT" { + t.Fatalf("approval_level=%v, want STRICT", c["approval_level"]) + } +} + +// A JSON null running-config must be rejected, not written back as {} +// (which would wipe the worker's config) and not panic. +func TestApprovalPut_NilConfigRejected(t *testing.T) { + putCalls := 0 + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _, _ = w.Write([]byte(`null`)) + case http.MethodPut: + putCalls++ + } + })) + defer up.Close() + h := newTestApprovalHandler(t, "embedded", up, + approvalTeamWithWorkers("market-team", "market-analyst")...) + rec := httptest.NewRecorder() + h.updateWorkerApproval(rec, adminCaller(approvalRequest(http.MethodPut, "market-analyst", `{"approval_level":"STRICT"}`))) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status=%d, want 502 for a null running-config", rec.Code) + } + if putCalls != 0 { + t.Fatalf("upstream PUT called %d times, want 0 (no write-back of an empty object)", putCalls) + } +} diff --git a/docs/usage/project-workflow-api.md b/docs/usage/project-workflow-api.md index 454cb45d8..d3505e052 100644 --- a/docs/usage/project-workflow-api.md +++ b/docs/usage/project-workflow-api.md @@ -834,6 +834,48 @@ distilled knowledge tree `digest/`. proxy. The multipart `file-upload` endpoint and the rest of the workspace API surface remain unreachable. +## Worker tool-approval endpoints + +Each QwenPaw worker's agent profile carries a tool-execution security level +(`approval_level` in the worker's `agent.json`) that decides which tool calls +run automatically and which pause for a human approval. The Controller +proxies a minimal read/write surface of the worker's +`/workspace/running-config` API so L2 humans can manage this level for +workers in their own teams: + +| Endpoint | Meaning | +|:--|:--| +| `GET /api/v1/workers/{name}/approval` | Current level: `{"approval_level": "AUTO"}`. | +| `PUT /api/v1/workers/{name}/approval` | Set the level. Body: `{"approval_level": "STRICT"}`. | + +- **Levels**: `STRICT` (every tool call needs approval) / `SMART` (low-risk + tools auto-allowed) / `AUTO` (only guarded tools — the upstream default) / + `OFF` (guard disabled). Any other value is rejected with `400` before the + worker is touched (the upstream model does not validate the value — the + proxy is the validation boundary). +- **OFF is elevated**: `approval_level=OFF` disables Tool Guard entirely, so + it is a security-policy operation rather than ordinary configuration. + Default L2 humans get `403` on `OFF` — they may switch among the guarded + levels (`STRICT`/`SMART`/`AUTO`) only. `OFF` requires the elevated + tool-approval capability that the L2 permission design (#1220) defines and + admins grant explicitly; admin/manager keep the full range until that + capability model lands. +- **Write scope**: `PUT` is allowed for admin/manager (any team); an L2 human + may set only workers in their own teams — cross-team workers hide as `404` + (existence is not probeable). Team leaders stay read-only (`403` on `PUT`, + the same boundary as the knowledge base write API). +- **Safe write**: the upstream `PUT /workspace/running-config` persists the + *full* running-config object, so the proxy performs GET → change only + `approval_level` → PUT the whole object back; every other field round trips + verbatim. An upstream `409` (concurrent config change) is passed through so + clients retry with a fresh `GET`. +- **Embedded mode only**: same worker addressing as the checkpoint proxy + (effective container prefix + system-wins console port). Kube mode returns + `503`. +- **Degradation**: a worker on a QwenPaw version without the running-config + router surfaces the upstream `404` verbatim (version gate). +- Every successful change is audit-logged (worker, new level, caller, role). + Error responses: | Code | Meaning | @@ -843,4 +885,10 @@ Error responses: | `404` | Worker not found or not in the caller's teams (existence hidden); or (passthrough) the file does not exist — see the version-gate probe above. | | `409` / `416` | (passthrough) the file changed while being read / while waiting for the write (ETag mismatch — reload and retry) / offset beyond end of file. | | `502` | Worker app unreachable, or an upstream error (status echoed in the body). | + +| `400` | Invalid worker name / invalid request body / `approval_level` not in the fixed set. | +| `403` | Team leader attempting `PUT` (read-only) / L2 human setting `OFF` (elevated capability required, #1220). | +| `404` | Worker not found / caller does not own it (existence hidden) / pre-2.x worker (version gate, passthrough). | +| `409` | Concurrent upstream config change (retry with a fresh `GET`). | +| `502` | Worker app unreachable or upstream error. | | `503` | Kube mode (no stable worker pod DNS to proxy). | diff --git a/docs/zh-cn/usage/project-workflow-api.md b/docs/zh-cn/usage/project-workflow-api.md index 596a2c888..316ff925c 100644 --- a/docs/zh-cn/usage/project-workflow-api.md +++ b/docs/zh-cn/usage/project-workflow-api.md @@ -441,3 +441,35 @@ Controller 代理每个 worker 的 QwenPaw app(QwenPaw ≥ 2.1)的四个端 | `409` / `416` | (透传)读取期间或写入等待期间文件被修改(ETag 不匹配——重载重试)/ offset 超出文件末尾。 | | `502` | worker app 不可达,或上游错误(状态码回显在 body 中)。 | | `503` | kube 模式(无稳定的 worker pod DNS 可代理)。 | + +## Worker 工具审批端点 + +每个 QwenPaw worker 的 agent profile 带一个工具执行安全级别(`agent.json` 的 +`approval_level`),决定哪些工具调用自动执行、哪些暂停等人工审批。Controller +代理 worker 的 `/workspace/running-config` API 的最小读写面,L2 人类可管理 +自己团队内 worker 的该级别: + +| 端点 | 含义 | +|:--|:--| +| `GET /api/v1/workers/{name}/approval` | 当前级别:`{"approval_level": "AUTO"}`。 | +| `PUT /api/v1/workers/{name}/approval` | 设置级别。Body:`{"approval_level": "STRICT"}`。 | + +- **档位**:`STRICT`(所有工具需审批)/ `SMART`(低风险工具自动放行)/ + `AUTO`(仅受管工具——上游默认)/ `OFF`(关闭守卫)。其他值在触碰 worker + 之前即被 `400` 拒绝(上游模型不校验取值,代理是校验边界)。 +- **OFF 为提权档**:`approval_level=OFF` 会完全关闭 Tool Guard,属安全策略 + 操作而非普通配置。默认 L2 人类设 `OFF` 得 `403`——只能在受管档位 + (`STRICT`/`SMART`/`AUTO`)间切换;`OFF` 需 L2 权限设计(#1220)定义的 + 提权工具审批能力、由 admin 显式授予,该能力模型落地前 admin/manager 保留 + 全档位。 +- **写范围**:`PUT` 对 admin/manager 全团队开放;L2 人类仅可设自己团队内 + worker——跨团队隐藏为 `404`(存在性不可探测)。团队 leader 保持只读 + (`PUT` 得 `403`,与知识库写 API 同界)。 +- **安全写**:上游 `PUT /workspace/running-config` 持久化*完整*运行配置对象, + 代理执行 GET→仅改 `approval_level`→PUT 回全量;其余字段原样往返。上游 + `409`(并发配置变更)透传,客户端以新 `GET` 重试。 +- **仅 embedded 模式**:worker 寻址与 checkpoint 代理相同(有效容器前缀 + + 系统优先端口)。kube 模式返回 `503`。 +- **降级**:无 running-config 路由的旧版 QwenPaw worker 原样透传上游 + `404`(版本门)。 +- 每次成功变更记审计日志(worker、新级别、调用者、角色)。