veripp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
veripp/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """veripp: AI-operated formal verification for C++."""
2
+
3
+ try: # the version lives in pyproject.toml; do not duplicate it here
4
+ from importlib.metadata import version as _version
5
+
6
+ __version__ = _version("veripp")
7
+ except Exception: # running from a source tree with nothing installed
8
+ __version__ = "0+unknown"
veripp/agent.py ADDED
@@ -0,0 +1,426 @@
1
+ """The agent loop: attempt -> triage -> escalate, under a hard budget.
2
+
3
+ Design invariants:
4
+ * The LLM never decides correctness. Every proposal (harness edit,
5
+ invariant, assumption) is re-checked by ESBMC.
6
+ * Every reported result carries the exact VerifyConfig it was obtained
7
+ under, plus the harness assumptions, so "verified" always means
8
+ "verified under these bounds and assumptions".
9
+ * The loop terminates: bounded iterations, wall time, and LLM calls.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import time
16
+ from dataclasses import dataclass, field, replace
17
+ from pathlib import Path
18
+
19
+ from . import term
20
+ from .esbmc import Outcome, VerifyConfig, VerifyResult, run
21
+ from .harness import HarnessError, generate, reachability_variant
22
+ from .llm import LLMClient, NullLLM
23
+ from .triage import Diagnosis, TargetInfo, triage_counterexample
24
+
25
+
26
+ @dataclass
27
+ class Budget:
28
+ max_attempts: int = 8
29
+ max_llm_calls: int = 12
30
+ max_precondition_rounds: int = 2 # LLM-proposed preconditions per run
31
+ wall_time_s: int = 600
32
+
33
+
34
+ @dataclass
35
+ class AgentReport:
36
+ final: VerifyResult
37
+ attempts: list[VerifyResult] = field(default_factory=list)
38
+ diagnosis: Diagnosis | None = None
39
+ narrative: str = ""
40
+ assumptions: list[str] = field(default_factory=list)
41
+ harness: Path | None = None
42
+ accepted_preconditions: list[str] = field(default_factory=list)
43
+ #: Bug classes the checker that produced this result is known to miss.
44
+ #: A "verified" is only as sound as the checker behind it.
45
+ unsound_probes: list[str] = field(default_factory=list)
46
+ #: True when the harness could not actually be reached under its own
47
+ #: assumptions, which makes any "verified" meaningless.
48
+ vacuous: bool = False
49
+
50
+ #: Termination, kept separate from the safety verdict on purpose. It is a
51
+ #: liveness property, and a safety proof says nothing about it: ESBMC
52
+ #: reports SUCCESSFUL under k-induction for a function that loops forever,
53
+ #: because an infinite loop violates no assertion. Folding the two would
54
+ #: let "verified" mean "terminates" to a reader, which it does not.
55
+ #: None -> not asked (no loop, or the safety check did not succeed).
56
+ terminates: bool | None = None
57
+
58
+ @property
59
+ def verified(self) -> bool:
60
+ return self.final.outcome is Outcome.VERIFIED and not self.vacuous
61
+
62
+ def _depth_bound_hint(self) -> str | None:
63
+ """Flag a null the harness itself introduced.
64
+
65
+ Pointer fields are cut to null at --max-struct-depth, so a NULL
66
+ dereference may be that cut rather than a missing check in the code.
67
+ It is not safe to call it an artifact -- an unchecked pointer is a
68
+ real bug class -- but the reader should know which nulls are ours.
69
+ """
70
+ prop = self.final.violated_property
71
+ if prop is None or "NULL pointer" not in prop.description:
72
+ return None
73
+ nulled = [a for a in self.assumptions if "is null" in a]
74
+ if not nulled:
75
+ return None
76
+ deeper = any("depth bound" in a for a in nulled)
77
+ advice = (
78
+ "re-run with a larger --max-struct-depth to tell the two apart"
79
+ if deeper
80
+ else "a caller would have set it; constrain it with --assume, or "
81
+ "target a function that does not take it"
82
+ )
83
+ return (
84
+ " NOTE: the harness left a pointer field null "
85
+ f"({nulled[0].split('`')[1] if '`' in nulled[0] else 'see assumptions'}"
86
+ f"), so this null may be the harness's rather than something a "
87
+ f"caller can produce. {advice.capitalize()}."
88
+ )
89
+
90
+ def summary(self) -> str:
91
+ if self.vacuous:
92
+ headline = term.style(
93
+ "VACUOUS (nothing was actually checked)", "yellow", "bold"
94
+ )
95
+ else:
96
+ headline = term.verdict(self.final.outcome.value)
97
+ lines = [f"Result: {headline}", f" {self.final.config.describe()}"]
98
+ if self.vacuous:
99
+ lines.append(
100
+ " The assumptions made the call unreachable, so every property "
101
+ "held trivially. This is NOT a proof. Weaken the precondition(s) "
102
+ "below until the harness can run."
103
+ )
104
+ if self.harness:
105
+ lines.append(f" harness: {self.harness}")
106
+ if self.assumptions:
107
+ lines.append("Assumptions (a result is only as good as these):")
108
+ lines += [f" - {a}" for a in self.assumptions]
109
+ if self.final.outcome is Outcome.VERIFIED and not self.final.config.k_induction:
110
+ lines.append(
111
+ " This is a BOUNDED proof: it holds for executions within the "
112
+ "unwind bound above, not for all executions."
113
+ )
114
+ # Termination gets its own line and its own words. "Verified" above
115
+ # covers safety only; a reader should never have to know that to read
116
+ # this report correctly.
117
+ if self.terminates is True:
118
+ lines.append(" Termination: proved -- this function always finishes.")
119
+ elif self.terminates is False:
120
+ lines.append(
121
+ " Termination: NOT PROVED. That is not the same as "
122
+ "'loops forever' -- ESBMC proves termination but cannot refute "
123
+ "it, so this is an open question, not a bug."
124
+ )
125
+ stubbed = self.final.stubbed_calls
126
+ if stubbed:
127
+ names = ", ".join(stubbed[:8]) + ("..." if len(stubbed) > 8 else "")
128
+ if self.verified:
129
+ lines.append(
130
+ f" STUBBED CALLS (no body was available): {names}. ESBMC "
131
+ "havocs their return values but assumes they do not write "
132
+ "through pointer arguments -- if any of them does, this "
133
+ "result does not account for it."
134
+ )
135
+ else:
136
+ lines.append(
137
+ f" STUBBED CALLS (no body was available): {names}. Their "
138
+ "effects were not modelled, so this counterexample may be "
139
+ "an artifact of the missing definition rather than a real "
140
+ "bug -- check it first."
141
+ )
142
+ lines.append(
143
+ " Link the defining source with --link, or point veripp at "
144
+ "compile_commands.json."
145
+ )
146
+ if self.verified and self.unsound_probes:
147
+ lines.append(
148
+ " CHECKER IS KNOWN-UNSOUND for: "
149
+ + ", ".join(self.unsound_probes)
150
+ + ". This 'verified' does NOT cover that class of bug; "
151
+ "upgrade esbmc and re-run (see `veripp doctor`)."
152
+ )
153
+ if self.verified and self.accepted_preconditions:
154
+ lines.append(
155
+ " CONDITIONAL: verified only under triage-proposed "
156
+ "precondition(s) the solver confirmed sufficient. Nothing "
157
+ "checks that real callers satisfy them - review before trusting:"
158
+ )
159
+ lines += [f" requires {p}" for p in self.accepted_preconditions]
160
+ prop = self.final.violated_property
161
+ if prop:
162
+ lines.append(f"Violated property: {prop.description}")
163
+ lines.append(f" at {prop.loc}")
164
+ if prop.expression:
165
+ lines.append(f" guard: {prop.expression}")
166
+ if prop.cwes:
167
+ lines.append(f" CWE: {', '.join(prop.cwes)}")
168
+ hint = self._depth_bound_hint()
169
+ if hint:
170
+ lines.append(hint)
171
+ inputs = self.final.input_summary()
172
+ if inputs:
173
+ lines.append("Counterexample inputs:")
174
+ lines += [f" {line}" for line in inputs]
175
+ if self.final.error:
176
+ lines.append(f"Error: {self.final.error}")
177
+ if self.diagnosis:
178
+ lines.append(f"Diagnosis: {self.diagnosis.kind}: {self.diagnosis.explanation}")
179
+ if self.narrative:
180
+ lines.append(self.narrative)
181
+ lines.append(f"Attempts: {len(self.attempts)}")
182
+ return "\n".join(lines)
183
+
184
+
185
+ # Escalation ladder for "not conclusive yet": widen the bound, then try to
186
+ # escape boundedness entirely.
187
+ _UNWIND_ESCALATIONS = [
188
+ lambda c: replace(c, unwind=c.unwind * 4),
189
+ lambda c: replace(c, unwind=c.unwind * 4),
190
+ lambda c: replace(c, k_induction=True),
191
+ ]
192
+
193
+ # A timeout means the search was too expensive, so widening the bound is the
194
+ # wrong move: switch to incremental BMC, which reports shallow bugs early.
195
+ _TIMEOUT_ESCALATIONS = [
196
+ lambda c: replace(c, incremental_bmc=True, k_induction=False),
197
+ ]
198
+
199
+
200
+ _COMMENT_RE = re.compile(r"/\*.*?\*/|//[^\n]*", re.DOTALL)
201
+
202
+
203
+ def _strip_comments(text: str) -> str:
204
+ """Drop comments before scanning for loop keywords.
205
+
206
+ Prose says "for" and "while" constantly ("loop for each element"), and a
207
+ match there would buy an extra verification run for nothing.
208
+ """
209
+ return _COMMENT_RE.sub(" ", text)
210
+
211
+
212
+ #: A function with no loop and no recursion terminates trivially, and asking
213
+ #: the checker costs a whole extra verification run. Cheap syntactic test:
214
+ #: only ask when there is something that could fail to terminate.
215
+ _LOOP_RE = re.compile(r"\b(while|for|goto)\b")
216
+
217
+
218
+ def _might_not_terminate(target: "TargetInfo | None") -> bool:
219
+ """Whether termination is worth asking about for this target.
220
+
221
+ Scans the original translation unit, not the harness: the harness only
222
+ `#include`s the source, so its own text has no loop in it even when the
223
+ code under test loops. Scanning the whole TU over-approximates -- a loop
224
+ in an unrelated function also triggers the question -- but a callee's loop
225
+ is just as able to hang the target, and the only cost of guessing yes is
226
+ one extra run. Guessing no would silently drop the question.
227
+ """
228
+ if target is None:
229
+ return False
230
+ try:
231
+ text = target.source.read_text(encoding="utf-8", errors="replace")
232
+ except OSError:
233
+ return False
234
+ return bool(_LOOP_RE.search(_strip_comments(text)))
235
+
236
+
237
+ def _check_termination(harness: Path, config: VerifyConfig) -> bool | None:
238
+ """True if termination is proved, False if the checker could not, None if
239
+ it could not be asked.
240
+
241
+ ESBMC proves termination but does not refute it: a function that may loop
242
+ forever comes back UNKNOWN, not FAILED. So False here means "not proved",
243
+ never "proved not to terminate", and the reporting says so.
244
+ """
245
+ from dataclasses import replace as _replace
246
+
247
+ try:
248
+ result = run(harness, _replace(config, termination=True))
249
+ except (OSError, RuntimeError):
250
+ return None
251
+ return result.outcome is Outcome.VERIFIED
252
+
253
+
254
+ def verify_with_agent(
255
+ source: Path,
256
+ base_config: VerifyConfig | None = None,
257
+ llm: LLMClient | None = None,
258
+ budget: Budget | None = None,
259
+ assumptions: list[str] | None = None,
260
+ harness: Path | None = None,
261
+ target: TargetInfo | None = None,
262
+ ) -> AgentReport:
263
+ """Main entry point: drive ESBMC to a conclusive answer if possible.
264
+
265
+ `target` (set when --function generated the harness) enables the
266
+ propose->check loop: triage may propose a precondition, the harness is
267
+ regenerated with it, and ESBMC re-runs. The solver, never the LLM,
268
+ decides whether the proposal stands.
269
+ """
270
+ llm = llm or NullLLM()
271
+ budget = budget or Budget()
272
+ config = base_config or VerifyConfig()
273
+ started = time.monotonic()
274
+ context = dict(assumptions=list(assumptions or []), harness=harness)
275
+ preconditions: list[str] = []
276
+ last_diagnosis: Diagnosis | None = None
277
+
278
+ attempts: list[VerifyResult] = []
279
+ unwind_idx = 0
280
+ timeout_idx = 0
281
+
282
+ while True:
283
+ if len(attempts) >= budget.max_attempts:
284
+ return _inconclusive(attempts, "attempt budget exhausted", **context)
285
+ if time.monotonic() - started > budget.wall_time_s:
286
+ return _inconclusive(attempts, "wall-time budget exhausted", **context)
287
+
288
+ result = run(source, config)
289
+ attempts.append(result)
290
+
291
+ if result.outcome is Outcome.VERIFIED:
292
+ # Safety holds. Termination is a separate question, and the tool
293
+ # asks it rather than making the user find a flag: only when there
294
+ # is a loop to worry about, and only once safety succeeded, since
295
+ # proving that buggy code terminates helps nobody.
296
+ terminates = None
297
+ if _might_not_terminate(target):
298
+ terminates = _check_termination(source, config)
299
+ return AgentReport(
300
+ final=result,
301
+ attempts=attempts,
302
+ diagnosis=last_diagnosis,
303
+ accepted_preconditions=preconditions,
304
+ vacuous=_is_vacuous(source, config),
305
+ terminates=terminates,
306
+ **context,
307
+ )
308
+
309
+ if result.outcome is Outcome.COUNTEREXAMPLE:
310
+ diagnosis = triage_counterexample(target, source, result, llm)
311
+ last_diagnosis = diagnosis
312
+ if (
313
+ diagnosis.kind in ("missing_assumption", "harness_issue")
314
+ and diagnosis.proposed_precondition
315
+ and target is not None
316
+ and len(preconditions) < budget.max_precondition_rounds
317
+ ):
318
+ # Regenerate the harness with the proposal; the re-run is the
319
+ # solver's verdict on it. Unwind may need widening once the
320
+ # precondition admits longer loops, so reset the ladder.
321
+ candidate = preconditions + [diagnosis.proposed_precondition]
322
+ try:
323
+ regenerated = generate(
324
+ target.source,
325
+ target.function,
326
+ target.options,
327
+ extra_preconditions=candidate,
328
+ )
329
+ except HarnessError:
330
+ # Proposal out of scope (guardrail refused it): report the
331
+ # counterexample as triaged, without the proposal.
332
+ return AgentReport(
333
+ final=result, attempts=attempts, diagnosis=diagnosis, **context
334
+ )
335
+ preconditions = candidate
336
+ source = regenerated.write(source.parent, tag=f"pre{len(preconditions)}")
337
+ context["assumptions"] = list(regenerated.assumptions)
338
+ context["harness"] = source
339
+ unwind_idx = 0
340
+ continue
341
+ return AgentReport(
342
+ final=result,
343
+ attempts=attempts,
344
+ diagnosis=diagnosis,
345
+ accepted_preconditions=[],
346
+ **context,
347
+ )
348
+
349
+ if result.outcome is Outcome.TOOL_ERROR:
350
+ # Escalating cannot fix a broken invocation; surface it immediately.
351
+ return _inconclusive(
352
+ attempts, f"esbmc could not be run: {result.error}", **context
353
+ )
354
+
355
+ if result.outcome is Outcome.TIMEOUT:
356
+ if timeout_idx < len(_TIMEOUT_ESCALATIONS):
357
+ config = _TIMEOUT_ESCALATIONS[timeout_idx](config)
358
+ timeout_idx += 1
359
+ continue
360
+ return _inconclusive(attempts, "esbmc timed out at every setting", **context)
361
+
362
+ if result.outcome in (Outcome.UNWIND_LIMIT, Outcome.UNKNOWN):
363
+ if unwind_idx < len(_UNWIND_ESCALATIONS):
364
+ config = _UNWIND_ESCALATIONS[unwind_idx](config)
365
+ unwind_idx += 1
366
+ continue
367
+ # Ladder exhausted: ask the LLM for loop invariants / lemmas.
368
+ proposal = llm.propose_invariants(source, result)
369
+ if proposal is not None:
370
+ source = proposal
371
+ config = replace(config, k_induction=True)
372
+ continue
373
+ return _inconclusive(
374
+ attempts, "escalation ladder and LLM proposals exhausted", **context
375
+ )
376
+
377
+ if result.outcome is Outcome.PARSE_ERROR:
378
+ fixed = llm.propose_frontend_fix(source, result)
379
+ if fixed is not None:
380
+ source = fixed
381
+ continue
382
+ return _inconclusive(
383
+ attempts,
384
+ f"ESBMC frontend rejected the input: {result.error or 'see raw output'}",
385
+ **context,
386
+ )
387
+
388
+
389
+ def _is_vacuous(harness: Path, config: VerifyConfig) -> bool:
390
+ """Did the harness's own assumptions make the call unreachable?
391
+
392
+ An unreachable program satisfies everything, so a "verified" from one is
393
+ worthless -- and neither ESBMC nor the LLM that proposed the precondition
394
+ can notice. Only a harness carrying assumptions can be vacuous, so the
395
+ extra run is skipped when there are none.
396
+ """
397
+ try:
398
+ code = harness.read_text(encoding="utf-8")
399
+ except OSError:
400
+ return False
401
+ if "VERIPP_ASSUME" not in code and "VERIPP_REQUIRES" not in code:
402
+ return False
403
+ probe = harness.with_name(f"{harness.stem}.reachable{harness.suffix}")
404
+ try:
405
+ probe.write_text(reachability_variant(code), encoding="utf-8")
406
+ result = run(probe, config)
407
+ except (OSError, RuntimeError):
408
+ return False
409
+ # The probe's trailing assertion is always false, so a reachable harness
410
+ # must fail it. Verifying means nothing could reach it.
411
+ return result.outcome is Outcome.VERIFIED
412
+
413
+
414
+ def _inconclusive(
415
+ attempts: list[VerifyResult],
416
+ reason: str,
417
+ assumptions: list[str],
418
+ harness: Path | None,
419
+ ) -> AgentReport:
420
+ return AgentReport(
421
+ final=attempts[-1],
422
+ attempts=attempts,
423
+ narrative=f"Inconclusive: {reason}. No claim is made about this code.",
424
+ assumptions=assumptions,
425
+ harness=harness,
426
+ )
veripp/baseline.py ADDED
@@ -0,0 +1,161 @@
1
+ """Accepted findings, so CI can fail on new ones only.
2
+
3
+ A verifier pointed at an existing codebase reports everything at once. cJSON
4
+ gives 33 counterexamples on the first run: fail the build on those and the
5
+ check is removed the next day, so the usual advice is to make it non-blocking
6
+ -- which turns it into a check nobody reads.
7
+
8
+ A baseline is the way out. Record what is already there, then fail only on
9
+ what appears after. The file is plain JSON and meant to be read in a pull
10
+ request: it is a record of accepted risk, and a reviewer should be able to see
11
+ what was accepted and why without running anything.
12
+
13
+ Findings are keyed on (file, function, property) and deliberately not on line
14
+ numbers, which change whenever anything above them moves. The signature is
15
+ stored for a reviewer but not keyed on, so adding a `const` does not silently
16
+ resurrect every finding in a file.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import dataclass, field
23
+ from datetime import date
24
+ from pathlib import Path
25
+
26
+ BASELINE_VERSION = 1
27
+ DEFAULT_NAME = ".veripp-baseline"
28
+
29
+
30
+ class BaselineError(Exception):
31
+ """The baseline file could not be used."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Key:
36
+ """What makes two findings the same finding."""
37
+
38
+ file: str
39
+ function: str
40
+ property: str
41
+
42
+ def as_dict(self) -> dict:
43
+ return {"file": self.file, "function": self.function, "property": self.property}
44
+
45
+
46
+ @dataclass
47
+ class Entry:
48
+ key: Key
49
+ signature: str = ""
50
+ accepted: str = ""
51
+ reason: str = ""
52
+
53
+ def as_dict(self) -> dict:
54
+ out = self.key.as_dict()
55
+ out["signature"] = self.signature
56
+ out["accepted"] = self.accepted
57
+ if self.reason:
58
+ out["reason"] = self.reason
59
+ return out
60
+
61
+
62
+ @dataclass
63
+ class Baseline:
64
+ entries: dict[Key, Entry] = field(default_factory=dict)
65
+ path: Path | None = None
66
+
67
+ # -- reading -----------------------------------------------------------
68
+
69
+ @classmethod
70
+ def load(cls, path: Path) -> Baseline:
71
+ try:
72
+ raw = json.loads(path.read_text(encoding="utf-8"))
73
+ except FileNotFoundError as exc:
74
+ raise BaselineError(f"{path} not found") from exc
75
+ except json.JSONDecodeError as exc:
76
+ raise BaselineError(f"{path} is not valid JSON: {exc}") from exc
77
+
78
+ version = raw.get("version")
79
+ if version != BASELINE_VERSION:
80
+ # Refuse rather than guess: a baseline read wrongly suppresses real
81
+ # findings, which is the one failure mode that must never be quiet.
82
+ raise BaselineError(
83
+ f"{path} is version {version!r}, this veripp understands "
84
+ f"{BASELINE_VERSION}. Regenerate it with `veripp accept`."
85
+ )
86
+
87
+ entries: dict[Key, Entry] = {}
88
+ for item in raw.get("findings", []):
89
+ try:
90
+ key = Key(item["file"], item["function"], item["property"])
91
+ except (KeyError, TypeError) as exc:
92
+ raise BaselineError(f"{path}: malformed entry {item!r}") from exc
93
+ entries[key] = Entry(
94
+ key=key,
95
+ signature=item.get("signature", ""),
96
+ accepted=item.get("accepted", ""),
97
+ reason=item.get("reason", ""),
98
+ )
99
+ return cls(entries=entries, path=path)
100
+
101
+ @classmethod
102
+ def load_if_present(cls, path: Path | None) -> Baseline | None:
103
+ if path is None:
104
+ return None
105
+ return cls.load(path)
106
+
107
+ # -- writing -----------------------------------------------------------
108
+
109
+ def save(self, path: Path, note: str = "") -> None:
110
+ payload = {
111
+ "version": BASELINE_VERSION,
112
+ "generated": date.today().isoformat(),
113
+ "note": note or (
114
+ "Findings accepted as known. veripp fails CI only on findings "
115
+ "absent from this file. Review it like any other change: each "
116
+ "entry is a risk someone decided to carry."
117
+ ),
118
+ # Sorted so the file is diffable and two people generating it get
119
+ # the same bytes.
120
+ "findings": [
121
+ entry.as_dict()
122
+ for _, entry in sorted(
123
+ self.entries.items(),
124
+ key=lambda kv: (kv[0].file, kv[0].function, kv[0].property),
125
+ )
126
+ ],
127
+ }
128
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
129
+
130
+ # -- using -------------------------------------------------------------
131
+
132
+ def covers(self, key: Key) -> bool:
133
+ return key in self.entries
134
+
135
+ def split(self, keys: list[Key]) -> tuple[list[Key], list[Key]]:
136
+ """(new, known) for the findings of this run."""
137
+ new = [k for k in keys if k not in self.entries]
138
+ known = [k for k in keys if k in self.entries]
139
+ return new, known
140
+
141
+ def stale(self, keys: list[Key]) -> list[Key]:
142
+ """Accepted findings that did not occur this run.
143
+
144
+ Worth surfacing: an entry that no longer matches anything grants
145
+ permission for a finding that cannot happen, and will go on granting
146
+ it to some future finding that happens to match.
147
+ """
148
+ seen = set(keys)
149
+ return [k for k in self.entries if k not in seen]
150
+
151
+
152
+ def key_for(source: Path, function: str, property_text: str, root: Path | None = None) -> Key:
153
+ """A finding's identity, with the path made relative so the baseline
154
+ survives being checked out somewhere else."""
155
+ path = Path(source)
156
+ base = root or Path.cwd()
157
+ try:
158
+ relative = path.resolve().relative_to(base.resolve())
159
+ except ValueError:
160
+ relative = path
161
+ return Key(str(relative), function, property_text)