Merge pull request #33 from PeterGuy326/feat/connect-twin-scope-enforce

feat(connect): enforce role capability scopes — keep a twin in its lane
This commit is contained in:
修雨
2026-06-15 17:16:11 +08:00
committed by GitHub
5 changed files with 76 additions and 0 deletions
+28
View File
@@ -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
@@ -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) {
+3
View File
@@ -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
}
+5
View File
@@ -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
+5
View File
@@ -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