技术思考问题14:Agent进了沙箱就能放心执行吗?
沙箱关住进程,授权守住业务:Agent 执行环境与业务授权的双重边界
沙箱能拦住进程乱来,拦不住合法凭证做错生意。
[读者] 把 Agent 放进容器沙箱、又准备给它真实写权限的平台工程师
[痛点] 沙箱配置齐全,业务越权却照旧发生,说不清防线该划在哪一层
[现在读] 客服 Agent 马上要拿到生产退款权限,边界必须先定死
[读完] 能用反演思维倒推防护点,用安全边际兜住剩下的错
[旧方案] 容器沙箱 + 只读挂载,Agent 只做查询和建议
|
v
[新需求] Agent 要自己执行真实退款,手里握着可调工具的短期凭证
|
v
[冲突] 沙箱约束的是进程能做什么,约束不了这笔生意该不该做
|
v
[后果] 凭证合法、网络合法、操作合规,退款却退给了别人家的订单
我是老李,在一家做多租户客服 SaaS 的公司负责 Agent 平台。所谓多租户,就是同一套服务同时伺候几十个商家,数据靠 tenant_id 隔开,谁也不能碰谁。
两年前我们把客服 Agent 塞进了容器:文件系统只读、非 root 用户、capability 基本掉光。它能看到的世界只有一份挂载进来的工单文件,能做的事只有查订单、查物流、生成一段建议话术。那时候它要动业务,得先输出文本,让人复制粘贴去执行——慢是慢,但确实没出过资金事故,因为钱根本不在它手里。
今年不一样了。日均退款工单涨到客服接不住,业务方要求 Agent 直接执行退款,把人的动作从流程里摘掉。于是我们给它签发了短期工具凭证,让它能调退款网关。从那一刻起,沙箱和资金之间,只隔着一层凭证。
凭证是合法的,沙箱是严的,网络是通的——那这笔退款到底靠什么拦?
是把沙箱配得更死,死到它连网关都调不到?还是承认沙箱管不了生意,在网关这一层重新划一条线?
如果这两条线一条都没划清楚,会不会出现这种局面:安全审计全绿,账单上却多出一堆不属于当前租户的退款,而且每一笔在日志里都写着“通过”?
01、故事
现场是一家 3C 电商的售后客服线。Agent 跑在容器里,容器只有一份挂载进来的工单文件可读,出口带宽被压到最小,进程以非 root 身份运行。最初给它的任务是查单加写建议:查到订单,读一遍物流轨迹和聊天记录,输出一段客服可以直接用的退款话术,人点确认,钱才动。
这套方案平稳跑了一年多。它的安全假设很朴素——Agent 不可信,所以不让它写。沙箱就是这个假设的物理实现,谁都没觉得有问题。
变化发生在今年春天。退款工单量涨到客服接不住,业务方提出让 Agent 直接执行退款。平台的第一次改动很自然:给它签发一张短期凭证,让它能调退款网关。凭证是签名过的,有效期五分钟,看起来人畜无害。
真正的麻烦在于,我们的注意力和过去一年多一样,全压在“别让 Agent 逃出容器”上。没人认真问过一个更朴素的问题:如果它压根不逃,就用你给的凭证,认认真真地退错一笔钱呢?
沙箱从头到尾都没被突破。它不需要被突破。
边界先画清
权限再上楼
沙箱管进程
生意管授权
02、问题
旧方案的失效点不在于沙箱变弱了,而在于它的保护对象变了。沙箱保护的是“执行环境不被滥用”,而业务现在需要它保护“资金动作不被误发”。这两件事在同一个容器里,却是两种完全不同的风险。
业务影响很直接:错退的款项要走人工申诉退回,退款对账多出一条平不了的流水,客服和财务要花额外工时去核。如果错退的是别的租户的订单,还会触发数据隔离的合规问题——对多租户 SaaS 来说,这比丢钱更难解释。
技术表现则相当反直觉:容器没有逃逸痕迹,宿主机没有异常进程,Agent 的调用链里每一步都带着合法签名。你去看监控,全是绿的;你去看账单,钱少了。故障现象和告警信号之间,隔着整整一层业务语义。
所以这件事不能再用“加固沙箱”来描述。我们需要一份可以被验证的完成标准,让边界从主观判断变成可执行的检查项:
- 沙箱内禁止出网:访问任意公网地址必须失败,不能返回任何 HTTP 状态;
- 越权写必须失败:在只读挂载点写入文件返回 Read-only file system;
- 跨租户退款必须被拒:t-1 的凭证对 t-2 的订单返回 403,且订单状态不变;
- 重复执行必须被折叠:同一幂等键第二次提交返回 duplicate,不产生第二笔资金动作;
- 启动延迟可跟踪:沙箱冷启动耗时被记录下来,作为准入指标而不是事后解释。
这五条里,只有前两条属于传统沙箱的范畴。后三条,沙箱一条也管不了。
旧路走到头
新需求压顶
标准要可验
才算真交付
03、原理
理解这件事,需要先把两条轴分开。第一条轴是能力:进程能读哪些文件、能连哪些地址、能调哪些系统调用。第二条轴是权限:这笔生意该不该做、谁有资格做、做多少算过头。沙箱工作在第一根轴上,业务授权工作在第二根轴上。
反演思维的用法,是不从“Agent 能做什么”出发,而从“钱会在哪条路径上丢”出发,倒着推防护点。盘一遍我们这套系统,损失路径至少有四条:Agent 被聊天记录里的恶意文本带偏,用合法凭证提交一笔错误订单;凭证在任务执行期间泄漏,被沙箱外的人重放;网络超时重试导致同一笔退款执行两次;单次授权没有额度上限,一次退光整单。
倒推回来,防护点是:入口要做凭证鉴权,动作要校验租户与对象归属,执行要有幂等键,金额要有单笔和累计上限,全过程要有审计。注意这五个点里有四个落在服务端,只有一个落在沙箱。
反直觉判断:沙箱越严,不等于业务越安全。因为真正的攻击者往往不走漏洞这条路,而是用你亲手发出去的那张凭证——它签名有效、范围合规、时间没过期,沙箱对它的态度只能是放行。
安全边际是这套设计里的另一半。隔离做的是“阻止”,它假设拦截是完备的;安全边际做的是“限制损害并留出恢复空间”,它假设拦截一定会漏。限额是边际,幂等是边际,审计留痕是边际,高风险人工确认也是边际。前者决定错误能不能发生,后者决定错误发生后能不能收场。
倒着想损失
顺着设防线
留出安全边际
才有回旋余地
04、架构
[输入] Agent 的工具调用请求(携带短期凭证)
|
v
[模块] 沙箱执行环境:文件系统最小权限 + 网络出口白名单
|
v
[数据/状态] 凭证声明(租户 / 范围 / 有效期)+ 订单与额度状态
|
v
[处理] 可信服务端四要素授权:租户 / 对象 / 动作 / 额度,附幂等与审计
|
v
[输出] 执行结果、审计记录、高风险人工确认
这条链路里,沙箱负责把进程关在一个足够小的盒子里:能读的文件只有任务包,能写的目录只有工作区,能连的地址只有网关。它不解释业务,也不该解释业务。
服务端负责把“这笔生意”判断清楚:调用方声明的租户与凭证里的租户是否一致、目标订单是否属于该租户、动作范围是否被授予、金额是否落在额度内。这四个判断点合起来,才是业务授权的完整含义。
边界在这里被明确切开:沙箱是执行侧的边界,网关是业务侧的边界,凭证是两者之间的传递介质。凭证里带什么声明,决定了网关能判多细;凭证有效期多长,决定了凭证泄漏后的暴露窗口有多大。
收益是判定点集中且可审计——所有业务拒绝都发生在一个可信进程里,日志口径统一。代价是链路变长:每次工具调用都要过一次服务端判定,还要维护订单与额度的状态一致性,网关成了新的关键路径。
适用条件是:Agent 确实需要写权限,且写操作可以通过“租户 + 对象 + 动作 + 额度”这四个要素描述清楚。如果某个业务动作连这四个要素都说不清,那它大概率不该交给 Agent 自主执行。
输入有边界
输出有闸门
收益在收敛
代价在复杂
05、实战一次
先把 V1 跑起来。V1 的目标只有一个:让 Agent 在受限容器里成功执行一次退款,同时故意留下两处缺口——网络出口没收紧、网关不校验业务归属。缺口不是疏忽,是要在下一章被证据抓出来的。
本文命令与输出未在当前环境实测,以下为预期结果,标注“示例输出”的部分是预期返回,不是生产数据。
环境与依赖:Linux 宿主机,Docker 24 及以上,Python 3.10 及以上。全流程只用 Python 标准库(http.server、sqlite3、urllib、hmac),不额外安装任何包;容器镜像使用官方 python:3.12-slim。
目录结构如下:
demo/
gateway/server.py # 退款网关 V1
gateway/issue_token.py # 签发短期凭证
agent/agent.py # 沙箱内运行的 Agent
workdir/task.json # 挂载进沙箱的任务包
网关 V1,完整实现:
#!/usr/bin/env python3
import base64, hashlib, hmac, json, os, sqlite3, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRET = os.environ.get('GATEWAY_SECRET', 'demo-secret')
DB = os.environ.get('GATEWAY_DB', '/app/gateway.db')
def sign(raw):
return hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
def verify(token):
raw, sig = token.split('.', 1)
raw = raw + '=' * (-len(raw) % 4)
payload = base64.urlsafe_b64decode(raw)
if not hmac.compare_digest(sign(payload), sig):
raise PermissionError('bad_signature')
claims = json.loads(payload)
if int(claims['exp']) < int(time.time()):
raise PermissionError('token_expired')
return claims
def init_db():
conn = sqlite3.connect(DB)
conn.executescript(
'CREATE TABLE IF NOT EXISTS orders(id TEXT PRIMARY KEY,'
' tenant TEXT NOT NULL, amount_paid INTEGER NOT NULL,'
' refunded INTEGER NOT NULL DEFAULT 0);'
'CREATE TABLE IF NOT EXISTS idem(key TEXT PRIMARY KEY, ts INTEGER NOT NULL);'
)
conn.execute("INSERT OR IGNORE INTO orders VALUES('o-1001','t-1',19900,0)")
conn.execute("INSERT OR IGNORE INTO orders VALUES('o-2001','t-2',8800,0)")
conn.commit()
conn.close()
class Handler(BaseHTTPRequestHandler):
def reply(self, code, payload):
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path != '/refund':
return self.reply(404, {'error': 'not_found'})
size = int(self.headers.get('Content-Length', '0'))
body = json.loads(self.rfile.read(size) or b'{}')
try:
claims = verify(self.headers.get('X-Agent-Token', ''))
except Exception as exc:
return self.reply(401, {'error': 'unauthorized', 'detail': str(exc)})
conn = sqlite3.connect(DB)
cur = conn.cursor()
try:
cur.execute('INSERT INTO idem VALUES(?,?)',
(body['idempotency_key'], int(time.time())))
except sqlite3.IntegrityError:
conn.close()
return self.reply(200, {'status': 'duplicate'})
row = cur.execute('SELECT amount_paid, refunded FROM orders WHERE id=?',
(body['order_id'],)).fetchone()
# V1 缺口:既不校验订单所属租户,也不校验退款额度
if row is None:
conn.close()
return self.reply(404, {'error': 'order_not_found'})
cur.execute('UPDATE orders SET refunded = refunded + ? WHERE id = ?',
(body['amount'], body['order_id']))
conn.commit()
conn.close()
self.reply(200, {'status': 'ok', 'tenant_claim': claims['tenant'],
'refunded': body['amount']})
def log_message(self, fmt, *args):
print('audit', fmt % args, flush=True)
if __name__ == '__main__':
init_db()
print('listening on 0.0.0.0:8800', flush=True)
ThreadingHTTPServer(('0.0.0.0', 8800), Handler).serve_forever()
凭证签发脚本:
#!/usr/bin/env python3
import base64, hashlib, hmac, json, os, sys, time
tenant = sys.argv[1]
secret = os.environ.get('GATEWAY_SECRET', 'demo-secret')
claims = {'tenant': tenant, 'scope': 'refund', 'exp': int(time.time()) + 300}
raw = json.dumps(claims, separators=(',', ':')).encode()
token = (base64.urlsafe_b64encode(raw).decode().rstrip('=')
+ '.' + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest())
print(token)
沙箱内的 Agent,它只读任务包,只调网关:
#!/usr/bin/env python3
import json, os, urllib.error, urllib.request
BASE = os.environ.get('GATEWAY_URL', 'http://refund-gateway:8800')
with open('/work/task.json') as fp:
task = json.load(fp)
req = urllib.request.Request(
BASE + '/refund',
data=json.dumps(task['refund']).encode(),
headers={'Content-Type': 'application/json',
'X-Agent-Token': task['token']},
method='POST')
try:
with urllib.request.urlopen(req, timeout=5) as resp:
print('HTTP', resp.status, resp.read().decode())
except urllib.error.HTTPError as exc:
print('HTTP', exc.code, exc.read().decode())
启动与请求:
docker network create agent-net
docker run -d --name refund-gateway --network agent-net \
-v "$PWD/gateway:/app" -w /app -e GATEWAY_SECRET=demo-secret \
python:3.12-slim python server.py
TOKEN=$(GATEWAY_SECRET=demo-secret python3 gateway/issue_token.py t-1)
cat > workdir/task.json <<JSON
{"token": "$TOKEN", "refund": {"tenant": "t-1", "order_id": "o-1001", "amount": 19900, "idempotency_key": "r-001"}}
JSON
docker run --rm --network agent-net \
--read-only --tmpfs /tmp:rw,size=16m \
--user 1000:1000 --cap-drop ALL \
--security-opt no-new-privileges \
-v "$PWD/workdir:/work:rw" -w /work \
python:3.12-slim python agent.py
示例输出:
HTTP 200 {"status": "ok", "tenant_claim": "t-1", "refunded": 19900}
V1 到此跑通:进程被关在只读根文件系统里,只有 /work 与 /tmp 可写,capability 全掉,凭证五分钟过期。遗留的两个问题也同时被钉住:agent-net 是普通 bridge,容器仍然可以出网;网关只验签不验生意,任何人拿着合法凭证都能退任意订单。
先跑通一条
再谈加固事
最小闭环成
问题自会来
06、排查
诊断链一:沙箱号称隔离网络,却仍然出网。
现象:安全同学在沙箱容器里访问一个公网地址,拿到了正常响应。
怀疑:沙箱的网络限制压根没生效。
检查:列出容器实际使用的网络,确认它是不是我们以为的那个受限网络。
docker inspect -f '{{json .NetworkSettings.Networks}}' refund-gateway
docker network inspect agent-net | grep -i driver
证据:agent-net 的驱动是 bridge,且没有配置任何出口限制,容器经宿主机 NAT 具备完整出网能力。当初为了让 Agent 能调到网关,我们随手建了一个普通 bridge 网络,把出网限制整块丢掉了。
根因:为了连通性牺牲了最小权限——这不是配置错误,是取舍时没有把“出口”当成一条独立边界来对待。
修复:改用 --internal 网络切断默认出网,需要放行的地址改走受控代理按白名单转发。这个思路和 Anthropic 官方工程博客里描述的沙箱实现属于同一类做法:文件系统隔离与网络隔离并用,网络访问经由受控代理解析放行(具体实现细节需按当前官方文档核验)。温昱在《Agent in the Sandbox:沙箱隔离架构技术概览》里也是把沙箱当作一类架构问题来梳理层次,而不是当作一条命令。
诊断链二:跨租户退款成功了。
现象:审计日志里出现一条针对 o-2001 的退款成功记录,而 o-2001 属于 t-2,当天发出的凭证全部是 t-1 的。
怀疑:容器被突破了,或者凭证被伪造。
检查:比对凭证声明、请求体声明和订单归属三处事实。
docker exec refund-gateway python3 -c "import sqlite3;print(sqlite3.connect('/app/gateway.db').execute('select id,tenant from orders').fetchall())"
证据:凭证里 tenant=t-1,请求体里 tenant=t-1,订单表里 o-2001 的 tenant=t-2。网关把三个事实原样收下,一个都没比对,直接执行了扣减。签名校验完全通过,容器也没有任何异常。
根因:网关只做了入口鉴权(是不是我签发的),没做业务授权(这笔生意允不允许)。对象归属与额度两个判定点缺失,等于把业务正确性完全交给了不可信的调用方。
错误尝试: 第一反应是在沙箱内部的 Agent 提示词里加一条“禁止退款到不属于当前租户的订单”,再配一条正则做兜底拦截。这个方向是错的,原因有三层:其一,提示词和正则都运行在不可信环境里,Agent 一旦被注入就同时具备绕过规则的能力;其二,沙箱根本不知道订单属于哪个租户,业务事实只存在于服务端,让它在信息不足的情况下做判断,只能靠猜;其三,授权逻辑一旦散落到客户端,审计时就没有唯一的判定点,出了事无法复现。修复方向只有一条:把判定搬回可信服务端。
现象先落地
证据再说话
根因不猜谜
修复才有底
07、优化
V2 只做一件事:把第 06 章抓到的两条根因,分别补在正确的那一层上。
根因一:出口没有最小权限。修改:网络改为 --internal,需要放行的地址走白名单代理;同时保持文件只读、非 root、capability 全掉。原因:执行侧边界的职责是缩小可动作范围,不是判断动作对错。新行为:沙箱内的进程无法直连公网,只能访问网关与白名单地址。
根因二:网关缺业务授权。修改:在可信服务端补齐四要素判定,并把凭证声明、额度上限、审计和幂等一并收紧。原因:只有服务端同时掌握凭证声明与业务事实。新行为:归属或额度不过,一律拒绝,且拒绝理由可审计。
网关 V2 的关键判定,插在验签之后、扣减之前:
tenant = claims['tenant']
if tenant != body['tenant']:
return self.reply(403, {'error': 'tenant_mismatch'})
if claims.get('scope') != 'refund:create':
return self.reply(403, {'error': 'scope_denied'})
row = cur.execute(
'SELECT tenant, amount_paid, refunded FROM orders WHERE id=?',
(body['order_id'],)).fetchone()
if row is None:
return self.reply(404, {'error': 'order_not_found'})
if row[0] != tenant:
return self.reply(403, {'error': 'object_not_in_tenant'})
remaining = row[1] - row[2]
if not 0 < body['amount'] <= remaining:
return self.reply(422, {'error': 'amount_out_of_range',
'remaining': remaining})
used = cur.execute(
'SELECT COALESCE(SUM(amount),0) FROM audit'
' WHERE tenant=? AND ts>?',
(tenant, int(time.time()) - 86400)).fetchone()[0]
if body['amount'] > SINGLE_LIMIT or used + body['amount'] > DAILY_LIMIT:
return self.reply(409, {'error': 'need_human_approval'})
配套的审计表与凭证收紧:
CREATE TABLE IF NOT EXISTS audit(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
tenant TEXT NOT NULL,
order_id TEXT NOT NULL,
amount INTEGER NOT NULL,
decision TEXT NOT NULL,
reason TEXT NOT NULL
);
凭证侧同步收紧:有效期从长期改为分钟级,scope 从笼统的 refund 细化为 refund:create,并把单笔上限写进声明,使网关的判定不依赖调用方自述。
验证仍然按第 02 章的完成标准逐条走,全部为预期结果:出网测试在 internal 网络中连接超时或被拒,没有 HTTP 状态返回;只读挂载点写入返回 Read-only file system;t-1 凭证对 o-2001 的退款返回 object_not_in_tenant;同一幂等键二次提交返回 duplicate;启动延迟用 time 包裹 docker run 记录 real 时间,作为持续跟踪指标。这些指标只做趋势观察,不在没有真实压测的情况下给出任何数值结论。
补上授权闸
再加额度锁
凭证短一分
风险少一分
08、演进
[同一笔退款请求]
|
+--[V1] 沙箱 + 只读挂载 + 可出网 -> 凭证验签通过即执行 / 代价:业务越权无法拦截
|
+--[V2] 最小权限沙箱 + 网络 internal + 网关四要素授权 -> 归属或额度不过即拒 / 代价:多一次服务端判定与审批链路
|
[Trade-off]
得到:错误退款在可信侧被拦下,拒绝理由可审计;失去:端到端延迟与实现复杂度上升;边界:授权只能覆盖网关能看见的业务语义
正确性上,V1 只保证“请求来自我方签发的凭证”,V2 保证“这笔退款在租户、对象、动作、额度四个维度上都成立”。前者是身份正确,后者是生意正确,两者不可互推。
稳定性上,V2 把不定因素收敛到了服务端的一个判定点上,Agent 侧的行为变化不再直接等价于资金变化;代价是网关从旁路组件变成了关键路径,它挂了,退款就整体不可用,可用性要求随之抬升。
复杂度上,V1 的复杂度藏在沙箱配置里,看起来简单;V2 的复杂度显式暴露在授权逻辑、额度状态和审计表里,看起来麻烦。后者更容易被评审、被测试、被追责,这在资金类系统里通常是更划算的交换。
成本上,真正的增量不是机器资源,而是链路长度和人工确认的引入:高风险动作需要人参与,自动化收益被削掉一部分。这部分收益损失应当被明确承认,而不是假装它不存在。
适用范围上,V2 的判定模型假设业务动作可以被拆成租户、对象、动作、额度四要素。对于语义模糊的动作,比如“调整客户体验评分”,这套模型就会失效,应该退回人工或只读。遗留问题有三个:凭证轮换与吊销机制还没有完整设计;长任务的凭证续期与中断恢复尚未覆盖;额度状态在高并发下的并发一致性还需要专门的幂等与锁方案,本文未做验证。
同一笔请求
两条路不同
得在取舍里
选适用边界
09、洞见
9.1 隔离强度和业务正确性是两个维度
沙箱衡量的是“进程能触碰多少东西”,业务授权衡量的是“这次触碰该不该发生”。前者是能力约束,后者是语义约束,二者没有包含关系:一个容器可以既无法出网又无法写盘,同时用一张合法凭证退掉别人家的订单。反直觉判断:加固沙箱在很多场景下并不能降低业务风险,因为攻击者根本不需要逃逸,它只需要你给的凭证还能用。这条判断直接解释了第 06 章诊断链二为什么能在隔离完好的前提下发生。
9.2 权限收窄之后,Agent 才可能被真正放开
表面上这是矛盾的:给 Agent 更少的能力,反而能给它更多的自主权。实际逻辑是,权限越模糊,人在每一步都不得不盯着;而当每个动作都被四要素和额度框住,超出范围的动作会由服务端拒绝并留下审计记录,人就可以只盯异常而不用盯全程。反直觉判断:让 Agent 更安全的方式,不是让它更谨慎,而是让它更受限——受限到即使判断失误,后果也被限制在一个可恢复的区间里。这正是安全边际在执行侧的具体含义。
9.3 判定必须写在攻击者够不着的地方
第 06 章的错误尝试说明了一件事:把拦截规则放在不可信环境里,等于把门锁装在门外。提示词、正则在沙箱内部只是建议,不是约束;它们既不能访问业务事实,也不能在事后被审计。判定点的位置比判定的聪明程度重要得多:网关可以笨一点,但必须唯一、必须可信、必须留痕。
9.4 安全边际承担的是“拦截失灵之后”的责任
隔离假设完备,边际假设不完备。幂等键解决的是重试导致的重复扣款,额度上限解决的是单次判断失误的爆炸半径,审计解决的是事后定位,高风险确认解决的是模型在边界样本上的不可靠。这四样东西在顺利运行的日子里看不出价值,它们只在拦截漏了的那一次里起作用——而资金系统里那一次一定会来。
隔离不是盾
授权才是门
边界写在可信处
洞见落在取舍间
10、系统落地
原来有什么:一套只读容器沙箱,Agent 能查单、能生成建议,但动不了钱;一张笼统的短期凭证;一个只验签不验生意的退款网关。
本篇新增了什么:执行侧把网络出口改成 internal 加白名单代理,文件保持只读,capability 全掉;业务侧在网关补齐租户、对象、动作、额度四要素判定,引入幂等键、单笔与日累计额度、审计表和超限人工确认;凭证侧把有效期压到分钟级、范围细化到动作粒度、把上限写进声明。
现在能做什么:Agent 可以自主执行低额度退款,跨租户、超额度、重复提交这三类错误在可信服务端被拦下并留下可复现的拒绝理由;同时沙箱侧的任何出网和越权写尝试都会失败。系统从“不可信但无害”变成了“不可信且受限”,这是能放它接触资金的最低门槛。
还缺什么:凭证的轮换与吊销链路没有闭环;长任务的凭证续期与中断恢复没有设计;额度状态在高并发下的原子性需要专项方案;高风险确认目前只有同步阻塞一种形态,会拖慢整条链路;审计记录还没有接入实时告警,只能事后查询。
下一步如何演进:把凭证粒度从“租户级”细化到“工单 + 动作 + 金额上限”,让每一次授权都带上明确的对象引用;把高风险确认从同步阻塞改为异步任务流,人在确认的同时 Agent 可以继续处理其他工单;把审计从离线表升级为可订阅事件,让异常拒绝率成为可观测指标,而不是等对账时才发现。
原来有沙箱
眼下添网关
缺口仍有三
演进看下一步
11、小结
Q1 → 沙箱管不住业务语义,越权判定必须落在可信服务端
Q2 → 执行环境隔离与业务授权是两条正交防线,谁也不能替代谁
Q3 → 用反演思维从损失路径倒推防护点,用安全边际兜住拦截失灵之后的那一次
状态 → 最小权限沙箱 + 网关四要素授权 + 短期凭证 / 额度 / 幂等 / 审计 / 高风险确认,可交付
本篇最终可交付的能力是:一条明确的双层边界——沙箱关住进程,授权守住业务;以及一套可以逐条验证的完成标准,让“Agent 能不能自主退款”这个问题从态度问题变成检查项问题。
三问有回响
能力可交付
边界已立住
余量仍要算
12、作业
12.1 理解题:为什么“文件只读 + 网络禁用”不能替代业务授权?
因为两者作用的对象不同。文件与网络限制约束的是进程能力,回答“它能碰到什么”;业务授权约束的是动作语义,回答“这次触碰该不该发生”。即使一个进程完全无法出网、无法写盘,它依然可以调用允许的接口,用合法凭证提交一笔错误对象的退款。能力受限只能缩小影响面,不能判定正确性,而正确性所需的业务事实(订单属于谁、还剩多少可退)只存在于可信服务端。
12.2 实战题:给退款网关补上对象归属与额度校验,写出关键判断。
关键判断有四处,缺一不可:第一,凭证声明的租户与请求体声明的租户必须一致,防的是调用方自述与授权不符;第二,查询订单归属后与凭证租户比对,防的是跨租户操作;第三,判定退款金额落在 0 与剩余可退金额之间,防的是超退与负数;第四,累计当日已退金额判断是否超过单笔与日均上限,超限不直接拒绝而是转人工确认。幂等键的插入必须发生在这些判断之前,否则重试仍然会重复产生资金动作。
12.3 排障题:审计日志显示一笔跨租户退款成功了,请给出排查链。
现象:日志中出现非当前租户订单的退款成功记录。怀疑:容器被突破或凭证被伪造。检查:比对三处事实——凭证声明里的 tenant、请求体里的 tenant、订单表里该订单的 tenant,同时确认容器无异常进程、签名校验通过。证据:前两者一致且签名有效,订单表里的 tenant 与之不同,且网关代码中不存在归属比对分支。根因:网关只有入口鉴权,没有业务授权,把业务正确性交给了不可信调用方。修复:在服务端补齐租户、对象、动作、额度四要素判定,并把拒绝理由写入审计表,同时回溯已有的退款记录做对账。
12.4 架构判断题:应不应该把跨租户拦截规则放进沙箱内的 Agent 提示词里?
不应该。理由有三层:其一,提示词运行在不可信环境,Agent 被注入后可以同时绕过规则本身;其二,沙箱不具备判定所需的业务事实,订单归属与额度都只存在于服务端,让它在信息缺失下判断只能靠猜;其三,授权逻辑分散到客户端之后没有唯一判定点,事后无法复现拒绝原因,也无法统一审计。正确的位置是可信服务端,且应当把判定点收敛为唯一入口,便于评审与告警。
题在纸上练
稳在线上见
四问四答后
边界自成章
13、思考
回到最初那个问题:沙箱是严的,凭证是合法的,这笔退款到底靠什么拦。答案是靠两件不同性质的事——把进程关小,以及把生意判对。前者是执行环境隔离,它负责让错误动作的影响面足够小;后者是业务授权,它负责让不该发生的动作根本发不出去。把它们混为一谈,就会得到一种很危险的系统:审计全绿,钱却在流走。
反演思维的实用价值在于,它把安全设计从“我们防住了什么”拉回到“钱会在哪条路径上丢”。沿着损失路径倒推,防护点自然就落在入口鉴权、对象归属、幂等和额度上,而不是落在更厚的容器配置上。安全边际则负责承认一件事:任何拦截都会漏。为漏掉的那一次预留限额、幂等、审计和确认,才是资金链路该有的余量。沙箱关住进程,授权守住业务,两者都对,缺一条就不成立。
一念辨边界
一证定根因
一改知取舍
一役见真章

浙公网安备 33010602011771号