Skip to content

Commit a5f3bfd

Browse files
authored
Harden formal audit trust gates (#1937)
* Harden formal audit trust gates * Harden trust surface registry check * Deduplicate benchmark case discovery
1 parent 00c18e3 commit a5f3bfd

15 files changed

Lines changed: 433 additions & 43 deletions

.github/workflows/verify.yml

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -419,36 +419,8 @@ jobs:
419419
- name: Build split Lake packages independently
420420
run: python3 scripts/check_split_package_builds.py
421421

422-
- name: Check for axioms in proof files
423-
run: |
424-
echo "Checking for axioms in Compiler/ and Verity/..."
425-
grep -rnE "^(private |protected )?axiom " Compiler/ Verity/ --include="*.lean" | tee axioms.txt || true
426-
AXIOM_COUNT=$(wc -l < axioms.txt 2>/dev/null | tr -d ' ')
427-
echo "Found $AXIOM_COUNT axiom declaration(s)"
428-
if [ "$AXIOM_COUNT" -gt 0 ]; then
429-
echo "::warning::Found $AXIOM_COUNT axiom(s) in proof files"
430-
if [ ! -f "AXIOMS.md" ]; then
431-
echo "::error::AXIOMS.md not found. All axioms must be documented."
432-
exit 1
433-
fi
434-
AXIOM_NAMES=$(sed -E 's/.*:(private |protected )?axiom ([a-zA-Z0-9_]*).*/\2/' axioms.txt | sort -u)
435-
UNDOCUMENTED=""
436-
for axiom_name in $AXIOM_NAMES; do
437-
if ! grep -q "$axiom_name" AXIOMS.md; then
438-
echo "::error::Axiom '$axiom_name' not documented in AXIOMS.md"
439-
UNDOCUMENTED="$UNDOCUMENTED $axiom_name"
440-
else
441-
echo "✓ Axiom '$axiom_name' is documented"
442-
fi
443-
done
444-
if [ -n "$UNDOCUMENTED" ]; then
445-
echo "::error::Undocumented axioms:$UNDOCUMENTED"
446-
exit 1
447-
fi
448-
echo "✓ All $AXIOM_COUNT axioms are documented in AXIOMS.md"
449-
else
450-
echo "✓ No axioms found in proof files"
451-
fi
422+
- name: Check documented Lean axioms
423+
run: python3 scripts/check_axioms.py
452424

453425
- name: Build proof modules for axiom audit
454426
run: lake build PrintAxioms

AXIOMS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,28 @@ axiom solidityMappingSlot_lt_evmModulus (baseSlot key : Nat) :
4545
**Runtime performance**: An `@[implemented_by]` annotation optionally redirects to
4646
the FFI version at runtime for speed, without affecting proof soundness.
4747

48+
## Trusted Reduction and Codegen Surface (Non-Axiom)
49+
50+
Verity currently has zero project-level Lean axioms, but some proofs and tests
51+
intentionally rely on Lean mechanisms that sit outside ordinary kernel
52+
elaboration:
53+
54+
- `native_decide` proofs depend on Lean's native code generation and the builtin
55+
`Lean.ofReduceBool` axiom. They are acceptable for executable smoke tests,
56+
concrete bridge checks, and explicitly documented reduction witnesses, but
57+
they are tracked as trusted reduction surface rather than counted as
58+
project-level axioms.
59+
- `@[implemented_by ...]` may redirect runtime execution to a faster
60+
implementation. The proof term still sees the kernel-computable definition,
61+
so this is a runtime/codegen trust boundary rather than a Lean axiom.
62+
- `partial def` marks recursive executable helpers whose termination is not
63+
kernel-proved. These helpers are allowed in macro, codegen, reporting, and
64+
native-test infrastructure, and their presence is registry-gated.
65+
66+
CI runs `scripts/check_trust_surface_registry.py` to ensure these mechanisms and
67+
all explicit ECM assumptions (`axioms := [...]`) remain documented when their
68+
source footprint changes.
69+
4870
### 2. Selector computation (eliminated earlier)
4971

5072
Function selector derivation (`bytes4(keccak256(signature))`) was previously
@@ -178,6 +200,13 @@ compilation fail if any assumption hasn't been reviewed.
178200
| `Hashing.sha256PackedWords` / `Hashing.sha256Packed` | `evm_sha256_precompile`, `abi_packed_static_word_layout` | Static packed words are laid out before SHA-256 precompile call |
179201
| `Hashing.sha256PackedStaticSegments` | `evm_sha256_precompile`, `abi_packed_static_segment_layout` | Static packed byte-width segments are laid out before SHA-256 precompile call |
180202

203+
### Test-Only ECM Assumptions
204+
205+
| Fixture | Assumption | Meaning |
206+
|---------|------------|---------|
207+
| `Compiler.CompileDriverTest` | `test_call_interface` | Synthetic compile-driver fixture for ECM trust reporting |
208+
| `Compiler.CompileDriverTest` | `ctor_hook_interface` | Synthetic constructor fixture for ECM trust reporting |
209+
181210
### Third-Party Module Assumptions
182211

183212
Third-party ECMs (external Lean packages) document their assumptions in their own

Compiler/CompilationModelFeatureTest.lean

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,27 @@ example : hashSliceExecutableUsesRuntimeStub = true := by native_decide
232232

233233
end MacroKeccakSmoke
234234

235+
namespace MappingWordSmoke
236+
237+
open Contracts.Smoke
238+
239+
example :
240+
MappingWordSmoke.setWord1_modelBody =
241+
[ Stmt.setMappingWord "words" (Expr.param "key") 1 (Expr.param "value"),
242+
Stmt.stop ] := rfl
243+
244+
example :
245+
MappingWordSmoke.getWord1_modelBody =
246+
[ Stmt.letVar "word" (Expr.mappingWord "words" (Expr.param "key") 1),
247+
Stmt.return (Expr.localVar "word") ] := rfl
248+
249+
example :
250+
MappingWordSmoke.isWord1NonZero_modelBody =
251+
[ Stmt.letVar "word" (Expr.mappingWord "words" (Expr.param "key") 1),
252+
Stmt.return (Expr.logicalNot (Expr.eq (Expr.localVar "word") (Expr.literal 0))) ] := rfl
253+
254+
end MappingWordSmoke
255+
235256
namespace MacroExternalSmoke
236257

237258
open Contracts
@@ -5451,10 +5472,11 @@ set_option maxRecDepth 4096 in
54515472
expectCompileToYul "abiEncodeStaticArray smoke spec" abiEncodeStaticArraySmokeSpec
54525473
expectTrue "abiEncodeStaticArray writes the single dynamic argument head and length"
54535474
(contains abiEncodeStaticArrayYul "mstore(__digest_abi_array_ptr, 32)" &&
5454-
contains abiEncodeStaticArrayYul "mstore(add(__digest_abi_array_ptr, 32), items_length)")
5475+
contains abiEncodeStaticArrayYul "let __digest_abi_array_length := items_length" &&
5476+
contains abiEncodeStaticArrayYul "mstore(add(__digest_abi_array_ptr, 32), __digest_abi_array_length)")
54555477
expectTrue "abiEncodeStaticArray copies the fixed-width element payload"
54565478
(contains abiEncodeStaticArrayYul
5457-
"let __digest_abi_array_data_bytes := mul(items_length, 128)" &&
5479+
"let __digest_abi_array_data_bytes := mul(__digest_abi_array_length, 128)" &&
54585480
contains abiEncodeStaticArrayYul
54595481
"calldatacopy(add(__digest_abi_array_ptr, 64), items_data_offset, __digest_abi_array_data_bytes)")
54605482
expectTrue "abiEncodeStaticArray hashes the ABI-encoded dynamic array byte length"

Compiler/Keccak/Sponge.lean

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,16 @@ def domainSuffix (v : HashVariant) : UInt8 :=
7171
/-- Pads the message tail (0 to 135 bytes) to a full 136-byte block. -/
7272
def padBlock (leftover : ByteArray) (variant : HashVariant) : ByteArray :=
7373
let rate := 136
74-
let arr1 := leftover.data.push (domainSuffix variant)
75-
let padLen := rate - arr1.size
76-
let arr2 := arr1 ++ Array.replicate padLen 0
77-
let lastIdx := rate - 1
78-
let lastVal := arr2[lastIdx]? |>.getD 0
79-
let arr3 := arr2.set! lastIdx (lastVal ||| 0x80)
80-
⟨arr3⟩
74+
if leftover.size >= rate then
75+
panic! "Keccak.padBlock: leftover block must be shorter than rate"
76+
else
77+
let arr1 := leftover.data.push (domainSuffix variant)
78+
let padLen := rate - arr1.size
79+
let arr2 := arr1 ++ Array.replicate padLen 0
80+
let lastIdx := rate - 1
81+
let lastVal := arr2[lastIdx]? |>.getD 0
82+
let arr3 := arr2.set! lastIdx (lastVal ||| 0x80)
83+
⟨arr3⟩
8184

8285
/-- Kernel-friendly evaluation of a single bitwise instruction. -/
8386
@[always_inline, inline]

Compiler/Modules/Hashing.lean

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,22 +181,25 @@ def abiEncodeStaticArrayModule
181181
if elementWords == 0 then
182182
throw "abiEncodeStaticArray requires elementWords > 0"
183183
let ptrName := s!"__{resultVar}_abi_array_ptr"
184+
let lengthName := s!"__{resultVar}_abi_array_length"
184185
let dataBytesName := s!"__{resultVar}_abi_array_data_bytes"
185186
let totalBytesName := s!"__{resultVar}_abi_array_total_bytes"
186187
let paddedTotalName := s!"__{resultVar}_abi_array_padded_total"
187188
let ptr := YulExpr.ident ptrName
189+
let length := YulExpr.ident lengthName
188190
let dataBytes := YulExpr.ident dataBytesName
189191
let totalBytes := YulExpr.ident totalBytesName
190192
pure [
191193
YulStmt.block ([
192194
YulStmt.let_ ptrName (YulExpr.call "mload" [YulExpr.lit freeMemoryPointer]),
195+
YulStmt.let_ lengthName arrayLengthExpr,
193196
YulStmt.expr (YulExpr.call "mstore" [ptr, YulExpr.lit 32]),
194197
YulStmt.expr (YulExpr.call "mstore" [
195198
YulExpr.call "add" [ptr, YulExpr.lit 32],
196-
arrayLengthExpr
199+
length
197200
]),
198201
YulStmt.let_ dataBytesName (YulExpr.call "mul" [
199-
arrayLengthExpr,
202+
length,
200203
YulExpr.lit (elementWords * 32)
201204
])
202205
] ++ ECM.dynamicCopyData ctx

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ check: ## Run local CI-equivalent checks job (no Lean build, no solc)
126126
python3 scripts/check_paths.py
127127
python3 scripts/check_compilationmodel_split.py
128128
python3 scripts/check_axioms.py
129+
python3 scripts/check_trust_surface_registry.py
130+
python3 scripts/check_benchmark_cases.py
129131
python3 scripts/generate_verification_status.py --check
130132
python3 scripts/generate_layer2_boundary_catalog.py --check
131133
python3 scripts/check_verification_status_doc.py

docs/EXTERNAL_CALL_MODULES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ Standard modules ship in `Compiler/Modules/`:
100100
| `ERC20.solmateSafeTransferFrom` | `transferFrom(from, to, amount)` | Solmate SafeTransferLib semantics: accepts no returndata or first returned word equal to `true` when `returndatasize() > 31` | `erc20_solmate_safe_transferFrom_interface` |
101101
| `Hashing.abiEncodeStaticWords` | Static-word `abi.encode` Keccak | Writes full 32-byte ABI words contiguously at the Solidity free-memory pointer and binds `keccak256(ptr, wordCount * 32)` | `keccak256_memory_slice_matches_evm`, `abi_standard_static_word_layout` |
102102
| `Hashing.abiEncodePackedWords` / `Hashing.abiEncodePacked` | Static-word packed Keccak | Writes 32-byte words contiguously at the Solidity free-memory pointer and binds `keccak256(ptr, wordCount * 32)` | `keccak256_memory_slice_matches_evm`, `abi_packed_static_word_layout` |
103+
| `Hashing.abiEncodeStaticArray` | Standard dynamic-array Keccak | Writes ABI head offset, binds array length once, writes contiguous fixed-width static elements, and hashes the ABI frame | `keccak256_memory_slice_matches_evm`, `abi_standard_dynamic_array_static_element_layout` |
103104
| `Hashing.sha256PackedWords` / `Hashing.sha256Packed` | Static-word packed SHA-256 | Writes 32-byte words contiguously, calls precompile 0x02, and binds digest word | `evm_sha256_precompile`, `abi_packed_static_word_layout` |
104105
| `Hashing.abiEncodePackedStaticSegments` | Static byte-width packed Keccak | Writes 1- to 32-byte static segments at byte-precise offsets from the Solidity free-memory pointer and binds `keccak256(ptr, totalBytes)` | `keccak256_memory_slice_matches_evm`, `abi_packed_static_segment_layout` |
105106
| `Hashing.sha256PackedStaticSegments` | Static byte-width packed SHA-256 | Writes 1- to 32-byte static segments, calls precompile 0x02 over the exact byte length, and binds digest word | `evm_sha256_precompile`, `abi_packed_static_segment_layout` |

scripts/check_axioms.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from property_utils import ROOT, die, report_errors, scrub_lean_code
1919

2020
AXIOM_DECL_RE = re.compile(r"^(?:private |protected )?axiom\s+([A-Za-z_][A-Za-z0-9_']*)\b")
21+
DEFAULT_AXIOM_ROOTS = ("Compiler", "Verity", "Contracts", "Benchmark")
2122

2223
LEAN_BUILTIN_AXIOMS = frozenset([
2324
"propext",
@@ -56,7 +57,7 @@ def parse_active_axiom_count(axioms_md_text: str) -> int | None:
5657
def discover_repo_axioms() -> dict[str, tuple[str, int]]:
5758
"""Return all Lean axiom declarations as ``{name: (relative_path, line)}``."""
5859
discovered: dict[str, tuple[str, int]] = {}
59-
for subdir in ("Compiler", "Verity"):
60+
for subdir in DEFAULT_AXIOM_ROOTS:
6061
base_dir = ROOT / subdir
6162
if not base_dir.exists():
6263
continue

scripts/check_benchmark_cases.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env python3
2+
"""Validate optional benchmark-case metadata.
3+
4+
Core Verity checkouts do not normally contain `Benchmark/`; downstream
5+
benchmark repos may vendor or overlay `Benchmark/`, while the standalone
6+
benchmark repository keeps metadata under `cases/`. When present, each
7+
`case.yaml` is a publication boundary and must identify the upstream target
8+
commit. If a case has `FormalAudit.lean`, it must not still be marked
9+
`proof_status: not_started`.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import re
15+
from pathlib import Path
16+
17+
from property_utils import ROOT, report_errors
18+
19+
KEY_VALUE_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$")
20+
21+
22+
def parse_case_yaml(path: Path) -> dict[str, str]:
23+
values: dict[str, str] = {}
24+
for line in path.read_text(encoding="utf-8").splitlines():
25+
line = line.split("#", 1)[0].strip()
26+
if not line:
27+
continue
28+
match = KEY_VALUE_RE.match(line)
29+
if match:
30+
values[match.group(1)] = match.group(2).strip().strip("\"'")
31+
return values
32+
33+
34+
def discover_case_files(root: Path = ROOT) -> list[Path]:
35+
case_files = sorted((root / "Benchmark").glob("Cases/**/case.yaml"))
36+
case_files += sorted((root / "cases").glob("**/case.yaml"))
37+
return case_files
38+
39+
40+
def check_benchmark_cases(root: Path = ROOT) -> list[str]:
41+
case_files = discover_case_files(root)
42+
if not case_files:
43+
return []
44+
45+
errors: list[str] = []
46+
for case_yaml in case_files:
47+
metadata = parse_case_yaml(case_yaml)
48+
case_dir = case_yaml.parent
49+
rel = case_yaml.relative_to(root)
50+
51+
if not metadata.get("upstream_commit"):
52+
errors.append(f"{rel}: missing upstream_commit")
53+
54+
if (case_dir / "FormalAudit.lean").exists():
55+
proof_status = metadata.get("proof_status")
56+
if proof_status == "not_started":
57+
errors.append(f"{rel}: FormalAudit.lean exists but proof_status is not_started")
58+
59+
audit_target_commit = metadata.get("audit_target_commit")
60+
if audit_target_commit is not None and not re.fullmatch(r"[0-9a-fA-F]{7,40}", audit_target_commit):
61+
errors.append(f"{rel}: audit_target_commit must be a 7-40 character git hex prefix")
62+
63+
return errors
64+
65+
66+
def main() -> int:
67+
case_files = discover_case_files()
68+
if not case_files:
69+
print("OK: no benchmark case metadata found; benchmark metadata check skipped")
70+
return 0
71+
72+
errors = check_benchmark_cases()
73+
report_errors(errors, "Benchmark case metadata check failed")
74+
print(f"OK: benchmark metadata check passed for {len(case_files)} case.yaml file(s)")
75+
return 0
76+
77+
78+
if __name__ == "__main__":
79+
raise SystemExit(main())
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
"""Validate that non-axiom trusted surfaces are documented.
3+
4+
The Lean kernel axiom registry is intentionally separate from Verity's broader
5+
trusted surface. This checker covers mechanisms that do not create project
6+
Lean axioms, but still matter for audit soundness:
7+
8+
* `native_decide`
9+
* `@[implemented_by ...]`
10+
* `partial def`
11+
* explicit ECM assumption strings in `axioms := [...]`
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import re
17+
from pathlib import Path
18+
19+
from property_utils import ROOT, report_errors, scrub_lean_code, strip_lean_comments
20+
21+
SCAN_ROOTS = ("Compiler", "Verity", "Contracts", "Benchmark")
22+
DOC_PATHS = ("AXIOMS.md", "TRUST_ASSUMPTIONS.md")
23+
DOC_DIRS = ("docs",)
24+
25+
ECM_AXIOM_STRING_RE = re.compile(r'"([^"]+)"')
26+
27+
MECHANISM_PATTERNS = (
28+
("native_decide", re.compile(r"\bnative_decide\b")),
29+
("@[implemented_by", re.compile(r"@\[\s*implemented_by\b")),
30+
("partial def", re.compile(r"\bpartial\s+def\b")),
31+
)
32+
33+
34+
def _iter_lean_files(root: Path = ROOT) -> list[Path]:
35+
files: list[Path] = []
36+
for subdir in SCAN_ROOTS:
37+
base = root / subdir
38+
if base.exists():
39+
files.extend(sorted(base.rglob("*.lean")))
40+
return files
41+
42+
43+
def _read_doc_corpus(root: Path = ROOT) -> str:
44+
chunks: list[str] = []
45+
for rel in DOC_PATHS:
46+
path = root / rel
47+
if path.exists():
48+
chunks.append(path.read_text(encoding="utf-8"))
49+
for rel in DOC_DIRS:
50+
base = root / rel
51+
if base.exists():
52+
for path in sorted(base.rglob("*.md")):
53+
chunks.append(path.read_text(encoding="utf-8"))
54+
return "\n".join(chunks)
55+
56+
57+
def collect_trust_surface(root: Path = ROOT) -> tuple[dict[str, int], dict[str, tuple[str, int]]]:
58+
mechanisms = {name: 0 for name, _ in MECHANISM_PATTERNS}
59+
ecm_axioms: dict[str, tuple[str, int]] = {}
60+
61+
for path in _iter_lean_files(root):
62+
raw_text = path.read_text(encoding="utf-8")
63+
text = scrub_lean_code(raw_text)
64+
text_without_comments = strip_lean_comments(raw_text)
65+
rel = str(path.relative_to(root))
66+
for name, pattern in MECHANISM_PATTERNS:
67+
mechanisms[name] += len(pattern.findall(text))
68+
for match in re.finditer(r"axioms\s*:=\s*\[(.*?)\]", text_without_comments, flags=re.DOTALL):
69+
line_no = text_without_comments.count("\n", 0, match.start()) + 1
70+
for axiom_name in ECM_AXIOM_STRING_RE.findall(match.group(1)):
71+
ecm_axioms.setdefault(axiom_name, (rel, line_no))
72+
73+
return mechanisms, ecm_axioms
74+
75+
76+
def main() -> int:
77+
docs = _read_doc_corpus()
78+
mechanisms, ecm_axioms = collect_trust_surface()
79+
80+
errors: list[str] = []
81+
for name, count in mechanisms.items():
82+
if count > 0 and name not in docs:
83+
errors.append(
84+
f"{name}: {count} occurrence(s) found in Lean sources but missing from trust docs"
85+
)
86+
87+
for axiom_name, (rel, line) in sorted(ecm_axioms.items()):
88+
if axiom_name not in docs:
89+
errors.append(
90+
f"{axiom_name}: ECM assumption from {rel}:{line} is missing from trust docs"
91+
)
92+
93+
report_errors(errors, "Trust-surface registry check failed")
94+
print(
95+
"OK: trust-surface registry documents "
96+
f"{len(ecm_axioms)} ECM assumption(s), "
97+
+ ", ".join(f"{name}={count}" for name, count in mechanisms.items())
98+
)
99+
return 0
100+
101+
102+
if __name__ == "__main__":
103+
raise SystemExit(main())

0 commit comments

Comments
 (0)