Skip to content

fix(account): stop a hook being removed out from under its validator - #313

Draft
emirongrr wants to merge 4 commits into
mainfrom
fix/validator-hook-dependency
Draft

fix(account): stop a hook being removed out from under its validator#313
emirongrr wants to merge 4 commits into
mainfrom
fix/validator-hook-dependency

Conversation

@emirongrr

Copy link
Copy Markdown
Owner

Motivation

Every built-in primary validator stores a policy-hook address per account and
fails closed when that hook is not installed. Nothing connected those two facts:
_uninstallModule had no dependency check, and evictHookWithGuardians removed a
hook immediately.

That left a terminal state. With the account's only hook evicted, its validator
could no longer pass validateUserOp or validateDirectExecution;
setPolicyHook needs a scheduled self-call that only a passing validator can
reach; and guardian recovery installs validators but not hooks, so it could not
repair the account either. The account was unusable and unrecoverable.

It went unnoticed because ADR-0005's acceptance evidence for hook eviction uses
MockValidator, which declares no policy hook, so the one configuration that
makes eviction dangerous was never exercised.

Validators can now declare the dependency through ILoomPolicyBoundValidator,
and the account enforces it:

  • _uninstallModule refuses to remove a hook an installed validator depends on,
    which covers the scheduled-uninstall path as well as guardian eviction.
  • evictHookWithGuardians takes a replacement. When a validator depends on the
    hook a replacement is required; the account installs it, rebinds every dependent
    validator onto it, and only then removes the old hook, in that order. Eviction
    without a replacement stays available when nothing depends on the hook, so the
    escape hatch is not made harder than it needs to be.
  • Initialization asserts coherence once the module set is in place, so an account
    cannot be created with a validator pointing at a hook that is not installed.

Rebinding is gated on the account reporting isEvictingHook(). Without that gate
it would be an instant, untimelocked way to re-point a validator at a permissive
hook, which setPolicyHook's configuration delay exists to prevent.

Two constraints shaped the design and are worth recording, because both rule out
the obvious alternatives:

A validator cannot check its own dependency at initialization. Module init runs
inside the account's install loop, and during a constructor or proxy deployment
the account has no code yet, so a callback into it returns nothing and reverts on
decode. The check therefore lives in the account, which can call out to a
deployed validator, and runs at the end of initialization rather than per install
-- modules arrive as a set, so a validator may legitimately name a hook that
appears later in the list.

The dependency is probed with try/catch. A validator that does not implement the
interface, or that reverts, reads as depending on nothing. That is deliberate: the
alternative hands any module a veto over hook removal, including over the guardian
escape hatch, which is the denial ADR-0005 exists to solve. The cost is that the
guard only protects modules that declare honestly, which is recorded in the
bug-class matrix.

Rejected: letting a validator fall back to "no policy hook" when its hook is
missing, which converts a liveness failure into a policy bypass; and blocking
eviction outright, which would let a malicious hook make itself unremovable.

ValidatorBranchCoverage and P256Validator each contained a test that removed a
hook to reach the validator's fail-closed branch. That state is now unreachable
through a real account, so those tests reach it through a stub account that
reports the hook absent. The branch stays covered -- it still matters for a
validator used with a non-Loom account or a future account version -- without
requiring the account to permit an incoherent state.

Scope

Included

  • The change, its tests, and the documentation that describes the changed behaviour.

Not included

  • Unrelated refactors. Each audit finding is on its own branch.

Security impact

Authority and availability. A hook could be removed out from under the validator that depends on it.

Verification

  • forge test, npm run test:mutation:critical.

Residual risks

Three pre-existing tests reach the fail-closed branch through a stub account rather than by uninstalling a hook; the stub is new, the assertions are unchanged.

Checklist

  • This pull request has one reviewable purpose
  • Tests cover the changed behavior and important failure paths
  • npm run verify passes (full program not run locally; forge test and the finding-specific gates are green)
  • Gas and bytecode changes are measured or not applicable
  • Security assumptions and residual risks are explicit
  • Branch history is clean

Every built-in primary validator stores a policy-hook address per account and
fails closed when that hook is not installed. Nothing connected those two facts:
`_uninstallModule` had no dependency check, and `evictHookWithGuardians` removed a
hook immediately.

That left a terminal state. With the account's only hook evicted, its validator
could no longer pass `validateUserOp` or `validateDirectExecution`;
`setPolicyHook` needs a scheduled self-call that only a passing validator can
reach; and guardian recovery installs validators but not hooks, so it could not
repair the account either. The account was unusable and unrecoverable.

It went unnoticed because ADR-0005's acceptance evidence for hook eviction uses
`MockValidator`, which declares no policy hook, so the one configuration that
makes eviction dangerous was never exercised.

Validators can now declare the dependency through `ILoomPolicyBoundValidator`,
and the account enforces it:

- `_uninstallModule` refuses to remove a hook an installed validator depends on,
  which covers the scheduled-uninstall path as well as guardian eviction.
- `evictHookWithGuardians` takes a `replacement`. When a validator depends on the
  hook a replacement is required; the account installs it, rebinds every dependent
  validator onto it, and only then removes the old hook, in that order. Eviction
  without a replacement stays available when nothing depends on the hook, so the
  escape hatch is not made harder than it needs to be.
- Initialization asserts coherence once the module set is in place, so an account
  cannot be created with a validator pointing at a hook that is not installed.

Rebinding is gated on the account reporting `isEvictingHook()`. Without that gate
it would be an instant, untimelocked way to re-point a validator at a permissive
hook, which `setPolicyHook`'s configuration delay exists to prevent.

Two constraints shaped the design and are worth recording, because both rule out
the obvious alternatives:

A validator cannot check its own dependency at initialization. Module init runs
inside the account's install loop, and during a constructor or proxy deployment
the account has no code yet, so a callback into it returns nothing and reverts on
decode. The check therefore lives in the account, which can call out to a
deployed validator, and runs at the end of initialization rather than per install
-- modules arrive as a set, so a validator may legitimately name a hook that
appears later in the list.

The dependency is probed with `try/catch`. A validator that does not implement the
interface, or that reverts, reads as depending on nothing. That is deliberate: the
alternative hands any module a veto over hook removal, including over the guardian
escape hatch, which is the denial ADR-0005 exists to solve. The cost is that the
guard only protects modules that declare honestly, which is recorded in the
bug-class matrix.

Rejected: letting a validator fall back to "no policy hook" when its hook is
missing, which converts a liveness failure into a policy bypass; and blocking
eviction outright, which would let a malicious hook make itself unremovable.

`ValidatorBranchCoverage` and `P256Validator` each contained a test that removed a
hook to reach the validator's fail-closed branch. That state is now unreachable
through a real account, so those tests reach it through a stub account that
reports the hook absent. The branch stays covered -- it still matters for a
validator used with a non-Loom account or a future account version -- without
requiring the account to permit an incoherent state.
…ath widened

`evictHookWithGuardians` gained a `replacement` argument, and
`docs/design/execution.md` still said the function "can only uninstall a hook -
it cannot install one". That sentence was false as soon as the replacement path
landed, in the design document a reader is most likely to check.

The section now describes what the call actually does and names the residual
directly: this is the one place the guardian threshold installs a module, and
because `isLowRisk` on the policy hook is the only gate `validateDirectExecution`
consults, the replacement the guardians name decides what direct execution is
permitted. That is more than the "guardians can cancel, never act" line the rest
of the design holds to.

It is bounded by three things and they belong together: the same threshold can
already replace every validator through `recoverConfiguration`, so the delta is
timing rather than category — recovery is visible for three days, this is
immediate; the call installs exactly the hook named in the signed digest and
touches nothing else; and rebinding is reachable only during an eviction, since
outside one it would be an untimelocked way to point a validator at a permissive
hook.

Decision 0005 carries the same statement, which its amendment had left implicit.
@emirongrr

Copy link
Copy Markdown
Owner Author

Follow-up commit 051bea4e: docs/design/execution.md still asserted that this function "can only uninstall a hook - it cannot install one", which the replacement path makes false. Corrected, and the residual is now stated in both the design document and decision 0005 rather than left implicit — the guardian threshold chooses the contract that gates direct execution, which is bounded by recovery already being able to replace every validator, but differs from it in being immediate rather than visible for three days.

…pendency

# Conflicts:
#	.gas-snapshot
#	docs/security/wallet-bug-regression.md
#	src/LoomAccount.sol
#	tools/quality/run-critical-guard-mutations.mjs
Merging with main surfaced a real defect in this branch's guard. `P256Validator`
gained a `P256RecoveryValidator` subclass, which inherits `policyHookFor`, so the
coherence assertion added here started firing inside `recoverConfiguration` and
reverted the whole recovery whenever the guardians' replacement validator named a
hook that was not installed. Three tests that arrived with that feature failed.

Blocking recovery is the wrong direction to fail. Recovery is the last-resort
path: it runs on guardian-threshold authority through an installed recovery
module and needs no working validator, so a revert leaves the validator the
guardians are trying to replace in place. Installing one that fails closed is
recoverable — another recovery repairs it — while a blocked recovery is not.

Both recovery entry points now set a `_recovering` flag that skips the
assertion, in the same shape as the existing `_initializing` flag. The exemption
is bounded and pinned: the new test asserts the same validator and init data are
still refused on the ordinary construction path, so the two cannot drift apart.
Decision 0005 records the reasoning.

Known blocker, not fixed here: with this branch `LoomAccount` runtime reaches
25,146 bytes against the 24,576 EIP-170 limit — 570 over, where main sits 910
under. `forge build --sizes` exits non-zero and both `verify.mjs` and CI run it,
so this cannot merge as it stands. `evictHookWithGuardians` measures 1,131 bytes
on its own, so the size has to come out of that function or move behind a linked
library. Deliberately left failing rather than papered over.
@emirongrr

Copy link
Copy Markdown
Owner Author

Held: this cannot merge as it stands. With this branch LoomAccount runtime reaches 25,146 bytes against the 24,576 EIP-170 limit — 570 over, where main currently sits 910 under. forge build --sizes exits non-zero and both tools/ci/verify.mjs and the ci workflow run it, so CI fails correctly. Measured, not estimated: removing evictHookWithGuardians alone takes the runtime to 24,015 (+561 margin), so that one function is 1,131 bytes.

Moving the logic to a library only helps if it is a deployed, link-time-bound library with public functions. All three existing libraries here (ExecutionLib, ValidatorSetLib, GuardianVerificationLib) are internal-only and therefore inlined, so they save no bytecode; there is no library linking in foundry.toml and no library deployment in the scripts. Introducing one would add a deployment artifact, a manifest entry, and a component the account delegatecalls into — a new pattern for this repository and a new line in the trusted computing base.

Separately, one real defect was found and fixed while merging: the coherence check added here reverted recoverConfiguration once P256RecoveryValidator (from #299) inherited policyHookFor. Recovery is now exempt, bounded by a test asserting the ordinary path still refuses the same validator, with the reasoning in decision 0005. 391 tests pass, 13/13 guard mutants killed.

The remaining audit PRs are being merged ahead of this one.

emirongrr added a commit that referenced this pull request Aug 4, 2026
Dependabot's #331 bumped only the `solc` npm dependency, which
`npm run toolchain:check` refused: `foundry.toml`, both Certora `solc-select`
steps, and the Kontrol `SOLC_BINARY` were still on 0.8.35, and a second compiler
reachable from the repository produces bytecode that no gate and no deployment
manifest ever measured. That gate exists to stop a partial bump, so the answer
is to move everything or nothing.

Everything moves here: 145 source `pragma` statements, `foundry.toml`, the npm
dependency and lockfile, four `solc-select` invocations, the Kontrol binary name
and its checksum, the assertions in `validate-ci-program.mjs` and
`validate-kontrol-program.mjs` that pin those strings, and the four documents
that state the version.

The two Kontrol pins are derived, not copied. The compiler that produced these
artifacts reports `0.8.36+commit.8a079791`, read back out of the build metadata,
and the binary's SHA-256 is
`c8d35afdddc3cd2743ee88b8f25e0fecd16e2bdd5f2120f37e52cd9cc45ae0e6`, computed by
downloading it. The same download reproduces the existing 0.8.35 pin exactly,
which is what makes the method trustworthy here: `list.json` publishes a
different SHA-256 for 0.8.35 than the file it serves, so it cannot be used as
the source for this field.

Measured rather than assumed:

- `LoomAccount` runtime stays at 23,733 bytes, leaving the EIP-170 margin
  unchanged at 843. The upgrade is size-neutral for the account, which matters
  because that margin is the binding constraint on #313.
- Gas barely moves: 22 of 302 snapshot entries change, 15 up and 7 down, median
  +28, extremes -56 and +56. Codegen jitter, not a systematic shift.
- 393 tests pass, 15 skipped; 16/16 critical guard mutants killed; coverage,
  lint, formatting, ABI freshness, documented constants, and the CI, Certora,
  Kontrol, and formal program-structure gates all pass.

`validate-toolchain-pins.test.mjs` needed one real edit beyond the version
strings: its disagreement case worked by passing the *other* version, so moving
the fixture default alone would have left it asserting that two identical
versions disagree. The intruder is now 0.8.35.

Not included: any behavioural change. No file under `src/` changes except its
pragma line.
emirongrr added a commit that referenced this pull request Aug 4, 2026
Dependabot's #331 bumped only the `solc` npm dependency, which
`npm run toolchain:check` refused: `foundry.toml`, both Certora `solc-select`
steps, and the Kontrol `SOLC_BINARY` were still on 0.8.35, and a second compiler
reachable from the repository produces bytecode that no gate and no deployment
manifest ever measured. That gate exists to stop a partial bump, so the answer
is to move everything or nothing.

This is not a hand-written diff. It is the output of

    npm run toolchain:bump 0.8.36
    npm install --package-lock-only

which is the tool added in #337, used here for the first time. It rewrote 157
files - 145 pragmas, `foundry.toml`, the npm dependency, four `solc-select`
invocations, the Kontrol binary name and its checksum, the validators that
assert those strings, the prover setup script, and four documents - and
reported nothing left over for a human to decide.

The two Kontrol pins are derived rather than copied. The build identifier
`0.8.36+commit.8a079791` comes from the published binary list, and the SHA-256
`c8d35afdddc3cd2743ee88b8f25e0fecd16e2bdd5f2120f37e52cd9cc45ae0e6` is computed
from the binary the tool downloads. That distinction matters: `list.json`
publishes a different SHA-256 for 0.8.35 than the file the same host serves,
while downloading and hashing reproduces the checksum already pinned here.

Measured rather than assumed:

- `LoomAccount` runtime stays at 23,733 bytes, leaving the EIP-170 margin
  unchanged at 843. The upgrade is size-neutral for the account, which matters
  because that margin is the binding constraint on #313.
- Gas barely moves: 22 of 302 snapshot entries change, 15 up and 7 down, median
  +28, extremes -56 and +56. No entry appears or disappears. Codegen jitter,
  not a systematic shift.
- 393 tests pass, 15 skipped; 16/16 critical guard mutants killed; the full
  `verify:quick` program is green, including coverage, lint, formatting, ABI
  freshness, documented constants, and the CI, Certora, Kontrol, and formal
  program-structure gates.

Not included: any behavioural change. No file under `src/` changes except its
pragma line.
emirongrr added a commit that referenced this pull request Aug 4, 2026
## Motivation

Dependabot's #331 bumped only the `solc` npm dependency, which `npm run
toolchain:check` refused: `foundry.toml`, both Certora `solc-select`
steps, and the Kontrol `SOLC_BINARY` were still on 0.8.35. A second
compiler reachable from the repository produces bytecode that no gate
and no deployment manifest ever measured. That gate exists to stop a
partial bump, so the answer is to move everything or nothing.

## Description

This is not a hand-written diff. It is the output of

```
npm run toolchain:bump 0.8.36
npm install --package-lock-only
```

which is the tool added in #337, used here for the first time. It
rewrote 157 files — 145 pragmas, `foundry.toml`, the npm dependency,
four `solc-select` invocations, the Kontrol binary name and its
checksum, the validators that assert those strings, the prover setup
script, and four documents — and reported nothing left over for a human
to decide.

The two Kontrol pins are derived rather than copied. The build
identifier `0.8.36+commit.8a079791` comes from the published binary
list, and the SHA-256
`c8d35afdddc3cd2743ee88b8f25e0fecd16e2bdd5f2120f37e52cd9cc45ae0e6` is
computed from the binary the tool downloads. That distinction matters:
`list.json` publishes a **different** SHA-256 for 0.8.35 than the file
the same host serves, while downloading and hashing reproduces the
checksum already pinned here.

## Scope

**Included**

- Every compiler pin in the repository, plus the regenerated lockfile
and gas snapshot

**Not included**

- Any behavioural change. No file under `src/` changes except its pragma
line.

## Security impact

The produced bytecode changes, so deployment evidence has to be
regenerated before any deployment claim is made against it. The
supply-chain property the bump protects is that exactly one compiler is
reachable from the repository; `toolchain:check` enforces it and passes
here.

## Verification

- `LoomAccount` runtime stays at **23,733 bytes**, EIP-170 margin
unchanged at **843**. Size-neutral for the account, which matters
because that margin is the binding constraint on #313.
- Gas barely moves: **22 of 302** snapshot entries change, 15 up and 7
down, median +28, extremes -56 and +56. No entry appears or disappears —
codegen jitter, not a systematic shift.
- `npm run verify:quick` — full program green: 393 contract tests pass,
15 skipped.
- `npm run test:mutation:critical` — 16/16 killed.

## Residual risks

Deployment manifests and published bytecode evidence predate this
compiler and must be rebuilt before they are cited again.

## Review notes

The diff is mechanical and large; the parts worth reading are
`foundry.toml`, `package.json`, the two workflow files, and
`tools/formal/setup-linux-provers.sh`. If those four agree,
`toolchain:check` guarantees the other 150 do.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant