Fork of anomalyco/opencode applying LemmaScript's Dafny backend to opencode's permission system. Annotations are added in-place — function bodies and signatures stay unchanged; everything goes through //@ comments. Work in progress. View as diff.
Currently verified: eleven functions, zero errors. The case study drove substantial LemmaScript additions — auto-extern for cross-file calls, spec lifting onto axiom declarations, declare-type aliases, dotted-name fallback, C-style for-loop desugaring, map literals, JS-safe slice, brownfield //@ verify extraction with continue-rewrite and function-scoped extern registration, in-file //@ extern declarations, array- and object-destructuring with rest at let-statement level, StringSplit + SeqFindIndex preambles, full nullable-return-type / optional-field / bool || undefined / string || undefined handling — see Notes for LemmaScript.
The "last-matching rule wins, otherwise ask" core of opencode's permission engine. Two //@ ensures clauses pin both directions of the spec, parametric over Wildcard.match (auto-externed; regex-based, treated as an opaque predicate).
- Default-iff-no-match. No rule matches
(permission, pattern)⟹ the result is the synthesized{ permission, pattern: "*", action: "ask" }default. Empty or non-matching rulesets never produce anallow. - Last-wins. Some rule matches ⟹ the result equals
rulesets.flat()[k]for the maximal matching index, and no later rule matches. Appending a deny at the end of the ruleset guarantees every subsequent matching query becomes that deny, regardless of earlier allows.
4 VCs, 0 errors.
Builds a subagent's session ruleset by combining parent-agent edit-denies, parent-session denies, and default task/todowrite denies. Introduced by opencode #26514 — subagents spawned via the task tool were silently bypassing Plan Mode's file-edit restriction because Plan Mode's denies live on the agent ruleset, not the session.
Both directions of deny-inheritance proven:
- Safety: no edit-allows in the output. For all
j,\result[j]is not a{ permission: "edit", action: "allow" }rule. The function never introduces an allow onedit. - Completeness: every parent edit-deny is preserved. When
parentAgentis defined, everyparentAgent.permission[i]withaction === "deny" && permission === "edit"appears somewhere in\result.
Together these close #26514 mechanically: every parent edit-deny carries forward, and nothing else can override them.
5 VCs, 0 errors. Completeness needs a 10-line proof body in the .dfy file (a recursive helper that recurses on parentAgent.permission's length); safety discharges automatically.
Extracts the "human-understandable command" from a shell token list using a longest-prefix-wins lookup against the static ARITY dictionary (160 entries: npm → 2, npm run → 3, git config → 3, etc.). Used by the permission system to attach allow/deny rules to the right logical command — e.g., so a rule on npm run dev matches whether the user typed npm run dev or npm run dev --watch.
Structural properties as three //@ ensures clauses on the function:
- Bounded length.
\result.length <= tokens.length. - Prefix.
\result[i] === tokens[i]for everyi < \result.length. Combined with the bound, the result is literallytokens[0..\result.length]. - Empty in → empty out.
tokens.length === 0 ==> \result.length === 0.
Longest-match property, proven as a freestanding Dafny lemma in arity.dfy:
If
kis the maximal length in[1, |tokens|]for whichSeqJoin(tokens[0..k], " ") in ARITY, thenlongestMatchPrefix(tokens) == SafeSlice(tokens, 0, ARITY[that joined prefix]).
That is: the algorithm returns the slice indicated by the longest matching dictionary entry. Proven via an auxiliary lemma LongestMatchReduce(tokens, len, k) that shows the loop's downward sweep reduces to a fixed length: starting from any len in [k, |tokens|], the recursion peels back one length at a time until it hits k, the longest match.
The lemma proves the property about a Dafny mirror function longestMatchPrefix that statement-for-statement matches the production code; the production prefix is a method (its body is opaque to external lemmas), so the connection is by code inspection rather than mechanical refinement. The mirror function is itself defined in arity.dfy next to the lemma.
10 VCs, 0 errors. The body is a decrementing C-style for loop over prefix lengths; LS desugars it to a while automatically.
The "which input tools have a wildcard-deny rule" check used to gate the tool list. For each tool, collapses edit-class tools (edit / write / apply_patch) to a single "edit" permission, finds the last rule whose permission field matches via Wildcard.match, and emits the tool iff that last rule has pattern === "*" and action === "deny". Annotated in-place inside permission/index.ts — the big Layer.effect/Effect.gen service file — using LS's brownfield //@ verify extraction so the surrounding state machine is bypassed.
Both directions of the deny-characterization proven, parametric over Wildcard.match:
- Necessary. If
tool ∈ \result, then there exists a maximal-index rule inrulesetmatchingpermission(tool)withpattern === "*"andaction === "deny". - Sufficient. If such a maximal matching deny rule exists for some
tools[i], thentools[i] ∈ \result.
Three //@ invariant clauses on the for (const tool of tools) loop carry the proof: a strengthened soundness invariant t ∈ result ⟹ ∃ j < _tool_idx. tools[j] === t (so prior-iteration witnesses transfer across same-string duplicates), plus both directions of the deny-witness restricted to j < _tool_idx. A single hand-added assert in .dfy names the SeqFindLast witness inside the *-deny branch, letting Dafny discharge the necessary-direction maintenance.
A pure subsequence matcher used by allStructured for structured permission matching (bash:git stash pop etc.). Recurses over patterns; * is a no-op pattern position (skips itself, consumes no item); a non-* pattern is consumed by some item, with subsequent matches required to come from later in the items sequence. Annotated in-place; match itself is opted out of verification via //@ extern (it constructs a RegExp — out of LS's verification model), and matchSequence is proven parametric over the resulting uninterpreted predicate.
Both directions of the subsequence theorem proven:
- Soundness.
matchSequence(items, patterns) === true ⟹ Matches(items, patterns), whereMatchesis the hand-added Dafny predicate that recursively existentially-quantifies a strictly-increasing index assignment for non-*patterns. - Completeness.
Matches(items, patterns) ⟹ matchSequence(items, patterns) === true.
A two-clause loop invariant carries the proof: 0 <= i <= items.length plus forall j < i. ¬(match(items[j], pattern) ∧ Matches(items[j+1..], rest)) ("no witness in the prefix scanned so far"). Soundness falls out from the recursive method's own ensures; completeness from the loop-exit invariant + the contrapositive of Matches's non-* case. No proof body in .dfy — just the predicate Matches definition, ~6 lines.
The unified-diff parser used by the apply_patch tool, plus the multi-strategy line-block matcher that the patch-apply pipeline uses to locate replacement contexts. Six functions verified in-place via //@ verify:
parsePatchHeader(requires startIdx < lines.length,ensuresonnextIdxbounds): when the header line matches one of the three patterns,nextIdxisstartIdx + 1(Add/Delete) orstartIdx + 2(Update with*** Move to:), andnextIdx <= lines.length.parseAddFileContentandparseUpdateFileChunks(eachrequires startIdx <= lines.length,ensures startIdx <= nextIdx <= lines.length): forward-progress + termination, with explicit loop invariants anddecreases lines.length - i.parsePatch: full extraction, helper-call preconditions discharged via the helpers' ensures. One hand-localizedassume {:axiom} falsein.dfycovers the throw-on-malformed branch — the natural precondition would have to inline the function's ownstripHeredoc/split/findIndexpipeline, which would be circular-looking. The escape is visible at one line with a comment.tryMatch(multi-strategy line-block search, parametric over aComparator = (a, b) => boolean):requires 0 <= startIndex,ensures result === -1 || (startIndex <= result && result + pattern.length <= lines.length && forall j: nat. j < pattern.length ==> compare(lines[result + j], pattern[j])). Soundness of the search — when a position is returned, the pattern matches there under the supplied comparator, with the witness available to callers.seekSequence(escalates through four strategies: exact → rstrip → trim → normalized):requires 0 <= startIndex,ensures result === -1 || (startIndex <= result && result + pattern.length <= lines.length). Structural soundness of the four-pass escalation; one//@ externonnormalizeUnicode(regex-based, out of model).tryMatch's comparator-parametric ensures are what makes this chain work: each pass returns either -1 or a valid index, propagated through the conditional cascade.
All six extract and verify cleanly. Dafny reports 12 procedures verified for the file, counting SeqFindIndex / SeqFindLast / StringTrim / StringSplit / StringTrimLeft / StringTrimRight preambles alongside the six method bodies. stripHeredoc and normalizeUnicode are //@ extern (regex-based, out of LS's verification model).
Conservation loop invariants (not lifted to a function-level theorem). Three ghost variables and five loop invariants track conservation properties inside the parse loop: hunkStartIdx: seq<int> records each hunk's start position; coveredCount and skippedCount accumulate lines consumed by hunks versus skipped via i++ fallthroughs. They are method-local — parsePatch carries no //@ ensures exposing them, so callers do not see these properties.
The invariants:
forall k. hunkStartIdx[k] < hunkStartIdx[k+1]— start indices are strictly increasing, so hunks are produced in input order. Strict monotonicity of starts does not by itself entail "no line assigned to two hunks": disjointness of covered ranges would require a separate per-hunk end-index sequence and an invariantend[k] <= start[k+1], which is not in the current proof. (Disjointness does hold operationally — each branch setsi := nextIdxso the next iteration begins where the previous hunk ended — but that fact is not captured as a stated invariant over the ghost sequences.)coveredCount + skippedCount == i - (beginIdx + 1)— at every loop step, lines in the scanned range partition into hunk-consumed and skipped. At loop exit,i == endIdx.|hunks| <= i - (beginIdx + 1)— each pushed hunk consumed at least one line.
The line-tracking pattern: each branch that pushes a hunk increments coveredCount by nextIdx - i; each i++ skip increments skippedCount by 1. Maintenance follows mechanically: every iteration advances i by exactly the amount added to one accumulator.
Why these don't lift to ensures. Lifting the conservation properties to function-level postconditions would require either ghost output values (not supported by LemmaScript — ghost state is method-local) or augmenting Hunk with start/end fields (changes the production data model — violates in-place verification). Relatedly, the //@ assume false on the malformed-patch throw branch is currently irreducible: the natural requires would inline the stripHeredoc/split/findIndex pipeline, but LemmaScript's spec-annotation tokenizer does not process string escapes — "\n" in a //@ requires is tokenized as the 2-character literal \+n, not a newline — so an inline precondition would not connect to the code's StringSplit(..., "\n").
The case study drove a large LS additions list — StringSplit / SeqFindIndex preambles, optional-field detection on records and discriminated-union variants, nullable-return-type wrapping ({...} | null → Option<T>), let-statement type-node fallback, asymmetric optional in conditionals, assign auto-wrap-Some, bool || undefined / string || undefined lowering, negative slice index, truthy-coercion on string ternary conds, extended continue-rewrite (non-trivial then-bodies, while loops). See Notes for LemmaScript.
Wildcard.matchis opaque. Bothevaluate.tsproofs are parametric over the matcher — they hold for any total(string, string) → boolean. The actual regex-based implementation is out of LemmaScript's verification model (regex modeling excluded), but every theorem transfers unchanged ifWildcard.matchis later replaced by a verifiable hand-written glob matcher.Schema.Xtypings don't expand through ts-morph. opencode usesSchema.Struct(...)andSchema.Schema.Type<typeof X>pervasively; ts-morph sees these asType<any>.subagent-permissions.tsworks around this with four//@ declare-typeshim lines that give LemmaScript a simplified view ofRule,Info,Ruleset, and the input-record shape.
Prerequisites: Dafny ≥ 4.0 with standard libraries, Node.js ≥ 18.
git clone https://github.com/midspiral/LemmaScript.git ../LemmaScript
cd ../LemmaScript/tools && npm install && cd -
./LemmaScript/tools/check.sh dafnysubagent-permissions.ts requires --standard-libraries (uses Std.Collections.Seq.Filter); the per-file entries in LemmaScript-files.txt carry the flag.
packages/opencode/src/permission/evaluate.ts ← In-place, 2 ensures
packages/opencode/src/permission/evaluate.dfy.gen ← Regeneratable
packages/opencode/src/permission/evaluate.dfy ← Verified
packages/opencode/src/permission/arity.ts ← In-place, 3 ensures
packages/opencode/src/permission/arity.dfy.gen ← Regeneratable
packages/opencode/src/permission/arity.dfy ← Verified (with longest-match lemma added)
packages/opencode/src/agent/subagent-permissions.ts ← In-place, 2 ensures + 4 declare-type shims
packages/opencode/src/agent/subagent-permissions.dfy.gen ← Regeneratable
packages/opencode/src/agent/subagent-permissions.dfy ← Verified (with 10-line manual proof addition)
packages/opencode/src/permission/index.ts ← In-place, //@ verify on disabled (2 shims, 3 ensures, 3 invariants)
packages/opencode/src/permission/index.dfy.gen ← Regeneratable
packages/opencode/src/permission/index.dfy ← Verified (with one inline assert hint)
packages/opencode/src/util/wildcard.ts ← In-place, //@ verify on matchSequence + //@ extern on match
packages/opencode/src/util/wildcard.dfy.gen ← Regeneratable
packages/opencode/src/util/wildcard.dfy ← Verified (with hand-added Matches predicate)
packages/opencode/src/patch/index.ts ← In-place, //@ verify on parsePatch + 3 helpers + //@ extern on stripHeredoc
packages/opencode/src/patch/index.dfy.gen ← Regeneratable
packages/opencode/src/patch/index.dfy ← Verified (with one inline `assume false` for the malformed-input throw)
The case study drove these additions to LemmaScript itself:
Toolchain features:
- Auto-extern for cross-file calls. When
lscseesWildcard.match(a, b)and ts-morph resolves the callee to another.tsfile, an opaquefunction {:axiom} Wildcard_match(...)is emitted and call sites are rewritten. No annotation required; theimportstatement is the entire signal. Both namespaced (X.y) and bare-name (foo) imports are auto-resolved;.d.tsdeclarations are skipped. - Cross-file spec lifting.
//@ requires///@ ensureson a cross-file callee's declaration are copied onto the axiom in the calling file's output, so callers reason against the source's verified contract. Transitive through nested cross-file references. Array.prototype.findLastwith full completenessensureson the preamble (returns the rightmost matching element; characterizes the index as maximal).Array.prototype.flatwith theseq<seq<T>> → seq<T>flattening preamble.Array.prototype.join(sep)with a recursiveSeqJoinpreamble.//@ declare-type Name = TsTypealias form, companion to the existing record form.- Dotted user-type lookup with last-segment fallback.
Agent.InfoandPermission.Rulesetresolve to declare-types namedInfo/Rulesetvia the trailing identifier. - Alias expansion for structural targets. Aliases pointing to array/map/set/optional/user types are expanded at use sites; primitive-targeted aliases (
type TaskId = number) stay nominal. - Module-level constants in scope for function bodies. A top-level
const X = ...is added to each function's environment soX[k]resolves to a typed map subscript rather thanunknown. - C-style
for (let i = N; cond; i++/i--/i += k)loops desugared towhileat extract time; counter is treated as mutable; pre/postfix++/--and+=/-=rewritten to assignments. - Map literals from typed record literals.
const X: Record<string, V> = { a: 1, b: 2 }emits as Dafny's flatmap[a := 1, b := 2](the chained-setform trips Dafny's type resolver on large dictionaries — theARITY160-entry case study forced this). - JS-safe
Array.prototype.slice(lo, hi)via aSafeSlicepreamble that clamps both bounds to[0, |s|], matching JS's permissive semantics instead of Dafny's strict0 <= lo <= hi <= |s|. - In-file
//@ externon a function declaration. Marks a same-file function as a body-less axiom (signature + any//@ requires///@ ensuresonly; body skipped). Parallel to the existing auto-extern for cross-file calls, registered in the same externs map, emitted the same way (function {:axiom} foo(...)). Use case:Wildcard.matchis regex-based — outside LS's verification model — butmatchSequence's proof needsmatchto be a deterministic-but-uninterpreted predicate.//@ externonmatchgives exactly that without refactoring it into a separate file.escapeNameis now applied to extern names so Dafny-keyword collisions (match) resolve consistently between declaration and call sites. - Array destructuring with rest at let-statement level.
const [a, , c, ...rest] = arrdesugars to individual bindings:var a := arr[0]; var c := arr[2]; var rest := arr[3..]. Omitted slots (,,) skip; rest emits as aslice. Single-eval temp introduced if the initializer isn't a bare variable. Nested binding patterns throw a clear error — extend when a case study hits them. - Brownfield
//@ verifyextraction on Effect-heavy files.permission/index.tscarries aLayer.effect(Effect.gen ...)service, schema decls, and runtime constants thatlsccan't model. Marking a single function with//@ verifymakes lsc extract only that function, skipping everything else with warnings. Two narrow fixes made this work fordisabled: (a) rewritingif (X) continue; resttoif (!X) { rest }in for-of bodies — and the post-narrowmatch/Some/None form wherecontinuelands in the None arm — so Dafny's while-with-bottom-increment doesn't infinite-loop; (b) scoping auto-extern registration to function-body extraction, so module-level constants likeEvent = { Asked: BusEvent.define(...) }don't pollute the output with un-Dafny-parseable externs. Both fixes are conservative — they cover the patternsdisabledproduces, not the general continue/extern problem.
Bug fixes uncovered along the way:
- Record literal emission was field-position-order, not struct-order.
{ action: "ask", permission, pattern: "*" }was emitting asRule("ask", permission, "*")against aRule(permission, pattern, action)declaration — silently misassigning all three fields. Exposed byevaluate.ts'sNone-branch default. \resultnarrowing through==>premises wasn't firing because\resultiskind: "result"in raw IR, notkind: "var".- Quantifier left-operand parens in binop emission.
forall i :: A || Bparses asforall i :: (A || B)in Dafny — the body extends as far as possible. The emitter now wraps quantifier-typed left operands in binops. inferLambdaParamTypesnot applied inoptChaincall steps. A lambda buried insideobj?.filter(r => ...)gotint-typed params instead of the array's element type.
Pending:
- Schema-aware extraction. Would remove the need for
//@ declare-typeshims when upstream types are Schema-derived. ~200–300 LOC; not strictly necessary given the shim workaround.