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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user