微软研究院用 PDD Kit 形式化验证智能体编写的全栈 Web 应用
Formally verifying agent-written full-stack web apps with PDD Kit
微软研究院发布 PDD Kit,把开发者的意图转成 Verus 形式化规格,再证明编码智能体生成的代码对每个输入都满足该规格,证明覆盖不到的部分改用测试。团队在 GitHub Next 的 Chopin 环境中用它验证了 Rust 全栈应用 Pet Store 的登录逻辑,约 5000 行 Rust 代码,开发者只审阅意图文档而未逐行阅读生成代码。
微软研究院公开 PDD Kit 的完整工作流,展示如何把智能体写代码的审查重心从逐行读代码转到审阅意图规格。

概览
- 面向日常软件的形式化方法。 PDD Kit 帮助你将自己的意图转化为形式化规约,然后证明你的智能体编写的代码对每一个输入都满足该规约,并在证明无法覆盖之处使用测试。它把形式化方法从细分、专家化的工作带给没有形式化方法背景的工程师,也带给日常应用,比如这里展示的全栈 Web 应用。
- 随着 AI 智能体生成越来越多的代码,逐行人工审查可能变得难以扩展。 计划本应是用户与编码智能体之间的契约,但实际上没有任何机制强制智能体遵循它。它编写的测试通常检查的是它构建了什么,而不是你要求了什么。
- PDD Kit 让智能体遵守计划。 它与用户协作,将意图转化为 Verus(在新标签页中打开) 中的形式化规约,证明智能体的代码对每一个输入都满足该规约,并在证明无法覆盖之处回退到测试。
- 我们在一个全栈 Web 应用上进行了尝试。 在 GitHub Next 的 Chopin(在新标签页中打开) 中,我们使用 PDD Kit 对 Pet Store(一个 Rust(在新标签页中打开) 店面)的登录逻辑进行了形式化验证。Verus 加固了代码并发现了现有缺陷,证明了相对于规约不存在登录缺陷。开发者审查了意图,从未逐行阅读生成的代码。
计划是智能体应当遵循的契约,但没有任何机制约束智能体遵循它。
编码智能体现在可以一次性交付完整功能,往往涉及数万甚至数十万行代码。因此,逐行阅读拉取请求无法适应这种新的工作方式。现在,大多数团队转而投入计划与相关测试:向智能体描述他们想要什么,反复沟通直到计划和拟议的测试看起来正确,然后交给智能体实现。
这份计划或自然语言规约本应是智能体所遵守的契约。然而在实践中,智能体可能偏离它。它实现了略有不同的东西,然后编写对其实现而非你期望它编写的代码能通过的测试。持续集成(CI)因大量低质量、高数量的测试而增长,覆盖率看起来健康,而你要求的与实际交付之间的差距一直隐藏,直到出现缺陷报告或棘手的边缘情况。
那么,随着智能体生成越来越多的代码,如何可靠地扩展审查?智能体代码会以两种细心的审查者通常能发现的方式失败:遗漏了明显必要的东西,或做了明显错误的事情。仅靠测试无法弥合这一差距,因为它们只覆盖程序可能行为的一小部分。弥合这一差距需要另一种机制,这就是证明的用武之地。
审查问的是“这看起来对吗?”证明回答的是“这是对的吗?”数学证明表明代码对每一个输入都满足其规约,包括测试套件可能遗漏的输入。对于已规约的属性,这比仅靠测试提供了更强的保证,尤其是在逐行审查不切实际时。
过去,形式化证明对日常软件来说过于昂贵。你需要专家、陌生的语言、比代码大许多倍的证明,而且证明需要数年时间才能开发出来,但此后有两件事发生了变化。首先,编码智能体的最新进展使它们越来越能够根据定义明确的规格帮助生成形式化证明。其次,智能体现在能更好地帮助你编写形式化规格。我们将这种新的软件开发范式称为证明驱动开发(PDD)。PDD Kit 是我们为此提供的工具包。
PDD Kit 如何工作
PDD Kit 之于形式化方法,大致相当于 Spec Kit(在新标签页中打开) 之于规格驱动开发。它作为合作伙伴运行在 Chopin(在新标签页中打开) 内部,这是 GitHub Next 的实时多人协作规划环境,团队和智能体可以在一个共享文档中共同规划。
该工作流分为两半(图 1)。首先,团队成员用自然语言审查意图。PDD Kit 会提出细心的审查者会提出的问题,在过程中解释相关代码,并将答案记录在一个意图工件中,这是一份连接用户意图与形式化规格或测试的文档。用户意图中可形式化的部分被转换为形式化规格,其余必须被信任的部分则转换为用于测试的非形式化规格。PDD Kit 自动生成证明,以表明代码满足所推导出的形式化规格,并为非形式化规格生成高质量测试。你不需要任何形式化方法背景。

因此,PDD Kit 中的每个结果都会说明它基于哪种验证:
- 形式化验证是一种数学证明,表明代码对所有可能的输入都满足其规格,由称为验证器的工具检查。目前 PDD Kit 使用的验证器是 Verus(在新标签页中打开),这是一种用于 Rust 的证明语言,由 Microsoft Research、卡内基梅隆大学、VMware Research、苏黎世联邦理工学院、MPI-SWS、悉尼新南威尔士大学、德克萨斯大学奥斯汀分校、密歇根大学、不列颠哥伦比亚大学、威斯康星大学麦迪逊分校和魏茨曼科学研究所共同开发。
- 经验验证通过运行代码并观察其行为来检查代码。它覆盖证明无法触及的部分,例如第三方密码学库或用户界面。它使用单元测试和端到端测试、终端自动化、浏览器智能体,以及用于浏览器无法驱动的任何内容的计算机使用智能体。通过的测试只能表明代码在其尝试的输入上有效,因此 PDD Kit 将其标记为证据而非证明。
两者之间的界线是可信计算基(TCB):证明所假定行为符合描述的那部分代码。PDD Kit 明确标出这条界线,并要求你逐项确认。
压轴:一个全栈 Web 应用
为了展示 PDD Kit 与现有代码库的集成,我们构建了 Pet Store,一个完全用 Rust(在新标签页中打开) 编写的全栈 Web 应用。后端使用 Axum(在新标签页中打开) 和 SQLite(在新标签页中打开),处理库存、促销、订单和结账。店面是一个编译为 WebAssembly(在新标签页中打开) 的 Leptos(在新标签页中打开) 应用。两者合计约 5,000 行 Rust 代码。
为简洁起见,本文聚焦于对应用的登录逻辑进行形式化验证,但同样的流程可用于验证整个应用的功能正确性。PDD Kit 建议从应用中最敏感的部分开始,逐步验证你的应用。登录漏洞可能让攻击者获得客户账户或管理员控制权,因此证明工作应首先在这里投入。开发者在 Chopin 中的整个请求只有两句话:
Verify the login logic in backend/src/handlers/auth.rs. Here are my requirements:
You only get a token with the right password.Only admin accounts get admin tokens.
我们故意让请求保持粗略。捕捉用户意图并将用户请求转化为证明可检查的内容,是 PDD Kit 的职责。
第 1 步:先提问,后写代码
PDD Kit 的规划工作流 pdd-plan 会阅读代码,并在编写任何内容之前,在 Decisions 标签页中带着问题返回。当某个问题取决于代码如何工作时,卡片会先解释那部分:目标、各部分如何配合,以及涉及的确切函数,这样你无需打开源代码就能回答。

这个问题看似吹毛求疵,但很重要。“只有密码正确才能获得令牌”排除了坏情况,即错误密码获得令牌,而一个拒绝所有人的登录完美地保持了这一承诺。“密码正确总是有效”是排除它的另一半。形式上,第一个是蕴含(令牌 ⇒ 正确密码),第二个是它的逆命题。两者合在一起构成“当且仅当”。一个无人监督的智能体会选择其中一个而不告诉你,但在这里开发者选择了双向。
第 2 步:人类可读的意图图
随着对话的进行,PDD Kit 会在计划顶部保持一个草稿部分为最新状态,这样用户可以跟踪重要概念,而无需在长聊天记录中回滚查找已达成一致的内容。它包括正在指定的登录流程图、将要证明什么和信任什么的规范全景图,以及在图片有帮助时的心智模型。对于登录,PDD Kit 画了一个售票台(图 3)。

在它下面,意图图将每项需求与开发者自己的话联系起来,并记录它是如何被确定的(图 4)。每个条目链接到对其进行建模的形式化规范部分,以及覆盖它的测试,规范部分展示 Verus 合约的片段,并加以注释,以便开发者看到形式化规范如何对其意图建模。

图 2 中的决策卡正是那种智能体原本会默默自行了断的歧义。图 5 展示了这种差异在纸面上看起来有多小:两种解读只在一个单元格上不一致,即一个正确的密码却拿不到令牌。

第 3 步:证明登录函数
在开始构建任何东西之前,PDD Kit 要求团队整体签署计划。它的初稿证明了登录核心处的决策,即选择表并以密码为门控,同时将其周围的处理程序保持为受信任且经过测试。这是一个合理的默认做法,也是 PDD Kit 所推荐的,但开发者想要更多。处理程序正是等待查找结果并签署令牌的地方,而这段胶水代码恰恰是重构可能悄悄调换两行的地方。因此在意图评审时,开发者选择为登录处理程序赋予自己的异步规范,这样证明也覆盖那段胶水代码,而不是信任它(图 6)。

这一选择几乎把所有东西都移入了证明,只给登录处理程序留下解包请求这一件事。一个新的异步函数 login_flow 承载从请求字段一直到令牌或 HTTP 错误的契约。随后 PDD Kit 将修订后的计划带回进行新一轮签署,因为符合你的意图并不等于证明,而且它不会批准新的信任或更大的范围。
第 4 步:证明止步之处
关于程序,有很多东西你无法证明,而登录几乎撞上了其中大多数:
- 验证器看不到的代码。 bcrypt 的哈希、sqlx 的查询和 jsonwebtoken 的签名位于其他 crate 中,在证明之外。
- 关于外部世界的事实。 SQLite 当前持有什么以及时钟显示什么时间,是程序的输入,而不是其代码能够确立的东西。
- 某事是否完成,以及完成得多快。 规范说明的是如果登录应答你会得到什么。挂起的查询或缓慢的查询不会破坏其中的任何东西。
- 任何必须由人来判断的东西,比如错误消息是否有帮助,或者屏幕是否合理。
- 任何没人写下来的东西。 证明覆盖其规范,对其之外的任何需求只字不提。
这就是经验验证的用武之地。PDD Kit 明确说明了受信任边界,即此证明所依赖的 TCB 部分。它用假定契约将每个受信任的部分包装在一个小函数中,使用 Verus 的 #[verifier::external_body] 或 external_fn_specification,要求开发者逐一接受每一个,并将每个假定条款的测试写入计划。对于登录,开发者签署了六个部分:
- 两个查找,
Customer::find_by_email和Admin::find_by_username。每个等待成功的查找恰好返回其自己的表为该电子邮件或用户名持有的行,或者不返回任何行。 check_password,一个围绕bcrypt::verify的薄包装器。它仅在存储的哈希 bcrypt 无法读取时报错,而Ok(true)恰好表示正确的密码。generate_token,它在配置的密钥下恰好签署给定的声明,并将签名失败报告为 500。expiry_from_now,它读取时钟并写入恰好 24 小时后的过期时间。database_error,它将失败的查找转换为今天带有 sqlx 消息的 500。- 登录处理程序外壳,它解包请求并将其字段原样传递给
login_flow。
在规范中,查找操作将代码保留在原处,并通过 external_fn_specification 从外部获取契约:
/// Looks up a customer by exact email.
/// Trusted, not proved: the SQL query runs in sqlx, which Verus cannot check.
/// Every customer promise of `login_flow` rests on it.
#[verifier::external_fn_specification]
pub async fn ex_customer_find_by_email(pool: &sqlx::SqlitePool, email: &str)
-> (r: Result, sqlx::Error>)
ensures
// When the query succeeds, it returns the customers row on
// file for exactly this email, or None when there is none.
r matches Ok(found) ==> found == customer_on_file(email@),
{
Customer::find_by_email(pool, email).await
}
时钟是一个标记为 #[verifier::external_body] 的小型包装器。Verus 使用其 ensures 子句,从不读取函数体:
/// Reads the clock and computes a token's expiry 24 hours ahead.
/// Trusted, not proved: chrono's clock is outside the proof.
/// It panics if the time is out of range, as today's login does;
/// that panic is not promised against.
#[verifier::external_body]
pub fn expiry_from_now() -> (r: Expiry)
ensures
// The expiry is exactly 24 hours (86,400 seconds) after the clock reading.
r.exp as int == r.now as int + 86_400,
{
...
}
图 7 将三个层次放在一起:Verus 证明了什么、人们接受并由测试检查什么,以及没有人承诺什么。

图 8 映射了哪些内容被证明、哪些内容被信任。每个信任决策都是独立的一张卡片。它展示了 Verus 将凭信念接受的子句、开发者所接受的内容,以及如果假设错误哪个承诺会失效(图 9)。随后,每个被假设的子句都会在计划的“经验验证”部分获得经验性检查,在任何测试存在之前就以 Given/When/Then 场景的形式写出(图 10)。在运行之前,条目会标注为已计划,运行后则记录命令的结果及其运行所针对的提交。


check_password 的信任卡片。Verus 凭信念接受这两个子句,因此由人接受它们,并由两个测试检查它们。 
check_password 计划的测试,在测试存在之前写出。计划将通过的测试标记为证据而非证明。 计划同样明确说明了没有人承诺的内容:登录 future 最终会完成、超出范围的时钟不会 panic、关于尝试的时序或速率的任何内容,以及其他端点如何读取角色。
第 5 步:阅读规范
意图工件是团队审查的内容。它是 PDD Kit 作为 Verus 契约所编写的正式规范的通俗语言版本。你可能会想,现在是否必须有人阅读证明而不是代码。没有人需要这样做,因为编码代理编写证明,而 Verus 检查证明的每一步。如果 Verus 接受该证明,那么无论是否有人阅读它,它都成立。规范是证明所必须满足的标准,因此人们审查的就是规范,而这个规范足够短,几分钟就能学会阅读。每个函数都有一个 ensures 块:关于其结果 r 的陈述,必须对每个可能的输入都成立。Verus 在构建时检查它们,如果任何子句哪怕对一个输入可能失败,构建就会失败,Verus 会指向该子句。每个子句都读作一个 if-then,图 11 拆解了开发者第一个需求的子句。

注意,每一行都出现同一个 a,这把密码校验与令牌绑定在一起。例如,设想一个 bug:登录时用某个账户校验密码,却为另一个账户签发令牌。Bob 用他自己的正确密码登录,却拿回一个属于 Alice 账户的令牌。每一步单独看都没问题,但这个子句能抓住它,因为密码校验和令牌必须指向同一个账户。这正是测试套件很少想到去查找的那类接线 bug。规范还必须在不实际运行 SQLite、bcrypt 和 JWT 的情况下谈论它们。它通过未解释函数做到这一点,这些函数只有名字没有函数体:customer_on_file(email) 和 admin_on_file(username) 表示数据库持有的行,bcrypt_verdict(password, hash) 表示 bcrypt 会给出的结果,token_claims(token, secret) 表示令牌携带的内容。Verus 对它们一无所知,只知道第 4 步中受信任契约所承诺的内容。证明说的是“无论数据库持有什么,登录都能正确处理它”,而一个受信任契约说“查询返回数据库持有的内容”。
/// The customers row the database holds for
/// exactly this email, if any, when login looks.
/// Left abstract: only `Customer::find_by_email`'s
/// trusted contract says what it is.
pub uninterp spec fn customer_on_file(email: Seq) -> Option;
...
/// What bcrypt says about `password` against a
/// stored `hash`: Some(true) when it is the password
/// the hash was made from, Some(false) when it is
/// not, None when bcrypt cannot read the hash.
/// Left abstract: its meaning is bcrypt's, taken on trust through `check_password`.
pub uninterp spec fn bcrypt_verdict(password: Seq, hash: Seq)
-> Option;
/// The password is the right one for the stored hash, by bcrypt's verdict.
pub open spec fn password_matches(password: Seq, hash: Seq) -> bool {
bcrypt_verdict(password, hash) == Some(true)
}
在这些之上,named_account(email, username) 说明一次登录指向哪个账户:如果发送了邮箱,就是该邮箱对应的 customers 行;否则就是该用户名对应的 admins 行;再否则就没有。它写起来像 Rust,但它是一个从不运行的数学定义,只出现在契约中。这样的代码被称为幽灵代码,因为验证器会检查它,但编译器会把它从程序中擦除。有了它,开发者那两句话的请求最终变成 login_flow 上的八个 ensures 子句(图 12)。
/// The account a login names: the customers row for
/// the email when one is sent (role "customer"),
/// otherwise the admins row for the username (role "admin"), otherwise none.
pub open spec fn named_account(email: Option, username: Option)
-> Option {
match email {
Some(e) => match customer_on_file(e@) {
Some(c) => Some(NamedAccount {
id: c.id@, password_hash: c.password_hash@, role: customer_role() }),
None => None,
},
None => match username {
Some(u) => match admin_on_file(u@) {
Some(a) => Some(NamedAccount {
id: a.id@, password_hash: a.password_hash@, role: admin_role() }),
None => None,
},
None => None,
},
}
}

下面是完整的契约,其文档注释已删减。每个子句上方都有意图工件向团队展示的通俗英文注释:
/// The whole login, awaited end to end: from the
/// request's fields to the token or the HTTP error.
/// ...
pub async fn login_flow(pool: &sqlx::SqlitePool, config: &Config,
email: Option, username: Option, password: &str)
-> (r: Result)
ensures
// Neither an email nor a username: 400 with today's message.
email is None && username is None
==> (r matches Err(AppError::BadRequest(m))
&& m@ == missing_identity_text()),
// A token only for the account the login names, with its right
// password; it carries that account's id and its table's role.
r matches Ok(t) ==> (named_account(email, username) matches Some(a)
&& password_matches(password@, a.password_hash)
&& token_claims(t@, config.jwt_secret@) matches Some(c)
&& c.sub@ == a.id && c.role@ == a.role),
// The right password for the named account always gets a
// token, unless a lookup or signing failure gives 500.
(named_account(email, username) matches Some(a)
&& password_matches(password@, a.password_hash))
==> (r is Ok || r matches Err(AppError::Internal(_))),
// A login naming an account nobody holds gets
// 401, unless the lookup fails with 500.
((email is Some || username is Some) && named_account(email, username) is None)
==> (r matches Err(AppError::Unauthorized)
|| r matches Err(AppError::Internal(_))),
// A wrong password for an account whose hash bcrypt
// can read gets 401, unless the lookup fails with 500.
(named_account(email, username) matches Some(a)
&& bcrypt_verdict(password@, a.password_hash) == Some(false))
==> (r matches Err(AppError::Unauthorized)
|| r matches Err(AppError::Internal(_))),
// An account whose stored hash bcrypt cannot read gets 500 and no token.
(named_account(email, username) matches Some(a)
&& bcrypt_verdict(password@, a.password_hash) is None)
==> r matches Err(AppError::Internal(_)),
// The admin role only for an admins row
// found by username, with no email sent.
(r matches Ok(t) && token_claims(t@, config.jwt_secret@) matches Some(c)
&& c.role@ == admin_role())
==> (email is None && username matches Some(u)
&& admin_on_file(u@) is Some),
// An email login's token always carries the customer role.
(r matches Ok(t) && email is Some)
==> (token_claims(t@, config.jwt_secret@) matches Some(c)
&& c.role@ == customer_role()),
{
unimplemented!()
}
受信任边界也出现在代码中。check_password 是 Verus 不检查其函数体的函数之一。它的契约把它与 bcrypt_verdict 绑定,后者是 bcrypt 的一个没有公理的未解释模型:
/// Asks bcrypt whether `password` matches the stored `hash`.
/// Trusted, not proved: Verus cannot check bcrypt's hashing,
/// so this wrapper's contract is assumed.
/// Every password promise of `login_flow` rests on it.
#[verifier::external_body]
pub fn check_password(password: &str, hash: &str) -> (r: Result)
ensures
// bcrypt answers (Ok) exactly when it can read the stored hash.
r is Ok <==> bcrypt_verdict(password@, hash@) is Some,
// Its answer is its verdict: true for the
// right password, false for any other.
r matches Ok(b) ==> bcrypt_verdict(password@, hash@) == Some(b),
{
bcrypt::verify(password, hash).map_err(|e| e.to_string())
}
它上方的文档注释说明了什么依赖于它:login_flow 的每一个密码承诺。 每次登录都会在少数几个位置之一结束,其中只有一个位置有令牌(图 13)。证明一次性覆盖了所有这些位置。

login_flow 是异步的:它会等待一个数据库查询。Verus 可以给异步函数加上契约,有一条规则值得了解(图 14)。requires 在调用创建 future 时被检查。ensures 描述你 await 它时得到的值。没有任何东西承诺 future 会完成,所以挂起的查询不会违反任何子句。
login_flow 上的文档注释明确说明了这一点:
/// ... No promise the future completes.
pub async fn login_flow(...) -> (r: Result)

sync fn 和 async fn,契约在何时被检查。 login_flow 保持简短,因为它是由更小的已证明片段构建的。lookup_target 选择表。login_core 等待查询并调用 decide_login,即密码门。issue_token 签发令牌,或将拒绝映射为当前的 HTTP 错误。每个片段都有自己的契约,Verus 仅使用这些契约来证明 login_flow,而不重新阅读它们的函数体。修改 decide_login 的函数体,只有 decide_login 会被重新证明。
下面是最小的片段,完整呈现,以及密码门的开头和结尾:
pub fn lookup_target(email: Option, username: Option)
-> (r: Result)
ensures
// An email, when sent, wins: customers are searched for exactly that email.
email matches Some(e)
==> r == Ok::(LookupTarget::CustomerByEmail(e)),
// With no email, a username sends the search
// to admins for exactly that username.
email is None && username matches Some(u)
==> r == Ok::(LookupTarget::AdminByUsername(u)),
// With neither, the login is refused for missing an identity.
email is None && username is None
==> r == Err::(LoginError::MissingIdentity),
pub fn decide_login(target: &LookupTarget, found: Option, password: &str)
-> (r: Result)
ensures
// No row found: refused as Unauthorized,
// the same answer as a wrong password.
found is None
==> r == Err::(LoginError::Unauthorized),
// Claims come back only for a found row
// whose stored hash matches the password.
r is Ok ==> found is Some
&& password_matches(password@, found->Some_0.password_hash@),
// The right password for a found row always gets claims.
found matches Some(a) && password_matches(password@, a.password_hash@)
==> r is Ok,
...
// An admins-table search gives the role text "admin".
r matches Ok(c) && target is AdminByUsername ==> c.role@ == admin_role(),
// A customers-table search gives the role text "customer".
r matches Ok(c) && target is CustomerByEmail ==> c.role@ == customer_role(),
login.rs 概览
图 15 映射了整个规范文件 verified/src/login.rs。它自上而下分为四层:头文件、Verus 需要了解的导入和类型、外部世界的抽象视图、将该视图连接到真实代码的可信契约,以及证明必须满足的五项契约。

login.rs 的各个部分,按文件顺序排列。灰色是头文件和类型,紫色是仅出现在契约中的数学,琥珀色是可信的,绿色是已证明的。以下是两个不太明显的部分,已作删节。external_type_specification 引入了一个 Verus 未定义的类型。pool 保持不透明,而 customers 行则保持其字段可见:
/// The connection pool, opaque to the proof: only
/// the lookups' contracts say what it returns.
#[verifier::external_type_specification]
#[verifier::external_body]
#[verifier::reject_recursive_types(DB)]
pub struct ExPool(sqlx::Pool);
...
/// A customers row, with its fields visible to the proof.
#[verifier::external_type_specification]
pub struct ExCustomer(Customer);
而 LoginError 将决策与 HTTP 响应分开,这正是让 issue_token 能将每次拒绝固定到当前状态码的原因:
/// Why a login gets no token.
pub enum LoginError {
/// Neither an email nor a username was sent: 400 Bad Request.
MissingIdentity,
/// No such account, or the password does not match: 401 Unauthorized.
Unauthorized,
/// bcrypt could not read the stored hash, with its message: 500 Internal.
UnreadableHash(String),
/// The lookup query failed: 500 Internal, with sqlx's message.
Database(sqlx::Error),
}
在计划返回给你之前,两个审查代理会从意图工件中对推导出的形式化规范挑毛病,以确保高质量的翻译,使规范符合你的要求,并且不会承诺超出证明所能展示的内容。规范细化在后台运行,并接收实时用户反馈,反复迭代,直到审查者一致认为规范是可靠的。
第 6 步:证明、检查、填补缺口
对规范签字确认是开发者审查的终点。此后的一切,包括实现和证明,都由机器检查,这就是为什么本文把时间花在规范上而不是证明上。开发者签字确认后,一个构建计划阶段(图 16)将已批准的计划交给 pdd-prove,后者将其转化为一个小型任务图(图 16)。对于登录,这是七个任务:让 login.rs 在后端 crate 内于 Verus 下构建,证明决策、令牌和整个流程,然后为可信包装器和端到端登录编写测试。

每个任务运行相同的循环:
- 编码代理编写实现和证明,Verus 根据推导出的形式化规范检查它们。
- 可信边界内的任何内容都会获得计划所要求的测试。
- 一个 LLM 验证器并排阅读计划、规范和结果,寻找缺口:没有证明也没有通过检查的需求。缺口会返回给编码代理,直到不再有缺口。如果规范本身有缺口,例如两个需求相互矛盾,它会作为决策返回给开发者。
证明的好坏取决于其规范,所以 pdd-prove 也会检查规范。当模型编写自己的规范,或被调优以生成证明时,它往往会落在弱不变量上:这些子句能通过验证,但几乎排除不了任何东西。它们证明了某些东西,但很少是你关心的东西。我们称之为“氛围验证”。这就是为什么确保你的意图与代理对齐如此重要,也是 PDD Kit 擅长的地方。
这个循环严格禁止移动目标。如果某个异步构造无法在固定的 Verus 中表达,任务会停下来询问团队,而不是切换到非异步辅助函数或削弱契约。
PDD Kit 如何消除各类 bug?变异检查正是 PDD Kit 用来防范“凭感觉验证”的手段。pdd-prove 会在代码的临时副本中,针对每个 ensures 子句植入一个 bug,比如拒绝所有人,或在检查邮箱之前先检查用户名,而 Verus 必须在其针对的子句上拒绝每一个变异体。如果有变异体通过了,就说明该子句没有约束任何东西,它会作为缺口被反馈回来。
第 7 步:在审查已验证代码的同时审查意图
当任务通过后,pdd-prove 会发起拉取请求。它们会添加一个 verified/ crate,其中包含 login_flow、它的各个组成部分、它们的证明,以及带有测试的可信包装器,并将真实的登录处理器路由到 login_flow,同时保留现有的 HTTP 状态码。你已验证的逻辑会集成到生产登录路径中并强化它,而不是它的副本。
开发者能够在不必对生成代码进行逐行详尽审查的情况下完成合并。审查其实一直都是在意图产物上进行的,团队在那里拥有最终决定权。我们将更多审查重心从代码审查转移到了意图审查。拉取请求充当收据,其描述将每一条登录需求标记为已形式化验证或已经验性检查。
合并是证明融入团队正常工作流的地方。PDD Kit 的 pdd-ship 工作流会将 Verus 检查与经验性测试一起加入 CI,因此之后对登录的任何更改,无论是人还是智能体做出的,都必须继续针对同一规范进行证明,而损坏的证明会像失败的测试一样使构建失败。而且由于证明覆盖所有输入,PDD Kit 会剪除那些只是重复检查已被证明内容的测试,例如针对密码门控的基于示例的测试,并保留那些守护可信边界的测试,因此 CI 同时变得更强、更精简。
证明说明了什么、没有说明什么
“完全验证”很容易被过度宣传,所以这里准确说明我们的主张。
- 已证明。
login_flow、login_core、lookup_target、decide_login和issue_token的函数体对每个输入都满足其契约。登录会选择正确的表,将请求中的密码与它所指定账户的存储哈希进行比较,签发一个带有该账户 id 及其表所赋予角色的令牌,并以当前的 400、401 或 500 拒绝其他所有情况且不签发令牌。 - 可信且已测试。 两次数据库查找、
check_password基于 bcrypt 的部分、时钟、令牌签名、数据库错误消息以及处理器的请求解包都位于证明之外,各自处于开发者已接受的假定契约之后。针对真实 bcrypt、真实 SQLite schema 和真实路由器的测试覆盖了它们。 - 假定。 Verus、Rust 编译器和 Z3(在新标签页中打开) 工具链,以及 Verus 的标准库 vstd(在新标签页中打开)。
为什么选择 Rust
PDD Kit 目前只验证 Rust,因为这是 Verus 所工作的语言。如果你的代码库已经是 Rust,则无需迁移。其他语言的代码可以移植到 Rust 并针对同一规范进行检查,非 Rust 项目可以通过绑定调用已验证的 Rust。
还有一个不那么明显的好处。一旦对逐行详尽审查的依赖减少,可读性就不再是选择语言的主要原因。你可以为了速度和安全性选择一种语言,并尽情优化,因为任何破坏规范的更改都会使证明失败。
对 PDD Kit 的期待
PDD Kit 是一个研究原型,目前只验证 Rust,因为 Verus 所支持的语言就是 Rust。其他语言的代码可以移植到 Rust,并对照同一份规范进行检查;非 Rust 项目也可以通过绑定调用经过验证的 Rust 代码。证明仍然需要付出实实在在的努力,主要在于产出证明本身,而清晰的规范正是压低这份努力的关键,这也是 PDD Kit 把大量精力放在先厘清意图上的原因。我们会从缺陷代价最高的地方开始,比如登录、支付或权限,然后逐块验证代码库的其余部分。
Pet Store 的登录只是某个应用的一个部分:一份任何人都能读懂的规范、一个证明代码与规范相符的 Verus 证明,以及针对证明所信任的一切的测试。我们想坦率说明最薄弱的环节。这些保证的强度只等同于六个受信任组件,其中两个承担了大部分分量:check_password 对应每一条密码承诺,以及两个查找对应每一条关于令牌指向哪个账户的承诺。测试为它们提供支持,但没有任何东西证明它们。我们预计这个受信任边界会随时间显著缩小,从而让越来越大比例的代码可以被形式化验证,我们也想弄清楚这能扩展到什么程度。
我们真正改变的是人们审查的对象。团队不再主要依赖对智能体生成的代码逐行审查,而是用平实的语言审查它的意图,再由一个证明来让代码符合该意图。计划本来就应该是契约,而现在智能体确实必须遵守它。
致谢
本工作是 Microsoft Research Catalyst Lab、GitHub Next 和软件工程研究(RiSE)组的合作成果。我们感谢 GitHub Next 的 Maggie Appleton 和 Krzysztof Cieślak 在 Chopin 上的合作,感谢 Nikhil Swamy、Lef Ioannidis、Janardhan Kulkarni 和 Shuvendu Lahiri 就形式化验证与意图形式化提供的反馈和讨论。我们也感谢 F*/Pulse 和 Verus 团队的支持。最后,我们感谢 GitHub Next 的 Idan Gazit 对本次合作的支持。
常见问题
-
是的,确实会。证明只能表明代码符合规范,而不能表明规范就是你的本意。这就是 PDD Kit 围绕意图形式化的现有研究来构建的原因。pdd-plan 工作流会阅读你的代码,在写任何东西之前先提问,并把你的回答转化为一份 Verus 规范以及一份你批准的平实语言计划,遇到歧义会标记出来而不是猜测。两个审查智能体会检查规范,pdd-prove 工作流会为每个条款植入一个缺陷,以捕捉那些能通过验证却什么也没排除的条款。错误的规范仍然可能出现,这就是 PDD Kit 强调让需求明确、识别歧义,并让开发者在验证开始前参与审查和批准规范的原因。
-
区别在于用什么让代码符合计划。
方法 什么在检查代码 规范驱动开发 没有任何东西强制执行规范 测试驱动开发 在输入样本上运行测试,这些测试往往是为了匹配已构建的东西而写的 智能体检查自己的工作 写代码的同一个模型 证明驱动开发 Verus 自动验证你的代码满足你所批准规范的证明 PDD 保留了 SDD 的规划循环,并在证明无法触及的地方仍然使用测试。测试检查部分输入,而证明则在所述假设下,确立代码对形式化模型覆盖的所有输入都满足指定性质。
-
证明是带有 Verus 构造注解的 Rust 代码,由 Verus 使用 Z3 求解器进行机械检查。它在你构建 Rust 的任何地方运行:pdd-prove 工作流在证明时运行它,而 pdd-ship 工作流会将其加入你的构建和 CI,因此损坏的证明会导致构建失败。该检查是确定性的,不涉及任何模型。模型编写规范、代码和证明,但只有确定性的 Verus 运行才能决定其是否通过。检查所需时间因代码规模和证明难度而异:小型模块通常只需几秒,较大的代码库则可能长达数分钟。
-
PDD Kit 将更多审查流程转向验证代码应实现的功能,同时利用形式化验证提供额外保障,确保实现满足这些需求。开发者审查自然语言计划、形式化规范以及不在证明范围内的可信组件。由此产生的拉取请求会记录哪些内容经过了形式化验证、哪些内容经过了测试,以及哪些地方可能需要额外审查。在 Pet Store 全栈 Web 应用上,这种方法减少了对生成代码进行逐行详尽审查的依赖。
-
我们选择 Verus,因为它允许我们直接验证 Rust 代码,这与 PDD Kit 将形式化验证集成到现有 Rust 开发工作流中的重点相一致。 其他证明语言(如 Lean、F*、Rocq)采用不同的形式化验证方法,各有其权衡。PDD Kit 背后的技术也可以帮助它们发挥作用。
在新标签页中打开
文章 使用 PDD Kit 对智能体编写的全栈 Web 应用进行形式化验证 首次出现在 Microsoft Research。
来源:Microsoft Research · microsoft.com