diff --git a/internal/helpers/connect_approval_card.go b/internal/helpers/connect_approval_card.go index b0081cf7..229184f1 100644 --- a/internal/helpers/connect_approval_card.go +++ b/internal/helpers/connect_approval_card.go @@ -186,6 +186,25 @@ type approvalOrchestrator struct { // (e.g. a DingTalk online sheet) on top of the local approvals JSON. nil = // local-file audit only. audit auditSink + // allowedScopes is the role's capability allowlist (product names). When + // non-empty, an action whose product is not listed is refused before it ever + // reaches the gate, keeping the role in its lane. Empty = no restriction. + allowedScopes []string +} + +// scopeAllows reports whether the role may use the given product. An empty +// allowlist means no restriction (allow all). +func (o *approvalOrchestrator) scopeAllows(product string) bool { + if len(o.allowedScopes) == 0 { + return true + } + product = strings.ToLower(strings.TrimSpace(product)) + for _, s := range o.allowedScopes { + if strings.ToLower(strings.TrimSpace(s)) == product { + return true + } + } + return false } // recordAudit writes the request's current (terminal) state to the audit sink, @@ -280,6 +299,15 @@ func (o *approvalOrchestrator) handleReply(ctx context.Context, requester, convI return cleaned, false } + // Role scope: an action outside the role's capability allowlist is refused + // up front (never reaches the gate or the owner). The requester is told it is + // out of lane — this is a capability boundary, not an approval, so it is fine + // to surface to them. + if !o.scopeAllows(pa.Product) { + fmt.Fprintf(os.Stderr, "[connect][approval] 越权拦截:角色无 %q 能力,拒绝动作 %s\n", pa.Product, summary) + return fmt.Sprintf("(该数字员工没有「%s」能力,无法执行此操作:%s)", pa.Product, summary), false + } + // 写类才拦:route the gate-or-not decision through the read/write classifier // instead of gating every detected marker. A definitively read-class action // is safe and bypasses the gate (the marker is stripped and the reply goes diff --git a/internal/helpers/connect_approval_card_test.go b/internal/helpers/connect_approval_card_test.go index fceb6185..9c3bfc49 100644 --- a/internal/helpers/connect_approval_card_test.go +++ b/internal/helpers/connect_approval_card_test.go @@ -601,6 +601,41 @@ func TestDeferredExecution_HoldsAndRetries(t *testing.T) { } } +// TestScopeEnforcement: an action outside the role's allowlist is refused up +// front (the requester is told, nothing reaches the gate); an in-scope action +// proceeds; an empty allowlist allows everything. +func TestScopeEnforcement(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + gate := newApprovalGate("ctscope") + runner := &fakeRunner{} + o := newTextApprovalOrchestrator(gate, runner, "owner", &fakeNotifier{}) + o.allowedScopes = []string{"todo"} // HR-style: only todo + + // doc.create is out of scope → refused, NOT gated, requester told. + out, handled := o.handleReply(context.Background(), "requester", "c1", `[[ACTION:doc.create name="周报"]]`, nil) + if handled { + t.Fatal("out-of-scope action must not be handled by the gate") + } + if !strings.Contains(out, "没有") || !strings.Contains(out, "doc") { + t.Fatalf("requester should be told it's out of scope, got %q", out) + } + if len(gate.list()) != 0 { + t.Fatal("out-of-scope action must not create an approval request") + } + + // todo.create is in scope → proceeds (owner self-request auto-executes). + o.handleReply(context.Background(), "owner", "c2", `[[ACTION:todo.create title="排期"]]`, func(_ context.Context, _, _ string) error { return nil }) + if len(gate.list()) != 1 { + t.Fatalf("in-scope action should be processed, got %d requests", len(gate.list())) + } + + // scopeAllows: empty allowlist = allow all. + o.allowedScopes = nil + if !o.scopeAllows("anything") { + t.Fatal("empty allowlist must allow all products") + } +} + // TestAutoFlushDeferred verifies the background retry: silent while the identity // is still wrong (no spam), and it drains + notifies once execution works. func TestAutoFlushDeferred(t *testing.T) { diff --git a/internal/helpers/connect_role.go b/internal/helpers/connect_role.go index 28b007c2..de462f86 100644 --- a/internal/helpers/connect_role.go +++ b/internal/helpers/connect_role.go @@ -198,6 +198,9 @@ func applyRoleConfig(opts connectAgentOptions, role *RoleConfig) connectAgentOpt if len(role.KnowledgeSources) > 0 { opts.KnowledgeSources = append(opts.KnowledgeSources, role.KnowledgeSources...) } + if len(opts.RoleScopes) == 0 && len(role.AllowedScopes) > 0 { + opts.RoleScopes = append([]string(nil), role.AllowedScopes...) + } return opts } diff --git a/internal/helpers/connect_stream.go b/internal/helpers/connect_stream.go index e498a661..5dda0821 100644 --- a/internal/helpers/connect_stream.go +++ b/internal/helpers/connect_stream.go @@ -153,6 +153,11 @@ type connectAgentOptions struct { // "Sheet1"). Empty AuditSheetNode = local approvals JSON only. AuditSheetNode string AuditSheetTab string + // RoleScopes is the role's capability allowlist (RoleConfig.AllowedScopes), + // e.g. ["todo", "approval"]. When non-empty the approval gate refuses an + // action whose product is not in the list, so a role stays in its lane (an + // HR assistant can't touch code/drive). Empty = no scope restriction. + RoleScopes []string } // isStreamBridgeChannel reports whether a channel is wired through the Go-native diff --git a/internal/helpers/devapp_connect.go b/internal/helpers/devapp_connect.go index bfc68b12..89ec4a70 100644 --- a/internal/helpers/devapp_connect.go +++ b/internal/helpers/devapp_connect.go @@ -282,9 +282,13 @@ func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, client audit = &sheetAuditSink{runner: runner, nodeID: opts.AuditSheetNode, sheetID: opts.AuditSheetTab} fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 操作审计已开启:在线表格 node=%s tab=%s(每个操作追加一行)\n", opts.AuditSheetNode, opts.AuditSheetTab) } + if len(opts.RoleScopes) > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 角色能力边界:仅允许 [%s](其它产品的执行动作会被拒)\n", strings.Join(opts.RoleScopes, ", ")) + } if sender := newDingtalkApprovalCardSender(clientID, clientSecret, opts.ApprovalCardTemplate); sender != nil { orch := newApprovalOrchestrator(gate, sender, runner, opts.OwnerUserID) orch.audit = audit + orch.allowedScopes = opts.RoleScopes extras.approval = orch fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 确认闸已开启:主人=%s(执行类请求先卡片审批,[同意]/[拒绝]按钮)\n", opts.OwnerUserID) } else { @@ -297,6 +301,7 @@ func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, client notifier := newAICardClient(clientID, clientSecret, "") orch := newTextApprovalOrchestrator(gate, runner, opts.OwnerUserID, notifier) orch.audit = audit + orch.allowedScopes = opts.RoleScopes extras.approval = orch // Background auto-retry: a deferred backlog (e.g. queued while the // dws login was not yet the owner) drains by itself once the identity