Skip to content

Commit 8d4470f

Browse files
Khaclaude
andcommitted
feat: add lake check to check a project against the kernel
This PR adds `lake check`, which builds a project's default targets, exports them, replays the result through the kernel, and reports the axioms that code rests on, failing on any beyond `propext`, `Classical.choice` and `Quot.sound`. There is no challenge to compare against. Nothing about the project is evaluated outside the sandbox. `lake env` resolves its dependencies and `lake query :modules` names the modules to check, both inside it, so the project's configuration is never elaborated in Lake's own address space; the build and the export follow in the same sandbox. As for `lake challenge`, this requires the project to carry a `lake-manifest.json`, since the sandbox cannot write one into the project directory. The exporter is given no declaration list, so the export covers everything in scope rather than only what the project declares, and a check costs roughly the same whatever the project's size: about a minute for a project holding a single theorem. The axiom report copes with that without a list of roots: an axiom that is merely importable is referred to by nothing, so the axioms that some other constant refers to are exactly the ones the code rests on. Note that the kernel accepts `sorryAx`, so a `sorry` is caught by this report and by nothing else. All the default targets' modules go through the pipeline together, in one sandboxed `lake build`, one export and one kernel replay. Since each module's export already covers its whole import closure, a pass per module would re-check what they share: on a two-root project the roots' exports agree on 6,437,744 of 6,437,817 lines, so the second pass would double the run for two extra constants. The sandbox invocations in `Lake.Check` get one definition each. `landrunSpawnArgs` builds the `IO.Process.SpawnArgs` that both `runSandBoxedWithStdout` and `runSandBoxedExitCode` use, and `runSandBoxed` is the latter plus the exit-code check. `runExternalKernel` goes through `runSandBoxedExitCode` rather than spawning `landrun` itself, keeping its own messages. `runExporter` holds the exporter's grants, which `safeExport` and `exportModules` had spelled out identically. The two commands share the tool resolution and the sandbox context through `mkContext`; the vendored comparison path is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b225f55 commit 8d4470f

21 files changed

Lines changed: 333 additions & 37 deletions

File tree

‎src/lake/Lake/CLI/Check.lean‎

Lines changed: 107 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -117,29 +117,29 @@ def buildLandrunArgs (spawnArgs : LandrunArgs) : Array String :=
117117
let args := spawnArgs.connectPorts.foldl (init := args) (fun acc port => acc ++ #["--connect-tcp", port])
118118
args ++ #["--", spawnArgs.cmd] ++ spawnArgs.args
119119

120-
def runSandBoxedWithStdout (spawnArgs : LandrunArgs) : M String := do
121-
let args := buildLandrunArgs spawnArgs
122-
let { stdout, stderr, exitCode } ← IO.Process.output {
123-
cmd := (← read).whichLandrun,
124-
args,
120+
/-- The `landrun` invocation that puts `spawnArgs` under the sandbox. -/
121+
def landrunSpawnArgs (spawnArgs : LandrunArgs) : M IO.Process.SpawnArgs := do
122+
return {
123+
cmd := (← read).whichLandrun
124+
args := buildLandrunArgs spawnArgs
125125
env := spawnArgs.envOverride
126-
cwd := (← getProjectDir)
126+
cwd := ← getProjectDir
127127
}
128+
129+
def runSandBoxedWithStdout (spawnArgs : LandrunArgs) : M String := do
130+
let { stdout, stderr, exitCode } ← IO.Process.output (← landrunSpawnArgs spawnArgs)
128131
IO.eprint stderr
129132
if exitCode != 0 then
130133
throw <| .userError s!"Child exited with {exitCode}"
131134
return stdout
132135

136+
/-- Runs `spawnArgs` sandboxed, letting its output through, and returns its exit code. -/
137+
def runSandBoxedExitCode (spawnArgs : LandrunArgs) : M UInt32 := do
138+
let proc ← IO.Process.spawn (← landrunSpawnArgs spawnArgs)
139+
proc.wait
133140

134141
def runSandBoxed (spawnArgs : LandrunArgs) : M Unit := do
135-
let args := buildLandrunArgs spawnArgs
136-
let proc ← IO.Process.spawn {
137-
cmd := (← read).whichLandrun,
138-
args,
139-
env := spawnArgs.envOverride
140-
cwd := (← getProjectDir)
141-
}
142-
let ret ← proc.wait
142+
let ret ← runSandBoxedExitCode spawnArgs
143143
if ret != 0 then
144144
throw <| .userError s!"Child exited with {ret}"
145145

@@ -183,8 +183,34 @@ def safeResolveWorkspace : M (String × String) := do
183183
throw <| .userError "`lake env` did not report the project's search path"
184184
return (leanPath, binPath)
185185

186-
def safeLakeBuild (target : Lean.Name) : M Unit := do
187-
IO.println s!"Building {target}"
186+
/--
187+
Asks the project which modules its default targets contain, inside the sandbox.
188+
189+
Resolving targets means evaluating the project's configuration, which is code, so it happens where
190+
every other evaluation of it happens. `lake query :modules` reports the modules of the package's
191+
default targets, one per line.
192+
-/
193+
def safeQueryModules : M (Array Lean.Name) := do
194+
IO.println "Resolving targets"
195+
let projectDir ← getProjectDir
196+
let whichLake := (← read).whichLake
197+
let out ← runSandBoxedWithStdout {
198+
cmd := whichLake.toString,
199+
args := #["query", ":modules"],
200+
envPass := #["PATH", "HOME", "LEAN_ABORT_ON_PANIC"]
201+
envOverride := #[("LEAN_ABORT_ON_PANIC", some "1")]
202+
readablePaths := #[projectDir]
203+
writablePaths := #[projectDir / ".lake"]
204+
}
205+
let mods := out.split '\n' |>.toStringList |>.filterMap fun line =>
206+
let line := line.trimAscii.toString
207+
if line.isEmpty then none else some line.toName
208+
return mods.toArray
209+
210+
def safeLakeBuild (targets : Array Lean.Name) : M Unit := do
211+
let targetArgs := targets.map (·.toString)
212+
let targetList := " ".intercalate targetArgs.toList
213+
IO.println s!"Building {targetList}"
188214
let projectDir ← getProjectDir
189215
let dotLakeDir := projectDir / ".lake"
190216

@@ -194,31 +220,31 @@ def safeLakeBuild (target : Lean.Name) : M Unit := do
194220
let whichLake := (← read).whichLake
195221
runSandBoxed {
196222
cmd := whichLake.toString,
197-
args := #["build", target.toString],
223+
args := #["build"] ++ targetArgs,
198224
envPass := #["PATH", "HOME", "LEAN_ABORT_ON_PANIC"]
199225
envOverride := #[("LEAN_ABORT_ON_PANIC", some "1")]
200226
readablePaths := #[projectDir]
201227
writablePaths := #[dotLakeDir]
202228
}
203229

204-
def safeExport (module : Lean.Name) (decls : Array Lean.Name) : M String := do
205-
IO.println s!"Exporting {decls} from {module}"
206-
let baseArgs := #[module.toString, "--"]
207-
let args := decls.foldl (·.push <| ·.toString) baseArgs
208-
230+
/-- Runs the bundled exporter in the sandbox, with the grants every export needs. -/
231+
def runExporter (args : Array String) : M String := do
209232
let projectDir ← getProjectDir
210-
let dotLakeDir := projectDir / ".lake"
211233
let whichLean4Export := (← read).whichLean4Export
212234
runSandBoxedWithStdout {
213235
cmd := whichLean4Export.toString
214-
args := args,
236+
args
215237
envPass := #["PATH", "HOME", "LEAN_PATH", "LEAN_ABORT_ON_PANIC"]
216238
envOverride := #[("LEAN_ABORT_ON_PANIC", some "1"), ("LEAN_PATH", some (← read).leanPath),
217239
("PATH", some (← read).binPath)]
218-
readablePaths := #[projectDir, dotLakeDir]
240+
readablePaths := #[projectDir, projectDir / ".lake"]
219241
writablePaths := #[]
220242
}
221243

244+
def safeExport (module : Lean.Name) (decls : Array Lean.Name) : M String := do
245+
IO.println s!"Exporting {decls} from {module}"
246+
runExporter <| #[module.toString, "--"] ++ decls.map (·.toString)
247+
222248
def runExternalKernel (kernelName : String) (kernelCommand : Array String)
223249
(solutionExport : String) : M (Option String) := do
224250
IO.println s!"Running {kernelName} kernel on solution"
@@ -253,17 +279,8 @@ def runExternalKernel (kernelName : String) (kernelCommand : Array String)
253279
readablePaths := #[configPath.toString, solutionPath.toString]
254280
writablePaths := #[]
255281
}
256-
let args := buildLandrunArgs spawnArgs
257-
258282
try
259-
let proc ← IO.Process.spawn {
260-
cmd := (← read).whichLandrun,
261-
args,
262-
env := spawnArgs.envOverride
263-
cwd := (← getProjectDir)
264-
}
265-
266-
let ret ← proc.wait
283+
let ret ← runSandBoxedExitCode spawnArgs
267284
if ret != 0 then
268285
IO.println s!"{kernelName} kernel rejected the solution"
269286
return some s!"{kernelName} exited with {ret}"
@@ -375,11 +392,11 @@ public def compareIt : M Unit := do
375392
++ (← primitiveTargets) ++ (← getDefinitionNames)
376393

377394
let challengeModule ← getChallengeModule
378-
safeLakeBuild challengeModule
395+
safeLakeBuild #[challengeModule]
379396
let challengeExport ← safeExport challengeModule exportTargets
380397

381398
let solutionModule ← getSolutionModule
382-
safeLakeBuild solutionModule
399+
safeLakeBuild #[solutionModule]
383400
let solutionExport ← safeExport solutionModule exportTargets
384401

385402
verifyMatch challengeExport solutionExport
@@ -465,6 +482,35 @@ def resolveExternalKernels (cfg : Config) : IO (Except ExitCode (Std.TreeMap Str
465482
return .error (← cannotRun s!"`{kernelName}` kernel `{kernelCommand[0]!}` was not found")
466483
return .ok externalKernels
467484

485+
/-- Exports everything in scope in `modules`. -/
486+
def exportModules (modules : Array Lean.Name) : M String := do
487+
let moduleArgs := modules.map (·.toString)
488+
IO.println s!"Exporting the declarations of {" ".intercalate moduleArgs.toList}"
489+
runExporter moduleArgs
490+
491+
def standardAxioms : Array Lean.Name :=
492+
#[``propext, ``Classical.choice, ``Quot.sound]
493+
494+
/-- Reports the axioms the checked modules rest on, and rejects any beyond `standardAxioms`. -/
495+
def checkUsedAxioms (exported : LeanExport.ExportedEnv) : M Unit := do
496+
let used := usedAxioms exported
497+
if used.isEmpty then
498+
IO.println "Uses no axioms"
499+
else
500+
IO.println s!"Uses axioms: {", ".intercalate (used.toList.map (·.1.toString))}"
501+
let illegal := used.filter fun (ax, _) => !standardAxioms.contains ax
502+
unless illegal.isEmpty do
503+
throw <| .userError <| "\n".intercalate <| illegal.toList.map fun (ax, ref) =>
504+
s!"Axiom '{ax}' is not permitted; it is used by '{ref}'"
505+
506+
/-- Checks a set of module roots at once against the kernel with no challenge to compare it to. -/
507+
def checkModules (modules : Array Lean.Name) : M Unit := do
508+
safeLakeBuild modules
509+
let exported ← LeanExport.parseStream (← stringStream (← exportModules modules))
510+
if let some error ← runBuiltinKernel exported then
511+
throw <| .userError error
512+
checkUsedAxioms exported
513+
468514
/--
469515
Runs `lake challenge`: builds and exports the challenge and the solution in a sandbox, then judges
470516
the solution against the challenge.
@@ -514,4 +560,28 @@ public def runChallenge (configFile? : Option System.FilePath) (lean : LeanInsta
514560
IO.eprintln s!"error: {e}"
515561
return 1
516562

563+
/--
564+
Runs `lake check`: builds and exports the project's default targets in the sandbox and checks them
565+
with the kernel, with no challenge to compare them against.
566+
-/
567+
public def runCheck (lean : LeanInstall) (lake : LakeInstall)
568+
(projectDir : System.FilePath) : IO ExitCode := do
569+
let base ←
570+
match ← mkContext "check" lean lake projectDir with
571+
| .error rc => return rc
572+
| .ok ctx => pure ctx
573+
if let some rc ← checkManifest "check" base.projectDir then
574+
return rc
575+
try
576+
let (leanPath, binPath) ← ReaderT.run safeResolveWorkspace base
577+
let ctx := { base with leanPath, binPath }
578+
let targets ← ReaderT.run safeQueryModules ctx
579+
if targets.isEmpty then
580+
return ← cannotRun "nothing to check: this project has no default build targets"
581+
ReaderT.run (checkModules targets) ctx
582+
return 0
583+
catch e =>
584+
IO.eprintln s!"error: {e}"
585+
return 1
586+
517587
end Lake.Check

‎src/lake/Lake/CLI/Help.lean‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ COMMANDS:
3131
clean remove build outputs
3232
shake minimize imports in source files
3333
challenge judge a solution against a challenge
34+
check check this project against external checker(s)
3435
env <cmd> <args>... execute a command in Lake's environment
3536
lean <file> elaborate a Lean file in Lake's context
3637
update update dependencies and save them to the manifest
@@ -479,6 +480,48 @@ HARDENING:
479480
systemd-run --user --pty --property=RestrictAddressFamilies=~AF_UNIX \\
480481
lake challenge --config challenge.json"
481482

483+
def helpCheck :=
484+
"Check this project against external checker(s)
485+
486+
USAGE:
487+
lake check
488+
489+
Builds the default build targets, exports them, and replays the result through
490+
the kernel, erroring on any use of non-standard axioms.
491+
492+
The project is untrusted input: its configuration is evaluated, and its code
493+
built and exported, inside a `landrun` sandbox, and none of its `.olean` files
494+
is ever loaded into Lake's own address space. Both the dependencies and the
495+
targets to check are resolved there, since resolving either evaluates the
496+
configuration and that is code. `landrun` is required; there is no unsandboxed
497+
mode, so this command is available on Linux only.
498+
499+
The project has to carry a `lake-manifest.json`, because dependencies are
500+
resolved inside the sandbox and it cannot write to the project directory.
501+
Building the project once is enough to write one.
502+
503+
EXIT CODES:
504+
0 the kernel accepts the project and it rests only on the
505+
permitted axioms
506+
1 the kernel rejects it, an axiom is not permitted, or a
507+
build did not succeed
508+
2 could not start: `landrun` is missing, the project has
509+
no `lake-manifest.json`, or it has no default targets
510+
511+
ENVIRONMENT:
512+
COMPARATOR_LANDRUN sandbox executable (default: `landrun` on PATH)
513+
514+
The exporter is always the `leanexport` of this toolchain, and deliberately
515+
not configurable: the export format has to match the compiler that produced
516+
the `.olean` files being exported.
517+
518+
HARDENING:
519+
The sandbox bounds writes and TCP connections exactly as `lake challenge`'s
520+
does, and its limits and the `AF_UNIX` caveat apply here too. See the
521+
HARDENING section of `lake help challenge`.
522+
523+
See `lake help challenge` to judge a solution against a challenge instead."
524+
482525
def helpCacheCli :=
483526
"Manage the Lake cache
484527
@@ -864,6 +907,7 @@ public def help : (cmd : String) → String
864907
| "clean" => helpClean
865908
| "shake" => helpShake
866909
| "challenge" => helpChallenge
910+
| "check" => helpCheck
867911
| "script" => helpScriptCli
868912
| "scripts" => helpScriptList
869913
| "run" => helpScriptRun

‎src/lake/Lake/CLI/Main.lean‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,17 @@ protected def challenge : CliM PUnit := do
11761176
let cfg ← mkLoadConfig opts
11771177
exit <| ← Check.runChallenge opts.challengeConfig? leanInstall lakeInstall cfg.wsDir
11781178

1179+
/-- The `lake check` command: check this project against the kernel. -/
1180+
protected def check : CliM PUnit := do
1181+
processOptions lakeOption
1182+
let opts ← getThe LakeOptions
1183+
noArgsRem do
1184+
let (leanInstall, lakeInstall) ← opts.getInstall
1185+
-- The workspace is deliberately not loaded here: evaluating the project's configuration is code
1186+
-- execution, and containing it is what the sandbox is for.
1187+
let cfg ← mkLoadConfig opts
1188+
exit <| ← Check.runCheck leanInstall lakeInstall cfg.wsDir
1189+
11791190
protected def script : CliM PUnit := do
11801191
if let some cmd ← takeArg? then
11811192
processLeadingOptions lakeOption -- between `lake script <cmd>` and args
@@ -1332,6 +1343,7 @@ def lakeCli : (cmd : String) → CliM PUnit
13321343
| "clean" => lake.clean
13331344
| "shake" => lake.shake
13341345
| "challenge" => lake.challenge
1346+
| "check" => lake.check
13351347
| "script" => lake.script
13361348
| "scripts" => lake.script.list
13371349
| "run" => lake.script.run

‎src/lake/Lake/Check/Axioms.lean‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ where
5454

5555
end Axioms
5656

57+
/--
58+
The axioms some other constant in `env` refers to, each paired with one constant that refers to it.
59+
-/
60+
public def usedAxioms (env : LeanExport.ExportedEnv) : Array (Lean.Name × Lean.Name) :=
61+
-- `constOrder` is the export order, so the result is deterministic without needing an `Ord`.
62+
let collect : StateM (Std.HashSet Lean.Name × Array (Lean.Name × Lean.Name)) Unit := do
63+
for name in env.constOrder do
64+
let some info := env.constMap[name]? | continue
65+
runForUsedConsts info fun ref => do
66+
-- `runForUsedConsts` reports the constant itself, which is not an incoming reference
67+
unless ref == name do
68+
if let some (.axiomInfo ..) := env.constMap[ref]? then
69+
modify fun (seen, used) =>
70+
if seen.contains ref then (seen, used) else (seen.insert ref, used.push (ref, name))
71+
(collect.run ({}, #[])).2.2
72+
5773
public def checkAxioms (solution : LeanExport.ExportedEnv) (theoremTargets : Array Lean.Name)
5874
(definitionTargets : Array Lean.Name) (legalAxioms : Array Lean.Name) : Except String Unit := do
5975
let mut worklist := #[]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Lean
2+
3+
open Lean
4+
5+
unsafe def badNatUnsafe : Nat := unsafeCast (-4294967296 : Int)
6+
7+
@[implemented_by badNatUnsafe] opaque badNatVal : Nat
8+
9+
run_elab
10+
addDecl <| .defnDecl {
11+
name := .str .anonymous "badNat"
12+
levelParams := []
13+
type := .const ``Nat []
14+
value := .lit <| .natVal badNatVal
15+
hints := .opaque
16+
safety := .safe
17+
}
18+
19+
theorem boom : False := by
20+
have truly_marvelous_0 : ¬badNat ≤ 9223372036854775807 := by decide
21+
have truly_marvelous_1 : ¬9223372036854775807 < badNat := by decide
22+
omega
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
rm -rf .lake lake-manifest.json produced.out
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
name = "checktest"
2+
version = "0.1.0"
3+
defaultTargets = ["Solution"]
4+
5+
[[lean_lib]]
6+
name = "Solution"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bash
2+
source ../common.sh
3+
4+
./clean.sh
5+
6+
if [ "`uname`" != Linux ]; then
7+
echo "Skipping test: lake check needs Linux Landlock"
8+
exit 0
9+
fi
10+
11+
# Landlock cannot be assumed available in CI containers; see `../fake-landrun.sh`.
12+
export COMPARATOR_LANDRUN="$PWD/../fake-landrun.sh"
13+
14+
# `lake check` resolves dependencies inside the sandbox, which cannot write to the project
15+
# directory, so the manifest has to be in place first. Building the project once does the same;
16+
# this just skips the build.
17+
"$LAKE" resolve-deps
18+
19+
test_status_out 1 'error:' check
20+
21+
rm -f produced.out
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
theorem comm (n m : Nat) : n + m = m + n := by
2+
grind
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
rm -rf .lake lake-manifest.json produced.out

0 commit comments

Comments
 (0)