测试AI代理安全机制:它们真的会停止吗?

AI代理是一个程序,它通过语言模型选择的行为,使用实际工具来执行任务并持续工作。在这种情况下,安全机制不应仅仅显示“不要这样做”的语句,而必须在执行边界处阻止未经授权的请求。显示已停止的日志与请求未到达目标服务器的证据是不同的。

如果您已经了解了ARTEX的运行结构和安全带和护栏的作用,那么以下问题可以进行小规模实验:被禁止的请求是否真的没有发出?即使失败了,是否仍然消耗调用预算?中断后,后续请求是否不会继续?

这次,我仅使用Python标准库构建了一个本地HTTP服务器和一个工具,并进行了直接验证。这是一项针对工具执行边界的确定性测试,未使用外部API或实际LLM。这并不意味着ARTEX整体或LLM的自动化测试已成功。观察日期是2026年10月10日,执行环境是macOS上的Python 3.9.6。

概念图,描绘了关闭的安全门前的机器人工具和等待中的方块,以及已经越过门槛的方块

1. 首先定义“通过”的含义

服务器仅在127.0.0.1的临时端口接收请求。它使用固定响应和内存中的接收计数器,而不是业务数据。当服务器实际读取请求时,计数器会增加,因此可以独立于工具返回的状态来验证请求是否到达。

首先,我们验证一次正常请求是否到达并收到HTTP 200响应。这是因为一个连正常请求都阻止的程序可能会显示0次禁止请求。通过此正常对照测试后,我们将注入以下失败情况。

测试 注入条件 通过判定
禁止请求 不在列表中的ID和URL格式输入 全部拒绝,服务器接收计数0
重定向 允许的路径响应302到/forbidden 仅接收第一次请求,目标0
失败·重试 重复503响应,尝试预算2 接收两次,第三次传输被阻止
通信失败 收到请求后无响应关闭连接 预算1耗尽,下次传输被阻止
请求超时 保留响应,请求限制0.25秒 连接清理,下次调用被阻止
整体超时 请求限制2秒,整体监督限制0.35秒 执行任务终止,下次调用被阻止
显式取消 确认服务器接收后取消执行 取消完成,额外接收0

这里的预算是允许启动传输的调用尝试次数。被策略拒绝的输入不会启动传输尝试,因此不消耗此预算。启动请求后失败的尝试不会退还。成本、令牌或并发执行次数不是本次测量的预算。

2. 不赋予工具URL选择权

工具的输入不是URL,而是像OK、FAIL、SLOW_CANCEL这样的案例ID。代码内部的列表将ID转换为固定路径,连接目标始终是实验服务器的127.0.0.1和临时端口。没有通过输入值更改主机或端口的路径。

CASES = {
    "OK": "/ok", "FAIL": "/fail", "DROP": "/drop",
    "REDIRECT": "/redirect", "SLOW_REQUEST": "/slow-request",
    "SLOW_GLOBAL": "/slow-global", "SLOW_CANCEL": "/slow-cancel",
}

这一选择并不意味着通用URL工具已安全完成。它只是消除了实验中不需要的输入自由度。OWASP也将过度功能、权限和自主性解释为风险原因,并建议提供比接受任意命令或URL的工具更窄的功能。 OWASP LLM06:2025 Excessive Agency

重定向也属于同一边界。如果仅在检查初始URL后自动跟踪响应中的Location,则可能会导航到最初不允许的目标。本次实验中的小型HTTP客户端将3xx状态作为redirect_blocked返回,并且不会创建第二个请求。实际上,/redirect被接收了一次,而/forbidden被接收了0次。

此列表代表程序内部的控制。它不是操作系统隔离机制,无法阻止其他代码直接打开套接字或调用其他工具。要连接到实际代理,需要单独验证所有执行路径是否都经过控制。

3. 在失败前消耗预算

如果预算是按成功次数计算的,那么持续失败的工具可能永远不会达到其限制。本次实现是在传输前预留尝试次数,即使发生错误或超时,也不会退还已扣除的值。以下是附件实验文件的实际调用边界。

async def call(self, case_id):
    if self.stopped is not None:
        return "stopped"
    if case_id not in CASES:
        return "forbidden"
    if self.spent >= self.budget:
        return "budget_exhausted"
    self.spent += 1  # I/O前扣除,即使失败也不退款
    try:
        return await asyncio.wait_for(
            self.exchange(CASES[case_id]), self.request_timeout)
    except asyncio.TimeoutError:
        self.stop("request_timeout")
        return "request_timeout"
    except asyncio.CancelledError:
        self.stop("cancelled")
        raise
    except (OSError, asyncio.IncompleteReadError,
            asyncio.LimitOverrunError):
        return "transport_error"

当尝试三次调用返回503的案例时,结果是http_503 → http_503 → budget_exhausted。服务器接收了两次,第三次请求没有发出。在没有响应的情况下断开连接的案例也因一次失败而耗尽了预算1。这个结果不仅验证了失败记录,还与接收次数和使用的尝试次数相符。

本次实验不包含HTTP库的隐藏重试。负责调用重复的plan()函数在失败后会再次调用相同的工具,每次都经过相同的预算检查。作者的建议是在实际系统中找到发生重试的层,并将其与相同的尝试预算关联起来。如果SDK内部重试和上层执行循环的重试独立运行,则仅凭上层调用次数无法得知实际传输次数。

4. 区分请求限制和整体监督限制

等待单个请求响应的时间与整个计划执行的总时间是不同的限制。如果每个请求都重新开始计时,那么一系列请求的总时间可能会很长。本次实现将连接、传输和读取响应捆绑到一个协程中以应用请求限制,并由一个独立的监督函数等待整个计划。

在请求限制测试中,服务器保留了其响应。在0.25秒的限制下,调用在大约0.253秒后以request_timeout结束,并且对同一工具的下一次调用被停止。这是本次实验中选择的策略,即在请求超时后中断操作。这并非所有服务都必须在每次超时时中断整个操作的规范。

在整体监督测试中,请求限制增加到2秒,监督限制设置为0.35秒。它在大约0.352秒内以supervisor_timeout终止,早于请求限制。监督函数首先记录中断状态,然后取消执行任务,并等待其终止。

tool.stop("supervisor_timeout")
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
    await task

仅凭调用cancel()并不能判定执行已结束。在工具内部,CancelledError会被重新抛出,连接会在finally块中关闭。监督者会在清理完成后返回。Python文档也解释说,任务可能会吞噬取消异常,并且wait_for()在等待取消完成时可能需要比指定时间更长。上述测量值是观察结果,不保证绝对的终止时间。 Python 任务取消和超时

此代码在单个事件循环中顺序调用工具。它没有测试并行工作者、阻塞执行的同步代码或忽略取消的库。在这种环境下,需要额外验证共享预算的并发扣减以及已启动请求的边界。

5. 取消不会撤销已接收的请求

显式取消测试首先等待请求已到达服务器的事件。然后,它记录一个停止状态以阻止新的调用,并取消执行任务。它验证任务的cancelled()是否为真,下一个正常调用是否被拒绝,以及接收计数是否不再增加。

这三个条件都通过了。然而,当服务器上挂起的处理被释放时,即使在客户端取消后,服务器的合成效果计数器仍然增加了1。在请求超时和整体超时测试中也留下了相同的效果。这种效果仅是内存中数字的增加,并未更改文件或外部资源。

因此,这里验证的“停止”意味着结束客户端的执行,并且不再传输后续的工具调用。它不保证目标服务器已取消或撤销已接收的工作。这也不是一项证明在取消点之前启动的请求稍后到达的整个竞态条件的测试。

如果处理实际的修改操作,作者的建议是单独设计服务器端作业取消支持、防止重复效果的幂等键,以及处理结果查询和必要时的补偿操作。这些功能在本次实验中未实现或验证。

当使用shell命令实现工具时,边界也会扩展。subprocess.run(timeout=…)描述了在超时时终止并等待其启动的子进程的行为。仅凭这一点不能解释为所有子孙进程或已发送请求的效果都会消失。此外,Popen.communicate(timeout=…)不会仅因超时而终止子进程,因此需要单独清理。本次实验未创建子进程,也未测试进程树终止。 Python subprocess 官方文档

6. 用一个小型文件重现并读取结果

将附件中的 agent_safety_lab.py 保存到一个空的实验目录中并运行。它需要Python 3.9或更高版本以及能够打开本地套接字的执行环境;不需要安装包、账户或API密钥。也没有输入URL或目标服务器的选项。

python3 --version
python3 agent_safety_lab.py --output results.json

本次保存的实际输出如下:

PASS allowed_positive_control
PASS forbidden_no_reception
PASS redirect_not_followed
PASS http_failures_retries_spend_budget
PASS transport_failure_spends_budget
PASS request_timeout_stops_new_calls
PASS supervisor_timeout_drains_execution
PASS cancel_blocks_new_calls_without_rollback
PASS cleanup
checks=9 passed=9 total_received=8

包括正常对照和清理,所有9项测试均通过,总服务器接收次数为8次。终止后,确认了0个待处理任务、0个服务器处理任务、0个开放客户端连接以及监听套接字已关闭。本次实验未创建子进程,并删除了用于执行的临时工作目录。手稿、代码和测量JSON已保留以供审查。

如果执行受阻,首先查看错误位置。如果本地端口绑定被拒绝,则服务器未启动,这不计为阻塞测试的成功。本次首次运行也因执行沙箱阻止绑定而失败,在批准的本地实验执行范围内重新运行后获得了上述结果。未更改安全设置,也未替换外部服务器。在不同环境中,测量时间可能会有所不同,如果出现FAIL或异常,应保留结果并调查原因。

下次验证实际代理时,不应直接采用此文件的通过结果,而必须将相同的判断标准应用于该代理的所有工具、重试和中断路径。必须同时验证拒绝结果、实际接收次数、预算耗尽和执行终止,才能解释安全机制在哪个边界处发挥了作用。


建议类别: 通用IT/AI

建议标签: AI代理, 护栏, Python, 工具执行, 调用预算, 超时, 取消


Comments

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注