银行在封杀用密码登录的 Agent:该做的是受委派访问

·阅读约11分钟·Evergreen Tools Team

2026 年 10 月 22 日,Axios 报道了一件正在美国银行业发生的事:多家大型银行发现第三方 AI Agent 在客户账户内运行,其中不少情况是客户自己把用户名和密码交给了 Agent,让它代为操作。路透随后跟进,称银行在识别并清除未授权的第三方 Agent。摩根大通、美国银行、富国银行、摩根士丹利都被点名,摩根士丹利否认存在"失控 Agent"。对美国银行来说,立场很直接:如果你分享了凭据,账户后续活动由你负责。这件事的技术核心不是"银行怕 AI",而是一个更老的问题——密码从来就不是为委派设计的。

银行卡与笔记本上的金融应用

把密码交给 Agent,等于把全部权限、永久地、无归因地转让出去。

一、发生了什么,银行说了什么

按 Axios 的报道,问题有两层。第一层是违规:多数银行的服务条款要求客户本人登录,把凭据交给第三方 Agent 本身就可能违约。第二层是行为:部分 Agent 出现了超出用户指令的动作,甚至有银行反映接到过来自 Agent 的客服来电。银行方面的解释由摩根大通 CEO Jamie Dimon 给出——不会随便让外部 Agent 进系统,因为对客户和对摩根大通都"太冒险"。也有另一种解读,来自 Bianco Research 的 Jim Bianco:传统银行靠客户把钱留在低息账户里赚钱,Agent 会替客户把钱挪走。两种解释可以同时成立。

二、密码是错误的原语,这和信不信任 AI 无关

把密码交给 Agent,等于把你的全部权限一次性转让,而且无法限定用途、无法限定范围、无法在事后区分谁的意图。它没有到期时间,没有撤销粒度,也没有"这个 Agent 只能看、不能转账"这种表达方式。这不是模型能力问题,是授权语义问题:你手上只有一个布尔值——全部权限或没有权限。任何想要"帮我看看这个月花了多少"的用户,都被迫交出"可以转走全部余额"的凭据。这个落差才是银行封杀的根本原因。

# A consent record is not a checkbox. It is the thing your authorisation
# server reads when a bank asks "who told you that you could read this?"
consent = {
    "id": "cns_01J9Z8",
    "user_id": "u_4471",
    "agent_id": "agt_budget_coach",
    "granted_at": "2026-11-01T09:12:04Z",
    "expires_at": "2027-02-01T09:12:04Z",
    "scopes": ["accounts:read", "transactions:read"],   # read-only, on purpose
    "purpose": "monthly budgeting summary",
    "renewable": False,
    "revocable_at_any_time": True,
}

# Notice what is absent: no username, no password, no MFA code. The moment a
# consent record contains a credential, it stops being a consent record and
# becomes a liability with a timestamp.

三、只读是权限,不是承诺

报道里提到,很多银行允许或正在转向允许只读访问——让工具分析交易记录而不动钱。这个方向是对的,但别把只读当安全区。只读的 Agent 拿到的是一份结构化的财务画像,一旦它被提示注入操纵或自身被攻破,泄露的是同一份数据。阻止交易和保守机密是两个独立的安全目标,达成前一个不等于达成后一个。工程上这意味着:只读也要有范围(哪个账户)、时效(多久过期)、以及可撤销性。

# Fail closed on scope. The most common production bug in agent integrations is
# not a missing permission check, it is a permission check that degrades to
# "just do it" when the token looks incomplete.
class ScopeError(Exception):
    pass

def require(claims: dict, scope: str) -> None:
    granted = set((claims.get("scope") or "").split())
    if scope not in granted:
        raise ScopeError(f"agent lacks {scope}; granted: {sorted(granted)}")

def read_transactions(token_claims, account_id):
    require(token_claims, "transactions:read")
    if token_claims.get("sub") != account_id:
        raise ScopeError("token is not scoped to this account")
    return api.get(f"/accounts/{account_id}/transactions")

# Note the second check. A read scope for account A must not read account B,
# and the bank will test exactly that before they let you into the programme.

四、受委派访问:用交换换来的 token,而不是密码

正确的做法在 OAuth 里已经存在十几年了:用户授权一个客户端,客户端拿到限定 scope 的 token;如果 Agent 要代表用户行动,就用 token exchange 换一个同时标明"哪个 Agent(actor)"和"代表谁(subject)"的凭证。这样每一次调用都带着可审计的语义:这个 Agent、代表这个人、只能做这件事、到这个时间为止。银行要问的三个问题——谁做的、代表谁、被授权做什么——都能从 token 里直接回答,而不是靠猜。

# Money movement is not a scope you grant once. It is an approval you ask for
# each time, with the intent shown to the human in words they can check.
from dataclasses import dataclass

@dataclass
class Intent:
    action: str            # "transfer"
    amount_usd: float
    from_account: str
    to_account: str
    memo: str

def request_approval(intent: Intent, user) -> bool:
    shown = "Transfer {amount} from {src} to {dst}. Memo: {memo}.".format(
        amount=format(intent.amount_usd, ",.2f"),
        src=intent.from_account,
        dst=intent.to_account,
        memo=repr(intent.memo),
    )
    approval = user.confirm(shown, timeout_seconds=120)   # shows the real values

    if not approval.ok:
        return False
    if approval.amount_confirmed_usd != intent.amount_usd:
        return False        # the human approved a different number; treat as deny
    return True

# "Are you sure?" is not an approval. An approval names the amount, the
# destination, and the consequence, and it expires.

五、动钱这件事,应该是每次都问,而不是一次授权

读权限可以一次授权、长期有效;转账不行。凡是会产生不可逆后果的动作,都应该是每次单独批准,而且批准界面必须展示真实的金额、真实的收款方、真实的后果——不是"是否继续",而是"向谁转多少"。还有一条容易被忽略的细节:如果用户确认的金额和 Agent 打算执行的金额不一致,必须当成拒绝处理,而不是"用户大概同意了"。批准还要过期,因为一个五分钟前的确认不该在五小时后生效。

# The audit record that answers the bank's question, and yours: which agent,
# on whose behalf, under which consent, with what result.
import hashlib, time, uuid

def audit(claims, action, params, outcome):
    return {
        "id": str(uuid.uuid4()),
        "ts": round(time.time(), 3),
        "agent_id": claims["act"]["sub"],        # the agent, named
        "agent_client": claims["act"].get("client_id"),
        "on_behalf_of": claims["sub"],           # the human, named
        "consent_id": claims.get("consent_id"),
        "scope_used": action.required_scope,
        "action": action.name,
        "params_digest": hashlib.sha256(
            repr(sorted(params.items())).encode()
        ).hexdigest()[:32],
        "outcome": outcome,
    }

# Store the digest, not the payload. You need to prove what happened, not to
# keep a second copy of somebody's transactions.

六、现在该做的四件事

第一,把所有存着银行密码的字段、提示词和配置清掉,加一条 lint 让构建直接失败。第二,把 Agent 的权限收窄到 scope,读和写分开,账户和账户分开。第三,给会产生后果的动作加逐步确认,并记录确认的原文。第四,落审计记录:agent_id、on_behalf_of、consent_id、scope、参数摘要、结果。银行未来开放接口时,会先看你有没有这四样东西。

# Ban the shape, not the string. A lint that fails the build when an agent
# config, prompt or tool schema asks for a banking credential.
import re, glob, sys

BANK_CRED = re.compile(
    r"(bank|chase|wells|bofa|barclays)[^\n]{0,40}"
    r"(password|passcode|pin|security code|otp)",
    re.I,
)

def scan(paths):
    hits = []
    for path in paths:
        text = open(path, encoding="utf-8", errors="ignore").read()
        for m in BANK_CRED.finditer(text):
            line = text[:m.start()].count("\n") + 1
            hits.append(f"{path}:{line}: {m.group(0)[:60]}")
    return hits

found = scan(glob.glob("agents/**/*.yaml", recursive=True)
             + glob.glob("prompts/**/*.md", recursive=True))
print("\n".join(found) or "clean")
sys.exit(1 if found else 0)

# If a prompt asks for a bank password, no amount of downstream authorisation
# will save you. Catch it before it reaches a customer.
屏幕上显示的授权与令牌界面

受委派访问要能回答三个问题:谁做的、代表谁、被授权做什么。

📌 常见问题 FAQ

银行到底在封什么?

封的是用客户本人凭据登录的第三方 Agent,不是禁止一切 AI 使用。按报道,多数银行的服务条款要求客户本人登录,把用户名密码交给第三方 Agent 可能构成违约;同时部分 Agent 出现了超出用户指令的动作。很多银行允许或正在转向允许只读访问。

只读访问就安全了吗?

不是。只读能阻止资金被转走,但阻止不了数据被泄露——一份完整的交易记录本身就是高价值目标。只读访问同样需要范围限定、时效限制和可撤销机制,而且要考虑 Agent 被提示注入操纵的情况。

技术上正确的替代方案是什么?

受委派访问:用户授权客户端、客户端拿到限定 scope 的 token;Agent 代表用户行动时,用 token exchange 换取同时标明 actor(哪个 Agent)和 subject(代表谁)的短时凭证。这样每次调用的意图、范围、期限都是显式的,也能被审计。

为什么银行说分享凭据后风险由客户承担?

这是银行公布的立场:美国银行在回应中表示,如果客户分享了凭据,就要为由此产生的账户活动负责。这不是对消费者法律权利的普遍裁定,但对做产品的人来说,它意味着你不能把"用户自己给的密码"当作合规依据。

如果我的产品必须动钱怎么办?

每次单独批准,而不是一次长期授权。批准界面要展示真实金额、真实收款方和真实后果;用户确认的金额与执行金额不一致时按拒绝处理;批准要带有效期。再加一层服务端限额和异常检测,别把安全性全押在前端确认上。